diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md
index fa097778d..a3ed4d206 100644
--- a/BREAKING-CHANGES.md
+++ b/BREAKING-CHANGES.md
@@ -42,6 +42,24 @@ A not-equal written `<>` is read as `not (a = b)`, and printed that way.
## Unreleased — since 2.6.0
+### A system with a product equation is solved branch by branch
+
+**Silent: solutions that were missing are answered.** `EquationSystem.Solve` eliminated `x * y = 0` for `y` as `0`,
+which is the answer only where `x` is not zero, and lost the solutions where it is, in a system the triangularisation
+does not take. A system with a product in more than one of its unknowns is now solved once for each factor, and its
+solutions are theirs together, each put back into the system as written. A row is dropped where an equation, with the
+row put in, is not a finite number, as `x ln(y)` is at `(0, 0)`. A square system with an unknown that no equation
+mentions answers it as a parameter, as a short system does, where it was `null`
+([#2128](https://github.com/asc-community/AngouriMath/issues/2128)).
+
+| Input | Was (2.6.0) | Now |
+|---|---|---|
+| `MathS.Equations("x*y", "sin(x) + y - 1").Solve("x", "y")` | `[[pi / 2 + 2 * pi * n_1, 0], [pi - pi / 2 + 2 * pi * n_1, 0]]` | `[[0, 1], [pi / 2 + 2 * pi * n_1, 0], [pi - pi / 2 + 2 * pi * n_1, 0]]` |
+| `MathS.Equations("(x - 1)*(y - 2)", "e^x - y").Solve("x", "y")` | `[[ln(2), 2]]` | `[[1, e], [ln(2), 2]]` |
+| `MathS.Equations("x*e^y", "x^2*y").Solve("x", "y")` | `[[0, 0]]` | `[[0, t_1], [0, 0]]` |
+| `MathS.Equations("x - 1", "x^2 - 1").Solve("x", "y")` | `null` | `[[1, t_1]]` |
+| `MathS.Equations("a - 1", "b * 0").Solve("a", "b")` | `null` | `[[1, t_1]]` |
+
### A polynomial system is not answered with some of its solutions missing
**Silent: a polynomial system that lost solutions has all of them.** `EquationSystem.Solve` triangularises a
diff --git a/Sources/.editorconfig b/Sources/.editorconfig
index d220dbbc3..b9d119287 100644
--- a/Sources/.editorconfig
+++ b/Sources/.editorconfig
@@ -264,6 +264,9 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed
[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
+[Tests/UnitTests/Algebra/SolveTest/ProductSystemTest.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/SpuriousSystemRowTest.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 168cd83f4..820c750ff 100644
--- a/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver.cs
+++ b/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver.cs
@@ -140,11 +140,21 @@ internal static Set Solve(Entity equation, Variable x)
equations, variables, out var family))
return family;
+ // A product is zero where any one of its factors is, and the elimination solves
+ // `x * y = 0` for `y` as `0`, which is the answer only where `x` is not zero. So a
+ // system with a product in more than one of its unknowns is solved once for each
+ // factor, and its solutions are theirs together.
+ // https://github.com/asc-community/AngouriMath/issues/2128
+ if (Branches(equations, variables) is { } branches)
+ return Together(branches, written, vars, variables);
+
// 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)
+ // An unknown that no equation mentions is free, as one is in a short system, and
+ // the eliminator has no equation to take it from.
+ if (equations.Count != vars.Length || variables.Any(variable => !equations.Any(equation => equation.ContainsNode(variable))))
return NonSquareSystemSolver.Solve(equations, variables, ledger) is { } families
? new MatrixBuilder(families, vars.Length).ToMatrix()
: null;
@@ -171,6 +181,75 @@ internal static Set Solve(Entity equation, Variable x)
}
}
+ /// The most systems a system is split into by its products.
+ private const int MostBranches = 64;
+
+ ///
+ /// One system for each way of choosing a factor of each product in more than one of
+ /// , with the product replaced by the factor, or
+ /// where there is no such product or the choices are too many.
+ ///
+ ///
+ /// A product whose factors all mention the same unknowns is left alone: solving it for
+ /// any of them reads every factor, as x (x - 1) is solved for x as
+ /// 0 and 1.
+ ///
+ private static List>? Branches(List equations, Variable[] variables)
+ {
+ var branches = new List> { new(equations) };
+ var split = false;
+ for (var i = 0; i < equations.Count; i++)
+ {
+ if (equations[i] is not Mulf)
+ continue;
+ var factors = Mulf.LinearChildren(equations[i]).Where(factor => variables.Any(factor.ContainsNode)).ToList();
+ if (factors.Count < 2)
+ continue;
+ var first = variables.Where(factors[0].ContainsNode).ToList();
+ if (factors.All(factor => variables.Where(factor.ContainsNode).SequenceEqual(first)))
+ continue;
+ if (branches.Count * factors.Count > MostBranches)
+ return null;
+ split = true;
+ branches = branches.SelectMany(branch => factors.Select(factor =>
+ {
+ var chosen = new List(branch);
+ chosen[i] = factor;
+ return chosen;
+ })).ToList();
+ }
+ return split ? branches : null;
+ }
+
+ ///
+ /// The solutions of each of together, each kept only where
+ /// it satisfies the system as written: a factor can be zero where another is not
+ /// defined, as x ln(y) is at x = 0 and y = 0.
+ ///
+ ///
+ /// A branch answered by is taken to have no solutions, which is
+ /// what says of it: x e^y = 0 has none where
+ /// e^y is zero.
+ ///
+ private static Matrix? Together(List> branches, List written, ReadOnlySpan vars, Variable[] variables)
+ {
+ var rows = new List>();
+ foreach (var branch in branches)
+ {
+ if (SolveSystem(branch, vars) is not { } solved)
+ continue;
+ for (var row = 0; row < solved.RowCount; row++)
+ {
+ var values = Enumerable.Range(0, solved.ColumnCount).Select(column => solved[row, column]).ToList();
+ if (!rows.Any(other => other.SequenceEqual(values)))
+ rows.Add(values);
+ }
+ }
+ if (rows.Count == 0 || RowsThatHold(written, variables, rows) is not { Count: > 0 } kept)
+ return null;
+ return new MatrixBuilder(kept, vars.Length).ToMatrix();
+ }
+
///
/// The rows of that satisfy each of ,
/// or where a row is shown not to for some value of the symbols
@@ -180,8 +259,9 @@ internal static Set Solve(Entity equation, Variable x)
///
/// A row is dropped on the evidence a root of one equation is dropped on,
/// : an equation that, with the row put
- /// in, has no symbol left and is a number away from zero. Such a row is no solution for
- /// any value of anything, so dropping it loses none.
+ /// in, has no symbol left and is a number away from zero, or is no finite number at all, as
+ /// x ln(y) is at (0, 0). Such a row is no solution for any value of anything,
+ /// so dropping it loses none.
///
///
/// A row with symbols left in it is tried at one value of them, and one that fails there
@@ -207,7 +287,8 @@ internal static Set Solve(Entity equation, Variable x)
for (var index = 0; index < variables.Length; index++)
replacements[variables[index]] = row[index];
var residuals = equations.Select(equation => equation.Substitute(replacements)).ToList();
- if (residuals.Any(residual => !residual.Vars.Any() && StatementSolver.IsEvidenceAgainst(residual)))
+ if (residuals.Any(residual => !residual.Vars.Any()
+ && (residual.Evaled is Complex { IsFinite: false } || StatementSolver.IsEvidenceAgainst(residual))))
continue;
if (residuals.Any(residual => residual.Vars.Any() && StatementSolver.IsEvidenceAgainst(AtASamplePoint(residual, own))))
return null;
diff --git a/Sources/Tests/UnitTests/Algebra/SolveTest/ProductSystemTest.cs b/Sources/Tests/UnitTests/Algebra/SolveTest/ProductSystemTest.cs
new file mode 100644
index 000000000..8c7be1697
--- /dev/null
+++ b/Sources/Tests/UnitTests/Algebra/SolveTest/ProductSystemTest.cs
@@ -0,0 +1,113 @@
+//
+// 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 an equation that is a product in more than one unknown, which the
+ /// elimination solved for one unknown as if the other factors were not zero: x y = 0
+ /// for y is 0, and the branch x = 0 was lost. Each is now solved once
+ /// for each factor.
+ /// #2128
+ ///
+ [Trait("Area", "Algebra")]
+ public sealed class ProductSystemTest
+ {
+ private static Entity.Matrix? Solve(params string[] equations)
+ => MathS.Equations(equations.Select(e => e.ToEntity()).ToArray()).Solve("x", "y");
+
+ ///
+ /// Each row satisfies each equation, at several values of what is left in it: an
+ /// integer parameter at whole numbers, anything else at values of both signs.
+ ///
+ private static void EveryRowHolds(Entity.Matrix solutions, params string[] equations)
+ {
+ for (var row = 0; row < solutions.RowCount; row++)
+ foreach (var equation in equations)
+ {
+ var residual = equation.ToEntity().Substitute("x", solutions[row, 0]).Substitute("y", solutions[row, 1]);
+ foreach (var at in new[] { -1, 0, 2 })
+ {
+ var pinned = residual;
+ foreach (var symbol in residual.Vars.ToList())
+ pinned = pinned.Substitute(symbol, symbol.Name.StartsWith("n_") ? at : at + 0.3);
+ var value = Assert.IsAssignableFrom(pinned.Evaled);
+ Assert.True(((Entity.Number.Real)value.Abs()).EDecimal.ToDouble() < 1e-9, $"{equation} at row {row}, {at}: {value}");
+ }
+ }
+ }
+
+ private static bool HasRow(Entity.Matrix solutions, string x, string y)
+ => Enumerable.Range(0, solutions.RowCount).Any(row =>
+ (solutions[row, 0] - x).Simplify() == 0 && (solutions[row, 1] - y).Simplify() == 0);
+
+ ///
+ /// The branch the elimination lost: (0, 1) for the first two, where x is
+ /// zero, and (1, e) for the third, where x - 1 is.
+ ///
+ [Theory]
+ [InlineData("x * y", "sin(x) + y - 1", 3, "0", "1")]
+ [InlineData("x * y", "y - cos(x)", 3, "0", "1")]
+ [InlineData("(x - 1) * (y - 2)", "e^x - y", 2, "1", "e")]
+ [InlineData("(x - 1) * (y - 2)", "e^x - y", 2, "ln(2)", "2")]
+ public void EachFactorIsABranch(string first, string second, int rows, string x, string y)
+ {
+ var solutions = Solve(first, second);
+ Assert.NotNull(solutions);
+ Assert.Equal(rows, solutions!.RowCount);
+ Assert.True(HasRow(solutions, x, y), $"({x}, {y}) is not among {solutions}");
+ EveryRowHolds(solutions, first, second);
+ }
+
+ ///
+ /// x e^y = 0 and x^2 y = 0 hold wherever x is zero, which was
+ /// answered as the one point (0, 0).
+ ///
+ [Fact]
+ public void ABranchThatLeavesAnUnknownFreeIsAFamily()
+ {
+ var solutions = Solve("x * e^y", "x^2 * y");
+ Assert.NotNull(solutions);
+ Assert.Contains(Enumerable.Range(0, solutions!.RowCount),
+ row => solutions[row, 0] == 0 && solutions[row, 1] is Entity.Variable);
+ EveryRowHolds(solutions, "x * e^y", "x^2 * y");
+ }
+
+ ///
+ /// The branch x = 0 of x ln(y) = 0 with x = y is (0, 0),
+ /// where ln(0) is not a number and the product is not zero.
+ ///
+ [Fact]
+ public void AFactorZeroWhereAnotherIsUndefinedIsNoSolution()
+ {
+ var solutions = Solve("x * ln(y)", "x - y");
+ Assert.NotNull(solutions);
+ Assert.Equal(1, solutions!.RowCount);
+ Assert.True(HasRow(solutions, "1", "1"));
+ }
+
+ ///
+ /// A branch can leave an unknown that no equation mentions, and so can a caller. It is
+ /// a parameter, as in a system with fewer equations than unknowns; the elimination had
+ /// no equation to take it from and answered nothing.
+ ///
+ [Fact]
+ public void AnUnknownNoEquationMentionsIsAParameter()
+ {
+ var solutions = Solve("x - 1", "x^2 - 1");
+ Assert.NotNull(solutions);
+ Assert.Equal(1, solutions!.RowCount);
+ Assert.Equal(Entity.Number.Integer.Create(1), solutions[0, 0]);
+ Assert.IsType(solutions[0, 1]);
+ }
+ }
+}
diff --git a/Sources/Tests/UnitTests/Algebra/SolveTest/SolveSystem.cs b/Sources/Tests/UnitTests/Algebra/SolveTest/SolveSystem.cs
index a3a63717b..1338b6325 100644
--- a/Sources/Tests/UnitTests/Algebra/SolveTest/SolveSystem.cs
+++ b/Sources/Tests/UnitTests/Algebra/SolveTest/SolveSystem.cs
@@ -79,11 +79,15 @@ public void TestTrigSystem3() => AssertSystemSolvable(new Entity[] {
"3a+4b-4c"
}, new Entity.Variable[] { "a", "b", "c" }, 1);
+ // b * 0 is zero for every b, so b is free and the solutions are (1, t) for every t. This
+ // was answered null, because no equation was left to eliminate b from; an unknown that
+ // no equation mentions is a parameter now, as it is in a short system.
+ // https://github.com/asc-community/AngouriMath/issues/2128
[Fact]
public void TestTrigSystem4() => AssertSystemSolvable(new Entity[] {
"a - 1",
"b * 0"
- }, new Entity.Variable[] { "a", "b" }, 0);
+ }, new Entity.Variable[] { "a", "b" }, 1);
// https://www.youtube.com/watch?v=dVs26SSUJSA
[Fact]