← board

Repro

Three files, 40 lines, in devdocs/progress/repro/dynamic-default-signature/ and reproduced inline here. The shape is the one lekkerzeilen has and a single-file reduction does NOT have: the class that declares the method is in a module parsed AFTER the caller, and the caller's own module declares a same-named method with a different signature.

pkg/early.py -- imports nothing, exactly like lekkerzeilen/wind.py:

class Breeze:
    def at(self, x, z, t=0.0, height=6.0):        # the sibling signature
        return "Breeze.at ..."


class Sampler:
    def __init__(self, grid=None):
        self.grid = grid

    def march(self, a, b):
        g = self.grid                              # no static type
        if g is None:
            return "no grid"
        return g.at(a, b)

pkg/late.py:

class Grid:
    def at(self, x, z, outside=None):
        return "Grid.at x=%r z=%r outside=%r" % (x, z, outside)

pkg/__main__.py imports early first, then late, and calls Sampler(Grid()).march(1, 2).

row CPython pxx @ ee441baba
g.at(1, 2), name shared with a sibling class outside=None outside=0.0
g.sample_zzz(1, 2), name unique to Grid outside=None TypeError, see below

The second row is the same defect with no sibling to mis-read: the scan finds nothing, falls to the run-time lookup, and the run-time lookup applies no defaults at all --

TypeError: sample_zzz() missing positional argument(s):
           its parameter list holds 3 and only 2 could be bound

The three outcomes, one cause

Which one you get depends only on what the scan happens to see, which is why this has been so hard to corner -- two of the three are harmless:

  1. No candidate visible, no defaults needed -- falls to the run-time lookup and is CORRECT. canopy.contains(sx, sz) takes this arm and returns the right answer, while the build log says "no class declares .contains()" and five classes do.
  2. No candidate visible, a default needed -- run-time lookup, hard error. Loud, and the message names the arity honestly.
  3. The WRONG candidate visible -- never reaches the run-time lookup. Static signature, wrong defaults, silent wrong value. This is the one that hurts.

Where it bites in lekkerzeilen

wind.py:143, rise = canopy.at(sx, sz) - self.level. wind.py imports nothing and __main__.py takes from .wind import Wind at line 207 and from . import world as world_mod at line 325, so when Wind.shelter's body is parsed the only visible .at is Wind.at(self, x, z, t=0.0, height=6.0) -- forty lines above the call, in the same file. Its signature holds four parameters; TiledGrid.at(self, x, z, outside=None) accepts three.

Measured with entry prints in every one of the six classes declaring .at (Grid, TiledGrid, Route, Wind, ui, gauges): on the failing call none of them is entered, and the call still returns a value. Under gdb the fault is

main -> App.run -> Environment.wind -> Wind.at -> Wind.shelter
     -> pysub_v -> PyVarUserArith -> PyVarUserObj -> mov (%rax),%rax, rax=1

with the operand variant {VType = 7, Payload = 1} -- si_addr 0x1, si_code 1. Exactly one such variant per run.

Why no reduction found it

Two single-file reductions came back CLEAN before this one: a method with a defaulted parameter on a variant receiver (7 rows), and the same with the caller parsed before the class (3 rows). Both test the SHAPE, and the shape is fine -- a parser pass sees every class in one file. Only the cross-MODULE arrangement, with a sibling of the same name in the caller's module, reproduces it. See debugging-playbook.md.

Where to look

compiler/pyparser.inc:19256, the candidate scan. It keeps a first-wins static pick plus up to sixteen run-time is-test arms, and the pick supplies the SIGNATURE -- arity and defaults -- for a call that will dispatch elsewhere. The same machinery already has a lekkerzeilen casualty recorded in its own comment at :19524 (app.py:1678, tile.grids.pop(name, None), first-wins picked TPyDeque). There the wrong pick produced a loud error; here it produces a wrong value.

Note that four pylib containers -- TPyList, TPyBytes, TPyRange, TPyDeque -- each declare at(i) taking ONE argument, so .at is a name the scan always has candidates for.

Gate

make test-nilpy + self-host byte-identical, plus a multi-module fixture under test/nilpy_units/. Both rows above belong in it: the silent wrong default and the loud missing one, because a fix that only taught the run-time lookup to apply defaults leaves row 1 silently wrong.

Log

Three things measured AFTER filing, each of which narrows the fix

1. The run-time lookup CANNOT be taught defaults without new RTTI. TMethInfo (lib/rtl/typinfo.pas:71) carries NamePtr, Code, Arity, RetKind, ParamKinds (kinds, then param-name pointers) and Flags. There is no block of DEFAULT VALUES. So the honest error in row 2 is not an oversight in PyHostCall -- the information is not there to bind with. Giving the dynamic path defaults is a FEATURE touching the RTTI emitter and that mirror record, not a fix, and it would still leave row 1 wrong.

2. The ordering fix is the one that repairs all three outcomes, and it is already proven. Swapping the two import lines in the repro's __main__.py turns both rows correct. PyPreScanImports (pyparser.inc:42740) does visit every import token including function-local ones -- its own header says it has no notion of reachability and resolves them all -- but it walks them in TOKEN ORDER, and PyParseImportUnitAs (:41942) parses a pulled module's BODIES at pull time. So wind.py's bodies are parsed during the prescan, before world.py is mentioned at all. The repair is a two-phase pull: register every imported module's class shells first, then parse bodies. That is a restructure of shared import machinery with a long tail of documented prior bugs (guarded arms, soft misses, alias rollback) and it is NOT a small change.

