From 53eff5e7df529073fbbce6a1736dd01d1c6249a5 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Fri, 14 Aug 2026 02:53:46 +0000 Subject: [PATCH] Fold the negation a half-turn table lookup builds, so InnerSimplified is idempotent (#930) "cos(0 ^ y)".InnerSimplified -(-1) provided y / 2 * (1 + 1 / sgn(y) ^ 2) > 0 "cos(0 ^ y)".InnerSimplified.InnerSimplified 1 provided y / 2 * (1 + 1 / sgn(y) ^ 2) > 0 Both are the same value, so nothing was wrong. What was wrong is that applying the normalisation twice gave a different tree from applying it once, and a great deal of the library treats what InnerSimplified hands back as settled -- rules match on it, caches key on it, tests compare against it. Whichever of the two trees a caller saw then depended on how many times something happened to normalise it. An exact trigonometric value is read off the table either directly or after a half turn, and the half-turn arm returns `-turned` for the odd case. That negation is built above a value already in its final form, so nothing downstream is obliged to look at it again -- and where the answer is *wrapped* rather than rebuilt, nothing does. `cos(0 ^ y)` is exactly that shape: `0 ^ y` is `0 provided ...`, and the cosine of it comes back wrapped in the same condition with the negation still standing. A bare `cos(0)` was fine because its result gets rebuilt on the way out. So the fold happens where the negation is made rather than being left to whoever receives it, which covers every table pull that goes through a half turn rather than this one expression. The exact values themselves are unchanged, which has tests: cos(pi) is -1, sin(pi/6) is 1/2, cos(pi/3) is 1/2, tan(pi/4) is 1. Found by `canoncheck`, the canonical-form harness added for #746 tier 1. Idempotence needs no oracle -- it compares a form against itself -- so it can be checked over generated input for nothing, and this was the only failure in 834 expressions. That count is now zero. The regression test compares **entities** rather than printed forms, and says why: the general class of idempotence failure includes two trees that print alike and differ, which `(x + y) + a` and `x + (y + a)` already do. Measured: suite 6960 passed / 0 failed; casbench 116/119 with 0 wrong, 0 error, 0 timeout; propcheck 1340 checks / 0 failures; rootcheck 596/596 clean; simpsweep 10463/10463 agree; canoncheck idempotence 1 failure -> 0 of 834. https://github.com/asc-community/AngouriMath/issues/930 Co-authored-by: Claude Opus 5 --- BREAKING-CHANGES.md | 22 +++++++++++++++ .../TrigonometricTableValues.cs | 9 +++++- .../UnitTests/Common/InnerSimplifyTest.cs | 28 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index 65ee2f17b..8d4f0393f 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -32,6 +32,28 @@ read first. | | `"x^4 + 3x^2 + 2".SolveEquation("x")` | `{ sqrt(-2), -sqrt(-2), i, -i }` | the same four, in the order the factors are found | | **silent** | `"1/(x^4 + 3x^2 + 2)".Integrate("x")` | unevaluated | `arctan(x) - sqrt(2) * arctan(sqrt(2) * x / 2) / 2 + C` | | **silent** | `"1/(x^4 + 4)".Integrate("x")` | unevaluated | the antiderivative over its two quadratic factors | +| **silent** | `"cos(0 ^ y)".InnerSimplified` | `-(-1) provided ...` | `1 provided ...` | + +### `InnerSimplified` is idempotent again + +An exact trigonometric value reached through a half turn — `cos(x)` read off the table as +`-cos(pi - x)` — was built as a negation over the value it turned to and handed back unfolded. Where +the answer is then *wrapped* rather than rebuilt, nothing normalises it again: + +``` +"cos(0 ^ y)".InnerSimplified + +was -(-1) provided y / 2 * (1 + 1 / sgn(y) ^ 2) > 0 +is 1 provided y / 2 * (1 + 1 / sgn(y) ^ 2) > 0 +``` + +Both are the same value, so nothing was wrong — but applying `InnerSimplified` twice gave a different +tree from applying it once, and a great deal of the library treats what it hands back as settled. +`Simplify` was unaffected, since it normalises again anyway. + +Found by `canoncheck`, the canonical-form harness: it was the only idempotence failure in 834 +generated expressions, and the count is now zero. +[#930](https://github.com/asc-community/AngouriMath/issues/930). ### A rational function is decomposed over the factors of its denominator, not only its roots diff --git a/Sources/AngouriMath/Functions/Simplification/TrigonometricTableValues.cs b/Sources/AngouriMath/Functions/Simplification/TrigonometricTableValues.cs index 38fe31cb6..8b78be55b 100644 --- a/Sources/AngouriMath/Functions/Simplification/TrigonometricTableValues.cs +++ b/Sources/AngouriMath/Functions/Simplification/TrigonometricTableValues.cs @@ -92,7 +92,14 @@ private static bool OrHalfTurn(System.Func } if (lookup(HalfTurn(arg)) is (true, { } turned)) { - res = oddOverHalfTurn ? -turned : turned; + // Folded here rather than left to whoever receives it. The negation is built + // above a value that is already in its final form, so nothing downstream is + // obliged to look at it again -- and where the answer is wrapped rather than + // rebuilt, nothing does: cos(0 ^ y) came back as `-(-1) provided ...` and + // needed a second InnerSimplified to become `1 provided ...`, which is an + // idempotence failure rather than a cosmetic one. + // https://github.com/asc-community/AngouriMath/issues/930 + res = oddOverHalfTurn ? (-turned).InnerSimplified : turned; return true; } res = null; diff --git a/Sources/Tests/UnitTests/Common/InnerSimplifyTest.cs b/Sources/Tests/UnitTests/Common/InnerSimplifyTest.cs index 34a503063..6a2cc846d 100644 --- a/Sources/Tests/UnitTests/Common/InnerSimplifyTest.cs +++ b/Sources/Tests/UnitTests/Common/InnerSimplifyTest.cs @@ -427,6 +427,34 @@ public void InequalityShouldBeNaN(string expr) { expr.ToEntity().Evaled.ShouldBe(MathS.NaN); expr.ToEntity().InnerSimplified.ShouldBe(MathS.NaN); } + + /// + /// Normalising a normalised expression leaves it alone. `cos(0 ^ y)` did not: the + /// exact value of a cosine reached through a half turn was built as a negation over + /// the value it turned to and handed back unfolded, so the answer arrived as + /// `-(-1) provided ...` and became `1 provided ...` only if something normalised it + /// again. https://github.com/asc-community/AngouriMath/issues/930 + /// + /// + /// Compared as entities rather than as strings, deliberately. The general failure here + /// includes two trees that print alike and differ — `(x + y) + a` and `x + (y + a)` do + /// — so a comparison of printed forms would wave those through. See + /// `Docs/Contributing/CanonicalForm.md`. + /// + [Theory] + [InlineData("cos(0 ^ y)")] + [InlineData("sin(0 ^ y)")] + [InlineData("cos(0)")] + [InlineData("cos(pi)")] + [InlineData("sin(pi / 6)")] + [InlineData("cos(pi / 3)")] + [InlineData("tan(pi / 4)")] + [InlineData("x + sin(0 ^ y)")] + public void InnerSimplifiedIsIdempotent(string expr) + { + var once = expr.ToEntity().InnerSimplified; + Assert.Equal(once, once.InnerSimplified); + } } }