Skip to content

Do not answer an exhausted search with the empty set (#1036, #746 tier 4) - #1046

Merged
Rafael-SOWNet merged 2 commits into
masterfrom
feat/solve-says-which-kind-of-empty
Aug 24, 2026
Merged

Rafael-SOWNet merged 2 commits into
masterfrom
feat/solve-says-which-kind-of-empty

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Member

Step 1 of #1036. Roadmap
#746 tier 4 — "Solve still returns an
empty set both for 'no solutions' and for 'I gave up'."
Does not close #1036, which has five steps.

What was wrong

Solve returns Entity.Set, and an empty FiniteSet meant two different things. Measured on
cd0b56b9, the merge base:

was truth
"x6 + x y + 1 = 0".ToEntity().Solve("x") { } six roots for every y
"sin(x) + x + y = 0".ToEntity().Solve("x") { } roots for every y
"e^x + x + y = 0".ToEntity().Solve("x") { } roots for every y
"x6 + x y + 1".ToEntity().SolveEquation("x") { } same equation, other entry point
"e^x = 0".ToEntity().Solve("x") { } genuinely none
"abs(x) = -1".ToEntity().Solve("x") { } genuinely none

Six identical answers; the last two are true and the first four are not. The empty set is a positive
mathematical claim — no x satisfies this — so asserting it from a search that ran out of ideas is
a wrong answer rather than a graceful failure.

The two sites

The issue names Solvers.Definition.cs:346 and AnalyticalEquationSolver.cs:337. Re-located on this
tree:

  • Functions/Continuous/Solvers/EquationSolver/AnalyticalEquationSolver.cs:337 — return Enumerable.Empty<Entity>().ToSet();, still at that line. This is the chokepoint: every solver
    below Solve converges here.
  • Functions/Continuous/Solvers/EquationSolver/AnalyticalEquationSolver.cs:335 — one line above
    it, expr.SolveNt(x) returning nothing. Newton is asked from finitely many starting points inside
    a bounded region, so finding nothing is a fact about the search.
  • Solvers.Definition.cs:346 is not in Functions/Continuous/Solvers/ — that file is 102 lines
    long and holds Entity.Solve. Line 346 is in
    Functions/Continuous/Limits/Solvers/Solvers.Definition.cs, return MathS.NaN; at the end of the
    two-sided branch. It is a limit-pipeline site, not a Solve one, and it is left for step 2 — see
    What this deliberately does not do.

What it returns now

The equation itself, as the set of the x that satisfy it:

"x6 + x y + 1 = 0".ToEntity().Solve("x")        { x : 1 + x ^ 6 + x * y = 0 }
"sin(x) + x + y = 0".ToEntity().Solve("x")      { x : sin(x) + x = -y }
"e^x + x + y = 0".ToEntity().Solve("x")         { x : e ^ x + x = -y }
"x6 + x y + 1".ToEntity().SolveEquation("x")    { x : 1 + x ^ 6 + x * y = 0 }
"e^x = 0".ToEntity().Solve("x")                 {  }        unchanged
"abs(x) = -1".ToEntity().Solve("x")             {  }        unchanged

With AllowNewton off there is nothing numerical left either:

"x5 + 3x + 1 = 0".ToEntity().Solve("x")         was {  }   is { x : x ^ 5 + 3 * x = -1 }
"sin(x) * x - 3 = 0".ToEntity().Solve("x")      was {  }   is { x : sin(x) * x = 3 }

And what was partly settled keeps the part that was — this one is the shape of the whole change,
because the exponential solver did settle the equation in 2 ^ (x sin(x)) and it was the inversion
of x * sin(x) that had nothing to say:

"2 ^ (x sin(x)) + 4 ^ (x sin(x)) + c".ToEntity().SolveEquation("x")
    was  {  }
    is   { x : sin(x) * x = ln(((-1 - sqrt(1 - 4 * c)) / 2) ^ (1 / ln(2)))
               or sin(x) * x = ln(((-1 + sqrt(1 - 4 * c)) / 2) ^ (1 / ln(2))) }

Why a ConditionalSet, and what was rejected

Chosen: the unsolved equation as a ConditionalSet. It is already this codebase's spelling for
"I did not settle this" in the solver itself —
#278 (a = b answered { x : a - b = 0 })
and #1025/#964
(UnsolvedWhereIndependenceIsDenied). It needs no new type, no new mechanism, and no change to
Solve's return type. It is not merely a marker: it names the solution set, so a caller can
substitute into it, Filter it, unite it with what was found, or ask TryContains — all of which
this change exercises. And it composes: a product one of whose factors was solved comes back as
{ 1 } \/ { x : ... }, keeping the root that was found.

Rejected: a BudgetOutcome-carrying result (#1035's machinery, which merged today). That layer
answers which resource ran out where, and nothing here ran out of a resource — the solvers simply
have no method for these equations. Attaching a budget outcome to "no algorithm applies" would say
something false about why, and would put a second meaning into a type that has one. #1035 stays the
mechanism for the sites that really are resource limits, which is most of the remaining ~66.

Rejected: something on the side a caller opts into — a TrySolve overload, an out-parameter, an
ambient recording scope. All of them leave the default answer wrong, which is the whole complaint.
The rule at the top of AGENTS.md is that a published API returning the wrong answer is a bug with
users, not an asset to preserve.

Rejected: leaving the empty set and documenting it. It is not underspecified, it is false.

The conjunction, which had to be handled

Once an unsettled equation is a ConditionalSet, and reaches
SetOperators.IntersectFiniteSetAndSet, which keeps an element whose membership in the other operand
could not be decided:

"{ 1, 2 } /\ { t : t > y }".ToEntity().InnerSimplified        { 1, 2 } \/ {  }      on cd0b56b9

That reading is older than this branch and reachable without it, but through Solve it would have
turned one false claim into another — x^6 + x*y + 1 = 0 and x - 1 = 0 becoming { 1 }, when 1
solves the first only at y = -2. So StatementSolver answers a conjunction with an unsettled side
as the conjunction:

"x6 + x y + 1 = 0 and x - 1 = 0".ToEntity().Solve("x")
    was  {  }
    is   { x : x ^ 6 + x * y + 1 = 0 and x - 1 = 0 }

Two settled sides still intersect ((x - 3)(x - 6) = 0 and (x - 3)(x - 7) = 0 is { 3 }), a
disjunction is still a union and keeps the root it had, and implies gets strictly more precise
({ 1 } \/ BB becomes { 1 } \/ (BB \ { x : ... })). The underlying
IntersectFiniteSetAndSet reading is left alone and reported on #1036 — it is the same confusion in
a shared set operator, and fixing it there is a change with repository-wide blast radius that wants
its own PR.

Public-API blast radius

No signature changes. Solve, SolveEquation and MathS.SolveEquation have always been typed
Set and have always been able to return an Interval, a ConditionalSet or a union; what changes
is how often they do. What breaks is code that assumed a FiniteSet:

var answer = equation.Solve(x);
if (answer is FiniteSet roots)      { /* these are all of them */ }
else if (answer.IsSetEmpty)         { /* shown to have none */ }
else                                { /* not settled, or not finite */ }

Checked and unaffected: the system solver (EquationSolver.InSolveSystem already tests
is not FiniteSet and moves on — MathS.Equations("x + y - 3", "x - y - 1").Solve("x", "y") is
[[2, 1]] on both builds, and so is the six-root system over x6 + x y + 1); the inequality
solvers; Entity.Solve's InnerSimplified, since ConditionalSet.InnerSimplify does not call back
into Solve; the F# wrapper (solutions returns Entity.Set, 134 tests pass); and all four
SampleNet5 Solve lines, byte-identical on both builds.

Two XML doc examples printed { } for an equation the solver gives up on and are corrected here
(Entity.Solve's own <example>, and MathS.Settings.AllowNewton's).

BREAKING-CHANGES.md carries all of it under Unreleased, with three rows in the at-a-glance table.

The tests, and the four that recorded the old answer

Sources/Tests/UnitTests/Algebra/SolveTest/UnsolvedEquationTest.cs, 25 tests. Built on the merge
base they fail 11 of 19 (before the conjunction ones were added), and the eight that pass there are
the controls: the impossible equations stay empty, the solvable ones stay solved, Newton still
answers.

Four theory rows asserted rootCount: 0 for equations the solver gives up on:

  • SolveOneEquation.TestExponentialSolver("2 ^ (x sin(x)) + 4 ^ (x sin(x)) + c", 0)
  • SolveOneEquation.FractionedPoly("x + sqrt(x^0.1 + a) + c", 0)
  • SolveOneEquation.FractionedPoly("(x + 6)^(1/6) + x + x3 + a", 0)
  • SolveOneEquation.FractionedPoly("sqrt(x + 1) + sqrt(x + 2) + a + x", 0)

Each of these equations has roots for suitable parameter values, so rootCount: 0 was pinning the
wrong answer. They move into the new file with the honest assertion — that the answer is the
condition and is not empty — rather than having their assertion loosened.

The new file takes the default 2019-2022 header: Tests/UnitTests/Algebra/SolveTest/ has no
directory-wide scope in Sources/.editorconfig, and that file has been a conflict magnet today, so
it is left untouched.

Measured

Suite — 8091 passed, 0 failed, 14 skipped (8105 total) against 8070 / 0 / 14 (8084) on the merge
base: 25 added, 4 removed rows. F# wrapper 134 passed, 0 failed. Every value in the tables above
measured on a build of each side.

Cost — three arms in one process, each an AssemblyLoadContext over its own AngouriMath.dll:
the merge base, this branch, and a byte-identical second copy of the merge base as the noise floor.
Ten equations that solve, three that do not, twenty repetitions, interleaved, third round reported;
three runs:

arm solves, ms solves, bytes gives up, ms gives up, bytes
merge base 184.8 / 210.8 / 183.5 300,852,373 / 300,840,202 / 300,852,117 3.64 / 3.83 / 3.89 10,102,074 / 10,102,458 / 10,102,074
this branch 188.0 / 200.2 / 187.4 301,209,973 / 301,209,973 / 301,222,762 4.21 / 4.22 / 4.13 10,170,858
control (identical to merge base) 189.2 / 186.2 / 186.4 301,208,309 / 301,209,717 / 301,220,842 4.26 / 4.27 / 4.01 10,166,186

Read against the control, not against the merge base: two byte-identical builds differ by up to 370
KB of allocation and 8% of wall clock depending on load position, and this branch sits inside that
on both arms — it is faster than the control on one run of each and slower on the others. On the
give-up arm it allocates 4,672 bytes more than the control on 10.1 MB (+0.046%), which is the
ConditionalSet it now builds. The common case is untouched by construction: an equation that
solves never reaches the changed lines.

The CI performance gate is safe — all five of CommonFunctionsInterVersion's Solve* benchmarks
return the same FiniteSet on both builds.

What this deliberately does not do

  • The other ~66 sites. Step 1 only.
  • InvertNode's empty returns, which produce an empty answer at
    AnalyticalEquationSolver.cs:157 and :184 and are genuinely mixed. Measured, three cases:
    abs(x) = -1 is empty because Invert produced a candidate carrying provided -1 >= 0, which is
    a proof; e^x = 0 is empty because Invert produced ln(0) and dropped it as non-finite, also a
    proof; x! - 6 = 0 is empty because Factorialf.InvertNode returns nothing at all, which is a
    give-up — and 3 is a root. Modf, Summationf, Productf, Limitf and Derivativef decline
    the same way, two of them with a comment saying so. Separating these means giving InvertNode a
    way to say "declined" across ~30 overrides, and it changes what Invert's five callers read; it
    belongs to Giving up on a resource is spelled the same as a mathematical negative #1036's step 5 rather than to this one, and x! - 6 = 0 is still { } after this PR.
  • Functions/Continuous/Limits/Solvers/Solvers.Definition.cs:346. Reached only where both
    one-sided descents returned a value; every limit probed that reaches it (1/x, abs(x)/x,
    sin(1/x) at 0) is one that genuinely does not exist, so nothing here demonstrates it live.
    Making it honest means separating a descent's NaN from a proof throughout the limit pipeline,
    which is step 2's ledger.
  • SetOperators.IntersectFiniteSetAndSet, described above.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd

…r 4)

`Solve` returned an empty `FiniteSet` both for an equation shown to have no roots
and for one every solver declined. The empty set is a positive claim, so the
second was a wrong answer: `x^6 + x*y + 1 = 0` has six roots for every `y` and
came back as `{ }`.

The two exits that mean "nothing settled this" now answer with the equation as a
set builder — the spelling `AnalyticalEquationSolver` already uses for #278 and
#964 — while an equation whose emptiness was established keeps `{ }`. Newton's
method is included: a search from finitely many starting points inside a bounded
region finding nothing is a fact about the search.

A conjunction with an unsettled side is answered as the conjunction. Intersecting
a finite set with a condition keeps the elements whose membership could not be
decided, so `x^6 + x*y + 1 = 0 and x - 1 = 0` would have become `{ 1 }` — one
false claim in place of another, since 1 solves the first only at `y = -2`.

Four theory rows recorded the old answer as correct. Each pinned an equation the
solver gives up on and that has roots, so they move to the new test file with the
honest assertion rather than being loosened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd
Both sides only add to BREAKING-CHANGES.md's Unreleased section; the resolution keeps both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd
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.

Giving up on a resource is spelled the same as a mathematical negative

1 participant