← Harrison Wolf

Algebra search engine

The search it ran, what it found, and how it's built.

Language
C++
Core
~1,200 lines, by hand
Built
2024
Speed
~70–115× vs Macaulay2
repoarXiv:2512.24320

02Benchmarks

Task-matched runs against an equivalent Macaulay2 implementation: same machine, same candidate families, same checks, program CPU time. On matched searches across codimensions 3–7 the engine runs ~70–115× faster — 1.2 million sequences in about a second versus a hundred, 20.7 million in 11 seconds versus ~17 minutes. Past that it hits the materialize-the-whole-list wall, and on lighter hardware it runs out of memory. The 2024 campaign swept tens of billions of sequences in a handful of overnight runs. Full data.

Runtime vs candidate count, codim 3-7, engine vs Macaulay2
Runtime vs. candidate count, codim 3–7, same machine, linear axes.
Peak memory vs candidate count
Peak memory. Macaulay2 materializes the whole candidate list (1.4 GB by 4.5M) and eventually runs out; the engine's compact representation stays ~3× lower.
Speedup vs number of sequences by codimension
Speedup by search size: a ~70–115× band through the sub-million range, climbing past 150× by a few million candidates.

03The scale of the search

One dot is one candidate: a subset, its degree sequence, one Betti table to test. Drawn on a shared scale, the size of the search becomes visible — and so does how little of it Macaulay2 could reach.

One dot per candidate on a shared scale from degree 3 to 1,500, with magnifier boxes for the smallest squares
One shared scale, degree 3 to 1,500. The smallest squares are a pixel across, so the boxes magnify them. Macaulay2's matched-run memory wall is the degree-500 square; the 2024 campaign is a solid square 4.4× the width of this image.
The shared-scale chart redrawn with the 2024 campaign square, 59.6 billion candidates, dwarfing the Macaulay2 memory-wall square
The campaign square drawn to fit. Macaulay2's entire matched-run reach is 1/54 its side — 11 seconds for the engine; ~17 minutes for Macaulay2, and past that it hits the materialize-the-whole-list wall.
3D surface of candidate count over codimension and max degree, out to degree 150 The same surface out to degree 7,100 with the campaign mark at 59.6 billion
Count over codimension and degree. Left, out to degree 150: codim 7 is already at 294 billion. Right, out to 7,100: only the codim-3 lane stays in view, ending at the campaign mark.
Linear-axis chart of candidate count for codim 3-7 out to degree 12,000 The codimension-3 lane alone, with Macaulay2's reach a narrow strip at the left edge
On linear axes. Codim 4–7 leave the chart almost immediately; everything Macaulay2 reached lives in the sliver the inset magnifies. The red mark is the campaign: 59.6 billion at degree 7,100, with the curve continuing past the chart.

04How it's built

The core represents the mathematical structures and runs the calculations, with a recursive enumeration algorithm generating the structures inside it. A performance layer runs the computations and checks them against the conjectures, working within the hardware limits and the difficulty of large-integer arithmetic on a 64-bit processor. The recursive generator works in resumable slices, able to pick up from any point in the space — which is what let the search reach as far as it did.

A snippet from the middle of one routine — notes-to-self included:

	//at this point all the fractions are reduced as much as possible, so L will be the lcm of all of them
	//question is, if we have (a*b) and (c*d), is lcm((a*b),(c*d)) the same as lcm(lcm(a,c),lcm(b,d))?
	//if so, this is good, since we can check L before multiplying out any one denominator, as any given denominator can be very big once fully multiplied out, but if we can go
	//factor by factor for each one, we will have a much, much easier time
	//lcm(2*3,6*1) = 6, lcm(lcm(2,6),lcm(3,1)) = lcm(6,3) = 6. Seems promising
	//IF we can "distribute" lcms like this, then we just update L not with each denominator, but with each iteration of a term in every denominator
	//eg if we have denoms (abc), (def), and (xyz), we don't have start with L=1 and do L = lcm(L,abc) then L = lcm(L,def) then check L (since abc and def could each be huge and at risk of
	//overflowing), but rather we start with L=1 and do L = lcm(L,a,d,x), then check L, then L = lcm(L,b,e,y), ... and stop if at any point L is big enough to pass the tests
	//if L is NOT big enough and we reach the end, does the pass necessarily fail? Could it still work? DO we have to worry abt overflow? Will solve these tomorrow 
	//NOTE: STL lcm function DOES take more than 2 params	
	//
	//start L calc
	int L = 1; //"global" L, lcm of all interim L's
	int target = binom(c,c/2);
	int warning_val = INT_MAX; //lol sqrt(LLONG_MAX) is just INT_MAX... duh, sqrt(2^64) = sqrt(2^32*2) = sqrt((2^32)^2) = 2^32
	if(target > warning_val) cout << "In function test_conjs_v2, target val for L greater than sqrt(LLONG_MAX), overflow likely.\n";
	//each denom will have c-1 factors, each of which may or may not be 1
	//need to go term by term in each denominator. remember each denominator is .second in a pair, and a vector of pairs makes up a whole pi_i
	//CANNOT DO THIS. L MUST BE LCM OF ALL DENOMS, BUT WITHIN A DENOM L MUST BE AT LEAST THE ENTIRE PRODUCT OF THAT DENOM
	//What this means is in any one denom, we can check if L would ever be made too big, but if not, L has to become the entire product
	vector<int> L_vec(c+1);
	L_vec.at(0) = 1; //since pi_0 is just 1/1
	int curr_L = 1;
	//need to calc an L for each denom, checking at every step, then do lcm of all the L's, again checking at every step
	for(int i=1; i<=c; i++){ //for each pi (starting at 1 since pi_0 is just 1/1)
		curr_L = 1; //reset curr L
		for(int j=0; j<c-1; j++){ //for each factor of the denom
			curr_L = curr_L *= pis.at(i).at(j).second;
			//each lcm can only make L bigger, so I can actually check intermediately
			if(curr_L >= target){
				cerr << "An interim L hit " << curr_L << " which is > binom(c,c/2); both conjs autopass.\n";
				return true;
			}
			//curr_L is not too big, push to L vec
			L_vec.at(i) = curr_L;
		}
//		cerr << "After pi_" << i << ", curr L is now " << L << endl;
	}	
The L computation, verbatim from src/test_funcs.cc. The comments work out — then reject — a way to distribute the lcm across denominators; the implementation instead accumulates each denominator factor by factor and stops once the target is reached.

05Ownership

The core is a ~1,200-line C++ engine I wrote by hand in 2024 and cross-validated case-by-case against Macaulay2's own BoijSoederberg package. Its exhaustive search surfaced the counterexamples used in one of the paper's three main theorems.

← Back to the portfolio