← board

What was measured

2026-09-13, at the tree that landed the single-carrier fix (binary c139938e5476). CPython is the oracle; both rows are ordinary Python it accepts.

class StatFirst:
    @staticmethod
    def both(n):     return "stat" + str(n)

class InstLast:
    def both(self, n): return "inst" + str(n)

class TwiceA:
    @staticmethod
    def twice(n):    return "A" + str(n)

class TwiceB:
    @staticmethod
    def twice(n):    return "B" + str(n)

def viadyn(o, n):  return o.both(n)
ta = TwiceA

row                            CPython   pxx
viadyn(InstLast(), 8)          inst8     inst8     <- works, and must keep working
viadyn(StatFirst, 9)           stat9     AttributeError: 'type' object has no attribute 'both'
ta.twice(10)                   A10       AttributeError: 'type' object has no attribute 'twice'

TwiceA.twice(10) spelled on the class LITERALLY is fine — that never reaches the dynamic path at all. It takes an alias to expose this.

Why the landed fix stops where it does

PyClassLevelOnlyMeth(nm, outMmi) returns True only for a name with exactly one class-level carrier (counted by distinct PROC, so inheritance does not count twice) and ZERO instance carriers. When it says True, the receiver has only one valid reading and PyParseVariantMethod emits a direct call, passing the variant's payload -- the RTTI blob -- as slot 0.

When it says False the function falls back to the old behaviour, which asserts pyvar_is_objtag in a HOISTED guard and therefore refuses a class receiver before any arm runs. That is correct in the sense that it refuses rather than guessing, and wrong in the sense that CPython answers.

The fix, and it is one mechanism for both rows

Give the arm chain a CLASSREF arm. Today each arm is

if pyvarobj(v) is SomeClass then <call SomeClass's method>

an INSTANCE test, which cannot discriminate two class receivers because neither is an instance of anything. The classref equivalent is a blob-identity test: pyvarobj(v) = <the RTTI blob of SomeClass>, emitted from the same place the statically spelled SomeClass.m(...) gets its blob. With that, both rows fall out of the existing generator:

PyClassLevelOnlyMeth then narrows to an OPTIMISATION (one carrier, skip the chain) rather than a gate, and the direct-call block it guards can stay exactly as it is.

What to be careful of

The hoisted guard is the thing that bites. It sits ahead of every arm, so widening the arms without widening the guard changes nothing -- and the guard cannot simply be dropped, because the arms dereference the payload as an instance. It has to become "objtag OR classreftag", with each arm asserting its own tag. pyvar_is_classreftag already exists in compiler/builtin/pylib.pas for this.

Do not reach for pyvar_holds for the tag test. It requires tag 7 and then asks which CONTAINER class the object is (k = 1/2/3 for list/dict/bytes), so pyvar_holds(v, 11) is unconditionally False. It reads like a tag test and is not one; it cost an hour on the fix that landed, producing a change with no behavioural effect and a guard that refused every receiver.

Every dynamic method call in NilPy flows through this function. Full NilPy tier, not a quick gate.

Not established

No census of how often a real program carries one name both at class level and as an instance method, beyond the one row below.

The census, measured 2026-09-13 -- and it contradicts the paragraph above it

The text here used to read "The shape that motivated the parent ticket -- a class-as-namespace in a platform backend -- does not need this, which is why the single-carrier case landed alone." That was written the same day and it is false for the program it names. With the bytearray/C-pointer defect fixed, lekkerzeilen --m0 gets a window, a GL 3.3 context, the renderer string and drawable : 1280x720, and then stops here:

Unhandled exception: AttributeError: 'type' object has no attribute 'clear'

gl.clear() (lekkerzeilen/__main__.py:182, and twice more in app.py) is a @staticmethod on the gl namespace class at platform/_pxx.py:453. The name clear is ALSO an ordinary instance method at platform/_ctypes_backend.py:209 and wake.py:53, and PyClassLevelOnlyMeth exits on the first instance carrier it meets. So the class-as-namespace backend needs exactly this, and needs only shape (a): ONE class-level carrier, any number of instance carriers.

That is the narrower fix and it is worth saying so: widening PyClassLevelOnlyMeth to ignore instance carriers and adding ONE classref arm clears the demo. Shape (b) -- two distinct class-level carriers -- still wants the blob-identity test described above and nothing measured asks for it yet.

See also

SHAPE (a) RESOLVED 2026-09-13

PyClassLevelOnlyMeth was answering two questions at once and folding the second into a refusal. Split into PyClassLevelCarrier(nm, outMmi, anyInstance):

