diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index 0b1cc649e..c74e243ee 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -25,6 +25,7 @@ read first. | **silent** | `exp(x)`, `log10(x)`, `log2(x)` | products of undeclared variables | the functions | | loud | `arcsinh(x)` and its five relatives | a product | `UnrecognizedFunctionParseException` | | loud | `floor(x)` and ten other names the library lacks | a product, silently | `UnrecognizedFunctionParseException` | +| **silent** | `sqrt(x^2)`, `sqrt(-x)` and their kind | `x`, `i*sqrt(x)` — wrong for negative x | left as written | | loud | `mod` as a variable name | a variable | a keyword, so a parse error | | **silent** | `Stringize` of powers, lambdas, applications, piecewises | did not parse back | parses back | | **silent** | numbers below `1e-16` | rounded to `0` | kept | @@ -160,6 +161,47 @@ their expression to fix nobody's. [#733](https://github.com/asc-community/AngouriMath/issues/733), PR [#750](https://github.com/asc-community/AngouriMath/pull/750). +### `sqrt(x^2)` is no longer `x`, and `sqrt(-x)` is no longer `i * sqrt(x)` + +| | | +|---|---| +| was | `x`, and `i * sqrt(x)` | +| is | left as written | + +Both were wrong, and by a whole sign rather than a rounding: + +```csharp +"sqrt(x ^ 2)".Simplify() // was x — at x = -0.63 that is -0.63 where it is 0.63 +"(x ^ 2) ^ (3/2)".Simplify() // was x ^ 3 — at x = -2 that is -8 where it is 8 +"sqrt(-x)".Simplify() // was i*sqrt(x) — at x = -0.63 that is -0.7937, not 0.7937 +``` + +Two rules were being applied without the condition each needs. `(a^b)^c = a^(b*c)` holds for +a positive `a`, and for any `a` when `c` is a whole number — `(a^b)^3` is `a^b` multiplied by +itself three times however `a` is signed. `(a*x)^c = a^c * x^c` holds for a positive `a`, and +again for a whole `c`. Both are now restricted to exactly that, and unchanged where they hold: +`(x^(1/2))^2` is still `x`, `(-x)^2` is still `x^2`, `(2^2)^(1/2)` is still `2`. + +`sqrt(x^2)` is `abs(x)`, and the library does not write that for you — it leaves the +expression alone rather than answering something false. Writing `abs` requires knowing the +expression is real, which is what +[#719](https://github.com/asc-community/AngouriMath/issues/719)'s codomain now makes sayable +and is not yet read by the simplifier. + +**One thing got worse, and it is worth stating plainly.** The inequality solver was leaning +on `sqrt(4a^2) = 2a`: `(x - a)(x + a) <= 0` answered `[a; -a]`, which is right for `a < 0` +and empty for `a > 0`. It now answers `{ a, -a } \/ (sqrt(a^2); -sqrt(a^2))`, whose interval +is empty for either sign. Neither form is right for both, because the solver has no case +split on the sign of a symbolic coefficient — that is +[#757](https://github.com/asc-community/AngouriMath/issues/757), and it was invisible while an +unsound rewrite was making it look right half the time. + +Found by `work/simpsweep`, which generates the expressions it checks; its count of +disagreements over 10463 expressions goes **30 to 0**. + +[#752](https://github.com/asc-community/AngouriMath/issues/752), PR +[#758](https://github.com/asc-community/AngouriMath/pull/758). + ### `mod` is now a keyword | | | diff --git a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Power.cs b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Power.cs index 55ec3197d..c2d9c3825 100644 --- a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Power.cs +++ b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Power.cs @@ -42,7 +42,18 @@ expr is Sumf(var any1, Mulf(Real { IsNegative: true } const1, var any2)) Divf(Powf(var any1, var any2), Powf(var any1a, var any3)) when any1 == any1a => new Powf(any1, any2 - any3), // ({} ^ {}) ^ {} = {} ^ ({} * {}) - Powf(Powf(var any1, var any2), var any3) => new Powf(any1, any2 * any3), + // + // True for a positive base whatever the exponents, and for any base when the + // outer exponent is a whole number -- (a^b)^3 is a^b * a^b * a^b however a is + // signed. Outside those two it is false, and it was applied unconditionally: + // + // sqrt(x^2) came back as x, which at -0.63 is -0.63 where it is 0.63 + // (x^2)^(3/2) came back as x^3, which at -2 is -8 where it is 8 + // + // https://github.com/asc-community/AngouriMath/issues/752 + Powf(Powf(var any1, var any2), var any3) + when any3 is Integer || any1.Evaled is Real { IsPositive: true } + => new Powf(any1, any2 * any3), // {1} ^ n * {2} ^ n = ({1} * {2}) ^ n Mulf(Powf(var any1, var any3), Powf(var any2, var any3a)) when any3 == any3a => new Powf(any1 * any2, any3), @@ -82,7 +93,15 @@ expr is Sumf(var any1, Mulf(Real { IsNegative: true } const1, var any2)) Mulf(Mulf(var any2, var any1), Powf(var any1a, var any3)) when any1 == any1a => new Powf(any1, any3 + 1) * any2, // (a * x) ^ c = a^c * x^c - Powf(Mulf(Number const1, var any1), Number const2) => + // + // Taking a factor out from under a root needs that factor to be positive, or the + // root to be a whole power. With a negative one it moves the branch: sqrt(-x) + // became sqrt(-1) * sqrt(x) = i * sqrt(x), and at x = -0.63 the first is 0.7937 + // while the second is -0.7937 -- the negation, not the value. A positive constant + // is safe whatever the sign of x, which is why the rule is narrowed rather than + // removed. https://github.com/asc-community/AngouriMath/issues/752 + Powf(Mulf(Number const1, var any1), Number const2) + when const2 is Integer || const1 is Real { IsPositive: true } => new Powf(const1, const2) * new Powf(any1, const2), // {1} ^ (-1) = 1 / {1} diff --git a/Sources/Tests/UnitTests/Algebra/SolveTest/SolveInequality.cs b/Sources/Tests/UnitTests/Algebra/SolveTest/SolveInequality.cs index 3c337b2f5..bd0524dba 100644 --- a/Sources/Tests/UnitTests/Algebra/SolveTest/SolveInequality.cs +++ b/Sources/Tests/UnitTests/Algebra/SolveTest/SolveInequality.cs @@ -22,7 +22,19 @@ public sealed class SolveInequality [InlineData("(x - 2)(x + 2) > 0", @"(-oo; -2) \/ (2; +oo)")] [InlineData("(x - 2)(x + 2) < 0", "(-2; 2)")] [InlineData("(x - 2)(x + 2) <= 0", "[-2; 2]")] - [InlineData("(x - a)(x + a) <= 0", "[a; -a]")] + // A symbolic coefficient has no known sign, and the solver builds (root1; root2) + // without asking which root is the smaller -- so one sign of `a` always comes out + // as an empty interval. This used to read as [a; -a], correct for a < 0 and empty + // for a > 0, and it read that way only because sqrt(4a^2) was simplifying to 2a. + // That is unsound for a < 0 and is fixed in + // https://github.com/asc-community/AngouriMath/issues/752; with it gone the interval + // is empty for either sign and the endpoints survive. + // + // Neither form is right for both signs. Pinned as it now stands rather than deleted, + // because the expectation is still evidence -- of + // https://github.com/asc-community/AngouriMath/issues/757, which wants the case split + // on the sign that would answer this properly. + [InlineData("(x - a)(x + a) <= 0", @"{ a, -a } \/ (sqrt(a ^ 2); -sqrt(a ^ 2))")] public void Test(string initial, string expected) { Variable x = "x"; diff --git a/Sources/Tests/UnitTests/Common/PowerOfPowerBranchTest.cs b/Sources/Tests/UnitTests/Common/PowerOfPowerBranchTest.cs new file mode 100644 index 000000000..0d7ed8c6a --- /dev/null +++ b/Sources/Tests/UnitTests/Common/PowerOfPowerBranchTest.cs @@ -0,0 +1,121 @@ +// +// 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.Linq; +using AngouriMath; +using AngouriMath.Extensions; +using Xunit; + +namespace AngouriMath.Tests.Common +{ + /// + /// Two rules rewrote a power in a way that is only true on part of the plane, and both + /// changed the value for a negative argument: (a^b)^c = a^(b*c), which needs a + /// positive a or a whole c, and (a*x)^c = a^c * x^c, which needs a + /// positive a or a whole c. Both were applied unconditionally, so + /// sqrt(x^2) came back as x and sqrt(-x) as i * sqrt(x) -- + /// each the negation of the right answer where x is negative. + /// https://github.com/asc-community/AngouriMath/issues/752 + /// + public sealed class PowerOfPowerBranchTest + { + /// + /// Value, not shape. The magnitude was right in every one of these and only the sign + /// was wrong, so a test comparing printed forms could have passed throughout. + /// + private static void AssertSameValueAt(string expr, string point) + { + var original = expr.ToEntity(); + var simplified = original.Simplify(); + double At(Entity e) + { + var substituted = e.Substitute("x", point.ToEntity()); + while (substituted is Entity.Providedf(var inner, _)) substituted = inner; + var value = substituted.EvalNumerical(); + Assert.True(System.Math.Abs(value.ImaginaryPart.EDecimal.ToDouble()) < 1e-9, + $"{expr} is not real at x = {point}"); + return value.RealPart.EDecimal.ToDouble(); + } + Assert.Equal(At(original), At(simplified), 9); + } + + /// + /// The property, checked at a negative point, which is the only place any of this + /// goes wrong. Every one of these was off by a sign before. + /// + [Theory] + [InlineData("sqrt(x ^ 2)")] + [InlineData("(x ^ 2) ^ (1/2)")] + [InlineData("(x * x) ^ (1/2)")] + [InlineData("(x ^ 2) ^ (3/2)")] + [InlineData("(x ^ 2) ^ (1/2 - 1)")] + [InlineData("(x ^ 2) ^ (1/2 - 2)")] + [InlineData("sqrt(-x)")] + [InlineData("sqrt(-1 * x)")] + [InlineData("sqrt(x / -1)")] + [InlineData("(-x) ^ (1/2)")] + [InlineData("(-x) ^ (1/2 - 1)")] + public void ARewriteThatMovesTheBranchIsNotMade(string expr) => + AssertSameValueAt(expr, "-63/100"); + + /// + /// What the rules still do, because there they are true. A whole outer exponent makes + /// (a^b)^c sound whatever the sign of a -- it is a^b multiplied by + /// itself c times -- and a positive constant may always be taken out from under a + /// root. + /// + [Theory] + [InlineData("(x ^ (1/2)) ^ 2", "x")] + [InlineData("(x ^ 2) ^ 2", "x ^ 4")] + [InlineData("(x ^ 2) ^ 3", "x ^ 6")] + [InlineData("(-x) ^ 2", "x ^ 2")] + [InlineData("(2 ^ 2) ^ (1/2)", "2")] + [InlineData("(4 ^ (1/2)) ^ (1/2)", "sqrt(2)")] + public void ARewriteThatHoldsIsStillMade(string expr, string expected) => + Assert.Equal(Entity.Number.Integer.Create(0), + (expr.ToEntity().Simplify() - expected.ToEntity()).Simplify()); + + // A positive base is unaffected in either rule, which is where they came from. + [Theory] + [InlineData("(2 ^ x) ^ 3")] + [InlineData("(3 * x) ^ 2")] + [InlineData("sqrt(4 * x)")] + [InlineData("(x ^ 3) ^ 2")] + public void APositiveBaseKeepsItsValue(string expr) => + AssertSameValueAt(expr, "17/10"); + + /// + /// The property that found all of this, stated over the shapes it found them in: + /// wherever the expression and its simplification are both real, they are the same + /// number. work/simpsweep asks it of ten thousand generated expressions and + /// went from 30 disagreements to 0 with these two rules narrowed. + /// + [Fact] + public void NoPowerRewriteChangesTheValueAtANegativePoint() + { + string[] shapes = + { + "sqrt({0})", "({0}) ^ (1/2)", "({0}) ^ (3/2)", "({0}) ^ 2", "({0}) ^ 3", + "1 / sqrt({0})", "sqrt(sqrt({0}))", + }; + string[] insides = { "x ^ 2", "x * x", "-x", "-1 * x", "x / -1", "x ^ 4", "2 * x ^ 2" }; + foreach (var shape in shapes) + foreach (var inside in insides) + { + var expr = string.Format(shape, inside); + var original = expr.ToEntity(); + var simplified = original.Simplify(); + var before = original.Substitute("x", "-63/100".ToEntity()).EvalNumerical(); + var after = simplified.Substitute("x", "-63/100".ToEntity()).EvalNumerical(); + Assert.Equal(before.RealPart.EDecimal.ToDouble(), + after.RealPart.EDecimal.ToDouble(), 9); + Assert.Equal(before.ImaginaryPart.EDecimal.ToDouble(), + after.ImaginaryPart.EDecimal.ToDouble(), 9); + } + } + } +} diff --git a/Sources/Tests/UnitTests/Common/PowerQuotientGatheringTest.cs b/Sources/Tests/UnitTests/Common/PowerQuotientGatheringTest.cs index b4c2a84ca..66b085bc2 100644 --- a/Sources/Tests/UnitTests/Common/PowerQuotientGatheringTest.cs +++ b/Sources/Tests/UnitTests/Common/PowerQuotientGatheringTest.cs @@ -127,14 +127,25 @@ public void TheSecondRemarkableLimitStillReadsWhatGatheringGivesIt( } /// - /// 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. + /// This was the case deliberately left: a fractional c would have to divide the + /// exponent rather than move into the base, so it would have become + /// ((sqrt(x) + 1)^2 / x)^(x/2) -- a squared numerator to buy a gathered form. + /// + /// It gathers now, and better, without that trade. Narrowing the + /// (a^b)^c = a^(b*c) rule to where it is true + /// (https://github.com/asc-community/AngouriMath/issues/752) means + /// sqrt(x)^x no longer flattens to x^(x/2), so both exponents stay + /// x and the ordinary pairing rule reaches it. The gap this file is about was + /// a rewrite running ahead of the pairing, and one less rewrite is one less way to + /// run ahead of it. /// [Fact] - public void AFractionalExponentRatioIsStillNotGathered() => - Assert.False(Simplified("(sqrt(x) + 1) ^ x / sqrt(x) ^ x") is Entity.Powf); + public void AFractionalExponentRatioGathersOnceNothingFlattensItFirst() + { + var simplified = Simplified("(sqrt(x) + 1) ^ x / sqrt(x) ^ x"); + Assert.True(simplified is Entity.Powf, + $"came back as {simplified.Stringize()}"); + AssertSameValueAt("(sqrt(x) + 1) ^ x / sqrt(x) ^ x", ("x", "13/10")); + } } }