← board

NilPy: dunders don't dispatch when the instance is reached through a container

Measured boundary (2026-08-01, self-hosted binary at da085e9de + the

ordering-dunder fix)

class C:
    def __init__(self, v): self.v = v
    def __repr__(self): return "C" + str(self.v)
    def __str__(self):  return "S" + str(self.v)
    def __lt__(self, o): return self.v < o.v
a, b = C(1), C(2)
print("direct str:", str(a))          # CPython S1     pxx S1     OK
print("direct lt:",  a < b)           # CPython True   pxx True   OK (just fixed)
print("in list:",    [a, b])          # CPython [C1, C2]  pxx "[, ]"   WRONG, SILENT
print("str of elem:", str([a][0]))    # CPython S1     pxx ""     WRONG, SILENT
print(sorted([b, a]))                 # CPython [C1, C2]  pxx TypeError

The split is exact and mechanical:

Why this is the dangerous half

sorted()/min()/max() at least RAISE. print([obj]) does not — it prints [, ], an empty string per element, which is a plausible-looking wrong value rather than a failure. That is exactly the repo's expensive-bug shape: no crash, no location, wrong output far from the cause.

Cause

There is no runtime dunder dispatch in pylib at all — verified by reading pyvar_gt (compiler/builtin/pylib.pas, the sort/compare path) and by the __repr__ measurement above. Both would need to look up a method BY NAME on an arbitrary TObject at run time. The machinery to do that plausibly exists (RTTI method reflection, VMT-8 — project_rtti_reflection_and_overload_landmines), but nothing wires it to the dunder names.

Blocked on a design call

How container-element dispatch should work is a real fork (runtime RTTI lookup in pylib vs. compile-time monomorphisation vs. a per-class dunder vtable stamped into the handle), with different cost/generality trade-offs and a self-host blast radius that differs a lot between them. Filed as [[decide-nilpy-runtime-dunder-dispatch-mechanism]] — do NOT guess a direction here; pick it there first.

blocked-by: decide-nilpy-runtime-dunder-dispatch-mechanism

Gate (when it lands)

make test-nilpy + self-host byte-identical, and the boundary script above diffed against CPython (tools/pydiff.py) — all five lines, not just the sorted one.

2026-08-01 — consolidated: same root as two sibling tickets

Reproduced exactly as filed ([a, b] -> [, ], str([a][0]) -> empty, sorted() -> TypeError, while direct str(a) and a < b are correct).

This is NOT a separate mechanism from the arithmetic case: dunder dispatch is compile-time only, so ANY route that puts the instance in a Variant loses it — containers here, a widened global elsewhere, a Variant parameter next. Folded into [[feature-nilpy-runtime-dunder-dispatch-on-variants]] so the three do not each grow a private runtime path, which is precisely how the not <x> family came to need three separate fixes.

blocked-by: [[decide-nilpy-runtime-dunder-dispatch-strategy]]

2026-08-03 — dependency recorded in frontmatter

The body named the blocker (twice, under two different slugs) but no blocked-by: edge existed. The edge is the broader of the two decide tickets, [[decide-nilpy-runtime-dunder-dispatch-strategy]], per the supersession note on [[decide-nilpy-runtime-dunder-dispatch-mechanism]]: one answer settles both, and deciding them separately is the outcome that note warns against.