Skip to content

A polynomial layer: factorisation over Q and F_p, resultants, square-free decomposition (#746 item 43) - #918

Merged
Rafael-SOWNet merged 12 commits into
masterfrom
feat/polynomial-factorization
Aug 13, 2026
Merged

A polynomial layer: factorisation over Q and F_p, resultants, square-free decomposition (#746 item 43)#918
Rafael-SOWNet merged 12 commits into
masterfrom
feat/polynomial-factorization

Conversation

@Rafael-SOWNet

@Rafael-SOWNet Rafael-SOWNet commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Closes the four pieces named by item 43 of #746"the single highest-leverage piece of work on this list": multivariate GCD, resultants, factorisation over ℚ and 𝔽ₚ, and square-free decomposition.

What was actually missing

Of the four, only the multivariate GCD existed. It was also untestable: everything in Functions/Algebra/Polynomials is internal, InternalsVisibleTo was commented out, so the only coverage was seventeen cases reading answers off Simplify(). That is why GroebnerSystemTest goes through the public Solve — not a style choice.

Piece Before Now
multivariate GCD subresultant PRS, no direct tests 149 tests; one latent defect fixed
resultants absent Resultant + Discriminant, Sylvester determinant by Bareiss elimination; 79 tests
factorisation over 𝔽ₚ absent Berlekamp; 40 tests
factorisation over ℚ rational roots only Zassenhaus: square-free → Berlekamp → Hensel lift → recombination; 172 tests
square-free decomposition absent Yun

Berlekamp rather than Cantor–Zassenhaus, deliberately. CZ is randomised; design principle 3 of #746 requires the same input to give the same answer on every platform and thread count. Berlekamp is deterministic by construction, and the dimension of its subalgebra is an exact factor count rather than an estimate, which gives a precise termination test.

The consumer

Infrastructure with nothing consuming it is speculative code, so the equation solver uses it. Where no rational root can be divided out, a polynomial may still factor, and each factor is then a lower-degree equation the existing product case answers exactly.

x^5 + 2x^3 - 2x^2 - 4 is (x^2 + 2)(x^3 - 2), and has no rational root:

was  { sqrt(-2), -sqrt(-2), sqrt(1.5874010519681995834417875812505371868610382080078125) }   4.6 s
is   { sqrt(-2), -sqrt(-2), 2^(1/3), (-1/2 ± i·1/2·sqrt(3))·2^(1/3) }                        0.09 s

Three of five roots, and the one it found came back as the square root of a float where an exact value exists. An incomplete solution set is not a partial answer, it is a false one.

What this does not do

  • Factorisation and square-free decomposition are univariate. GCD and resultants are multivariate. Multivariate factorisation is not here.
  • Nothing is wired into Simplify. Measured first: SimplifiedRate prefers the expanded form in every case tried (x^6 - 1 rates 12 expanded against 58 factored), so a factored candidate offered there could never win. That is a cost-model question, and Goal: Math OS — a ten-year vision for AngouriMath as an open mathematical reasoning platform #746 puts a pluggable cost model at v2.0.
  • Resultants have no caller yet. They are additive and unreferenced; no existing input changes its answer because of them.
  • The layer stays internal. Goal: Math OS — a ten-year vision for AngouriMath as an open mathematical reasoning platform #746 item 78 says published API boundaries must be settled deliberately, so this proposes none.
  • Factoring is only attempted from degree four. Not caution — a quadratic or cubic that factors at all has a rational root, so the step before this one has already divided it out. Four is the first degree at which a polynomial can factor with nothing rational to catch.
  • A two-termed a*x^n + b is left whole, for the reason TrySplitOffRationalRoots already gives for declining one.

One change that needs a decision, not just a review

InternalsVisibleTo("UnitTests") is now enabled, and because the package is strong-named it carries the public key and UnitTests.csproj signs with the same key.snk. Without it none of this machinery can be tested other than through Solve, and the exhaustive checks below are what make it trustworthy. If you would rather not widen the friend surface, say so and I will split it into its own PR so the discussion is not tangled with the mathematics — but the tests do depend on it.

Evidence

Full suite 6944 passed / 0 failed / 14 skipped, against 6504 on master — exactly +172 +40 +149 +79. F# wrapper 130. Measurement harnesses (they live outside this repo): root-completeness 596/596 clean, solver corpus 117/119 with 0 wrong / 0 error / 0 timeout, property checker 1340 checks 0 failures, simplification sweep 10463/10463 agreeing, boundary checker byte-identical to its baseline, crash harness 1652 cases 0 crashes.

The tests bind, checked by mutation rather than asserted:

mutation tests that fail
transpose the Berlekamp matrix 3
drop the row-interchange sign in the Sylvester determinant 3
swap the content exponents in the resultant fails
drop (-1)^(n(n-1)/2) in the discriminant 8
reverse the Sylvester block order 5

Two of those mutations initially failed nothing, which found two real gaps in the resultant tests; the cases that close them were added.

The factoriser's hardest case is the Swinnerton-Dyer octic with roots ±√2 ±√3 ±√5: irreducible over ℚ but split into quadratics modulo every prime, so recombination must try every subset, find none divides, and conclude irreducibility from the search being exhausted. The test asserts that the splitting actually happens before asserting irreducibility, so it cannot pass by short-circuiting. x^4 + 1 and x^4 - 10x^2 + 1 are there for the same reason.

Every candidate factor is confirmed by exact division over ℤ, and the factors are multiplied back and compared before any answer is returned, so the surviving failure mode is an incomplete factorisation and never a wrong one. Resultant conventions were checked against SymPy 1.14 rather than recalled — one of them was wrong by a factor of x^2 before it was checked.

Defect found on the way

MultivariatePolynomial.TryParse accepted a ninth variable and silently aliased it onto the first: ShiftOf(8) is -8, and C# masks a shift count to six bits, so x_1 - x_9 read as the zero polynomial. Unreachable today — both callers check the count first — so no entry in BREAKING-CHANGES.md, but it is fixed at the door rather than at each caller, since the packing belongs to that type.

Behaviour changes

BREAKING-CHANGES.md records five, with before and after measured on builds of each version from a clean worktree at origin/master, not read off the diff. One is the missing roots above; the rest are the same solution sets written more plainly or in a different order, and the members were checked to be equal rather than merely to look it.

Reviewing it

The commits are separable and in dependency order, so it reads commit-by-commit: header scoping, the friend-assembly change, Berlekamp, the ℚ factoriser and its consumer, the GCD tests and their defect, resultants, then the degree guard.

Two limits are documented rather than fixed, and are filed separately: the GCD's 512-term ceiling is reached by intermediates of the remainder sequence rather than by the input (#920 — 7 refusals in 3000 random triples, refusals and never wrong answers), and MaxSylvesterSize = 24 is a judgement rather than a measurement (#921). The first consumer this unblocks but does not include is partial-fraction integration over irreducible factors (#919).

Rafael-SOWNet and others added 12 commits August 13, 2026 16:48
Sections for files that do not exist yet are inert, and putting them in
one commit keeps three concurrent branches out of the same file.
The polynomial layer is internal and is reached from outside only through
Simplify, Solve and Integrate, so a test driven from the public surface
cannot tell a defect in a greatest common divisor from a defect in the
caller that invoked it. Berlekamp, the Hensel lift and the subresultant
sequence each have answers that can be checked exhaustively or computed
independently, and that check has to be able to name them.

The assembly is strong-named, so the friend reference carries the public
key and the test project signs with the same key.snk. Dropping the strong
name instead would change a published property of the package.
The first of the four pieces of the polynomial layer (#746, item 43).
PrimeFieldPolynomial is dense univariate arithmetic over F_p -- Euclid,
long division, square-and-multiply, the formal derivative and the
square-free test -- and PrimeFieldFactorization splits a monic
square-free one into monic irreducibles.

Berlekamp rather than Cantor-Zassenhaus. For a large modulus
Cantor-Zassenhaus is the faster algorithm, since it replaces the sweep
over the whole field with a random probe, but it is randomised, and
design principle 3 of #746 requires the same input to give the same
answer on every platform and in every thread count. Berlekamp is
deterministic by construction, and the dimension of its kernel is the
number of irreducible factors exactly, so the splitting loop has a
termination test rather than a stopping heuristic. The output is sorted
into a canonical order so that it does not record which basis vector
happened to separate what.

The modulus is bounded so that a product of two reduced coefficients
fits a long, and factorisation is bounded again, far lower, because its
sweep is linear in the modulus; both refuse rather than hang. Factor
answers null for everything it is not contracted for -- a composite or
oversized modulus, an oversized degree, a non-monic or non-square-free
input -- since on a repeated factor Berlekamp would return a product
short of the input, which is a wrong answer and not a partial one.

The test that carries the weight is the exhaustive one: every monic
polynomial of degree 2 to 4 over F_2, F_3 and F_5, with the factors
multiplied back and each checked for irreducibility by a brute-force
trial division sharing no code with Berlekamp. That oracle is itself
checked against Gauss's count of the monic irreducibles, so an oracle
that called everything irreducible could not pass. 40 tests; the full
unit suite is 6544 passed, 14 skipped, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PolynomialFactoring finds rational roots and divides out the linear
factors it finds, which answers the question only when the polynomial
splits into linear pieces. A factor of degree two or more with no
rational root is invisible to it, and above degree three that is the
common case: x^4 + 3x^2 + 2 is (x^2 + 1)(x^2 + 2) and neither half has
a root to find.

The route is Zassenhaus's. Clear denominators and take the primitive
part; split off the repeated factors with Yun's square-free
decomposition; factor what is left modulo a small prime, where Berlekamp
answers completely; lift that from p to p^k by Hensel's construction with
k chosen from Mignotte's bound; then try products of the lifted pieces
against the original. The recombination is what recovers the truth, since
an irreducible factor over Z may split further modulo p -- x^4 + 1 and
the Swinnerton-Dyer polynomials factor modulo every prime and are
irreducible over Q, and both are tested.

Nothing is trusted: every candidate is divided out exactly over Z, and
the factors are multiplied back and compared before the answer is
returned. What survives that is an incomplete factorisation, never a
wrong one.

The solver is the consumer. Where no rational root can be divided out, a
polynomial may still factor, and each factor is then a lower-degree
equation the existing product case answers exactly. x^5 + 2x^3 - 2x^2 - 4
is (x^2 + 2)(x^3 - 2): it returned three of its five roots, one of them
as a float, in 4.6 seconds, and now returns all five exactly in 91ms. A
two-termed polynomial is left alone there, for the reason
TrySplitOffRationalRoots already gives for declining one.

Yun and the Hensel lift work on a dense univariate polynomial over Z
rather than on MultivariatePolynomial: every bound the algorithm turns on
is a statement about integers, and the inner loop of the lift wants
coefficient access by degree rather than a dictionary lookup.
The subresultant sequence and the cancellation it drives had no test that
named them. What covered them was six quotients in SimplificationRegressionTest
and four patterns in SimplifyTest, all in two variables, all reading the answer
off Simplify -- so a divisor that was merely common rather than greatest, or a
condition that went missing, was only visible where somebody had written the
quotient down.

MultivariateGcdTest checks the defining property instead: the divisor divides
both arguments, the quotients multiply back, and their own gcd is constant --
which together say it is the greatest one, so no expected value has to be
trusted on its own. Over divisors computed by hand in one to four variables,
over a written-out table of pairwise coprime factors, and over three thousand
triples drawn from that table with a fixed seed. Knuth's degree-8 pair is there
for the coefficient growth the sequence exists to bound, and the cancellation's
condition is asserted node by node, since it is what keeps the reduced form
from claiming a value where the original was 0/0.

Two things the measurement found.

TryParse accepted more than eight variables. A ninth has no byte in the packed
exponent vector and ShiftOf gives it a negative shift, which the language turns
into a shift by 56 -- the first variable's byte -- so x_1 - x_9 read as the zero
polynomial. Both callers check the count before calling, so nothing reached it;
the check belongs with the packing rather than with each caller, and is now
there.

The 512-term ceiling is reached by an intermediate of the remainder sequence,
not by the input. Two polynomials of 19 and 29 terms in four variables that
share a + b + c + d are declined, because a multivariate pseudo-remainder
multiplies through by a leading coefficient that is itself a polynomial and the
product of a 25-term and a 159-term intermediate goes past the ceiling three
steps in. The subresultant divisions bound the coefficients, not the monomial
count. Seven of the three thousand drawn triples land there. Pinned as the
refusal it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured on a build of each version rather than read off the diff, with
the baseline taken from a worktree at origin/master.
Eliminating a variable between two polynomial equations is what the solver
needs from the polynomial layer next, and the discriminant of a polynomial in
one variable over the others follows from the same computation. Both sit on
the sparse multivariate polynomial over Q that the greatest common divisor
already uses, so a resultant is a polynomial in the remaining variables and
null wherever a step of the machinery declines.

The resultant is defined as the determinant of the Sylvester matrix and is
computed as one. The remainder-sequence formulations are faster, but each
carries a sign and a power of a leading coefficient that has to be tracked
through every step, and getting one of those wrong produces a plausible wrong
answer rather than a visible failure. Taken as a determinant the two sign
conventions -- Res(f, g) = (-1)^(deg f deg g) Res(g, f), and the product over
the differences of the roots -- fall out of the matrix instead of being
imposed on it. The determinant itself is taken by one-step fraction-free
elimination, which is the same reason the subresultant divisions in
PolynomialGcd come out exact: every intermediate entry is a minor of the
original matrix.

The content in the main variable is divided out before the elimination and
its power multiplied back afterwards, since Res(c f, g) = c^deg g Res(f, g)
and a factor left in would otherwise be raised to the size of the matrix. It
is an optimisation, so a content that cannot be settled leaves the input
alone rather than refusing.

The degenerate cases were measured against SymPy 1.14 rather than recalled: a
zero argument gives zero whatever the other side is, two arguments free of
the main variable give one, and Res(f, c) is c^deg f. The tests check the
answer against the product over the differences of the roots and against a
cofactor expansion of the Sylvester matrix, neither of which shares anything
with the implementation, and the sign cases were searched for rather than
guessed -- an odd number of row interchanges in the elimination is the one
place the sign can go missing without any other case noticing.

MultivariatePolynomial gains DerivativeIn, which the discriminant needs and
which cannot leave the type's bounds. PolynomialGcd.ContentIn becomes
internal so the content split does not need a second copy of it.
A quadratic or a cubic that factors at all has a rational root -- a cubic
splits as a linear factor times a quadratic, or into three linear ones,
and either way there is a linear factor to find -- so the rational-root
split that runs first has already divided it out. Four is the first
degree at which a polynomial can factor with nothing rational to catch.

So the guard is not caution, it is the statement of where the previous
step stops being complete; below it the work was never going to find
anything, and a quartic that turns out irreducible is the only case that
pays for the search without being helped by it.
@Rafael-SOWNet
Rafael-SOWNet merged commit a3c7554 into master Aug 13, 2026
25 checks passed
@Rafael-SOWNet
Rafael-SOWNet deleted the feat/polynomial-factorization branch August 13, 2026 18:19
Rafael-SOWNet added a commit that referenced this pull request Aug 14, 2026
…y its roots (#919) (#926)

A partial fraction decomposition split N/D at a rational root of D, which was all the
decomposition there was. A denominator that factors over Q with no rational root anywhere in it
was left whole and its integral came back unevaluated -- even where every factor was one the
integrator already reads:

    1/(x^4 + 3x^2 + 2)    unevaluated  ->  arctan(x) - sqrt(2)*arctan(sqrt(2)*x/2)/2 + C
    x/(x^4 + 3x^2 + 2)    unevaluated  ->  (ln|x^2 + 1| - ln|x^2 + 2|)/2 + C
    1/(x^4 + 4)           unevaluated  ->  the antiderivative over its two quadratic factors

The denominator of the first is (x^2 + 1)(x^2 + 2) and of the third (x^2 - 2x + 2)(x^2 + 2x + 2);
the rule for a linear numerator over a quadratic answers both halves of each. Nothing was missing
but the split. #918 supplied the factorisation over Q that makes it available, so this is the
second consumer of the polynomial layer after the equation solver.

The step is a coprime split, the same shape as the step at a root: one irreducible factor with its
multiplicity against the product of the rest, U*A + V*B = 1 from the extended Euclidean algorithm
in Q[x] -- which did not exist and is the new RationalPolynomial -- and N/(A*B) = N*V/A + N*U/B
with each numerator reduced modulo its own denominator. The polynomial parts that come off cannot
survive, a proper fraction less two proper fractions being a polynomial that vanishes at infinity.
Both sides are strictly smaller problems of the same kind, so the integrator recurses and reaches
the full decomposition whichever factor is peeled first.

No condition is attached, and that is a statement rather than an omission. A and B being coprime,
A*B vanishes exactly where one of them does, so the two sides are undefined at the same points; a
decomposition loses a singularity when it cancels a shared factor, and this cancels nothing. The
identity is checked -- overA*B + overB*A against the numerator, over Q -- rather than trusted, so
the failure that survives is a refusal and not a wrong antiderivative.

The decomposition is produced only where every factor is a shape an integration rule reads: linear
at any multiplicity, quadratic at the first, nothing else. That costs no answer, since a piece with
no rule leaves the whole integral unevaluated either way, and it is what keeps declining cheap.
Deciding it by trying instead made (1 - x^4)/(1 + x^4 + x^8), whose factorisation holds the
irreducible quartic x^4 - x^2 + 1, take 18s to return the same unevaluated integral it returns in
203ms, because every half of every split is a fresh problem the whole integrator then searches.
Read off the factorisation, which is already in hand, the cost of declining is that one
factorisation.

One test changed rather than being added to. ADenominatorWithNoRationalRootIsStillDeclined pinned
1/(x^4 + 4) as out of reach on the grounds that it has no rational root; it is reducible over Q and
is now answered, so the boundary it records has moved to what does not factor over Q at all.
x^2/(x^4 + 1) stays, x^4 + 1 being irreducible, and 1/(x^4 + 2x^2 + 1) joins it as the power of a
single irreducible.

Measured on this branch against a stock build of master, same machine:

    unit suite            6960 passed, 0 failed        F# wrapper 130/130
    casbench              116/119, 0 wrong 0 error 0 timeout, equal to master
    propcheck             1340 checks, 0 failures      rootcheck 596/596 clean
    simpsweep             10463/10463 agree            crashcheck 1652, 0 crashed
    intbench families=0   46/191 -> 47/191 solved, 0 wrong, 6 timeout either side
    intbench families=1   30/228 both, and identical to master problem by problem

#919

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Rafael-SOWNet added a commit that referenced this pull request Aug 14, 2026
…rary is (#746) (#928)

* State what canonical form means here, and measure how far off the library is (#746)

#746's tier 1 asks for "canonical forms with a written specification of what canonical means for each
node class, and a stated distinction between canonical and simplest", and its item 65 asks whoever
does it to take a position, write it down, and let the engine be checked against it. This is the
document; the checking is a new harness in the analysis workspace, and every number here came out of
it rather than out of an argument.

**The position.** Canonical is about identity and simplest is about presentation. A canonical form
exists to make equality a structural comparison; a simplest form is the best-rated member of a class
under a cost metric that the caller chooses, so it is only defined relative to one.

**A complete canonical form does not exist, and that is a theorem rather than a gap.** Zero-
equivalence is undecidable for the class the library accepts -- rationals, pi, exp, the trigonometric
functions, abs and composition (Richardson 1968) -- and a canonical form would decide it. So the
specification is a canonical form on a decidable sublanguage with the boundary written down, a
normalisation everywhere else that must not be mistaken for one, and a search that is not required to
be canonical at all. That is Moses' three-way split from 1971 and it costs nothing but saying so.

**Three properties, none needing an oracle, measured against both candidates:**

                        InnerSimplified      Simplify
    idempotence           1 failed of 834    0 failed of 120
    order independence 2024 failed of 2738   8 failed of  72
    listed agreements    20 failed of  30    6 failed of  30

Neither is canonical, and Simplify is much the closer of the two -- which is the opposite of what the
names suggest. InnerSimplified does not order the operands of a sum or a product at all, so it fails
three quarters of the order checks by construction. Simplify reorders as a side effect of rating
candidates, and its eight failures are all ties settled by generation order.

**The finding that matters most to anyone writing a rule or a test:** `(x + y) + a` and
`x + (y + a)` both print as `x + y + a` and are different trees. Associativity is normalised in the
printer, not in the expression, so a comparison of printed forms calls them equal and a comparison of
entities does not. The harness compares entities, which is why it saw this.

**Two defects rather than decisions**, each measured and each reproducible on its own:

    cos(0 ^ y).InnerSimplified   -(-1) provided ...   then   1 provided ...    not idempotent
    cos(-x).Simplify()           cos(-x)         while  cos(-2 * x)  ->  cos(2 * x)

The first is a rewrite building `-(-1)` above already-normalised children and returning without
re-normalising; `-(-1)` on its own folds immediately. The second is the parity identities keyed on a
shape a bare negation does not have -- sin, tan and abs behave the same way.

Per node class the file states the target rather than describing today, marking each line met or not.
One line is flagged as needing a maintainer's yes before anyone implements it: making subtraction and
division sugar for a sum and a product, which is what makes the commutative laws reachable and is
also a breaking change to everything that matches on Divf.

And it names where a canonical form is actually available now: rational functions over Q, where
zero-equivalence is decidable and the parts -- multivariate GCD, expansion, a monomial order -- landed
with the polynomial layer in #918 and #923. That is the piece to build first because it is the piece
that is possible.

#746

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Correct the specification: the total order exists, and the obstacle is confluence (#746)

The first version of this file said a total order on operands was owed. It is not: the library has
one, at three granularities, and measuring it changes what the document should ask for.

`RewriteRules.CanonicalOrderExact` sorts and groups the operands of sums, products, conjunctions,
disjunctions and set operations by the whole subtree. Applied before the normalisation it makes
order independence **perfect** -- 0 failures of 2738, against 2024 without it. So the piece a
specification would normally have to invent is built; what is missing is that the normalisation does
not run it, which is also most of why `Simplify` agrees so much more often than `InnerSimplified`.

And the reason it cannot simply be moved there is now measured rather than guessed at. Sorting and
then normalising is **not idempotent** -- 21 of 834 -- and every failure is the same phenomenon, the
sort and `Patterns.NumericNeatRules` disagreeing about where a numeric operand belongs and each
undoing the other:

    1 / 2 - x   ->   -x + 1/2   ->   1/2 + -x   ->   ...

Neither is wrong on its own. Until they are made to agree, applying the order inside the
normalisation trades 2024 order failures for a form that never settles.

So §8's first item is no longer "specify and implement a total order" but "make the order and the
normalisation confluent", which is a much smaller and much better defined question, and it is the
one actually in the way of everything else in tier 1's canonicaliser.

The measurement table gains the third column, §4's commutative rows say ordered-but-not-run rather
than not-ordered, and §7 records that the harness now runs all three properties over all three
candidate forms -- the differences between the columns being the point rather than any one number.

Also: the two defects §3 lists are fixed, in #929 and #930, so the table is labelled with the build
it was taken on and with what it reads without them. A harness report records a build.

#746

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Compose the canonicaliser out of what exists: normalise, order, normalise (#746)

The previous revision said the order and the normalisation had to be made to agree, and left that
as the open question. It is not a disagreement between rules and nothing has to be decided between
them.

The sort's key depends on a node's class and the normalisation changes classes. In 1/2 - x the
constant reaches the sort as 1 * 2 ^ (-1), a product, and is ordered against -x as one; the
normalisation folds it to the number 1/2, and the next sort orders it the other way. So the sort was
ordering a shape about to stop existing.

Normalising first gives both properties at once:

                      InnerSimplified   order then normalise   normalise, order, normalise
    idempotence         0 of 834          21 of 834              0 of 834
    order independence  2024 of 2738       0 of 2738             0 of 2738

x + (-1/2), whose constant is already a number when the sort reads it, was stable throughout, which
is the control that makes this the explanation rather than a guess.

So §8's first item is now to expose that composition as the canonicaliser rather than to reconcile
anything. It needs no rule changed. What it needs is a decision about where it runs: opt-in changes
nothing, and inside InnerSimplified every commutative operand order in every printed answer moves at
once.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* The canonicaliser reaches associativity too, and it now exists (#746)

Two corrections, both from a test that failed the way round I did not expect.

Nesting goes with order. The sort works over commutative *chains* rather than over one node, so it
flattens as it sorts: (x + y) + a and x + (y + a) both reach a + x + y and reach it as the same
tree. I had written a test asserting they stay different and it failed, which is how this was found.
So flattening is no longer owed -- what is owed is that InnerSimplified on its own does neither, and
InnerSimplified is what every rule and every cache in the library actually sees.

And the composition now exists under a name, Transformation.Canonicalisation (PR #933), so §8's
first item is no longer to build it but to decide where it runs. Offering it changes nothing;
putting it inside InnerSimplified moves every commutative operand order in every printed answer at
once.

The warning in §3 stands unsoftened for the same reason: the canonicaliser makes those two trees
one, and almost nothing calls the canonicaliser.

#746

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Name what is actually missing for the rational-function form (#934)

§8's remaining build item is smaller and more specific than 'a rational-function canonical form'.
The greatest common divisor is already there and already verifies itself; what is missing is that
nothing in the library puts an expression over a common denominator, so 1/x + 1/y and (x+y)/(x*y)
cannot be brought to a common form by any existing route -- measured through Simplify,
InnerSimplified and Factorize alike, and Simplify actively prefers the split form.

Filed as #934 with the design and that measurement, so the next person starts from what is missing
rather than from what it is called.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Rafael-SOWNet added a commit that referenced this pull request Aug 14, 2026
#746 makes a measured performance column a standing condition of a release rather than a roadmap
item, and 2.0.0 shipped without one. This is the pair for 2.2.0: the 1709th, tagged v2.1.0, against
the 1724th, measured minutes apart in one session on one machine so that the two may be read against
each other.

Everything that is not the solver is flat -- parse, simplify, evaluate and the compiled-call trio
within 2%, allocation byte-identical on most rows. Four Solve rows are 7% to 21% slower and allocate
10% to 20% more.

**Allocation moving with the timing is what makes it real.** The previous pair is the cautionary
case: a row reported +8.7% with allocation flat and re-measuring put it at +2.1%. Here four related
rows move together in both, which noise does not do.

Isolated to #918, the polynomial layer, by measuring the commits either side of it: allocation steps
exactly once, at that commit, and is identical to v2.1.0 before it and to master after it, in all
four rows. SolveEasy -- a quadratic, which never reaches the factorisation path -- is flat
throughout, which is the mechanism corroborating itself.

**It is a price rather than a regression.** #918 made the equation solver the polynomial layer's
first consumer, which is what turned x^5 + 2x^3 - 2x^2 - 4 from three roots, one of them a float,
into all five, exact. An incomplete solution set is a false answer, not a partial one, so the trade
is the one AGENTS.md's first rule requires. Recorded, not fixed.

And the part worth more than the rows: **none of these ten solver benchmarks benefit from #918.**
They are quadratics, a substituted quadratic and a trigonometric substitution; not one factors into
lower-degree pieces, so every one pays the search and none collects the answer. The column shows the
change as pure cost, which is true of these inputs and false of the change. A benchmark whose
polynomial does factor is owed before the next column.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant