A field's type is fixed by its first assignment and never widened
- Type: bug (Track N — Nil Python frontend) — silent wrong value.
- Filed: 2026-08-26 by frank1-N-truediv, found while varying shapes for [[bug-n-inferred-return-type-of-true-division-is-int]].
Repro
class E:
def __init__(self, n: int):
self.v = n
def scale(self):
self.v = 1.5 # CPython 1.5 pxx 4609434218613702656
return self.v
class E2:
def __init__(self, n: int):
self.v = n
def scale(self):
self.v = self.v / 2 # CPython 2.5 pxx 4612811918334230528
return self.v
print(E(5).scale()); print(E2(5).scale())
The 1.5 arm carries no division at all, which is what says this is about
FIELDS and not about /.
Cause
The self.NAME = ... pre-pass in compiler/pyparser.inc states its own rule in
a comment: "The scan only ADDS fields, and a name already registered by an
earlier method keeps its first type." That rule was written to let a field
declared in __init__ survive being re-assigned in _build_layout, and it is
right about IDENTITY. It is wrong about TYPE: the second assignment stores a
double into the Int64 slot the first one sized, with no coercion and no
diagnostic.
A local does not behave this way — PyCollectLocalsAST unions the types of
every Syms[] entry for a name, which is exactly why n = 5; n = 1.5 is fine
and self.v = 5; self.v = 1.5 is not. Two spellings of one concept answering
differently is the tell
(devdocs/dev/normalise-dont-special-case.md).
Fix shape
Give the field scan the same widening harvest the local scan has: when a name is
already registered and a later assignment types it differently, widen
(PyWiden) rather than keep the first answer. PyWidenBinding is probably the
right join — an int field later assigned a float becomes a float; two unrelated
CLASSES already have a decided answer elsewhere (widen to variant, see
bug-nilpy-local-reassigned-across-classes-keeps-one-static-class), so reuse
that rule rather than inventing a second one.
Note the ordering hazard: the scan walks methods in source order, so the harvest has to complete before any field OFFSET is assigned.
Gate
A .npy diffed against CPython: int→float, int→str, and class→other-class
rebinds of one field across two methods; plus the controls that must NOT widen —
a field assigned the same type twice, and a subclass refinement.
Resolution (2026-08-27)
Fixed in compiler/pyparser.inc, PyRegisterClassMembers. Witness
test/test_nilpy_a_field_widens_across_methods.npy, registered in test-core
as test_nilpy_fldwiden26, .expected generated by CPython. Red at pinned
v383 (18392d1d3181), green at HEAD. Both repro rows now print 1.5 and
2.5.
The join is the LOCAL scan's, not a second rule
The field scan's skip-if-present arm now widens instead of skipping, using
PyWidenBinding — the same function PyCollectLocalsAST already uses to
union the types of every Syms[] entry for a local name. That was the whole
diagnosis: two spellings of one concept answering differently. So int-then-float
becomes a variant, not a double, because that is PyWidenBinding's existing
decided answer for a numeric rebind across the float boundary — the same answer
the older accumulator rule (PyFieldIsIntAccumulator) already gives, and the
same one two unrelated classes get.
The ordering hazard the ticket flagged, handled by re-layout
A widened field changes SIZE — tyInt64 is 8 bytes, tyVariant is 16 — and the
offsets were handed out against the narrow type as the walk went. Rather than
restructure the walk into two passes (it is nested inside a method loop inside a
mixin loop), the layout is simply re-run: baseOff records where this
class's own fields start, and if anything widened, the window
UClsFBase[ci] .. +UClsFCount[ci] gets its offsets reassigned from the same
AlignTo/TypeSize formula that assigned them originally.
That is exact rather than approximate, and the reason is worth stating: every
AddUField call in this procedure passes the plain six arguments, so no field
in a NilPy class's own window carries an array, bitfield or string-capacity
marker whose size TypeSize would not describe. Re-running the identical
formula over the identical window reproduces the identical offsets when nothing
widened. The N and Many test rows exist to hold that: they read a widened
field's NEIGHBOURS, which is precisely what a widen-in-place without a re-layout
would have corrupted.
Scoped to the class's OWN window — and the residual is filed
FindUField in a descendant also answers for an ANCESTOR's field, and rewriting
it there changes a layout that is already final: curOff for every subclass
starts at UClsSize_[parent], so a subclass registered earlier has already
baked in the narrow size. The arm is therefore restricted to
UClsFBase[ci] .. +UClsFCount[ci], and a class-attribute override slot is
skipped for the same reason the add arm skips it — it has no instance field at
all.
So this remains wrong, measured identically at v383 and at HEAD:
class P:
def __init__(self): self.v = 1
class Q(P):
def widen(self):
self.v = 2.5
return self.v # CPython 2.5, pxx 4612811918334230528
It is a different mechanism — the join has to be computed over every class body
in the hierarchy before any of them is laid out, i.e. a whole-program pre-pass
— so it is filed on its own:
[[bug-n-a-field-declared-in-an-ancestor-is-not-widened-by-a-descendants-rebind]].
class C(M) where M only supplies setup() is the same case, not a
mixin-specific one: the FIRST base stays a real parent and is never flattened.
Measured green beyond the ticket's gate
Dataclass field rebound in a method, @property whose backing field is rebound
(the property gains no field either way), a subclass adding its own field
alongside an inherited one, and a fresh instance reading its constructor values
after another instance widened — all match CPython.
Gate
make compiler/pascal26 + tools/gate.sh quick GREEN. Parser only; no pin
needed, though the fix rides the next one.
Log
- 2026-08-27 — resolved, commit 65335c061.