← board

self.n += v adds 1 instead of v when v is a variant parameter

Repro

class C:
    def __init__(self, n: int):     # ANNOTATED — the field is a real int
        self.n = n
    def add(self, v):               # UNANNOTATED — v is a variant
        self.n += v
        return self.n

print(C(10).add(5))                 # CPython 15    pxx 11

10 + 1 = 11. The variant v contributes 1 regardless of its value, which is what a truthiness test yields for any non-zero — so the variant argument is almost certainly being read as a Boolean rather than unboxed to its number.

Note the typing, because it is the mirror image of the sibling ticket

field operand result
variant (unannotated ctor param) literal 3 fixed by the sibling ticket
int (annotated ctor param) variant (unannotated method param) this ticket: adds 1

So the two tickets are the two sides of the same missing conversion, and fixing one does not fix the other — measured, not assumed. The sibling's fix routes the statement through the variant-aware binop when the LVALUE is variant; here the lvalue is a plain int and it is the RHS that needs unboxing.

Where to look

The int-typed augmented path with a variant right-hand side. self.n = self.n + v (the explicit spelling) is worth measuring first — if that is correct, the difference is again the augmented lowering and not the binop, which would make this a close cousin of the sibling fix rather than a new area.

Gate

A .npy diffed against CPython: +=, -=, *= on an annotated int field with an unannotated parameter operand; the same with an annotated parameter (control); the explicit self.n = self.n + v spelling; the same shapes on a plain local with a variant operand; and a str field with a variant operand.

Log

Resolved 2026-08-02

The ticket's own "where to look" was right on both counts: the explicit self.n = self.n + v spelling IS correct, so the difference is the augmented lowering rather than the binop — which made this a close cousin of the sibling fix, exactly as predicted.

Same compound-assignment tail. The sibling routed a NilPy VARIANT LVALUE to the explicit form; this widens that condition to either side being a variant, and the same rewrite carries it.

The typing was the trap, and it cost a wrong first attempt

Taking the rewritten binop's type from the LVALUE (an int field stays an int, which sounds right) makes an int-typed binop over a 16-byte variant slot: every result came back a pointer — 140733931812968 for each of the five values on the first line. Widening the two operand types is what the written-out spelling gets, and that spelling is the measured-correct reference. Recorded because the wrong version is the intuitive one.

test/test_nilpy_augmented_assign_variant_operand.npy (+ .expected, wired into make test-nilpy) is byte-identical to CPython across +=, -=, *= with a variant operand, the annotated control, a str field, a both-variant case, plain locals, the explicit spelling, and accumulation across calls.

Also re-verified after the change: the realistic bank-account program this whole thread was reduced from, the dict-subscript method-call probe, the variant-field augmented tests and the floor-division tests — all agree with CPython.

gate.sh quick GREEN, self-host fixedpoint byte-identical.