Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions BREAKING-CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ read first.
| | `"abs(i * x)".Simplify()`, and every modulus of a numeric multiple with an exact modulus off the real line | `abs(i * x)` — left as written | `abs(x)` |
| **Silent** | `"0 * (i * +oo)".Evaled`, and every whole zero times a complex infinity | `0` | `NaN` |
| | `RewriteRules.Common.Rules.Count`, and `RewriteRules.All` by growth | `62`; 124 / 46 / 31 / 123 | `65`; 124 / 49 / 31 / 123 |
| | `"(x - b) / (x + a) + c / (x + a)".SolveEquation("x")`, and every equation the replacement machinery solves whose condition is spelled from the equation as written | `{ -(-b + c) provided not a + -(-b + c) = 0 }` | `{ -(-b + c) provided not -(-b + c) + a = 0 }` — the same set, the condition's terms in the order the equation had |

### A cancelled quotient says its operand is defined, not only non-zero

Expand Down Expand Up @@ -1003,6 +1004,33 @@ machinery it is kept, because it is what stops the search. Both columns measured
| `RewriteRules.All` by growth — collects / rearranges / expands / unknown | 124 / 46 / 31 / 123 | 124 / 49 / 31 / 123 |
| `Saturation.SafeRules.Count` | `170` | `173` |

### The solver tries the equation as written before asking for its alternatives

Two changes inside `SolveEquation`, made for speed and measured for answers. The replacement
machinery — solve for a subtree, then solve the subtree for `x` — used to ask
`Entity.Alternate(4)` for every spelling of the equation before trying any of them, at every
depth of its own recursion; that is a whole level-4 simplification, the same search `Simplify`
runs, and in the ordinary case the first spelling it produced was the equation itself. It now
tries the equation as it stands first and asks for the alternatives only if that does not settle
it; a spelling that comes back as a condition rather than a finite set is kept as a fallback and
the next spelling is tried, which is what keeps `x^4 * x^y - 2` answered with a root. And the
two polynomial-factoring attempts every equation is offered — a rational root to split off, an
irreducible factorisation — read the tree before they expand it: a sine is not a monomial, and
`cos(x) + sin(x) - r` with `r` a page of radicals was being expanded twice at each of eighteen
visits to be told so.

Every solve in the suite answers as before but one, whose condition is spelled from the equation
as written rather than from its resimplified form — the same set with its terms in the other
order. Measured by the gate on one machine, bytes and time per call:

| | Was | Is |
|---|---|---|
| `"(x - b) / (x + a) + c / (x + a)".SolveEquation("x")` | `{ -(-b + c) provided not a + -(-b + c) = 0 }` | `{ -(-b + c) provided not -(-b + c) + a = 0 }` |
| `SolveHard` — the benchmark's quartic in `sin(cos(x) + sin(x) + c)` | 859,268,416 B, 700 ms | 11,818,936 B, 109 ms |
| `SolveMediumHard` | 94,415,256 B, 66.8 ms | 1,435,934 B, 13.9 ms |
| `SolveMedium`, `SolveEasyMedium` | 662,923 B, 456 µs; 97,335 B, 29.7 µs | 452,901 B, 387 µs; 65,232 B, 19.2 µs |
| `SimplifyHard`, `SimplifyEasy` — the same pre-check, reached through `Simplify`'s factoring candidates | 733,188,536 B, 371 ms; 116,291 B, 71.2 µs | 680,457,400 B, 339 ms; 110,051 B, 66.4 µs |

## 2.4.0 — since 2.3.0

Released 2026-08-28. These entries sat under “Unreleased” while 2.4.0 was tagged and
Expand Down
36 changes: 36 additions & 0 deletions Sources/AngouriMath/Docs/WhatsNew/version_performance_control.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,42 @@ What is left of a `SimplifyHard` level, for whoever comes next: the remaining re
factorisation at level 2, and the opened angles expanded — at 177, 170 and 82 MB, each a
recursive simplification of an expanded tree.

## The 1935th: the solver tries the equation as written

The same hook, on `SolveHard` and attributed to the stages of `AnalyticalEquationSolver.Solve`
per recursion depth. Two things were done before any solving. The replacement machinery asked
`Entity.Alternate(4)` — a whole level-4 simplification of the equation, the search `Simplify`
runs — for every spelling before trying any, at every depth of its own recursion, and in the
ordinary case solved the first spelling it got, which was the equation itself: 268 MB of the
top-level call, 103 MB at the next depth, 20 MB below that. And the two polynomial-factoring
attempts every equation is offered — a rational root to split off, an irreducible factorisation —
each expanded the whole equation, a page of radicals included, to learn that a sine is not a
monomial: 3 MB a time, twice per equation visited, eighteen visits at one depth, 268 MB in all.

