Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions BREAKING-CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,31 @@ expr is Powf(var @base, Integer { IsNegative: true } pow)
_ => expr
};

/// <summary>
/// Whether <c>(c / a)^d</c> may be written <c>c^d * a^(-d)</c> whatever <c>a</c> is.
/// </summary>
/// <remarks>
/// For a whole <c>d</c> it is a quotient multiplied by itself, and holds wherever
/// <c>a</c> is not zero. For a rational <c>d</c> with an odd denominator it holds over a
/// positive <c>c</c>, because a negative base takes the real root there and real roots
/// multiply: <c>(1/(-8))^(1/3)</c> is <c>-1/2</c>, and so is <c>(-8)^(-1/3)</c>. For any
/// other <c>d</c> a negative <c>a</c> moves the branch -- <c>(1/(-2))^(1/2)</c> is
/// <c>0.707i</c> and <c>(-2)^(-1/2)</c> is <c>-0.707i</c> -- and a negative or complex
/// <c>c</c> does the same to the odd root, so the rule refuses there rather than assume
/// the sign of <c>a</c>.
/// https://github.com/asc-community/AngouriMath/issues/1734
/// </remarks>
internal static bool AReciprocalPowerSplits(Number c, Number d) =>
d is Integer || IsWholeOrAnOddRoot(d) && c is Real { IsPositive: true };

/// <summary>
/// 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.
/// </summary>
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
{
Expand Down Expand Up @@ -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 =>
Expand Down
4 changes: 4 additions & 0 deletions Sources/Tests/Harnesses/BoundCheck/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
72 changes: 72 additions & 0 deletions Sources/Tests/UnitTests/Common/ReciprocalPowerBranchTest.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// <c>(c/a)^d * a^e = c^d * a^(e - d)</c> splits the power of the quotient, which holds for a
/// whole <c>d</c>, and for an odd root over a positive <c>c</c>, and moves the branch
/// otherwise: <c>(1/(-2))^(1/2)</c> is <c>0.707i</c> and <c>(-2)^(-1/2)</c> is
/// <c>-0.707i</c>. It was applied for every numeric <c>d</c>, so <c>sqrt(1/x) * sqrt(x)</c>
/// came back as 1 where it is -1, and <c>sec(x)^(3/2) cos(x)^(3/2)</c>, the constant an
/// antiderivative carries where the cosine is negative, as 1 where it is -1.
/// https://github.com/asc-community/AngouriMath/issues/1734
/// </summary>
[Trait("Area", "Common")]
public sealed class ReciprocalPowerBranchTest
{
/// <summary>
/// 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.
/// </summary>
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}");
}

/// <summary>The negative side, where every one of these was wrong.</summary>
[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);

/// <summary>
/// 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.
/// </summary>
[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");
}
}
}
Loading