Archive — history, not state. Kept for its reasoning and its evidence; its plan is closed.

Link-time optimization — night of 2026-08-04 → 05

One section per lane. Sections are appended; nobody rewrites anybody else’s.

CP2 phase 3 + CP2b results. Each section is independent; sections are added by the agent that measured them.

Branch: cp/join-linked (from dev @ 0c6f6aacd) What landed: the join-aware type lattice (join_types, landed earlier tonight and OFF in every unit profile) now runs inside the LINKED streamline, as a link-plan switchjoin_types on pit.mcode.link-plan@1, default on for a whole-program plan, false to turn it off. It is deliberately not a unit-profile default: passflags.cm keeps the lattice off at both stages and the linker passes it as a direct override, so the lattice only runs where finalized cross-unit facts could exist.

The lattice was reused unchanged. It already consumes the linker’s facts — join_type_flow’s transfer function reads link_type_of(func, slot) for import, member_import and fact-carrying get — so the work was feeding it the linked facts and running it at the right point in the linked pipeline, not writing a new analysis. Every conservative rule stays: unreachable (null) is kept distinct from unknown (T_UNKNOWN) so a backedge cannot make a loop body optimistic; record ⊓ null = unknown; the numeric widening meet is shared with merge_backward; closure-written slots are killed at every closure call; and the whole analysis bails out (falls back to the legacy walk) above 12,000 instructions or 256 slots per function.

How this was measured

tools/link_join_census.ce (added on this branch) reproduces the seam census: it compiles a corpus to portable mcode units, resolves the real import edges between them, links the whole set under one ship/program plan twice — join off and join on — and explains every surviving is_* site with shop_tools/internal/guard_reasons. Three arms are reported:

  • unit — each unit’s own final code, ship/unit streamline, no link
  • link — ship/program link plan, join_types: false
  • join — ship/program link plan, join_types: true (the new default)
./pit tools/link_join_census.ce pit-compiler,shoplib          # all three arms
./pit tools/link_join_census.ce pit-compiler,shoplib link     # one arm, fresh heap
./pit tools/link_join_census.ce pit-compiler,shoplib join

