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
- an object right operand (
b == B()) — likely the arm that works, and if so it is the specification to copy !=,>,>=,<=, andstr/float/None/boolright operands- whether
sorted()/.sort()/inover such objects inherit the same failure (they route through pylib'sPyUserObjLt/PyUserObjGt, a different mechanism — two mechanisms for one concept, so check whether they agree)
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
pasparser_expr.inc— all three arms now require a user class on either side, and the ordering arm computesordLCi/ordRCiseparately, each-1when its side is not a class. The direct lookup is tried on the left and the reflected one on the right, neither needing the other to exist.pylib.pas— one new predicate,PyVarUserObj(p), answering "the user object behind this slot, or nil" (nil for this unit's own containers). It replaces the four hand-written copies; the four copies are how they drifted.PyVarEqgained a mixed-tag arm above the equal-tags gate,pyvar_gtandpycmp_vgained theirs beside their two-object arms, andPyUserObjBoolDunderno longer refuses a nilotherObj— only its class-pointer parameter shape needs an instance, and it now checks for one itself.
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
- 2026-08-27 — resolved, commit 2e2c5b939.