← board

A comparison dunder against a non-class operand answers wrongly

Filed 2026-08-26 while fixing [[bug-n-a-subscript-inside-a-base-class-skips-the-subclass-override]], from varying the operand types that ticket did not vary.

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

class B:
    def __eq__(self, o):
        return True
    def __lt__(self, o):
        return True
b = B()
print(b == 1)     # CPython: True    pxx: False
print(b < 1)      # CPython: True    pxx: False
CPython True / True
pxx (pinned v376 and HEAD) False / False

No inheritance, no self, one class, from outside it. The methods are never consulted: a class whose __eq__ returns True unconditionally still compares unequal, so the answer is the opposite of CPython rather than merely imprecise.

The right operand's type is the variable. Both dunders are reached in pasparser_expr.inc (~9342/9356 for __eq__/__ne__, ~9416 for the ordering pair) through PyCallMeth1(ResolveNodeRec(left) - REC_UCLASS_BASE, ...), and the ordering arm additionally computes ordLCi/ordRCi for both sides — a reflected-operand lookup that an int right operand plausibly fails, sending the comparison to the default identity/numeric path. Not verified — measure with PXXDBG=a.opovl / n.locals before writing a cause into this ticket; two recent N tickets named the wrong mechanism and one of them quoted a source comment that was simply false.

What was NOT measured

Why this ranks where it does

Equality is the dunder people actually write, == against a scalar sentinel is ordinary code, and the failure is a silent inverted boolean rather than an exception — the shape devdocs/dev/debugging-playbook.md opens by calling the expensive case. Below the arithmetic sibling only because that one prints an address, which at least gets noticed.

Sibling: [[bug-n-an-arithmetic-dunder-on-self-is-pointer-arithmetic]].

Gate

make compiler/pascal26 + the repro answering True twice, plus a witness row in test-core pairing the class-operand arm (expected already-correct) with the int-operand arm, .expected generated by CPython.


Resolved 2026-08-27

One over-strict guard, written out SEVEN times. Every site that dispatches a comparison to a user class asked "are BOTH operands user classes?" when the question is "is EITHER operand a user class?". Three copies at compile time in pasparser_expr.inc (__eq__, __ne__, and the four orderings), four at runtime in compiler/builtin/pylib.pas (PyVarEq, pyvar_gt, pycmp_v, and PyUserObjBoolDunder's own otherObj = nil bail). The ticket's guess named the reflected-operand lookup; that was wrong in a useful way — the reflected lookup was fine, it just never ran, because the arm containing it was never entered.

What it actually did

b == 1 did not "fail to find __eq__" — it never looked. The comparison fell through to the default identity/numeric path and answered by allocation address: constant False for ==, constant True for !=, and an address ordering for </<=/>/>=. The full 6-operator x 6-right-operand matrix (int, float, str, bool, None, and a second instance) answered the opposite of CPython in every non-class column and correctly in the class column — a plausible boolean, never an error.

The measurement that mattered

The "What was NOT measured" list was right to exist: the second mechanism it names does not agree, and finding out took one program.

xs = [M(3), M(1), M(2)]
print(2 in xs)          # CPython True, pxx False
g = 0; g = M(5)         # variant-typed holder, no static class
print(g == 5)           # pxx: Unhandled exception: expected a number, got object

The compile-time arms key on an operand's static class. A variant-typed holder has none and a list element never has one, so those route through pylib instead — and pylib's PyVarEq bailed one line before its user-object arm on if p^.VType <> q^.VType then Exit, while pyvar_gt/pycmp_v required (pa^.VType = 7) and (pb^.VType = 7). Same predicate, four more copies, same error. Fixing only the parser would have left in, .index(), .count(), .remove(), sorted(), min()/max() and every variant-typed comparison exactly as broken, with the operator spelling now correct — the worst of both, because the two spellings would have disagreed.

The fix

The regression the fix nearly shipped

Widening pyvar_gt turned 9 < g — CPython raises TypeError there, since M declares no __gt__ — from a raise into a silent False. Strictly worse than the bug being fixed, and invisible: it only showed up because the probe kept the unorderable row. The refusal belongs in PyOrdCheck rather than in pyvar_gt, because that is the one place still holding the operator and the operand order the source wrote (pyvar_lt reaches pyvar_gt with them swapped). It now raises CPython's exact sentence, character for character:

TypeError: '<' not supported between instances of 'int' and 'M'

which is also an improvement on the pre-fix expected a number, got object.

What is deliberately NOT fixed

Q(1) == None at runtime (both slots tag 7, payload 0 on one) still answers by identity rather than consulting __eq__. The compile-time spelling is correct. Left alone: a None-check is the single most common == in Python and rerouting it through a dunder at runtime is a much larger blast radius than this ticket, with no program yet observed that needs it.

Gate

make compiler/pascal26 (fixedpoint 05dc9565a6ed), tools/gate.sh quick GREEN, the c1/c2/c4/c6 matrices all agreeing with CPython, and a witness row test_nilpy_cmp_dunder_nonclass_operand in test-core — 23 diff lines red at the previous pin, green at HEAD, .expected generated by CPython. It pairs the class-operand arm (the control that was already correct), int/float/reflected operands, the runtime twin, a dunder-less class that must keep identity, and the container rows that must not move.

compiler/builtin/pylib.pas changed, so this needs a PIN — other lanes build against the frozen copy.

Log