Corpus A is pit-compiler/*.cm + shoplib/*.cm — the same corpus perf/ir_census.py uses — 50 units with 70 import edges resolved inside the corpus. Corpus B is pit-linker/*.cm, 4 units, 1 edge. Walls below are one link_result call, each arm timed in its own fresh process (running both arms in one actor inflates the second; the isolated numbers agree with the combined run to within 4%).

Guard table — corpus A (50 units, 70 import edges)

arminstructionsguardslinked-streamline wall
unit (no link)755,52387,417
link, join off748,46886,48645,764 ms
link, join on727,35482,413201,033 ms
  • link vs unit: −7,055 instructions (−0.93%), −931 guards (−1.06%)
  • join vs link: −21,114 instructions (−2.82%), −4,073 guards (−4.71%)
  • join vs link wall: +155,269 ms, +339% (4.39x)

By guard reason (guard_reasons, every surviving is_* site):

reasonunitlinkjoinjoin − link
slot_type_unknown_at_join39,42639,00636,070−2,936
param_polymorphic11,79011,46710,708−759
dynamic_read_result10,87810,85311,072+219
predicate_result_observed9,4539,4509,190−260
call_result_polymorphic8,6718,5418,213−328
fact_killed_by_call_or_invoke2,3732,3812,383+2
receiver_escaped1,7711,7561,747−9
type_unknown1,2631,2441,239−5
numeric_kind_polymorphic1,1551,1471,1470
stone_status_unknown451464469+5
param_stone_status_unknown128119117−2
import_result_polymorphic5858580

(The dynamic_read_result and stone_status_unknown rises are reclassification, not regression: a site whose reaching definitions the lattice narrowed from several kinds to one stops being blamed on the join and gets blamed on the single definition that remains. Total guards fall by 4,073.)

Guard table — corpus B (4 units, 1 import edge)

arminstructionsguardswallslot_type_unknown_at_join
unit92,1928,6163,023
link, join off91,0978,4755,957 ms2,954
link, join on90,4578,33411,556 ms2,853

join vs link: −1.66% guards, −0.70% instructions, +94% wall — the number the kickoff expected. The 4.39x on corpus A is what the same switch costs on a corpus of large functions.

Verdict — honest, and partly negative

The 12k seam guards do not die, and the reason is structural, not a tuning problem. Two independent facts say so.

1. There are no cross-unit facts to feed the lattice. On corpus A the linker reports finalized_sites: 0, link_fact_slots: 0, constant_members: 0, function_members: 0, generic_members: 396. Every one of the 396 member sites at a module seam stayed generic. CP2b level-2 finalization requires the provider’s whole main to be an exact literal stone record construction (export_summary’s fail-closed abstract execution), and almost nothing in the real tree is written that way — 5 of 50 corpus modules contain return stone(...) at all, and none of those survived the exactness test through an import edge that another corpus module actually takes. So the linked streamline currently hands the lattice exactly the same facts the unit lane had. Everything the join arm won above, it won without a single cross-unit fact.

2. Even with facts, they would not reach this guard population. Of the 39,426 sites blamed on slot_type_unknown_at_join, 0 have an import result or a function reference among their reaching definitions. The census counts the definition-kind set behind every join guard; the population is dominated by merges of purely local definitions:

reaching-definition kindssites
dynamic_read + null12,607
add + literal2,481
array + dynamic_read + is_func + null1,018
call_result + stone887
dynamic_read + null + stone673
dynamic_read + is_func + literal + null612
length + null + stone440
dynamic_read + literal + null385
…~200 further setsremainder

The canonical shape is var x = null (or a ?:/|| default) meeting a record/array read on the other arm — record ⊓ null is unknown by the lattice’s own soundness rule, and no import-edge fact can change what the other predecessor writes. The seam census’s 12,010 slot_type_unknown_at_join guards are not seam guards in the sense of “an import made them unknown”; they are ordinary local joins that happen to be counted in units that sit at a seam. Naming them the join lattice’s “exact target” was the wrong read of the census: the reason string names the shape (a join), not the cause (a cross-unit unknown).

What the lattice does buy, at link scope: −2,936 of that population (−7.5%), −4,073 guards overall (−4.71%), −21,114 instructions (−2.82%). That is a real program win and it is bigger than the per-unit measurement (+11 guards) by three orders of magnitude — the difference is that the link re-streamlines already-streamlined code over whole-program literal pools, where more slots are concretely typed and the meet has something to prove.

The cost is the problem. −4.71% guards for +339% linked-streamline wall does not clear the ruled exchange rate (~5% program win for 2x compile time) on corpus A. It does clear it on corpus B (−1.66% for +94%) only if the win threshold is lowered. The default is left on as ruled — the linked streamline runs only at ship/press time — but the plan switch exists precisely so the integrator can flip it, and this cost number should be the deciding input. Two follow-ups are worth more than tuning this switch:

  1. The lattice is quadratic-ish in function size. The 4.39x on corpus A vs 1.94x on corpus B tracks function size, and the bail-out is at 12,000 instructions / 256 slots — a much lower cap, or a per-function size-based gate, would keep most of the win at a fraction of the cost. Not measured here.
  2. finalized_sites: 0 is the real CP2b finding. Whole-program link-time member finalization currently fires on nothing in the tree. Before more optimization is stacked behind link facts, either the exactness test needs to widen or the tree’s export style needs to change; today CP2b level 2 is dead code on real input.

Changes

  • pit-linker/mcode_link.cmplan_join_types(plan); join_types accepted and validated on pit.mcode.link-plan@1; carried through canonical_link_plan; passed to compiler.streamline at the link stage; reported as stats.link_join_types.
  • pit-compiler/passflags.cm — comment only: records that the lattice is off in every profile at both stages and that the link stage drives it through the link plan.
  • shop_tools/internal/diff_runner.cm — comment only: records that the fuzzer’s linked arm now exercises linked+join because SHIP_PLAN carries no join_types key and therefore takes the on-by-default path.
  • tests/mcode_link.cm — two tests: the switch is on by default for a whole-program plan, an explicit false is honoured, a non-logical value is a plan error, the census reports which arm ran; and the join arm removes a guard the plain link keeps.
  • tools/link_join_census.ce — the census harness above.

Gates

gateresult
make cold (rm -rf .pit build cold-build pit && make)OK
./pit test run tests/mcode_link.cm18/18, 0 failed
./pit test run tests/compile.cm187/187, 0 failed
./pit fuzz 1002,238 checks, 0 failed — linked_unlinked 750/0, join_types 744/0
sh scripts/gate.sh "vm suite" ./pit test run tests/vm_suite.ceOK

Cross-unit inlining

Branch cp/link-inline, from 0c6f6aacd.

What it does

CP2b level 2 already turns a proven import member into function_ref provider_unit, function_id — a direct reference across units. Phase 3 makes that reference inlinable.

The pass runs between finalization and the linked streamline. It copies the named provider body into the consuming unit as an ordinary local function and rewrites the site to a plain function. The splice itself is then performed by the existing per-unit inliner inside streamline.

Ordering, and why. The alternative — streamline first, splice after — buys nothing without a second full streamline. Splicing before means streamline’s own post_inline_optimize round re-runs the fact passes over the spliced region, which is exactly CP2’s archived note (“consume exact types after inline splices”). Reusing the per-unit inliner also inherits its legality checks, its budget, its parent-map cache (cp/inline-cache), and its drop_dead_gets cleanup, instead of growing a second splicer beside it.

The inliner runs at link only over a unit that actually received a cross-unit body. A unit with no import to splice is left byte-for-byte as the pre-phase-3 lane left it: this is cross-unit inlining, not a blanket second round of per-unit inlining.

Legality, all fail-closed

A provider body is copied only if it is self-contained in the consuming unit:

RejectedBecause
get / put, or non-zero close slotsreads the provider’s frame; that binding does not exist in the consumer and materializing it is not cleanly expressible
nested functionits operand names the provider’s function table
import / member_importdense import ids are unit-local
further function_reffail-closed for one level; chasing is not attempted
any log regionthe channel is claimed by the provider’s unit, not this one
body larger than the budget’s import capsize
target resolves inside the consuming unitrecursion

Proxy and guarded member paths never become function_ref at all, so they are unreachable from here and stay generic by design — confirmed by the proxy-return corpus row (0 sites, 111->111 instructions, guards unchanged).

Budget (CP7)

inline_budget is now a link-plan parameter, validated and recorded in the canonical plan of every linked program:

budgethot / prefer / hard capmax inlinesimport caplink-stage inline
none00off entirely
default64 / 200 / 80020800on
aggressive256 / 400 / 2400642400on

default is exactly the per-unit inliner’s shipped heuristic, which is now read from helpers.budget rather than compiled in. An unknown budget name is a link.invalid_plan refusal, not a value to round off.

Ten provider/consumer shapes, the same finalization seam the fuzzer’s semantic_link_shapes arm carries, plus loop/multi-site rows. Totals across all ten, per budget:

nonedefaultaggressive
instructions before link994994994
instructions after link832765765
link removed162 (16.3%)229 (23.0%)229 (23.0%)
guards before / after48 / 2548 / 2548 / 25
finalized sites131313
cross-unit sites seen1010
sites imported088
sites inlined077
bodies imported / pruned0 / 06 / 56 / 5
skipped: closure capture22
skipped: all other reasons00

¹ none does not run the import pass, so it does not take the site census.

Cross-unit inlining is worth 8.1% of the linked instruction count on this corpus over what finalization alone already achieved (765 vs 832). aggressive buys nothing here: no site was blocked by size.

Per case, default:

caseinstructionsguardssitesinlinedskip
constant-member58→474→100
identity-function-member79→563→111
constant-function-member77→563→111
arithmetic-function-member99→834→210closure capture
record-member-access123→1077→510closure capture
proxy-return111→1119→900
nested-moved-member89→613→111
member-in-loop88→663→111
three-member-mixed119→925→310imported, not resolved²
two-providers151→867→133

² imported (8) exceeds inlined (7) by exactly this row: the body was copied in and the site rewritten to a direct function, but the inliner’s flow-sensitive callee resolution did not carry the fact through the array-building shape. The site still ends up direct rather than a member lookup; it just is not spliced.

Guards: unchanged, 25 after, in every budget. Cross-unit inlining removed no guard on this corpus beyond what finalization already removed. The surviving guard-reason breakdown is identical across none/default/aggressive:

reasonsites
receiver_escaped10
param_polymorphic7
import_result_polymorphic4
slot_type_unknown_at_join2
predicate_result_observed2

That is the honest headline: phase 3’s win on this corpus is instruction count and call directness, not guard collapse. The guards that survive are the ones the splice does not speak to — polymorphic parameters and escaped receivers.

The finding that matters most: the panic-tail upvalue

closure_capture is not an exotic skip. It is the dominant one on any realistic body, and it has a single cause:

every compiler-emitted guard’s cold arm reads a panic helper closure out of its own unit’s MAIN frame through a level-1 get.

function(x) { return x + 1 } is already ineligible: the is_num guard’s cold arm does get 3, <main slot>, 1 and calls it. So a body is eligible today only if it emits no guard at all — identity functions, constant returns, and little else. record-member-access (function(r) { return r.a }) fails for the same reason: the load’s cold arm.

This is CP4’s measured fat seen from a new angle, and it is the single gate on phase 3 reaching real code. Two unblockers, in order of preference:

  1. CP4.4 / cp/outline-binding — route panic tails through a unit-preloaded binding (GETUP) instead of a captured helper closure. If the tail stops being an upvalue read, the bodies become self-contained and eligible with no change to this pass.
  2. A dead-upvalue-get relaxation — the panic helpers themselves contain a get whose result is immediately overwritten (observed: ["get",0,4,1] then ["literal",0,2]). A body whose every get result is dead is upvalue-free in effect. Cheaper, but it is a second analysis with real miscompile surface and it was NOT attempted tonight, per the brief’s “skip and count, do not force it”.

Three things this pass had to fix to work at all

  1. Unreachable blocks poisoned callee resolution (pit-compiler/inline.cm). compute_resolved_callees gave a block with no predecessor the EMPTY state — the least precise element — so a run of labels left behind by proved-away guard arms became the pessimistic branch of the very join a finalized call reaches its callee through. Dead blocks are now TOP (the identity of cp_meet), computed by reachability from entry plus the disruption handler pc, and the whole precision step turns itself off if any jump target failed to resolve. Without this, zero cross-unit sites resolve. This is the one change with blast radius beyond the link lane — it makes per-unit inlining more precise too.

  2. A fully spliced import left a function nobody names. The function op that named the imported copy is not dead code to the ordinary passes (building a closure allocates), so the imported body was pinned forever and the pass was a size regression. Dead imported definitions are now tombstoned in place (no pc moves) and the unreferenced imported tail is pruned with its function operands renumbered: 5 of 6 bodies pruned on the corpus.

  3. The module binding itself. Once every finalized member is folded or spliced, the consumer’s import reads a value nobody uses. Under a program link the covering has already placed the provider in this program, so the only thing that read still did was disrupt on a covering that had not. It is stripped, and only for a unit that received a cross-unit body. This is also what makes the executable test below possible.

    Soundness note, found by that test: the first version of the dead-slot check walked only the defining function’s CFG and so missed a slot read by a child closure’s upvalue get. It is now blocked by a unit-global captured-parent-slot set. The nested-moved-member shape is exactly the case: p is captured by invoke, so its binding correctly stays live.

Debug identity

A spliced row keeps the origin’s line and column, which is the rule the same-unit inliner already follows: the provider’s sites/spans rows are copied into the consumer and re-pointed, never re-derived from consumer positions. Which unit those positions belong to is carried separately, because an mcode span has no unit field: the inliner attaches _cross_unit_inline_origins at each splice, and the linker collects them into program.cross_unit_inline_origins{unit, function, provider_unit, provider_function, provider_locator, provider_source, member}. That is the linker half of the origin map’s reserved inline chain (shoplib/debug_resolve.cm: “the origin map’s inline_chain is empty until the linker fills it”). Granularity is per splice, not per pool pc; the press-side pc-exact inline_chains column remains reserved and unfilled.

Gates

gateresult
./pit fuzz 200 --seed 4242 (mid-change)4518 checks, 0 failed
./pit fuzz 200 --seed 90210 (final)4482 checks, 0 failed (1492 opt/noopt, 1498 linked/unlinked incl. 6 shapes, 1492 join-types)
./pit test run tests/mcode_link.cm21 / 0 (was 16; 5 new)
./pit test run tests/compile.cm187 / 0
./pit test run tests/vm_suite.ce1089 / 0
./pit test suite475 / 0

Honest limit of the fuzz gate. pit fuzz’s linked_unlinked dimension links a program of ONE unit with no imports, so the random arm does not reach cross-unit inlining at all — it gates the rest of the pipeline against this change (including the resolver precision fix, which it does exercise on every program), not the new path. Building a random two-unit execution arm needs a realization state that mach_load does not provide: a two-unit pool emits fine, but entry 1’s load_bound_module raises outside a realization. Verified directly.

What covers the new path instead is test_spliced_cross_unit_program_runs_and_agrees: a consumer whose every finalized member was spliced no longer reads its module binding, so it presses as a standalone unit and runs, through the real lowering and the real VM, and its value is checked. Four shapes today. This is a golden-value oracle, not a differential one — weaker than the linked_unlinked dimension, and it is the gap to close next.

Verdict

Landable. All suites green, both fuzz runs clean, and the pass is fail-closed everywhere it is unsure. It is correct and it is measurably a win on the seam corpus (8.1% of linked instructions, calls made direct, imported bodies pruned).

But its reach is small until CP4.4 lands. With today’s codegen, a body is eligible only if it emits no guard, because every guard’s cold arm captures a panic helper. Phase 3 should land now — it is the machinery, the budget, the provenance and the accounting, all in place and gated — with the expectation that its numbers move sharply when the panic tails stop being upvalue reads.

Two items queued out of this work:

  • A random two-unit execution arm for the fuzzer. Needs realization plumbing in the harness. This is the real gate for phase 3 and it does not exist yet.
  • three-member-mixed’s unresolved site. Imported and made direct but not spliced; the callee fact does not survive the array-building shape. Likely the same class as CP9.2’s join-aware facts.

Export recognizer

Branch cp/export-recognizer, from 5fa961f52.

finalized_sites: 0 was the standing CP2b finding: import-edge finalization fired on nothing, which made level-2 finalization and cross-unit inlining dead code on real input. This section reports what was actually blocking it — which is not what the switch report said — what the recognizer now decides, and what still does not finalize.

The switch report’s diagnosis was wrong on both halves

The report attributed 99/99 generic to control_safe: “real modules branch while building their export record (platform selection, capability probes, memoization), which sets control_safe false”. Reading the corpus’s actual ship/unit mcode says otherwise.

1. Nothing reached the control_safe test, because nothing passed the test after it. The recognizer’s final gate required the returned value to be a stone_record — a record an explicit stone instruction had frozen. Real modules write return {a, b, c}. They do not write return stone({a, b, c}). That single requirement accounted for all 99, whatever control_safe said.

2. control_safe was the wrong question anyway. The guarded store lowering emits an is_stone diamond around every member store (mcode.cm:1395), and any mod.member read in a module body emits a load_field_* diamond. A monotone flag over a three-prefix label allowlist therefore rejects essentially every module, for reasons that have nothing to do with what the module’s own source does. pit-compiler/fold.cm’s whole body is 29 instructions and contains no record op at all; shoplib/path.cm builds its export with seven consecutive is_stone diamonds and no other control flow.

3. And the fact that decides the whole design: module results are not frozen. docs/spec/stone.md line 54 says “Every module’s return value is automatically stoned.” It is not. is_stone(use('shoplib::path')) is false, and a consumer’s p.zzz = 1 succeeds. MACH_LOAD_BOUND_MODULE (source/mach_vm.c) reads the value straight out of the actor’s realization result array; nothing stones it on the way, and every importer inside one actor shares that one record.

So the stone requirement was not over-strictness. It was the only thing making the exactness claim true. Removing it does not widen a conservative test — it moves the burden of proof, from the provider to the linked program.

Shape taxonomy — 99 real modules, export_summary verdicts

Corpus is plans/night-2026-08-04/corpus.txt (the shipped clerk closure), compiled at ship/unit, wired into one program with 216 resolved import edges and 1,336 import member-load sites. Harness: tools/export_taxonomy.ce (added on this branch).

verdictbeforeafter
exact export record (stone_record)054
generic9945

Why the 45 that stay generic do:

reasonunitswhat it is
return_is_not_a_literal_record17the module returns a bare function (return fold), a call result, or a value the lattice cannot name
captured_by_nested_function17a nested function reads or writes the module slot the export record lives in — the record is reachable from code this pass does not model
unresolved_jump7the module body contains a jump to a label the function does not define (see “A defect this turned up”)
no_exact_member3a record is returned but not one member of it is exact
return_unreachable1the single return is not reachable from entry under the pruned CFG

Of the 54 exact exports, 493 of their 617 declared members are exact (79.9%). Exactly one of the 54 is frozen (the provider called stone). The other 53 depend entirely on the program-side census below.

The per-member exactness rules

The recognizer is now a real forward dataflow over the module body’s CFG, run twice, and the verdict is per member rather than per record.

  1. Slot facts are joined over predecessors, not read linearly. A linear scan reads if (posix) h = a else h = b; rec.open = h as whichever arm was emitted last. The lattice meets the arms and produces unknown, so that member is not exact. Facts are literal / function-of-this-unit / deleted / record / stone-record / unknown.
  2. A member is exact iff (a) every store to it agrees on one compile-time value, and (b) it is definitely assigned — written on every path from the body’s entry to the single return. (b) is an intersection-at-joins lattice, not dominance, which is what makes “both arms assign the same function” finalizable while “one arm assigns it” is not.
  3. Members that fail are dropped individually. They no longer poison the record. A partially-exact export finalizes its exact members and leaves the rest on the ordinary guarded load.
  4. is_stone / is_record on a candidate are answered from the lattice, and the dead arm of the diamond is pruned. Without this nothing works: the is_stone guard around each member store makes the store’s block fail to dominate the merge, so no member would ever be definitely assigned. Folding is allowed only for records a first, fold-free run proved never escape — a record that reaches unmodelled code could have been frozen by it, and must not be allowed to decide control flow.
  5. Escape is fatal to the record. The candidate may only appear as the base of a store/load, the source of a move/stone, an is_*/length probe, an identity compare, or the returned value. A call argument, a push, a put into a captured cell, or being stored as another record’s field drops it. So does any get/put in a nested function naming a slot the record ever occupied.
  6. A computed member key voids the whole record. r[k] = v could have landed on a member every static store agrees about, so per-member exactness cannot survive it and the export goes back to generic wholesale.
  7. complete is separate from exact. Only an export where every stored member is exact may answer “this member does not exist” — that is what keeps link.missing_member a link error for a fully known export and a plain unknown (ordinary guarded load) for a partially known one.

