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.
Join facts at link time
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 switch — join_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)
| arm | instructions | guards | linked-streamline wall |
|---|---|---|---|
| unit (no link) | 755,523 | 87,417 | — |
| link, join off | 748,468 | 86,486 | 45,764 ms |
| link, join on | 727,354 | 82,413 | 201,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):
| reason | unit | link | join | join − link |
|---|---|---|---|---|
| slot_type_unknown_at_join | 39,426 | 39,006 | 36,070 | −2,936 |
| param_polymorphic | 11,790 | 11,467 | 10,708 | −759 |
| dynamic_read_result | 10,878 | 10,853 | 11,072 | +219 |
| predicate_result_observed | 9,453 | 9,450 | 9,190 | −260 |
| call_result_polymorphic | 8,671 | 8,541 | 8,213 | −328 |
| fact_killed_by_call_or_invoke | 2,373 | 2,381 | 2,383 | +2 |
| receiver_escaped | 1,771 | 1,756 | 1,747 | −9 |
| type_unknown | 1,263 | 1,244 | 1,239 | −5 |
| numeric_kind_polymorphic | 1,155 | 1,147 | 1,147 | 0 |
| stone_status_unknown | 451 | 464 | 469 | +5 |
| param_stone_status_unknown | 128 | 119 | 117 | −2 |
| import_result_polymorphic | 58 | 58 | 58 | 0 |
(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)
| arm | instructions | guards | wall | slot_type_unknown_at_join |
|---|---|---|---|---|
| unit | 92,192 | 8,616 | — | 3,023 |
| link, join off | 91,097 | 8,475 | 5,957 ms | 2,954 |
| link, join on | 90,457 | 8,334 | 11,556 ms | 2,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 kinds | sites |
|---|---|
dynamic_read + null | 12,607 |
add + literal | 2,481 |
array + dynamic_read + is_func + null | 1,018 |
call_result + stone | 887 |
dynamic_read + null + stone | 673 |
dynamic_read + is_func + literal + null | 612 |
length + null + stone | 440 |
dynamic_read + literal + null | 385 |
| …~200 further sets | remainder |
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:
- 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.
finalized_sites: 0is 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.cm—plan_join_types(plan);join_typesaccepted and validated onpit.mcode.link-plan@1; carried throughcanonical_link_plan; passed tocompiler.streamlineat the link stage; reported asstats.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 becauseSHIP_PLANcarries nojoin_typeskey 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 explicitfalseis 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
| gate | result |
|---|---|
make cold (rm -rf .pit build cold-build pit && make) | OK |
./pit test run tests/mcode_link.cm | 18/18, 0 failed |
./pit test run tests/compile.cm | 187/187, 0 failed |
./pit fuzz 100 | 2,238 checks, 0 failed — linked_unlinked 750/0, join_types 744/0 |
sh scripts/gate.sh "vm suite" ./pit test run tests/vm_suite.ce | OK |
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:
| Rejected | Because |
|---|---|
get / put, or non-zero close slots | reads the provider’s frame; that binding does not exist in the consumer and materializing it is not cleanly expressible |
nested function | its operand names the provider’s function table |
import / member_import | dense import ids are unit-local |
further function_ref | fail-closed for one level; chasing is not attempted |
| any log region | the channel is claimed by the provider’s unit, not this one |
| body larger than the budget’s import cap | size |
| target resolves inside the consuming unit | recursion |
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:
| budget | hot / prefer / hard cap | max inlines | import cap | link-stage inline |
|---|---|---|---|---|
none | — | 0 | 0 | off entirely |
default | 64 / 200 / 800 | 20 | 800 | on |
aggressive | 256 / 400 / 2400 | 64 | 2400 | on |
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.
Numbers — the seam corpus (benchmarks/link_inline.ce)
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:
none | default | aggressive | |
|---|---|---|---|
| instructions before link | 994 | 994 | 994 |
| instructions after link | 832 | 765 | 765 |
| link removed | 162 (16.3%) | 229 (23.0%) | 229 (23.0%) |
| guards before / after | 48 / 25 | 48 / 25 | 48 / 25 |
| finalized sites | 13 | 13 | 13 |
| cross-unit sites seen | 0¹ | 10 | 10 |
| sites imported | 0 | 8 | 8 |
| sites inlined | 0 | 7 | 7 |
| bodies imported / pruned | 0 / 0 | 6 / 5 | 6 / 5 |
| skipped: closure capture | — | 2 | 2 |
| skipped: all other reasons | — | 0 | 0 |
¹ 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:
| case | instructions | guards | sites | inlined | skip |
|---|---|---|---|---|---|
| constant-member | 58→47 | 4→1 | 0 | 0 | — |
| identity-function-member | 79→56 | 3→1 | 1 | 1 | — |
| constant-function-member | 77→56 | 3→1 | 1 | 1 | — |
| arithmetic-function-member | 99→83 | 4→2 | 1 | 0 | closure capture |
| record-member-access | 123→107 | 7→5 | 1 | 0 | closure capture |
| proxy-return | 111→111 | 9→9 | 0 | 0 | — |
| nested-moved-member | 89→61 | 3→1 | 1 | 1 | — |
| member-in-loop | 88→66 | 3→1 | 1 | 1 | — |
| three-member-mixed | 119→92 | 5→3 | 1 | 0 | imported, not resolved² |
| two-providers | 151→86 | 7→1 | 3 | 3 | — |
² 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:
| reason | sites |
|---|---|
| receiver_escaped | 10 |
| param_polymorphic | 7 |
| import_result_polymorphic | 4 |
| slot_type_unknown_at_join | 2 |
| predicate_result_observed | 2 |
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:
- 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. - A dead-upvalue-
getrelaxation — the panic helpers themselves contain agetwhose result is immediately overwritten (observed:["get",0,4,1]then["literal",0,2]). A body whose everygetresult 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
Unreachable blocks poisoned callee resolution (
pit-compiler/inline.cm).compute_resolved_calleesgave 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 ofcp_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.A fully spliced import left a function nobody names. The
functionop 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 itsfunctionoperands renumbered: 5 of 6 bodies pruned on the corpus.The module binding itself. Once every finalized member is folded or spliced, the consumer’s
importreads 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. Thenested-moved-membershape is exactly the case:pis captured byinvoke, 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
| gate | result |
|---|---|
./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.cm | 21 / 0 (was 16; 5 new) |
./pit test run tests/compile.cm | 187 / 0 |
./pit test run tests/vm_suite.ce | 1089 / 0 |
./pit test suite | 475 / 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).
| verdict | before | after |
|---|---|---|
exact export record (stone_record) | 0 | 54 |
| generic | 99 | 45 |
Why the 45 that stay generic do:
| reason | units | what it is |
|---|---|---|
return_is_not_a_literal_record | 17 | the module returns a bare function (return fold), a call result, or a value the lattice cannot name |
captured_by_nested_function | 17 | a 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_jump | 7 | the module body contains a jump to a label the function does not define (see “A defect this turned up”) |
no_exact_member | 3 | a record is returned but not one member of it is exact |
return_unreachable | 1 | the 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.
- Slot facts are joined over predecessors, not read linearly. A linear scan
reads
if (posix) h = a else h = b; rec.open = has whichever arm was emitted last. The lattice meets the arms and producesunknown, so that member is not exact. Facts are literal / function-of-this-unit / deleted / record / stone-record / unknown. - 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. - 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.
is_stone/is_recordon a candidate are answered from the lattice, and the dead arm of the diamond is pruned. Without this nothing works: theis_stoneguard 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.- Escape is fatal to the record. The candidate may only appear as the base
of a
store/load, the source of amove/stone, anis_*/lengthprobe, an identity compare, or the returned value. A call argument, apush, aputinto a captured cell, or being stored as another record’s field drops it. So does anyget/putin a nested function naming a slot the record ever occupied. - A computed member key voids the whole record.
r[k] = vcould 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. completeis separate from exact. Only an export where every stored member is exact may answer “this member does not exist” — that is what keepslink.missing_membera 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 throughmove/stone, killed on redefinition — a linear scan smears an import across the whole frame, because slots are reused heavily); - a nested function’s
getoff 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, anis_*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 acallis 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:
| census | before | after |
|---|---|---|
| exact exports | 0 | 37 |
| exact export members | 0 | 265 |
| exports withheld by the census | — | 17 |
finalized_sites | 0 | 142 |
— constant_members | 0 | 9 |
— function_members | 0 | 133 |
generic_members | 1,336 | 1,194 |
link_fact_slots | 0 | 92 |
link_record_origins | 0 | 63 |
| linked instructions | 1,133,402 | 1,132,739 (−663, −0.06%) |
| linked guards | 117,056 | 116,965 (−91, −0.08%) |
| link wall | 49.2 s | 50.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:
| census | before | after |
|---|---|---|
cross_unit_sites | 0 | 133 |
cross_unit_sites_imported | 0 | 0 |
cross_unit_bodies_imported | 0 | 0 |
cross_unit_sites_inlined | 0 | 0 |
skipped: closure_capture | — | 125 |
skipped: size_budget | — | 7 |
skipped: log_region | — | 1 |
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”.
Seam census (tools/link_join_census.ce, pit-compiler + shoplib)
The 50-unit seam corpus with 70 import edges resolved inside it, ship/program
plan, join_types: false:
| census | before | after |
|---|---|---|
finalized_sites | 0 | 4 |
generic_members | 396 | 392 |
exact_exports | 0 | 19 |
exact_export_members | 0 | 142 |
mutable_exports_withheld | — | 7 |
link_fact_slots | 0 | 7 |
link_record_origins | 0 | 5 |
cross_unit_sites | 0 | 4 (all 4 skipped: closure_capture) |
| linked instructions | 750,493 | 750,463 (−30) |
| linked guards | 86,733 | 86,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
| category | verdict | reason |
|---|---|---|
bare-function export (return fold) | generic | there is no record and no members; the import edge is a direct call, which finalization does not touch |
| export record captured by a nested function | generic | the record is reachable from code the pass does not model; it could be mutated after return |
| member assigned on one arm only | generic (per member) | not definitely assigned |
| member assigned different values per arm | generic (per member) | the stores disagree |
| member assigned a computed value | generic (per member) | not a compile-time value |
| record written through a computed key | generic (whole record) | the write could have landed on any member |
| record built by a helper call | generic | the returned value is a call result, not a literal record |
| record that escapes to a call before return | generic | the callee is unmodelled |
| unfrozen export any unit can mutate | generic | the census withholds it — 17 units here |
| body with an unresolvable jump | generic | fail-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
- Lexical-level resolution for
get. The census matches a nestedgeton the parent slot number with the level ignored. Building the function nesting tree from each function’sfunctionops 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). captured_by_nested_function, 17 units. Same over-approximation on the provider side: any nestedget/putnaming a slot the record ever occupied drops the export, including slots the record only transiently occupied.- The real fix is upstream. If the compiler emitted the
stonethatdocs/spec/stone.mdalready promises on a module’s returned record, the whole census would be unnecessary, every unfrozen export would be frozen, andp.zzz = 1on 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.cm—build_blocks(CFG, strict/lenient);transfer_factslot-fact lattice withis_stone/is_recordfolding;taken_successorsedge pruning; definite-assignment lattice (block_assignments,intersect_assigned);analyze_export_body(two runs);export_summaryrewritten to a per-member verdict reportingfields/frozen/complete/reason; the program-side census (mutable_export_riskand its holder dataflow);link_resultwithholds unfrozen exports the census cannot clear and reportsexact_exports,exact_export_members,mutable_exports_withheld;link.missing_memberis now raised only for acompleteexport.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 deterministicsemantic_link_shapesrows carrying branching-export providers through the linked/unlinked fuzz dimension: same-both-arms, differing-arms, loop-assigned, helper-built, escaping-mutation, consumer-mutated.
Gates
| gate | result |
|---|---|
make (no C changed on this branch; cold build done at branch start) | OK |
./pit test mcode_link | 27/27, 0 failed |
./pit test compile | 187/187, 0 failed |
./pit test suite | 475/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 200 | 4,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