Only invert an equation whose variable occurs once (#744) - #745
Merged
Conversation
Inverting an expression isolates the variable by walking down to it and undoing
each node on the way, which works only if there is one place to walk to. Invert's
own documentation says so. Solving `f(x) = 0` did not check it, so an equation
whose variable occurs more than once came back rearranged rather than solved:
(x^2 + x + 1)^2 = 0 -> { sqrt(-1 - x), -sqrt(-1 - x) }
sin(x^2 + x) = 0 -> { +-sqrt(2*pi*n_1 - x), +-sqrt(pi + 2*pi*n_1 - x) }
ln(x^2 + x) = 0 -> { sqrt(1 - x), -sqrt(1 - x) }
abs(x^2 + x) = 0 -> { sqrt(-x), -sqrt(-x) } provided r_1 in RR
Each is the equation with one node inverted and the rest of the variable left
where it stood: `x^2 = -1 - x` is a true statement and not an answer. A "root"
like that satisfies nothing and cannot be evaluated at all, which is why nothing
caught it -- substituting it back leaves an expression rather than a residual,
and a root is only ever dropped on positive evidence against it.
Where the variable occurs more than once, the replacement machinery further down
is what answers: it solves `sin(t) = 0` and then `t = x^2 + x` for each root. It
was always there, and a right-hand side of zero is the only reason it was being
skipped -- the same equations written with a non-zero side reach it and were
right all along, which is why `cos(x^2 + x + 1) = 1` has never been wrong.
sin(x^2 + x) = 0 -> { (-1 +- sqrt(1 + 8*pi*n_1))/2,
(-1 +- sqrt(1 + 4*pi + 8*pi*n_1))/2 }
ln(x^2 + x) = 0 -> { (-1 - sqrt(5))/2, (-1 + sqrt(5))/2 }
abs(x^2 + x) = 0 -> { 0, -1 }
A power is routed directly instead, because a power is zero exactly where its
base is and that is worth saying once: `f(x)^n = 0` then answers *identically*
to `f(x) = 0` rather than merely equivalently. Through the replacement machinery
`(x^2 + x + 1)^2 = 0` reaches the same two numbers written `-1/2 + -sqrt(-3/4)`,
which is the output-quality complaint of #272 one step along, and takes four
times as long. Only for an exponent that is a positive number and free of x: a
negative one is never zero, and one that moves with x is a different equation.
(x^2 + x + 1)^2 = 0 -> { (-1 - sqrt(-3))/2, (-1 + sqrt(-3))/2 }, 63 ms
(x^2 + x)^2 = 0 -> { 0, -1 }
(x^3 + x + 1)^2 = 0 -> the three roots of the cubic
sqrt(x^2 + x + 1) = 0 -> the two roots of the quadratic
The exponent does not have to be a whole number, which the issue does not record:
`sqrt(f(x)) = 0` and `f(x)^(3/2) = 0` failed the same way and are fixed with it.
Alongside, a root that mentions the variable it solves for is now rejected in
`IsSpurious`. Nothing reaches it once the two routes above are fixed -- measured
over eighteen function-shaped equations with the check disabled, none produced
one -- so it is a net rather than the fix, and it is stated as such in the code.
It is worth having because `Invert` requires the variable to occur once and only
one of its five callers checks that.
Found by `work/rootcheck`, which multiplies polynomials up from factors whose
roots are known before anything is solved and then asks whether every root came
back. Its one failing case, `(x^2 + x + 1) * (x^2 + x + 1)`, is a product only
until `InnerSimplified` writes it as a square.
Measured: rootcheck 596 cases, 1 incomplete and 2 missing roots -> 0 and 0.
Corpus 112/117 with 0 wrong, 0 error, 0 timeout, and every verdict and answer
byte-identical. Suite 4979 -> 4992 passed / 0 failed, F# 130/130.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 5, 2026
Rafael-SOWNet
added a commit
that referenced
this pull request
Aug 5, 2026
Every angle in the table of exact values is written `2pi/n`, so it names only
the angles that divide the turn evenly -- and nothing at all between `2pi/5`
and `2pi/3`, which is most of the second quadrant and the whole lower half.
Each lookup then tried a couple of identities to reach the rest, and they reach
some of it and not the neighbouring rest:
sin(2pi/3) -> sqrt(3)/2 the table has the entry
sin(4pi/3) -> -sqrt(3)/2 reached by the doubled-angle identity
sin(5pi/3) -> sin(5/3 * pi) the same value again, and not reached
What was missing is the half turn: `sin(x) = -sin(x - pi)`, and the same for
the cosine, while the tangent's period *is* half a circle so it takes no sign
back. Applied once around the lookups rather than inside them, so it cannot
turn back on itself and loop.
The table entries are also read before a value is built out of the doubled
angle, which was not the previous order and is what makes the output
consistent. Both routes are exact; the doubled-angle one builds a nested
radical where the table has a flat one, so `cos(6pi/5)` came back as
`-sqrt((1 + (sqrt(5) - 1)/4) / 2)` for a number the table names as
`-(sqrt(5) + 1)/4`, and the roots of `x^5 = 1` were written both ways at once.
This is how #743 was reported: the roots of unity are handed back in a + bi
form, and one of six came back carrying a sine.
x^6 - 1 = 0
was { 1, 1/2 + i*sqrt(3)/2, ..., 1/2 + i * sin(5/3 * pi) }
now { 1, 1/2 + i*sqrt(3)/2, ..., 1/2 + i * -1/2*sqrt(3) }
x^5 - 1 = 0 had cos(4/5*pi) and sin(8/5*pi) in it; both are now exact,
and each root is now written the way its conjugate is.
Measured by sweeping every `k/n * pi` of the first turn for eighteen
denominators -- 1158 calls -- and checking each resolved value against the
numeric value of the call it replaced. 581 resolved before and 690 after, with
no disagreement in either run: what was missing was missing rather than wrong,
and the 109 newly exact values are right.
Suite 4979 -> 4991 passed / 0 failed, F# 130/130, corpus 112/117 with 0 wrong
and every verdict and answer byte-identical. rootcheck is 595/596 on this
branch, its one incomplete case being #744, which is a separate defect in the
solver and is fixed in PR #745 rather than here.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Rafael-SOWNet
added a commit
that referenced
this pull request
Aug 5, 2026
…ed (#740) (#748) `a^p / b^p` is gathered into `(a/b)^p`, and stopped being gathered as soon as one of the bases was itself a power: (a^2)^x / (b^2)^x gathers (a^2)^x / b^x did not (a^2 + 1)^x / (a^2)^x did not It is not the shape of the quotient but the order the rules run in. The rule that rewrites `(b^c)^p` as `b^(c*p)` applies to the *child*, on the way up, so by the time the pair of powers is looked at it has already happened. Where both bases are powers it moves both exponents together and the pair survives -- which is why the first line above works, and why it looked like the shape mattered. Where only one base is a power it moves one exponent and not the other, the exponents stop matching, and there is nothing left to pair on. So the pair is read back: `a^p / b^(c*p)` is `(a / b^c)^p`, and the same the other way up. Restricted to a whole `c`, so that `b^c` moves into the base and nothing gains a root it did not have -- a fractional `c` would have to divide the exponent instead, turning `(sqrt(x) + 1)^x / sqrt(x)^x` into `((sqrt(x) + 1)^2 / x)^(x/2)`, which buys a gathered form with a squared numerator. That is a judgement about output rather than the gap this fixes, so it is left as it stands and pinned as such. Why it is worth gathering: the limit machinery reads a `1^oo` off a single power and cannot see one in a quotient, so the same function was answered or not according only to how it had been written. lim x->+oo (x^2 + 1)^x / (x^2)^x unevaluated after 5.5 s -> 1 in 31 ms lim x->+oo (x^3 + 1)^x / (x^3)^x the same A quotient of numeric powers is untouched, because a numeric exponent is not written as a product and so matches nothing here: `x^4 / y^2` keeps its form. `2^(2x) / 3^x` becoming `(4/3)^x` is the one visible widening beyond the issue. Suite 4979 -> 4986 passed / 0 failed, F# 130/130, corpus 112/117 with 0 wrong and every verdict and answer byte-identical. rootcheck is 595/596 on this branch, its one incomplete case being #744, fixed in PR #745 rather than here. 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 #744.
What was wrong
Inverting an expression isolates the variable by walking down to it and undoing each node on the way, which works only if there is one place to walk to —
Invert's own documentation requires "exactly ONE occurance of x". Solvingf(x) = 0did not check it, so an equation whose variable occurs more than once came back rearranged rather than solved:(x^2 + x + 1)^2 = 0{ sqrt(-1 - x), -sqrt(-1 - x) }{ (-1 - sqrt(-3))/2, (-1 + sqrt(-3))/2 }(x^2 + x)^2 = 0{ sqrt(-x), -sqrt(-x) }{ 0, -1 }(x^3 + x + 1)^2 = 0{ (-1 - x)^(1/3), … }sqrt(x^2 + x + 1) = 0{ sqrt(-1 - x), -sqrt(-1 - x) }sin(x^2 + x) = 0{ ±sqrt(2·pi·n_1 - x), ±sqrt(pi + 2·pi·n_1 - x) }{ (-1 ± sqrt(1 + 8·pi·n_1))/2, (-1 ± sqrt(1 + 4·pi + 8·pi·n_1))/2 }ln(x^2 + x) = 0{ sqrt(1 - x), -sqrt(1 - x) }{ (-1 - sqrt(5))/2, (-1 + sqrt(5))/2 }abs(x^2 + x) = 0{ ±sqrt(-x) provided r_1 in RR }{ 0, -1 }x = sqrt(-1 - x)isx^2 + x + 1 = 0moved tox^2 = -1 - xwith the square inverted and thexon the right left where it stood. It is a true statement and not an answer.Such a "root" satisfies nothing and cannot be evaluated at all, which is why nothing caught it: substituting it back leaves an expression rather than a residual, and root verification drops an answer only on positive evidence against it.
The issue records only the power shape and only whole exponents. The
sqrt/^(3/2)cases and the wholesin/ln/absfamily are the same defect and are fixed with it.What the fix does
1.
case Functionnow requires the variable to occur once. Where it occurs more than once, the replacement machinery further down is what answers — it solvessin(t) = 0and thent = x^2 + xfor each root. That machinery was always there; a right-hand side of zero is the only reason it was being skipped, since a non-zero side leaves a subtraction that reaches it. This is whycos(x^2 + x + 1) = 1has never been wrong whilesin(x^2 + x) = 0always was.2. A power is routed directly to its base, because a power is zero exactly where its base is. This is not needed for correctness — the replacement machinery gets there too — but it makes
f(x)^n = 0answer identically tof(x) = 0rather than merely equivalently. Without it(x^2 + x + 1)^2 = 0reaches the same two numbers written-1/2 + -sqrt(-3/4), which is the output-quality complaint of #272 one step along, and takes about four times as long (238 ms vs 63 ms). Restricted to an exponent that is a positive number and free ofx: a negative one is never zero, and one that moves withxis a different equation.3. A root that mentions the variable it solves for is rejected in
IsSpurious. Nothing reaches this once (1) and (2) are in — measured over eighteen function-shaped equations with the check disabled, none produced such a root — so it is a net rather than the fix, and the comment says so. It is kept becauseInvertrequires the variable to occur once and only one of its five callers checks that.How it was found
By
work/rootcheck, a completeness harness that multiplies polynomials up from factors whose roots are known before anything is solved, then asks not "is every answer right" but "is every root there". Substituting answers back can only confirm the roots you were given.Its one failing case was
(x^2 + x + 1) * (x^2 + x + 1)— a product only untilInnerSimplifiedwrites it as a square.Measured
1 incomplete / 2 missing roots→ 0 and 0; 0 unsound either way.work/casbench): 112/117 solved, 0 wrong, 0 error, 0 timeout — unchanged, and every verdict and answer byte-identical to before (only the ms column moves).Regression tests are in a new
RepeatedVariableSolveTest, including the property the harness checks — no root mentions the variable it solves for — and the cases that must not change, where the variable occurs once and inversion is still the route.🤖 Generated with Claude Code