← board

Arithmetic dunders (__add__, __sub__, …) — full protocol

Phase 2 of [[feature-nilpy-arithmetic-ordering-dunders]] (umbrella), decided in [[decide-nilpy-arithmetic-dunder-scope]]. Do after [[bug-nilpy-comparison-dunders-not-dispatched]] lands.

class C:
    def __init__(self, v):
        self.v = v
    def __add__(self, o):
        return self.v + o.v
print(C(1) + C(2))     # CPython: 3     pxx: 268329319137376 (adds the HANDLES)

Two tyClass operands on +/-/*//////%/** take the raw IR_BINOP path and operate on the instance pointers.

Why this is bigger than phase 1

Unlike comparisons, arithmetic operators already have legitimate special-cased class-operand routes (Path("a") / "b" is pathlib's join). A blanket "any class operand checks the dunder first" rule has to be threaded through every operator and compose with whatever else already special-cases a class operand on that operator, per-operator, without breaking the existing cases. IRPyNumStrClash's str-vs-number clash check also only fires for that one pair today — extending it to any class operand isn't safe without the same per-operator care.

Land one operator at a time, +/- first (pathlib's actual collision) so the composition question is answered where it's real, not guessed.

Scope

__add__/__radd__, __sub__/__rsub__, __mul__, __truediv__, __floordiv__, __mod__, __pow__, and their reflected forms as CPython actually falls back to them. No matching dunder → genuine runtime TypeError, not silent pointer arithmetic.

Gate

make test-nilpy + self-host byte-identical, .npy per operator vs CPython's own output, plus a regression confirming pathlib's / (and any other existing special-cased class-operand route) is unaffected.

2026-08-01 — measured against CPython, whole scope quantified

Differential sweep (1094 cases, self-hosted binary at 3f2c5b915). Every row below is measured, not inferred. C is a plain user class; v is 7/3.

Dunder declared but NOT dispatched

expression CPython pxx
C(7) // C(3) (__floordiv__) 2 0
C(7) % C(3) (__mod__) 1 126959237464088
C(7) ** C(3) (__pow__) 343 TypeError

__floordiv__, __mod__, __pow__ appear nowhere in compiler/**.

Dunder MISSING — must raise, instead computes on the handle

expression CPython pxx
C(7) // C(3) TypeError 0
C(7) % C(3) TypeError 138903256301592
C(7) + C(3) TypeError compile error — see below

Reflected forms — none work

3 <op> C(4) where C declares only __radd__/__rsub__/__rmul__/ __rtruediv__/__rfloordiv__/__rmod__/__rpow__: all seven diverge. Measured: 3 + C(4)1744830491 (CPython radd), 3 * C(4)-1906311096 (CPython rmul). Note __radd__/__rmul__/__rsub__/ __rtruediv__ do appear as strings in compiler/**, so the names are known but the dispatch does not fire — worth finding out why before writing new code, since a partially-wired path is a different fix from an absent one.

Already filed separately, do not re-file

Sequencing note

The landed ordering-dunder fix ([[bug-nilpy-comparison-dunders-not-dispatched]]) is the working template, including the trap it hit: guarding on "operand is tyClass" also captures pylib's own TPyList/TPyDict and broke [1,2] < [1,3]. Use PyRecIsPylibOwnClass (compiler/symtab.inc), not a class-name list. That trap is more dangerous here than for ordering, because pylib's containers have real + (concat) and * (repeat) semantics that a naive guard would capture.

2026-08-01 — floordiv / mod / pow LANDED

The three measured-broken direct forms are done. All verified against CPython:

expression was now
C(7) // C(3) 0 2
C(7) % C(3) 126959237464088 1
C(7) ** C(3) TypeError 343

test/test_nilpy_dunder_arith2.npy is byte-identical to CPython: numeric results, a class returning NON-numeric tags (which proves the method ran rather than a numeric path coincidentally agreeing), plain // % ** including the negative-operand rules, and "%d apples" % 5 staying string FORMATTING rather than being captured as __mod__.

Native confirm: self-host fixedpoint A==B==C from the pinned seed, testmgr --tier quick GREEN; matrix offloaded to Track T.

Deliberately not asserted in that test

[1, 2] // [3] should raise TypeError and still returns 0. That is [[bug-nilpy-static-typed-operands-skip-mixed-type-guard]], a different bug with a different fix; encoding today's answer either way would freeze it, so the test says so in a comment instead.

What remains on this ticket

The reflected forms — all seven still broken (__radd__, __rsub__, __rmul__, __rtruediv__, __rfloordiv__, __rmod__, __rpow__). Measured: 3 + C(4)1744830491, 3 * C(4)-1906311096. Note four of those names already appear in compiler/**, so check what the existing references do before writing new code — a partially-wired path is a different fix from an absent one.

2026-08-01 — REFLECTED forms LANDED; this ticket's scope is now complete

All seven, verified against CPython:

expression was now
3 + C(4) 1744830491 radd:3
3 - C(4) (handle math) rsub:3
3 * C(4) -1906311096 rmul:3
3 / C(4) 0.000000000022 rtruediv:3
3 // C(4) 0 rfloordiv:3
3 % C(4) 3 rmod:3
3 ** C(4) TypeError rpow:3

The "partially wired" worry is resolved: they were simply absent. Four reflected names did appear in compiler/**, which earlier notes flagged as a reason for caution — grepping showed all of them are in COMMENTS saying the fallback "is not implemented". No half-built path existed.

PyReflName (operator → reflected method name) plus PyReflectedDunder (compiler/parser.inc) — one table and one dispatcher, so the three call sites (ParseSimpleExpr for +/-, ParseTerm for * / // %, the power arm of ParseFactor for **) cannot drift apart. Each reflected check is gated on the LEFT operand NOT being a user class, so left-operand priority is preserved by construction rather than by ordering luck.

test/test_nilpy_dunder_reflected.npy is byte-identical to CPython and covers the precedence rule explicitly: a class declaring BOTH __add__ and __radd__ must answer direct when it is on the left and reflected when on the right. That case exists because getting the receiver/argument swap backwards produces a plausible wrong answer rather than an error. Also asserts that plain arithmetic, string concat/repeat and list concat are untouched.

Native confirm: self-host fixedpoint A==B==C from the pinned seed, testmgr --tier quick GREEN; matrix offloaded to Track T.

Ticket status

Direct and reflected forms for + - * / // % ** are all done. What is NOT here, and is tracked elsewhere:

2026-08-03 — re-measured; the subject of this ticket ALREADY WORKS

Run against HEAD rather than re-read. The ticket's own reproducer (print(C(1) + C(2)) with __add__ declared) now prints 3, not a sum of handles. Full operator matrix, all diffed against CPython and all correct:

form result
a + b, a - b, a * b, a / b, a // b, a % b, a ** b on two instances all correct
mixed type — c + 3 via __add__ correct
reflected — 3 + c via __radd__ correct
augmented — d += 5 0, silently

So this feature landed at some point without the ticket being updated — the "adds the HANDLES" symptom in the header is stale, and the phase-2 framing (threading a blanket rule through every operator, composing with Path("a") / "b") describes work that has evidently been done.

The one remaining hole is split out as [[bug-nilpy-augmented-assign-on-a-class-instance-silently-yields-zero]] — a silent wrong answer (0, no diagnostic), which also reproduces with only __add__ declared, i.e. it is the augmented-assignment path rather than __iadd__ specifically. Filed separately because a silent wrong answer buried as one line in a 195-line feature ticket stays invisible.

Do not close this ticket on the strength of the table above — what was measured is the operator dispatch, not this ticket's full phase-2 scope (in-place operators beyond +=, __divmod__/__matmul__-shaped members, and the composition-with-existing-special-cases concerns below were not swept). Re-scope it against what remains rather than re-deriving the parts that work.

2026-08-03 (later) — in-place operators re-scoped by measurement

The 2026-08-03 note above left "in-place operators beyond +=" as unswept remaining scope. Swept now, against CPython, on three target shapes — a bare name, a class-typed field, and an in-method self. target:

-= *= /= //= %= &= |= ^= <<= >>= all dispatch the in-place dunder, fall back to the binary one with a rebind, and raise a catchable TypeError with neither. So this line of the remaining scope is closed, pinned by test/test_nilpy_augmented_assign_class_dunder.npy (name target) and test/test_nilpy_augmented_assign_class_field.npy (field target, added with [[bug-nilpy-augmented-assign-to-a-class-typed-FIELD-silently-yields-zero]]).

One exception, split out: **= is a hard parse error — [[bug-nilpy-power-augmented-assign-does-not-parse]]. It is not a gap in this protocol so much as a consequence of power being the one operator with no token: ** is two tkStar plus an ad-hoc lookahead, so there is no binary token for PyAugDunderName to key __ipow__ off. Filed separately because the fix is a new TTokenKind in defs.inc — Track A ground — rather than more dunder plumbing.

Still genuinely open here, and still not swept: __divmod__ / __matmul__- shaped members, and the composition-with-existing-special-cases concerns above.