How it was reached
Not by a report. Lifting the frontend's 4-argument cap on run-time dispatch
(MAX_DYN_ARGS, so lekkerzeilen's gl.tex_image_2d(target, fmt, w, h, fmt, data, level=at) could compile) made an arm REACHABLE that could not previously
be entered with five arguments: PyDynMethL's fallback for a receiver whose
attribute is a callable value, not a method.
case nargs of 0..3: ... else pyvar_callv4(...) was exactly right while the
frontend capped at four — else could only mean four. With the cap lifted it
would have meant "five or more, silently pass the first four", which is the
plausible-wrong-value trade the cap's own comment was written to avoid. So it
raises instead, and this ticket is the honest record of what that costs.
Repro
A callable FIELD, not a method, on a receiver from another module:
# holder.py
class Box:
def __init__(self):
self.thing = lambda a, b, c, d, e: a + b + c + d + e
def make():
return Box()
import holder
o = holder.make()
print(o.thing(1, 2, 3, 4, 5))
CPython prints 15. pxx raises
thing() is dispatched at run time through a callable attribute, which takes at most 4 arguments.
The fix
pyvar_callv needs a list-taking form the way pydyn_meth just got one —
pyvar_callvl(cb, args: TPyList) — and then PyDynMethL's callable arm loses its
case entirely. Both pyvar_callv_kw and the rungs stay as wrappers, for the
reason the pydyn rungs stayed: they are a public builtin interface.
Note the neighbouring refusal is a DIFFERENT one and is not this ticket: a KEYWORD argument through a callable field is refused because there are no parameter names to bind against, which is correct and intended.
Resolution (2026-09-18)
The repro in this ticket does not reproduce, and never did. o = holder.make() then o.thing(1, 2, 3, 4, 5) prints 15 on the PINNED, unfixed
compiler: the frontend types o from the call and dispatches STATICALLY,
reaching neither capped arm. A fixture written to that shape passes before the
fix -- the ucycle_b failure mode CLAUDE.md warns about, caught here by running
the negative control before committing rather than after, which is when it feels
like verification.
THE RECEIVER HAS TO ARRIVE THROUGH A CONTAINER FOR EITHER ARM TO BE ENTERED. That sentence is the reproducer; this ticket's own was not. (A parameter with no single inferable call site does it too.)
Two spellings, and grepping for the construct does not relate them. With the
receiver reached through a list, the observable is not this ticket's title at
all -- it is a COMPILE error, .c5() — a callable field with no signature must be variant-typed to be called on a dynamically-typed value, from
PyVariantFieldCallArm. That message is true of a POINTER field and a false lead
about this one: a variant callable field has no signature at FOUR either, and
four compiles. The run-time message this ticket is named for needs a third
shape -- an attribute set on an instance whose class declares no such field, so
there is no candidate class and the lookup happens on the receiver. Measured,
both routes, 4 through 9 arguments, pinned versus fixed.
The ceiling refusals. Past eight, the candidate-class route used to fall
through to the pointer-field arm and report the no signature message, sending
the reader off to annotate a field whose annotation was never what decided it.
It now names the ceiling and the count, as the run-time route does. Both are
asserted in the Makefile.
Unmasked, NOT caused, by this fix: self.cb = Tagged(tag).m5 where tag is
a PARAMETER does not register cb as a field at all, falls to the run-time
route, and dies with object is not callable -- at ONE argument, on the pinned
compiler, identical before and after. Its own ticket:
bug-n-a-bound-method-stored-in-a-field-from-a-parameterised-receiver-is-not-callable
Fixture test/test_nilpy_callable_field_wide_arity.npy (+ callablefield_mod.py)
covers both routes, all four carrier families, a second candidate class, and
both ceilings. It is refused by the pinned compiler and byte-identical to
CPython on the fixed one.
Log
- 2026-09-18 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 0560e7b50.
The general lesson: when you extend a ladder, grep for who counted its old top
This is not two authors drifting apart over months. pyvar_callv5..8 landed at
2b7068dd7, later the SAME DAY as 95e7eb26e, and left TWO consumers behind within
hours. Neither consumer names the ladder and the ladder names neither consumer,
so no grep for the construct relates them -- the sibling-is-a-spelling case from
normalise-dont-special-case.md, arriving through a shared numeric CONSTANT
rather than through a shared shape.
The compile-time arm is the nastier of the two because its diagnostic argues AGAINST the real cause: a fifth argument was reported as a MISSING SIGNATURE, and a reader who believes it goes off to annotate a field whose annotation was never what decided anything -- four arguments compile with the same missing signature.
TRIGGER FOR PROMOTION TO CLAUDE.md, recorded rather than acted on. This met the MERIT test and has NOT met the RECURRENCE one: it is a single subsystem -- the pyvar_callv ladder and its two consumers -- and CLAUDE.md promotes on a SECOND INDEPENDENT subsystem, not on quality, because merit-based promotion is how that file reached 72KB the first time.
If a second independent subsystem shows a consumer left behind by a WIDENED NUMERIC BOUND, it is promoted as a STRENGTHENING of the existing sibling-is-a-spelling rule -- an extension sentence, not a new neighbour.
What it would strengthen, and why the existing rule does not already cover it: CLAUDE.md says to grep for the other spelling's HANDLER. That does not find this class. A consumer that merely stopped at four is not a handler for anything -- it names no ladder, contains no shared construct, and reads as ordinary correct code. The actionable form is the one this ticket found: WHEN YOU EXTEND A LADDER, GREP FOR WHO COUNTED ITS OLD TOP.