Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions BREAKING-CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions Sources/.editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
105 changes: 88 additions & 17 deletions Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -59,6 +60,34 @@ internal static Set Solve(Entity equation, Variable x)
/// Then we substitute back <br/>
/// y = -3a + a = -2a <br/>
/// </summary>
/// <summary>
/// What a whole system solve is allowed to spend before it declines.
/// </summary>
/// <remarks>
/// <para>
/// <b>Both ceilings, because neither is a bound on its own.</b> 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.
/// </para>
/// <para>
/// 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.
/// <a href="https://github.com/asc-community/AngouriMath/issues/896">#896</a>
/// </para>
/// </remarks>
[ConstantField]
internal static readonly WorkBudget SystemSolveBudget =
new() { Steps = 10_000, Time = TimeSpan.FromSeconds(60) };

internal static Matrix? SolveSystem(IEnumerable<Entity> inputEquations, ReadOnlySpan<Variable> vars)
{
var equations = new List<Entity>(inputEquations.Select(equation => equation.InnerSimplified));
Expand All @@ -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();
}
}

/// <summary>Solves system of equations</summary>
Expand All @@ -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
/// </param>
internal static List<List<Entity>> InSolveSystem(List<Entity> equations, ReadOnlySpan<Variable> vars, Entity nameSource)
/// <param name="ledger">
/// 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.
/// </param>
internal static List<List<Entity>> InSolveSystem(
List<Entity> equations, ReadOnlySpan<Variable> 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)
{
Expand Down Expand Up @@ -136,7 +207,7 @@ internal static List<List<Entity>> InSolveSystem(List<Entity> 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++)
Expand Down
7 changes: 6 additions & 1 deletion Sources/Tests/UnitTests/Calculus/OneSidedLimitTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
48 changes: 42 additions & 6 deletions Sources/Tests/UnitTests/Core/Budgets/BudgetTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -112,18 +116,44 @@ public void TheStepCountIsAFunctionOfTheInput()
}

/// <summary>
/// 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.
/// </summary>
[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);
}

/// <summary>
/// A budget that <em>is</em> reached bounds the whole call, and the caller is told.
/// </summary>
/// <remarks>
/// <para>
/// 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
/// <a href="https://github.com/asc-community/AngouriMath/issues/896">#896</a>: the
/// triangularising path bounded itself and then handed the problem to an elimination
/// with no budget of its own, so the same <c>Solve</c> was bounded or not depending on
/// which internal path accepted it.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[Fact]
public void ABudgetThatIsReachedBoundsTheWholeCall()
{
using var _ = MathS.Settings.Budget.Set(new WorkBudget { Steps = 1 });
Assert.Throws<AngouriMath.Core.Exceptions.NotSufficientlySupportedException>(
() => MathS.Equations(Eqs("x - y + 3", "y + 2")).Solve(Vars("x", "y")));
}

/// <summary>
/// The first ceiling to fire is the one reported: whatever the algorithm asks on its
/// way out cannot overwrite the reason it is leaving.
Expand Down Expand Up @@ -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);
}
}
Expand Down
Loading
Loading