The loop now yields the equation as it stands and builds the alternatives only if it gets past
that — keeping a spelling that came back as a condition as a fallback while the next is tried —
and the coefficient extraction reads the tree before it expands anything.

| benchmark | 1933rd | 1935th | allocation | time |
|---|--:|--:|--:|--:|
| `SolveHard` | 859,268,416 | **11,818,936** | **−98.6%** | 700 → 109 ms |
| `SolveMediumHard` | 94,415,256 | **1,435,934** | **−98.5%** | 66.8 → 13.9 ms |
| `SolveMedium` | 662,923 | 452,901 | −31.7% | 456 → 387 µs |
| `SolveEasyMedium` | 97,335 | 65,232 | −33.0% | 29.7 → 19.2 µs |
| `SimplifyHard` | 733,188,536 | 680,457,400 | −7.2% | 371 → 339 ms |
| `SimplifyEasy` | 116,291 | 110,051 | −5.4% | 71.2 → 66.4 µs |

Bytes allocated per call, same machine, both columns measured by the gate in one session; every
other entry within 0.1%. The `Simplify` rows move through the same pre-check, which `Simplify`'s
own factoring candidates reach. Since the 1930th, this morning's column: `SolveHard` **−99.2%**,
`SolveMediumHard` −99.1%, `SimplifyHard` −81%. Timings carry the usual rider. The gate's baseline
was taken from this run.

One answer changes its spelling and nothing else moves: the condition on
`(x - b)/(x + a) + c/(x + a)`'s root has its terms in the order the equation had, since the
equation is solved as written rather than resimplified first. The `SolveHard` and
`SolveMediumHard` answers are the same size as before, 149,153 and 37,073 nodes.

## The 1933rd: a candidate registered once

The remaining registration, split by stage with the same hook: at level 4 on `SimplifyHard`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,15 @@ internal static bool TryGetRationalCoefficients(
out ERational[] coefficients)
{
coefficients = System.Array.Empty<ERational>();
// Read off the tree before anything is expanded. The expansion below is the cost
// of this whole method, and it was paid in full for an equation this cannot
// accept: the solver asks twice per equation it visits, on the way down through
// every replacement, and cos(x) + sin(x) - r with r a page of radicals was
// expanded twice at each of eighteen visits to be told that a sine is not a
// monomial. A third of SolveHard's allocation was that.
// https://github.com/asc-community/AngouriMath/issues/746
if (!MayBeAPolynomialWithRationalCoefficients(expr, x))
return false;
var monomials = PolynomialSolver.GatherMonomialInformation<EInteger, TreeAnalyzer.PrimitiveInteger>(
Sumf.LinearChildren(expr.Expand()), x);
if (monomials is null || monomials.Count < leastTerms)
Expand All @@ -340,6 +349,46 @@ internal static bool TryGetRationalCoefficients(
return true;
}

/// <summary>
/// Whether <paramref name="expr"/> has the shape of a polynomial in <paramref name="x"/>
/// whose coefficients could all be rational: <paramref name="x"/> occurs only under
/// sums, products, quotients by something free of it, and whole non-negative powers,
/// and no subtree free of <paramref name="x"/> carries a symbol or a constant. A
/// necessary condition, read off the tree without building anything; what passes it
/// is still expanded and checked.
/// </summary>
/// <remarks>
/// A symbol in a coefficient is refused outright rather than expanded and evaluated,
/// because a coefficient with a symbol in it is not rational, and the one way it could
/// still be -- the symbol cancelling against itself across terms -- is what
/// <see cref="Entity.InnerSimplified"/> has already collected before anything here is
/// asked. A constant is refused for the same reason: pi is not rational either. An
/// irrational literal such as <c>sqrt(2)</c> is not refused here, since it is a power of
/// a rational and reads as one; the expansion still decides it.
/// </remarks>
private static bool MayBeAPolynomialWithRationalCoefficients(Entity expr, Variable x)
{
if (!expr.ContainsNode(x))
return expr.VarsAndConsts.Count == 0;
return expr switch
{
Variable => true,
Sumf(var augend, var addend)
=> MayBeAPolynomialWithRationalCoefficients(augend, x) && MayBeAPolynomialWithRationalCoefficients(addend, x),
Minusf(var minuend, var subtrahend)
=> MayBeAPolynomialWithRationalCoefficients(minuend, x) && MayBeAPolynomialWithRationalCoefficients(subtrahend, x),
Mulf(var multiplier, var multiplicand)
=> MayBeAPolynomialWithRationalCoefficients(multiplier, x) && MayBeAPolynomialWithRationalCoefficients(multiplicand, x),
Divf(var dividend, var divisor)
=> !divisor.ContainsNode(x)
&& MayBeAPolynomialWithRationalCoefficients(dividend, x)
&& MayBeAPolynomialWithRationalCoefficients(divisor, x),
Powf(var @base, Integer { IsNegative: false })
=> MayBeAPolynomialWithRationalCoefficients(@base, x),
_ => false
};
}

/// <summary>
/// Every p/q that could be a root, by the rational root theorem: p divides the
/// constant term and q the leading one.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,8 @@ bool ProductIsDefinedAt(Entity root)
{
var newVar = Variable.CreateTemp(expr.Vars);
// Here we find all possible replacements and find one that has at least one solution
foreach (var alt in expr.Alternate(4))
Set? unsettled = null;
foreach (var alt in SpellingsToSolveOver(expr))
{
MultithreadingFunctional.ExitIfCancelled();
if (!alt.ContainsNode(x))
Expand All @@ -276,9 +277,16 @@ bool ProductIsDefinedAt(Entity root)
if (solutions is FiniteSet els)
return els.Select(ent => TryDowncast(expr, x, ent)).ToSet();
else if (solutions is Set { IsSetEmpty: false } set)
return set;
// A set that is not finite is an answer nothing settled -- a
// condition, or a union with one in it -- and another spelling
// may settle it: x^4 * x^y - 2 as written comes back as a
// condition, and the spelling x^(4 + y) - 2 comes back as a root.
// Kept in case no spelling does better.
unsettled ??= set;
}
}
if (unsettled is { } condition)
return condition;
// // //
}