The program-side census — what replaces stone

export_summary now reports frozen. A frozen export is exact on the provider’s own word. An unfrozen export is exact only if the linked program proves nobody can mutate it, which mutable_export_risk(rows) decides:

  • a reaching-definitions dataflow, per function, over which slots carry an import value (seeded at import, flowed through move/stone, killed on redefinition — a linear scan smears an import across the whole frame, because slots are reused heavily);
  • a nested function’s get off the enclosing frame seeds from the union of everything the parent slot ever held (the lexical level is ignored, which over-approximates in the direction that loses coverage);
  • safe uses of an import: reading a member, copying it, freezing it, length, an is_* probe, an identity compare, and calling it. Calling matters more than anything else here: mod.f(x) lowers to a function-proxy diamond whose first arm calls the module value itself (mcode.cm:emit_call_method), so the callee position of a call is the single most common place an import appears — 996 sites on this corpus. It cannot change what the record’s members hold.
  • unsafe: everything else — the import as a call argument, push, append, store (as base, value, or key), put, return (re-export).
  • an import row the covering did not resolve to a unit of this program is taken at the covering’s word: it is not one of these providers, so it carries no risk;
  • a function whose CFG cannot be rebuilt withholds that unit’s imports only, not the whole program.

Any provider marked unsafe goes back to generic before a single site is rewritten. On this corpus that withholds 17 of the 54, leaving 37 exact exports carrying 265 exact members into the link.

