← board

A field's type is fixed by its first assignment and never widened

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