diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index a70ca6bd5..12d497e91 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -389,6 +389,31 @@ one inside another was answered with the empty set, a claim that it has no solut | `"1/(2 + p^(-3)) = 1/3".Solve("p")` | `{ }` | `{ 1, -1/2 + i * 1/2 * sqrt(3), -1/2 + i * -1/2 * sqrt(3) }` | | `"1/(1 + x^(-2)) = y".Solve("x")` | `{ }` | `{ ((-1) / (-y) - 1) ^ (-1/2), -((-1) / (-y) - 1) ^ (-1/2) }` | +### An equation with a power of x to a number that is not real is solved rather than overflowing the stack + +**A crash becomes an answer.** A power of x to an exponent that is a number but not a real one, such as +`t ^ ln(-2)` (which is how `(-2)^x` reaches the solver), was substituted by x itself. That renamed the +variable, the same equation was asked again, and the stack overflowed and took the process down. The +substitution is declined now, and the equation goes on to the other routes. Where Newton's method answers it, a +point is a root only if the last step was small relative to the point. Without that, points crawling towards +`t = 0`, where every term vanishes, were kept: `x = -12.536 + 2.550i` was answered for `(-2)^x + 4^x = 0`, a +quarter of the way from the nearest root. A point is also not a root where a function in the equation is undefined +at exact arguments: Newton's method compiles `0^i` to 0, so `x^i + x = 0` had 0 among its roots, where `0^i` has no +value. The answers are the roots that lie within the search, not the whole +family `x = (ln(-s) + 2 pi i k) / (ln(a) - ln(b))` +([#2319](https://github.com/asc-community/AngouriMath/issues/2319)). +[#2424](https://github.com/asc-community/AngouriMath/issues/2424) + +| Input | Was (2.6.0) | Now | +|---|---|---| +| `"(-2)^x + 4^x = 0".Solve("x")` | stack overflow | nine roots `(2k + 1) pi i / (pi i - ln(2))`, `k` from -7 to 1, in decimals | +| `"(-3)^x + 9^x = 0".Solve("x")` | stack overflow | seven roots `(2k + 1) pi i / (pi i - ln(3))`, `k` from -5 to 1, in decimals | +| `"(-2)^x - 4^x = 0".Solve("x")` | stack overflow | eight roots `2k pi i / (pi i - ln(2))`, `k` from -6 to 1, in decimals | +| `"(-2)^x + 2^x = 0".Solve("x")` | stack overflow | `{ -5, -3, -1, 1, 3 }` | +| `"x^(1 + i) + x = 0".Solve("x")` | stack overflow | `0` and `e^((2k + 1) pi)` for `k` from -3 to 0, in decimals | +| `"x^i + x = 0".Solve("x")` | stack overflow | `{ -e^(pi/2) i, e^(-pi/2) i }`, in decimals | +| `"x^i + x^(2*i) = 0".Solve("x")` | stack overflow | `{ e^pi }`, in decimals | + ### A vanishing factor times an infinity is asked as a whole when read as a part **Silent: a limit with, somewhere inside it, a product of a factor tending to 0 and one tending to an infinity is diff --git a/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/ExponentialSolver.cs b/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/ExponentialSolver.cs index 0b0fb73ae..32edf38da 100644 --- a/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/ExponentialSolver.cs +++ b/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/ExponentialSolver.cs @@ -226,6 +226,13 @@ Entity ApplyPowerTransform(Entity @base, Entity arg) MultithreadingFunctional.ExitIfCancelled(); } + // A substitution of x by itself only renames the variable, and solving the renamed + // equation asks this solver the same question again, without end. It is what is left + // when every exponent is numeric but one is not real, so the case above cannot choose + // a power of x: (-2)^x + 4^x = 0 reaches here as t^ln(-2) + t^ln(4), and so does + // x^(1 + i) + x = 0. https://github.com/asc-community/AngouriMath/issues/2424 + if (substitution == x) + return null; expr = expr.Substitute(substitution, replacement); if (expr.ContainsNode(x)) return null; // cannot be solved, not a multiplicative exponenial equation diff --git a/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/SolveStatement.cs b/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/SolveStatement.cs index b867e52be..c8691bb9e 100644 --- a/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/SolveStatement.cs +++ b/Sources/AngouriMath/Functions/Continuous/Solvers/EquationSolver/SolveStatement.cs @@ -162,7 +162,8 @@ internal static Set UnsolvedWhereIndependenceIsDenied(Set roots, Entity conditio /// Whether a root demonstrably fails the equation. Anything that cannot be /// evaluated to a number -- a root still carrying a parameter, say /// pi + 2 * pi * n_1, or one whose residual is not finite -- is kept, so - /// that a root is only ever dropped on positive evidence against it. + /// that a root is only ever dropped on positive evidence against it. A function + /// undefined at exact arguments is such evidence; see . /// private static bool IsSpurious(Entity equation, Variable x, Entity root) { @@ -183,7 +184,32 @@ private static bool IsSpurious(Entity equation, Variable x, Entity root) return true; if (root.Vars.Any()) return false; - return IsEvidenceAgainst(equation.Substitute(x, root)); + var substituted = equation.Substitute(x, root); + return IsUndefinedExactly(substituted) || IsEvidenceAgainst(substituted); + } + + /// + /// Whether , with no symbol left in it, has no value because + /// some function in it is undefined at its arguments: a node whose arguments are all exact + /// numbers and whose own value is not a finite number. x^i + x at 0 is one, since + /// 0^i is undefined, so 0 is not a root of x^i + x = 0 however small the + /// compiled function Newton's method iterated on is there. + /// + /// + /// A residual that is not finite is otherwise no evidence against a root: a root found in + /// decimals carries rounding, and a function taken at it next to the edge of its domain, or + /// a term that overflows, may have no value where the root itself gives one. Exact arguments + /// have neither to come from. https://github.com/asc-community/AngouriMath/issues/2424 + /// + internal static bool IsUndefinedExactly(Entity substituted) + { + static bool IsExact(Entity argument) + => argument.Evaled is Complex value && value.RealPart is Rational && value.ImaginaryPart is Rational; + foreach (var node in substituted.Nodes) + if (node.DirectChildren.Count > 0 && node.DirectChildren.All(IsExact) + && node.Evaled is Complex { IsFinite: false }) + return true; + return false; } /// diff --git a/Sources/AngouriMath/Functions/Continuous/Solvers/NumericalSolving/NewtonSolver.cs b/Sources/AngouriMath/Functions/Continuous/Solvers/NumericalSolving/NewtonSolver.cs index 3719a1555..009d9a153 100644 --- a/Sources/AngouriMath/Functions/Continuous/Solvers/NumericalSolving/NewtonSolver.cs +++ b/Sources/AngouriMath/Functions/Continuous/Solvers/NumericalSolving/NewtonSolver.cs @@ -47,10 +47,18 @@ static Complex NewtonIter(FastExpression f, FastExpression df, NumericsComplex v { var prev = value; - NumericsComplex ChooseGood() => - NumericsComplex.Abs(prev - value) > (double)MathS.Settings.PrecisionErrorCommon.Value - ? double.NaN - : value; + // Converged means the last step was small beside the point, not only small: near a + // zero that every term shares, t ^ ln(-2) + t ^ ln(4) at t = 0, the iteration crawls + // and its steps fall under any fixed tolerance while it is still a fifth of the way + // from where it stops, and those points came back as roots. So below 1 the step is + // measured against the point; from 1 up the tolerance is what it always was. + // https://github.com/asc-community/AngouriMath/issues/2424 + NumericsComplex ChooseGood() + { + var tolerance = (double)MathS.Settings.PrecisionErrorCommon.Value; + var scale = Math.Min(1, Math.Max(NumericsComplex.Abs(value), tolerance)); + return NumericsComplex.Abs(prev - value) > tolerance * scale ? double.NaN : value; + } int minCheckIters = (int)Math.Sqrt(precision); for (int i = 0; i < precision; i++) @@ -341,11 +349,16 @@ private static Entity WithoutConditions(Entity expr) /// /// /// for anything that cannot be checked, so that a root is - /// only ever dropped on positive evidence that it is spurious. + /// only ever dropped on positive evidence that it is spurious. A point where the + /// expression is undefined at exact arguments is such evidence: the compiled form + /// takes 0^i to be 0, and x^i + x was answered with the root 0. /// private static bool Satisfies(Entity expr, Entity.Variable v, Complex root) { - return expr.Substitute(v, root).Evaled is not Complex residual + var substituted = expr.Substitute(v, root); + if (AnalyticalSolving.StatementSolver.IsUndefinedExactly(substituted)) + return false; + return substituted.Evaled is not Complex residual || !residual.IsFinite || residual.Abs().EDecimal.LessThan(MathS.Settings.PrecisionErrorCommon); } diff --git a/Sources/Tests/UnitTests/Common/ANegativeBaseExponentialSolveTerminatesTest.cs b/Sources/Tests/UnitTests/Common/ANegativeBaseExponentialSolveTerminatesTest.cs new file mode 100644 index 000000000..da3425545 --- /dev/null +++ b/Sources/Tests/UnitTests/Common/ANegativeBaseExponentialSolveTerminatesTest.cs @@ -0,0 +1,97 @@ +// +// 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 System.Threading.Tasks; +using AngouriMath; +using AngouriMath.Extensions; +using Xunit; +using NumericsComplex = System.Numerics.Complex; + +namespace AngouriMath.Tests.Common +{ + /// + /// A power of x to an exponent that is a number but not a real one was substituted by x itself, + /// which renamed the variable and asked the same equation again until the stack overflowed: + /// (-2)^x + 4^x = 0 reaches that as t^ln(-2) + t^ln(4). + /// #2424 + /// + [Trait("Area", "Common")] + public sealed class ANegativeBaseExponentialSolveTerminatesTest + { + private static readonly TimeSpan Budget = LimitTermination.Guard; + + private static Entity.Set Solved(string equation) + { + var task = Task.Run(() => equation.ToEntity().Solve("x")); + Assert.True(task.Wait(Budget), $"{equation} did not finish within {Budget.TotalSeconds}s"); + return task.Result; + } + + private static NumericsComplex Value(Entity root) + => Assert.IsAssignableFrom(root.EvalNumerical()).ToNumerics(); + + /// + /// a^x + s b^x = 0 with s = 1 or -1. With principal powers it is + /// e^(x (ln a - ln b)) = -s, so the roots are + /// x = (ln(-s) + 2 pi i k) / (ln a - ln b) for every whole k: + /// (-2)^x + 4^x = 0 has x = (2k + 1) pi i / (pi i - ln 2), the first of which is + /// 0.95358 - 0.21039i, and (-2)^x + 2^x = 0 has the odd integers. Each root + /// the solver answers is to be one of them; declining is honest, an empty set is not. + /// + [Theory] + [InlineData("(-2)^x + 4^x = 0", -2, 4, 1)] + [InlineData("(-3)^x + 9^x = 0", -3, 9, 1)] + [InlineData("(-2)^x - 4^x = 0", -2, 4, -1)] + [InlineData("(-2)^x + 2^x = 0", -2, 2, 1)] + public void TheRootsAreOnTheFamily(string equation, int a, int b, int sign) + { + var solved = Solved(equation); + if (solved is not Entity.Set.FiniteSet roots) + return; + Assert.NotEmpty(roots); + var rate = NumericsComplex.Log(a) - NumericsComplex.Log(b); + var shift = NumericsComplex.Log(-sign); + foreach (var root in roots) + { + var x = Value(root); + var left = NumericsComplex.Exp(x * NumericsComplex.Log(a)); + var right = sign * NumericsComplex.Exp(x * NumericsComplex.Log(b)); + Assert.True(NumericsComplex.Abs(left + right) <= 1e-9 * (NumericsComplex.Abs(left) + NumericsComplex.Abs(right)), + $"{root} leaves {left + right} in {equation}"); + var k = (x * rate - shift) / (2 * Math.PI * NumericsComplex.ImaginaryOne); + Assert.True(Math.Abs(k.Real - Math.Round(k.Real)) < 1e-9 && Math.Abs(k.Imaginary) < 1e-9, + $"{root} is not on the family of {equation}: k = {k}"); + } + } + + /// + /// The same substitution without a negative base. x^(1 + i) + x is + /// x (x^i + 1), whose roots are 0, where 0^(1 + i) is 0, and e^((2k + 1) pi). + /// x^i + x and x^i + x^(2i) have no root at 0, where 0^i is undefined, + /// though the function Newton's method compiles is 0 there. + /// + [Theory] + [InlineData("x^(1 + i) + x")] + [InlineData("x^i + x")] + [InlineData("x^i + x^(2*i)")] + public void AComplexExponentOfXTerminates(string expression) + { + if (Solved(expression + " = 0") is not Entity.Set.FiniteSet roots) + return; + Assert.NotEmpty(roots); + foreach (var root in roots) + { + var residual = expression.ToEntity().Substitute("x", root).EvalNumerical(); + var value = Assert.IsAssignableFrom(residual); + Assert.True(value.IsFinite && NumericsComplex.Abs(value.ToNumerics()) <= 1e-9 * Math.Max(1, NumericsComplex.Abs(Value(root))), + $"{root} leaves {residual} in {expression} = 0"); + } + } + } +}