This is a precondition, and it should be stated plainly: finalizing an unfrozen export is sound only if rows is the complete set of consumers of those providers. That is already the standing assumption of the whole pass — granularity: "program", provider bodies copied into consumers, import decisions resolved against rows — but it is now load-bearing for correctness, not only for coverage. link_result still has no production caller; wiring one must supply a complete covering.

Before / after

Whole 99-unit corpus, one ship/program link, inline_budget: none:

censusbeforeafter
exact exports037
exact export members0265
exports withheld by the census17
finalized_sites0142
constant_members09
function_members0133
generic_members1,3361,194
link_fact_slots092
link_record_origins063
linked instructions1,133,4021,132,739 (−663, −0.06%)
linked guards117,056116,965 (−91, −0.08%)
link wall49.2 s50.8 s (noise)

The instruction and guard deltas are small and should be read as small: 142 of 1,336 member sites finalize, and each one removes a guarded member load, not a loop. The number that matters for the arc is that the two landed optimizations now have input at all.

Cross-unit inlining, same corpus, inline_budget: default:

censusbeforeafter
cross_unit_sites0133
cross_unit_sites_imported00
cross_unit_bodies_imported00
cross_unit_sites_inlined00
skipped: closure_capture125
skipped: size_budget7
skipped: log_region1

