← board

x /= 2 on an annotated int parameter keeps the Int64 slot

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:

  1. 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.
  2. Coerce: store Trunc(x / 2). Wrong value, silently — strictly worse than today, because today at least the bits survive.
  3. 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:

  1. x = x + 1 then x /= 2 — refused as too 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]]
  2. Rebound on ONE pathbranch(5, 0) returns 5.0 where CPython returns 5. Measured with PXXDBG=n.locals,n.ret: the private slot IS correctly a variant (symtk=22) holding VT_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 gave 4.612811918334231e+18 for the taken branch; HEAD gives 2.5), one row still wrong. [[bug-n-a-parameter-rebound-on-one-path-returns-the-widened-type-on-every-path]]
  3. Captured by a nested definvalid 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 before PyCollectLocalsAST and 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