From b8835bdbaa00239755bc7ec197864a1f8e5a844c Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Sun, 6 Sep 2026 09:41:06 +0000 Subject: [PATCH] 2.4.0's breaking changes are under a 2.4.0 heading v2.4.0 was tagged at de7189b1 and published on 2026-08-28, and everything it shipped stayed under "Unreleased -- since 2.3.0". Seventy commits then landed in the same section. A reader on 2.4.0 could not tell from this file what they had received, and the next release would have inherited the same section. Split at the tag rather than from memory. An entry is under 2.4.0 if it was in this file at v2.4.0 and under Unreleased if it was added after -- which is decidable rather than a judgement, and is why this was worth doing mechanically: the entries are interleaved, not appended, so reading them and sorting by recollection would have misattributed some. 26 subsections and 47 at-a-glance rows to 2.4.0 21 subsections and 49 at-a-glance rows to Unreleased -- since 2.4.0 Verified by sorting both versions of the file and diffing: the only lines that differ are the two headings, the note under 2.4.0, and the duplicated table header. Nothing was dropped, reworded or merged. The subsection count went from 157 to 158, the one addition being the second "At a glance". Sources/Package.Build.props still reads 2.3.0 and is left alone deliberately. The comment above it calls it "the version under development", the NuGet workflow takes the real version from the tag name, and what comes after 2.4.0 is a question for #746 rather than bookkeeping. Flagged rather than guessed. Full suite 9564 passed, 0 failed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura --- BREAKING-CHANGES.md | 484 ++++++++++++++++++++++---------------------- 1 file changed, 247 insertions(+), 237 deletions(-) diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index c4143531a..1bae51e2b 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -15,17 +15,12 @@ read first. --- -## Unreleased — since 2.3.0 +## Unreleased — since 2.4.0 ### At a glance | Silent? | What | Was | Is | |---|---|---|---| -| **Silent** | `"limit(t * b, t, 0)".ToEntity().FreeVariables`, and every limit | `{ t, b }` — the variable it approaches along counted as free | `{ b }` | -| **Silent** | `MathS.Polynomials.Factor("4 * x2 - 4 * y2", "x")`, and every multivariate polynomial whose content is a bare constant | `(x + y) * (x - y)` — **not equal to what it factored** | `4 * (x + y) * (x - y)` | -| | `"2 * x3 - 2".ToEntity().Factorize()`, and every polynomial whose content the rules take out | `2 * (x ^ 3 - 1)` — the remainder left whole | `2 * (x - 1) * (x ^ 2 + x + 1)` | -| | `"3^(x+1) - 2^(x-1)".ToEntity().SolveEquation("x")`, and every equation between two powers of numeric bases | `{ ln(0.04674569822628630438865471319331845734268426895141601562 ^ (1 / ln(2))) }` — a `double` promoted to a decimal | `{ -(ln(3) + ln(2)) / (ln(3) + -ln(2)) }` | -| **Silent** | `MathS.Abs("x").WithCodomain(Domain.Any).Stringize()`, and every node widened to `Any` from a narrower default | `abs(x)` — reads back as `Real`, losing the widening | `domain(abs(x), Any)` | | **Silent** | `"domain(1/2, CC)".ToEntity()`, and every quotient of two integer literals annotated with the codomain its node type does not default to | `1/2` — the annotation dropped, equal to the unannotated literal | `1/2` carrying `Codomain = Complex`, which prints and reads back as `domain(1/2, CC)` | | | `"a in (a / 3; 3a)".ToEntity().Simplify()`, and every denominator but 2 | `a in (a / 3; 3 * a)` — left as written | `a > 0` | | | `MathS.Matrix(...).Determinant` on a symbolic matrix of polynomials | `a * d + -b * c`, Laplace's nested expansion | `a * d - b * c`, expanded — the same value, no larger | @@ -33,57 +28,15 @@ read first. | **Silent** | `MathS.Equations(...).Solve(...)` on a system neither internal path can finish | ran without a bound — cyclic-6 exceeded 20 s | `NotSufficientlySupportedException`, naming both paths | | | the same with `MathS.Settings.Budget` set below what the solve needs | answered anyway, the fall-through having no budget | raises | | | `"a in (a / 2; 0)".ToEntity().Simplify()`, and every interval demanding both signs | left as written | `False provided a in RR` | -| | `new Entity[0].SumAll()`, and `Sumf.Sum` on an empty list | `AngouriBugException: At least 1 child required` | `0` | -| | `new Entity[0].MultiplyAll()`, and `Mulf.Multiply` on an empty list | `AngouriBugException` | `1` | -| | `MathS.Vector()` and `new Entity[0].ToVector()` | `IndexOutOfRangeException` — outside the documented hierarchy | `InvalidMatrixOperationException` | -| | `"x3 - 1".ToEntity().Factorize()`, and every polynomial no rewrite rule has a rule for | `x ^ 3 - 1` — handed back whole | `(x - 1) * (x ^ 2 + x + 1)` | | | `"x^2/(x^4 + 1)".Integrate("x")`, and every quotient whose denominator is a biquadratic irreducible over `Q` | `integral(x ^ 2 / (x ^ 4 + 1), x)` — left unevaluated | the antiderivative, over the real quadratic factors | | | `"sqrt(x)/(1 + x^2)".Integrate("x")`, and every integrand a fractional power of the variable makes rational | `integral(sqrt(x) / (1 + x ^ 2), x)` — left unevaluated | the antiderivative | | | `"sqrt(tan(x))".Integrate("x")`, and every integrand that is a function of `tan(x)` alone and rational in it | `integral(sqrt(tan(x)), x)` — left unevaluated | the antiderivative | | | `"x^2/(x + 1)".Integrate("x")`, and every improper quotient of polynomials | `integral(x ^ 2 / (x + 1), x)` — left unevaluated | `x ^ 2 / 2 + -x + ln(x + 1) + C` | | | `MathS.Equations("2*x - 4*y - 12").Solve("x", "y")`, and every linear system with fewer equations than unknowns | `WrongNumberOfArgumentsException` | `[[6 + 2 * t_1, t_1]]` — the family of all its solutions | -| | `Entity.DomainConditionIn(Domain)` | did not exist | the domain of definition for a **stated** reading, through the whole tree | -| | `MathS.Polynomials.Factor("(y * x3 + 1) * (x4 - y3)", "x")`, and bivariate polynomials whose leading coefficient in the main variable is a polynomial | `null` — a refusal | `(x ^ 4 - y ^ 3) * (x ^ 3 * y + 1)` | -| | `MathS.Polynomials.Factor("x7 - y7", "x")`, and bivariate polynomials whose substituted image over-factors | `null` — a refusal | `(x - y) * (x ^ 6 + x ^ 5 * y + … + y ^ 6)` | -| | `MathS.Polynomials.Factor("x2 + y2 + z2 + w2 + 1", "x")`, and polynomials in enough variables generally | `null` — a refusal | the polynomial itself, meaning it does not factor | -| **Silent** | `"x! = 0".ToEntity().Simplify()` | `False`, including at `x = -1` where `x!` has a pole and the statement is `NaN` | `False provided x in RR and (x >= 0 or not x in ZZ)` | -| **Silent** | `"x! / x!".ToEntity().Simplify()`, and the same for any factorial over itself | `1`, including at `x = -1` where the quotient is `NaN` | `1 provided not x! = 0` | -| **Silent** | `"(y < x) or (x = y)".ToEntity().Simplify()`, and three more disjunctions of a comparison with an equality written the other way round | `x <= y` — False at `x = 3, y = 2` where the input is True | `x >= y` | -| **Silent** | `"x6 + x y + 1 = 0".ToEntity().Solve("x")`, and every equation no solver settles | `{ }` — there are no roots | `{ x : 1 + x ^ 6 + x * y = 0 }` — these are the roots, whichever they are | -| **Silent** | `"(x - 1) * (x6 + x y + 1) = 0".ToEntity().Solve("x")` | `{ 1 }` | `{ 1 } \/ { x : 1 + x ^ 6 + x * y = 0 }` | -| **Silent** | `"x6 + x y + 1 = 0 and x - 1 = 0".ToEntity().Solve("x")` | `{ }` | `{ x : x ^ 6 + x * y + 1 = 0 and x - 1 = 0 }` | -| **Silent** | `"sum(k, k, 1, n)".ToEntity().FreeVariables`, and `product` | `{ k, n }` — the bound index counted as free | `{ n }` | -| **Silent** | `"integral(t * b, t, 0, 1)".ToEntity().FreeVariables`, and every integral with limits | `{ b, t }` | `{ b }` | -| | `"x ^ 3 - x > 0".ToEntity().Solve("x")`, and every polynomial inequality of degree three or more | `NotSufficientlySupportedException: Only linear and quadratic polynomial inequalities are supported` | `(-1; 0) \/ (1; +oo)` — the solution set | -| **Silent** | `"a implies (b implies c)".ToEntity().Stringize()` | `a implies b implies c`, which reads back as `(a implies b) implies c` | `a implies (b implies c)` | -| | `"(a implies b) implies c".ToEntity().Stringize()` | `(a implies b) implies c` | `a implies b implies c` | -| **Silent** | `@"A \ (B \ C)".ToEntity().Stringize()`, and `Latexize` | `A \ B \ C` / `A \setminus B \setminus C` | `A \ (B \ C)` / `A \setminus \left(B \setminus C\right)` | -| **Silent** | `@"A \ (B \/ C)".ToEntity().Stringize()`, and `Latexize` | `A \ B \/ C` / `A \setminus B \cup C` | `A \ (B \/ C)` / `A \setminus \left(B \cup C\right)` | -| **Silent** | `@"A \/ (B \ C)".ToEntity().Stringize()`, and `Latexize` | `A \/ B \ C` / `A \cup B \setminus C` | `A \/ (B \ C)` / `A \cup \left(B \setminus C\right)` | -| **Silent** | `"(x provided p) provided q".ToEntity().Stringize()`, and `Latexize` | `x provided p provided q`, which reads back as `x provided (p provided q)` | `(x provided p) provided q` | -| **Silent** | `"a in (b in c)".ToEntity().Stringize()`, and `Latexize` | `a in b in c` / `a \in b \in c` | `a in (b in c)` / `a \in \left(b \in c\right)` | -| **Silent** | `"x * (y mod z)".ToEntity().Stringize()`, and `Latexize` | `x * y mod z` / `x y \bmod z` | `x * (y mod z)` / `x \left(y \bmod z\right)` | -| **Silent** | `"-1 * (y mod z)".ToEntity().Stringize()` | `-y mod z` | `-(y mod z)` | -| | any expression mixing a number with a `Complex` argument, `Compile`d in a NativeAOT app — `"x + 1".Compile("x")` | `UncompilableNodeException: ... The binary operator Add is not defined for the types 'System.Numerics.Complex' and 'System.Numerics.Complex'` | the compiled function, answering as it does under the JIT | -| | `Compile` to a nullable integral return type in a NativeAOT app | `AngouriBugException: IsNaN method expected for type System.Double`, which took the process down | the compiled function | | **Silent** | `"not (x = 1)".ToEntity().Solve("x")`, and every negation | `{ }` — no value satisfies it | `{ x : not x = 1 }` | | **Silent** | `"not (x > 1)".ToEntity().Solve("x")`, and every negated comparison | `{ }` | `(-oo; 1]` | | **Silent** | `"(x = 1) implies (x = 2)".ToEntity().Solve("x")`, and every implication | `{ 2 } \/ BB` — truth values in the solution set of a numeric question | `{ x : not x = 1 }` | | | `"domain((-oo; +oo), Any) = RR".ToEntity().Solve("x")`, and every unbounded interval widened to `Any` | `NotSufficientlySupportedException: There is no special set for domain Any` | `{ }` | -| **Silent** | an app publishing with `PublishTrimmed` or NativeAOT | `AngouriMath.dll` was copied in whole, being unmarked | it is trimmed with the rest, since the assembly now declares `IsTrimmable` | -| **Silent** | `"domain(x, ZZ)".ToEntity().Stringize()`, and `ToString`, and `EntityJsonConverter` | `x`, which reads back with `Codomain = Any` | `domain(x, ZZ)`, which reads back narrowed | -| **Silent** | `"domain(sqrt(-1), RR)".ToEntity().Stringize()` | `sqrt(-1)`, which evaluates to `i` when read back | `domain(sqrt(-1), RR)`, which evaluates to `NaN` | -| **Silent** | `"domain(x, ZZ)".ToEntity().Latexize()` | `x` | `{\left(x\right)}_{\mathbb{Z}}` | -| **Silent** | `"x - domain(x, ZZ)".ToEntity().Simplify()`, and any sum mixing a node with a narrowed codomain and the same node without | `0` — the two were collected as one monomial | `x - domain(x, ZZ)`, left alone | -| | every node type's own `Stringize()` and `Latexize()` overrides — `Entity.Sumf.Stringize()` and 129 more | declared on each node | declared once on `Entity`; still callable on every node, and an assembly compiled against 2.3.0 keeps working without a rebuild | -| | `"x + 1 // done".ToEntity()`, and any input whose last line ends in a `//` comment | `UnhandledParseException: extraneous input '/'` | `x + 1` — the comment is skipped, as the block form already was | -| | `MathS.Polynomials.Factor("x * y + y", "x")`, and any polynomial whose coefficients in the named variable share a common divisor | `null` — a refusal | `y * (x + 1)` | -| | `MathS.Polynomials.SquareFreePart("(x - y) ^ 2 * (x + y)", "x")`, and any polynomial in more than one variable | `null` — a refusal | `x ^ 2 - y ^ 2` | -| | `MathS.Polynomials.Factor("x ^ 2 - y ^ 2", "x")`, and polynomials in two variables of small enough bidegree | `null` — a refusal | `(x + y) * (x - y)` | -| | a `switch` over `RewriteRuleGrowth` with no default arm | compiled | does not compile — there is a fourth value, `Unknown` | -| **Silent** | `RewriteRules.RationalizeDenominator.Rules` | `[]` — the registry could not read the set | its two rules, addressable and named | -| **Silent** | `RewriteRules.Power.ApplyOnce("ln(1 / x)")`, and every `log(_, 1/_)` and `log(1/_, _)` whose argument is not decidably a positive real | `-ln(x)`, which is wrong on the negative reals | `ln(1 / x)`, left alone | -| **Silent** | `RewriteRules.Boolean.ApplyOnce("a and b or a")`, and two more orientations of absorption | left alone — the arm for that orientation was never written | `a` | | **Silent** | `RewriteRules.DivisionPreparing.Rules[0].Name`, and every rule of the twenty-seven sets now described from their data form | `Mulf(var any1, Divf(Integer(1), var any2))` — the `switch` arm's rendered pattern | `reciprocal-factor-becomes-a-quotient` | | | `RewriteRules.ExpandFactorialDivisions.Rules.Count`, and `FactorizeFactorialMultiplications` | `8` | `3` — the same rewrites, five of the eight arms being one commutative pattern | | | `RewriteRules.Boolean.Rules.Count` | `36` | `20` — a commutative pattern finds a shared operand wherever it sits | @@ -114,9 +67,7 @@ read first. | | `"1/(x^2 + 1)^2".Integrate("x")`, and every proper rational function over a repeated irreducible quadratic | `integral(1 / (1 + x ^ 2) ^ 2, x)` — left unevaluated, after 2.9 s | `arctan(x) / 2 + C + 1/2 * x / (x ^ 2 + 1)`, in 0.11 s | | | `"x^2/(x^2 + 2)^2".Integrate("x")`, and every polynomial numerator over one | `integral(x ^ 2 / (x ^ 2 + 2) ^ 2, x)` — left unevaluated, after 93 s | the antiderivative, in 0.57 s | | | `"1/(x^2 - 1)^2".Integrate("x")`, and every repeated quadratic whose roots are real | `C - ln(x + -1) / 4 + ln(1 + 2 * x + x ^ 2) / 8 + -1/2 * x / (x ^ 2 + -1)` | `C - ln(1 + (-2) / (x + 1)) / 4 + -1/2 * x / (x ^ 2 + -1)` — the same value, one logarithm rather than two | - | **Silent** | `"1/x - 1/x".ToEntity().Simplify()`, and every difference of a term from itself where that term can be undefined | `0`, including at `x = 0` where neither side has a value | `0 provided not x = 0` | - | **Silent** | `"(x + 1)!/(x + 1)!".ToEntity().Simplify()`, and every cancelled quotient whose repeated part can be undefined | `1 provided not (1 + x)! = 0` | `1 provided 1 + x in RR and (1 + x >= 0 or not 1 + x in ZZ)` — the same value everywhere, a condition that says why | | | `"ln(x)/ln(x)".ToEntity().Simplify()`, and every quotient divided out by a divisor that can be undefined | `1 provided not ln(x) = 0` — which is `1` at `x = 0`, where the quotient has no value | `1 provided not ln(x) = 0 and not x = 0` | @@ -727,6 +678,252 @@ caller can write ([#996](https://github.com/asc-community/AngouriMath/issues/996 "domain((-oo; +oo), CC)".ToEntity().Simplify() CC unchanged ``` +### An annotated rational literal keeps the annotation, `CC` included + +A codomain is a property of a node rather than a node of its own, so `domain(1/2, CC)` has to become +one `Rational` carrying `Complex` — and it did not. The pass that reads a quotient of two integer +literals as the rational it denotes ([#873](https://github.com/asc-community/AngouriMath/issues/873)) +ran over the **finished** tree, by which point `Complex` means two things at once: it is what an +unannotated `Divf` carries by default, and it is what `domain(x, CC)` asks for. The pass read it as +the first and dropped it. + +| | before | now | +|---|---|---| +| `"domain(1/2, CC)".ToEntity().Codomain` | `Rational` — the annotation gone | `Complex` | +| `"domain(1/2, CC)".ToEntity() == "1/2".ToEntity()` | `true` | `false` | +| `(1/2).WithCodomain(Complex).Stringize().ToEntity()` | `1/2`, `Rational` — printed one node, read back another | round-trips | +| `"domain(1/2, RR)".ToEntity()` | `Real`, correct | unchanged | +| `"1/2".ToEntity()` | a `Rational`, `Codomain = Rational` | unchanged | +| `"domain(4/2, CC)".ToEntity()` | a `Divf`, `Codomain = Complex` | unchanged | +| `"domain(1/2, CC)".ToEntity().Evaled` | `1/2` — the annotation erased by evaluating | `domain(1/2, CC)`, carried through | +| the same, `.Simplify()` | `1/2` | `domain(1/2, CC)` | +| the same, `.Latexize()` | `\frac{1}{2}` | `{\left(\frac{1}{2}\right)}_{\mathbb{C}}` | +| `"domain(1/2, CC) + 1".ToEntity().Evaled` | `3/2` | unchanged | + +**The fix is where the fold happens, not what it reads.** `domain(...)`'s own parser rule now folds +its argument before annotating it, so the two meanings of `Complex` never meet: an annotation lands +on a node that is already the right shape, and the sweep over the rest of the tree — which is the +only other place the fold runs — can hand back a bare `Rational` and read no codomain at all. It is +sound to do so because those two are the only routes, `domain(...)` being the one syntax that +annotates anything and the sweep meeting only what it did not annotate. + +`Complex` on a rational literal is a **widening** — a rational is a complex number — so nothing that +was defined becomes `NaN` and no value changes: `domain(1/2, CC) + 1` evaluates to `3/2` as before, +and `domain(1/2, ZZ)` is still `NaN`. What changes is equality, and everything that follows from the +node genuinely carrying the annotation now — `Evaled` and `Simplify` hand it back instead of erasing +it, and `Latexize` writes the subscript. That is the point: an annotation the caller wrote is no +longer indistinguishable from one they did not. + +This was the second of the two gaps +[#1048](https://github.com/asc-community/AngouriMath/issues/1048) recorded, and the last entry in +`CodomainSurvivesPrintingTest.StillUnparseable`. That dictionary is now empty and still asserted in +both directions, so every writable domain on every node type reads back as itself, and a gap added +to it later fails the day it closes. + +### An interval bounded by its own element is decided, and `Common` terminates + +`a in (a / 2; 2 * a)` was `a > 0` and `a in (a / 3; 3 * a)` was left as written. The difference was +not the mathematics — it was which candidate survived `Simplify`'s pruning. + +`ParaphraseInterval` writes a membership out as two comparisons with zero, and the difference it +compares was left as a two-term sum: `a - a / 3` came back as `a + -a / 3`, which no rule about a +sign can read. It is collected now, and the positive factor is divided out where the comparison is +built rather than later — because `Simplify` prunes by `SimplifiedRate`, and +`2/3 * a > 0 and 2 * a > 0` rates **26** against the membership's **25**, one point worse, so it was +discarded before anything could take it to `a > 0`, which rates **8**. The `n = 2` case answered +only because `1/2 * a > 0 and a > 0` happens to rate **24**. + +| | before | now | +|---|---|---| +| `"a in (a / 2; 2a)".Simplify()` | `a > 0` | unchanged | +| `"a in (a / 3; 3a)".Simplify()` | `a in (a / 3; 3 * a)` | `a > 0` | +| `"a in (a / 4; 4a)".Simplify()`, and every denominator through 8 | left as written | `a > 0` | +| `"a in (a / 2; 3a)".Simplify()` | `a in (a / 2; 3 * a)` | `a > 0` | +| `"a in (a / 7; 5a)".Simplify()` | left as written | `a > 0` | +| `"a in (a / 2; 0)".Simplify()` | `a in (a / 2; 0)` | `False provided a in RR` | +| `"a in (-2a; -a/2)".Simplify()` | left as written | `False provided a in RR` | +| `"a in (a / 2; 2a + 1)".Simplify()` | `a > 0 and 1 + a > 0` | unchanged | +| `"a in (1; 2)".Simplify()`, `"3 in (1; 5)"`, `"x in [0; 1]"`, `"a in (b; c)"` | unchanged | unchanged | + +The two `False` rows are answers where there were none: `a / 2 < a < 0` wants `a > 0` and `a < 0` at +once, and so does `-2a < a < -a / 2`. The condition is there because the ordering is a claim about +reals. + +**And `RewriteRules.Common` reaches a fixed point.** It was the library's only non-terminating rule +set: a three-cycle on `-x * 1/2`, `Mulf(-1/2, x)` to `Mulf(-1, Divf(x, 2))` to `Divf(Mulf(-1, x), 2)` +and back — three trees printing as two strings. Two of the three rules turning it are exact inverses +on that shape: one reads `(-1 * x) / 2` as a numeric factor to collect, giving `-1/2 * x`, and the +other reads that back as `-(x / 2)`. The first now declines a factor of `-1`, which is the sign +rather than a number to collect. + +The positive case never cycled, and the reason says why the guard is where it is: `x / 2` is a +quotient over a *leaf*, so it does not re-enter the collection rule's pattern at all. The loop +existed only because a negation is spelled as a product. + +`Simplify` bounded its own iteration and never hung, so no caller saw the cycle — but a rule set is +public, a caller may apply one by itself, and `Common` did not terminate when applied that way. +`RuleSetTerminationTest.NeverSettle` is now empty and still asserted in both directions. + +**Nothing else moved.** `-x * 1/2` is `-1/2 * x` and `x * 1/2` is `x / 2`, both as before; the guard +changes which rewrites are available, not which answer wins. The two halves of this entry ship +together because the first is what makes the second free: the guard alone cost three interval shapes +that were being answered by coincidence, and the collection restores them along with the rest of the +family ([#1056](https://github.com/asc-community/AngouriMath/issues/1056)). + +### The determinant is computed by fraction-free elimination where it can be + +`Matrix.Determinant` expanded by Laplace, which is `O(n!)`. For a fully symbolic matrix that is +optimal — the determinant genuinely has `n!` terms, and no algorithm returns it smaller in expanded +form. For a numeric one it is pure waste: the answer is a single number and `O(n^3)` work suffices. + +Bareiss' fraction-free elimination now runs wherever the entries are polynomials over the rationals, +and Laplace answers everything else. What decides it is not the size but whether the entries can be +read, settled per matrix by trying. + +**The ceiling this removes**, both arms built from source on one machine: + +| | before | now | +|---|---|---| +| numeric 8×8 | 382 ms | under 1 ms | +| numeric 10×10 | 14 415 ms | 2 ms | +| numeric 11×11 | did not return in four minutes | 2 ms | +| numeric 12×12 | did not return | 3 ms | +| numeric 20×20 | did not return | 11 ms | +| numeric 30×30 | did not return | 22 ms | + +**The printed form of a symbolic determinant changes**, because an elimination produces an expanded +polynomial where Laplace produces a nested expansion. The value is the same and the expression is no +larger in any case measured: + +| | before | now | +|---|---|---| +| `"[[a, b], [c, d]]"` | `a * d + -b * c` | `a * d - b * c` | +| `"[[x, 1, 0], [1, x, 1], [0, 1, x]]"` | `x * (x ^ 2 + -1) + -x` | `x ^ 3 - 2 * x` | +| `"[[a, b, 1], [c, d, 2], [1, 2, 3]]"` | `a * (d * 3 + -4) + -b * (c * 3 + -2) + c * 2 + -d` | `3 * a * d - 4 * a - 3 * b * c + 2 * b + 2 * c - d` | +| `"[[x, 1], [1, x]]"` | `x ^ 2 + -1` | unchanged | +| `"[[1/2, 1/3], [1/4, 1/5]]"` | `1/60` | unchanged | + +**No condition is introduced, and that is the point.** An ordinary Gaussian elimination leaves its +pivots as literal divisions, so its answer is undefined wherever a pivot vanishes — at points where +the determinant is perfectly well defined. That was +[#992](https://github.com/asc-community/AngouriMath/issues/992), and it is why Laplace was chosen. +Bareiss divides as well, but each division is by the *previous* pivot and is exact: the quotient is a +determinant of a minor, so it is back in the ring. Here it is exact **and checked** — the arithmetic +happens in `MultivariatePolynomial`, which has no quotients to leave behind, and a division that does +not come out returns null and sends the caller to Laplace. + +**What is declined**, and answered by Laplace exactly as before: an entry that is not a polynomial +over the rationals (`sin(x)`, `1 / x`, `2 ^ x`), a matrix in more than eight indeterminates, and a +matrix mentioning `e` or `pi` — a constant is a value rather than an indeterminate, and this ring +cannot hold one. + +The two algorithms were compared on 300 generated matrices where both apply, as a difference +simplified to zero rather than as trees, with **no disagreements** +([#999](https://github.com/asc-community/AngouriMath/issues/999)). +### Solving a system is bounded whichever internal path takes it + +`Solve` on a system tries a triangularising path first, which bounds itself, and hands what it +declines to an elimination in radicals, which had **no budget at all**. So the same call was bounded +or unbounded depending on which internal path accepted it — cyclic-6 exceeded twenty seconds with +nothing to stop it — and that is worse to debug than either extreme. + +The whole call now draws on one budget. Where it runs out, `NotSufficientlySupportedException` is +raised, naming both paths and both ways to ask for more. + +| | before | now | +|---|---|---| +| `solvesys[a,b,c,d,f,g]` cyclic-6 | exceeded 20 s, no bound beyond it | declines, returning in about two minutes | +| `solvesys[a,b,c,d,e]` of `a^4-1 … e^4-1` | 1024 solutions | unchanged | +| `solvesys[a,b,c,d]` cyclic-4 | 158 solutions | unchanged | +| `x + y = 3, x - y = 1` and every system that answered | unchanged | unchanged | +| the same with `MathS.Settings.Budget` set below what the solve needs | answered anyway | raises | + +**Nothing that answered stops answering.** That was the risk, and it was measured before the change +rather than argued about: the 1024-solution system is one the triangularising path declines on its +quotient-dimension cap, and it declines it **in 8 milliseconds** — so bounding the whole call leaves +the elimination essentially the whole budget. "The fast path declined" does not mean "hopeless", and +this is what keeps that true. + +**The default is two ceilings, and both are measured.** A step is one candidate solution the +elimination explores, which is what compounds — each elimination turns the next level's coefficients +into nested radicals. The systems that answer explore very few: a symbolic 2×2 takes 2, cyclic-4 +takes 8, and the largest that answers at all takes 341. Cyclic-5 passes 100 000 without finishing. So +the step ceiling is 10 000, with thirty-fold headroom over anything known to work. + +The clock is a backstop rather than the bound, and it is deliberately loose at sixty seconds, because +a step can be arbitrarily expensive — cyclic-4 spends five seconds in eight of them. A tight clock +was tried at five seconds and makes the same system answer or decline depending on what else the +machine is doing, which is a worse failure than a slow answer. + +**The bound is cooperative and is checked once per branch**, so a call can overshoot by the cost of +the branch that was running when the budget ran out. That is `BudgetLedger`'s stated design: an +algorithm that does not ask cannot be bounded, and a bound enforced from outside is a thread abort in +the middle of a rewrite. It is a bound where there was none, not a tight one. + +**A budget recording now sees two outcomes per solve** rather than one, named `Gröbner` and +`SolveSystem`. What stopped each is reported separately, which is the thing +[#896](https://github.com/asc-community/AngouriMath/issues/896) said a caller could not previously +find out. + +## 2.4.0 — since 2.3.0 + +Released 2026-08-28. These entries sat under “Unreleased” while 2.4.0 was tagged and +published, so a reader on that version could not tell from this file what they had. They +are under their own heading now, split at the tag rather than from memory: an entry is +here if it was in this file at `v2.4.0` and above if it was added after. + +### At a glance + +| Silent? | What | Was | Is | +|---|---|---|---| +| **Silent** | `"limit(t * b, t, 0)".ToEntity().FreeVariables`, and every limit | `{ t, b }` — the variable it approaches along counted as free | `{ b }` | +| **Silent** | `MathS.Polynomials.Factor("4 * x2 - 4 * y2", "x")`, and every multivariate polynomial whose content is a bare constant | `(x + y) * (x - y)` — **not equal to what it factored** | `4 * (x + y) * (x - y)` | +| | `"2 * x3 - 2".ToEntity().Factorize()`, and every polynomial whose content the rules take out | `2 * (x ^ 3 - 1)` — the remainder left whole | `2 * (x - 1) * (x ^ 2 + x + 1)` | +| | `"3^(x+1) - 2^(x-1)".ToEntity().SolveEquation("x")`, and every equation between two powers of numeric bases | `{ ln(0.04674569822628630438865471319331845734268426895141601562 ^ (1 / ln(2))) }` — a `double` promoted to a decimal | `{ -(ln(3) + ln(2)) / (ln(3) + -ln(2)) }` | +| **Silent** | `MathS.Abs("x").WithCodomain(Domain.Any).Stringize()`, and every node widened to `Any` from a narrower default | `abs(x)` — reads back as `Real`, losing the widening | `domain(abs(x), Any)` | +| | `new Entity[0].SumAll()`, and `Sumf.Sum` on an empty list | `AngouriBugException: At least 1 child required` | `0` | +| | `new Entity[0].MultiplyAll()`, and `Mulf.Multiply` on an empty list | `AngouriBugException` | `1` | +| | `MathS.Vector()` and `new Entity[0].ToVector()` | `IndexOutOfRangeException` — outside the documented hierarchy | `InvalidMatrixOperationException` | +| | `"x3 - 1".ToEntity().Factorize()`, and every polynomial no rewrite rule has a rule for | `x ^ 3 - 1` — handed back whole | `(x - 1) * (x ^ 2 + x + 1)` | +| | `Entity.DomainConditionIn(Domain)` | did not exist | the domain of definition for a **stated** reading, through the whole tree | +| | `MathS.Polynomials.Factor("(y * x3 + 1) * (x4 - y3)", "x")`, and bivariate polynomials whose leading coefficient in the main variable is a polynomial | `null` — a refusal | `(x ^ 4 - y ^ 3) * (x ^ 3 * y + 1)` | +| | `MathS.Polynomials.Factor("x7 - y7", "x")`, and bivariate polynomials whose substituted image over-factors | `null` — a refusal | `(x - y) * (x ^ 6 + x ^ 5 * y + … + y ^ 6)` | +| | `MathS.Polynomials.Factor("x2 + y2 + z2 + w2 + 1", "x")`, and polynomials in enough variables generally | `null` — a refusal | the polynomial itself, meaning it does not factor | +| **Silent** | `"x! = 0".ToEntity().Simplify()` | `False`, including at `x = -1` where `x!` has a pole and the statement is `NaN` | `False provided x in RR and (x >= 0 or not x in ZZ)` | +| **Silent** | `"x! / x!".ToEntity().Simplify()`, and the same for any factorial over itself | `1`, including at `x = -1` where the quotient is `NaN` | `1 provided not x! = 0` | +| **Silent** | `"(y < x) or (x = y)".ToEntity().Simplify()`, and three more disjunctions of a comparison with an equality written the other way round | `x <= y` — False at `x = 3, y = 2` where the input is True | `x >= y` | +| **Silent** | `"x6 + x y + 1 = 0".ToEntity().Solve("x")`, and every equation no solver settles | `{ }` — there are no roots | `{ x : 1 + x ^ 6 + x * y = 0 }` — these are the roots, whichever they are | +| **Silent** | `"(x - 1) * (x6 + x y + 1) = 0".ToEntity().Solve("x")` | `{ 1 }` | `{ 1 } \/ { x : 1 + x ^ 6 + x * y = 0 }` | +| **Silent** | `"x6 + x y + 1 = 0 and x - 1 = 0".ToEntity().Solve("x")` | `{ }` | `{ x : x ^ 6 + x * y + 1 = 0 and x - 1 = 0 }` | +| **Silent** | `"sum(k, k, 1, n)".ToEntity().FreeVariables`, and `product` | `{ k, n }` — the bound index counted as free | `{ n }` | +| **Silent** | `"integral(t * b, t, 0, 1)".ToEntity().FreeVariables`, and every integral with limits | `{ b, t }` | `{ b }` | +| | `"x ^ 3 - x > 0".ToEntity().Solve("x")`, and every polynomial inequality of degree three or more | `NotSufficientlySupportedException: Only linear and quadratic polynomial inequalities are supported` | `(-1; 0) \/ (1; +oo)` — the solution set | +| **Silent** | `"a implies (b implies c)".ToEntity().Stringize()` | `a implies b implies c`, which reads back as `(a implies b) implies c` | `a implies (b implies c)` | +| | `"(a implies b) implies c".ToEntity().Stringize()` | `(a implies b) implies c` | `a implies b implies c` | +| **Silent** | `@"A \ (B \ C)".ToEntity().Stringize()`, and `Latexize` | `A \ B \ C` / `A \setminus B \setminus C` | `A \ (B \ C)` / `A \setminus \left(B \setminus C\right)` | +| **Silent** | `@"A \ (B \/ C)".ToEntity().Stringize()`, and `Latexize` | `A \ B \/ C` / `A \setminus B \cup C` | `A \ (B \/ C)` / `A \setminus \left(B \cup C\right)` | +| **Silent** | `@"A \/ (B \ C)".ToEntity().Stringize()`, and `Latexize` | `A \/ B \ C` / `A \cup B \setminus C` | `A \/ (B \ C)` / `A \cup \left(B \setminus C\right)` | +| **Silent** | `"(x provided p) provided q".ToEntity().Stringize()`, and `Latexize` | `x provided p provided q`, which reads back as `x provided (p provided q)` | `(x provided p) provided q` | +| **Silent** | `"a in (b in c)".ToEntity().Stringize()`, and `Latexize` | `a in b in c` / `a \in b \in c` | `a in (b in c)` / `a \in \left(b \in c\right)` | +| **Silent** | `"x * (y mod z)".ToEntity().Stringize()`, and `Latexize` | `x * y mod z` / `x y \bmod z` | `x * (y mod z)` / `x \left(y \bmod z\right)` | +| **Silent** | `"-1 * (y mod z)".ToEntity().Stringize()` | `-y mod z` | `-(y mod z)` | +| | any expression mixing a number with a `Complex` argument, `Compile`d in a NativeAOT app — `"x + 1".Compile("x")` | `UncompilableNodeException: ... The binary operator Add is not defined for the types 'System.Numerics.Complex' and 'System.Numerics.Complex'` | the compiled function, answering as it does under the JIT | +| | `Compile` to a nullable integral return type in a NativeAOT app | `AngouriBugException: IsNaN method expected for type System.Double`, which took the process down | the compiled function | +| **Silent** | an app publishing with `PublishTrimmed` or NativeAOT | `AngouriMath.dll` was copied in whole, being unmarked | it is trimmed with the rest, since the assembly now declares `IsTrimmable` | +| **Silent** | `"domain(x, ZZ)".ToEntity().Stringize()`, and `ToString`, and `EntityJsonConverter` | `x`, which reads back with `Codomain = Any` | `domain(x, ZZ)`, which reads back narrowed | +| **Silent** | `"domain(sqrt(-1), RR)".ToEntity().Stringize()` | `sqrt(-1)`, which evaluates to `i` when read back | `domain(sqrt(-1), RR)`, which evaluates to `NaN` | +| **Silent** | `"domain(x, ZZ)".ToEntity().Latexize()` | `x` | `{\left(x\right)}_{\mathbb{Z}}` | +| **Silent** | `"x - domain(x, ZZ)".ToEntity().Simplify()`, and any sum mixing a node with a narrowed codomain and the same node without | `0` — the two were collected as one monomial | `x - domain(x, ZZ)`, left alone | +| | every node type's own `Stringize()` and `Latexize()` overrides — `Entity.Sumf.Stringize()` and 129 more | declared on each node | declared once on `Entity`; still callable on every node, and an assembly compiled against 2.3.0 keeps working without a rebuild | +| | `"x + 1 // done".ToEntity()`, and any input whose last line ends in a `//` comment | `UnhandledParseException: extraneous input '/'` | `x + 1` — the comment is skipped, as the block form already was | +| | `MathS.Polynomials.Factor("x * y + y", "x")`, and any polynomial whose coefficients in the named variable share a common divisor | `null` — a refusal | `y * (x + 1)` | +| | `MathS.Polynomials.SquareFreePart("(x - y) ^ 2 * (x + y)", "x")`, and any polynomial in more than one variable | `null` — a refusal | `x ^ 2 - y ^ 2` | +| | `MathS.Polynomials.Factor("x ^ 2 - y ^ 2", "x")`, and polynomials in two variables of small enough bidegree | `null` — a refusal | `(x + y) * (x - y)` | +| | a `switch` over `RewriteRuleGrowth` with no default arm | compiled | does not compile — there is a fourth value, `Unknown` | +| **Silent** | `RewriteRules.RationalizeDenominator.Rules` | `[]` — the registry could not read the set | its two rules, addressable and named | +| **Silent** | `RewriteRules.Power.ApplyOnce("ln(1 / x)")`, and every `log(_, 1/_)` and `log(1/_, _)` whose argument is not decidably a positive real | `-ln(x)`, which is wrong on the negative reals | `ln(1 / x)`, left alone | +| **Silent** | `RewriteRules.Boolean.ApplyOnce("a and b or a")`, and two more orientations of absorption | left alone — the arm for that orientation was never written | `a` | + ### An equation nothing settled is no longer answered with the empty set `Solve` and `SolveEquation` returned an empty `FiniteSet` for two different things: an equation @@ -1125,193 +1322,6 @@ it stays an ordinary variable. `Latexize` renders the subscript as `\mathrm{Any}` rather than a `\mathbb`, since there is no set to render ([#1048](https://github.com/asc-community/AngouriMath/issues/1048)). -### An annotated rational literal keeps the annotation, `CC` included - -A codomain is a property of a node rather than a node of its own, so `domain(1/2, CC)` has to become -one `Rational` carrying `Complex` — and it did not. The pass that reads a quotient of two integer -literals as the rational it denotes ([#873](https://github.com/asc-community/AngouriMath/issues/873)) -ran over the **finished** tree, by which point `Complex` means two things at once: it is what an -unannotated `Divf` carries by default, and it is what `domain(x, CC)` asks for. The pass read it as -the first and dropped it. - -| | before | now | -|---|---|---| -| `"domain(1/2, CC)".ToEntity().Codomain` | `Rational` — the annotation gone | `Complex` | -| `"domain(1/2, CC)".ToEntity() == "1/2".ToEntity()` | `true` | `false` | -| `(1/2).WithCodomain(Complex).Stringize().ToEntity()` | `1/2`, `Rational` — printed one node, read back another | round-trips | -| `"domain(1/2, RR)".ToEntity()` | `Real`, correct | unchanged | -| `"1/2".ToEntity()` | a `Rational`, `Codomain = Rational` | unchanged | -| `"domain(4/2, CC)".ToEntity()` | a `Divf`, `Codomain = Complex` | unchanged | -| `"domain(1/2, CC)".ToEntity().Evaled` | `1/2` — the annotation erased by evaluating | `domain(1/2, CC)`, carried through | -| the same, `.Simplify()` | `1/2` | `domain(1/2, CC)` | -| the same, `.Latexize()` | `\frac{1}{2}` | `{\left(\frac{1}{2}\right)}_{\mathbb{C}}` | -| `"domain(1/2, CC) + 1".ToEntity().Evaled` | `3/2` | unchanged | - -**The fix is where the fold happens, not what it reads.** `domain(...)`'s own parser rule now folds -its argument before annotating it, so the two meanings of `Complex` never meet: an annotation lands -on a node that is already the right shape, and the sweep over the rest of the tree — which is the -only other place the fold runs — can hand back a bare `Rational` and read no codomain at all. It is -sound to do so because those two are the only routes, `domain(...)` being the one syntax that -annotates anything and the sweep meeting only what it did not annotate. - -`Complex` on a rational literal is a **widening** — a rational is a complex number — so nothing that -was defined becomes `NaN` and no value changes: `domain(1/2, CC) + 1` evaluates to `3/2` as before, -and `domain(1/2, ZZ)` is still `NaN`. What changes is equality, and everything that follows from the -node genuinely carrying the annotation now — `Evaled` and `Simplify` hand it back instead of erasing -it, and `Latexize` writes the subscript. That is the point: an annotation the caller wrote is no -longer indistinguishable from one they did not. - -This was the second of the two gaps -[#1048](https://github.com/asc-community/AngouriMath/issues/1048) recorded, and the last entry in -`CodomainSurvivesPrintingTest.StillUnparseable`. That dictionary is now empty and still asserted in -both directions, so every writable domain on every node type reads back as itself, and a gap added -to it later fails the day it closes. - -### An interval bounded by its own element is decided, and `Common` terminates - -`a in (a / 2; 2 * a)` was `a > 0` and `a in (a / 3; 3 * a)` was left as written. The difference was -not the mathematics — it was which candidate survived `Simplify`'s pruning. - -`ParaphraseInterval` writes a membership out as two comparisons with zero, and the difference it -compares was left as a two-term sum: `a - a / 3` came back as `a + -a / 3`, which no rule about a -sign can read. It is collected now, and the positive factor is divided out where the comparison is -built rather than later — because `Simplify` prunes by `SimplifiedRate`, and -`2/3 * a > 0 and 2 * a > 0` rates **26** against the membership's **25**, one point worse, so it was -discarded before anything could take it to `a > 0`, which rates **8**. The `n = 2` case answered -only because `1/2 * a > 0 and a > 0` happens to rate **24**. - -| | before | now | -|---|---|---| -| `"a in (a / 2; 2a)".Simplify()` | `a > 0` | unchanged | -| `"a in (a / 3; 3a)".Simplify()` | `a in (a / 3; 3 * a)` | `a > 0` | -| `"a in (a / 4; 4a)".Simplify()`, and every denominator through 8 | left as written | `a > 0` | -| `"a in (a / 2; 3a)".Simplify()` | `a in (a / 2; 3 * a)` | `a > 0` | -| `"a in (a / 7; 5a)".Simplify()` | left as written | `a > 0` | -| `"a in (a / 2; 0)".Simplify()` | `a in (a / 2; 0)` | `False provided a in RR` | -| `"a in (-2a; -a/2)".Simplify()` | left as written | `False provided a in RR` | -| `"a in (a / 2; 2a + 1)".Simplify()` | `a > 0 and 1 + a > 0` | unchanged | -| `"a in (1; 2)".Simplify()`, `"3 in (1; 5)"`, `"x in [0; 1]"`, `"a in (b; c)"` | unchanged | unchanged | - -The two `False` rows are answers where there were none: `a / 2 < a < 0` wants `a > 0` and `a < 0` at -once, and so does `-2a < a < -a / 2`. The condition is there because the ordering is a claim about -reals. - -**And `RewriteRules.Common` reaches a fixed point.** It was the library's only non-terminating rule -set: a three-cycle on `-x * 1/2`, `Mulf(-1/2, x)` to `Mulf(-1, Divf(x, 2))` to `Divf(Mulf(-1, x), 2)` -and back — three trees printing as two strings. Two of the three rules turning it are exact inverses -on that shape: one reads `(-1 * x) / 2` as a numeric factor to collect, giving `-1/2 * x`, and the -other reads that back as `-(x / 2)`. The first now declines a factor of `-1`, which is the sign -rather than a number to collect. - -The positive case never cycled, and the reason says why the guard is where it is: `x / 2` is a -quotient over a *leaf*, so it does not re-enter the collection rule's pattern at all. The loop -existed only because a negation is spelled as a product. - -`Simplify` bounded its own iteration and never hung, so no caller saw the cycle — but a rule set is -public, a caller may apply one by itself, and `Common` did not terminate when applied that way. -`RuleSetTerminationTest.NeverSettle` is now empty and still asserted in both directions. - -**Nothing else moved.** `-x * 1/2` is `-1/2 * x` and `x * 1/2` is `x / 2`, both as before; the guard -changes which rewrites are available, not which answer wins. The two halves of this entry ship -together because the first is what makes the second free: the guard alone cost three interval shapes -that were being answered by coincidence, and the collection restores them along with the rest of the -family ([#1056](https://github.com/asc-community/AngouriMath/issues/1056)). - -### The determinant is computed by fraction-free elimination where it can be - -`Matrix.Determinant` expanded by Laplace, which is `O(n!)`. For a fully symbolic matrix that is -optimal — the determinant genuinely has `n!` terms, and no algorithm returns it smaller in expanded -form. For a numeric one it is pure waste: the answer is a single number and `O(n^3)` work suffices. - -Bareiss' fraction-free elimination now runs wherever the entries are polynomials over the rationals, -and Laplace answers everything else. What decides it is not the size but whether the entries can be -read, settled per matrix by trying. - -**The ceiling this removes**, both arms built from source on one machine: - -| | before | now | -|---|---|---| -| numeric 8×8 | 382 ms | under 1 ms | -| numeric 10×10 | 14 415 ms | 2 ms | -| numeric 11×11 | did not return in four minutes | 2 ms | -| numeric 12×12 | did not return | 3 ms | -| numeric 20×20 | did not return | 11 ms | -| numeric 30×30 | did not return | 22 ms | - -**The printed form of a symbolic determinant changes**, because an elimination produces an expanded -polynomial where Laplace produces a nested expansion. The value is the same and the expression is no -larger in any case measured: - -| | before | now | -|---|---|---| -| `"[[a, b], [c, d]]"` | `a * d + -b * c` | `a * d - b * c` | -| `"[[x, 1, 0], [1, x, 1], [0, 1, x]]"` | `x * (x ^ 2 + -1) + -x` | `x ^ 3 - 2 * x` | -| `"[[a, b, 1], [c, d, 2], [1, 2, 3]]"` | `a * (d * 3 + -4) + -b * (c * 3 + -2) + c * 2 + -d` | `3 * a * d - 4 * a - 3 * b * c + 2 * b + 2 * c - d` | -| `"[[x, 1], [1, x]]"` | `x ^ 2 + -1` | unchanged | -| `"[[1/2, 1/3], [1/4, 1/5]]"` | `1/60` | unchanged | - -**No condition is introduced, and that is the point.** An ordinary Gaussian elimination leaves its -pivots as literal divisions, so its answer is undefined wherever a pivot vanishes — at points where -the determinant is perfectly well defined. That was -[#992](https://github.com/asc-community/AngouriMath/issues/992), and it is why Laplace was chosen. -Bareiss divides as well, but each division is by the *previous* pivot and is exact: the quotient is a -determinant of a minor, so it is back in the ring. Here it is exact **and checked** — the arithmetic -happens in `MultivariatePolynomial`, which has no quotients to leave behind, and a division that does -not come out returns null and sends the caller to Laplace. - -**What is declined**, and answered by Laplace exactly as before: an entry that is not a polynomial -over the rationals (`sin(x)`, `1 / x`, `2 ^ x`), a matrix in more than eight indeterminates, and a -matrix mentioning `e` or `pi` — a constant is a value rather than an indeterminate, and this ring -cannot hold one. - -The two algorithms were compared on 300 generated matrices where both apply, as a difference -simplified to zero rather than as trees, with **no disagreements** -([#999](https://github.com/asc-community/AngouriMath/issues/999)). -### Solving a system is bounded whichever internal path takes it - -`Solve` on a system tries a triangularising path first, which bounds itself, and hands what it -declines to an elimination in radicals, which had **no budget at all**. So the same call was bounded -or unbounded depending on which internal path accepted it — cyclic-6 exceeded twenty seconds with -nothing to stop it — and that is worse to debug than either extreme. - -The whole call now draws on one budget. Where it runs out, `NotSufficientlySupportedException` is -raised, naming both paths and both ways to ask for more. - -| | before | now | -|---|---|---| -| `solvesys[a,b,c,d,f,g]` cyclic-6 | exceeded 20 s, no bound beyond it | declines, returning in about two minutes | -| `solvesys[a,b,c,d,e]` of `a^4-1 … e^4-1` | 1024 solutions | unchanged | -| `solvesys[a,b,c,d]` cyclic-4 | 158 solutions | unchanged | -| `x + y = 3, x - y = 1` and every system that answered | unchanged | unchanged | -| the same with `MathS.Settings.Budget` set below what the solve needs | answered anyway | raises | - -**Nothing that answered stops answering.** That was the risk, and it was measured before the change -rather than argued about: the 1024-solution system is one the triangularising path declines on its -quotient-dimension cap, and it declines it **in 8 milliseconds** — so bounding the whole call leaves -the elimination essentially the whole budget. "The fast path declined" does not mean "hopeless", and -this is what keeps that true. - -**The default is two ceilings, and both are measured.** A step is one candidate solution the -elimination explores, which is what compounds — each elimination turns the next level's coefficients -into nested radicals. The systems that answer explore very few: a symbolic 2×2 takes 2, cyclic-4 -takes 8, and the largest that answers at all takes 341. Cyclic-5 passes 100 000 without finishing. So -the step ceiling is 10 000, with thirty-fold headroom over anything known to work. - -The clock is a backstop rather than the bound, and it is deliberately loose at sixty seconds, because -a step can be arbitrarily expensive — cyclic-4 spends five seconds in eight of them. A tight clock -was tried at five seconds and makes the same system answer or decline depending on what else the -machine is doing, which is a worse failure than a slow answer. - -**The bound is cooperative and is checked once per branch**, so a call can overshoot by the cost of -the branch that was running when the budget ran out. That is `BudgetLedger`'s stated design: an -algorithm that does not ask cannot be bounded, and a bound enforced from outside is a thread abort in -the middle of a rewrite. It is a bound where there was none, not a tight one. - -**A budget recording now sees two outcomes per solve** rather than one, named `Gröbner` and -`SolveSystem`. What stopped each is reported separately, which is the thing -[#896](https://github.com/asc-community/AngouriMath/issues/896) said a caller could not previously -find out. - ### A fold over an empty sequence answers instead of throwing A fold over a monoid has an identity, so the empty sum is `0` and the empty product is `1` — which