← board

Type a bare parameter from its call sites

The lekkerzeilen demo has 334 constructor parameters and zero annotations, which is how Python is written, so every field set from one was a variant and every arithmetic op on every field ran the full dynamic protocol. Measured 2026-09-15 on Grid.at: annotating the parameters took it from 1337 B / 48 calls / 0 SSE to 280 B / 1 call / 3 SSE. The program does not get annotated (programs do not contort for the frontend); the compiler reads what the call sites already say.

Mechanism. PyParamTypeFromSites(defTok, pname, mode, cls) in pyparser.inc, token-only like every pre-pass scanner, memoised per (def token, name) so the three header passes agree. Mode 0 = constructor (Cls( sites), 1 = method (.name( sites; Cls.name( skipped), 2 = module function (name(). The default is a site of its own and anchors; every visible site must agree on ONE scalar (float, int, bool, str) or the answer is unknown. An argument that is the caller's bare parameter recurses into the caller's sites; one that is the caller's LOCAL is typed by PyLocalScalarType, which needs every binding to agree and accepts the accumulator idiom (x = x + dt, x += dt) only when it keeps the type. A parameter the body REBINDS is checked the same way. Only the def's OWN FILE is scanned (PasSrcRangeStart bounds): imported modules and the builtin Pascal units share the token stream, and an unbounded scan met parts.at(k) in pylib.pas and vetoed Grid.at.

Two wrong values found by the fixture's controls, both pre-existing: def probe(q): return q * 3 answered 4 for probe(1.5), because the return scan joined the unknown parameter with the literal and typed the return Int64 (now: an untyped bare parameter read in a return expression makes it a variant, PyRetExprHasUntypedParam). And response.read() with response bound by with ... as was typed Int64 from a module-level read in another module, because the expression typer's call arm fell back to a global proc lookup for a dotted non-unit root (now: no fallback for a dotted root). A third, bisected by the lekkerzeilen seat from the demo's keel(): return self.section(t)[0][1] typed Int64 from the INDEX literals, because the call arm leaves the walk on the [ and the indices were joined as operands (1.75 came out 1; r = self.section(t); return r[0][1] was correct). Now a [ after ) or ] is a subscript: the element is a variant, the group skipped.

Behaviour the typing changed, kept CPython-shaped. def f(vm): del vm[1:2] with every site passing a str: vm is a str now, and a str's del was a compile error where the variant raised CPython's TypeError at run time. pystr_del_at/pystr_del_slice raise it at run time instead (test_nilpy_del_of_a_slice_on_a_variant_receiver's last row, which caught it on the tier). The tier also caught a bool default typed Int64: Boolean is a machine int to PyIsMachineIntTk, so PyIsSiteIntTk excludes it.

Ints are NOT claimed, and that is the second finding of the evening. The lekkerzeilen seat ran its value-parity test against the real modules: text. measure(line, scale=2) typed int from its ONE own-file site while all four real callers sit in ui.py/app.py, invisible to an own-file scan, and 80 of the 357 typed parameters rested on a single int site. An int claim is the one a caller the scan cannot see defeats silently (a variant 1.25 truncates to 1; a static 1.25 is a compile error for a program CPython runs). A float claim is safe both ways (an int widens exactly, a str raises), so floats and strs are claimed and an int OR BOOL site or default VETOES (a float claim over an int site would print 6.0 for 6; a bool field typed from v=True turned a later self.icon.visible = 11 into True where CPython keeps 11 -- test_nilpy_chained_assign_nested_attr, on the tier). The demo's hot path is float. Ints come back with feature-n-lex-the-import-closure-before-parsing-so-call-site-typing-sees-every-caller.

A float parameter reaches pyeval's host-method dispatch, which took only Int64-register shapes (plus a lone fpush/fpop pair) and Halted with "unsupported param shape" on at(self, x, z, outside=77.5) once outside was a Double (test_nilpy_dynamic_call_takes_defaults_from_its_own_class, on the tier). pyeval.pas now has the MIXED family: m pointer-class args and k doubles, one thunk per (m, k, result kind), relying on SysV assigning the two register classes independently of interleaving; a Double result with n > 0 is now read from xmm0 too (it read rax before).

Known residual, owner: this seat. A variant holding a double coerced into an int-typed slot TRUNCATES silently (pyvar_to_int), so an int claim is the one answer a foreign-module caller can defeat without a diagnostic. Float, str and bool claims fail loudly. The local typer refuses an int base that takes a float step for exactly this reason.

Fixture: test/test_nilpy_a_bare_parameter_is_typed_from_its_call_sites.npy (value row + a PXXDBG=n.psites census row; the census is the only instrument that can see the typing).

Log