Skip to content

Commit 9b50cd9

Browse files
Add up the like terms Expand produces (#164) (#676)
`(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 <noreply@anthropic.com>
1 parent 70e39b2 commit 9b50cd9

5 files changed

Lines changed: 165 additions & 5 deletions

File tree

‎Sources/AngouriMath/Functions/Evaluation/Evaluation.Definition.cs‎

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@ private Entity InnerSimplifyWithCheck(bool isExact)
202202
/// 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
203203
/// </code>
204204
/// </example>
205+
205206
public Entity Expand(int level = 2)
206207
{
207208
static Entity Expand_(Entity e, int level) =>
@@ -214,7 +215,89 @@ static Entity Expand_(Entity e, int level) =>
214215
expChildren.AddRange(exp);
215216
else
216217
return this; // if one is too complicated, return the current one
217-
return Expand_(TreeAnalyzer.MultiHangBinary(expChildren, (a, b) => new Sumf(a, b)), level).InnerSimplified;
218+
return CollectLikeTerms(
219+
Expand_(TreeAnalyzer.MultiHangBinary(expChildren, (a, b) => new Sumf(a, b)), level).InnerSimplified);
220+
}
221+
222+
/// <summary>
223+
/// Adds up the terms of an expanded sum that differ only by a numeric factor, so
224+
/// that expanding actually finishes: <c>(x+1)^2 * (x+1)^2</c> multiplied out gives
225+
/// sixteen terms, of which only five are distinct.
226+
/// </summary>
227+
/// <remarks>
228+
/// Deliberately not <see cref="Simplify(int)"/>, which is far too expensive to run
229+
/// inside <see cref="Expand(int)"/>. Each term is reduced to a coefficient and a
230+
/// product of powers, and terms whose products agree are added together --
231+
/// enough to collect like terms and nothing more.
232+
/// </remarks>
233+
private static Entity CollectLikeTerms(Entity expanded)
234+
{
235+
if (expanded is not Sumf and not Minusf)
236+
return expanded;
237+
238+
// A term carrying a domain condition must not be folded into another: the
239+
// conditions are what say where the answer holds, and adding coefficients
240+
// together loses them. (4a - 2)/(2x) + (1 - 2a)/x is 0 only where x is not 0,
241+
// and collecting it to a plain 0 would drop exactly that.
242+
if (expanded.Nodes.Any(node => node is Providedf))
243+
return expanded;
244+
245+
var coefficients = new Dictionary<string, Entity>();
246+
var monomials = new Dictionary<string, Entity>();
247+
var order = new List<string>();
248+
249+
foreach (var term in Sumf.LinearChildren(expanded))
250+
{
251+
Entity coefficient = 1;
252+
var exponents = new Dictionary<string, Entity>();
253+
var bases = new Dictionary<string, Entity>();
254+
255+
foreach (var factor in Mulf.LinearChildren(term))
256+
{
257+
// PowerRules folds (x^2)^2 into x^4, without which the two would be
258+
// counted as different monomials.
259+
var reduced = factor.Replace(Functions.Patterns.PowerRules).InnerSimplified;
260+
if (reduced is Number)
261+
{
262+
coefficient = (coefficient * reduced).InnerSimplified;
263+
continue;
264+
}
265+
var (@base, exponent) = reduced is Powf(var b, var e) ? (b, e) : (reduced, (Entity)1);
266+
var key = @base.Stringize();
267+
bases[key] = @base;
268+
exponents[key] = exponents.TryGetValue(key, out var already)
269+
? (already + exponent).InnerSimplified
270+
: exponent;
271+
}
272+
273+
var monomialKey = string.Join(" ", (IEnumerable<string>)exponents.Keys.OrderBy(k => k, System.StringComparer.Ordinal)
274+
.Select(k => k + "^" + exponents[k].Stringize()));
275+
276+
if (coefficients.TryGetValue(monomialKey, out var running))
277+
coefficients[monomialKey] = (running + coefficient).InnerSimplified;
278+
else
279+
{
280+
order.Add(monomialKey);
281+
coefficients[monomialKey] = coefficient;
282+
Entity monomial = 1;
283+
foreach (var key in exponents.Keys.OrderBy(k => k, System.StringComparer.Ordinal))
284+
monomial = (monomial * bases[key].Pow(exponents[key])).InnerSimplified;
285+
monomials[monomialKey] = monomial;
286+
}
287+
}
288+
289+
Entity result = 0;
290+
foreach (var key in order)
291+
{
292+
// A term whose coefficients cancelled is still written out rather than
293+
// dropped. Where the monomial is defined everywhere it simplifies to 0 and
294+
// costs nothing; where it is not -- x^-1, say -- InnerSimplified turns
295+
// 0 * x^-1 into `0 provided not x = 0`, and dropping the term would have
296+
// thrown that condition away. (4a - 2)/(2x) + (1 - 2a)/x is the case.
297+
var term = (coefficients[key] * monomials[key]).InnerSimplified;
298+
result = result == Integer.Create(0) ? term : result + term;
299+
}
300+
return result.InnerSimplified;
218301
}
219302

220303
/// <summary>

‎Sources/Tests/UnitTests/Calculus/IntegrationTest.cs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public sealed class IntegrationTest
1818
[InlineData("2x", "C + x ^ 2")]
1919
[InlineData("x2", "x ^ 3 / 3 + C")]
2020
[InlineData("x2 + x", "x ^ 3 / 3 + x ^ 2 / 2 + C")]
21-
[InlineData("x2 - x", "x ^ 3 / 3 - x ^ 2 / 2 + C")]
21+
[InlineData("x2 - x", "C + x ^ 3 / 3 - x ^ 2 / 2")]
2222
[InlineData("a / x", "a * ln(abs(x)) + C")]
2323
[InlineData("x cos(x)", "cos(x) + sin(x) * x + C")]
2424
[InlineData("sin(x)cos(x)", "sin(x) ^ 2 / 2 + C")]
@@ -252,7 +252,9 @@ public void TestLnAbsSquared()
252252
// (ln|x| - 1)^2 + 1 = ln^2|x| - 2ln|x| + 2, so this is the antiderivative
253253
// written above. Term collection used to stop short, leaving the repeated
254254
// ln(abs(x)) uncombined and the 2 * x outside the bracket.
255-
Assert.Equal("C + ((ln(abs(x)) - 1) ^ 2 + 1) * x", result.Stringize());
255+
// (ln|x| - 1)^2 + 1 multiplied out is ln^2|x| - 2ln|x| + 2, which is what
256+
// Expand now returns, having collected the like terms it used to leave apart.
257+
Assert.Equal("C + (2 + ln(abs(x)) ^ 2 + (-2) * ln(abs(x))) * x", result.Stringize());
256258

257259
// Verify the result by differentiation
258260
var derivative = result.Differentiate("x"); // TODO: Make this simplify to expr with Simplify()

‎Sources/Tests/UnitTests/Common/ExpandCollapseTest.cs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ [Fact] public void Factorize1() =>
2424
public void Factorial()
2525
{
2626
var expr = MathS.Factorial(x + 3) / MathS.Factorial(x + 1);
27-
Assert.Equal(MathS.Pow(x, 2) + x * 3 + (2 * x + 6), expr.Expand());
27+
// Expand adds up like terms now, so 3x and 2x arrive as 5x rather than
28+
// side by side.
29+
Assert.Equal(MathS.Pow(x, 2) + 5 * x + 6, expr.Expand());
2830
expr = MathS.Factorial(x + -3) / MathS.Factorial(x + -1);
2931
Assert.Equal(1 / (x + -2) / (x + -1), expr.Expand());
3032
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//
2+
// Copyright (c) 2019-2022 Angouri.
3+
// AngouriMath is licensed under MIT.
4+
// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
5+
// Website: https://am.angouri.org.
6+
//
7+
8+
using AngouriMath;
9+
using AngouriMath.Extensions;
10+
using Xunit;
11+
12+
namespace AngouriMath.Tests.Common
13+
{
14+
/// <summary>
15+
/// Expand adds up the terms it produces that differ only by a numeric factor.
16+
/// </summary>
17+
public sealed class ExpandCollectsTermsTest
18+
{
19+
// https://github.com/asc-community/AngouriMath/issues/164
20+
// Reported as "cannot be expanded": multiplying it out gave sixteen terms, of
21+
// which only five are distinct.
22+
[Fact]
23+
public void Issue164_RepeatedFactorExpands() =>
24+
Assert.Equal("1 + 4 * x + 6 * x ^ 2 + 4 * x ^ 3 + x ^ 4",
25+
"(x+1)^2*(x+2-1)^2".ToEntity().Expand().Stringize());
26+
27+
// The maintainer asked for this one on the issue as well.
28+
[Fact]
29+
public void Issue164_CubeOfAProductExpands() =>
30+
Assert.Equal("8 + (-36) * x + 66 * x ^ 2 + (-63) * x ^ 3 + 33 * x ^ 4 + (-9) * x ^ 5 + x ^ 6",
31+
"((x-1)*(x-2))^3".ToEntity().Expand().Stringize());
32+
33+
[Theory]
34+
[InlineData("(x + 1) ^ 2", "1 + 2 * x + x ^ 2")]
35+
[InlineData("(x + 1) ^ 3", "1 + 3 * x + 3 * x ^ 2 + x ^ 3")]
36+
[InlineData("x * (x + 1)", "x ^ 2 + x")]
37+
public void OrdinaryProductsCollect(string input, string expected) =>
38+
Assert.Equal(expected, input.ToEntity().Expand().Stringize());
39+
40+
// Nothing may be collected across different variables.
41+
[Fact]
42+
public void UnlikeTermsAreLeftApart() =>
43+
Assert.Equal("a * c + a * d + b * c + b * d", "(a+b)*(c+d)".ToEntity().Expand().Stringize());
44+
45+
// Whatever comes out has to be the same expression.
46+
[Theory]
47+
[InlineData("(x+1)^2*(x+2-1)^2")]
48+
[InlineData("((x-1)*(x-2))^3")]
49+
[InlineData("(x + 1) ^ 3")]
50+
[InlineData("(x + y) ^ 2")]
51+
[InlineData("(x + 1) ^ 2 * (x + 2)")]
52+
public void ExpansionPreservesValue(string input)
53+
{
54+
var before = input.ToEntity();
55+
var after = before.Expand();
56+
foreach (var point in new[] { 0.37, 1.41, -0.8 })
57+
{
58+
var a = before.Substitute("x", point).Substitute("y", point + 0.5).EvalNumerical();
59+
var b = after.Substitute("x", point).Substitute("y", point + 0.5).EvalNumerical();
60+
Assert.Equal(a.RealPart.EDecimal.ToDouble(), b.RealPart.EDecimal.ToDouble(), 9);
61+
}
62+
}
63+
64+
// Terms whose coefficients cancel are still written out, because the monomial may
65+
// carry a domain condition that dropping the term would throw away: this is zero
66+
// only where x is not.
67+
[Fact]
68+
public void CancellingTermsKeepTheirDomainCondition() =>
69+
Assert.Equal("0 provided not x = 0".ToEntity(),
70+
"(4a - 2) / (2x) + (1 - 2a) / x".ToEntity().Simplify());
71+
}
72+
}

‎Sources/Tests/UnitTests/Core/PolyParser.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ public void TestLinear3()
5454
{
5555
if (MathS.Utils.TryGetPolyLinear("a x - b + 3x + x", "x", out var a, out var b))
5656
{
57-
AssertEntityEqual("a + 3 + 1", a);
57+
// 3 and 1 are added together now that Expand collects like terms.
58+
AssertEntityEqual("a + 4", a);
5859
AssertEntityEqual("-b", b);
5960
}
6061
else

0 commit comments

Comments
 (0)