x /= 2 on an annotated int parameter keeps the Int64 slot
- Type: bug (Track N — Nil Python frontend) — silent wrong value.
- Filed: 2026-08-26 by frank1-N-truediv, split out of [[bug-n-inferred-return-type-of-true-division-is-int]]. That ticket's fix made the def's registered RETURN type correct for this shape; the stored VALUE is still wrong, for a reason with nothing to do with return-type inference.
Repro
def a3(x: int):
x /= 2
return x
def a5(x: int):
x /= 2
print(x) # CPython 2.5 pxx 4612811918334230528
return 0
def a4(x: int): # the control: a LOCAL copy is correct
y = x
y /= 2
return y
print(a3(5)) # CPython 2.5 pxx 4.612811918334231e+18
a5(5)
print(a4(5)) # 2.5 both
a5 is the one that localises it: no return type in play, and the value printed
inside the function is already the double's raw bits.
Cause — measured, not reasoned
$ PXXDBG=n.locals ./compiler/pascal26 r7.npy r7
PXXDBG n.locals a3 x tk=19 rec=-1 | sym=437 symtk=13 symrec=0 kind=2
PXXDBG n.locals a4 y tk=22 rec=-1 | sym=<none>
PXXDBG n.locals a5 x tk=19 rec=-1 | sym=437 symtk=13 symrec=0 kind=2
The /= handler already does the right thing — PyNoteLocalType records the
float (tk=19) exactly as its comment says it must
(feature-nilpy-power-operator-and-divmod). But kind=2 is a parameter,
and the parameter's symbol keeps its declared symtk=13 (Int64). The note is
recorded and then ignored, so the double is written into the incoming ABI slot.
a4 works because y is a real local with no declared type to defend.
Why it is not a one-line fix
The parameter's type IS the calling convention: widening x to a Double would
change what the caller must push. Python has no typed slots — a parameter that
is REBOUND is simply a local from that point on — so the faithful lowering is to
introduce a shadow local when a parameter is rebound to an incompatible type,
and rewrite the body's references from the rebind onward.
Narrower alternatives, in increasing order of honesty:
- Reject it: error "cannot rebind an annotated int parameter to a float". Cheap and loud, but rejects code CPython runs, which is the one direction the N rule forbids.
- Coerce: store
Trunc(x / 2). Wrong value, silently — strictly worse than today, because today at least the bits survive. - Shadow local. The real fix.
The same question applies to any rebind that changes a parameter's type
(x = "s" on x: int), so solve it once for the general case rather than for
/=.
Gate
A .npy diffed against CPython: /= on an annotated int parameter, read back
by print AND by return; the same on an unannotated parameter and on a plain
local (both must stay correct); //= and %= on an annotated int parameter
(must stay integer); and a parameter rebound to a string.
Resolution (2026-08-27)
Fixed in compiler/pyparser.inc. Witness
test/test_nilpy_a_rebound_parameter_widens.npy, registered in test-core as
test_nilpy_parmwiden26, .expected generated by CPython. Red at pinned
v383 (18392d1d3181), green at HEAD. a3, a5 and every other shape in
this ticket's gate list now match.
Option 3 — and the mechanism was already installed
The ticket ranked three answers and called the shadow local "the real fix". It
is, and it did not need building: a variant parameter that is rebound has
had a private slot since
[[bug-nilpy-rebinding-a-list-parameter-aliases-the-callers-list]] — the
parameter symbol is renamed to $byref.<name>, an ordinary local takes the
name, and a seeding assignment local := param is emitted before the body so a
read before the rebind still sees the argument. That was built for a different
reason (a variant travels const-by-ref, so p = p + [9] reached the caller's
list), and its gate was PyHdrPTypes[i] = tyVariant.
So the fix is that gate's type test, generalised: a new
PyReboundParamSlotType answers "the type this rebound parameter's private slot
must have, or tyUnknown", using PyWidenBinding — the same join the locals
table itself uses, so a parameter and a local rebound the same way land on the
same type. Same rename, same seeding assignment, same "every read, every write,
the locals inference and the nested-def capture scan see one ordinary local"
property. Not a second mechanism beside the first.
Both parameter paths are wired — PyParseDef and PyParseMethod — because they
are separate code and the older ticket's own note says a fix in one leaves the
other wrong. The method rows in the witness exist to hold that.
The ordering, and why the pass runs twice
The question needs the constraint table, which is what the trial pass builds, so
the decision is made after the first PyCollectLocalsAST — and the pass is
then re-run, so the trial sees the ordinary local the name has become. The
re-run happens only for a def that actually creates a slot; everything else pays
nothing.
Three neighbouring shapes measured, NOT fixed, and filed
Each was found by varying the shape, each is recorded with its own measurement, and none is a regression:
x = x + 1thenx /= 2— refused astoo dynamic [a=28 b=19], a promotable int joined against a double. Identical at v383 and HEAD. [[bug-n-rebinding-an-int-parameter-twice-across-the-float-boundary-is-refused]]- Rebound on ONE path —
branch(5, 0)returns5.0where CPython returns5. Measured withPXXDBG=n.locals,n.ret: the private slot IS correctly a variant (symtk=22) holdingVT_INT, but the def's registered RETURN type is the widened double (n.ret tk=19), so it is coerced on the way out — the return-type inference is a token scan that never reads the constraint table. Improved by this fix (v383 gave4.612811918334231e+18for the taken branch; HEAD gives2.5), one row still wrong. [[bug-n-a-parameter-rebound-on-one-path-returns-the-widened-type-on-every-path]] - Captured by a nested def —
invalid IR node reference in store_sym. Identical at v383 and HEAD. The boundary is the useful part: capturing a plain local of any type works, and capturing a rebound variant parameter works, so the capture scan handles a private slot — it does not find this one. The likely difference is that the variant slot is allocated beforePyCollectLocalsASTand this one after; filed with that as the first thing to try, and explicitly as a thing to measure rather than assume. [[bug-n-a-nested-def-capturing-a-rebound-parameter-uses-the-parameters-type]]
Gate
make compiler/pascal26 + tools/gate.sh quick GREEN. Parser only; no pin
needed.
Log
- 2026-08-27 — resolved, commit 5d8784f1d.