diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index 9f1a36a05..5875d2c1c 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -58,6 +58,20 @@ as `a - b = 0`, as a statement's own solve reads it: `"x + 1 = 3".Solve("x")` wa | `MathS.Equations("x + y = 3", "x - y = 1").Solve("x", "y")` | `null` | `[[2, 1]]` | | `MathS.Equations("x + y = 3", "x - y = 1").ToString()` | `x + y = 3 = 0` and `x - y = 1 = 0` | `x + y - 3 = 0` and `x - y - 1 = 0` | +### Numerical equality is to half the evaluation's digits + +**Silent: wrong answers.** `MathS.UnsafeAndInternal.AreEqualNumerically` evaluated both sides at its check points and +compared the values exactly, so two equal expressions computed by different routes, which differ in their last digits, +read as unequal. Two values now agree when they differ by no more than half the evaluation's digits, relative to the +larger of them or to one: a difference of `1e-40` is still a difference at the default hundred digits. The limit +solver's check that a limit agrees from both sides goes through the same comparison +([#1674](https://github.com/asc-community/AngouriMath/issues/1674)). + +| Input | Was (2.6.0) | Now | +|---|---|---| +| `AreEqualNumerically("2 x (-1/4 + ln(x)/2) + 1/x * 2/4 * x^2", "x ln(x)")` | `false` | `true` | +| `AreEqualNumerically` of `x ln(x)` and its antiderivative's derivative | `false` | `true` | + --- ## 2.6.0 — since 2.5.0 diff --git a/Sources/AngouriMath/Functions/ExpressionNumerical.Equality.cs b/Sources/AngouriMath/Functions/ExpressionNumerical.Equality.cs index 3c5e3898e..763faf99f 100644 --- a/Sources/AngouriMath/Functions/ExpressionNumerical.Equality.cs +++ b/Sources/AngouriMath/Functions/ExpressionNumerical.Equality.cs @@ -6,6 +6,7 @@ // using AngouriMath.Extensions; +using PeterO.Numbers; using static AngouriMath.Entity; namespace AngouriMath.Functions @@ -47,13 +48,31 @@ static int TrueRemainder(int left, int right) var evaled2 = expr2Subs.EvalNumerical(); // TODO: should we consider NaN = {} to be true? - if (evaled1 != evaled2 && evaled1 != MathS.NaN && evaled2 != MathS.NaN) + if (evaled1 != evaled2 && evaled1 != MathS.NaN && evaled2 != MathS.NaN && !AgreeToHalfThePrecision(evaled1, evaled2)) return false; } return true; } + /// + /// Whether two values differ by no more than half the evaluation's digits, relative to the larger + /// of them or to one. Two routes to one value differ in their last digits: the derivative of + /// x ln(x)'s antiderivative, 2 x (-1/4 + ln(x)/2) + 1/x * 2/4 * x^2, is x ln(x), + /// and compared exactly at -100 and 100 the two read as unequal. A value that is not finite agrees + /// only with itself. + /// https://github.com/asc-community/AngouriMath/issues/1674 + /// + private static bool AgreeToHalfThePrecision(Number first, Number second) + { + if (first is not Number.Complex a || second is not Number.Complex b || !a.IsFinite || !b.IsFinite) + return false; + var digits = MathS.Settings.DecimalPrecisionContext.Value.Precision; + var tolerance = EDecimal.FromString("1E-" + digits.Divide(2)); + var scale = EDecimal.Max(EDecimal.One, EDecimal.Max(a.Abs().EDecimal, b.Abs().EDecimal)); + return ((Number.Complex)(a - b)).Abs().EDecimal.CompareTo(scale.Multiply(tolerance)) <= 0; + } + /// /// Checks if two expressions are equivalent if /// does not give the diff --git a/Sources/MCP/AngouriMath.Mcp/Numeric.cs b/Sources/MCP/AngouriMath.Mcp/Numeric.cs index 7964bf784..e2bbc0727 100644 --- a/Sources/MCP/AngouriMath.Mcp/Numeric.cs +++ b/Sources/MCP/AngouriMath.Mcp/Numeric.cs @@ -13,16 +13,11 @@ namespace AngouriMath.Mcp; /// /// Tolerance-based numeric equality. /// -/// Why not just use MathS.UnsafeAndInternal.AreEqualNumerically? Because despite the name it -/// compares with EXACT equality — the implementation is `if (evaled1 != evaled2 ...) return -/// false` over arbitrary-precision decimals, with no tolerance at all. Any expression -/// involving a transcendental therefore fails: ln(x) computed via two different but -/// mathematically identical routes disagrees in the last digit and the check reports the -/// expressions as different. -/// -/// That is not a hypothetical. `∫ x*ln(x)` returns the correct antiderivative, which -/// differentiates back to the correct integrand, and AreEqualNumerically still says false. -/// So this compares with a relative tolerance instead. +/// Why not MathS.UnsafeAndInternal.AreEqualNumerically? It compares to half the evaluation's +/// digits since #1674, but at its own check points, negative and complex among them, where a +/// correct antiderivative with ln(x) or abs(x) in it can disagree with its integrand on a branch, +/// and it answers only true or false. This samples positive reals and answers null where no +/// point can be evaluated, which is not evidence either way. /// https://github.com/asc-community/AngouriMath/issues/1674 /// internal static class Numeric diff --git a/Sources/MCP/README.md b/Sources/MCP/README.md index cfd5afc61..3da4fb593 100644 --- a/Sources/MCP/README.md +++ b/Sources/MCP/README.md @@ -151,8 +151,5 @@ existing tool, then a prompt, and add a tool last. A tool's description is a rou documentation: say when to call it, and where a model would wrongly trust itself, say so. This is an adapter, not a second computer algebra system. Mathematics belongs in the library, where -the terminal and the notebooks get it too, and three workarounds here wait for it: -eigenvalues by the characteristic polynomial ([#1676](https://github.com/asc-community/AngouriMath/issues/1676)), -a numeric comparison with a tolerance ([#1674](https://github.com/asc-community/AngouriMath/issues/1674)), -and the numeric definite integral run twice to find its good digits -([#1675](https://github.com/asc-community/AngouriMath/issues/1675)). +the terminal and the notebooks get it too. Where the server has to work around the library, the +workaround links the library's issue in a comment beside it, and the fix for that issue removes it. diff --git a/Sources/Tests/UnitTests/Core/AreEqualNumericallyToThePrecisionTest.cs b/Sources/Tests/UnitTests/Core/AreEqualNumericallyToThePrecisionTest.cs new file mode 100644 index 000000000..5563a8569 --- /dev/null +++ b/Sources/Tests/UnitTests/Core/AreEqualNumericallyToThePrecisionTest.cs @@ -0,0 +1,45 @@ +// +// 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.Extensions; +using Xunit; + +namespace AngouriMath.Tests.Core +{ + /// + /// compares the values at + /// its check points to half the evaluation's digits, where it compared them exactly: two routes to + /// one value differ in their last digits, and the derivative of x ln(x)'s antiderivative read + /// as unequal to x ln(x) at -100 and 100. + /// #1674 + /// + [Trait("Area", "Core")] + public sealed class AreEqualNumericallyToThePrecisionTest + { + [Theory] + [InlineData("2 * x * (-1/4 + ln(x)/2) + 1/x * 2/4 * x^2", "x * ln(x)")] + [InlineData("sin(x)^2 + cos(x)^2", "1")] + [InlineData("e^(2 * ln(x))", "x^2")] + public void TwoRoutesToOneValueAreEqual(string one, string another) + => Assert.True(MathS.UnsafeAndInternal.AreEqualNumerically(one.ToEntity(), another.ToEntity())); + + [Fact] + public void AnAntiderivativeDifferentiatedBackIsItsIntegrand() + { + var integrand = "x * ln(x)".ToEntity(); + var back = integrand.Integrate("x").Substitute("C", 0).Differentiate("x"); + Assert.True(MathS.UnsafeAndInternal.AreEqualNumerically(back, integrand)); + } + + [Theory] + // Past the tolerance, half of a hundred digits: still unequal. + [InlineData("x", "x + 1/10^20")] + [InlineData("x^2", "x^2 + 1/10^40")] + public void ADifferenceAboveThePrecisionStillCounts(string one, string another) + => Assert.False(MathS.UnsafeAndInternal.AreEqualNumerically(one.ToEntity(), another.ToEntity())); + } +}