diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index 4d2df3baf..45505f9fb 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -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 @@ -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 diff --git a/Sources/AngouriMath/Docs/WhatsNew/version_performance_control.md b/Sources/AngouriMath/Docs/WhatsNew/version_performance_control.md index daa5c3747..73dac6f1b 100644 --- a/Sources/AngouriMath/Docs/WhatsNew/version_performance_control.md +++ b/Sources/AngouriMath/Docs/WhatsNew/version_performance_control.md @@ -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`, diff --git a/Sources/AngouriMath/Functions/Algebra/Polynomials/PolynomialFactoring.cs b/Sources/AngouriMath/Functions/Algebra/Polynomials/PolynomialFactoring.cs index a329b8ecb..b60a05151 100644 --- a/Sources/AngouriMath/Functions/Algebra/Polynomials/PolynomialFactoring.cs +++ b/Sources/AngouriMath/Functions/Algebra/Polynomials/PolynomialFactoring.cs @@ -314,6 +314,15 @@ internal static bool TryGetRationalCoefficients( out ERational[] coefficients) { coefficients = System.Array.Empty(); + // 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( Sumf.LinearChildren(expr.Expand()), x); if (monomials is null || monomials.Count < leastTerms) @@ -340,6 +349,46 @@ internal static bool TryGetRationalCoefficients( return true; } + /// + /// Whether has the shape of a polynomial in + /// whose coefficients could all be rational: occurs only under + /// sums, products, quotients by something free of it, and whole non-negative powers, + /// and no subtree free of carries a symbol or a constant. A + /// necessary condition, read off the tree without building anything; what passes it + /// is still expanded and checked. + /// + /// + /// 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 + /// 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 sqrt(2) is not refused here, since it is a power of + /// a rational and reads as one; the expansion still decides it. + /// + 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 + }; + } + /// /// Every p/q that could be a root, by the rational root theorem: p divides the /// constant term and q the leading one. diff --git a/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/AnalyticalEquationSolver.cs b/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/AnalyticalEquationSolver.cs index 3718b4762..95ceed033 100644 --- a/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/AnalyticalEquationSolver.cs +++ b/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/AnalyticalEquationSolver.cs @@ -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)) @@ -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; // // // } @@ -349,6 +357,28 @@ bool ProductIsDefinedAt(Entity root) return Unsolved(expr, x); } + /// + /// The spellings of an equation the replacement machinery tries, in order: the + /// equation as it stands, and then every alternative the simplifier can offer. + /// + /// + /// The alternatives are built only if they are reached. + /// is a whole level-4 simplification of the equation -- the same search + /// 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 + /// + private static IEnumerable SpellingsToSolveOver(Entity expr) + { + yield return expr; + foreach (var alt in expr.Alternate(4)) + if (alt != expr) + yield return alt; + } + /// /// The answer when every solver above has declined: the equation itself, as the set /// of the that satisfy it. diff --git a/Sources/Tests/DotnetBenchmark/performance-baseline.json b/Sources/Tests/DotnetBenchmark/performance-baseline.json index 86462063d..26b22b430 100644 --- a/Sources/Tests/DotnetBenchmark/performance-baseline.json +++ b/Sources/Tests/DotnetBenchmark/performance-baseline.json @@ -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": { diff --git a/Sources/Tests/UnitTests/Algebra/SolveTest/SolveOneEquation.cs b/Sources/Tests/UnitTests/Algebra/SolveTest/SolveOneEquation.cs index 976788849..ee8ae68aa 100644 --- a/Sources/Tests/UnitTests/Algebra/SolveTest/SolveOneEquation.cs +++ b/Sources/Tests/UnitTests/Algebra/SolveTest/SolveOneEquation.cs @@ -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);