From 0f273f85fcc6353e34f16ab257a25464f4641354 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Tue, 27 Oct 2026 03:47:23 +0000 Subject: [PATCH] A cosine of a sum is expanded, as a sine of one is The expansion rules had the sine's addition theorem and not the cosine's, so no shift of a cosine was reduced: sin(pi - x) - sin(x) was 0 and cos(pi - x) + cos(x) stayed as written, and so did cos(s + t) - cos(s) cos(t) + sin(s) sin(t). cos(a + b) and cos(a - b) are added beside the sine's, in the switch and in the matcher's data, as the same addition theorem: Sound for every complex a and b. A shift whose reduced form is shorter comes back reduced: cos(pi/2 - x) is sin(x), cos(2pi - x) is cos(x). cos(pi - x) on its own stays, as sin(pi + x) always has, because -cos(x) is rated no shorter than it. The rule census moves by two in WritingARule.md and the tests that pin it, and Saturation's prose had gone stale since 324 rules and is brought to the measured 337. Measured: CanonCheck, Confluence and RuleCheck unchanged against their baselines. Rubi's trigonometric family (1339 sampled) and its textbook family (338): every verdict the same on this branch and its base, time +0.9% and +0.5%. One integrand is slower alone, cos(e+f*x)^2/((a+a*sin(e+f*x))^(5/2)*sqrt(c-c*sin(e+f*x))) from 163 ms to 636 ms, still solved. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura --- BREAKING-CHANGES.md | 17 +++++++ Sources/.editorconfig | 3 ++ .../Transformations/Matching/MatchedRules.cs | 34 ++++++++++++- .../Core/Transformations/Saturation.cs | 6 +-- .../Docs/Contributing/WritingARule.md | 12 ++--- .../Patterns/Patterns.ExpandFactorize.cs | 4 ++ .../UnitTests/Common/CosineOfASumTest.cs | 51 +++++++++++++++++++ .../Transformations/AddressableRulesTest.cs | 2 +- .../Transformations/RuleAuthoringGuideTest.cs | 14 ++--- .../Core/Transformations/RuleMetadataTest.cs | 6 +-- .../Transformations/StepAsASentenceTest.cs | 2 +- 11 files changed, 129 insertions(+), 22 deletions(-) create mode 100644 Sources/Tests/UnitTests/Common/CosineOfASumTest.cs diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index 12aa10633..1a1ab7b56 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -217,6 +217,23 @@ its arguments and answers may change in a minor release. `amcli mcp` no longer l | `am_classify` on `G*M*m/r^2`, to `amcli mcp` | `solved`, `Newtonian gravitation` first | `failed`: `'am_classify' is preliminary, and is served only when the server is started as 'amcli mcp --preliminary'` | | the same, to `amcli mcp --preliminary` | `solved` (the flag is ignored) | `solved`, and `"preliminary": true` | +### A cosine of a sum is expanded, as a sine of one is + +**Silent: identities in a shifted cosine reduce, and `Expand` opens a cosine of a sum.** The expansion rules had the +sine's addition theorem and not the cosine's, so no shift of a cosine was reduced: `sin(pi - x) - sin(x)` was `0` and +`cos(pi - x) + cos(x)` stayed as written. `cos(a + b)` and `cos(a - b)` are now expanded too. A shift whose reduced +form is shorter comes back reduced. `cos(pi - x)` on its own stays, as `sin(pi + x)` always has, because `-cos(x)` +is rated no shorter ([#2135](https://github.com/asc-community/AngouriMath/issues/2135)). + +| Input | Was (2.6.0) | Now | +|---|---|---| +| `"cos(pi/2 - x)".Simplify()` | `cos(pi / 2 - x)` | `sin(x)` | +| `"cos(2*pi - x)".Simplify()` | `cos(2 * pi - x)` | `cos(x)` | +| `"cos(pi - x) + cos(x)".Simplify()` | `cos(pi - x) + cos(x)` | `0` | +| `"cos(s + t) - cos(s)*cos(t) + sin(s)*sin(t)".Simplify()` | unchanged | `0` | +| `"cos(a + b)".Expand()` | `cos(a + b)` | `cos(a) * cos(b) + -sin(a) * sin(b)` | +| `"cos(a - b)".Expand()` | `cos(a - b)` | `cos(a) * cos(b) + sin(a) * sin(b)` | + ### A subscript may have a subscript **Not silent: what was a parse error is a name.** A name took at most one `_`, so `x_1_2` and `X_a_beta` were lexer diff --git a/Sources/.editorconfig b/Sources/.editorconfig index 090d52ebb..a834a9318 100644 --- a/Sources/.editorconfig +++ b/Sources/.editorconfig @@ -279,6 +279,9 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed [Tests/UnitTests/Algebra/SolveTest/DuplicateSystemRowTest.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/Common/CosineOfASumTest.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/Common/LambdaArrowSyntaxTest.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/Core/Transformations/Matching/MatchedRules.cs b/Sources/AngouriMath/Core/Transformations/Matching/MatchedRules.cs index 2bef66f90..8c862ab46 100644 --- a/Sources/AngouriMath/Core/Transformations/Matching/MatchedRules.cs +++ b/Sources/AngouriMath/Core/Transformations/Matching/MatchedRules.cs @@ -612,7 +612,39 @@ internal static class MatchedRules MatchPattern.Node(MatchPattern.Any("b")), MatchPattern.Node(MatchPattern.Any("a")))), Soundness.Sound, - description: "sin(a - b) = sin(a)cos(b) - sin(b)cos(a)")); + description: "sin(a - b) = sin(a)cos(b) - sin(b)cos(a)"), + + // cos(a + b) -> cos(a)cos(b) - sin(a)sin(b) + // https://github.com/asc-community/AngouriMath/issues/2135 + new MatchedRule( + "cosine-of-a-sum", + MatchPattern.Node( + MatchPattern.Node(MatchPattern.Any("a"), MatchPattern.Any("b"))), + MatchPattern.Node( + MatchPattern.Node( + MatchPattern.Node(MatchPattern.Any("a")), + MatchPattern.Node(MatchPattern.Any("b"))), + MatchPattern.Node( + MatchPattern.Node(MatchPattern.Any("a")), + MatchPattern.Node(MatchPattern.Any("b")))), + // The addition theorem again, for every complex a and b. + Soundness.Sound, + description: "cos(a + b) = cos(a)cos(b) - sin(a)sin(b)"), + + // cos(a - b) -> cos(a)cos(b) + sin(a)sin(b) + new MatchedRule( + "cosine-of-a-difference", + MatchPattern.Node( + MatchPattern.Node(MatchPattern.Any("a"), MatchPattern.Any("b"))), + MatchPattern.Node( + MatchPattern.Node( + MatchPattern.Node(MatchPattern.Any("a")), + MatchPattern.Node(MatchPattern.Any("b"))), + MatchPattern.Node( + MatchPattern.Node(MatchPattern.Any("a")), + MatchPattern.Node(MatchPattern.Any("b")))), + Soundness.Sound, + description: "cos(a - b) = cos(a)cos(b) + sin(a)sin(b)")); /// /// , as data. diff --git a/Sources/AngouriMath/Core/Transformations/Saturation.cs b/Sources/AngouriMath/Core/Transformations/Saturation.cs index 7bc6bddbf..5861b2495 100644 --- a/Sources/AngouriMath/Core/Transformations/Saturation.cs +++ b/Sources/AngouriMath/Core/Transformations/Saturation.cs @@ -43,9 +43,9 @@ 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 324 rules in - /// , 124 collect, 49 rearrange, 32 expand and 123 are - /// unjudged. A ceiling that refuses the fourth value still refuses 38% of the library, + /// nobody measured. But it is where the rules are: of the 337 rules in + /// , 126 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 /// ceiling proved two, and diff --git a/Sources/AngouriMath/Docs/Contributing/WritingARule.md b/Sources/AngouriMath/Docs/Contributing/WritingARule.md index 1af3fdf87..0bb1be776 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 -**335** rules live there today. +**337** 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 306 distinct rule names are, +So the name has to be a clause that survives being read that way. **All 308 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. **332** rules carry one today; a new rule should. +rule happens to be applied in. **334** rules carry one today; a new rule should. ## The pattern language @@ -123,12 +123,12 @@ matches and silently builds nothing. `BuildableNodeTypesTest` is the list. ## Replacement: a pattern, or code -**35** of the 328 rules have a pattern on both sides. The rest build their answer in code, and both +**38** of the 337 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 **32** of the 36 +- **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, `{ 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 @@ -226,7 +226,7 @@ limit, against nothing for handing the node over. | `SoundUnderAssumptions` | holds given something the rule does not check | | `Heuristic` | usually right | -**186** of the 335 rules are `Sound` and **149** are conditional. Every one of the thirty registered +**188** of the 337 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/AngouriMath/Functions/Simplification/Patterns/Patterns.ExpandFactorize.cs b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.ExpandFactorize.cs index 47a9c7b8e..0659be336 100644 --- a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.ExpandFactorize.cs +++ b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.ExpandFactorize.cs @@ -21,6 +21,10 @@ internal static partial class Patterns { Sinf(Sumf(var any1, var any2)) => new Sinf(any1) * new Cosf(any2) + new Sinf(any2) * new Cosf(any1), Sinf(Minusf(var any1, var any2)) => new Sinf(any1) * new Cosf(any2) - new Sinf(any2) * new Cosf(any1), + // The cosine's, which were missing: cos(pi - x) + cos(x) did not reduce to 0 where + // sin(pi - x) - sin(x) did. https://github.com/asc-community/AngouriMath/issues/2135 + Cosf(Sumf(var any1, var any2)) => new Cosf(any1) * new Cosf(any2) - new Sinf(any1) * new Sinf(any2), + Cosf(Minusf(var any1, var any2)) => new Cosf(any1) * new Cosf(any2) + new Sinf(any1) * new Sinf(any2), _ => x }; diff --git a/Sources/Tests/UnitTests/Common/CosineOfASumTest.cs b/Sources/Tests/UnitTests/Common/CosineOfASumTest.cs new file mode 100644 index 000000000..74fcfb72c --- /dev/null +++ b/Sources/Tests/UnitTests/Common/CosineOfASumTest.cs @@ -0,0 +1,51 @@ +// +// 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.Number; + +namespace AngouriMath.Tests.Common +{ + /// + /// The expansion rules opened a sine of a sum and not a cosine, so no shift of a cosine was + /// reduced: sin(pi - x) - sin(x) was 0 and cos(pi - x) + cos(x) stayed as it + /// was, and so did cos(s + t) - cos(s) cos(t) + sin(s) sin(t). + /// #2135 + /// + [Trait("Area", "Common")] + public sealed class CosineOfASumTest + { + [Theory] + [InlineData("cos(pi - x) + cos(x)")] + [InlineData("cos(x - pi) + cos(x)")] + [InlineData("cos(pi/2 + x) + sin(x)")] + [InlineData("cos(s + t) - cos(s) * cos(t) + sin(s) * sin(t)")] + [InlineData("cos(s - t) - cos(s) * cos(t) - sin(s) * sin(t)")] + [InlineData("cos(pi/3 - x) - cos(x) / 2 - sqrt(3) * sin(x) / 2")] + public void TheIdentityIsZero(string identity) + => Assert.Equal(Integer.Zero, identity.Simplify()); + + /// + /// Where the shifted form is longer than what it reduces to, the reduced one is the + /// answer. cos(pi - x) on its own stays, as sin(pi + x) always has: + /// -cos(x) is rated no shorter. + /// + [Theory] + [InlineData("cos(pi/2 - x)", "sin(x)")] + [InlineData("cos(2 * pi - x)", "cos(x)")] + public void AShiftIsReducedWhereThatIsShorter(string shifted, string reduced) + => Assert.Equal(reduced.ToEntity(), shifted.Simplify()); + + [Theory] + [InlineData("cos(a + b)", "cos(a) * cos(b) - sin(a) * sin(b)")] + [InlineData("cos(a - b)", "cos(a) * cos(b) + sin(a) * sin(b)")] + public void ExpandOpensACosineOfASum(string cosine, string expanded) + => Assert.Equal(Integer.Zero, (cosine.ToEntity().Expand() - expanded).Simplify()); + } +} diff --git a/Sources/Tests/UnitTests/Core/Transformations/AddressableRulesTest.cs b/Sources/Tests/UnitTests/Core/Transformations/AddressableRulesTest.cs index 7b241bbb3..17ed1d916 100644 --- a/Sources/Tests/UnitTests/Core/Transformations/AddressableRulesTest.cs +++ b/Sources/Tests/UnitTests/Core/Transformations/AddressableRulesTest.cs @@ -299,7 +299,7 @@ public void TheRegistryIsAddressableAsFarAsItSaysItIs() // are thirty-three; NumericNeat's sixteen are eleven, six of them being three rules // written once per side a negative factor can sit on; and the two factorial sets are // eight arms each written as three. Every other repointed set is one arm for one rule. - Assert.Equal(332, withRules.Sum(set => set.Rules.Count)); + Assert.Equal(334, withRules.Sum(set => set.Rules.Count)); } /// diff --git a/Sources/Tests/UnitTests/Core/Transformations/RuleAuthoringGuideTest.cs b/Sources/Tests/UnitTests/Core/Transformations/RuleAuthoringGuideTest.cs index ae4db26ef..496a1603b 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(335, MatchedRules.All.Sum(set => set.Rules.Count), "the number of rules written as data"); + Stated(337, 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(306, names.Count, "the number of distinct rule names"); + Stated(308, 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(332, + => Stated(334, MatchedRules.All.SelectMany(set => set.Rules).Count(rule => rule.Description is not null), "how many rules carry an identity"); @@ -85,9 +85,9 @@ public void ThePatternLanguage() public void ReplacementAPatternOrCode() { var rules = MatchedRules.All.SelectMany(set => set.Rules).ToList(); - Stated(36, rules.Count(rule => rule.Right is not null), + Stated(38, rules.Count(rule => rule.Right is not null), "how many rules have a pattern on both sides"); - Stated(32, rules.Count(rule => rule.Reversed is not null), + Stated(34, rules.Count(rule => rule.Reversed is not null), "how many two-sided rules have a direction"); Stated(126, rules.Count(rule => rule.Growth is RewriteRuleGrowth.Unknown), "how many rules sit at Unknown growth"); @@ -101,7 +101,7 @@ public void ReplacementAPatternOrCode() "how many rules are declared Collects"); Stated(49, rules.Count(rule => rule.Growth is RewriteRuleGrowth.Rearranges), "how many rules are declared Rearranges"); - Stated(34, rules.Count(rule => rule.Growth is RewriteRuleGrowth.Expands), + 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 @@ -132,7 +132,7 @@ public void ReplacementAPatternOrCode() public void SoundnessIsPerRule() { var rules = MatchedRules.All.SelectMany(set => set.Rules).ToList(); - Stated(186, rules.Count(rule => rule.Soundness is Soundness.Sound), + Stated(188, 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 6f8c9c8a5..406f0dbf5 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(335, rules.Count); - Assert.Equal(186,rules.Count(rule => rule.Soundness is Soundness.Sound)); + Assert.Equal(337, rules.Count); + Assert.Equal(188,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 @@ -174,7 +174,7 @@ public void EveryRuleOfARepointedSetCarriesItsIdentity() var repointed = RewriteRules.All .Where(set => set.Rules.Count > 0 && set.Rules.All(rule => rule.Soundness is not null)) .ToList(); - Assert.Equal(332, repointed.Sum(set => set.Rules.Count)); + Assert.Equal(334, repointed.Sum(set => set.Rules.Count)); Assert.All(repointed, set => Assert.All(set.Rules, rule => Assert.NotNull(rule.Description))); } diff --git a/Sources/Tests/UnitTests/Core/Transformations/StepAsASentenceTest.cs b/Sources/Tests/UnitTests/Core/Transformations/StepAsASentenceTest.cs index 777ee381e..aed9ab502 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(306, names.Count); + Assert.Equal(308, 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"));