For Steve. 2026-08-28. A design sketch and a cost estimate, written after measuring the block and prototyping the shake outside the plug.
A day. Not an hour, not a week.
Roughly five hours at the keyboard and about two hours of box time for the ceremony that any emitter change owes. The reason it isn't a week is that the seam is already cut — I'll show that below. The reason it isn't an hour is the byte-identity gate, which is the only thing that makes the change safe and which is most of the work.
The thing I'd want you to decide before spending the day is why, because I measured the payoff and it is not what I expected. More on that at the end.
zig-prelude is a single Text in ZigEmitter.codex, one &-chained
concatenation running from line 3672 to line 3795, prepended unconditionally
to every emitted program:
in zig-prelude & types-text & defs-text & zig-main opening-entry-point (m.defs)
Measured:
| Prelude, as emitted | 37,409 bytes |
| Top-level decls in it | 93 (69 fn cx_*, plus the vars, consts, CCE tables and the CxFn1..4/CxList generics) |
| Emitted corpus | 589 files, 32.9 MB |
| Of which is 589 copies of the prelude | 22.0 MB, 67% |
| Smallest emitted program | 38,219 bytes — of which 37,409 is prelude. The program is 2% of its own file. |
That last row is the one that makes the case. manifest-subject.zig compiles
a opening that returns 0. It ships a 4 GiB bump allocator, a CCE
translation table, a deck high-water tracker and a UTF-8 encoder to do it.
I expected the hard part to be carving 37 KB of string concatenation into addressable units. It is already carved.
Every & "..." chunk in the prelude is exactly one zig top-level decl, or
exactly one comment. I ran a mechanical split over the source:
chunks=123 named-decls=93 comment-chunks=30 problems=0
Zero chunks that were half a decl. Zero chunks with two decls. Zero unbalanced braces. Whoever wrote it wrote it one decl per line and never broke the habit.
That means the restructure from
zig-prelude : Text =
"fn cx_ll_push(...) ... }\n"
& "fn cx_ll_of(...) ... }\n"
to
zig-prelude-parts : List ZigPreludePart = [
ZigPreludePart { name = "cx_ll_push", text = "fn cx_ll_push(...) ... }\n" },
ZigPreludePart { name = "cx_ll_of", text = "// Exact, not rounded...\nfn cx_ll_of(...) ... }\n" },
can be generated by a 40-line script, not typed by hand. The name comes off the front of the zig text with a regex; the comment chunks attach forward onto the decl they describe. That single fact is what collapses this from a week to a day — hand-editing 123 string literals byte-exactly is where the week would have gone.
Walk the IR, collect the set of builtins the program uses, map builtins to
prelude decls. The ZigBuiltinEmitter table (69 rows, name + emit lambda)
looks like it's begging for a third field, needs : List Text.
It isn't. Two problems:
The table isn't the only emission site. There are 51 other lines in the
emitter that name a cx_ symbol: cx_show_int from the integer show path,
cx_new from record construction, cx_print_line from zig-main,
CxList from every list type, and CxFn1..4 built by string
concatenation from an arity. Every one of those would have to be audited
and kept in sync forever.
Its failure mode is the wrong one. Miss a site and the emitter drops a
decl the program calls, and you find out at zig build time, for the
subset of programs that exercise that path. That is exactly the shape we
keep calling rot: a silent omission that only some inputs reveal.
Also: Codex is pure, so "collect the used set while emitting" means either
threading a second return value through the whole recursive emit-zig-expr,
or writing a second traversal that mirrors its shape. Several hundred lines
that must stay in lockstep with the emitter forever.
Emit types-text, defs-text and the main text first (the lets in
emit-zig-chapter already run in that order), then ask the finished text
which prelude names it mentions, close that set under the prelude's own
internal references, and emit only those parts.
The failure mode inverts. A name inside a string literal or a comment in the
emitted body is a false root — so the shake keeps too much, never too
little. It cannot drop a live decl, because a live decl's name is by
definition present in the text that calls it. CxFn2 built by concatenation
in the emitter still appears literally in the output, so dynamic construction
costs nothing.
I prototyped Route A outside the plug (shake_probe.py, in the scratchpad)
and ran it over all 589 emitted corpus files.
1. Split zig-prelude into zig-prelude-parts : List ZigPreludePart.
Generated by script from the existing chunks. { name, text }. 93 entries.
Order preserved exactly as it is today.
2. zig-prelude-used : Text -> List Text. For each part name, test
whether the text mentions it. The simplest version is 93 text-contains
calls. One detail worth writing down now: search for name & "(" for the
functions, bare name for the vars and consts — because cx_print is a
prefix of cx_print_line, and cx_print( is not a prefix of
cx_print_line(.
If 93 scans over a 2.27 MB self-hosted emit turns out to cost, the faster
version is one text-split on "cx_" and an identifier-prefix scan per
piece. Start with the simple one, measure, only switch if it shows up.
3. Close the set. Prelude parts call each other — cx_ll_of calls
cx_ll_empty, cx_concat touches cx_heap_mem, cx_print_line calls
cx_cce_to_utf8, the vtable consts reach the four bump functions. Run the
same scanner over the parts' own texts to a fixpoint. Do not hand-write
the dependency lists: 93 hand-maintained edges is a rot generator, and
computing them costs nothing on texts this small.
4. Emit. zig-prelude-parts filtered by the closed set, in original
order, concatenated. Roots are types-text & defs-text & main-text.
5. Leave zig-prelude-decls alone. The 18-name shadow list at line 66
that makes a colliding local get a _ suffix must stay static. If it became
conditional on what got included, the same source would rename a local in one
program and not in another, and the rename would stop being a property of the
plug.
Two things adjacent to this that I'd note and not do in the same change:
Tup2..Tup5 are emitted into all 589 files by the type-def path and are the
same shape of problem, and the CxFn1..4 arity bound is a related fixed
block. Separate change, same machinery, afterwards.
Build it with the shake disabled — all 93 parts, always, in order — and
require the output to be byte-identical to the current bank for all 589
corpus programs and all 13 rungs. That is the whole gate. It proves the
mechanical restructure preserved every \n and every escape, with the shake
logic held out of the picture.
Only then turn the filter on. If the disabled-shake pass is green and the
enabled pass compiles the corpus to the same results, the change is sound. If
you skip that gate and go straight to shaking, then a dropped \n in part 41
and a mis-scanned root are the same symptom and you get to bisect string
literals.
Every emitted .zig in the ladder moves, by design. That means:
ast/allcycles.sh sweep — 14/14, ~13.5 min — must stay green.corpus_run.py's "zig byte-identical" fast path)
is fully invalidated. Full transpile + verify, ~10 min plus the
compile arm.codexzig_build.sh, ~10 min, and the fixed point must still hold —
the bundle re-emitting itself byte-identically. It should; the compiler
uses most of the prelude. But it is the sharpest single check available and
it is not optional.The bare-metal truths should not move — they measure the bare-metal image, not the plug's output. If a truth moves, something is wrong and the change should stop there.
Call it two hours of box time, one compute job at a time.
Route A over the whole corpus:
CORPUS 589 files: 32,857,905 -> 19,924,505 bytes (60.6%)
Per-program, the spread is wide and it is the small end that moves:
| program | before | after | prelude decls kept |
|---|---|---|---|
manifest-subject.zig |
38,219 | 839 (2.2%) | 1 / 93 |
neg-int-parse.zig |
38,248 | 1,284 (3.4%) | 2 / 93 |
coap-block.zig |
42,595 | 16,509 (38.8%) | 32 / 93 |
sort-test.zig |
42,547 | 20,726 (48.7%) | 41 / 93 |
db-full-test.zig |
296,059 | 278,356 (94.0%) | 55 / 93 |
brotli-interop.zig |
861,168 | 842,511 (97.8%) | 47 / 93 |
Note that even the biggest, greediest program in the corpus uses 55 of 93 decls. Nothing uses all of them.
Then I compiled a shaken program against its unshaken twin, because I assumed the payoff was build cost. It is not:
sort-test, full vs shaken: binaries the same size to the byte
(10,204,600 both), program output byte-identical.coap-block fails to build both ways, with the same single error.Zig's frontend is lazy and its linker already dead-strips. Every byte we would remove, zig was already ignoring. So this buys no runtime, no binary size, and no measurable compile time.
What it does buy is two things, and you should weigh them as the whole case:
Legibility. A defect in a small emitted program is currently 800 bytes of signal in a 38 KB file. Reading emitted zig is how a lot of the finding work gets done, and right now it starts with scrolling past 813 lines of the same thing.
A sharper oracle — this is the real one. Today, editing any prelude
function moves all 589 emitted files, so the corpus byte-identity check
goes completely dark on every prelude edit. That check is one of the
ladder's better change detectors and a prelude edit blinds it. After the
shake, editing cx_text_split moves only the programs that use
cx_text_split, and the 564/564 signal stays live and says something
specific.
zig-prelude-parts — 1 hzig-prelude-used + the fixpoint + wiring — 1.5 h, including the usual
tax for CDX1070 (no multi-line applications; let..in)The failure that would blow past a day is the byte-identity gate failing for a reason that isn't obvious, and the mitigation is entirely upstream of it: generate the restructure, don't type it.
The thing that would make this a week is choosing Route B.