Phase 3 now sees real edges — and rejects 125 of 133 for closure_capture. Real module functions close over module-level state (get/put against the provider’s frame, or non-zero close slots), which the phase-3 legality table rejects by design because that binding does not exist in the consumer. That is the next question for cross-unit inlining, and it is a materialization question, not a recognizer question. The recognizer half of the arc is done; the inliner half now has a measured, specific blocker instead of “no input”.

The 50-unit seam corpus with 70 import edges resolved inside it, ship/program plan, join_types: false:

censusbeforeafter
finalized_sites04
generic_members396392
exact_exports019
exact_export_members0142
mutable_exports_withheld7
link_fact_slots07
link_record_origins05
cross_unit_sites04 (all 4 skipped: closure_capture)
linked instructions750,493750,463 (−30)
linked guards86,73386,726 (−7)

This corpus resolves only 70 import edges internally, so it is the wrong place to read the size of the effect — the 99-unit closure above, with 216 resolved edges, is. What the seam census does establish is that the linked streamline is now fed non-zero cross-unit facts (link_fact_slots 0 → 7, link_record_origins 0 → 5) for the first time, which was the precondition the join-lattice verdict in this document was written without.

What stays generic, and why

categoryverdictreason
bare-function export (return fold)genericthere is no record and no members; the import edge is a direct call, which finalization does not touch
export record captured by a nested functiongenericthe record is reachable from code the pass does not model; it could be mutated after return
member assigned on one arm onlygeneric (per member)not definitely assigned
member assigned different values per armgeneric (per member)the stores disagree
member assigned a computed valuegeneric (per member)not a compile-time value
record written through a computed keygeneric (whole record)the write could have landed on any member
record built by a helper callgenericthe returned value is a call result, not a literal record
record that escapes to a call before returngenericthe callee is unmodelled
unfrozen export any unit can mutategenericthe census withholds it — 17 units here
body with an unresolvable jumpgenericfail-closed

