Skip to content

Keep an exact value exact on the way to SymPy (#911) - #913

Merged
Rafael-SOWNet merged 1 commit into
masterfrom
fix/sympy-exact-rationals
Aug 12, 2026
Merged

Keep an exact value exact on the way to SymPy (#911)#913
Rafael-SOWNet merged 1 commit into
masterfrom
fix/sympy-exact-rationals

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator

Closes #911, taking that issue's second option — the narrow one, done in the two nodes where a value is
actually lost.

Where #909 was code that would not run, this is code that 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, and that is measured

The issue floated wrapping every integer, which is faithful but turns x + 1 into
x + sympy.Integer(1). It isn't needed. I checked each operator shape against Python rather than
reasoning about it:

1 / 2        -> 0.5                      float     <- loses
2 ** (-1)    -> 0.5                      float     <- loses
2 ** (-3)    -> 0.125                    float     <- loses
x / 2        -> x/2                      Mul       <- exact already
1 / x        -> 1/x                      Pow       <- exact already
x ** (-1)    -> 1/x                      Pow       <- exact already
2 ** 70      -> 1180591620717411303424   int       <- exact already

So two arms cover it: Divf(Integer, Integer) and Powf(Integer, negative Integer). Everything else is
byte-for-byte what it was — with a symbol anywhere SymPy's own operators take over, and +, -, * and
a non-negative ** are exact on Python integers, whose precision is unbounded.

It bit the form a caller writes

Only the unsimplified expression. "1/2".ToEntity() is a Divf of two integers, because a printed
rational parses back as a division (#873); a simplified 1/2 is a Rational node and already emitted
sympy.Rational(1, 2). So the defect appeared exactly when someone exported what they wrote rather than
what the library computed, and gave no sign that anything had been lost.

Verified by running it

Against SymPy 1.14 locally — the only way this class shows itself, since the code was always valid
Python and so #909's tests could not have caught it. Ten expressions: six were inexact before, all ten
are exact now, none fails.

The two tests added here assert the property without an interpreter in the suite: no two plain integer
literals are combined with / or with a negative **
, plus the positive form for the shapes that must
stay as they were. That is the strongest statement available without Python, and it generalises to any
future node that emits bare integer arithmetic.

Suite 6488 passed / 0 failed, F# wrapper 130 passed, casbench 117/119 with 0 wrong. ToSymPy is reached
only from ToSympyCode, so evaluation and simplification cannot see this.

Cut from master at 32a90588.

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, so 1 / 2 was 0.5 in the emitted code and
sympify(0.5) is a Float rather than Rational(1, 2) -- the exactness gone before SymPy saw the
expression, with nothing downstream able to recover it. 2 ** (-1) and 2 ** (-3) went the same way,
as 0.5 and 0.125.

Making one operand a SymPy integer hands the arithmetic to SymPy, which keeps it exact.
sympy.Integer(1) / 2 is Rational(1, 2); sympy.Integer(2) ** (-3) is 1/8.

Only a pair of integers is rewritten, which is the whole of what loses a value. With a symbol
anywhere in the shape SymPy's operators already take over -- x / 2, 1 / x and x ** (-1) are
untouched and were always exact -- and +, -, * and a non-negative ** are exact on Python integers,
whose precision is unbounded, so 2 ** 70 was always right. Measured shape by shape rather than
assumed, since the guess about which operators lose a value is the kind that is usually wrong.

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), while a simplified 1/2
is a Rational node and already emitted sympy.Rational(1, 2).

Verified 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 Python, so #909's tests could not have caught it.
Ten expressions, previously six of them inexact, now all exact and none failing. The two tests
added here assert the property without an interpreter -- no two plain integer literals are combined
with / or with a negative ** -- and the positive form for the shapes that must stay as they were.

Suite 6488 passed, F# wrapper 130 passed, casbench 117/119 with 0 wrong. ToSymPy is reached only
from ToSympyCode, so nothing in evaluation or simplification can see this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Rafael-SOWNet
Rafael-SOWNet merged commit 9446c76 into master Aug 12, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ToSympyCode exports an exact rational as float division, so 1/2 arrives as 0.5

1 participant