From 20743adbe3d71befd2a82c60053347cba28d2ca5 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Tue, 27 Oct 2026 19:37:18 +0000 Subject: [PATCH] A squared sine and cosine with one coefficient are found anywhere in a sum The gathered Pythagorean rule matches sin(x)^2 and cos(x)^2 wherever they sit in a sum, but only with a coefficient of one. With a coefficient the pair was left to SharedFactor, which sees two siblings, so the sort decided: y + (-2) cos(x)^2 + (-2) sin(x)^2 came out y - 2, and 2 + (-2) cos(x)^2 + (-2) sin(x)^2, where the constant sorts between the two, stayed. #2133's 2 + (-2) cos(2x) + (-4) sin(x)^2 opens to that and stopped there, while 2 - 2 cos(2x) - 4 sin(x)^2 reached 0. squared-sine-and-cosine-with-one-coefficient-sum-to-it gathers k sin(x)^2 and k cos(x)^2 of one argument and one coefficient and writes k: Sound for every complex k and x, two-sided, Collects, and with no backwards reading, since it forgets the angle as the first rule does. The census moves by one in WritingARule.md, Saturation's remark and the tests that pin them; ReversibleRuleTest asks every rule of the set instead of Single(). (cos(2x) - 1)^2 + sin(2x)^2 - 4 sin(x)^2 still stops at 2 + (-2) cos(2x) + (-4) sin(x)^2, which one more Simplify now takes to 0: that is #2133's main point, Simplify not simplifying its own answer, and is not this change. On 2.6.0 and on this branch, Simplify: 2 + (-2)*cos(x)^2 + (-2)*sin(x)^2 unchanged -> 0 2 + (-2) * cos(2 * x) + (-4) * sin(x) ^ 2 unchanged -> 0 Chain against master 966f26a9f: verdicts identical, 0 moves; performance gate passed on all 19; unit suite 17,159 passed, 0 failed. RuleCheck passes, Confluence and CanonCheck match their baselines, SimpSweep 10,463/10,463. Part of https://github.com/asc-community/AngouriMath/issues/2133 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura --- BREAKING-CHANGES.md | 13 +++++ .../Transformations/Matching/MatchedRules.cs | 27 ++++++++- .../Core/Transformations/Saturation.cs | 4 +- .../Docs/Contributing/WritingARule.md | 15 ++--- .../PythagoreanPairWithACoefficientTest.cs | 58 +++++++++++++++++++ .../Transformations/ReversibleRuleTest.cs | 10 +++- .../Transformations/RuleAuthoringGuideTest.cs | 17 +++--- .../Core/Transformations/RuleMetadataTest.cs | 4 +- .../Transformations/StepAsASentenceTest.cs | 2 +- 9 files changed, 126 insertions(+), 24 deletions(-) create mode 100644 Sources/Tests/UnitTests/Common/PythagoreanPairWithACoefficientTest.cs diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index 6258c8236..f3690fa0f 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -175,6 +175,19 @@ its rows in the order it finds them, which can differ from the eliminator's | `MathS.Equations("x*y", "x + y^2 - 1").Solve("x", "y")` | `[[1, 0]]` | `[[1, 0], [0, 1], [0, -1]]` | | `MathS.Equations("x*y + 3*x - 5*y - 15", "x^2 - 3*x - 4*y - 10").Solve("x", "y")` | `[[1, -3], [2, -3]]` | `[[5, 0], [1, -3], [2, -3]]` | +### A squared sine and cosine with one coefficient are found anywhere in a sum + +**Silent: an identity reaches its value however the sum is written.** `k sin(x)^2 + k cos(x)^2` was reduced to `k` +only where the two terms were siblings, so the order a sum was sorted into decided: with the constant `2` sorted +between them, `2 + (-2) cos(x)^2 + (-2) sin(x)^2` stayed as written while `2 - 2 cos(x)^2 - 2 sin(x)^2` was `0`. The +Pythagorean identity with a coefficient is now a rule of its own, found wherever the two terms sit +([#2133](https://github.com/asc-community/AngouriMath/issues/2133)). + +| Input | Was (2.6.0) | Now | +|---|---|---| +| `"2 + (-2)*cos(x)^2 + (-2)*sin(x)^2".Simplify()` | `2 + (-2) * cos(x) ^ 2 + (-2) * sin(x) ^ 2` | `0` | +| `"2 + (-2) * cos(2 * x) + (-4) * sin(x) ^ 2".Simplify()` | unchanged | `0` | + ### A lambda applied to a vector multiplies by it as a vector **Silent: a matrix of vectors becomes the product.** Applying a lambda simplified its body first, and a simplified diff --git a/Sources/AngouriMath/Core/Transformations/Matching/MatchedRules.cs b/Sources/AngouriMath/Core/Transformations/Matching/MatchedRules.cs index 8c862ab46..d3bf1d123 100644 --- a/Sources/AngouriMath/Core/Transformations/Matching/MatchedRules.cs +++ b/Sources/AngouriMath/Core/Transformations/Matching/MatchedRules.cs @@ -345,7 +345,32 @@ internal static class MatchedRules // "x" is bound by the first part and re-matched by the second, so the two terms // are required to be about the same argument rather than merely both squared. MatchPattern.Node(MatchPattern.Exact(Integer.Create(1)), MatchPattern.Any("rest")), - Soundness.Sound)); + Soundness.Sound), + + // ... + k sin(x)^2 + ... + k cos(x)^2 + ... -> k + (everything else) + // With a coefficient the pair was left to SharedFactor, which sees two siblings + // only, so the sort decided: y + (-2) cos(x)^2 + (-2) sin(x)^2 came out y - 2, and + // 2 + (-2) cos(x)^2 + (-2) sin(x)^2, where the constant sorts between them, stayed. + // "k" is bound by the first part and re-matched by the second, so the coefficients + // are required to be the same, as "x" requires the arguments to be. + // https://github.com/asc-community/AngouriMath/issues/2133 + new MatchedRule( + "squared-sine-and-cosine-with-one-coefficient-sum-to-it", + MatchPattern.Gathered( + "rest", + MatchPattern.Commutative( + MatchPattern.Any("k"), + MatchPattern.Node( + MatchPattern.Node(MatchPattern.Any("x")), + MatchPattern.Exact(Integer.Create(2)))), + MatchPattern.Commutative( + MatchPattern.Any("k"), + MatchPattern.Node( + MatchPattern.Node(MatchPattern.Any("x")), + MatchPattern.Exact(Integer.Create(2))))), + MatchPattern.Node(MatchPattern.Any("k"), MatchPattern.Any("rest")), + Soundness.Sound, + description: "k sin(x)^2 + k cos(x)^2 = k")); /// /// , as data. diff --git a/Sources/AngouriMath/Core/Transformations/Saturation.cs b/Sources/AngouriMath/Core/Transformations/Saturation.cs index 5861b2495..76b7e0c89 100644 --- a/Sources/AngouriMath/Core/Transformations/Saturation.cs +++ b/Sources/AngouriMath/Core/Transformations/Saturation.cs @@ -43,8 +43,8 @@ internal static class Saturation /// one, and that is a measurement rather than a preference. It reads like the value a /// ceiling should refuse — it means the rule's growth was not judged, because its /// replacement is code rather than a written pattern, so admitting it accepts a rewrite - /// nobody measured. But it is where the rules are: of the 337 rules in - /// , 126 collect, 49 rearrange, 36 expand and 126 are + /// nobody measured. But it is where the rules are: of the 338 rules in + /// , 127 collect, 49 rearrange, 36 expand and 126 are /// unjudged. A ceiling that refuses the fourth value still refuses 37% of the library, /// and what is left could not do much when this was written — over six expression pairs /// equal only through a larger intermediate form, the diff --git a/Sources/AngouriMath/Docs/Contributing/WritingARule.md b/Sources/AngouriMath/Docs/Contributing/WritingARule.md index 0bb1be776..cef8c2add 100644 --- a/Sources/AngouriMath/Docs/Contributing/WritingARule.md +++ b/Sources/AngouriMath/Docs/Contributing/WritingARule.md @@ -18,7 +18,7 @@ effect". This is how to supply those seven things. ## Where a rule goes `Core/Transformations/Matching/MatchedRules.cs`, as a value in a `MatchedRuleSet`. **33** sets and -**337** rules live there today. +**338** rules live there today. The `switch` statements in `Functions/Simplification/Patterns` are the older form. **All thirty registered sets now run as data and describe what they run**; none executes its `switch` any more. @@ -64,7 +64,7 @@ in a name and putting the identity in brackets after it: 2. Tangent is sine over cosine (tan(a) = sin(a) / cos(a)), so tan(x) becomes sin(x) / cos(x). ``` -So the name has to be a clause that survives being read that way. **All 308 distinct rule names are, +So the name has to be a clause that survives being read that way. **All 309 distinct rule names are, and `StepAsASentenceTest` holds them to it** — a name with a capital, a bracket or an underscore fails that test rather than degrading the prose quietly. @@ -91,7 +91,7 @@ debugged for an afternoon. | `Left.ToString()` | `Divf(var a, Divf(var b, var c))` — how the matcher spells it | Write the identity with `=`, not `->`: it is an equality, and the arrow belongs to the direction the -rule happens to be applied in. **334** rules carry one today; a new rule should. +rule happens to be applied in. **335** rules carry one today; a new rule should. ## The pattern language @@ -123,13 +123,14 @@ matches and silently builds nothing. `BuildableNodeTypesTest` is the list. ## Replacement: a pattern, or code -**38** of the 337 rules have a pattern on both sides. The rest build their answer in code, and both +**39** of the 338 rules have a pattern on both sides. The rest build their answer in code, and both are legitimate — but the choice costs two things, so make it deliberately. A pattern replacement gets: -- **a direction.** `MatchedRule.Reversed` is the rule read the other way, and **34** of the 38 - two-sided rules have one. The other four forget something: `sin²+cos² = 1` forgets the angle, +- **a direction.** `MatchedRule.Reversed` is the rule read the other way, and **34** of the 39 + two-sided rules have one. The other five forget something: `sin²+cos² = 1` and + `k sin² + k cos² = k` forget the angle, `{ x : x in S } = S` forgets the name the set builder bound, and `c ^ log(c, a) = a` and `c ^ (k * log(c, a)) = a ^ k` for a constant `c` forget which constant, so none has anything to read back. @@ -226,7 +227,7 @@ limit, against nothing for handing the node over. | `SoundUnderAssumptions` | holds given something the rule does not check | | `Heuristic` | usually right | -**188** of the 337 rules are `Sound` and **149** are conditional. Every one of the thirty registered +**189** of the 338 rules are `Sound` and **149** are conditional. Every one of the thirty registered *sets* declares `SoundUnderAssumptions`, because a set's tier is the **minimum** over its rules — so the set grain says nothing and the rule grain says everything. A derivation reports the rule's tier (`RewriteStep.Soundness`), which is why getting it right matters beyond the label. diff --git a/Sources/Tests/UnitTests/Common/PythagoreanPairWithACoefficientTest.cs b/Sources/Tests/UnitTests/Common/PythagoreanPairWithACoefficientTest.cs new file mode 100644 index 000000000..356881fa6 --- /dev/null +++ b/Sources/Tests/UnitTests/Common/PythagoreanPairWithACoefficientTest.cs @@ -0,0 +1,58 @@ +// +// 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 Xunit; +using static AngouriMath.Entity; +using static AngouriMath.Entity.Number; + +namespace AngouriMath.Tests.Common +{ + /// + /// k sin(x)^2 + k cos(x)^2 was reduced only where the two terms were siblings, so the + /// sort decided: y + (-2) cos(x)^2 + (-2) sin(x)^2 came out y - 2, and + /// 2 + (-2) cos(x)^2 + (-2) sin(x)^2, where the constant sorts between the two, stayed. + /// #2133 + /// + [Trait("Area", "Common")] + public sealed class PythagoreanPairWithACoefficientTest + { + [Theory] + [InlineData("2 + (-2)*cos(x)^2 + (-2)*sin(x)^2", "0")] + [InlineData("2 + (-2) * cos(2 * x) + (-4) * sin(x) ^ 2", "0")] + [InlineData("a + b*sin(t)^2 + c + b*cos(t)^2", "a + b + c")] + [InlineData("y + (-2)*cos(x)^2 + (-2)*sin(x)^2", "y - 2")] + public void ThePairIsItsCoefficient(string input, string expected) + => Assert.Equal(Integer.Zero, (input.Simplify() - expected.ToEntity()).Simplify()); + + /// + /// Two coefficients, or two arguments, that differ are not the identity. + /// + [Theory] + [InlineData("2*sin(x)^2 + 3*cos(x)^2")] + [InlineData("k*sin(x)^2 + k*cos(y)^2")] + public void UnequalPartsAreLeft(string input) + => Assert.Contains("sin", input.Simplify().Stringize()); + + /// + /// The first rows of #2133's table, which are rational identities, and are answered by the + /// rational canonical form since + /// #2137. + /// + [Theory] + [InlineData("a*b*c*(1/a + 1/b + 1/c) - (a*b + b*c + c*a)")] + [InlineData("(a*b + b*c + c*a)/3 - (a*b*c)*(1/a + 1/b + 1/c)/3")] + public void TheRationalRowsAreZero(string input) + { + var simplified = input.Simplify(); + while (simplified is Providedf(var inner, _)) + simplified = inner; + Assert.Equal(Integer.Zero, simplified); + } + } +} diff --git a/Sources/Tests/UnitTests/Core/Transformations/ReversibleRuleTest.cs b/Sources/Tests/UnitTests/Core/Transformations/ReversibleRuleTest.cs index fe564b571..a24828c85 100644 --- a/Sources/Tests/UnitTests/Core/Transformations/ReversibleRuleTest.cs +++ b/Sources/Tests/UnitTests/Core/Transformations/ReversibleRuleTest.cs @@ -370,6 +370,7 @@ public void EveryDataRuleIsClassifiedAsWritten() "one-less-a-squared-sine-is-a-squared-cosine: ReplacementIsCode", "sine-of-a-whole-multiple-of-an-angle: ReplacementIsCode", "squared-sine-and-cosine-of-one-argument-sum-to-one: ReplacementDropsHoles", + "squared-sine-and-cosine-with-one-coefficient-sum-to-it: ReplacementDropsHoles", "the-arctangent-of-one-over-root-three: ReplacementIsCode", "the-arctangent-of-root-three: ReplacementIsCode", "the-negation-of-a-greater-turns-it-round: ReplacementIsCode", @@ -436,9 +437,12 @@ public void AReversedRuleSaysWhatAnExpressionCameFrom() [Fact] public void ARuleThatForgetsAHoleHasNoBackwardsReading() { - var pythagoras = MatchedRules.PythagoreanIdentity.Rules.Single(); - Assert.Equal(RuleReversal.ReplacementDropsHoles, pythagoras.Reversal); - Assert.Null(pythagoras.Reversed); + // Both rules of the set, with a coefficient and without, drop the argument. + foreach (var pythagoras in MatchedRules.PythagoreanIdentity.Rules) + { + Assert.Equal(RuleReversal.ReplacementDropsHoles, pythagoras.Reversal); + Assert.Null(pythagoras.Reversed); + } // And the reversed set is empty rather than absent, which is the difference being // reported rather than hidden. diff --git a/Sources/Tests/UnitTests/Core/Transformations/RuleAuthoringGuideTest.cs b/Sources/Tests/UnitTests/Core/Transformations/RuleAuthoringGuideTest.cs index 496a1603b..035b687c1 100644 --- a/Sources/Tests/UnitTests/Core/Transformations/RuleAuthoringGuideTest.cs +++ b/Sources/Tests/UnitTests/Core/Transformations/RuleAuthoringGuideTest.cs @@ -43,7 +43,7 @@ private static void Stated(int stated, int measured, string claim) public void WhereARuleGoes() { Stated(33, MatchedRules.All.Count, "the number of rule sets written as data"); - Stated(337, MatchedRules.All.Sum(set => set.Rules.Count), "the number of rules written as data"); + Stated(338, MatchedRules.All.Sum(set => set.Rules.Count), "the number of rules written as data"); Stated(30, RewriteRules.All.Count, "the number of registered sets"); // Two families register under one name and run a data set under another -- // CommonDenominator over MatchedRules.CommonDenominator(level), CanonicalOrder over @@ -64,7 +64,7 @@ public void TheNameIsASentence() { var names = MatchedRules.All.SelectMany(set => set.Rules) .Select(rule => rule.Name).Distinct(StringComparer.Ordinal).ToList(); - Stated(308, names.Count, "the number of distinct rule names"); + Stated(309, names.Count, "the number of distinct rule names"); var words = names.Select(name => name.Split('-').Length).ToList(); Stated(4, words.Min(), "the shortest rule name in words"); @@ -73,7 +73,7 @@ public void TheNameIsASentence() [Fact] public void TheIdentityIsNotTheName() - => Stated(334, + => Stated(335, MatchedRules.All.SelectMany(set => set.Rules).Count(rule => rule.Description is not null), "how many rules carry an identity"); @@ -85,7 +85,7 @@ public void ThePatternLanguage() public void ReplacementAPatternOrCode() { var rules = MatchedRules.All.SelectMany(set => set.Rules).ToList(); - Stated(38, rules.Count(rule => rule.Right is not null), + Stated(39, rules.Count(rule => rule.Right is not null), "how many rules have a pattern on both sides"); Stated(34, rules.Count(rule => rule.Reversed is not null), "how many two-sided rules have a direction"); @@ -97,17 +97,17 @@ public void ReplacementAPatternOrCode() // rearrange, 9 expand and 270 unjudged", measured before the growth declarations // went in. The argument survived; the arithmetic did not. A number a remark reasons // from belongs somewhere that fails when it moves. - Stated(126, rules.Count(rule => rule.Growth is RewriteRuleGrowth.Collects), + Stated(127, rules.Count(rule => rule.Growth is RewriteRuleGrowth.Collects), "how many rules are declared Collects"); Stated(49, rules.Count(rule => rule.Growth is RewriteRuleGrowth.Rearranges), "how many rules are declared Rearranges"); Stated(36, rules.Count(rule => rule.Growth is RewriteRuleGrowth.Expands), "how many rules are declared Expands"); - // And the three the document names. By their own names rather than by what the prose + // And the ones the document names. By their own names rather than by what the prose // calls them: the first is `squared-sine-and-cosine-of-one-argument-sum-to-one` and // the prose calls it the Pythagorean identity, which is right and is not what to - // match on. All three forget something the backwards direction would have to invent + // match on. All of them forget something the backwards direction would have to invent // -- the angle, the name the set builder bound, and which constant was raised. var oneWay = rules .Where(rule => rule.Right is not null && rule.Reversed is null) @@ -121,6 +121,7 @@ public void ReplacementAPatternOrCode() "a-constant-raised-to-a-logarithm-of-itself-is-the-antilogarithm", "a-constant-raised-to-a-multiple-of-a-logarithm-of-itself-is-a-power-of-the-antilogarithm", "squared-sine-and-cosine-of-one-argument-sum-to-one", + "squared-sine-and-cosine-with-one-coefficient-sum-to-it", }, oneWay); Assert.All( @@ -132,7 +133,7 @@ public void ReplacementAPatternOrCode() public void SoundnessIsPerRule() { var rules = MatchedRules.All.SelectMany(set => set.Rules).ToList(); - Stated(188, rules.Count(rule => rule.Soundness is Soundness.Sound), + Stated(189, rules.Count(rule => rule.Soundness is Soundness.Sound), "how many rules are Sound"); Stated(149, rules.Count(rule => rule.Soundness is Soundness.SoundUnderAssumptions), "how many rules are SoundUnderAssumptions"); diff --git a/Sources/Tests/UnitTests/Core/Transformations/RuleMetadataTest.cs b/Sources/Tests/UnitTests/Core/Transformations/RuleMetadataTest.cs index 406f0dbf5..c0681b2bd 100644 --- a/Sources/Tests/UnitTests/Core/Transformations/RuleMetadataTest.cs +++ b/Sources/Tests/UnitTests/Core/Transformations/RuleMetadataTest.cs @@ -88,8 +88,8 @@ public void AddressableGrowthIsTheRulesOwnAnswer() public void SoundnessIsFinerPerRuleThanPerSet() { var rules = MatchedRules.All.SelectMany(set => set.Rules).ToList(); - Assert.Equal(337, rules.Count); - Assert.Equal(188,rules.Count(rule => rule.Soundness is Soundness.Sound)); + Assert.Equal(338, rules.Count); + Assert.Equal(189,rules.Count(rule => rule.Soundness is Soundness.Sound)); Assert.Equal(149, rules.Count(rule => rule.Soundness is Soundness.SoundUnderAssumptions)); // And every set still reports the weakest of them, which is what makes the set grain diff --git a/Sources/Tests/UnitTests/Core/Transformations/StepAsASentenceTest.cs b/Sources/Tests/UnitTests/Core/Transformations/StepAsASentenceTest.cs index aed9ab502..bb4881ea4 100644 --- a/Sources/Tests/UnitTests/Core/Transformations/StepAsASentenceTest.cs +++ b/Sources/Tests/UnitTests/Core/Transformations/StepAsASentenceTest.cs @@ -59,7 +59,7 @@ public void EveryRuleWrittenAsDataIsNamedInProse() { var names = MatchedRules.All.SelectMany(set => set.Rules).Select(rule => rule.Name) .Distinct(StringComparer.Ordinal).ToList(); - Assert.Equal(308, names.Count); + Assert.Equal(309, names.Count); Assert.All(names, name => Assert.True(Explanation.IsProse(name), $"'{name}' is a rule name that does not read as a phrase in English"));