From 52b79033d8c74c43575cd7e726c24964d77cbcf5 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Wed, 5 Aug 2026 20:17:37 +0000 Subject: [PATCH] Gather a quotient of powers whose exponents a rewrite has already moved (#740) `a^p / b^p` is gathered into `(a/b)^p`, and stopped being gathered as soon as one of the bases was itself a power: (a^2)^x / (b^2)^x gathers (a^2)^x / b^x did not (a^2 + 1)^x / (a^2)^x did not It is not the shape of the quotient but the order the rules run in. The rule that rewrites `(b^c)^p` as `b^(c*p)` applies to the *child*, on the way up, so by the time the pair of powers is looked at it has already happened. Where both bases are powers it moves both exponents together and the pair survives -- which is why the first line above works, and why it looked like the shape mattered. Where only one base is a power it moves one exponent and not the other, the exponents stop matching, and there is nothing left to pair on. So the pair is read back: `a^p / b^(c*p)` is `(a / b^c)^p`, and the same the other way up. Restricted to a whole `c`, so that `b^c` moves into the base and nothing gains a root it did not have -- a fractional `c` would have to divide the exponent instead, turning `(sqrt(x) + 1)^x / sqrt(x)^x` into `((sqrt(x) + 1)^2 / x)^(x/2)`, which buys a gathered form with a squared numerator. That is a judgement about output rather than the gap this fixes, so it is left as it stands and pinned as such. Why it is worth gathering: the limit machinery reads a `1^oo` off a single power and cannot see one in a quotient, so the same function was answered or not according only to how it had been written. lim x->+oo (x^2 + 1)^x / (x^2)^x unevaluated after 5.5 s -> 1 in 31 ms lim x->+oo (x^3 + 1)^x / (x^3)^x the same A quotient of numeric powers is untouched, because a numeric exponent is not written as a product and so matches nothing here: `x^4 / y^2` keeps its form. `2^(2x) / 3^x` becoming `(4/3)^x` is the one visible widening beyond the issue. Suite 4979 -> 4986 passed / 0 failed, F# 130/130, corpus 112/117 with 0 wrong and every verdict and answer byte-identical. rootcheck is 595/596 on this branch, its one incomplete case being #744, fixed in PR #745 rather than here. Co-Authored-By: Claude Opus 5 --- .../Simplification/Patterns/Patterns.Power.cs | 16 ++ .../Common/PowerQuotientGatheringTest.cs | 140 ++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 Sources/Tests/UnitTests/Common/PowerQuotientGatheringTest.cs diff --git a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Power.cs b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Power.cs index f7b02bf09..55ec3197d 100644 --- a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Power.cs +++ b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Power.cs @@ -48,6 +48,22 @@ expr is Sumf(var any1, Mulf(Real { IsNegative: true } const1, var any2)) Mulf(Powf(var any1, var any3), Powf(var any2, var any3a)) when any3 == any3a => new Powf(any1 * any2, any3), Divf(Powf(var any1, var any3), Powf(var any2, var any3a)) when any3 == any3a => new Powf(any1 / any2, any3), + // {1} ^ n / {2} ^ (c * n) = ({1} / {2} ^ c) ^ n, and the same the other way up. + // + // The rule above pairs two powers by their exponents, and the ({}^{})^{} rule + // further up rewrites (b^c)^n as b^(c*n) -- on the child, on the way up, so it + // has already happened by the time the pair is looked at. Where it applies to + // only one of the two, which is whenever only one base is itself a power, the + // exponents stop matching and the pair is lost: (a^2)^x / (b^2)^x gathers, + // because both sides moved together, while (a^2)^x / b^x does not. + // These read that pair back. Restricted to a whole c so that nothing gains a + // root it did not have -- b^c goes into the base, rather than the exponent + // being divided. https://github.com/asc-community/AngouriMath/issues/740 + Divf(Powf(var any1, var any3), Powf(var any2, Mulf(Integer { IsPositive: true } const1, var any3a))) + when any3 == any3a => new Powf(any1 / new Powf(any2, const1), any3), + Divf(Powf(var any1, Mulf(Integer { IsPositive: true } const1, var any3)), Powf(var any2, var any3a)) + when any3 == any3a => new Powf(new Powf(any1, const1) / any2, any3), + // x / x^n Divf(var any1, Powf(var any1a, var any2)) when any1 == any1a => new Powf(any1, 1 - any2), diff --git a/Sources/Tests/UnitTests/Common/PowerQuotientGatheringTest.cs b/Sources/Tests/UnitTests/Common/PowerQuotientGatheringTest.cs new file mode 100644 index 000000000..b4c2a84ca --- /dev/null +++ b/Sources/Tests/UnitTests/Common/PowerQuotientGatheringTest.cs @@ -0,0 +1,140 @@ +// +// Copyright (c) 2019-2022 Angouri. +// AngouriMath is licensed under MIT. +// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. +// Website: https://am.angouri.org. +// + +using System.Threading.Tasks; +using AngouriMath; +using AngouriMath.Extensions; +using Xunit; + +namespace AngouriMath.Tests.Common +{ + /// + /// a^p / b^p is gathered into (a/b)^p, but stopped being gathered as soon + /// as one of the bases was itself a power: (a^2)^x / (b^2)^x gathers and + /// (a^2)^x / b^x did not. The cause is that (b^c)^p is rewritten to + /// b^(c*p) on the child, on the way up, so by the time the pair is looked at it + /// has already happened -- and where it applies to only one of the two, the exponents + /// no longer match. https://github.com/asc-community/AngouriMath/issues/740 + /// + public sealed class PowerQuotientGatheringTest + { + private static Entity Simplified(string expr) => expr.ToEntity().Simplify(); + + /// + /// The rewrite has to preserve the value, not just the shape. Checked at a point + /// where every base is positive, which is where a^p / b^(c*p) = (a/b^c)^p + /// holds without a branch argument. + /// + private static void AssertSameValueAt(string expr, params (string Variable, string Value)[] at) + { + var original = expr.ToEntity(); + var simplified = original.Simplify(); + Entity Substituted(Entity e) + { + foreach (var (variable, value) in at) + e = e.Substitute(variable, value.ToEntity()); + return e; + } + Assert.Equal( + Substituted(original).EvalNumerical().RealPart.EDecimal.ToDouble(), + Substituted(simplified).EvalNumerical().RealPart.EDecimal.ToDouble(), + 9); + } + + // A single power is what the gathering is for, and what the limit machinery reads a + // 1^oo off. Asserted as "one power" rather than as a string, since which of the + // equivalent single powers the complexity metric picks is not the point. + private static void AssertGathersIntoOnePower(string expr) + { + var simplified = Simplified(expr); + Assert.True(simplified is Entity.Powf, + $"{expr} came back as {simplified.Stringize()}, which is not a single power"); + } + + [Theory] + [InlineData("(a ^ 2 + 1) ^ x / (a ^ 2) ^ x")] + [InlineData("(x ^ 2 + 1) ^ x / (x ^ 2) ^ x")] + [InlineData("(x ^ 3 + 1) ^ x / (x ^ 3) ^ x")] + [InlineData("(a ^ 2) ^ x / b ^ x")] + [InlineData("(x ^ 2) ^ x / (x ^ 2 + 1) ^ x")] + [InlineData("x ^ (2 * a) / y ^ a")] + public void AQuotientOfPowersGathersWhenOneBaseIsItselfAPower(string expr) => + AssertGathersIntoOnePower(expr); + + [Theory] + [InlineData("(a ^ 2 + 1) ^ x / (a ^ 2) ^ x")] + [InlineData("(a ^ 2) ^ x / b ^ x")] + [InlineData("x ^ (2 * a) / y ^ a")] + [InlineData("(x ^ 3 + 1) ^ x / (x ^ 3) ^ x")] + public void GatheringDoesNotChangeTheValue(string expr) => + AssertSameValueAt(expr, ("a", "17/10"), ("b", "23/10"), ("x", "13/10"), ("y", "31/10")); + + /// + /// Why it is worth gathering. The limit machinery reads a 1^oo off a single power + /// and cannot see one in a quotient, so the same function was answered or not + /// according only to how it had been written -- and spent five and a half seconds + /// not answering. + /// + [Theory] + [InlineData("(x ^ 2 + 1) ^ x / (x ^ 2) ^ x")] + [InlineData("(x ^ 3 + 1) ^ x / (x ^ 3) ^ x")] + [InlineData("((x ^ 2 + 1) / x ^ 2) ^ x")] + public void TheQuotientFormIsAnsweredAsTheSinglePowerFormIs(string expr) + { + var limit = Task.Run(() => expr.ToEntity().Limit("x", "+oo").Simplify()); + Assert.True(limit.Wait(System.TimeSpan.FromSeconds(30)), $"{expr} did not terminate"); + Assert.Equal(Entity.Number.Integer.Create(1), limit.Result); + } + + // What must not change. A numeric exponent is not written as a product, so nothing + // here matches it, and x^4 / y^2 keeps the form it had. + [Theory] + [InlineData("x ^ 4 / y ^ 2")] + [InlineData("x ^ 4 / y ^ 3")] + [InlineData("a ^ 2 / b")] + public void AQuotientWithNumericExponentsIsUnaffected(string expr) => + Assert.False(Simplified(expr) is Entity.Powf, + $"{expr} was gathered into {Simplified(expr).Stringize()}"); + + // What already worked, and still does. + [Theory] + [InlineData("(y + 1) ^ x / y ^ x")] + [InlineData("a ^ x / b ^ x")] + [InlineData("(a ^ 2) ^ x / (b ^ 2) ^ x")] + [InlineData("(x + 1) ^ (2 * x) / x ^ (2 * x)")] + public void TheQuotientsThatAlreadyGatheredStillDo(string expr) => + AssertGathersIntoOnePower(expr); + + /// + /// The limits #739 fixed go through the same gathering, so they are pinned here as + /// well: a change to which quotients gather is a change to which of these are + /// answered. + /// + [Theory] + [InlineData("(x - 5) ^ x / x ^ x", "+oo", "1 / e ^ 5")] + [InlineData("(x + 1) ^ x / x ^ x", "+oo", "e")] + [InlineData("(x - 5) ^ x / x ^ x", "-oo", "1 / e ^ 5")] + public void TheSecondRemarkableLimitStillReadsWhatGatheringGivesIt( + string expr, string approach, string expected) + { + var limit = expr.ToEntity().Limit("x", approach.ToEntity()).Simplify(); + Assert.Equal(Entity.Number.Integer.Create(0), + (limit - expected.ToEntity()).Simplify()); + } + + /// + /// What is deliberately left. A fractional c would have to divide the exponent + /// rather than move into the base, so (sqrt(x) + 1)^x / sqrt(x)^x would + /// become ((sqrt(x) + 1)^2 / x)^(x/2) -- a squared numerator to buy a + /// gathered form. That is a judgement about output rather than the gap this fixes, + /// so it is pinned as it stands rather than forced. + /// + [Fact] + public void AFractionalExponentRatioIsStillNotGathered() => + Assert.False(Simplified("(sqrt(x) + 1) ^ x / sqrt(x) ^ x") is Entity.Powf); + } +}