An int prints as 5.0 because the SAME NAME is assigned a float later in the file
x = 5
print(x) # CPython: 5 pxx: 5.0
x = 3.14
print(x) # CPython: 3.14 pxx: 3.14
The first print runs before any float exists, and still renders 5.0. A
later assignment retroactively changes how an earlier value is displayed.
Same inside a function body:
def local() -> int:
y = 7
print(y) # CPython: 7 pxx: 7.0
y = 2.5
print(y)
How it was found, and why it survived
The suite's own test/test_nilpy_widen_fix.npy is exactly this program, and its
recorded expectation is pxx's output, not Python's:
test "$$(/tmp/test_nilpy_widen_fix26)" = "$$(printf '5.0\n3.14\n7.0\n2.5')"
So the test passes while encoding the divergence — the same shape as
test_nilpy_string_variant, whose x < a expectation turned out to BE the bug.
Found by running all 230 .npy tests under CPython and diffing: 202 run
cleanly under CPython and only three diverge, this being the only genuine one
(one is a stdout-buffering artifact of the harness, one is the deliberate
Optional[int] -> 0 sentinel).
Cause
Rebinding a name across types widens its STATIC type to the join (int + float
-> float), which is what makes x = 5 then x = 3.14 legal at all — the name
has one slot. But the widened type is then used for every reference to the
name, including those lexically BEFORE the widening assignment, so the integer
is stored and rendered as a double.
CPython has no such constraint: a name is a reference, and each binding carries its own type.
Options
- Widen to VARIANT rather than to float. The value then carries its own tag and renders per-binding, which is what the variant tier is for. Costs a boxed slot for any name that is rebound across numeric types.
- Split the binding. Treat the pre-widening and post-widening regions as separate slots when the assignments are unambiguous — cheap where it applies, silent where it does not.
- Accept and document. Weak:
5.0for5is a wrong value in output, not just a representation nicety, and it appears in any script that reuses a loop or accumulator name for a float.
Recommendation: 1, since the variant path already exists and already renders correctly for exactly this case (a value out of a heterogeneous container prints correctly per element). Measure the cost before defaulting it on.
Gate
make test-nilpy + self-host byte-identical. Note the expectation in the
Makefile for test_nilpy_widen_fix must be CORRECTED to CPython's
5 / 3.14 / 7 / 2.5 as part of the fix — leaving it as-is would keep the bug
green.
Resolved 2026-08-03 — option 1, but ONLY for the binding join
Widened to VARIANT as recommended, with the one distinction the options section
did not draw: the join for a REBOUND NAME is not the join for an EXPRESSION.
1 + 2.5 really is a float and must stay one; x = 5 then x = 3.14 is two
bindings of one name.
PyWiden serves both, so redirecting it wholesale would have boxed every mixed
arithmetic expression in every NilPy program. The new PyWidenBinding wraps it
and redirects exactly one pair — int meets float — at the two local-collection
join sites (module scope and def scope). Everything else keeps PyWiden's
answer: int-meets-int stays scalar so ordinary integer code boxes nothing, the
two string kinds keep their own rule (a variant scalar-loaded on return yields
its tag word — the reason that rule exists), and a promotable int keeps its own
join, whose float case is a deliberate honest error rather than a silent boxing.
The Makefile expectation for test_nilpy_widen_fix is corrected to CPython's
5 / 3.14 / 7 / 2.5, as the gate required — leaving it would have kept the bug
green, which is how it survived in the first place.
Blast radius, measured against pinned rather than assumed
The obvious hazard was a widened local reaching a context that scalar-loads it. Probed it directly. Two shapes still diverge from CPython, and both are IMPROVED by this change, neither is caused by it:
| program | pinned | HEAD | CPython |
|---|---|---|---|
def g() -> int: v=1; v=2.5; return v |
4612811918334230528 | 2 | 2.5 |
def k(): v=3; v=0.5; return v + 1 |
0 | 1 | 1.5 |
Pinned's 4612811918334230528 is the IEEE bit pattern of 2.5 read as an
integer. Those two are the def's RETURN coercing to its annotated or inferred
result type, which is a different defect — filed as
[[bug-nilpy-def-return-coerces-a-float-to-the-inferred-int-result]]. They are
deliberately NOT in this ticket's test: recording pxx's 2 as expected output
would be exactly the sin this ticket is about.
Returning the widened value ITSELF (return v) is correct, and arithmetic on
the widened binding at module level is correct — which is what makes the return
path the narrow suspect.
Verified
test/test_nilpy_widen_binding_variant.npy (+ .expected, wired into
make test-nilpy), 11 lines byte-identical to CPython: the two repros; an
integer accumulator loop as the gate on ordinary integer code not being boxed;
a float-only and an int-only rebinding as the gate on a same-kind rebinding
staying scalar; and arithmetic on each binding of a widened name.
gate.sh quick GREEN, self-host fixedpoint byte-identical, FPC seed clean.
Log
- 2026-08-03 — resolved.