Two categories were considered and deliberately left generic because they could not be excluded cleanly:

  • A computed member key with otherwise-exact members. Per-member exactness is not sound under a dynamic-key store — a key the pass cannot evaluate could be any member name. Voiding the record is the only sound reading.
  • An import the census cannot attribute to a single provider. Where the holder set is empty and the use is unsafe, the pass has no provider to blame and blanks the program rather than guess.

Headroom, not taken tonight

  1. Lexical-level resolution for get. The census matches a nested get on the parent slot number with the level ignored. Building the function nesting tree from each function’s function ops would resolve the frame exactly and should recover several of the 17 withheld exports (append@2, 153 sites, is the largest remaining false-positive class).
  2. captured_by_nested_function, 17 units. Same over-approximation on the provider side: any nested get/put naming a slot the record ever occupied drops the export, including slots the record only transiently occupied.
  3. The real fix is upstream. If the compiler emitted the stone that docs/spec/stone.md already promises on a module’s returned record, the whole census would be unnecessary, every unfrozen export would be frozen, and p.zzz = 1 on an import would disrupt instead of silently mutating a record shared by every importer in the actor. That is a language-semantics decision, not a linker one, and it is left for the ruling.

A defect this turned up

archive/qop.cm, function 56 (archive_from_entries), pc 154, ship/unit mcode:

