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"); + } + } +}