← board

obj in [list of objects] ignores __eq__ and compares identity

class V:
    def __init__(self, v: int):
        self.v = v
    def __eq__(self, o) -> bool:
        return self.v == o.v

print(V(1) in [V(1), V(2)])     # CPython: True    pxx: False

== itself now dispatches __eq__ (parser.inc, the comparison arm). in does not, because membership is decided at RUN time by pylib's PyVarEq, which compares two object slots by pointer and has a by-content case only for TPyList and TPyDict. It has no way to reach back into a user method.

The machinery to do it exists in a neighbouring form: pyvar_callv0..3 (pyeval.pas) already tells the callable shapes apart at run time and invokes them, which is how a def, a lambda and a bound method are all callable from a variant. Membership needs the same trick for a per-class __eq__ — most directly, a slot in the object's class record pointing at its __eq__ proc, which PyVarEq calls when both operands are user objects.

Same fix covers list.count(obj), list.remove(obj) and dict keyed by an object, all of which route through PyVarEq.

Split out of [[bug-nilpy-eq-dunder-ignored]] when that landed.

Gate

make test-nilpy + self-host byte-identical, plus in, count and index over a list of objects with and without __eq__, and an object as a dict key.

Recon 2026-07-30 — sized, not started

Confirmed the shape above is still the blocker: pylib's PyVarEq decides membership and cannot reach a user method, so this needs a per-class __eq__ entry that pylib can call — i.e. a CLASS-RECORD / RTTI change, which is Track A shared ground, not a pylib-local fix. That makes it a two-track item (A for the slot, N for the dispatch), which is why it was left rather than started at the tail of a long session. Note the stride landmine on that table: [[project_rtti_method_table_multi_consumer_stride_landmine]].

2026-08-04 — the premise is WRONG, and the cheap fix is ruled out by measurement

Picked this up to build the frontend-only version: lower x in xs into a loop over x == xs[i], which needs no runtime hook, because this ticket states that "== itself now dispatches __eq__".

It does not. == dispatches __eq__ only when BOTH operands are statically class-typed. As soon as one is a VARIANT — which is what a container element and a for-in variable are — it falls back to PyVarEq and compares pointers:

a = V(1); b = V(1); xs = [V(1)]
print(a == b)         # True   correct
print(a == xs[0])     # False  CPython: True
print(a in xs)        # False  CPython: True
print(xs.count(a))    # 0      CPython: 1

So the loop rewrite would have expanded in into exactly the expression that is already broken, and would have "fixed" nothing while looking plausible. Not implemented; nothing changed in the tree.

That is a bigger and more user-visible defect than membership on its own — the dunder works in the shape a minimal test uses (two named locals) and fails in the shape real code uses — so it is filed with its own repro as [[bug-nilpy-eq-dunder-skipped-when-either-operand-is-a-variant]] and carries the higher priority.

The 2026-07-30 recon's conclusion stands and is reinforced: both tickets need one thing, a way for PyVarEq to reach a user __eq__ at run time, and fixing it fixes ==, in, count/index/remove and object dict keys together.

One lead the recon did not have

__pxxMethodAddress(Instance, Name) (compiler/builtin/builtin.pas) already walks a class's RTTI method table BY NAME at run time. Two things to settle before building on it, both recorded on the sibling ticket: whether a NilPy method carries PXX_RTTI_METH_PUBLISHED (that routine skips anything that does not), and the ABI — __eq__(self, o)'s second parameter is a variant when unannotated and a class pointer when annotated, so one fixed call signature would miscompile half the cases. The safe shape is a compiler-emitted fixed-signature wrapper per class, published under a known name.

Returned to backlog/ with the premise corrected, blocked in practice on the sibling above.

2026-08-10 — the blocker it describes in prose now has a real edge

This ticket's closing line says it was "returned to backlog/ with the premise corrected, blocked in practice on the sibling above" — but the file sat in blocked/ with no blocked-by: in frontmatter, so the board could not see why. That is the same failure mode [[feature-lib-tkinter-callable-options-with-args]] recorded on itself: a blocker stated in prose is invisible to the ranker, which reads frontmatter.

