diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index 4c241186d..82feeb25d 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -30,6 +30,8 @@ read first. | | `"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 | | | the same on a numeric matrix past 10x10 | did not return | 11x11 in 2 ms, 30x30 in 22 ms | +| **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` | @@ -900,6 +902,50 @@ 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 diff --git a/Sources/.editorconfig b/Sources/.editorconfig index 0356c8d66..0c7165476 100644 --- a/Sources/.editorconfig +++ b/Sources/.editorconfig @@ -60,6 +60,9 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed [Tests/UnitTests/Algebra/Polynomials/FractionFreeDeterminantTest.cs] file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n +[Tests/UnitTests/Core/Budgets/SolveIsBoundedEndToEndTest.cs] +file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n + [AngouriMath/Functions/Algebra/Groebner/*.cs] file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n diff --git a/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver.cs b/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver.cs index 190d45d4e..ef9c035e4 100644 --- a/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver.cs +++ b/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver.cs @@ -5,6 +5,7 @@ // Website: https://am.angouri.org. // +using AngouriMath.Core.Budgets; using AngouriMath.Core.Exceptions; using AngouriMath.Extensions; using AngouriMath.Functions.Algebra.AnalyticalSolving; @@ -59,6 +60,34 @@ internal static Set Solve(Entity equation, Variable x) /// Then we substitute back
/// y = -3a + a = -2a
/// + /// + /// What a whole system solve is allowed to spend before it declines. + /// + /// + /// + /// Both ceilings, because neither is a bound on its own. A step here is one + /// candidate solution the elimination explores, and that is what compounds: each + /// elimination turns the next level's coefficients into nested radicals. Measured, the + /// systems that answer explore very few — a symbolic 2×2 takes 2, cyclic-4 takes 8, + /// and the largest that answers at all, five uncoupled quartics with 1024 solutions, + /// takes 341. Cyclic-5 passes 100 000 without finishing. So 10 000 admits everything + /// known to work with a factor of thirty to spare and still refuses the ones that run + /// away. + /// + /// + /// The clock is a backstop, not the bound, because a step can be arbitrarily expensive: + /// cyclic-4 spends five seconds in eight of them. It is set well above what any + /// answering case needs — the slowest takes about five seconds unloaded — since a + /// tight clock makes the same system answer or decline depending on what else the + /// machine is doing, and a flaky answer is worse than a slow one. That was measured + /// too: at five seconds the uncoupled case passed alone and failed inside the suite. + /// #896 + /// + /// + [ConstantField] + internal static readonly WorkBudget SystemSolveBudget = + new() { Steps = 10_000, Time = TimeSpan.FromSeconds(60) }; + internal static Matrix? SolveSystem(IEnumerable inputEquations, ReadOnlySpan vars) { var equations = new List(inputEquations.Select(equation => equation.InnerSimplified)); @@ -68,23 +97,47 @@ internal static Set Solve(Entity equation, Variable x) // does -- costs nothing on an uncoupled system and does not finish on a coupled // one, so this is tried before the equation count is even insisted on: a Groebner // basis has no use for as many equations as unknowns. - var variables = new Variable[vars.Length]; - vars.CopyTo(variables); - if (Groebner.GroebnerSystemSolver.TrySolve(equations, variables, out var triangularised)) - return triangularised; + // One ledger for the whole call, drawn on by both paths. Each stage having a + // budget of its own is what bounded the fast path and left the fall-through + // unbounded, so the same `Solve` finished or did not depending on which internal + // path accepted it. https://github.com/asc-community/AngouriMath/issues/896 + var ledger = BudgetLedger.For("SolveSystem", SystemSolveBudget); + try + { + var variables = new Variable[vars.Length]; + vars.CopyTo(variables); + // The Gröbner path keeps a ledger of its own, deliberately. It uses the same + // mechanism for two different things -- a genuine resource ceiling, and a + // structural refusal like "not polynomial" -- and a ledger that has recorded + // any ceiling refuses every later spend. Sharing it would therefore read + // "declined in 8 ms because the system is uncoupled" as "the budget is gone", + // and the 1024-solution case that the eliminator answers cheaply would raise + // instead. Measured: it does exactly that. + // + // What is shared is the clock. This ledger starts when the call does, so the + // time the Gröbner path spends is already gone from it when the elimination + // begins -- which is the bound that was missing, without conflating the two + // meanings of "stopped". + if (Groebner.GroebnerSystemSolver.TrySolve(equations, variables, out var triangularised)) + return triangularised; - if (equations.Count != vars.Length) - throw new WrongNumberOfArgumentsException("Number of equations must be equal to that of vars"); - int initVarCount = vars.Length; + if (equations.Count != vars.Length) + throw new WrongNumberOfArgumentsException("Number of equations must be equal to that of vars"); + int initVarCount = vars.Length; - var res = InSolveSystem(equations, vars, Sumf.Sum(equations)); - foreach (var tuple in res) - if (tuple.Count != initVarCount) - throw new AngouriBugException("InSolveSystem incorrect output"); - if (res.Count == 0) - return null; - var tb = new MatrixBuilder(res, initVarCount); - return tb.ToMatrix(); + var res = InSolveSystem(equations, vars, Sumf.Sum(equations), ledger); + foreach (var tuple in res) + if (tuple.Count != initVarCount) + throw new AngouriBugException("InSolveSystem incorrect output"); + if (res.Count == 0) + return null; + var tb = new MatrixBuilder(res, initVarCount); + return tb.ToMatrix(); + } + finally + { + ledger.Report(); + } } /// Solves system of equations @@ -97,8 +150,26 @@ internal static Set Solve(Entity equation, Variable x) /// The system as a whole, so that a parameter standing for a free variable is given /// a name that none of the equations already uses /// - internal static List> InSolveSystem(List equations, ReadOnlySpan vars, Entity nameSource) + /// + /// The caller's budget, drawn on once per candidate solution explored. Optional + /// because the recursion passes it along and a caller may have none; where it is + /// absent this is unbounded, which is what it always was. + /// + internal static List> InSolveSystem( + List equations, ReadOnlySpan vars, Entity nameSource, + BudgetLedger? ledger = null) { + // Charged before the branch rather than after it, so the ceiling bounds what is + // done rather than what has been done. One unit per candidate solution explored is + // the right grain: this eliminates one variable per level, and each elimination + // turns the next level's coefficients into nested radicals, so the count of + // branches explored is what compounds. https://github.com/asc-community/AngouriMath/issues/896 + if (ledger is not null && !ledger.Spend()) + throw new NotSufficientlySupportedException( + "this system of equations is not solvable within the budget: the " + + "triangularising path declined it and eliminating in radicals has not " + + "finished. Raise MathS.Settings.Budget to allow more, or pass a " + + "cancellation token with MathS.Multithreading.SetLocalCancellationToken"); var var = vars[^1]; if (equations.Count == 1) { @@ -136,7 +207,7 @@ internal static List> InSolveSystem(List equations, ReadOnl rest.RemoveAt(i); foreach (var sol in sols) - foreach (var j in InSolveSystem(rest.Select(eq => eq.Substitute(var, sol)).ToList(), remainingVars, nameSource)) + foreach (var j in InSolveSystem(rest.Select(eq => eq.Substitute(var, sol)).ToList(), remainingVars, nameSource, ledger)) { replacements.Clear(); for (int varid = 0; varid < remainingVars.Length; varid++) diff --git a/Sources/Tests/UnitTests/Calculus/OneSidedLimitTest.cs b/Sources/Tests/UnitTests/Calculus/OneSidedLimitTest.cs index 53895f334..585cd187b 100644 --- a/Sources/Tests/UnitTests/Calculus/OneSidedLimitTest.cs +++ b/Sources/Tests/UnitTests/Calculus/OneSidedLimitTest.cs @@ -122,9 +122,14 @@ public void EstablishedOneSidedLimitsAreUnaffected(string expression, string des [InlineData(ApproachFrom.Left)] public void ADifferenceOfReciprocalLogarithms(ApproachFrom side) { + // The guard is against not terminating, not against being slow, so it is set far + // above the cost of the limit rather than near it. Measured, this takes about seven + // seconds; at sixty it failed intermittently once the suite grew, because a wall + // clock in a parallel run measures what else the machine is doing as much as what + // this does. Three minutes still catches a hang in a suite that takes seven. var task = System.Threading.Tasks.Task.Run(() => Limit("1 / ln(x + sqrt(x * x + 1)) - 1 / ln(x + 1)", "0", side)); - Assert.True(task.Wait(System.TimeSpan.FromSeconds(60)), "the limit did not terminate"); + Assert.True(task.Wait(System.TimeSpan.FromSeconds(180)), "the limit did not terminate"); Assert.Equal("-1/2".ToEntity().Evaled, task.Result.Evaled); } diff --git a/Sources/Tests/UnitTests/Core/Budgets/BudgetTest.cs b/Sources/Tests/UnitTests/Core/Budgets/BudgetTest.cs index 78aaf2e4d..9c059c9ad 100644 --- a/Sources/Tests/UnitTests/Core/Budgets/BudgetTest.cs +++ b/Sources/Tests/UnitTests/Core/Budgets/BudgetTest.cs @@ -40,7 +40,11 @@ static BudgetOutcome Solve(string[] equations, string[] variables) // What the fall-through does with a system the bounded path declined is not // what these assert; the outcome is recorded before it is reached either way. } - return Assert.Single(recording.Outcomes); + // The Gröbner ledger, which is what every assertion below is about. A solve now + // opens a second one for the whole call -- that is what bounds the elimination + // the Gröbner path hands over to, and is #896 -- so the outcomes are named rather + // than assumed to be one. + return Assert.Single(recording.Outcomes, outcome => outcome.Where == "Gröbner"); } [Fact] @@ -112,18 +116,44 @@ public void TheStepCountIsAFunctionOfTheInput() } /// - /// Exhausting the bounded path is not an answer — the caller carries on and gets the - /// same solutions it always did. + /// A budget the solve fits inside changes nothing: the same solutions, by the same + /// path, as a caller who never mentioned a budget. /// [Fact] - public void ExhaustionDoesNotChangeTheAnswer() + public void ABudgetThatIsNotReachedChangesNothing() { var withoutBudget = MathS.Equations(Eqs("x - y + 3", "y + 2")).Solve(Vars("x", "y")); - using var _ = MathS.Settings.Budget.Set(new WorkBudget { Steps = 1 }); + using var _ = MathS.Settings.Budget.Set(new WorkBudget { Steps = 1_000_000 }); var withBudget = MathS.Equations(Eqs("x - y + 3", "y + 2")).Solve(Vars("x", "y")); Assert.Equal(withoutBudget, withBudget); } + /// + /// A budget that is reached bounds the whole call, and the caller is told. + /// + /// + /// + /// This test used to assert the opposite — that exhausting the bounded path left the + /// answer alone because the caller carried on regardless — and that was + /// #896: the + /// triangularising path bounded itself and then handed the problem to an elimination + /// with no budget of its own, so the same Solve was bounded or not depending on + /// which internal path accepted it. + /// + /// + /// A step ceiling of one is a caller asking for one step of work. Answering anyway is + /// not generosity, it is ignoring what was asked; and the systems where it mattered + /// were the ones that ran for minutes rather than the ones that answered quickly. + /// + /// + [Fact] + public void ABudgetThatIsReachedBoundsTheWholeCall() + { + using var _ = MathS.Settings.Budget.Set(new WorkBudget { Steps = 1 }); + Assert.Throws( + () => MathS.Equations(Eqs("x - y + 3", "y + 2")).Solve(Vars("x", "y"))); + } + /// /// The first ceiling to fire is the one reported: whatever the algorithm asks on its /// way out cannot overwrite the reason it is leaving. @@ -182,7 +212,13 @@ public void ExhaustedListsOnlyWhatStopped() _ = MathS.Equations(Eqs("x - y + 3", "y + 2")).Solve(Vars("x", "y")); try { _ = MathS.Equations(Eqs("sin(x) + y", "y - 1")).Solve(Vars("x", "y")); } catch (Exception exception) when (exception is not Xunit.Sdk.XunitException) { } - Assert.Equal(2, recording.Outcomes.Count); + // Two solves, each opening two ledgers: the Gröbner path's and the whole call's. + Assert.Equal(4, recording.Outcomes.Count); + Assert.Equal(2, recording.Outcomes.Count(outcome => outcome.Where == "Gröbner")); + Assert.Equal(2, recording.Outcomes.Count(outcome => outcome.Where == "SolveSystem")); + // And only one of the four stopped anything. The second solve's elimination + // answers `sin(x) + y` without running out, so the whole-call ledger has no + // ceiling to report -- which is the point of listing only what stopped. Assert.Equal("not polynomial", Assert.Single(recording.Exhausted).Reason); } } diff --git a/Sources/Tests/UnitTests/Core/Budgets/SolveIsBoundedEndToEndTest.cs b/Sources/Tests/UnitTests/Core/Budgets/SolveIsBoundedEndToEndTest.cs new file mode 100644 index 000000000..17d5558f9 --- /dev/null +++ b/Sources/Tests/UnitTests/Core/Budgets/SolveIsBoundedEndToEndTest.cs @@ -0,0 +1,145 @@ +// +// Copyright (c) 2019-2026 Angouri. +// AngouriMath is licensed under MIT. +// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. +// Website: https://am.angouri.org. +// + +using System; +using System.Linq; +using AngouriMath.Core.Budgets; +using AngouriMath.Core.Exceptions; +using AngouriMath.Extensions; +using Xunit; + +namespace AngouriMath.Tests.Core.Budgets +{ + /// + /// Solve on a system is bounded whichever internal path takes it. + /// #896 + /// + /// + /// + /// The triangularising path bounded itself and then handed what it declined to an + /// elimination in radicals that had no budget at all — so the same call finished or did + /// not depending on which internal path accepted it, which is worse to debug than either + /// extreme. + /// + /// + /// What is shared between the two stages is the clock, not the ledger. The Gröbner + /// path uses one mechanism for two different things — a genuine resource ceiling, and a + /// structural refusal like "not polynomial" — and a ledger that has recorded any ceiling + /// refuses every later spend. Sharing the ledger itself therefore reads "declined in 8 ms + /// because the system is uncoupled" as "the budget is gone", and the 1024-solution system + /// below stops being answered. That was measured, not reasoned about: it is why the + /// whole-call ledger is opened separately and only its elapsed time carries across. + /// + /// + [Trait("Area", "Core")] + public sealed class SolveIsBoundedEndToEndTest + { + private static Entity.Matrix? Solve(string[] equations, string[] variables) + => MathS.Equations(equations.Select(equation => equation.ToEntity()).ToArray()) + .Solve(variables.Select(name => (Entity.Variable)MathS.Var(name)).ToArray()); + + private static readonly string[] UncoupledFive = + { "a4 - 1", "b4 - 1", "c4 - 1", "d4 - 1", "e4 - 1" }; + + + /// + /// The case the issue names as the one a bound must not cost. Its quotient dimension is + /// 1024, above the Gröbner cap of 512, so that path declines it — and the elimination + /// answers it cheaply, because the system is uncoupled. "Gröbner declined" does not + /// imply "hopeless", and this is what says so. + /// + [Fact] + public void AnUncoupledSystemTheGroebnerPathDeclinesIsStillAnswered() + { + var solutions = Solve(UncoupledFive, new[] { "a", "b", "c", "d", "e" }); + Assert.NotNull(solutions); + Assert.Equal(1024, solutions!.RowCount); + } + + /// + /// The half that had no budget at all: a system the Gröbner path declines reaches the + /// elimination, and the elimination now asks before it explores. + /// + /// + /// + /// x + y = a, x - y = b is not polynomial over the rationals in x and + /// y alone, so the triangularising path refuses it and the elimination answers + /// it — in two candidate solutions, measured. A ceiling of one therefore stops it, and + /// stops it in milliseconds, which is what makes this a test rather than a wait. + /// + /// + /// The system the issue is about is deliberately not here. Cyclic-6 under the + /// default budget returns in about two minutes and declines, where before it did not + /// return at all — but two minutes is two minutes added to every suite run, and the + /// mechanism it would demonstrate is the one below. Its measurement is recorded in + /// BREAKING-CHANGES.md instead. + /// + /// + /// The bound is cooperative and checked once per branch, so a call can overshoot + /// by the cost of the branch that was running when the budget ran out — cyclic-6's + /// branches are individually enormous, which is why even a ceiling of twenty takes + /// half an hour on it. That is 's stated design: an algorithm + /// that does not ask cannot be bounded, and a bound enforced from outside is a thread + /// abort. + /// + /// + [Fact] + public void TheEliminationConsultsTheBudgetBeforeExploring() + { + using var _ = MathS.Settings.Budget.Set(new WorkBudget { Steps = 1 }); + Assert.Throws( + () => Solve(new[] { "x + y - a", "x - y - b" }, new[] { "x", "y" })); + } + + /// + /// The message says which of the two paths did what, because a caller who has to guess + /// cannot act on it. + /// + [Fact] + public void TheRefusalSaysWhatWasTried() + { + using var _ = MathS.Settings.Budget.Set(new WorkBudget { Steps = 1 }); + var thrown = Assert.Throws( + () => Solve(new[] { "x + y - a", "x - y - b" }, new[] { "x", "y" })); + + Assert.Contains("budget", thrown.Message); + Assert.Contains("triangularising", thrown.Message); + Assert.Contains("radicals", thrown.Message); + // And what to do about it, both ways: raise the ceiling, or bound it yourself. + Assert.Contains(nameof(MathS.Settings.Budget), thrown.Message); + Assert.Contains("SetLocalCancellationToken", thrown.Message); + } + + /// + /// Nothing that answered before stops answering. These take both paths — the first two + /// are triangularised, the third is not polynomial over the rationals and the fourth is + /// uncoupled — and none is near any ceiling. + /// + [Theory] + [InlineData(new[] { "x + y - 3", "x - y - 1" }, new[] { "x", "y" })] + [InlineData(new[] { "x2 - 2", "y - x" }, new[] { "x", "y" })] + [InlineData(new[] { "x + y - a", "x - y - b" }, new[] { "x", "y" })] + [InlineData(new[] { "a4 - 1", "b4 - 1", "c4 - 1" }, new[] { "a", "b", "c" })] + public void AnOrdinarySystemIsUnaffected(string[] equations, string[] variables) + => Assert.NotNull(Solve(equations, variables)); + + /// + /// A caller who wants more may have it, which is what makes the bound a default rather + /// than a limit — and is half of what the refusal's message says to do. + /// + [Fact] + public void TheBoundIsACallersToRaise() + { + using var _ = MathS.Settings.Budget.Set(new WorkBudget { Steps = 1 }); + Assert.Throws( + () => Solve(UncoupledFive, new[] { "a", "b", "c", "d", "e" })); + // And the same system answers under the default, which is the other half of + // "a default rather than a limit" -- asserted above, in + // AnUncoupledSystemTheGroebnerPathDeclinesIsStillAnswered. + } + } +}