← board

An arithmetic dunder on self is pointer arithmetic

Filed 2026-08-26 while fixing [[bug-n-a-subscript-inside-a-base-class-skips-the-subclass-override]], from varying the shapes that ticket did not vary. Not the same defect — that one was static-vs-virtual dispatch and is fixed; this one never reaches the dunder at all.

Measured on pinned v376 and on HEAD after that fix (identical on both):

class A:
    def __add__(self, o):
        return 'A-add'
    def probe(self):
        return self + 1     # <-- never reaches __add__
a = A()
print(a + 1)        # CPython: A-add   pxx: A-add     <- the arm that works
print(a.probe())    # CPython: A-add   pxx: 127164370387056
CPython A-add / A-add
pxx (pinned v376 and HEAD) A-add / 127164370387056

No inheritance is involved. One class, one method, and the same expression answers correctly through a named receiver and wrongly through self. That rules out the dispatch mechanism and points at operand typing: the additive arm in pasparser_expr.inc (~8854) reaches the dunder via PyCallMeth1(ResolveNodeRec(left) - REC_UCLASS_BASE, '__add__', left, right), so if ResolveNodeRec does not give self a user-class rec id at that site, the expression falls past every dunder arm to the raw integer/pointer add. Verify that before believing itPXXDBG=n.locals / a.ast:probe — the last two N tickets both named the wrong mechanism.

The boundary, as far as it was measured

shape answer
a + 1 (named receiver) correct
self + 1 (inside the class) address printed
self == 1, self < 1 wrong, but for a different reason — see the sibling below
len(self), str(self), self[k], k in self, for x in self correct as of 2026-08-26

Only + was reduced to a minimal case. -, *, /, %, @, the bitwise family and the augmented forms all go through the same arms and are not measured — check them together, and note that a wrong answer here is an ADDRESS, not an exception, so a passing-looking program proves nothing.

Why this is worth more than its shape suggests

The failure is silent and the value is plausible-looking only until you read it. It is also the natural way to write a mixin that composes with itself (def __iadd__(self, o): return self + o), which is the pattern the collections-ABC work keeps landing on.

Sibling: [[bug-n-a-comparison-dunder-against-a-non-class-operand-answers-wrongly]].

Gate

make compiler/pascal26 + the repro above answering A-add twice, plus a witness test in test-core pairing the named-receiver arm with the self arm (the pattern test_nilpy_dunder_on_self_reaches_the_override.npy uses), with the .expected generated by CPython.

Root cause (2026-08-27) — the ticket's own hypothesis was wrong, as it warned

The dunder IS dispatched. PXXDBG=a.ast:* on def probe(self): return self + 1 shows an AN_CALL (kind 8) to __add__ with tk=23 (tyAnsiString) and the two arguments in place. ResolveNodeRec gives self its user-class rec id at that site and the additive arm fires exactly as it does for a named receiver. Nothing about dispatch is broken.

What is broken is the def's registered return type. PXXDBG=n.ret:

PXXDBG n.ret def@5  __add__ tk=23 rec=0   <- tyAnsiString, correct
PXXDBG n.ret def@19 probe   tk=13 rec=0   <- tyInt64, wrong

probe returns Int64, so the correct call's AnsiString handle is handed back through an integer slot and printed as an address. The two facts the ticket found most damning — that a named receiver works and that no inheritance is involved — are both explained by this and by nothing about dispatch: the scanner's answer does not depend on the receiver spelling being wrong, only on self being untypeable.

Why untypeable: PyInferDefRetTypeScan types the return expression with PyInferExprType, a token scanner that has no way to know which class self belongs to — the same limitation PyMethodReturnsSelf was written to work around, in as many words: "self is a parameter whose class only this pre-pass knows". So every arm in that scanner that types a CLASS operand was unreachable through self, and the expression fell to the arithmetic walk.

Scope: all eleven, not just +

The ticket left -, *, /, %, the bitwise family unmeasured. Measured: through a named receiver all eleven dispatch correctly and always did (__add__ __sub__ __mul__ __truediv__ __floordiv__ __mod__ __and__ __or__ __xor__ __lshift__ __rshift__ — every one matches CPython). Through self all eleven were wrong, uniformly, and all eleven are fixed by the one change.

Fix

  1. PyInferSelfCi — the enclosing class, lent to the scanner by the one pass that has it (PyRegisterClassMembers, around its PyMethodRetType call) and cleared straight after. PyInferExprType types a bare self from it; self.x / self.m() keep their own arms.
  2. PyBinOpDunderName — raw-token operator → dunder name, beside PyAugDunderName. Not a delegation to that one: it is keyed on the token PyAugBinTok normalises to, where &= becomes tkAnd, while a bare & is tkAmp and tkAnd is the and KEYWORD. Delegating would have given self and x the type of __and__.
  3. An operator-dunder arm in PyInferExprType: first top-level operator, left operand only (matching Python's lookup and the parser arms it mirrors), answering the dunder's registered return type — including its class, so the result chains.

Placed above the true-division arm. Below it, self / 1 was still typed tyDouble by that arm and was the one of the eleven that stayed wrong — caught by running all eleven rather than the ticket's single +.

Verified against the CPython oracle

Filed, not fixed here

[[bug-n-a-short-circuit-or-returning-self-is-typed-as-a-number]] — return self or 1 dies with TypeError: expected a number, got object, pre-existing at pinned. A different mechanism (a short-circuit operator hands back an OPERAND; there is no dunder), so it is a sibling rather than part of this. The witness test names the two rows it deliberately does not assert.

Log