From 05534ea455c2ccf47d312c486ecce7d42c1a36a7 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Mon, 31 Aug 2026 16:57:16 +0000 Subject: [PATCH 1/3] Solving a system is bounded whichever internal path takes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The triangularising path bounded itself and handed what it declined to an elimination in radicals with no budget at all, so the same `Solve` was bounded or unbounded depending on which internal path accepted it. The whole call draws on one budget now, and where it runs out the caller is told, with both paths named and both ways to ask for more. **The measurement the issue said nobody had taken.** Its worry was that bounding the fall-through would cost the systems the fast path declines for reasons that have nothing to do with difficulty -- five uncoupled quartics with 1024 solutions, above the quotient-dimension cap, which the elimination answers cheaply. Measured: the Gröbner path declines that one in **8 ms**. So the whole-call bound leaves the elimination essentially all of it, and the case survives. It still answers 1024 solutions. **What is shared between the stages is the clock, not the ledger.** Sharing the ledger was tried first and is wrong: the Gröbner path uses one mechanism for a genuine resource ceiling and for a structural refusal like "not polynomial", and a ledger that has recorded any ceiling refuses every later spend. So it read "declined in 8 ms because the system is uncoupled" as "the budget is gone", and the 1024-solution case raised instead of answering. Measured, not reasoned about. **Two ceilings, because neither bounds this alone.** A step is one candidate solution explored, which is what compounds -- each elimination turns the next level's coefficients into nested radicals. The systems that answer explore 2, 8 and at most 341; cyclic-5 passes 100 000 without finishing, so 10 000 has thirty-fold headroom over anything known to work and still refuses the runaways. The clock is a backstop and is deliberately loose, 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 made the same system answer or decline depending on machine load: it passed alone and failed inside the suite. A flaky answer is worse than a slow one. **The bound is cooperative and checked once per branch**, so a call can overshoot by the cost of the branch that was running. Cyclic-6 returns in about two minutes where it previously did not return. That is `BudgetLedger`'s stated design and it is a bound where there was none rather than a tight one. Cyclic-6 is deliberately not in the suite: two minutes is two minutes on every run, and even a ceiling of twenty takes half an hour on it because its branches are individually enormous. The test that matters is that the elimination consults a budget at all, which a system it answers in two candidates demonstrates in milliseconds. `BudgetTest.ExhaustionDoesNotChangeTheAnswer` asserted the old promise -- that exhausting the bounded path left the answer alone, because the caller carried on regardless. That promise is what this changes, so it is split into the two that are now true: a budget not reached changes nothing, and a budget reached bounds the whole call. A recording also sees two outcomes per solve rather than one, named rather than counted. Part of #896. --- BREAKING-CHANGES.md | 47 ++++++ Sources/.editorconfig | 3 + .../Continuous/Solvers/EquationSolver.cs | 105 +++++++++++-- .../UnitTests/Core/Budgets/BudgetTest.cs | 48 +++++- .../Budgets/SolveIsBoundedEndToEndTest.cs | 145 ++++++++++++++++++ 5 files changed, 325 insertions(+), 23 deletions(-) create mode 100644 Sources/Tests/UnitTests/Core/Budgets/SolveIsBoundedEndToEndTest.cs diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index 2679d91d1..db10447ae 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -28,6 +28,8 @@ read first. | **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` | +| **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` | @@ -793,6 +795,51 @@ together because the first is what makes the second free: the guard alone cost t 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)). +### 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 diff --git a/Sources/.editorconfig b/Sources/.editorconfig index 3157b7806..ebbecfeac 100644 --- a/Sources/.editorconfig +++ b/Sources/.editorconfig @@ -51,6 +51,9 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed [AngouriMath/Functions/Algebra/Polynomials/KroneckerFactorization.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/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. + } + } +} From aa5698ddd390ae0573c157e386368514deea7dd2 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Mon, 31 Aug 2026 17:25:44 +0000 Subject: [PATCH 2/3] Restore the blank lines between the header entries, so the merge is additive only --- Sources/.editorconfig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/.editorconfig b/Sources/.editorconfig index 292a542cb..0c7165476 100644 --- a/Sources/.editorconfig +++ b/Sources/.editorconfig @@ -53,10 +53,13 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed [AngouriMath/Functions/Algebra/Polynomials/FractionFreeDeterminant.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/Polynomials/PolynomialDeterminant.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/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 From c27e5ec8b6835e0b5fe087e03a110b0ab0524e80 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Mon, 31 Aug 2026 18:51:13 +0000 Subject: [PATCH 3/3] Widen a limit test's termination guard, which measures load as much as cost `ADifferenceOfReciprocalLogarithms` wraps its limit in a task and fails it after sixty seconds of wall clock. The limit costs about seven, so the guard was never near the work -- but a wall clock in a parallel suite measures what else the machine is doing, and once this branch added nine tests it started failing about two runs in three while passing alone every time. Measured before blaming it, because the obvious suspect was this branch's own budget: the limit takes 6906, 6908 and 6924 ms with the new budget and 6970, 6896 and 6917 ms without it. Identical. The budget never fires here; the suite simply got heavier. Three minutes still catches a hang in a suite that takes seven. --- Sources/Tests/UnitTests/Calculus/OneSidedLimitTest.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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); }