Added the edge to [[bug-nilpy-eq-dunder-skipped-when-either-operand-is-a-variant]], which is the sibling meant. Both in and == bottom out in pylib's PyVarEq, so the same dispatch fix serves both and this one should not be worked separately.

Correctly stays in blocked/: the sibling is genuinely unfinished. But it now inherits the sibling's priority down the dependency edge instead of ranking on its own.

2026-08-10 — MEASURED: in and count now pass; == against a variant does NOT

The 2026-08-04 probe on this ticket, re-run verbatim on the current binary and on pinned (identical on both, so this predates today's work):

a = V(1); b = V(1); xs = [V(1)]
expression CPython pxx now this ticket's model said
a == b True True correct already
a == xs[0] True False broken (still is)
a in xs True True broken — now passes
xs.count(a) 1 1 broken — now passes

This ticket's own headline repro (V(1) in [V(1), V(2)]) now answers True.

Why that matters more than a status update

Both this ticket and its blocker assert that in, count and ==-with-a- variant bottom out in the same PyVarEq call, and therefore that "the same dispatch fix serves both and this one should not be worked separately". The measurement contradicts that premise: two of the three now dispatch __eq__ and the third does not, so they are no longer one mechanism — whatever fixed membership did not fix ==.

That has a concrete consequence: the dependency edge added earlier the same day now says this ticket waits on a sibling that no longer gates its symptom.

Do not close this on the table above, and do not just drop the edge. The honest next step is one measurement, not a decision: find out whether in reaches __eq__ through PyVarEq at all any more, or through a separate path added since. That answer decides whether this is closable as-is, or whether PyVarEq still has a hole that in happens to route around.

The ==-against-a-variant row is unchanged and remains the real, user-visible defect — the dunder works with two named locals and fails the moment one side is a container element, which is the shape real code writes. That is the sibling [[bug-nilpy-eq-dunder-skipped-when-either-operand-is-a-variant]] and it is correctly still open.

No code changed. Left in blocked/ deliberately: the edge is not wrong yet, only unproven.

Log

Resolution (2026-08-10) — measured fixed; the edge it waited on no longer gates it

The earlier note today left this in blocked/ because the dependency edge was "unproven, not wrong". It is now proven wrong, by the one measurement that note asked for. No code was written for this ticket.

__eq__ genuinely RUNS — not an identity coincidence

A print inside the dunder settles what the value table alone could not:

in:    __eq__ ran  -> True
count: __eq__ ran  -> 1
eqvar: (never ran) -> False

So membership really does dispatch the user method; it is not matching by pointer and happening to agree. That was the open question, and it is the reason the close is safe.

The ticket's own gate, all rows, against CPython

row CPython pxx
a in xs True True
xs.count(a) 1 1
xs.index(a) 0 0
ys.remove(V(1)) [2] [2]
class WITHOUT __eq__, other instance False False
class WITHOUT __eq__, same instance True True

The last two rows are the guard that matters: identity semantics are preserved for a class that declares no __eq__, so the dispatch did not simply make everything compare by content.

Control: identical on stable_linux_amd64/default/pinned, so this predates today's session and is not credited to it.

The premise both tickets rested on was wrong

Both this ticket and its blocker assert that in, count and ==-with-a- variant bottom out in one PyVarEq call and therefore need one fix. They do not: two dispatch and the third does not. The blocked-by edge is dropped with this resolution.

Still open, and NOT closed by this

[[bug-nilpy-eq-dunder-skipped-when-either-operand-is-a-variant]] — a == xs[0] is still False where CPython says True. That is the real remaining defect (the dunder works with two named locals and fails when one side is a container element), and it is narrower than it reads now that membership works.

One divergence found while running the gate, filed separately

d[V(1)] = "one" — CPython raises TypeError: unhashable type: 'V' (a class defining __eq__ without __hash__ is unhashable); pxx accepts the store and then d.get(V(1)) misses, answering MISSING. Different defect, different shape (silent miss vs. refusal), so it is not folded in here: [[bug-nilpy-object-dict-key-with-eq-but-no-hash-is-accepted-then-misses]].