Skip to content

Give the resultant a consumer, and publish the polynomial layer (#746 item 43) - #1017

Merged
Rafael-SOWNet merged 4 commits into
masterfrom
feat/polynomial-public-surface
Aug 23, 2026
Merged

Give the resultant a consumer, and publish the polynomial layer (#746 item 43)#1017
Rafael-SOWNet merged 4 commits into
masterfrom
feat/polynomial-public-surface

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator

Two halves of #746 item 43, and the first is the one that matters.

(a) The resultant had no caller, and now it has one that needs it

PolynomialResultant — 307 lines with a careful content-extraction argument and a measured work ceiling — was referenced nowhere outside its own file except its unit test, ten days after landing flagged as "on probation until something eliminates a variable with them". Every other file in the layer is wired: PolynomialGcdPatterns and RationalFunction, PolynomialFactorizationAnalyticalEquationSolver and PartialFractions, PartialFractionsIndefiniteIntegralSolver, RationalFunctionTransformation.Catalogue. Under this issue's own counterweight — infrastructure must be validated by a consumer in the same change — that made it speculative code.

The consumer is polynomial inequalities of degree three and up, which previously threw. AnalyticalInequalitySolver handled linear and quadratic only.

A polynomial has one sign on each open interval between consecutive real roots, so the answer is the union of the intervals where the sign is right — provided the root list is complete. A missed root merges two intervals of opposite sign and reports the wrong half as the solution, which is a wrong answer, not an incomplete one. So completeness has to be established rather than hoped for, and that is what the discriminant is for here:

  1. PolynomialFactorization.FactorPrimitive writes the polynomial as a product of powers of irreducibles over ℚ, and verifies the product.
  2. The number of real roots of each irreducible factor is read off PolynomialResultant.Discriminant — two or none for a quadratic by its sign; three or one for a cubic; and for a quartic the discriminant together with P = 8ac − 3b² and D = 64a³e − 16a²c² + 16ab²c − 16a²bd − 3b⁴.
  3. The ordering of the roots is numeric and therefore checked rather than trusted: the sign of the polynomial at an exact rational point inside each interval is computed in exact arithmetic, and the sequence must flip at every odd-multiplicity root and hold at every even one. The two outer samples sit beyond Cauchy's bound. Any mismatch is a refusal.

Degree five is where it stops, and honestly: an irreducible quintic factor has no real-root criterion and no radical formula either.

Measured on builds of each side, and re-verified independently on this machine:

                            master                              this branch
x^3 - x > 0                 NotSufficientlySupported            (-1; 0) \/ (1; +oo)
x^4 - 5x^2 + 4 > 0                  "                           (-oo; -2) \/ (-1; 1) \/ (2; +oo)
(x - 1)^2 * (x + 2) > 0             "                           (-2; 1) \/ (1; +oo)
x^3 - 2x + 1 > 0                    "                           ((-1 - sqrt(5))/2; (-1 + sqrt(5))/2) \/ (1; +oo)
x^5 - 5x^3 + 4x > 0                 "                           (-2; -1) \/ (0; 1) \/ (2; +oo)
x^4 - 10x^2 + 1 > 0                 "                           three intervals at ±sqrt(5 ± 2 sqrt(6))
x^5 - x - 1 > 0                     "                           still refused, with a message naming the real gap

Checked by 32 point-verified theory cases — 121 rational points each, membership against EvalBoolean — not by printed form.

The suggestion I rejected, with the reason. Discriminant for repeated-root detection in AnalyticalEquationSolver would be decorative: that solver already runs TryFactorIntoIrreducibles, whose SquareFreeDecomposition computes gcd(f, f′) — which detects and divides out multiplicity at any degree, where the discriminant only answers yes/no and only up to the resultant's size budget. The sign table is where the discriminant does work nothing else in the tree does: it is the completeness certificate for a real-root list, and without it the interval endpoints would be a guess.

Rothstein–Trager (item 44) is therefore no longer the argument for keeping the resultant.

(b) MathS.Polynomials

Factor, Gcd, Resultant, Discriminant, SquareFreePart — five members, each with XML documentation and a worked example.

Does it belong in the kernel? Yes, and the argument is that it adds nothing to it. Every one is a thin adapter over code already compiled into AngouriMath.dll and already reached by Simplify, Solve and Integrate. No new machinery ships, the common case pays exactly what it paid before, and the trimmer's picture is unchanged because Simplify/Solve already reference the layer. Item 78 says a large thing landing in the kernel wants the package decision first (now #1008); this lands nothing.

What it does cost is a promise for the rest of 2.x, and that is bought with this issue's own complaint: the layer's genuine limits were previously invisible, showing up as a simplification that quietly did nothing.

Refusals are honest — null means "I could not settle this", never "the answer is the input":

Factor(x*y + y, x)     [multivariate]   = null        <- not "x * y + y"
Factor(sin(x) + 1, x)  [not polynomial] = null
Factor(x^33 - 1, x)    [past degree 32] = null
Gcd(<9 variables>, …)                   = null        <- 8 variables answers
Factor(x^2 + 1, x)     [irreducible]    = x ^ 2 + 1   <- an answer, not a refusal
Discriminant(x^2 + b*x + c, x)          = b ^ 2 - 4 * c

Telling irreducible from declined needed one change inside the layer: PolynomialFactorization.Factor returned null for both. FactorComplete now separates them; Factor is the same computation with the trivial factorisation rejected, and its existing callers are unchanged.

Measured

Suite: 7615 passed, 0 failed, 14 skipped (baseline 7533/0/14) — +82, being 32 inequality cases, 44 surface cases and 6 that moved out of the "not supported" theory. F# wrapper 134/0.

Three tests in KnownLimitsAreNotBugsTest pinned the old refusal and now answer. They moved to TheDegreesThatAreSupportedStillAnswer, and the refusal theory now lists irreducible quintics, which is what the gap actually is.

No regression on solving. Both arms in one session, back to back:

alloc before alloc after
SolveEasy 8,852,269 B 8,852,269 B
SolveEasyMedium 95,795 B 95,791 B
SolveMedium 658,066 B 658,066 B
SolveMediumHard 162,305,664 B 162,305,664 B
SolveHard 1,431,857,768 B 1,431,857,336 B

Read the allocation column, not the time column. Byte-identical to within 0.005% on all five — the solving path does the same work, which is the actual answer to "did this regress solving". The timings on that pair moved by up to 9% in both directions with five to eight other agents building on the same 8 cores; ParseEasy, SimplifyEasy and EvalEasy moved by more than any Solve row with identical allocation, which no change here could cause. Nothing is claimed as a speed-up.

PublicApi.txt regenerated: +5 members, +1 type, 0 removals. Sources/Package.Build.props carried a figure of 103 additions since the 2.2.0 baseline that counted those same lines; it was true and this change falsified it, so it now reads 109.

Not done, and why

  • No Sturm sequences, no degree ≥ 5. Real-root isolation is separate work; the refusal says so.
  • Entity.Factorize() still does not use the polynomial layer. x^4 - 5x^2 + 4 comes back unchanged from it while MathS.Polynomials.Factor splits it into four linear factors. That is a real inconsistency and it lives in Core/Transformations/**, which another branch owned during this work — worth its own issue.
  • No PartialFractions on the public surface — it needs a public type for the decomposition, which is a larger promise than five scalar-returning members.

darkfader and others added 4 commits August 23, 2026 02:37
The inequality solver refused everything above degree two. It now builds a
sign table: a polynomial has one sign on each open interval between
consecutive real roots, so the answer is the union of the intervals where
that sign is positive.

What makes it an answer rather than a guess is that the root list is
complete. The polynomial is written as a product of powers of irreducibles
over Q -- verified to multiply back -- and the number of real roots of each
irreducible factor is read off its discriminant: two where a quadratic
factor's is positive and none where it is negative, three and one
respectively for a cubic. Four is where the discriminant stops deciding, so
an irreducible factor of degree four or more is refused.

This is the first production caller of PolynomialResultant, which had none
outside its own tests.

The ordering of the roots is decided numerically and therefore checked
rather than trusted: the sign of the polynomial at an exact rational point
in each interval is computed in exact arithmetic, and the sequence has to
change sign at every root of odd multiplicity and hold it at every root of
even multiplicity. The outermost two samples sit beyond Cauchy's bound.

Three cases in KnownLimitsAreNotBugsTest that pinned the old refusal now
answer, and move to the theory for the ones that do; the ones left there
are irreducible quartics, which is what the remaining gap actually is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011emtnRT6EWTrxXNtqDVK3e
Factorisation over Q, multivariate greatest common divisors, resultants,
discriminants and square-free parts are all in the kernel assembly already
and are all called by Simplify, Solve and Integrate. None of them could be
asked for directly, so a caller who wanted the factors rather than whatever
a simplification decided to do with them had no way to say so -- and no way
to find out that the request was outside what the layer can do.

Five members, each documented with a worked example that is executed by
PolynomialSurfaceTest, so the page cannot drift from the output.

Every one refuses with null rather than guessing, and null means "I could
not settle this" and never "the answer is the input". Factor hands back an
irreducible polynomial unchanged, which is the statement that it is
irreducible; it hands back null for a multivariate one, because returning
x * y + y from a factorisation would say that y * (x + 1) does not exist.
Nine variables, degree 128 and degree 33 are refused the same way.

Telling those two apart needed a change inside PolynomialFactorization:
Factor reported an irreducible polynomial as null, since its callers are
looking for a product to work through, and that is the same value it uses
for declining. FactorComplete is the entry point that separates them, and
Factor is now the same computation with the trivial factorisation rejected.

PublicApi.txt regenerated: five members and one type added, none removed.
The growth figure in Package.Build.props counts the same lines and moves
with them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011emtnRT6EWTrxXNtqDVK3e
The discriminant decides how many real roots an irreducible factor has up
to degree three. At four it decides half of it -- negative means exactly two
real roots -- and P = 8ac - 3b^2 with D = 64a^3e - 16a^2c^2 + 16ab^2c
- 16a^2bd - 3b^4 decide the other half: four real where both are negative,
none where either is positive. Where neither holds the criterion says
nothing, and neither does this.

So x^4 + 1 > 0 is the whole line, x^4 - 2 > 0 is the outside of a pair of
fourth roots, and x^4 - 10x^2 + 1 > 0 has three intervals. The gap is now an
irreducible factor of degree five or more, where there is no criterion and
no formula for the roots either.

The selection of the real roots among the complex ones generalises from the
cubic's one-of-three to any count below the degree, with the same guard: the
two groups have to be far enough apart for saying which is which not to be a
close call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011emtnRT6EWTrxXNtqDVK3e
The sample points of the sign table are placed from the double
approximations of the roots. A rational root whose numerator dwarfs its
denominator by more than a double can express comes back as an infinity,
and the midpoints computed from it are nowhere in particular. It is refused
now, which is the answer that shape of input has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011emtnRT6EWTrxXNtqDVK3e
@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator Author

One commit landed after the description above was written, and it belongs in it — 277aaaa7, a refusal rather than a feature.

The sign table places its sample points from double approximations of the roots, then evaluates the polynomial there in exact arithmetic. A rational root whose numerator dwarfs its denominator by more than a double can express approximates to an infinity, and the midpoints computed from it are nowhere in particular — so the sign sequence would be read at points that have no relation to the intervals they are supposed to be inside.

It is refused now. That keeps the property the rest of the change rests on: every interval endpoint in an answer is one the sign sequence was actually verified across. A sign table built on sample points that drifted is not a weaker answer, it is a wrong one — a missed or misplaced root merges two intervals of opposite sign and reports the wrong half as the solution.

Worth noting because it is the shape of thing this whole change is careful about: the numeric part is used only to order the roots and is checked against exact arithmetic afterwards, and where the numeric part cannot be trusted at all the answer is no answer.

@Rafael-SOWNet
Rafael-SOWNet merged commit d52b792 into master Aug 23, 2026
25 checks passed
Rafael-SOWNet added a commit that referenced this pull request Aug 23, 2026
The entry for polynomial inequalities of degree three and up sits under
'2.3.0 -- since 2.2.0'. It merged as #1017 today, after v2.3.0 was tagged, and
git show v2.3.0:BREAKING-CHANGES.md does not contain it -- so the file tells a
reader the change shipped in a release that does not have it, which is the one
thing this file exists to get right.

Moved verbatim to Unreleased, both the at-a-glance row and the prose section.
Sorting the whole file before and after gives an identical multiset of lines:
nothing is added, reworded or lost.
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.

2 participants