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]