diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index 89aaf21db..a791c04b9 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -29,6 +29,7 @@ read first. | loud | `mod` as a variable name | a variable | a keyword, so a parse error | | loud | `NaN` as a variable name | a variable | a keyword, so the NaN value | | loud | `MathS.ToSympyCode` of any non-integer rational | `SyntaxError` — a parenthesis was never closed | code that runs | +| **silent** | `MathS.ToSympyCode` of `1/2`, `2^(-1)` | ran, and gave the float `0.5` | `1/2`, exact | | loud | `MathS.ToSympyCode` of `NaN`, `+oo`, `-oo` | `NameError` — the name is never bound | `sympy.nan`, `sympy.oo`, `-sympy.oo` | | **silent** | `NaN` printed and read back | a variable of that name, which cancels and collects | the NaN value | | **silent** | `Stringize` of powers, lambdas, applications, piecewises | did not parse back | parses back | @@ -1040,6 +1041,36 @@ through `sympy.`. 17 of their 23 cases fail against the old exporter. Issue [#909](https://github.com/asc-community/AngouriMath/issues/909). +### `MathS.ToSympyCode` keeps an exact value exact + +The generated program ran, and then quietly gave a different number. Python's `/`, and its `**` with a +negative exponent, are float operations on two integers: + +| expression | emitted | SymPy read it as | now emitted | and reads as | +|---|---|---|---|---| +| `1/2` | `1 / 2` | `0.500000000000000`, a `Float` | `sympy.Integer(1) / 2` | `1/2`, a `Rational` | +| `2^(-1)` | `2 ** (-1)` | `0.5` | `sympy.Integer(2) ** (-1)` | `1/2` | +| `2^(-3)` | `2 ** (-3)` | `0.125` | `sympy.Integer(2) ** (-3)` | `1/8` | +| `x + 1/2` | `x + 1 / 2` | `x + 0.5` | `x + sympy.Integer(1) / 2` | `x + 1/2` | + +Making one operand a SymPy integer hands the arithmetic to SymPy, which keeps it exact. **Only a pair of +integers is rewritten**, and the rest of the emitted code is unchanged: with a symbol anywhere in the +shape SymPy's own operators already take over (`x / 2`, `1 / x`, `x ** (-1)`), and `+`, `-`, `*` and a +non-negative `**` are exact on Python integers, whose precision is unbounded — `2 ** 70` was always +right. + +It bit only the **unsimplified** form, which is the one a caller writes: `"1/2".ToEntity()` is a `Divf` +of two integers, because a printed rational parses back as a division +([#873](https://github.com/asc-community/AngouriMath/issues/873)), while a simplified `1/2` is a +`Rational` node and already emitted `sympy.Rational(1, 2)`. + +Checked by running the emitted programs against SymPy 1.14, which is the only way this class of defect +shows itself — the code was always valid, so the earlier tests could not have caught it, and the two +added here assert the property instead: no two plain integer literals are combined with `/` or with a +negative `**`. + +Issue [#911](https://github.com/asc-community/AngouriMath/issues/911). + ### `NaN` is now a keyword, and the printed form of NaN reads back `Stringize` prints the NaN value as `NaN`, and the grammar had no such token, so reading it back gave a diff --git a/Sources/AngouriMath/Functions/Output/ToSympy/ToSympy.Arithmetics.Classes.cs b/Sources/AngouriMath/Functions/Output/ToSympy/ToSympy.Arithmetics.Classes.cs index 3ca5bb89d..48814dbf5 100644 --- a/Sources/AngouriMath/Functions/Output/ToSympy/ToSympy.Arithmetics.Classes.cs +++ b/Sources/AngouriMath/Functions/Output/ToSympy/ToSympy.Arithmetics.Classes.cs @@ -27,10 +27,31 @@ internal override string ToSymPy() => Multiplier.ToSymPy(Multiplier.Priority < Priority.Mul) + " * " + Multiplicand.ToSymPy(Multiplicand.Priority < Priority.Mul); } + /// + /// The operand written so that SymPy does the arithmetic rather than Python. + /// + /// + /// Python's / and its ** with a negative exponent are float operations on two + /// integers, so an exact value would leave here inexact: 1 / 2 is 0.5 there, + /// and sympify(0.5) is a Float and not Rational(1, 2) — the exactness + /// is gone before SymPy sees the expression, and nothing downstream recovers it. Making one + /// operand a SymPy integer is enough, since its operators sympify the other side. + /// + /// Only a pair of integers needs this. With a symbol anywhere in the shape SymPy's own + /// operators already take over, and +, -, * and a non-negative + /// ** are exact on Python integers, whose precision is unbounded. + /// https://github.com/asc-community/AngouriMath/issues/911 + /// + private static string ToSymPyExactly(Entity operand) + => $"sympy.Integer({operand.ToSymPy()})"; + public partial record Divf { internal override string ToSymPy() => - Dividend.ToSymPy(Dividend.Priority < Priority.Div) + " / " + Divisor.ToSymPy(Divisor.Priority <= Priority.Div); + (Dividend is Number.Integer && Divisor is Number.Integer + ? ToSymPyExactly(Dividend) + : Dividend.ToSymPy(Dividend.Priority < Priority.Div)) + + " / " + Divisor.ToSymPy(Divisor.Priority <= Priority.Div); } public partial record Modf @@ -49,7 +70,10 @@ public partial record Powf internal override string ToSymPy() => Exponent == 0.5m ? "sympy.sqrt(" + Base.ToSymPy() + ")" - : Base.ToSymPy(Base.Priority < Priority.Pow) + " ** " + Exponent.ToSymPy(Exponent.Priority < Priority.Pow); + : (Base is Number.Integer && Exponent is Number.Integer { IsNegative: true } + ? ToSymPyExactly(Base) + : Base.ToSymPy(Base.Priority < Priority.Pow)) + + " ** " + Exponent.ToSymPy(Exponent.Priority < Priority.Pow); } public partial record Signumf diff --git a/Sources/Tests/UnitTests/Convenience/ToSympyCodeTest.cs b/Sources/Tests/UnitTests/Convenience/ToSympyCodeTest.cs index 698b1d671..b64a477f0 100644 --- a/Sources/Tests/UnitTests/Convenience/ToSympyCodeTest.cs +++ b/Sources/Tests/UnitTests/Convenience/ToSympyCodeTest.cs @@ -111,5 +111,51 @@ public void TheEmittedCodeHasBalancedParentheses(string expression) [InlineData("-oo", "-sympy.oo")] public void AValueIsEmittedWithSympysOwnSpelling(string expression, string expected) => Assert.Contains(expected, MathS.ToSympyCode(expression.ToEntity().Simplify())); + + /// + /// Python's /, and its ** with a negative exponent, are float operations on + /// two integers. So the emitted body must never combine two plain integer literals with + /// either: 1 / 2 is 0.5 there, and an exact value would leave here inexact + /// with nothing downstream able to recover it. + /// + /// + /// Asserted as a property of the emitted text rather than by running it, since the suite + /// cannot depend on an interpreter. These are taken unsimplified on purpose: a + /// simplified 1/2 is a Rational node and was always exact, while what a + /// caller writes parses to a Divf of two integers — which is #873 — and that is the + /// shape that was losing the value. + /// https://github.com/asc-community/AngouriMath/issues/911 + /// + [Theory] + [InlineData("1/2")] + [InlineData("4/2")] + [InlineData("x + 1/2")] + [InlineData("2 ^ (-1)")] + [InlineData("2 ^ (-3)")] + [InlineData("(1/2) ^ (-1)")] + [InlineData("1/3 + 1/6")] + [InlineData("sin(x) / 2 + 1/4")] + public void NoTwoIntegerLiteralsAreCombinedInexactly(string expression) + { + var (_, body) = Split(MathS.ToSympyCode(expression.ToEntity())); + Assert.DoesNotMatch(@"(? + /// What that looks like: one operand handed to SymPy, which then does the arithmetic and + /// keeps it exact. Only the pair-of-integers shapes are touched — with a symbol anywhere + /// SymPy's operators already take over, and 2 ** 70 is exact because Python's + /// integers are unbounded. + /// + [Theory] + [InlineData("1/2", "sympy.Integer(1) / 2")] + [InlineData("2 ^ (-1)", "sympy.Integer(2) ** (-1)")] + [InlineData("x / 2", "x / 2")] + [InlineData("1/x", "1 / x")] + [InlineData("2 ^ 70", "2 ** 70")] + [InlineData("x ^ (-1)", "x ** (-1)")] + public void OnlyAPairOfIntegersIsRewritten(string expression, string expected) => + Assert.Contains(expected, MathS.ToSympyCode(expression.ToEntity())); } }