A polynomial layer: factorisation over Q and F_p, resultants, square-free decomposition (#746 item 43) - #918
Merged
Merged
Conversation
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.
This was referenced Aug 13, 2026
This was referenced Aug 13, 2026
Merged
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/Polynomialsisinternal,InternalsVisibleTowas commented out, so the only coverage was seventeen cases reading answers offSimplify(). That is whyGroebnerSystemTestgoes through the publicSolve— not a style choice.Resultant+Discriminant, Sylvester determinant by Bareiss elimination; 79 testsBerlekamp 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 - 4is(x^2 + 2)(x^3 - 2), and has no rational root: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
Simplify. Measured first:SimplifiedRateprefers the expanded form in every case tried (x^6 - 1rates 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.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.a*x^n + bis left whole, for the reasonTrySplitOffRationalRootsalready 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 andUnitTests.csprojsigns with the samekey.snk. Without it none of this machinery can be tested other than throughSolve, 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:
(-1)^(n(n-1)/2)in the discriminantTwo 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 + 1andx^4 - 10x^2 + 1are 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^2before it was checked.Defect found on the way
MultivariatePolynomial.TryParseaccepted a ninth variable and silently aliased it onto the first:ShiftOf(8)is-8, and C# masks a shift count to six bits, sox_1 - x_9read 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 = 24is a judgement rather than a measurement (#921). The first consumer this unblocks but does not include is partial-fraction integration over irreducible factors (#919).