What is there now
compiler/pasparser_expr.inc, two else if chains:
ParseTerm(* / // %): the variant arm, then the user-class__mul__/__truediv__/__floordiv__/__mod__arm ~24 lines below it.ParseSimpleExpr(+ -): the variant arm at ~11163, the user-class__add__/__sub__arm ~91 lines below it.
else if (ASTTk[left] = Ord(tyVariant)) or (ASTTk[right] = Ord(tyVariant)) then
ASTTk[node] := Ord(tyVariant)
Either operand being a variant is enough, so a pair whose LEFT is a genuine user class declaring the dunder never reaches the arm that would bind it.
The precedent for giving a statically-decidable arm precedence is already in the
same chain, forty lines up: the list-repeat and bytes-repeat arms were moved
ABOVE the variant arm for
bug-nilpy-sequence-repeat-with-a-variant-count-falls-through-to-arithmetic,
with the argument stated in its comment -- the repeat is decided by the SEQUENCE
operand, which is statically known, while the COUNT may be anything. A dunder
call is decided by the LEFT operand, which is equally statically known.
Why it is a refactor and not a bug
Every observable is correct. bug-n-arithmetic-on-a-user-class-fails-when-the- other-operand-is-object-typed was fixed in compiler/builtin/pylib.pas by
making the eight variant arithmetic entry points ask the one-sided question
("either side is a user object"), which is where a variant operand belongs and
which also covers the shapes with NO static class on either side. So the runtime
path answers correctly; the question here is only whether the compile-time path
should have claimed the pair first.
Two things it would buy:
- A dispatch per evaluation. The runtime arm does
GetInstanceRTTIplus aPyFindDunderstring lookup on every operator evaluation. A vector demo doingv * kper frame per object pays that; a parse-time bind does not. NOT MEASURED -- measure before ranking this up. - The result's static type. The node is
tyVariantwhere a parse-time dispatch would type it by the dunder's declaredRetType. The list-repeat comment records the shape of what that costs when it matters: an inferred local holding a value with no class identity mis-picks an overload.
Do NOT simply hoist the arm
Measured by reading, 2026-09-14: in ParseSimpleExpr the user-class dunder arm
has no PyRecIsPylibOwnClass exclusion (its sibling in ParseTerm does), and
it sits BELOW the bytes-concat, list-concat, dict-union and set-operator arms
that depend on being reached first. Moving it above the variant arm moves it
above those too, and xs + ys would be routed into a TPyList.__add__ that
does not exist -- turning working list concatenation into a runtime TypeError,
which is exactly the failure 833f3ccba repaired for the ordering arm.
The shape that works is to NARROW the variant arm rather than move anything:
else if ((ASTTk[left] = Ord(tyVariant)) or (ASTTk[right] = Ord(tyVariant))) and
not PyUserDunderOwnsPair(op, left, right) then
with one predicate answering "does a user-class dunder arm below own this pair"
-- left is a genuine non-pylib user class declaring PyBinOpDunderName(op), or
left is not a class and right declares PyReflName(op). Guard it to the ops the
two arms actually handle (+ - * / // %), or a &/| pair with an __and__
on the left would skip the variant typing and land in the generic arithmetic
with no arm to catch it.
Gate
make test-nilpy + self-host byte-identical. The rows that must not move are
test_nilpy_variant_operand_arith_dunders (both arrangements),
test_nilpy_mixed_type_operands (list comparison, which is what the ordering
arm's own exclusion protects) and the list/dict/set operator rows in
test_nilpy_dunder_bitwise.
Log
- 2026-09-14 -- filed while resolving
bug-n-arithmetic-on-a-user-class-fails-when-the-other-operand-is-object-typedat the runtime instead. The parser asymmetry was diagnosed by the peer session lekkerzeilen-c8 and is correct; it turned out not to be the biting cause, and it is banked here rather than lost.