← board

NilPy const_str bypasses both the literal fast path and the call-arg funnel

Nothing is wrong today. This is the reason ten duplicated backend arms have to stay, which is what makes it worth a ticket.

The shape

x = "a" * 3        # NilPy: reaches a backend frozen-arg arm
y = "ab"; x = y * 3   # does not
3: const_str a=7049 b=43 tk=4     <- NilPy literal, tagged tyString (FROZEN)
4: arg      a=3               tk=4
5: call     a=-44 b=4

The same two calls in Pascal (P('literal'); P(s);) reach neither arm: the literal takes EmitStaticLitHandle, the variable takes IRLowerCallArg.

IRLowerCallArg's frozen->managed arm excludes ASTKind[argAST] = AN_STR_LIT deliberately, because routing a literal through a heap temp is a pessimisation. That exclusion names a PASCAL AST node. A NilPy literal never becomes one, so it falls through to whichever backend arm is next — and those arms convert it correctly, which is why NilPy is green.

What that costs

Five arms are PROVEN live by a canary build (2026-09-03, tip 8cd3d6eb4): each was replaced by an Error carrying a per-site id and the non-Pascal corpus was swept.

arm fires population
x86-64/direct 61 30 distinct .npy sources
x86-64/ordered-predicate 60 same, -O3 only
i386/direct 120 same
arm32/direct 120 same
aarch64/direct 120 same
riscv32/direct 0 of 1932 compiles — vacuous
xtensa/direct 0 of 0 compiles — entirely vacuous
x86-64/ordered, /ctor, /method-indirect 0 5092 compiles

riscv32 and xtensa are silent because NilPy cannot build for them at all (bug-a-nilpy-on-cross-targets-four-remaining-walls): xtensa answers a heap arena needs mmap, which bare metal has not. Their silence is not evidence. Given i386/arm32/aarch64 all fire 120 times on the same sources, expect both to become live the moment that ticket clears.

Deleting the arms is measurable damage, not a theoretical risk: with all ten removed the self-host fixedpoint still held and gate.sh quick went RED — test/quick_canary_nilpy.npy fell from total ok 36 / 36 to ok 23 and segfaulted.

The fix

Normalise the frontends, not the backends: give a NilPy constant string the same treatment a Pascal AN_STR_LIT gets, so the literal fast path claims it and IRLowerCallArg remains the single funnel for everything else. C, Rust and Zig fired nothing in this sweep, but they were not shown to PRODUCE this shape either — check what each frontend emits for a constant string before assuming Pascal and NilPy are the only two answers.

One asymmetry noticed and NOT constructed

aarch64, riscv32 and xtensa guard their arm with (not Procs[procIdx].Params[nArgs].IsRef); i386 and arm32 do not. If a var AnsiString parameter can receive a frozen argument, those two would convert where the other three pass the slot through. I did not build that case — the guards may be unreachable on both. Construct it before treating this paragraph as a defect.