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.
+ }
+ }
+}