3. THE OBVIOUS WORKAROUND FIXES THIS SITE AND UNCOVERS THE NEXT ONE. Adding from . import world to the top of lekkerzeilen/wind.py, with every other source byte-identical to the owner's tree, MOVES the failure rather than removing it: the world path then dies during startup with

AttributeError: 'World' object has no attribute 'flow'

and World does declare flow, as a @property (world.py:1272; Region.flow at :343 is a second, and Environment.flow at environment.py:25 is a plain instance attribute -- three unrelated declarers).

CORRECTED by lekkerzeilen-c8, who ran the test in both spellings, 3/3 each, before my "do not run it" reached them:

A. from . import world added to wind.py B. from . import world at module level in main.py, ahead of from .wind import Wind -- adds NO new import edge to wind.py, it only changes what the scan has seen when wind.py is parsed

Both give rc=217 with the identical message, and both turn an unattributable segfault 2.95s into the frame loop into a NAMED deterministic error at 0.95s. Spelling B is what retires the "perturbed module graph" worry: the graph is unchanged and the answer is the same to two decimal places.

So the workaround is not a failure -- it repairs .at and then falls into the SECOND INSTANCE of this same defect, on a property read. app.py:1243, self.env.flow = source.flow if source else None, where source = self.scene is a Region OR a World. The message is FALSE as stated: the property is there.

This is why an app-side import order cannot be the fix. .at and .flow want different orders, so there is no single arrangement that satisfies both; the repair has to be in the compiler.

AND THE WARNING'S ABSENCE IS NOT EVIDENCE OF SAFETY -- read a build log the other way round. .up, .water_level and .spawn all draw "several unrelated classes declare a .X property"; .flow, with three declarers, draws NOTHING. Identical to .at (silent, three declarers) against .contains (warned, five declarers). Where the scan sees the WRONG SUBSET it stays quiet and binds anyway; where it sees nothing it says so. A reader scanning warnings for trouble is looking at exactly the sites that are fine.

FIXED 2026-09-14 -- two halves, and neither works alone

1. THE FRONTEND: the closed world's innermost fallback is now the open world. PyParseVariantMethod builds a chain of runtime-tested arms over a STATIC arm, and that static arm -- hitCi, the first-wins pick -- was the innermost fallback. A receiver that matched no arm therefore took a VMT-slot call on a class it is not, with hitCi's DEFAULTS already filled at the call site. It is now wrapped:

pyvarobj(recv) is <the pick> ? <static call> : pydyn_meth<n>(recv, 'name', ...)

Pascal's is is true for the pick and every descendant, so nothing the static arm was right about changes; only a genuinely unrelated class falls through, and today that class gets a wrong-slot call. Narrowed to i + 1 < ParamCount -- calls where the site FABRICATES arguments from a guessed signature, which is the defect's actual mechanism rather than its neighbourhood. A call whose arity matches exactly is untouched.

2. THE RUNTIME: the by-name binder can now fill the callee's OWN defaults. This is what made the first half insufficient on its own -- routed to pydyn_meth2, the reduction then died with at() missing positional argument(s), because PyHostCall had mi^.Arity and no values. The defaults already existed, populated, in the PYSIG record EmitPySignatures emits per def; there was simply no route from a TMethInfo to one (Code is a code address and keys nothing).

The route is ONE WORD, appended to the method's ParamKinds block after the name pointers, flagged by RTTI_METH_FLAG_HASSIG. In the BLOCK and not in the record, for the same reason the *args index lives in Flags: TMethInfo's mirror in lib/rtl/typinfo.pas and the three stride consumers must not move (project_rtti_method_table_multi_consumer_stride_landmine). The block carries no length word, so every reader that takes Arity kinds and Arity names cannot see it. pysig_fill_defaults lives in pylib, which already owns the layout, so pyeval passes the pointer and never learns the shape -- a third mirror is a third thing to keep in step.

It is all-or-nothing: every slot in the missing range is checked before any is appended, because a partial fill calls the body at an arity it cannot take, which is the smash this path exists to stop. When it declines, the existing TypeError still fires and still says the true thing.

Reduction: devdocs/progress/repro/dynamic-default-signature/ now matches CPython byte for byte on both rows. Fixture: test/test_nilpy_dynamic_call_takes_defaults_from_its_own_class.npy, whose package puts the fitting class SECOND -- the only arrangement a first-wins table is exposed by -- and whose control rows (a call written in full, and a receiver that really IS the pick) assert that the static arm still wins where it was already right.

What this did NOT fix, measured rather than assumed

The world path still does not run. It gets FURTHER -- the .at site is repaired and the run advances from ~2.95s in-loop to ~5.7s -- and then dies in malloc(): unsorted double linked list corrupted, a HEAP corruption, which is a different defect wearing a different failure mode. --open-water survives a 150s timeout (rc=124) on the same binary, so the corruption is specific to the world path and not general.

And one layer underneath was uncovered: bug-n-a-run-time-dispatched-call-s-result-is-coerced-to-an-integer -- a dispatched call returning a float truncates and one returning a string raises. Filed with its own repro and a control proving it was UNREACHABLE before this fix, because the call never completed at all.