Annotating a dunder operand breaks the operator on a variant receiver
Two programs, byte-identical except for one annotation:
class V:
def __init__(self, x):
self.x = float(x)
def __add__(self, o): # vs. def __add__(self, o: 'V'):
return V(self.x + o.x)
class H:
def __init__(self):
self.a = V(1.0)
self.b = V(2.0)
def known_receiver():
a = V(1.0); b = V(2.0)
c = a + b
return c.x
def variant_receiver(h): # h is bare, so h.a is a VARIANT
c = h.a + h.b
return c.x
bare known_receiver 3.0 variant_receiver 3.0 rc=0
annotated known_receiver 3.0 TypeError: expected a number, got object rc=217
CPython 3.0 3.0
Known receiver: correct both ways. Variant receiver: correct bare, raises
annotated. The temporary is bound to a local on purpose — (a + b).x
segfaults on its own account
(bug-n-attribute-access-directly-on-a-dunder-result-segfaults) and would mask
this.
The raise
PyTypeError(p^.VType, 'a number') in pyvar_to_float, pylib.pas:9433 — the
generic variant-add arm taking the NUMERIC path with an object operand. pylib
carries roughly twenty comment blocks describing this same message from this
same cause, all of the form "numeric path and raised 'expected a number, got
object' for a dunder the ...", so the arm is known to be the fragile one.
The mechanism, stated as a guess and marked as one: the annotated dunder no longer matches whatever the variant dispatch matches on, so dispatch falls through to the numeric arm. Nobody has read that matching code yet.
Why it matters more than an ordinary dispatch bug
The operand annotation is the largest single code-generation improvement
measured on this compiler. On real code (feature-n-specialise-a-dunder-body-...):
Vec3.__add__ 6260 B, 210 calls, 0 SSE -> 873 B, 18 calls, 3 SSE
And in real programs a receiver is a variant most of the time — off an unannotated parameter, a container element, or a field of an untyped object. A demo-wide census found 95 classes, 82 constructors, 334 constructor parameters, ZERO annotated, which is simply how Python is written.
So the annotation that produces that code generation is the same annotation that breaks the operator at most of the call sites that would benefit — and the runtime message points nowhere near the edit the user made. A user following the advice gets a program that is beautiful in the disassembly and dies at run time.
It is NOT a blocker, because the safe cut is measured
Five arms, one compiler sha, guard satisfied:
arm Vec3.__add__ Quat.rotate
b_no 6260 24674
b_op 873 2175 <- CRASHES
b_op2 6260 2175 <- runs
b_op2 annotates nine NON-dunder method parameters. It gets the entire
Quat.rotate win — 24.7 kB to 2.2 kB — and the program runs. The dangerous
annotation bought only Vec3.__add__, which that program barely executes.
So the shippable guidance is: annotate method parameters and constructor parameters; leave operator dunders bare until this is fixed. No caveat a user has to interpret. That is what should go in the NilPy docs, and it is why this sits at 75 rather than blocking the guidance.
Likely one fix, not two
If the variant dispatch consulted the annotated signature the way the static path does, both the crash and the code-generation win would come from the same place, and the operand annotation would become a real recommendation instead of a trap.
Found 2026-09-15 by the lekkerzeilen seat, by building the matched pair rather
than accepting the advice. Its own demo arm died after exactly one frame on this
bug, in Camera._chase:
state.position + behind * self.distance with state a bare parameter.
The mechanism, read rather than guessed — and it is one line
PyUserArithCall1 (compiler/builtin/pylib.pas), the single runtime call every
arithmetic, in-place and __getitem__ dunder goes through when an operand's
static type is a variant:
pk := PInt64(mi^.ParamKinds);
if pk[1] <> 22 then Exit; { `other` must be a Variant }
An annotated other is not kind 22 — o: 'V' is a class pointer (6), k: float a Double (19), i: int an Int64 (13) — so the dispatch DECLINES the
dunder, PyVarUserArith answers False, and the caller's numeric arm raises
expected a number, got object about an operand it was never meant to see.
o: float fails identically (measured: def __add__(self, o: float) with
h.a + 2.0 on a bare h, rc=217), so the class shape was never special; the
guard is the whole bug. The type block above it says why it was written that
way: "unlike eq there is no dataclass-GENERATED arithmetic dunder to
produce the class-pointer shape" — true, and hand-written annotations produce
it every day.
__eq__ already had the class-pointer arm (TPyEqObjFn), with an exact-class
check that CPython's own dataclass __eq__ performs. Arithmetic has no such
rule in CPython, and the RTTI records a parameter's KIND but not its class, so a
cross-class check would refuse Quat.__mul__(self, v: 'Vec3'); the compiled
method-call path on a variant receiver already hands an instance to a
class-typed parameter by TAG alone, and this follows it.
FIXED — in the runtime dispatch, not the parser
PyUserArithCall1 now delivers other in the shape the dunder DECLARES: a
Variant (unchanged), a class pointer (kind 6, the object behind a VT_OBJECT
variant), a Double (kind 19, any numeric variant through pyvar_to_float) or
an Int64 (kinds 13/1/11/15, an int-family variant's payload). Eighteen new
thunk types beside the six that existed, mirroring the TPyEqObjFn precedent.
An operand the declared shape cannot take DECLINES (so the reflected dunder on
the other operand still gets its turn, then the caller's numeric arm raises as
before) rather than raising from inside the dispatch. Cross-class instances are
handed to a class-typed other by tag alone, the way the compiled method-call
path on a variant receiver already does — the RTTI records a parameter's kind
and not its class, and refusing every cross-class pair would refuse
Quat.__mul__(self, v: 'Vec3').
Covers every call site of that helper at once: the eight arithmetic entry
points, the seven in-place ones (PyVarUserAug), and __getitem__ — an
annotated def __getitem__(self, i: int) on a variant receiver was declined
by the same line and is green in the fixture.
Verified: the ticket's own matched pair (dv_bare/dv_ann) both print 3.0
on the variant receiver; the float-annotated twin too. Fixture
test/test_nilpy_an_annotated_dunder_operand_dispatches_on_a_variant_receiver.npy,
nine (operand shape × return kind) cells plus the bare control, an
int-for-float coercion row and a wrong-kind row that must raise, all matching
CPython; pinned v410 raises on it. Ownership unchanged: the class-result arm
takes over the dunder's owned reference exactly as before, and a
3000-iteration loop of h.a + h.b / h.a * 2.0 / h.a - h.b on a variant
receiver ends with allocs=6088 frees=6080 live=8 under -dPXX_ALLOC_CENSUS.
Not in this fix, noted on the bitwise ticket: @ on a variant receiver never
reaches __matmul__ at all, bare or annotated — the runtime has no arm for it.
So the guidance changes: annotate operator dunder operands too; the safe cut in this ticket is no longer needed. The peer seat's annotation arms can be re-run against a tree carrying this.
Log
- 2026-09-15 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 0badcd665.