Expand Down Expand Up @@ -349,6 +357,28 @@ bool ProductIsDefinedAt(Entity root)
return Unsolved(expr, x);
}

/// <summary>
/// The spellings of an equation the replacement machinery tries, in order: the
/// equation as it stands, and then every alternative the simplifier can offer.
/// </summary>
/// <remarks>
/// The alternatives are built only if they are reached. <see cref="Entity.Alternate"/>
/// is a whole level-4 simplification of the equation -- the same search
/// <see cref="Entity.Simplify(int)"/> runs, sorted -- and the replacement loop asked
/// for it before trying anything, at every depth of its own recursion, when in the
/// ordinary case the equation as written is the spelling that solves. On SolveHard
/// that search was 268 MB of the top-level call and a third of each level below it,
/// to produce a first candidate the loop then solved and never looked past.
/// https://github.com/asc-community/AngouriMath/issues/746
/// </remarks>
private static IEnumerable<Entity> SpellingsToSolveOver(Entity expr)
{
yield return expr;
foreach (var alt in expr.Alternate(4))
if (alt != expr)
yield return alt;
}

/// <summary>
/// The answer when every solver above has declined: the equation itself, as the set
/// of the <paramref name="x"/> that satisfy it.
Expand Down
56 changes: 28 additions & 28 deletions Sources/Tests/DotnetBenchmark/performance-baseline.json
Original file line number Diff line number Diff line change
@@ -1,82 +1,82 @@
{
"comment": "Allocated bytes and mean nanoseconds per operation for the popular use cases of https://github.com/asc-community/AngouriMath/issues/746. Checked by PerformanceGate.cs; see Sources/AngouriMath/Docs/WhatsNew/version_performance_control.md for when updating it is legitimate.",
"benchmark": "CommonFunctionsInterVersion",
"commit": "e45413c2cbdd8ae7e2d2cbf64a16a7118924a67d",
"commit": "28dcb9917302396c30ddd3d0c96b71954cbef7fb",
"measuredOn": "2026-09-07",
"runtime": ".NET 10.0.10",
"machine": "Ubuntu 26.04 LTS, X64, 8 logical cores",
"cases": {
"CompileEasy": {
"allocatedBytes": 11003,
"meanNanoseconds": 190731.167578125
"meanNanoseconds": 189915.3998674665
},
"CompileHard": {
"allocatedBytes": 20434,
"meanNanoseconds": 318304.1868815104
"allocatedBytes": 20433,
"meanNanoseconds": 317361.80247395835
},
"Derivate": {
"allocatedBytes": 52823,
"meanNanoseconds": 13948.127454317533
"meanNanoseconds": 13861.021153041294
},
"EvalEasy": {
"allocatedBytes": 0,
"meanNanoseconds": 1.903860840946436
"meanNanoseconds": 1.8879165711502235
},
"EvalTrig": {
"allocatedBytes": 1341377,
"meanNanoseconds": 710293.4863978794
"meanNanoseconds": 711527.9987792969
},
"EvalTrigPrecise": {
"allocatedBytes": 12742205,
"meanNanoseconds": 22850819.927083332
"meanNanoseconds": 22582372.584134616
},
"ParseEasy": {
"allocatedBytes": 18125,
"meanNanoseconds": 5922.934341430664
"meanNanoseconds": 5814.182232157389
},
"ParseHard": {
"allocatedBytes": 3531201,
"meanNanoseconds": 1393710.7973958333
"allocatedBytes": 3531241,
"meanNanoseconds": 1385166.6953125
},
"RunEasy": {
"allocatedBytes": 0,
"meanNanoseconds": 20.149500537377136
"meanNanoseconds": 20.092127207914988
},
"RunHard": {
"allocatedBytes": 0,
"meanNanoseconds": 295.372756921328
"meanNanoseconds": 292.54727721214294
},
"RunMedium": {
"allocatedBytes": 0,
"meanNanoseconds": 163.79638368288676
"meanNanoseconds": 174.00879979133606
},
"SimplifyEasy": {
"allocatedBytes": 116291,
"meanNanoseconds": 71181.0720296224
"allocatedBytes": 110051,
"meanNanoseconds": 66419.67370605469
},
"SimplifyHard": {
"allocatedBytes": 733188536,
"meanNanoseconds": 370647283.9444444
"allocatedBytes": 680457400,
"meanNanoseconds": 339196117.2307692
},
"SolveEasy": {
"allocatedBytes": 8853834,
"meanNanoseconds": 4886113.9921875
"allocatedBytes": 8864440,
"meanNanoseconds": 4847439.122767857
},
"SolveEasyMedium": {
"allocatedBytes": 97335,
"meanNanoseconds": 29743.26444498698
"allocatedBytes": 65232,
"meanNanoseconds": 19175.254420689173
},
"SolveHard": {
"allocatedBytes": 859268416,
"meanNanoseconds": 699574551.2666667
"allocatedBytes": 11818936,
"meanNanoseconds": 108568157.4
},
"SolveMedium": {
"allocatedBytes": 662923,
"meanNanoseconds": 455894.9962890625
"allocatedBytes": 452901,
"meanNanoseconds": 387163.33642578125
},
"SolveMediumHard": {
"allocatedBytes": 94415256,
"meanNanoseconds": 66756986.75
"allocatedBytes": 1435934,
"meanNanoseconds": 13918494.591517856
}
},
"ungated": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,10 @@ public void InvertedFunctions(string func, int rootAmount)
// Both operands share the denominator, so they now add as (x - b + c) / (x + a)
// rather than being cross-multiplied; the root is the same b - c, reached with its
// terms in the other order.
[InlineData("(x - b) / (x + a) + c / (x + a)", 1, "{ -(-b + c) provided not a + -(-b + c) = 0 }")]
// The condition's terms come in the order the equation was solved in: as written,
// since the replacement machinery no longer resimplifies an equation before it
// solves it (see SpellingsToSolveOver).
[InlineData("(x - b) / (x + a) + c / (x + a)", 1, "{ -(-b + c) provided not -(-b + c) + a = 0 }")]
[InlineData("(x - b) / (x + a) + c / (x + a)2", 2, "{ (-(-b + a) - sqrt((-b + a) ^ 2 - 4 * (a * -b + c))) / 2 provided not (-(-b + a) - sqrt((-b + a) ^ 2 - 4 * (a * -b + c))) / 2 + a = 0, (-(-b + a) + sqrt((-b + a) ^ 2 - 4 * (a * -b + c))) / 2 provided not (-(-b + a) + sqrt((-b + a) ^ 2 - 4 * (a * -b + c))) / 2 + a = 0 }")]
[InlineData("(x - b) / (x + a) + c + (x - c) / (x + d)", 2, null)]
public void CDSolver(string expr, int rootCount, string? verifyRoots) => TestSolver(expr, rootCount, verifyRoots: verifyRoots);
Expand Down
Loading