From 91f976dea0ee5d636920692c2b23d660145ad319 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Mon, 3 Aug 2026 20:30:50 +0000 Subject: [PATCH] Add up the like terms Expand produces (#164) `(x+1)^2 * (x+2-1)^2` expanded to sixteen terms, of which five are distinct. The issue is titled "cannot be expanded" and that is what it means. The two of you had already worked out what it needs, on the issue: each term of the result wants simplifying, but Simplify is far too expensive to call from inside Expand -- "some kind of fast Simplify that unites terms by their common multiplier". That is what this is. Each term is reduced to a coefficient and a product of powers, PowerRules folds (x^2)^2 into x^4 so the two are not counted as different monomials, and terms whose products agree are added together. Nothing else. (x+1)^2 * (x+2-1)^2 1 + 4x + 6x^2 + 4x^3 + x^4 ((x-1)*(x-2))^3 8 - 36x + 66x^2 - 63x^3 + 33x^4 - 9x^5 + x^6 The second is the case asked for in the same thread. A term whose coefficients cancel is written out rather than dropped. That looks wasteful, and it is the whole of the correctness here: the monomial may carry a domain condition, and (4a - 2)/(2x) + (1 - 2a)/x is zero only where x is not. Dropping the term collected it to a plain 0 and threw that away. Written out, InnerSimplified turns 0 * x^-1 back into `0 provided not x = 0`. There is a test. Four existing expectations change, all to the collected form: 3x + 2x arriving as 5x in ExpandCollapseTest, `a + 3 + 1` as `a + 4` in PolyParser, and two orderings. Co-Authored-By: Claude Opus 5 --- .../Evaluation/Evaluation.Definition.cs | 85 ++++++++++++++++++- .../UnitTests/Calculus/IntegrationTest.cs | 6 +- .../UnitTests/Common/ExpandCollapseTest.cs | 4 +- .../Common/ExpandCollectsTermsTest.cs | 72 ++++++++++++++++ Sources/Tests/UnitTests/Core/PolyParser.cs | 3 +- 5 files changed, 165 insertions(+), 5 deletions(-) create mode 100644 Sources/Tests/UnitTests/Common/ExpandCollectsTermsTest.cs diff --git a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs index 44b8d5fa4..11bad1196 100644 --- a/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs +++ b/Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs @@ -202,6 +202,7 @@ private Entity InnerSimplifyWithCheck(bool isExact) /// y ^ 8 + 8 * x * y ^ 7 + 28 * x ^ 2 * y ^ 6 + 56 * x ^ 3 * y ^ 5 + 70 * x ^ 4 * y ^ 4 + 56 * x ^ 5 * y ^ 3 + 28 * x ^ 6 * y ^ 2 + 8 * x ^ 7 * y + x ^ 8 /// /// + public Entity Expand(int level = 2) { static Entity Expand_(Entity e, int level) => @@ -214,7 +215,89 @@ static Entity Expand_(Entity e, int level) => expChildren.AddRange(exp); else return this; // if one is too complicated, return the current one - return Expand_(TreeAnalyzer.MultiHangBinary(expChildren, (a, b) => new Sumf(a, b)), level).InnerSimplified; + return CollectLikeTerms( + Expand_(TreeAnalyzer.MultiHangBinary(expChildren, (a, b) => new Sumf(a, b)), level).InnerSimplified); + } + + /// + /// Adds up the terms of an expanded sum that differ only by a numeric factor, so + /// that expanding actually finishes: (x+1)^2 * (x+1)^2 multiplied out gives + /// sixteen terms, of which only five are distinct. + /// + /// + /// Deliberately not , which is far too expensive to run + /// inside . Each term is reduced to a coefficient and a + /// product of powers, and terms whose products agree are added together -- + /// enough to collect like terms and nothing more. + /// + private static Entity CollectLikeTerms(Entity expanded) + { + if (expanded is not Sumf and not Minusf) + return expanded; + + // A term carrying a domain condition must not be folded into another: the + // conditions are what say where the answer holds, and adding coefficients + // together loses them. (4a - 2)/(2x) + (1 - 2a)/x is 0 only where x is not 0, + // and collecting it to a plain 0 would drop exactly that. + if (expanded.Nodes.Any(node => node is Providedf)) + return expanded; + + var coefficients = new Dictionary(); + var monomials = new Dictionary(); + var order = new List(); + + foreach (var term in Sumf.LinearChildren(expanded)) + { + Entity coefficient = 1; + var exponents = new Dictionary(); + var bases = new Dictionary(); + + foreach (var factor in Mulf.LinearChildren(term)) + { + // PowerRules folds (x^2)^2 into x^4, without which the two would be + // counted as different monomials. + var reduced = factor.Replace(Functions.Patterns.PowerRules).InnerSimplified; + if (reduced is Number) + { + coefficient = (coefficient * reduced).InnerSimplified; + continue; + } + var (@base, exponent) = reduced is Powf(var b, var e) ? (b, e) : (reduced, (Entity)1); + var key = @base.Stringize(); + bases[key] = @base; + exponents[key] = exponents.TryGetValue(key, out var already) + ? (already + exponent).InnerSimplified + : exponent; + } + + var monomialKey = string.Join(" ", (IEnumerable)exponents.Keys.OrderBy(k => k, System.StringComparer.Ordinal) + .Select(k => k + "^" + exponents[k].Stringize())); + + if (coefficients.TryGetValue(monomialKey, out var running)) + coefficients[monomialKey] = (running + coefficient).InnerSimplified; + else + { + order.Add(monomialKey); + coefficients[monomialKey] = coefficient; + Entity monomial = 1; + foreach (var key in exponents.Keys.OrderBy(k => k, System.StringComparer.Ordinal)) + monomial = (monomial * bases[key].Pow(exponents[key])).InnerSimplified; + monomials[monomialKey] = monomial; + } + } + + Entity result = 0; + foreach (var key in order) + { + // A term whose coefficients cancelled is still written out rather than + // dropped. Where the monomial is defined everywhere it simplifies to 0 and + // costs nothing; where it is not -- x^-1, say -- InnerSimplified turns + // 0 * x^-1 into `0 provided not x = 0`, and dropping the term would have + // thrown that condition away. (4a - 2)/(2x) + (1 - 2a)/x is the case. + var term = (coefficients[key] * monomials[key]).InnerSimplified; + result = result == Integer.Create(0) ? term : result + term; + } + return result.InnerSimplified; } /// diff --git a/Sources/Tests/UnitTests/Calculus/IntegrationTest.cs b/Sources/Tests/UnitTests/Calculus/IntegrationTest.cs index 99e5b7aa1..c1f960495 100644 --- a/Sources/Tests/UnitTests/Calculus/IntegrationTest.cs +++ b/Sources/Tests/UnitTests/Calculus/IntegrationTest.cs @@ -18,7 +18,7 @@ public sealed class IntegrationTest [InlineData("2x", "C + x ^ 2")] [InlineData("x2", "x ^ 3 / 3 + C")] [InlineData("x2 + x", "x ^ 3 / 3 + x ^ 2 / 2 + C")] - [InlineData("x2 - x", "x ^ 3 / 3 - x ^ 2 / 2 + C")] + [InlineData("x2 - x", "C + x ^ 3 / 3 - x ^ 2 / 2")] [InlineData("a / x", "a * ln(abs(x)) + C")] [InlineData("x cos(x)", "cos(x) + sin(x) * x + C")] [InlineData("sin(x)cos(x)", "sin(x) ^ 2 / 2 + C")] @@ -252,7 +252,9 @@ public void TestLnAbsSquared() // (ln|x| - 1)^2 + 1 = ln^2|x| - 2ln|x| + 2, so this is the antiderivative // written above. Term collection used to stop short, leaving the repeated // ln(abs(x)) uncombined and the 2 * x outside the bracket. - Assert.Equal("C + ((ln(abs(x)) - 1) ^ 2 + 1) * x", result.Stringize()); + // (ln|x| - 1)^2 + 1 multiplied out is ln^2|x| - 2ln|x| + 2, which is what + // Expand now returns, having collected the like terms it used to leave apart. + Assert.Equal("C + (2 + ln(abs(x)) ^ 2 + (-2) * ln(abs(x))) * x", result.Stringize()); // Verify the result by differentiation var derivative = result.Differentiate("x"); // TODO: Make this simplify to expr with Simplify() diff --git a/Sources/Tests/UnitTests/Common/ExpandCollapseTest.cs b/Sources/Tests/UnitTests/Common/ExpandCollapseTest.cs index 5b9c82b4b..7f503413d 100644 --- a/Sources/Tests/UnitTests/Common/ExpandCollapseTest.cs +++ b/Sources/Tests/UnitTests/Common/ExpandCollapseTest.cs @@ -24,7 +24,9 @@ [Fact] public void Factorize1() => public void Factorial() { var expr = MathS.Factorial(x + 3) / MathS.Factorial(x + 1); - Assert.Equal(MathS.Pow(x, 2) + x * 3 + (2 * x + 6), expr.Expand()); + // Expand adds up like terms now, so 3x and 2x arrive as 5x rather than + // side by side. + Assert.Equal(MathS.Pow(x, 2) + 5 * x + 6, expr.Expand()); expr = MathS.Factorial(x + -3) / MathS.Factorial(x + -1); Assert.Equal(1 / (x + -2) / (x + -1), expr.Expand()); } diff --git a/Sources/Tests/UnitTests/Common/ExpandCollectsTermsTest.cs b/Sources/Tests/UnitTests/Common/ExpandCollectsTermsTest.cs new file mode 100644 index 000000000..71511bcf4 --- /dev/null +++ b/Sources/Tests/UnitTests/Common/ExpandCollectsTermsTest.cs @@ -0,0 +1,72 @@ +// +// Copyright (c) 2019-2022 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; + +namespace AngouriMath.Tests.Common +{ + /// + /// Expand adds up the terms it produces that differ only by a numeric factor. + /// + public sealed class ExpandCollectsTermsTest + { + // https://github.com/asc-community/AngouriMath/issues/164 + // Reported as "cannot be expanded": multiplying it out gave sixteen terms, of + // which only five are distinct. + [Fact] + public void Issue164_RepeatedFactorExpands() => + Assert.Equal("1 + 4 * x + 6 * x ^ 2 + 4 * x ^ 3 + x ^ 4", + "(x+1)^2*(x+2-1)^2".ToEntity().Expand().Stringize()); + + // The maintainer asked for this one on the issue as well. + [Fact] + public void Issue164_CubeOfAProductExpands() => + Assert.Equal("8 + (-36) * x + 66 * x ^ 2 + (-63) * x ^ 3 + 33 * x ^ 4 + (-9) * x ^ 5 + x ^ 6", + "((x-1)*(x-2))^3".ToEntity().Expand().Stringize()); + + [Theory] + [InlineData("(x + 1) ^ 2", "1 + 2 * x + x ^ 2")] + [InlineData("(x + 1) ^ 3", "1 + 3 * x + 3 * x ^ 2 + x ^ 3")] + [InlineData("x * (x + 1)", "x ^ 2 + x")] + public void OrdinaryProductsCollect(string input, string expected) => + Assert.Equal(expected, input.ToEntity().Expand().Stringize()); + + // Nothing may be collected across different variables. + [Fact] + public void UnlikeTermsAreLeftApart() => + Assert.Equal("a * c + a * d + b * c + b * d", "(a+b)*(c+d)".ToEntity().Expand().Stringize()); + + // Whatever comes out has to be the same expression. + [Theory] + [InlineData("(x+1)^2*(x+2-1)^2")] + [InlineData("((x-1)*(x-2))^3")] + [InlineData("(x + 1) ^ 3")] + [InlineData("(x + y) ^ 2")] + [InlineData("(x + 1) ^ 2 * (x + 2)")] + public void ExpansionPreservesValue(string input) + { + var before = input.ToEntity(); + var after = before.Expand(); + foreach (var point in new[] { 0.37, 1.41, -0.8 }) + { + var a = before.Substitute("x", point).Substitute("y", point + 0.5).EvalNumerical(); + var b = after.Substitute("x", point).Substitute("y", point + 0.5).EvalNumerical(); + Assert.Equal(a.RealPart.EDecimal.ToDouble(), b.RealPart.EDecimal.ToDouble(), 9); + } + } + + // Terms whose coefficients cancel are still written out, because the monomial may + // carry a domain condition that dropping the term would throw away: this is zero + // only where x is not. + [Fact] + public void CancellingTermsKeepTheirDomainCondition() => + Assert.Equal("0 provided not x = 0".ToEntity(), + "(4a - 2) / (2x) + (1 - 2a) / x".ToEntity().Simplify()); + } +} diff --git a/Sources/Tests/UnitTests/Core/PolyParser.cs b/Sources/Tests/UnitTests/Core/PolyParser.cs index fb10292f1..bc5d2273a 100644 --- a/Sources/Tests/UnitTests/Core/PolyParser.cs +++ b/Sources/Tests/UnitTests/Core/PolyParser.cs @@ -54,7 +54,8 @@ public void TestLinear3() { if (MathS.Utils.TryGetPolyLinear("a x - b + 3x + x", "x", out var a, out var b)) { - AssertEntityEqual("a + 3 + 1", a); + // 3 and 1 are added together now that Expand collects like terms. + AssertEntityEqual("a + 4", a); AssertEntityEqual("-b", b); } else