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:
- two class-level carriers -> two classref arms, chosen by blob identity;
- a name carried both ways -> a classref arm for the static reading and the ordinary objtag arm for the instance reading, in one chain.
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
bug-n-a-staticmethod-or-classmethod-is-unreachable-through-a-class-held-as-a-value-- the parent; the single-carrier call path, resolved 2026-09-13.bug-n-a-class-level-method-read-off-a-class-value-as-a-value-is-refused-- the READ path, a different mechanism (the attribute registry), same shape.
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):
- exactly ONE class-level carrier (counted by distinct proc) decides whether a class receiver can be dispatched at all;
anyInstancedecides only whether the instance arm chain is needed BESIDE it.
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:
- the hoisted guard is widened to
objtag OR classreftagwhen the name has a class-level carrier AND instance carriers -- widening the arms alone changes nothing, because the guard runs first; - a CLASSREF ARM, added LAST so it is outermost:
pyvar_is_classreftag(recv) ? <the class-level method> : <the instance chain>. Slot 0 takespyvarobj(recv)-- the RTTI blob, which is what an injected$clsrecvor a declaredclswants and what a statically spelledC.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 andclear(depth=True)declares one; - 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_methodraise 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
- Fixture extended to 16 rows. Control under pin v408: A-E, J and L raise
AttributeError: 'type' object has no attribute <name>; F-I, K, M, N, O, P pass there too and are regression guards. So SEVEN rows have moved across the two commits and J and L are the two this change owns. - L/M/N are ONE call site (
viawipe(o)) reached with a class value and two different instances, which is the claim in both directions: the arm must not steal an instance receiver and the chain must not steal a class one. gate.sh quickGREEN after a reviewed--updateof the AST slot-write census (14 rows, every one an ordinary node-into-child-slot write for an existing kind, soASTLeftIsChild/ASTRightIsChildneed nothing).- lekkerzeilen
--m0no longer raises ongl.clear(); it reaches its render loop and stays there. NOT established: that frames are being SWAPPED. The loop prints its frame count only on a clean exit, and closing the window from outside failed (the session is Wayland, and forcing X11 then driving it with xdotool killed the process through its X connection rather than through the QUIT path -- my own interference, not a defect).
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) | 1 — identity, 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.