Working state — a note taken while the work happens, not a specification. The system as it is meant to be is in Architecture.

B3 — what the builder live-set diet cut, and where the floor is

Lane arc/builder-diet, base dev @ 8791b2c91, 2026-08-05.

Measurement instrument: tools/builder_diet_probe.ce, an in-process harness that use()s pit-compiler::compiler directly. Anything realized through the daemon runs the SEEDED compiler, so a daemon-lane reading describes the compiler the daemon was built from, not the working tree (plans/carried.md, “the seeded-compiler observation gotcha”).

Live figures are max(new_used_bytes) over the probe actor’s gc ladder (tools/diet_gc_report.py over .pit/log/observe.jsonl). The copying collector reports exactly the surviving bytes there, so it is the only honest live reading a running actor produces. The probe ends with a settle step — churning short-lived garbage until one more collection fires — because the finished unit is assembled after the last natural GC and was otherwise never sampled.

Calibration unit: pit-compiler/streamline.cm, ~192 KB, 109 functions, ~105k instructions, 6.8 MB canonical mcode.

The numbers

Full builder shape (compile + canonical.encode, which is what shop_store.stage_mcode_unit does):

dev 8791b2c91arc/builder-diet
peak live72.2 MB45.7 MB
peak GC capacity256 MB128 MB
peak physical heap block512 MB256 MB
bytes allocated1,420 MB842 MB

Compile only, no canonical encode:

devbranch
peak live72.2 MB37.7 MB
GC time632 ms179 ms

What was cut

  1. The token stream and the AST stopped riding every stage envelope. analyze_result_from / mcode_result_from / compile_result_from carried tokens, ast and analyzed forward from parse to portable emission. No reader in this tree ever read them off a stage result — every in-tree caller that wants raw tokens or a pre-fold AST calls parse_result directly. Each stage now releases the form it consumed: the ~6 MB token stream goes at fold, the AST goes at emission (BEFORE streamline, which is 87% of a compile), and mcode_unit_result renders its claims up front so the analysis result dies with the AST.

  2. portable_body(code, consume) drops each semantic function’s canonical rows once its portable function exists. Every positioned instruction becomes a fresh stripped row plus a site row, so without this the located rows and their portable descendants were both live for the whole unit. consume is opt-in; link-time re-encoding through portable_unit_from_code is unchanged.

  3. stream_ir.decode() no longer populates the row cache. Decode is a bulk walk — the caller already holds every row it built — so caching pinned a complete canonical copy of the function to the stream itself. row() still caches, which is where a cache pays.

  4. panic_outline stopped holding the whole unit decoded. It scanned every function up front and retained each one’s rows until the splice pass at the end — even for the common unit with no panic site at all. Combined with (3) that was the “canonical rows and interned stream coexist” finding, literally. The scan now returns sites only.

  5. panic_outline.replace_sites was O(sites x rows). It rebuilt the entire row array once per site, three fresh arrays each, with the intermediates live inside the nested calls — the largest single allocator in a compile per the gc allocation-site records. Sites are ascending and disjoint by construction, so the splices apply in one forward pass.

  6. The interned IR’s dead source/dirty passthrough is deleted. CP1’s review flagged it; source is never assigned anywhere, so row()’s first branch was unreachable and the dirty array guarding it was write-only — one 8-byte parallel array per instruction, doubled by array growth.

Byte identity

tools/diet_bytes_diff.py over a 144-unit corpus (pit-compiler, shoplib, pit-linker, pit-shop, lang, lang-endowments, logger, std, net, http, shop_tools), compiled in-process at both revisions and compared by canonical.hash of the pit.mcode.unit@3:

140/144 byte-identical. The 4 that move are the 4 files this lane edited, whose canonical mcode legitimately changes because their source changed. Zero unexpected moves, zero compile failures, at every intermediate commit as well as at the tip.

Where the floor is — the target is not reachable this way

The proposal’s target was ≤ 15–20 MB live. It is arithmetically out of reach while mcode_unit_result returns the unit as one pit value:

  • $vm.value_footprint of the finished unit: 32.1 MB in 349,250 objects, for a 6.8 MB artifact — a 4.7x expansion that is pure record/array/text header overhead. sites alone is ~71k records plus ~105k id texts.
  • The canonical encode adds the output blob on top. blob.make(1024) starts at 128 bytes and doubles, so the final buffer holds up to 2x the used bytes, and stone(out) copies alongside it.

45.7 MB peak = 32.1 MB unit tree + 6.8 MB stoned blob + ~6.8 MB of blob over-allocation. There is essentially no pipeline residue left in the peak — what remains is the artifact and its encoding. Getting under 20 MB requires either a smaller in-memory artifact shape or a streaming encoder, and E4/E9 settled the encoder (byte identity is law), so neither is B3’s to take.

Remaining holders, with evidence

  • The unit value tree, 32.1 MB. Irreducible without changing what a builder produces or how it hands it over.
  • The canonical encode buffer, up to ~6.8 MB of over-allocation. blob_grow doubles (pitlib/blob.c:29). A growth policy change would halve it, but it is a shared primitive and belongs with whoever owns blob, not here.
  • Encode churn: 168 MB allocated to produce 6.8 MB. write_value calls nota.encode per scalar, one fresh blob each, ~350k of them. Churn, not live — it drives GC frequency during the encode, not the block size.
  • The 2.5x-rounded-to-a-power-of-two capacity policy and the one-GC physical shrink lag are why 45.7 MB live still holds a 256 MB block. That is B4, and after this lane it is the larger of the two remaining factors: a 45.7 MB live set justifies 128 MB of capacity and is holding twice that.

B5’s prerequisite, restated with the new number

plans/proposal-notes/builder-memory.md §4.4 computed that an unbounded fleet on a 4 GB budget over the 77-unit pit-shop/clerk plan needs ~53 MB physical per builder, i.e. an 11–21 MB live peak. This lane reaches 45.7 MB live / 256 MB physical on the WIDEST unit in the tree. B5 therefore still needs B4, and it needs the bound to be bytes rather than a count — a 500-unit closure blows any fixed per-actor number regardless.

Source: plans/proposal-notes/builder-diet-b3.md