diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index 83a62c6da..aad6eccdf 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -42,6 +42,26 @@ A not-equal written `<>` is read as `not (a = b)`, and printed that way. ## Unreleased — since 2.6.0 +### A system with more or fewer equations than unknowns is solved or declined + +**Not silent: what was an exception is an answer or `null`.** `EquationSystem.Solve` threw +`WrongNumberOfArgumentsException` for a system with more or fewer equations than unknowns, unless it was polynomial +over the rationals with rational solutions or had fewer equations and was linear with rational coefficients. Such a +system is now eliminated one unknown at a time, and an unknown that nothing is left to constrain becomes a parameter, +`t_1`, as in the linear case. Every row is substituted back into every equation and kept only where each simplifies to +zero. Where one does not, or where a branch cannot be decided, the system is declined as `null`, which `Solve` +documents as no solution found ([#212](https://github.com/asc-community/AngouriMath/issues/212)). Code that caught the +exception around `Solve` to detect a non-square system no longer sees it. + +| Input | Was (2.6.0) | Now | +|---|---|---| +| `MathS.Equations("x^2 + y^2 - 1").Solve("x", "y")` | `WrongNumberOfArgumentsException` | `[[t_1, sqrt(-(t_1 ^ 2 + -1))], [t_1, -sqrt(-(t_1 ^ 2 + -1))]]` | +| `MathS.Equations("a*x + y - 1").Solve("x", "y")` | `WrongNumberOfArgumentsException` | `[[t_1, -(a * t_1 + -1)]]` | +| `MathS.Equations("sin(x)", "x").Solve("x")` | `WrongNumberOfArgumentsException` | `[0]` | +| `MathS.Equations("sin(x) - y", "cos(x) - y", "x - pi/4").Solve("x", "y")` | `WrongNumberOfArgumentsException` | `[[pi / 4, 1/2 * sqrt(2)]]` | +| `MathS.Equations("sqrt(y) - x").Solve("x", "y")` | `WrongNumberOfArgumentsException` | `null`: `y = x^2` is no solution where `x` is negative | +| `MathS.Equations("x + y - a", "x - y - b", "x - c").Solve("x", "y")` | `WrongNumberOfArgumentsException` | `null`: there are solutions only where `a + b = 2c` | + ### `am_classify` is a preliminary tool, served by `amcli mcp --preliminary` **Not silent: what answered is a failure naming the flag.** `am_classify` guesses which field a formula comes from by diff --git a/Sources/.editorconfig b/Sources/.editorconfig index 45b05543c..b1324ec83 100644 --- a/Sources/.editorconfig +++ b/Sources/.editorconfig @@ -252,9 +252,15 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed [AngouriMath/Functions/Continuous/Solvers/EquationSolver/LinearSystemSolver.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/Continuous/Solvers/EquationSolver/NonSquareSystemSolver.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/Continuous/Solvers/EquationSolver/ModulusSolver.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/SolveTest/NonSquareSystemTest.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/SolveTest/ParametricSystemTest.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/Core/EquationSystem.cs b/Sources/AngouriMath/Core/EquationSystem.cs index 65175390e..1dfe06237 100644 --- a/Sources/AngouriMath/Core/EquationSystem.cs +++ b/Sources/AngouriMath/Core/EquationSystem.cs @@ -67,7 +67,10 @@ private static Entity AsAnExpressionEqualToZero(Entity equation) /// Solves a system of equations, if possible. /// /// - /// The umber of variables must match number of equations + /// The unknowns, as many as there are equations or not. Where there are more, or where a + /// system leaves one free, it becomes a parameter in the answer: x^2 + y^2 = 1 for + /// x and y is two rows, x = t_1 with y the positive and the + /// negative square root of 1 - t_1^2. /// /// /// If there exists at least one solution, returns a solution matrix diff --git a/Sources/AngouriMath/Docs/Usage/Exceptions.md b/Sources/AngouriMath/Docs/Usage/Exceptions.md index fb1045c7b..d493560d4 100644 --- a/Sources/AngouriMath/Docs/Usage/Exceptions.md +++ b/Sources/AngouriMath/Docs/Usage/Exceptions.md @@ -216,9 +216,9 @@ largest base there are digits for, and the base that was asked for. ### `WrongNumberOfArgumentsException` An API call whose arity does not match: a compiled function called with the wrong number of values, -a truth table asked for over a different number of variables than the expression has, a system of -equations with more or fewer equations than unknowns. Where the expression is in scope the message -names it and its variables. +or a truth table asked for over a different number of variables than the expression has. Where the +expression is in scope the message names it and its variables. A system of equations with more or +fewer equations than unknowns is not one: it is solved, or declined as `null`. Compare `FunctionArgumentCountException`, which is the same complaint about a function call written inside a string. **Use `FunctionArgumentCountException` from the parser and this one from diff --git a/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver.cs b/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver.cs index de4091918..902ebc064 100644 --- a/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver.cs +++ b/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver.cs @@ -127,8 +127,8 @@ internal static Set Solve(Entity equation, Variable x) if (Groebner.GroebnerSystemSolver.TrySolve(equations, variables, out var triangularised)) return triangularised; - // Fewer equations than unknowns is not a mistake by the caller, which is what - // the exception below says it is. A linear system of that shape has infinitely + // Fewer equations than unknowns is not a mistake by the caller, which is what an + // exception here used to say it was. A linear system of that shape has infinitely // many solutions and one family that describes them all, so it is answered as // that family -- the unknowns a row reduction leaves free become parameters. // Only the short count is taken here: a square system that is rank-deficient @@ -139,8 +139,14 @@ internal static Set Solve(Entity equation, Variable x) equations, variables, out var family)) return family; + // Nor is any other count: `x^2 + y^2 = 1` has a family of solutions in a + // parameter, and `sin(x) = 0` with `x = 0` has one solution. Those are eliminated, + // and a system whose answer cannot be shown to hold is declined, as `Solve` + // documents. https://github.com/asc-community/AngouriMath/issues/212 if (equations.Count != vars.Length) - throw new WrongNumberOfArgumentsException("Number of equations must be equal to that of vars"); + return NonSquareSystemSolver.Solve(equations, variables, ledger) is { } families + ? new MatrixBuilder(families, vars.Length).ToMatrix() + : null; int initVarCount = vars.Length; var res = InSolveSystem(equations, vars, Sumf.Sum(equations), ledger); diff --git a/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/NonSquareSystemSolver.cs b/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/NonSquareSystemSolver.cs new file mode 100644 index 000000000..4f8b648e6 --- /dev/null +++ b/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/NonSquareSystemSolver.cs @@ -0,0 +1,231 @@ +// +// 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 AngouriMath.Core.Budgets; +using AngouriMath.Core.Exceptions; +using System; +using static AngouriMath.Entity; +using static AngouriMath.Entity.Number; +using static AngouriMath.Entity.Set; + +namespace AngouriMath.Functions.Algebra.AnalyticalSolving +{ + /// + /// A system with more or fewer equations than unknowns that neither the triangularisation nor + /// takes, such as x^2 + y^2 = 1 for x and + /// y, or sin(x) = 0 and x = 0 for x. Each unknown is eliminated in + /// turn, an unknown that nothing is left to constrain becomes a parameter, and every family + /// found is substituted back into every equation. + /// #212 + /// + /// + /// + /// An answer is given only where it is shown to hold. The elimination trusts each solve + /// it makes, and a solve can answer for a case its equation does not cover: sqrt(y) = x + /// is solved for y as x^2, which is no solution where x is negative. So + /// each family is kept only where every equation, with the family substituted, simplifies to + /// 0. sqrt(t^2) - t does not, and that system is declined. + /// + /// + /// A decline declines the whole system. An equation left with no unknown in it is zero, + /// a number away from zero, or neither as far as can be told, like (a + b)/2 - c. A + /// branch where one is away from zero has no solutions and is dropped. A branch where one + /// cannot be told gives no answer for the system, rather than leaving the other branches to + /// read as all of its solutions. + /// + /// + /// A square system does not come here. It is the eliminator's in + /// , whose answers this would change. + /// + /// + internal static class NonSquareSystemSolver + { + /// + /// The solutions of in , a list of + /// values in their order for each family, or where none were found. + /// + internal static List>? Solve(List equations, Variable[] variables, BudgetLedger ledger) + { + // One parameter per unknown that is left free, the same in every branch. Each is + // minted against the equations and the parameters already minted, since CreateUnique + // compares whole names in use and would otherwise hand back the same name twice. + var parameters = new Variable?[variables.Length]; + Entity nameSource = equations.Count > 0 ? Sumf.Sum(equations) : Integer.Zero; + Variable ParameterFor(int index) + { + if (parameters[index] is { } minted) + return minted; + var parameter = Variable.CreateUnique(nameSource, "t"); + nameSource += parameter; + return parameters[index] = parameter; + } + + if (Eliminate(equations, variables, variables.Length, ParameterFor, ledger) is not { Count: > 0 } families) + return null; + foreach (var family in families) + foreach (var equation in equations) + { + var substituted = equation; + for (var index = 0; index < variables.Length; index++) + substituted = substituted.Substitute(variables[index], family[index]); + if (!Vanishes(substituted)) + return null; + } + return families; + } + + /// + /// The solutions in the first of , each + /// a list of their values in order. Empty where there are none, and + /// where that could not be told. + /// + private static List>? Eliminate(List equations, Variable[] variables, int count, + Func parameterFor, BudgetLedger ledger) + { + // Charged as InSolveSystem charges it: one unit for each candidate explored. + if (!ledger.Spend()) + throw new NotSufficientlySupportedException( + "this system of equations is not solvable within the budget. Raise " + + "MathS.Settings.Budget to allow more, or pass a cancellation token with " + + "MathS.Multithreading.SetLocalCancellationToken"); + + // No unknown is left, so each equation holds as it stands or does not. + if (count == 0) + { + var told = true; + foreach (var equation in equations) + switch (Holds(equation)) + { + case false: + return new(); + case null: + told = false; + break; + } + return told ? new() { new() } : null; + } + + // No equation is left, so every unknown left is free. + if (equations.Count == 0) + { + var free = new List(); + for (var index = 0; index < count; index++) + free.Add(parameterFor(index)); + return new() { free }; + } + + var variable = variables[count - 1]; + // The equation with the fewest occurrences of the unknown first, which is solved by + // inverting it and has the plainest roots: of `cos(x) = sin(x)` and `x = pi/4`, the + // first is solved as ln(sqrt(i))/i and the second as pi/4. + var candidates = Enumerable.Range(0, equations.Count) + .Where(i => equations[i].ContainsNode(variable)) + .OrderBy(i => equations[i].Nodes.Count(node => node == variable)) + .ThenBy(i => equations[i].Complexity) + .ToList(); + foreach (var i in candidates) + { + // An empty set is passed over as InSolveSystem passes it over: substituting an + // earlier unknown can leave an occurrence that cancels, and then the solve has + // nothing to say about this one. + if (equations[i].SolveEquation(variable).InnerSimplified is not FiniteSet roots || roots.Count == 0) + continue; + var rest = new List(equations); + rest.RemoveAt(i); + if (EachRoot(roots, rest, variables, count, parameterFor, ledger) is { } solutions) + return solutions; + // This equation's roots left a question that could not be told. Another + // equation's may not: `sin(x) = 0` gives x = 2 pi n_1, where `x = 0` gives 0. + } + + if (candidates.Count > 0) + return null; + // Nothing constrains this unknown, so it is a parameter in every solution of the rest. + var others = Eliminate(equations, variables, count - 1, parameterFor, ledger); + others?.ForEach(family => family.Add(parameterFor(count - 1))); + return others; + } + + /// + /// The solutions where the last of the unknowns is each of in + /// turn, or where that could not be told for one of them. + /// + private static List>? EachRoot(FiniteSet roots, List rest, Variable[] variables, int count, + Func parameterFor, BudgetLedger ledger) + { + var variable = variables[count - 1]; + var solutions = new List>(); + foreach (var root in roots) + { + var substituted = rest.Select(equation => equation.Substitute(variable, root)).ToList(); + if (Eliminate(substituted, variables, count - 1, parameterFor, ledger) is not { } families) + return null; + foreach (var family in families) + { + var value = root; + for (var index = 0; index < count - 1; index++) + value = value.Substitute(variables[index], family[index]); + family.Add(value.InnerSimplified); + solutions.Add(family); + } + } + return solutions; + } + + /// + /// Whether , with no unknown left in it, holds: + /// where it simplifies to zero, where it is a + /// number away from zero, and where neither can be told. + /// + private static bool? Holds(Entity equation) + { + if (Vanishes(equation)) + return true; + if (equation.Vars.Any() || equation.Evaled is not Complex value || value.IsNaN) + return null; + // An exact number that is not zero is not zero. A decimal is taken as away from zero + // only well past the digits its evaluation may have lost. + if (value is Rational) + return false; + return ((Real)value.Abs()).EDecimal.ToDouble() > 1e-9 ? false : null; + } + + /// + /// Whether is zero for every value of its symbols: zero as it + /// is evaluated, or simplified to zero. A simplification to 0 provided C counts only + /// where C holds with the symbols pinned, since t - c (t/c) simplifies to + /// 0 provided not c = 0 for a c that is zero without being written + /// 0, such as (a + 1)^2 - a^2 - 2a - 1. + /// + private static bool Vanishes(Entity expression) + { + if (expression.InnerSimplified.Evaled is Complex { IsZero: true }) + return true; + // Away from zero at a point is not zero everywhere, which settles most without + // simplifying. + if (Pinned(expression).Evaled is Complex { IsNaN: false } atAPoint + && ((Real)atAPoint.Abs()).EDecimal.ToDouble() > 1e-9) + return false; + return expression.Simplify() switch + { + Providedf(var simplified, var condition) => + simplified.Evaled is Complex { IsZero: true } && Pinned(condition).Evaled is Entity.Boolean { Value: true }, + var simplified => simplified.Evaled is Complex { IsZero: true }, + }; + } + + /// with each of its symbols at a fixed value. + private static Entity Pinned(Entity expression) + { + var values = new[] { 1.37, 0.61, 2.23, 1.91, 0.83, 1.13 }; + var index = 0; + foreach (var symbol in expression.Vars.ToList()) + expression = expression.Substitute(symbol, values[index++ % values.Length]); + return expression; + } + } +} diff --git a/Sources/Tests/UnitTests/Algebra/SolveTest/NonSquareSystemTest.cs b/Sources/Tests/UnitTests/Algebra/SolveTest/NonSquareSystemTest.cs new file mode 100644 index 000000000..311a57b01 --- /dev/null +++ b/Sources/Tests/UnitTests/Algebra/SolveTest/NonSquareSystemTest.cs @@ -0,0 +1,109 @@ +// +// 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 AngouriMath; +using AngouriMath.Extensions; +using System.Linq; +using Xunit; + +namespace AngouriMath.Tests.Algebra +{ + /// + /// Systems with more or fewer equations than unknowns that are not linear over the + /// rationals, which answers. Each of these raised + /// WrongNumberOfArgumentsException, and is now answered or declined as + /// . + /// #212 + /// + [Trait("Area", "Algebra")] + public sealed class NonSquareSystemTest + { + private static Entity.Matrix? Solve(string[] equations, string[] variables) + => MathS.Equations(equations.Select(e => e.ToEntity()).ToArray()) + .Solve(variables.Select(v => (Entity.Variable)v).ToArray()); + + /// + /// Each row, substituted into each equation, is zero at several values of the parameters + /// and symbols left in it, negative ones among them. That is checked numerically rather + /// than by simplifying, which is how the solver checks it: sqrt(t^2) - t is zero at + /// every positive t and at no negative one. + /// + private static void HoldsAtSampledValues(string[] equations, string[] variables, Entity.Matrix solutions) + { + var samples = new[] { 1.37, -1.7, 2.3, -0.61 }; + for (var row = 0; row < solutions.RowCount; row++) + foreach (var text in equations) + { + Entity residual = text.ToEntity(); + for (var column = 0; column < variables.Length; column++) + residual = residual.Substitute(variables[column], solutions[row, column]); + var symbols = residual.Vars.ToList(); + for (var shift = 0; shift < samples.Length; shift++) + { + var pinned = residual; + for (var i = 0; i < symbols.Count; i++) + pinned = pinned.Substitute(symbols[i], samples[(shift + i) % samples.Length]); + var value = Assert.IsAssignableFrom(pinned.Evaled); + Assert.False(value.IsNaN, $"{residual} at {pinned}"); + Assert.True(((Entity.Number.Real)value.Abs()).EDecimal.ToDouble() < 1e-9, $"{residual} at {pinned} is {value}"); + } + } + } + + /// + /// Fewer equations than unknowns, not linear in them or with a symbol for a coefficient: + /// an unknown that is left free is a parameter, and a root with two values is two rows. + /// + [Theory] + [InlineData(new[] { "x^2 + y^2 - 1" }, new[] { "x", "y" }, 2)] + [InlineData(new[] { "x*y - 1" }, new[] { "x", "y" }, 1)] + [InlineData(new[] { "a*x + y - 1" }, new[] { "x", "y" }, 1)] + [InlineData(new[] { "sin(x) + y" }, new[] { "x", "y" }, 1)] + [InlineData(new[] { "e^x - y", "y - z" }, new[] { "x", "y", "z" }, 1)] + [InlineData(new[] { "x^2 + y - 1" }, new[] { "x", "y", "z" }, 1)] + public void AShortSystemIsAFamily(string[] equations, string[] variables, int rows) + { + var solutions = Solve(equations, variables); + Assert.NotNull(solutions); + Assert.Equal(rows, solutions!.RowCount); + Assert.Equal(variables.Length, solutions.ColumnCount); + HoldsAtSampledValues(equations, variables, solutions); + } + + /// + /// More equations than unknowns, which the triangularisation does not take because they + /// are not polynomial. The last drops x = -1, where sin(-pi/2) - 1 is -2. + /// + [Theory] + [InlineData(new[] { "sin(x)", "x" }, new[] { "x" }, new[] { "0" })] + [InlineData(new[] { "e^x - 1", "x + y", "y" }, new[] { "x", "y" }, new[] { "0", "0" })] + [InlineData(new[] { "sin(x) - y", "cos(x) - y", "x - pi/4" }, new[] { "x", "y" }, new[] { "pi/4", "sqrt(2)/2" })] + [InlineData(new[] { "x^2 - 1", "sin(pi*x/2) - 1" }, new[] { "x" }, new[] { "1" })] + public void AnOverdeterminedSystemIsSolved(string[] equations, string[] variables, string[] solution) + { + var solutions = Solve(equations, variables); + Assert.NotNull(solutions); + Assert.Equal(1, solutions!.RowCount); + for (var column = 0; column < variables.Length; column++) + Assert.Equal(Entity.Number.Integer.Create(0), (solutions[0, column] - solution[column]).Simplify()); + } + + /// + /// What the elimination finds and cannot show to hold. sqrt(y) = x is solved for + /// y as x^2, no solution where x is negative. The coefficient of + /// y in the second is zero without being written so, and dividing by it gives a + /// family that Simplify calls zero provided it is not zero. The third has solutions + /// only where a + b = 2c. + /// + [Theory] + [InlineData(new[] { "sqrt(y) - x" }, new[] { "x", "y" })] + [InlineData(new[] { "x^2 - ((a + 1)^2 - a^2 - 2*a - 1)*y" }, new[] { "x", "y" })] + [InlineData(new[] { "x + y - a", "x - y - b", "x - c" }, new[] { "x", "y" })] + public void WhatCannotBeShownToHoldIsDeclined(string[] equations, string[] variables) + => Assert.Null(Solve(equations, variables)); + } +} diff --git a/Sources/Tests/UnitTests/Algebra/SolveTest/ParametricSystemTest.cs b/Sources/Tests/UnitTests/Algebra/SolveTest/ParametricSystemTest.cs index 7c0281561..0de312f73 100644 --- a/Sources/Tests/UnitTests/Algebra/SolveTest/ParametricSystemTest.cs +++ b/Sources/Tests/UnitTests/Algebra/SolveTest/ParametricSystemTest.cs @@ -6,7 +6,6 @@ // using AngouriMath; -using AngouriMath.Core.Exceptions; using AngouriMath.Extensions; using System.Linq; using Xunit; @@ -124,22 +123,6 @@ public void AContradictoryShortSystemHasNoSolutions() => Assert.Null(MathS.Equations(new Entity[] { "x + y - 1", "2*x + 2*y - 3" }) .Solve("x", "y", "z")); - /// - /// What is not taken here, and still reaches the old refusal. Linearity is checked rather - /// than assumed, so a product or a power of the unknowns fails the check; and a - /// coefficient that is not rational is refused because a row reduction has to decide - /// whether a pivot is zero, which is not decidable for a symbol. - /// - [Theory] - [InlineData(new[] { "x^2 + y^2 - 1" }, new[] { "x", "y" })] - [InlineData(new[] { "x*y - 1" }, new[] { "x", "y" })] - [InlineData(new[] { "a*x + y - 1" }, new[] { "x", "y" })] - [InlineData(new[] { "sin(x) + y" }, new[] { "x", "y" })] - public void WhatIsNotLinearInTheUnknownsIsRefused(string[] equations, string[] variables) - => Assert.Throws( - () => MathS.Equations(equations.Select(e => e.ToEntity()).ToArray()) - .Solve(variables.Select(v => (Entity.Variable)v).ToArray())); - /// /// The neighbours must answer exactly as they did: a determined system, an /// overdetermined but consistent one, and a contradictory one. Only the short count