diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md
index b708e0b60..42dce8f43 100644
--- a/BREAKING-CHANGES.md
+++ b/BREAKING-CHANGES.md
@@ -856,6 +856,26 @@ the root's linear. The same where a square factor is taken out of a root: `x/(x
| `"1/(2+sqrt(x^2+2*x+1))".ToEntity().Integrate("x")` | `integral(...)` | `ln(x + 3)` where `x + 1 > 0`, `-ln(1 - x)` otherwise |
| `"x/(x+sqrt(x^6))".ToEntity().Integrate("x")` | `integral(...)` | `arctan(x)` where `x > 0`, a logarithm of `(1 + x)/(1 - x)` otherwise |
+### A power of a quotient is split only where that keeps its branch
+
+**Wrong answers, silent.** `Simplify` wrote `(c/a)^d * a^e` as `c^d * a^(e - d)` for every numeric
+`d`, which splits the power of the quotient as `(c/a)^d = c^d a^(-d)`. That holds for a whole `d`,
+and for a rational `d` with an odd denominator over a positive `c`, where a negative base takes its
+real root; for any other `d` it moves the branch wherever `a` is negative: `(1/(-2))^(1/2)` is
+`0.707i` and `(-2)^(-1/2)` is `-0.707i`. So `sqrt(x) sqrt(1/x)` simplified to 1 where it is -1 for
+every negative `x`, and `sec(x)^(3/2) cos(x)^(3/2)`, the constant an antiderivative carries
+where the cosine is negative, to 1 where it is -1. The rewrite is made where it holds now, and the
+expression is left as written elsewhere
+([#1734](https://github.com/asc-community/AngouriMath/issues/1734)).
+
+| Input | Was (2.5.0) | Now |
+|---|---|---|
+| `"sqrt(x)*sqrt(1/x)".Simplify()` | `1 provided not x = 0`, and it is -1 for every negative `x` | unchanged |
+| `"sqrt(1/x)*x".Simplify()` | `sqrt(x)`, its negation for every negative `x` | unchanged |
+| `"(2/x)^(1/2)*x".Simplify()` | `sqrt(2) * sqrt(x)`, its negation for every negative `x` | unchanged |
+| `"sec(x)^(3/2)*cos(x)^(3/2)".Simplify()` | `1 provided not cos(x) = 0`, and it is -1 wherever the cosine is negative | unchanged |
+| `"(1/x)^(1/3)*x^(1/3)".Simplify()` | `1 provided not x = 0` | the same: an odd root of a negative base is the real one |
+
### A root of an even power is the modulus, and is no longer read as the power
**Wrong answers, silent.** `(u^2)^(3/2)` is `|u|^3`, and two readers took it for `u^3`: the
diff --git a/Sources/AngouriMath/Core/Transformations/Matching/MatchedRules.cs b/Sources/AngouriMath/Core/Transformations/Matching/MatchedRules.cs
index 2b5031234..2bef66f90 100644
--- a/Sources/AngouriMath/Core/Transformations/Matching/MatchedRules.cs
+++ b/Sources/AngouriMath/Core/Transformations/Matching/MatchedRules.cs
@@ -3174,7 +3174,10 @@ is var (divided, remainder)
bound => new Powf(bound["c"], bound["d"])
* new Powf(bound["a"], 1 - (Number)bound["d"]),
Soundness.SoundUnderAssumptions,
- description: "(c / a) ^ d * a = c ^ d * a ^ (1 - d), for numeric c and d",
+ // Splitting the power of the quotient moves the branch for a negative a unless d
+ // is whole, or an odd root over a positive c: see AReciprocalPowerSplits.
+ when: bound => Functions.Patterns.AReciprocalPowerSplits((Number)bound["c"], (Number)bound["d"]),
+ description: "(c / a) ^ d * a = c ^ d * a ^ (1 - d), for numeric c and d, d whole or an odd root over a positive c",
// The two numbers are leaves and 1 - d folds to one; the base is matched twice
// and written once, and the second power is one node more than the quotient it
// replaces: the delta is 1 - |a|, nothing at a leaf and a shrink beyond it.
@@ -3190,7 +3193,11 @@ is var (divided, remainder)
bound => new Powf(bound["c"], bound["d"])
* new Powf(bound["a"], (Number)bound["e"] - (Number)bound["d"]),
Soundness.SoundUnderAssumptions,
- description: "(c / a) ^ d * a ^ e = c ^ d * a ^ (e - d), for numeric c, d and e",
+ // As above, and the two powers of a combine only where both read a negative a the
+ // same way: an odd root beside a principal one does not.
+ when: bound => Functions.Patterns.AReciprocalPowerSplits((Number)bound["c"], (Number)bound["d"])
+ && (bound["d"] is Integer || Functions.Patterns.IsWholeOrAnOddRoot((Number)bound["e"])),
+ description: "(c / a) ^ d * a ^ e = c ^ d * a ^ (e - d), for numeric c, d and e, d whole or an odd root over a positive c",
// The three numbers are leaves and e - d folds to one; the base is matched twice
// and written once, and the quotient goes: the delta is -1 - |a|, at most -2.
growth: RewriteRuleGrowth.Collects),
diff --git a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Power.cs b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Power.cs
index cbe7cd717..139eb7c57 100644
--- a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Power.cs
+++ b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Power.cs
@@ -42,6 +42,31 @@ expr is Powf(var @base, Integer { IsNegative: true } pow)
_ => expr
};
+ ///
+ /// Whether (c / a)^d may be written c^d * a^(-d) whatever a is.
+ ///
+ ///
+ /// For a whole d it is a quotient multiplied by itself, and holds wherever
+ /// a is not zero. For a rational d with an odd denominator it holds over a
+ /// positive c, because a negative base takes the real root there and real roots
+ /// multiply: (1/(-8))^(1/3) is -1/2, and so is (-8)^(-1/3). For any
+ /// other d a negative a moves the branch -- (1/(-2))^(1/2) is
+ /// 0.707i and (-2)^(-1/2) is -0.707i -- and a negative or complex
+ /// c does the same to the odd root, so the rule refuses there rather than assume
+ /// the sign of a.
+ /// https://github.com/asc-community/AngouriMath/issues/1734
+ ///
+ internal static bool AReciprocalPowerSplits(Number c, Number d) =>
+ d is Integer || IsWholeOrAnOddRoot(d) && c is Real { IsPositive: true };
+
+ ///
+ /// A whole number, or a rational whose denominator is odd: an exponent under which a
+ /// negative base takes its real root, so that powers of it combine as they do on the
+ /// positive axis.
+ ///
+ internal static bool IsWholeOrAnOddRoot(Number n) =>
+ n is Integer || n is Rational r && !r.ERational.Denominator.IsEven;
+
[AddressableRules]
internal static Entity PowerRules(Entity x) => x switch
{
@@ -198,10 +223,16 @@ expr is Powf(var @base, Integer { IsNegative: true } pow)
Powf(var any1, Integer(-1)) => 1 / any1,
// (a / {})^b * {} = a^b * {}^(1-b)
- Mulf(Powf(Divf(Number const1, var any1), Number const2), var any1a) when any1 == any1a =>
+ //
+ // Splitting the power of the quotient is (c / a)^d = c^d * a^(-d), which is not true
+ // on the whole plane: see AReciprocalPowerSplits. sqrt(1/x) * sqrt(x) came back as 1,
+ // and at x = -2 it is -1.
+ Mulf(Powf(Divf(Number const1, var any1), Number const2), var any1a)
+ when any1 == any1a && AReciprocalPowerSplits(const1, const2) =>
new Powf(const1, const2) * new Powf(any1, 1 - const2),
Mulf(Powf(Divf(Number const1, var any1), Number const2), Powf(var any1a, Number const3))
- when any1 == any1a => new Powf(const1, const2) * new Powf(any1, const3 - const2),
+ when any1 == any1a && AReciprocalPowerSplits(const1, const2) && (const2 is Integer || IsWholeOrAnOddRoot(const3))
+ => new Powf(const1, const2) * new Powf(any1, const3 - const2),
// {1} / {2} / {2}
Divf(Divf(var any1, var any2), var any2a) when any2 == any2a =>
diff --git a/Sources/Tests/Harnesses/BoundCheck/Program.cs b/Sources/Tests/Harnesses/BoundCheck/Program.cs
index c43dde1ed..96bddad12 100644
--- a/Sources/Tests/Harnesses/BoundCheck/Program.cs
+++ b/Sources/Tests/Harnesses/BoundCheck/Program.cs
@@ -115,6 +115,10 @@ static readonly (string Name, Complex Value)[] Points =
"abs(x)^2", "abs(x^2)", "abs(-x)", "abs(x)/x", "x/abs(x)",
"sgn(x)*abs(x)", "abs(x)*sgn(x)",
"sqrt(x)*sqrt(x)", "sqrt(-x)", "sqrt(1/x)", "1/sqrt(x)",
+ // A power of a reciprocal beside a power of its denominator, which splitting the power
+ // of the quotient got wrong for every negative x.
+ // https://github.com/asc-community/AngouriMath/issues/1734
+ "sqrt(x)*sqrt(1/x)", "sqrt(1/x)*x", "(1/x)^(3/2)*x^(3/2)", "sec(x)^(3/2)*cos(x)^(3/2)",
"(-x)^(1/3)", "(-x)^(1/2)", "(2*x)^(1/2)",
"tan(x)*cotan(x)", "sin(x)/cos(x)", "sin(x)^2+cos(x)^2",
"arcsin(x)+arccos(x)", "arctan(x)+arccotan(x)",
diff --git a/Sources/Tests/UnitTests/Common/ReciprocalPowerBranchTest.cs b/Sources/Tests/UnitTests/Common/ReciprocalPowerBranchTest.cs
new file mode 100644
index 000000000..1e3d234c8
--- /dev/null
+++ b/Sources/Tests/UnitTests/Common/ReciprocalPowerBranchTest.cs
@@ -0,0 +1,72 @@
+//
+// 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.Extensions;
+using Xunit;
+
+namespace AngouriMath.Tests.Common
+{
+ ///
+ /// (c/a)^d * a^e = c^d * a^(e - d) splits the power of the quotient, which holds for a
+ /// whole d, and for an odd root over a positive c, and moves the branch
+ /// otherwise: (1/(-2))^(1/2) is 0.707i and (-2)^(-1/2) is
+ /// -0.707i. It was applied for every numeric d, so sqrt(1/x) * sqrt(x)
+ /// came back as 1 where it is -1, and sec(x)^(3/2) cos(x)^(3/2), the constant an
+ /// antiderivative carries where the cosine is negative, as 1 where it is -1.
+ /// https://github.com/asc-community/AngouriMath/issues/1734
+ ///
+ [Trait("Area", "Common")]
+ public sealed class ReciprocalPowerBranchTest
+ {
+ ///
+ /// Value, not shape, compared as complex numbers: several of these are not real at the
+ /// point, and the simplified form was off by a sign or a phase there.
+ ///
+ private static void AssertSameValueAt(string expr, string variable, string point)
+ {
+ var original = expr.ToEntity();
+ var simplified = original.Simplify();
+ Entity.Number.Complex At(Entity e)
+ {
+ var substituted = e.Substitute(variable, point.ToEntity());
+ while (substituted is Entity.Providedf(var inner, _)) substituted = inner;
+ return substituted.EvalNumerical();
+ }
+ var (want, got) = (At(original), At(simplified));
+ Assert.True((want - got).Abs().EDecimal.ToDouble() < 1e-9,
+ $"{expr} is {want} at {variable} = {point}, and its simplified form {simplified} is {got}");
+ }
+
+ /// The negative side, where every one of these was wrong.
+ [Theory]
+ [InlineData("sqrt(x) * sqrt(1/x)", "x", "-2")]
+ [InlineData("sqrt(1/x) * x", "x", "-63/100")]
+ [InlineData("sqrt(1/x) * x ^ (3/2)", "x", "-63/100")]
+ [InlineData("(1/x) ^ (3/2) * x ^ (3/2)", "x", "-63/100")]
+ [InlineData("(2/x) ^ (1/2) * x", "x", "-3")]
+ [InlineData("sec(x) ^ (3/2) * cos(x) ^ (3/2)", "x", "2")]
+ [InlineData("sec(x) ^ (1/2) * cos(x) ^ (1/2)", "x", "2")]
+ public void ARewriteThatMovesTheBranchIsNotMade(string expr, string variable, string point) =>
+ AssertSameValueAt(expr, variable, point);
+
+ ///
+ /// What the rule still does, because there it is true: a whole power of the quotient,
+ /// and an odd root, which takes the real root of a negative base.
+ ///
+ [Theory]
+ [InlineData("(1/x) ^ 2 * x ^ 3", "x")]
+ [InlineData("(2/x) ^ 2 * x", "4 / x")]
+ [InlineData("(1/x) ^ (1/3) * x ^ (1/3)", "1")]
+ public void ARewriteThatHoldsIsStillMade(string expr, string expected)
+ {
+ var simplified = expr.ToEntity().Simplify();
+ while (simplified is Entity.Providedf(var inner, _)) simplified = inner;
+ Assert.Equal(expected.ToEntity().Simplify(), simplified);
+ AssertSameValueAt(expr, "x", "-63/100");
+ }
+ }
+}