diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md
index 2e1480250..e2a58264a 100644
--- a/BREAKING-CHANGES.md
+++ b/BREAKING-CHANGES.md
@@ -135,6 +135,25 @@ except where shown.
| `([[1, 2], [2, 4]] ^ (-1)).Evaled` | throws `InvalidDeterminantException` | `[[1, 2], [2, 4]] ^ (-1)` |
| `([[1, 2], [2, 4]] ^ (-1)).Simplify()` | throws `InvalidDeterminantException` | `1 / [[1, 2], [2, 4]]` |
+### A sum of fractions that is a constant over one denominator simplifies to it
+
+**Silent: an identity in fractions reaches its value, and conditions are written shorter.** `Simplify` gathered
+fractions a pair or a sort level at a time and left some sums apart that are a constant over one denominator. It now
+also offers the rational canonical form (`Transformation.RationalCanonicalization`) as a candidate, where the
+expression is a rational function with a quotient in it and of complexity at most 64, and not while the integrator is
+running. The candidate wins only where it rates shorter, and a tie keeps the old answer. Where it wins, the condition
+is the canonical one: square-free, and without what the answer's own denominator already excludes
+([#2137](https://github.com/asc-community/AngouriMath/issues/2137)).
+
+| Input | Was (2.6.0) | Now |
+|---|---|---|
+| `"x/(x+y) + y/(x+y)".Simplify()` | `x / (x + y) + y / (x + y)` | `1 provided not x + y = 0` |
+| `"1/(1 - (b/a)^2) - a^2/(a^2 - b^2)".Simplify()` | unchanged | `0 provided not a ^ 3 - a * b ^ 2 = 0` |
+| `"x/c + (y*c - x*d)/(c^2*(z + d/c)) - (x*z + y)/(c*z + d)".Simplify()` | unchanged | `0 provided not c * d + c ^ 2 * z = 0` |
+| `"R^2 - R^2*(a+b)*(1/a+1/b)/4 - R^2*(a-b)*(1/a-1/b)/4".Simplify()` | `R ^ 2 + -1/2 * (R ^ 2 + R ^ 2) provided not a = 0 and not b = 0` | `0 provided not a * b = 0` |
+| `"(a - 1)/(1 - b) - (1 - a)/(b - 1)".Simplify()` | `0 provided not (b - 1) * (1 - b) = 0` | `0 provided not b - 1 = 0` |
+| `"(x*y*a*b*c)/(c*b*a*x*x)".Simplify()` | `y / x provided not a * b * c * x = 0` | `y / x provided not a * b * c = 0` |
+
### A polynomial system is not answered with some of its solutions missing
**Silent: a polynomial system that lost solutions has all of them.** `EquationSystem.Solve` triangularises a
diff --git a/Sources/AngouriMath/Functions/Algebra/Polynomials/RationalFunction.cs b/Sources/AngouriMath/Functions/Algebra/Polynomials/RationalFunction.cs
index ceb494fd1..41afc14c1 100644
--- a/Sources/AngouriMath/Functions/Algebra/Polynomials/RationalFunction.cs
+++ b/Sources/AngouriMath/Functions/Algebra/Polynomials/RationalFunction.cs
@@ -71,7 +71,14 @@ internal static class RationalFunction
/// where it is not a rational function over Q in its
/// free variables, or where a bound is reached.
///
- internal static bool TryCanonicalize(Entity expr, [NotNullWhen(true)] out Entity? canonical)
+ /// The expression.
+ /// Its canonical form.
+ /// Whether the quotient is scaled at the end so that its
+ /// denominator has whole coprime coefficients and a positive leading one, rather than a
+ /// leading one of 1: 2/(4x^2 + 1), not (1/2)/(x^2 + 1/4). The same quotient
+ /// either way, and still one tree per function; the monic form is the specification's,
+ /// and this is the one written for a reader, as Simplify offers it.
+ internal static bool TryCanonicalize(Entity expr, [NotNullWhen(true)] out Entity? canonical, bool wholeCoefficients = false)
{
canonical = null;
if (expr.Complexity > MaxComplexity)
@@ -140,6 +147,11 @@ internal static bool TryCanonicalize(Entity expr, [NotNullWhen(true)] out Entity
denominator = denominator.ScaleBy(inverse);
}
+ if (wholeCoefficients && !denominator.IsConstant)
+ {
+ denominator = denominator.Normalized(out var whole);
+ numerator = numerator.ScaleBy(whole);
+ }
// The denominator is now monic, so a constant one is exactly 1 and is dropped.
var quotient = denominator.IsConstant
? numerator.ToEntity(variables)
diff --git a/Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs b/Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs
index 9687c84b1..88fd5b114 100644
--- a/Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs
+++ b/Sources/AngouriMath/Functions/Continuous/Integration/Integration.Definition.cs
@@ -448,6 +448,13 @@ private static Entity WithItsPiecewiseSumsCombined(Entity expr)
///
internal static bool AnsweringTheQuestionAsked => descentDepth == 1;
+ ///
+ /// Whether the integrator is running on this thread: for a Simplify candidate that
+ /// is wanted by its callers and not by the integrator's rules, which read the shape of what
+ /// Simplify returns.
+ ///
+ internal static bool IsIntegrating => descentDepth > 0;
+
///
/// The question asked or one step below it: the remainder by parts leaves, the
/// integrand a substitution makes. For a rewriting rule that lands on the chain and is
diff --git a/Sources/AngouriMath/Functions/Simplification/Simplificator.cs b/Sources/AngouriMath/Functions/Simplification/Simplificator.cs
index 757254203..f9e9a3613 100644
--- a/Sources/AngouriMath/Functions/Simplification/Simplificator.cs
+++ b/Sources/AngouriMath/Functions/Simplification/Simplificator.cs
@@ -619,9 +619,68 @@ not solve this issue completely and yet too slow to be accepted.
}
}
+ // A rational function's canonical form, offered as one more candidate. The passes
+ // above gather fractions a pair or a sort level at a time, which reaches most sums of
+ // fractions and not all: x/(x + y) + y/(x + y) stayed as it was, and so did
+ // 1/(1 - (b/a)^2) - a^2/(a^2 - b^2), whose canonical forms are 1 and 0 with their
+ // conditions. RationalCanonicalization decides every such identity, and the complexity
+ // metric decides whether its quotient is the better answer, which as an expanded
+ // quotient it often is not. Last, so that a tie goes to the form the rules reached;
+ // with whole coefficients, so that it reads 2/(4x^2 + 1) and not (1/2)/(x^2 + 1/4);
+ // and only where there is a quotient to gather, since a polynomial's canonical form is
+ // its expansion, which is offered above. That is asked by a walk that allocates
+ // nothing: SimplifyEasy, which has a logarithm in it and is never a rational function,
+ // allocated 3.4% more when it was asked through Nodes. Only up to a complexity of 64,
+ // well above the 11 to 39 of the identities this is for, so that a large quotient is
+ // not gathered for a form that will mostly lose. And not while the integrator is
+ // running: its rules read the shape of what Simplify hands back, and a gathered
+ // quotient that rated shorter sent 1/(b cosh x + c sinh x + sqrt(b^2 - c^2))^4 down a
+ // route that took 4.8 s to an answer of 737 characters, where master's took 1.1 s to
+ // one of 427.
+ // https://github.com/asc-community/AngouriMath/issues/2137
+ var canonicalMark = recording?.Mark() ?? 0;
+ if (!Algebra.Integration.IsIntegrating && stage1.Complexity <= 64 && IsARationalFunctionWithAQuotient(stage1)
+ && RationalFunction.TryCanonicalize(stage1, out var canonical, wholeCoefficients: true))
+ AddHistory(Noted(recording, stage1, canonical, "RationalCanonicalization", canonicalMark));
+
return history.Values.SelectMany(x => x);
}
+ ///
+ /// Whether is made of numbers, variables, sums, differences,
+ /// products, quotients and whole powers alone, with a quotient or a negative power among
+ /// them: the expressions may gather into
+ /// one quotient.
+ ///
+ private static bool IsARationalFunctionWithAQuotient(Entity expr)
+ {
+ var quotient = false;
+ return IsARationalFunction(expr, ref quotient) && quotient;
+ }
+
+ private static bool IsARationalFunction(Entity node, ref bool quotient)
+ {
+ switch (node)
+ {
+ case Number or Variable:
+ return true;
+ case Divf(var dividend, var divisor):
+ quotient = true;
+ return IsARationalFunction(dividend, ref quotient) && IsARationalFunction(divisor, ref quotient);
+ case Powf(var @base, Integer exponent):
+ quotient |= exponent.EInteger.Sign < 0;
+ return IsARationalFunction(@base, ref quotient);
+ case Sumf(var left, var right):
+ return IsARationalFunction(left, ref quotient) && IsARationalFunction(right, ref quotient);
+ case Minusf(var left, var right):
+ return IsARationalFunction(left, ref quotient) && IsARationalFunction(right, ref quotient);
+ case Mulf(var left, var right):
+ return IsARationalFunction(left, ref quotient) && IsARationalFunction(right, ref quotient);
+ default:
+ return false;
+ }
+ }
+
///
/// Sorts an expression into a polynomial.
/// See more at
diff --git a/Sources/Tests/UnitTests/Common/SimplificationRegressionTest.cs b/Sources/Tests/UnitTests/Common/SimplificationRegressionTest.cs
index 9c17e4c51..4286b5347 100644
--- a/Sources/Tests/UnitTests/Common/SimplificationRegressionTest.cs
+++ b/Sources/Tests/UnitTests/Common/SimplificationRegressionTest.cs
@@ -121,8 +121,10 @@ public void Issue403_FractionsOverACommonDenominatorCollapse(string input, strin
// The general case still brings unlike denominators together, which needs the
// numerator fully simplified to reach 0.
[Fact]
+ // The condition is the square-free part of (b - 1)(1 - b), which vanishes at the same
+ // points, since #2137 offers the rational canonical form as a candidate.
public void Issue403_UnlikeDenominatorsStillCombine() =>
- Assert.Equal("0 provided not (b - 1) * (1 - b) = 0".ToEntity(),
+ Assert.Equal("0 provided not b - 1 = 0".ToEntity(),
"(a - 1) / (1 - b) - (1 - a) / (b - 1)".ToEntity().Simplify());
// Three separate defects sat on top of each other in the integrator,
diff --git a/Sources/Tests/UnitTests/Common/SumOfFractionsOverOneDenominatorTest.cs b/Sources/Tests/UnitTests/Common/SumOfFractionsOverOneDenominatorTest.cs
new file mode 100644
index 000000000..2756ecd41
--- /dev/null
+++ b/Sources/Tests/UnitTests/Common/SumOfFractionsOverOneDenominatorTest.cs
@@ -0,0 +1,54 @@
+//
+// 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;
+using static AngouriMath.Entity.Number;
+
+namespace AngouriMath.Tests.Common
+{
+ ///
+ /// Simplify gathered fractions a pair or a sort level at a time, and left some sums of
+ /// fractions apart that are a constant over one denominator. The rational canonical form
+ /// decides them, and is offered as a candidate since
+ /// #2137; the first
+ /// row of #2133 is the
+ /// same shape.
+ ///
+ [Trait("Area", "Common")]
+ public sealed class SumOfFractionsOverOneDenominatorTest
+ {
+ [Theory]
+ [InlineData("x/(x + y) + y/(x + y)", 1)]
+ [InlineData("1/(1 - (b/a)^2) - a^2/(a^2 - b^2)", 0)]
+ [InlineData("x/c + (y*c - x*d)/(c^2*(z + d/c)) - (x*z + y)/(c*z + d)", 0)]
+ [InlineData("R^2 - R^2*(a + b)*(1/a + 1/b)/4 - R^2*(a - b)*(1/a - 1/b)/4", 0)]
+ public void ItIsTheConstantItIs(string input, int constant)
+ {
+ var simplified = input.Simplify();
+ while (simplified is Providedf(var inner, _))
+ simplified = inner;
+ Assert.Equal(Integer.Create(constant), simplified);
+ }
+
+ ///
+ /// The condition the answer carries is the input's: defined where the input is.
+ ///
+ [Theory]
+ [InlineData("x/(x + y) + y/(x + y)", "x", "1", "y", "2")]
+ [InlineData("1/(1 - (b/a)^2) - a^2/(a^2 - b^2)", "a", "3", "b", "1")]
+ [InlineData("R^2 - R^2*(a + b)*(1/a + 1/b)/4 - R^2*(a - b)*(1/a - 1/b)/4", "a", "2", "b", "5")]
+ public void ItIsDefinedWhereTheInputIs(string input, string first, string firstValue, string second, string secondValue)
+ {
+ var at = input.Simplify().Substitute(first, firstValue.ToEntity()).Substitute(second, secondValue.ToEntity()).Substitute("R", 7);
+ Assert.IsAssignableFrom(at.Evaled);
+ Assert.NotEqual(MathS.NaN, at.Evaled);
+ }
+ }
+}
diff --git a/Sources/Tests/UnitTests/PatternsTest/FractionSimplify.cs b/Sources/Tests/UnitTests/PatternsTest/FractionSimplify.cs
index 7589137fd..c63349bbc 100644
--- a/Sources/Tests/UnitTests/PatternsTest/FractionSimplify.cs
+++ b/Sources/Tests/UnitTests/PatternsTest/FractionSimplify.cs
@@ -22,7 +22,7 @@ public sealed class FractionSimplify
[InlineData("x/x-x/x", "0 provided not x = 0")]
[InlineData("(xy)/(xy)", "1 provided not xy = 0")]
[InlineData("0*(x+3)^-4", "0 provided not 3 + x = 0")]
- [InlineData("(a - 1) / (1 - b) - (1 - a) / (b - 1)", "0 provided not (b - 1) * (1 - b) = 0")] // #254
+ [InlineData("(a - 1) / (1 - b) - (1 - a) / (b - 1)", "0 provided not b - 1 = 0")] // #254, and #2137 for the square-free condition
[InlineData("(4a - 2) / (2x) + (1 - 2a) / x", "0 provided not x = 0")]
[InlineData("sin(a) * a * b / a", "b * sin(a) provided not a = 0")] // #311
[InlineData("sin(a) * cos(b) * tan(c) / (tan(c)3 * sin(a)2 * cos(b)^(-2))", "csc(a) * cos(b) ^ 3 * cotan(c) ^ 2")]
diff --git a/Sources/Tests/UnitTests/PatternsTest/SimplifyTest.cs b/Sources/Tests/UnitTests/PatternsTest/SimplifyTest.cs
index 90afb0faf..355c54979 100644
--- a/Sources/Tests/UnitTests/PatternsTest/SimplifyTest.cs
+++ b/Sources/Tests/UnitTests/PatternsTest/SimplifyTest.cs
@@ -61,8 +61,10 @@ [Fact] public void Patt8Negative() =>
// The condition used to read `not c = 0 and not a * b * c * x ^ 2 = 0`, which says
// the same thing twice: a product is nonzero exactly when each of its factors is,
// so a b c x^2 != 0 already carries c != 0. Cancelling the common divisor in one
- // step states the domain once, over the same set of points.
- [Fact] public void Patt12() => AssertSimplify((x * y * a * b * c) / (c * b * a * x * x), "y / x provided not a * b * c * x = 0", 4);
+ // step states the domain once, over the same set of points. And x is not repeated in
+ // the condition, since y/x already has no value at x = 0: the rational canonical form
+ // writes the condition without what its own denominator excludes (#2137).
+ [Fact] public void Patt12() => AssertSimplify((x * y * a * b * c) / (c * b * a * x * x), "y / x provided not a * b * c = 0", 4);
[Fact] public void Frac1() => AssertSimplify("x / (y / z)", "x * z / y");
[Fact] public void Frac2() => AssertSimplify("x / y / z", "x / (y * z)");
[Fact] public void Factorial2() => AssertSimplify(MathS.Factorial(2), 2);