From 0ccadc9b935a83d18cd3bacf91dad54552ab18d7 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Sun, 9 Aug 2026 20:34:48 +0000 Subject: [PATCH] Let a numeric factor reach the coefficient when collecting like terms CollectLikeTerms failed on the expression its own documentation cites: (x+1)^2 * (x+1)^2 came back as 1 + 4x + 2x^2 + 4x^2 + 4x^3 + x^4, with the two x^2 terms side by side instead of added. Instrumenting the monomial keys gives the cause in one line: term=(2 * x) ^ 2 key='4 * x ^ 2^1' coeff=1 (2 * x)^2 reduces to 4 * x^2, which is a product and not a power, so it fell to the exponent-of-one branch and the 4 was baked into the monomial key rather than extracted into the coefficient. Keyed on "4 * x ^ 2" it could never meet the plain x^2 terms. A factor that reduces to a product is now split again, and an integer power of a product is distributed over its factors, so the number reaches the coefficient in both shapes. Only for polynomial-shaped factors. Opening a product is what lets like terms meet and it is also what stops a product cancelling as a whole: lifting the 1/2 out of (1/2 * sin(2t))^2 * csc(t)^2 costs the cancellation to cos(t)^2, and DoubleAngleOverSineTest is there to catch exactly that. Collection is a statement about polynomials, so it opens polynomials and leaves anything carrying a function to the rules that know it. Two other shapes were tried and rejected against that test rather than against reasoning: splitting only products that carry a number does not help, since 1/2 is one; and comparing the collected and expanded forms by their complexity cannot work, because the payoff of leaving a product intact only appears after passes that run later -- which is what the comment on TheReportersSecondExpressionIsAlsoZero already says about this metric. Closes #855. Co-Authored-By: Claude Opus 5 (1M context) --- .../Evaluation/Evaluation.Definition.cs | 44 ++++++++++++++++++- .../Common/ExpandCollectsTermsTest.cs | 34 ++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs index 455a5bb61..479a7fa55 100644 --- a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs +++ b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs @@ -239,6 +239,22 @@ static Entity Expand_(Entity e, int level) => /// product of powers, and terms whose products agree are added together -- /// enough to collect like terms and nothing more. /// + /// + /// Whether every node of the expression is one that monomial collection describes: + /// numbers, variables, and the operations that build a polynomial out of them. + /// + /// + /// Opening a product to reach its numeric factor is what lets like terms meet, and + /// it is also what stops a product cancelling as a whole -- lifting the 1/2 out of + /// (1/2 * sin(2t))^2 * csc(t)^2 costs the cancellation to cos(t)^2. + /// Collection is a statement about polynomials, so it opens polynomials and leaves + /// anything carrying a function intact for the rules that do know it. + /// https://github.com/asc-community/AngouriMath/issues/855 + /// + private static bool IsPolynomialShaped(Entity expression) + => expression.Nodes.All(node => + node is Number or Variable or Mulf or Powf or Sumf or Minusf or Divf); + private static Entity CollectLikeTerms(Entity expanded) { if (expanded is not Sumf and not Minusf) @@ -261,16 +277,39 @@ private static Entity CollectLikeTerms(Entity expanded) var exponents = new Dictionary(); var bases = new Dictionary(); - foreach (var factor in Mulf.LinearChildren(term)) + var pending = new Stack(Mulf.LinearChildren(term)); + while (pending.Count > 0) { // PowerRules folds (x^2)^2 into x^4, without which the two would be // counted as different monomials. - var reduced = factor.Rewrite(RewriteRules.Power).InnerSimplified; + var reduced = pending.Pop().Rewrite(RewriteRules.Power).InnerSimplified; if (reduced is Number) { coefficient = (coefficient * reduced).InnerSimplified; continue; } + // Reducing a factor can turn it into a product -- (2 * x)^2 becomes + // 4 * x^2 -- and taken whole that is a monomial of its own, keyed on + // "4 * x ^ 2" and unable to meet the plain x^2 terms it belongs with. + // Split it again so the 4 reaches the coefficient. Each child is + // smaller than the product it came from, so this terminates. + // https://github.com/asc-community/AngouriMath/issues/855 + if (reduced is Mulf && IsPolynomialShaped(reduced)) + { + foreach (var inner in Mulf.LinearChildren(reduced)) + pending.Push(inner); + continue; + } + // The same one shape out: (2 * x * y)^2 is a power of a product that + // the rules above do not distribute. Only for an integer exponent, + // where (a * b)^n = a^n * b^n holds for every a and b; for any other + // exponent it is a statement about branches. + if (reduced is Powf(Mulf product, Integer wholePower) && IsPolynomialShaped(product)) + { + foreach (var inner in Mulf.LinearChildren(product)) + pending.Push(inner.Pow(wholePower)); + continue; + } var (@base, exponent) = reduced is Powf(var b, var e) ? (b, e) : (reduced, (Entity)1); var key = @base.Stringize(); bases[key] = @base; @@ -327,6 +366,7 @@ private static Entity CollectLikeTerms(Entity expanded) if (collected.Nodes.Any(node => node == MathS.NaN) && !expanded.Nodes.Any(node => node == MathS.NaN)) return expanded; + return collected; } diff --git a/Sources/Tests/UnitTests/Common/ExpandCollectsTermsTest.cs b/Sources/Tests/UnitTests/Common/ExpandCollectsTermsTest.cs index feecebeaf..cd9d359b4 100644 --- a/Sources/Tests/UnitTests/Common/ExpandCollectsTermsTest.cs +++ b/Sources/Tests/UnitTests/Common/ExpandCollectsTermsTest.cs @@ -91,6 +91,40 @@ public void ATermThatTakesNoPowerIsNotLost(string input) Assert.NotEqual(MathS.NaN, input.ToEntity().Simplify()); } + // Collecting keys a term on its monomial, so a numeric factor has to reach the + // coefficient rather than stay inside the key. A factor that reduces to a product + // -- (2 * x)^2 becomes 4 * x^2 -- or that is a power of one -- (2 * x * y)^2 -- + // was taken whole, keyed on "4 * x ^ 2", and could not meet the plain x^2 terms it + // belonged with. https://github.com/asc-community/AngouriMath/issues/855 + [Theory] + [InlineData("(x+1)^2 * (x+1)^2", "1 + 4 * x + 6 * x ^ 2 + 4 * x ^ 3 + x ^ 4")] + [InlineData("(x+1)^4", "1 + 4 * x + 6 * x ^ 2 + 4 * x ^ 3 + x ^ 4")] + [InlineData("(2x+1)^2 * (2x+1)^2", "1 + 8 * x + 24 * x ^ 2 + 32 * x ^ 3 + 16 * x ^ 4")] + [InlineData("(3x)^2 + x^2", "10 * x ^ 2")] + public void ANumericFactorReachesTheCoefficient(string input, string expected) => + Assert.Equal(expected.ToEntity(), input.ToEntity().Expand()); + + // Compared as printed rather than as trees: the two agree in value and in every + // coefficient and differ only in how the products associate. + [Fact] + public void APowerOfAProductIsSplitOverItsFactors() => + Assert.Equal("y ^ 4 + 4 * x * y ^ 3 + 6 * x ^ 2 * y ^ 2 + 4 * x ^ 3 * y + x ^ 4", + "(x+y)^2 * (x+y)^2".ToEntity().Expand().Stringize()); + + // A term carrying a function is left whole, because opening it costs a cancellation + // the trigonometric rules would otherwise make: (1/2 * sin(2t))^2 * csc(t)^2 + // collapses to cos(t)^2 while it is intact and does not once the 1/2 is lifted out. + [Fact] + public void ATermCarryingAFunctionIsLeftWhole() + { + // The domain condition is stripped the way DoubleAngleOverSineTest does: the + // cosecant makes this undefined where sin(x) is 0, which is a true statement + // about the expression and not what this test is about. + var simplified = "(sin(2 * x) * cosec(x)) ^ 2 / 4 - cos(2 * x) - sin(x) ^ 2".ToEntity().Simplify(); + while (simplified is Entity.Providedf(var inner, _)) simplified = inner; + Assert.Equal(Entity.Number.Integer.Create(0), simplified); + } + // The sets that can be shifted elementwise still are -- the guard above must not // turn collecting off for them. [Theory]