One shadow, two populations
The census
19 units under lib/rtl and lib/pcl carry a CPython stdlib module name:
ast atexit base64 collections configparser html http io json math
pathlib platform random re subprocess tempfile tkinter types zlib
Some of those are deliberate and good — math is auto-used by NilPy, and the
resolver comment names lib/rtl/re.pas as a unit that is supposed to win.
This ticket is not "rename them". It is that winning and then binding nothing
is not winning, and that two separate resolution steps are being pre-empted.
Arm 1 — a relative import is defeated, and it is lekkerzeilen's seam
lekkerzeilen/bindings.py:43 is from . import platform, and lib/rtl/platform.pas
is the PAL facade. The reported wall is bindings.py:117:
error: no member KEY_ESCAPE came of the qualifier platform
Reproduced with no lekkerzeilen code at all:
pkg/__init__.py (empty)
pkg/platform.py KEY_ESCAPE = 27
pkg/sub.py from . import platform
print("KEY_ESCAPE =", platform.KEY_ESCAPE)
→ pascal26:2: error: no member KEY_ESCAPE came of the qualifier platform
Positive control — the same four lines, module renamed seam:
pkg/seam.py KEY_ESCAPE = 27
pkg/sub2.py from . import seam
print("KEY_ESCAPE =", seam.KEY_ESCAPE)
→ ok: sub2 … KEY_ESCAPE = 27
So relative imports are not broken. The name is shadowed. The control is drawn from the population the question is about — same construct, same package, one character of difference that is the hypothesis — and it is the asymmetry that makes it a control rather than a second subject.
The fix for this arm has no design fork in it. from . import X names a
module relative to this package. No unit in lib/rtl can be that module, ever,
under any precedence policy. So for pyRelLevel > 0 the unit lookup must not
run at all. That is the whole of arm 1, and it unblocks platform/ — which the
lekkerzeilen census has as the gate on bindings, app, gfx, capture,
__main__ and the seam's own __init__.
Arm 2 — a mimic shim is unreachable under a colliding name
import zlib compiles (ok:, and the unit is linked in — 1.28 MB binary), and
then nothing is bound:
zlib.decompress(...) → no member decompress came of the qualifier zlib
zlib.InflateZlib(...) → no member InflateZlib came of the qualifier zlib
from zlib import InflateZlib → undefined variable (InflateZlib)
pasparser_proc.inc:6500 states the order in its own comment:
The mapping is consulted only after every ordinary lookup has failed, so a real unit of that name (lib/rtl/re.pas, a sibling .py) always wins and Pascal
usesis untouched.
Correct as designed, and it means a mimic_zlib written tomorrow would never
be consulted. No shim currently collides (measured: the 21 existing
mimic_* names and the 19 colliding unit names are disjoint), so nothing is
broken today — this is a trap that springs the first time someone shims a
module we already have a unit for, which is precisely what the owner's
instruction to craft shims makes likely.
The recommended precedence, with the machinery already in place. The
frontend already records why a uses exists: PyImportLang, added at
pyparser.inc:38273, whose comment says it exists so the rule is "keyed on
something the parser KNOWS instead of on a predicate that merely correlates
with it." A NilPy import statement asked in Python and should get Python
semantics, so it should prefer mimic_<name> over a same-named Pascal unit;
a Pascal uses is untouched, because it is not an import statement. The door
to the unit stays open and is the one this ticket measured through:
import 'zlib.pas' as z # works today — binds InflateZlib
This half is a precedence change with a visible blast radius (the 19 names),
so measure it per name rather than asserting it: for each, does any .py in
the tree or the corpora import it expecting the unit?
And the Pascal door is half-open anyway
Through the quoted import, InflateZlib binds — and then refuses every
argument shape NilPy can construct:
| call | result |
|---|---|
z.InflateZlib(src, dst, err) with src = bytes([...]) |
no overload matches |
z.InflateZlib(bytearray(src), dst, err) |
no overload matches |
z.InflateZlib(list(src), dst, err) |
no overload matches |
ok, dst, err = z.InflateZlib(src) (return-lifted) |
no overload matches |
It wants hashing.pas's TByteArray = array of Byte — a Pascal dynamic array,
and NilPy has no spelling that builds one. (Note for whoever touches this:
sysutils.pas declares a different TByteArray = array[0..32767] of Byte
and says so in its own comment — two types, one name.)
So even with the name resolved, a Python-side caller cannot reach the unit.
This is the argument for the shim being Pascal-side, which is exactly what
mimic_struct.pas says about itself: the resolver does not care whether it
finds mimic_X.pas or mimic_X.py, and the one to pick is whichever language
can do the job. Here, only Pascal can hold a TByteArray.
Follow-on, not part of this ticket
A mimic_zlib.pas exposing CPython's decompress(data) -> bytes over
InflateZlib is the thing neo-dd actually wants, and it is blocked on arm 2.
Filed separately so this one stays a compiler bug:
[[feature-n-mimic-zlib-gives-nilpy-cpythons-decompress-over-the-rtl-inflater]].
Note for that work: lib/rtl/zlib.pas only DEFLATES as stored blocks
(DeflateZlibStored — valid zlib, no compression), so compress can be honest
about round-tripping and must not claim a ratio.
math is the counter-example, and the whitelist must not break it
Measured 2026-09-10, because neo-dd predicted from the export lists that math
would be the worst case — 21 of ~30 lekkerzeilen modules import it, and a
case-insensitive Pascal bridge would make nine of seventeen names silently work
while atan2→ArcTan2, log→Ln and radians→DegToRad failed scattered
across twenty-one files. That prediction is wrong, and it is worth recording
why, because the reasoning was sound.
math does not go through a naive name bridge. It has a deliberate
compiler-provided shim layer, and the layer says so in its own diagnostic:
math.atan2is a compiler-provided shim and can only be CALLED, not taken as a value — the call site adds the domain, overflow and arity handling that make it match CPython
All seventeen names neo-dd listed are reachable, under their CPython
spellings, including the six they expected to need renaming. Ten called and
diffed against CPython: eight byte-identical, and tan and exp differ in the
last digit only (1 ULP on a transcendental — Track F by definition, and the
goal file's rule is the value stored in its declared type, not the intermediate).
math.pi is a float and not a function, despite math.pas exporting Pi as a
function.
Two consequences for the fix. First, a preference table must leave math
alone — it is already routed correctly and is the model for what the others
should look like. Second, my own first probe of these names manufactured a
failure: I took them as values (x = math.atan2), which is the one spelling
the shim refuses, and read five "absent" names off it. A caller writes a call.
Probe the construct the consumer actually writes.
The population this fix serves, measured
lekkerzeilen's whole runtime import surface is 14 stdlib modules and no third-party anything (neo-dd, 2026-09-10). Each probed with a characteristic CPython call at 546d4dcbd305:
| module | state | door |
|---|---|---|
collections json math os sys time |
works | native / compiler shim |
array queue struct urllib |
works | mimic_array mimic_queue mimic_struct mimic_urllib_parse |
zlib |
broken — collision | system C header (zlib.h) |
sqlite3 |
broken — collision | system C header (sqlite3.h) |
threading |
broken — absent | no header, no unit, no shim |
ctypes |
broken — absent | ditto, and a different animal |
So 10 of 14 work, and of the four that do not, two are this ticket and two
are not. threading already has [[feature-n-the-threading-module]] at p90.
Do not let this ticket be read as gating all four.
platform — the seam — is a fifth collision and takes the Pascal door, not
the C one, because no platform.h exists.
Why the C-header door survived: it is right by accident for sqlite3
The soname is derived from the header's stem, and that is wrong in general:
| header | derived | real | outcome |
|---|---|---|---|
zlib.h |
libzlib.so |
libz.so |
dead at exec |
sqlite3.h |
libsqlite3.so |
libsqlite3.so |
loads |
One rule, one module where the stem happens to be the library name and one
where it is not. sqlite3.sqlite3_open and sqlite3.sqlite3_libversion bind
and would run; zlib.crc32 binds and would not. That is very likely why the
derivation is still in the tree — the case anyone tried first worked. The
soname half belongs to frankH's existing derived-soname ticket (the SDL2
blocker) and has been relayed there with this as the smaller repro; it is not
part of this ticket, and fixing it does NOT give a Python caller CPython
semantics — C compress still takes four arguments to CPython's one.
THE RULE, FROM THE OWNER, 2026-09-10 — and Pascal already obeys it
"our import rules are like. native language first. built-in and rtl libraries first. so zlib.pas has prio over /usr/include/zlib.h in case we specify 'zlib' in pascal or python.."
So this is a straightforward bug against a stated rule, and it is narrower than the rest of this ticket says. Measured at 546d4dcbd305:
| spelling | resolves to | verdict |
|---|---|---|
Pascal uses hashing, zlib |
lib/rtl/zlib.pas — InflateZlib runs, answers zlib stream too short on empty input |
correct |
NilPy import zlib |
/usr/include/zlib.h — zlibVersion binds from libzlib.so |
violates the rule |
The Pascal path is already right. Only the NilPy import path puts a system C
header ahead of our own unit, so the fix is in that path and the blast radius
is NilPy imports, not uses.
THE RULE IS A MEANS; PREDICTABILITY IS THE END (owner, same evening)
"well, my ruling is not absolute. it's because mixing languages creates a big mess where no-one can predict the outcome."
Record this above the ordering, because it is the test that survives when the ordering is argued about. The question to ask of any resolution change is not "is this the right precedence" but "can a reader of the import site predict what it binds?" An ordering is one way to buy that; it is not the goal, and the owner has said in his own words that it is not absolute.
Measured, and it is worse than the language mixing he described — the outcome depends on the MACHINE, not only on the languages present:
import zlib, native |
binds /usr/include/zlib.h |
import zlib, --target=i386 |
binds /usr/include/zlib.h |
import zlib, --target=wasm32 |
binds /usr/include/zlib.h |
import zlib on a box with no zlib.h |
would fall through to lib/rtl/zlib.pas |
So a wasm32 build resolves a Python import against this Linux host's C
headers and derives libzlib.so for a target that has no shared libraries at
all. (riscv32 and xtensa could not be measured — they refuse earlier, on a heap arena needs mmap, which is upstream of resolution.)
Nothing at the import site distinguishes any of these rows. The same four
characters bind a Pascal unit, a C header or a Python shim depending on what is
installed on the box doing the compiling — which makes the source's meaning a
property of the build host. That is the unpredictability to fix, and an ordering
that consults the host's /usr/include for a cross target is not fixed merely
by being reordered: a bare Python import should not reach the host's C headers
at all, on any target.
The cross rows are also the cheapest positive control for whoever takes this: a wasm32 build binding a host glibc header cannot be correct under any precedence policy, so a fix that still does it has not worked.
What "ours first" means for a PYTHON import
lib/rtl/mimic_* are RTL files — the RTL's own Python face, not a third-party
fork — so a mimic IS "built-in and rtl". For import zlib the order that
satisfies the rule and also produces something callable is:
mimic_zlib— ours, CPython-shapedlib/rtl/zlib.pas— ours, Pascal-shaped (and in practice unreachable from NilPy: it wantshashing'sTByteArrayand NilPy has no spelling for one)- never the system header for a bare name
Rung 2 being effectively dead from Python is not an argument against the order —
it is why the mimic has to exist. The explicit door to the unit stays
import 'zlib.pas' as z, which works today.
The one case the rule does not cover, decided rather than escalated
from . import platform carries a leading ., which is the program scoping the
name to ITSELF. "Built-in and rtl first" is a rule for resolving a bare
name; a dotted-relative import is not one. So the program's own module wins
there, independent of this ordering — otherwise lekkerzeilen's seam stays broken
by a rule written about something else. Recorded as decided, not asked, because
it follows from what the spelling means; relayed to the owner for contradiction
in the same breath. If he reads it the other way, arm 1 of this ticket becomes
a Track U question and lekkerzeilen's platform/ package needs renaming
instead — which is the consequence to weigh, and it is a rename in a consuming
program rather than a compiler change.
ARM 1'S MECHANISM IS WRONG AND ITS POPULATION IS TWO, NOT NINETEEN — frankZ, 2026-09-11, compiler fe40bf55e141
Not a criticism of the ticket, which is the reason any of this was findable. But whoever takes arm 1 should not start from its stated cause, because I did and it cost a rebuild-and-measure cycle to find out.
THE DOT IS IRRELEVANT. Arm 1 says "a relative import (pyRelLevel > 0) must
never consult the global unit namespace at all — the program said .". That is
a true statement about intent and it is not this bug. A bare sibling import
fails identically:
dir/platform.py + dir/main.py: import platform -> no member MARKER
dir/pathlib.py + dir/main.py: import pathlib -> sibling wins, 27
Same directory, same shape, no package and no dots. I implemented arm 1 as
written — closed the Pascal chain and the host-header chain for
pyRelLevel > 0 — rebuilt, and it measured as NO CHANGE on every name I
could construct, before and after, with the fix stashed and restored. Dropped
rather than landed: a narrowing no probe can distinguish is speculative code.
THE POPULATION IS TWO. The nineteen is a count of NAME collisions, not of
the symptom. Measured, all nineteen, each with a sibling <name>.py defining
MARKER = 27:
| result | names |
|---|---|
| sibling wins, correct | ast atexit base64 collections configparser html http io json math pathlib re subprocess tempfile tkinter types zlib — 17 |
| shadowed | platform, random — 2 |
And the two fail differently, so they are probably not one mechanism:
platform no member MARKER came of the qualifier platform
random undefined variable (random)
PyRtlUnitServesPython does not explain it either, which is the tell that sent
me looking elsewhere: platform is NOT in that list and random IS, and both
misbehave; pathlib is in it and zlib is not, and both are fine.
WHAT THE EVIDENCE POINTS AT INSTEAD — a hypothesis, stated as one
The door is the already-compiled guard, not the chain. ParseUsesUnit
scans CompiledUnitKey for the name and takes an early exit before any .pas /
.py / header probe runs. Nothing in the chain can be reordered to beat it.
What makes platform different from the seventeen: the RTL pulls it in
itself. grep for units naming it in a uses clause —
platform 25 base64/http/json/math/random/zlib 2-3 the other 13 0
lib/rtl/platform.pas is the PAL facade and baseunix.pas and classes.pas
are among the 25, so it is compiled in essentially every program before the
program's own import is parsed. platform.PAL_STDOUT binds and prints 1,
which says the import reached the Pascal unit; pathlib.PAL_STDOUT does not.
Not established: that the guard is where it is decided, and nothing about
random, whose message says the name did not bind as a qualifier at all rather
than binding to the wrong thing. Both want the same next step and it is one
step: print what ParseUsesUnit resolves for these two names, rather than
inferring it from which members bind.
THE COROLLARY THAT MATTERS FOR RANKING
If the door is "already compiled", then the fix cannot be a precedence table in the general case — the owner's ruling (a hardcoded per-module preference table) is about arm 2 and stays right for arm 2, but a table consulted in the chain never runs for an ambient unit. Whatever lands has to act at or before the guard.
THE CORPUS SHAPE, ISOLATED, WITH ITS OWN CONTROL IN THE SAME RUN
lekkerzeilen's platform/ is a package DIRECTORY, not a flat module, and that
was worth checking separately rather than assuming:
# pkg/__init__.py
from . import platform # pkg/platform/__init__.py: KEY_ESCAPE = 27
from . import seam # pkg/seam/__init__.py: KEY_ESCAPE = 27
print("pkgdir", platform.KEY_ESCAPE, seam.KEY_ESCAPE)
CPython: pkgdir 27 27. pxx: no member KEY_ESCAPE came of the qualifier platform, with seam — the identical package one line down — fine. The
control is inside the run, so it cannot pass by having been dragged in by
something the failing half needed.
ARM 1 IS FIXED, AND THE POPULATION IS FOUR OF 117 — frankZ, 2026-09-11, fe40bf55e141 -> cb278748efbf
Arm 2 is untouched and this ticket stays open for it. The C-header door, the per-module preference table the owner specified, and the half-open Pascal door are all exactly as described above.
The door, confirmed by building it
The hypothesis banked in ea5fda6ba held. It is the already-compiled guard
in ParseUsesUnit, not the resolution chain: the routine scans
CompiledUnitKey for the name and returns before any .pas, .py or header
probe is attempted, so no reordering of those probes can beat it, and
platform.pas is named in the uses of 25 RTL units. The first attempt —
closing the Pascal and host-header chains for pyRelLevel > 0, which is what
the "Arm 1 is unambiguous and narrow" sentence in the summary prescribes —
measured as no change and was dropped, not landed.
The fix records WHICH compiled unit matched (compIdx) and clears isCompiled
when a NilPy import whose Pascal door is closed hits an already-compiled unit
whose own file is Pascal source. Keyed on the compiled unit's FILE, so a .py
module already compiled under this name still short-circuits exactly as before.
The population, and it corrects BOTH numbers this ticket has carried
The census above says nineteen; my own section at line 304 says two. It is
four, measured over all 117 lib/rtl unit names rather than over a
hand-picked subset of CPython-shaped ones — each name given a sibling module
defining MARKER = 27, each row in its own directory so no earlier row could
supply what a later row needed:
before fe40bf55e141 112 OK / 5 walled
after cb278748efbf 116 OK / 1 walled
moved platform platform_types textfile typinfo
Not random, which walls before and after with undefined variable (random) — a different message, a different mechanism, and it must not be read
as part of this population or as a regression of this fix.
The reason the nineteen number was wrong is the reason the mechanism was wrong:
113 of the 117 were already correct, because their Pascal door is closed for
a NilPy import in the first place. Only a unit the RTL drags in ANYWAY ever
reaches the already-compiled guard, and that is a much smaller set than "shares
a name with a CPython module". textfile and typinfo are in it and are not
CPython module names at all — they were invisible to every census this ticket
ran, because every one of them selected on the hypothesis.
What it moves on the corpus, stated before the re-run
Predicted: bindings.py stops naming no member KEY_ESCAPE came of the qualifier platform and advances to platform/__init__.py:95 undefined variable (_pxx); nothing else moves; modules-compiling delta 0. Matched.
So this clears the wall the summary called "a wall on the top-ranked target" and delivers zero modules, because [[bug-n-a-module-bound-by-an-import-is-not-a-value]] is directly behind it — which the summary's own caveat predicted and asked the fixer to re-measure rather than assume. Both walls were real; this was the outer one.
Other rows moved in the same census and are not mine — app.py,
__main__.py and gauges.py came off threading and zlib walls through
other seats' work in the same range.
Test: test/test_nilpy_a_same_named_rtl_unit_no_longer_answers_a_relative_import.npy
with test/nilpy_rtlshadow/, package and flat spellings, seam and probe as
same-file controls with non-colliding names. Positive control measured against
pin 095ef4811a5b.
RE-MEASURED AT HEAD 2026-09-20 (frankb-8e) — EVERY LIVE ROW WORKS, THE OWNER'S SPECIFIED FIX IS BUILT, AND WHAT IS LEFT IS TWO UNSPRUNG TRAPS — ONE OF THEM NOW GUARDED
Compiler f94c2a7e2396d2be, pxx HEAD a852664bb. Oracle: CPython 3.14.4. Re-measured rather than read, because this ticket has carried a hazard block since 09-10 and a warning decays like a lock: silently, in the direction of doing nothing, for as long as it is trusted.
ARM 2's LIVE ROWS, ALL PASSING:
| row | pxx | CPython |
|---|---|---|
zlib.compress / zlib.decompress round trip |
True |
True |
zlib.InflateZlib as a member |
binds | — (Pascal-only) |
from zlib import InflateZlib |
binds | — |
zlib.decompress(b"\x78\x9c\x00") (short stream) |
zlib.error |
zlib.error |
THE CONCRETE MISBEHAVIOUR ROWS THIS TICKET LISTED ARE DISCHARGED. import random compiles (the body records undefined variable (random)). platform, textfile and typinfo now give a loud, explanatory refusal that names the remedy — "platform is the Pascal unit .../lib/rtl/platform.pas, not a Python module — a bare NilPy import resolves to Python (.py/.npy) only. To reach the Pascal unit, name it with its extension: import 'platform.pas' as platform". That is the correct answer for a unit that is not a Python module, and it is the opposite of the silent wrong binding this ticket was filed about. math, re, json, base64, html all compile.
THE PER-MODULE PREFERENCE TABLE THE OWNER SPECIFIED IS BUILT, AND THIS TICKET DOES NOT SAY SO. His 2026-09-10 ruling was "and importing preference whitelist we can just hardcode. 'zlib? -> rtl zlib unless...'". That is PyRtlUnitServesPython (compiler/pasparser_proc.inc), 18 entries: ast atexit base64 collections configparser html io json markdown math pathlib pil random re subprocess tempfile tkinter zlib. zlib entered it in 80d71d782 ("zlib round-trips, and import zlib stops binding the host header"), which is what moved the rows above.
AND THE BODY OF THIS TICKET IS STALE ABOUT IT. The section above says "pathlib is in it and zlib is not, and both are fine" — true when written and false since 80d71d782. It is left in place as the 09-10 report it was, flagged here rather than edited, because it is the reasoning that correctly sent its author to ParseUsesUnit and the record of that is worth more than a tidy line.
THE MIMIC-SHIM PRECEDENCE TRAP IS STILL UNSPRUNG — re-censused with the populations stated, since a bare count is not re-derivable. Measured from the filesystem, not a list: 163 .pas under lib/, of which 23 are mimic_* shims and 137 are unit names, plus the 18 curated names above. Collisions: 0. (The 09-19 row recorded 34 and 135 against my 23 and 137 — both are carried, neither replaces the other; that census counted something else, most likely module names a shim serves rather than shim FILES, and the disjointness verdict agrees either way.)
THE HALF THAT NOW HAS AN ALARM. A trap with a named spring condition and no detector is a hazard block waiting to go stale, so it got a detector: tools/mimic_shadow_check.py, wired into tools/gate.sh quick. It asserts that no mimic_X.pas is shadowed by either ours-first substitution — a same-named unit, or a PyRtlUnitServesPython entry — because --no-shims lifts both and checking one would pass a tree where the other hides it. Its criterion is set DISJOINTNESS and it records no baseline count, deliberately: a guard that pins "23 and 137" reds on the next unit anyone adds, for no defect, and a guard that cries wolf on its first outside run teaches that it can be ignored. --selftest proves it can fail in both directions against the real code path — its first version asserted against a local copy of the comparison, which would have kept printing PASS had the checker itself broken.
WHAT IS LEFT, AND WHO OWNS IT:
- The routine-level collision hazard — UNSPRUNG AND UNGUARDED, and it stays here. A Pascal routine whose name collides with a Python one, whose signature ACCEPTS CPython's argument shape, and whose SEMANTICS differ, returns a wrong value with no diagnostic. Measured 09-19 over 74 outcome comparisons: zero silent divergences, every failure a loud compile-time refusal.
Ldexpis the near miss — two of the three properties, saved by the third. My guard does not reach this: it is module-level, and this is routine-level. - The host-C-header fallthrough is NOT this ticket's: [[bug-n-a-bare-nilpy-import-falls-through-to-a-host-c-header-of-the-same-name-and-says-nothing]], p35, open. The compiler's own comment at
PyRtlUnitServesPythonroutes it there too.
RE-RANKED 85 -> 30, AND THE REASON IS THE SAME MECHANISM I SPENT THE DAY ON. The 85 was set when arm 1 was a wall on the top-ranked target. Arm 1 was fixed 2026-09-11 and arm 2's deliverable landed in 80d71d782; nobody re-ranked, so the number outlived both arms and sat at the top of Track N pointing at two traps with measured-zero populations. This is a third field in one day where partial closure left a mechanical record true-looking and stale — after a blocked-by edge on the lekkerzeilen umbrella and an inherited effective_prio (d4ce89f5a). The summary here was exemplary throughout and did not prevent it, exactly as in those two: the summary is what READERS read, and prio: is what the RANKER reads.