Skip to content

Judge a root against the terms it cancels, and judge it either way it is written - #685

Merged
Happypig375 merged 1 commit into
ASC-Community:masterfrom
Rafael-SOWNet:fix/spurious-roots-without-equals
Aug 4, 2026
Merged

Happypig375 merged 1 commit into
ASC-Community:masterfrom
Rafael-SOWNet:fix/spurious-roots-without-equals

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Member

Two faults in the check that drops roots which do not satisfy the equation they came from. Found while testing whether #214 could be closed.

A quartic answered with the empty set

"1/210 - (17*x)/210 + (101*x^2)/210 - (247*x^3)/210 + x^4 = 0".Solve("x")
// master: {  }

It has four real roots. They come back from the numeric search as decimals, and a root found that way is only as accurate as the search that found it — the terms cancel at it to within that accuracy rather than exactly. These leave a residual of 5.2e-6 against terms of about 1.2e-2. The check read that residual on its own size against a fixed 1e-6 and threw all four away.

The residual is now read against the largest of the terms it is the sum of.

The threshold is measured, not picked. A spurious root is not a near miss — it comes from a rewrite that widened the domain, so its residual is a whole quantity in its own right: 2*pi*i for a logarithm sum, or the difference between −3 and 9 for an exponential. Across the equations I measured:

relative residual
genuine roots 0, or 1e-15, worst case 4.5e-4
spurious roots 1.55 – 1.98

The threshold is a hundredth — twenty times above the worst genuine root, a hundred and fifty times below the mildest spurious one.

The same equation answered two ways

The check only ran when the caller wrote the equation out. Handed the bare expression it went through a different entry point that checked only for finiteness:

("2^x + 2^(2*x) - 6 = 0").Solve("x")       // { 1 }
"2^x + 2^(2*x) - 6".SolveEquation("x")     // { 1, ln((-3) ^ (1 / ln(2))) }

The second answer is a root of nothing. The exponential solver substitutes t = e^x and inverts with t = v^(1/p); composing that with x = ln(t) is only the same number while the imaginary part stays inside the principal branch. For a negative v it does not — ln((-3)^(1/ln 2)) is 1.58 - 1.75i, exactly one turn away from the actual root ln(-3)/ln(2) = 1.58 + 4.53i, and satisfies nothing. Both entry points now check.

The genuine complex root is still not found; this only stops a non-root being reported in its place. Recovering it needs the exponential solver to invert the composition rather than the two steps separately, which is a separate piece of work.

Verification

18 tests, 5 of which fail without the change. 3898 unit tests and 127 F# tests on both target frameworks, none failing — including MomoTest, which is the quartic above and which fails without the first half of this. 117-problem self-verifying corpus at 88/117 with nothing wrong, in error or timing out; 1320 property checks over 151 expressions, none failing.

One thing I did not pin: x^5 + 3*x + 1 comes back with 28 roots for a quintic, near-duplicates from the numeric search. That is untouched here and worth its own look.

@codecov-commenter

codecov-commenter commented Aug 4, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 88.23529% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.57%. Comparing base (90c00a8) to head (f1460df).
⚠️ Report is 57 commits behind head on master.

Files with missing lines Patch % Lines
...ontinuous/Solvers/EquationSolver/SolveStatement.cs 86.66% 0 Missing and 2 partials ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #685      +/-   ##
==========================================
- Coverage   80.99%   80.57%   -0.43%     
==========================================
  Files         155      156       +1     
  Lines       13687    12930     -757     
  Branches     1957     2125     +168     
==========================================
- Hits        11086    10418     -668     
+ Misses       1990     1906      -84     
+ Partials      611      606       -5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

… is written

Two faults, both in the check that drops roots which do not satisfy the equation
they came from.

The check reads the residual on its own size, and a root found numerically is
only as accurate as the search that found it: the terms of the equation cancel at
such a root to within that accuracy rather than exactly. The four roots of
1/210 - 17x/210 + 101x^2/210 - 247x^3/210 + x^4 come back as decimals and leave
5.2e-6 against terms of about 1.2e-2, so all four were dropped and master answers
that quartic with the empty set. The residual is now read against the largest of
the terms it is the sum of. The two cases are far apart because a spurious root
is not a near miss -- it comes from a rewrite that widened the domain, so the
residual is a whole quantity in its own right, 2*pi*i for a logarithm sum or the
difference between -3 and 9 for an exponential. Measured over these equations
every genuine root sits at 4.5e-4 or below and most at 1e-15, while every
spurious one sits between 1.5 and 2. The threshold is a hundredth.

The check also ran only when the caller wrote the equation out as expr = 0. The
same equation handed over as the bare expr went through another entry point and
was checked only for being finite, so the two disagreed:
(2^x + 2^(2x) - 6 = 0).Solve(x) gave { 1 } while
"2^x + 2^(2x) - 6".SolveEquation(x) gave { 1, ln((-3) ^ (1 / ln(2))) }. That
second answer is a root of nothing: the exponential solver substitutes t = e^x
and inverts with t = v^(1/p), and composing that with x = ln(t) is only the same
number while the imaginary part stays inside the principal branch. For a negative
v it does not, and 1.58 - 1.75i is a full turn away from the root 1.58 + 4.53i.
Both entry points now check.

Found while testing whether #214 could be closed. 18 tests, of which 5 fail
without the change; 3898 in all, 127 F#, corpus 88/117 with nothing wrong, and
1320 property checks.
@Rafael-SOWNet
Rafael-SOWNet force-pushed the fix/spurious-roots-without-equals branch from a93d959 to f1460df Compare August 4, 2026 11:08
@Happypig375
Happypig375 merged commit 8004910 into ASC-Community:master Aug 4, 2026
24 checks passed
@Rafael-SOWNet
Rafael-SOWNet deleted the fix/spurious-roots-without-equals branch August 4, 2026 20:49
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.

3 participants