← board

*= on a variant-typed target still rebinds

def double(xs):
    xs *= 2

g = [4, 5]
double(g)
print(g)                 # CPython [4, 5, 4, 5]     pxx [4, 5]

dd = {"k": [3]}
dz = dd["k"]
dd["k"] *= 3
print(dd["k"], dz)       # CPython [3,3,3] [3,3,3]  pxx [3,3,3] [3]

Split out of [[bug-nilpy-augmented-sequence-repeat-rebinds-instead-of-mutating]] when the statically-typed half landed (2026-08-15). A bare name and a FIELD whose type is known both mutate correctly now; what is left is every target that reads as a VARIANT.

PyAugMulNode's in-place arm is gated on PyNodeIsPyList(left), which needs a tyClass/TPyList node. A parameter, a dict value and a list element are all variants, so they fall to pylist_repeat — the fresh-list path — and the store puts a NEW object where the alias expects the old one mutated.

+= has exactly the same split and the same known gap: parser.inc's comment at the extend arm says so ("PyNodeListCi above only recognises a STATICALLY tyClass/TPyList target, so this is the variant case the in-place extend cannot reach"). One rule, two operators, one missing half each — fix them together.

Shape of a fix

A run-time twin: pyvar_repeat_inplace(const v: Variant; n: Int64): Variant that mutates when v holds a mutable TPyList and otherwise computes the ordinary product, plus the += equivalent. The risk to weigh first is that routing EVERY variant *= through it must reproduce the arithmetic path exactly — a variant holding an int must still multiply — so the fallback has to be the same routine the binop lowering uses, not a re-implementation.

Worth checking while there: whether the in-place form should skip the store on the variant path too, the way the typed one now does (PyAugMulInPlace) — storing the same handle back through a field target released the list before the append ran, which is the trap that half of the fix exists to avoid.

Gate

.npy diffed against CPython: xs *= 2 on an unannotated parameter with the caller observing; a dict value with an alias; a list element; a variant holding an int/float/str (arithmetic and str-repeat unchanged); and every row of test_nilpy_augmented_repeat_mutates_in_place.npy still green.

Resolution (2026-08-15) — the PARAMETER half

pymul_v_inplace(a, b): a mutable list is repeated in place and the same handle answered; everything else is pymul_v itself, called rather than re-implemented, which is what makes the arithmetic, str-repeat and tuple rows provably unchanged. The tuple/frozenset question is not asked here at all — pylist_repeat_inplace owns it, and that is the one place that knows the run-time kind.

The store around it STAYS, unlike the statically-typed arm: when the fallback runs, the product is a new value the target must receive. Only the typed arm can skip the store, and only because it can prove nothing new was built.

One measurement worth keeping: a boxed integer wears VT_INT (1) or VT_INT64 (2) depending on where it came from, so the first version — which tested tag 2 alone — compiled, ran, changed nothing, and looked exactly like the arm not existing. Bool (4) is accepted too, since xs *= True is a legal repeat count.

What is left

A dict VALUE as the target (dd["k"] *= 3) still rebinds: the value is right, an alias of it keeps the old contents. Container reads ARE aliases here — measured, dz = dd["k"]; dz.append(9) is visible through dd — so the residue is in how the subscript target's augmented store is built, not in the container model. Left on this ticket, which returns to the backlog at prio 20.

Gate

make compiler/pascal26 + tools/gate.sh quick GREEN; pinned v338. test/test_nilpy_augmented_repeat_mutates_in_place.npy extended and still byte-identical to CPython: the parameter case with the caller observing, and the same parameter over int, str, float and tuple (which must not mutate).

2026-09-15 — the USER-CLASS half of *= landed; the dict-value residue did NOT

pymul_v_inplace now tries __imul__ before its list arm, so c *= 3 on a variant holding a user object mutates and the caller sees it (was: __mul__, new object, caller untouched). That came in with [[bug-n-augmented-assignment-to-an-unannotated-parameter-silently-loses-the-mutation]], which generalised the augmented marker from + to the whole operator family.

This ticket's own residue is UNCHANGED and was measured, not assumed.

d = {"k": [3]}
z = d["k"]
d["k"] *= 3
print(d["k"], z)      # CPython [3,3,3] [3,3,3]   pxx [3,3,3] [3]

Identical on the pre-change compiler (79551a1b) and the post-change one (861b3ad3) — it was a row in the regression probe precisely so the two could not be confused. The residue is in how the SUBSCRIPT target's augmented store is built, which the __imul__ try does not touch: the value is right and an alias of it keeps the old contents.

The += / *= split this ticket opens with is now closed in the direction it named — both operators reach their in-place dunder on a variant target — so what remains here is the subscript-target store alone.