154: ["jump_false", 12, "store_bad_key_2544"]

store_bad_key_2544 is defined nowhere in that function, or anywhere else in the unit. A store bad-key panic block was eliminated and its inbound branch was left behind. Seven of the 99 corpus modules have at least one such dangling branch in the module body itself (unresolved_jump above), and enough have one somewhere in the unit that leaving the census strict about it withheld 26 more exports before it was made lenient. The recognizer stays strict (it needs sound control flow); the census treats the missing arm as unreachable, which is what it is. This is a real defect in shipped mcode and it is not this branch’s to fix — it is filed here because nothing else had noticed it.

A tested contract this branch changes

tests/mcode_link.cm::test_plain_record_return_keeps_generic_path asserted that a mutable (unfrozen) record export is never finalized. That assertion is incompatible with finalizing anything in the real tree, since the real tree exports nothing else. It is replaced by test_plain_record_return_finalizes_only_when_no_unit_can_mutate_it, which pins the sharper contract in both directions: an unfrozen export with a clean program-wide census finalizes; the same export with a consumer that assigns to the import does not, and the census records the withholding. This is the one decision in this branch that wants ratification rather than review, because it trades a blanket refusal for a proof obligation on the covering.

Changes

  • pit-linker/mcode_link.cmbuild_blocks (CFG, strict/lenient); transfer_fact slot-fact lattice with is_stone/is_record folding; taken_successors edge pruning; definite-assignment lattice (block_assignments, intersect_assigned); analyze_export_body (two runs); export_summary rewritten to a per-member verdict reporting fields / frozen / complete / reason; the program-side census (mutable_export_risk and its holder dataflow); link_result withholds unfrozen exports the census cannot clear and reports exact_exports, exact_export_members, mutable_exports_withheld; link.missing_member is now raised only for a complete export.
  • tests/mcode_link.cm — the plain-record contract replaced as above, plus four new per-member category tests (same value on both arms finalizes, one-armed member does not, computed key voids the record, helper-built and escaping records stay generic).
  • tools/export_taxonomy.ce — new: the taxonomy/census harness used for every number in this section.
  • tools/link_join_census.ce — a single-arm run no longer disrupts in its own reason table after the numbers are computed (the absent arms read as empty).
  • shop_tools/internal/diff_runner.cm — six new deterministic semantic_link_shapes rows carrying branching-export providers through the linked/unlinked fuzz dimension: same-both-arms, differing-arms, loop-assigned, helper-built, escaping-mutation, consumer-mutated.

Gates

gateresult
make (no C changed on this branch; cold build done at branch start)OK
./pit test mcode_link27/27, 0 failed
./pit test compile187/187, 0 failed
./pit test suite475/475, 0 failed
./pit test (baseline set, includes vm_suite)2,350/2,355 — the same 5 failures the dev base has with this branch stashed (mach_pool_emit ×2, network_info, recipes, shop_resolve); pre-existing, not this branch
./pit fuzz 2004,554 checks, 0 failed — linked_unlinked 1,526/0 (shapes 12/0), join_types 1,514/0, optimized_unoptimized 1,514/0 (seed 824918)

Source: plans/archive/night-2026-08-04/link-opt.md