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()));
}
}