PyClassLevelOnlyMeth is now one line over that, so the single-carrier direct call is untouched.

Three changes in PyParseVariantMethod, and the middle one is the load-bearing part the parent ticket warned about:

  1. the hoisted guard is widened to objtag OR classreftag when the name has a class-level carrier AND instance carriers -- widening the arms alone changes nothing, because the guard runs first;
  2. a CLASSREF ARM, added LAST so it is outermost: pyvar_is_classreftag(recv) ? <the class-level method> : <the instance chain>. Slot 0 takes pyvarobj(recv) -- the RTTI blob, which is what an injected $clsrecv or a declared cls wants and what a statically spelled C.m(...) passes. Missing arguments are filled from DEFAULTS exactly as a dual candidate's are, and the arm is DROPPED rather than fudged when the arity cannot be completed. The fill is not optional: gl.clear() writes no arguments and clear(depth=True) declares one;
  3. the unkept-promise FIXUP, mirroring the str and float ones: if the guard was widened and the arm was then dropped on arity, restore the pydynattr_no_method raise for a tag-11 receiver.

The fixup's positive control, measured

Row O of the fixture answers the same string before and after this fix, so on a value comparison alone it is a guard that cannot fail. Its assertion class is a CRASH. Measured by disabling ONLY the fixup and rebuilding: the fixture SEGFAULTS at row O (rc=139), and every row from O onward vanishes -- so the diff does see it, by absence. Without the fixup a tag-11 receiver passes the widened guard, misses every pyvarobj(v) is C arm, and reaches the static class cast, which dereferences an RTTI blob as an instance.

Verified

SHAPE (b) HAS NO CORPUS DEMAND EITHER — measured, not assumed (2026-09-13)

This ticket already said "nothing measured asks for it, which is why this is back at 40". That is now checked rather than inferred: frankh-30 grepped the running lekkerzeilen and no second class in the demo declares, at CLASS level, a name that gl also declares at class level. Instance methods sharing the name do not count — that is shape (a), which 5445b96d8 fixed and which cleared the live wall.

The p40 is therefore correct and the corpus agrees with it. Recorded so nobody re-derives the question; the answer cost one message.

THE p40 SURVIVES AND THE PARAGRAPH ABOVE DOES NOT — re-measured 2026-09-20 (frankb-8e)

The verdict is right and the stated reason is not, which is the dangerous combination: a correct conclusion protects a broken instrument from review.

The paragraph above asks whether a second class declares a name that gl also declares. Shape (b) does not mention gl. gl is where shape (a) bit, so the question inherited shape (a)'s frame, and a census filtered on the hypothesis's own subject cannot see an instance anywhere else — which is precisely where the one instance is.

Re-measured with ast, not grep, over 192 *.py files at lekkerzeilen 587681a (2026-09-20), 192 parsed, 120 distinct names carried at class level:

condition rows
names with >=2 distinct class-level carriers (shape (b) NECESSARY condition) 1identity, on Mat4 and Quat, both lekkerzeilen/math3d.py
of those, ever reached through a class held as a value (SUFFICIENT condition) 0

So the corpus does contain the pair frankh-30 reported absent, and shape (b) still does not bite, for a different reason than the one on record: all eight .identity( call sites spell the class literally (Mat4.identity(), Quat.identity() in vessel.py, sim.py, app.py, math3d.py), and neither class is ever bound to a name. This ticket's own opening says why that is decisive: "spelled on the class LITERALLY is fine — that never reaches the dynamic path at all. It takes an alias to expose this."

THE CONDITION THAT WOULD SPRING IT, stated as a mechanism because a row decays and a mechanism does not: any two classes carrying one name at class level, where the call goes through a class value — an alias, a parameter, a dict lookup, a backend = Mat4-style selection. Today the corpus has the carriers and not the alias. A single M = Mat4 added anywhere makes this p40 ticket a live wall, and nothing warns, because the carriers are already there.

WHY THIS DID NOT CHANGE THE NUMBER: p40 is still correct. It is recorded because the next reader re-deriving "is there demand" from the paragraph above would ask about gl again and get a clean answer about the wrong question.

Sibling context: [[bug-n-a-class-reached-through-a-unit-alias-is-not-a-value]] and [[bug-n-a-from-imported-class-loses-its-methods-unless-it-is-renamed]] closed the same day on that corpus, both with the repair unattributed from their side — this ticket's credit of 5445b96d8 for shape (a), verified by its author against lekkerzeilen, is the attribution those two defer to.