Skip to content

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

Description

@Rafael-SOWNet

MathS.ToSympyCode emits a quotient of two integers as Python's /, which is float division, so an
exact value leaves as an inexact one. Measured on master (548ea178) with SymPy 1.14:

"1/2".ToEntity()          =>  expr = 1 / 2        which Python evaluates to 0.5, a float
"x + 1/2".ToEntity()      =>  expr = x + 1 / 2    which is x + 0.5, a Float coefficient

sympy.sympify(1/2) is 0.500000000000000 — a Float, not Rational(1, 2) — so the exactness is gone
before SymPy ever sees the expression, and nothing downstream can recover it.

It bites only the unsimplified form. "1/2".ToEntity() is a Divf of two integers, because a
printed rational parses back as a division (#873);
once simplified it is a Rational node and emits sympy.Rational(1, 2), which is exact. So the defect
appears exactly when a caller exports what they wrote rather than what the library computed — which is a
normal thing to do, and gives no sign that anything was lost.

Split out of #909, which was about names the
generated program never binds. That one is a defect with one answer; this one is a design choice, which
is why it is separate.

The choice

Emit sympy.Integer(n) for every integer. Then 1 / 2 becomes
sympy.Integer(1) / sympy.Integer(2), which SymPy evaluates to Rational(1, 2), and every arithmetic
operation in the emitted program is exact for the same reason. The cost is that x + 1 becomes
x + sympy.Integer(1), and a polynomial becomes noticeably wordier.

Or wrap only where exactness is at stake — the numerator or denominator of a division, the exponent
of a negative power. That keeps the common case readable, at the cost of the exporter needing to know
its context, which a per-node ToSymPy() does not have. It would mean passing that context down, or
handling the quotient case inside Divf.ToSymPy() by wrapping integer operands there.

My recommendation is the second, done in Divf. Divf(Integer, Integer) is the only shape where
Python's / silently changes the value — +, -, * and ** on Python integers are all exact — so
one arm covers it, and the emitted code stays readable everywhere else. 2 ** 70 already exports
correctly because Python's integers are arbitrary-precision.

Worth deciding at the same time: a finite Real emits Stringize(), which for a 100-digit EDecimal
is a decimal literal that Python narrows to a 17-digit float. sympy.Float('<digits>', <precision>)
would keep it. Same class of loss, different node, and it has no bearing on the choice above.

Why nothing caught it

The emitted code is never executed by the suite, and it never was — #909's fix added tests for what can
be checked without an interpreter (balanced parentheses, bound names), and value fidelity is not in
that set. The general answer is a harness that emits code for a corpus, runs it under Python, and
compares SymPy's value against the library's own — which would also serve
#717.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions