Skip to content

Only invert an equation whose variable occurs once (#744) - #745

Merged
Rafael-SOWNet merged 1 commit into
masterfrom
fix/repeated-variable-inversion
Aug 5, 2026
Merged

Only invert an equation whose variable occurs once (#744)#745
Rafael-SOWNet merged 1 commit into
masterfrom
fix/repeated-variable-inversion

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator

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". Solving f(x) = 0 did not check it, so an equation whose variable occurs more than once came back rearranged rather than solved:

equation was is
(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), … } the three roots of the cubic
sqrt(x^2 + x + 1) = 0 { sqrt(-1 - x), -sqrt(-1 - x) } the two roots of the quadratic
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) is x^2 + x + 1 = 0 moved to x^2 = -1 - x with the square inverted and the x on 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 whole sin / ln / abs family are the same defect and are fixed with it.

What the fix does

1. case Function now requires the variable to occur once. Where it 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. 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 why cos(x^2 + x + 1) = 1 has never been wrong while sin(x^2 + x) = 0 always 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 = 0 answer identically to f(x) = 0 rather than merely equivalently. Without it (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 about four times as long (238 ms vs 63 ms). Restricted to 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.

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 because Invert requires 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 until InnerSimplified writes it as a square.

Measured

  • rootcheck: 596 cases, 1 incomplete / 2 missing roots0 and 0; 0 unsound either way.
  • Corpus (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).
  • Suite: 4979 → 4992 passed / 0 failed, F# 130/130.

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

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>
@Rafael-SOWNet
Rafael-SOWNet merged commit 9f790a6 into master Aug 5, 2026
24 checks passed
@Rafael-SOWNet
Rafael-SOWNet deleted the fix/repeated-variable-inversion branch August 5, 2026 22:15
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>
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.

A power of a polynomial is solved by inverting into itself, so the roots come back containing x

1 participant