Second of two iterations delivering spec 0060's mir.3 row (plan docs/plans/0118-mir.3b-marg-mode-and-table-delete.md). lower_to_mir's Term::App arm fills each MArg.mode from the resolved callee's param_modes (the sig = synth_pure(callee) it already holds); codegen's emit_call anon-temp borrow-slot drop gate reads arg.mode instead of re-looking-up the callee's param_modes from the module_def_ail_types table; and — since that gate was the table's only reader — the module_def_ail_types field plus all its construction and threading are deleted. Two refinements to the spec's mir.3b sketch, settled in planning from a focused recon and recorded in spec 0060: 1. module_def_ail_types is deleted in mir.3b, not mir.5. A grep proved self.module_def_ail_types had exactly ONE reader (the anon-temp gate); the own-param drop reads param_modes from the def's own f.ty, not this table. Once the gate moves onto MArg.mode the table is dead, so it is removed now rather than carried as dead code to mir.5. The mir.5 row's "last re-derivation residue" shrinks to the element-type / Term::New (New.elem) work. 2. MArg.mode is filled for App args only; MTerm::Let.mode is not filled. Only App args have a real per-arg mode source (the callee Type::Fn.param_modes). Do args (EffectOpSig has no param modes), Ctor args (no per-field mode), and Recur args (loop binders carry no mode) stay Mode::Owned. Let.mode has no source (ast::Term::Let has no mode field) and no consumer (the let-drop gate reads consume, not Let.mode), so it stays Owned — filling it would invent a value nobody reads. Safety property held: MArg.mode is filled from the same callee fn-type the gate read from the table, so the drop fires identically. For (app RawBuf.get (app RawBuf.set …) 0), RawBuf.get's param 0 is borrow, so args[0].mode = Borrow — exactly the value module_def_ail_types yielded. Confirmed by the anon-temp witness staying green. ParamMode -> Mode conversion: Borrow -> Mode::Borrow; Own and Implicit (the Implicit ≡ Own contract) -> Mode::Owned. The own-param drop keeps reading ParamMode off f.ty (Type/ParamMode imports retained, live readers at lib.rs:1356/1507). Also retires three now-stale doc comments that named the deleted module_def_ail_types field (ailang-check/src/lib.rs, uniqueness.rs, and the codegen_import_map_fallback_pin test doc) — a direct consequence of the deletion, reworded to the current architecture. Verification (orchestrator, post-implement inspect): diff matches the plan (App-arg m_args built before m_callee consumes sig — the benign borrow-order deviation the plan's note flagged); module_def_ail_types grep-clean across all of crates/; cargo build --workspace clean (no unused/dead_code); cargo test --workspace 702 passed / 0 failed / 3 ignored (+1 = the new producer pin app_arg_carries_callee_borrow_mode; no #[ignore] added). The anon-temp drop-correctness witness raw_buf_owned_drop_balances_rc_stats stays live=0, now driven by MArg.mode; the other RC-stats leak pins green; #49 stays #[ignore] (mir.4); #51/#53 build guards green. mir.3 is now complete (3a relocated consume_count + deleted the second uniqueness run; 3b filled the mode annotation + deleted module_def_ail_types). Remaining: mir.4 (#49 StrRep RED->GREEN), mir.5 (element-type/Term::New + ledger).
20 KiB
Typed MIR — the check→codegen elaboration boundary — Design Spec
Date: 2026-05-31 Status: Approved Authors: orchestrator + Claude
Goal
Introduce a typed mid-level IR (MIR) as the single artefact that
crosses the check → codegen boundary, carrying every fact check
proves — node types, statically-resolved callees, parameter modes /
consume counts, and value representation (heap vs. static Str) — so
that codegen re-derives nothing.
Today there is no such artefact. crates/ail/src/main.rs:2293 calls
ailang_check::check_workspace(&ws), discards its result, and at
:2348 hands ailang_codegen::lower_workspace(&ws) the raw
Workspace. CheckedModule
(crates/ailang-check/src/lib.rs:1283) carries only top-level
(Type, hash) — zero interior annotation. Codegen therefore runs four
independent re-derivations of information check already computed:
- Type synthesis at every node —
synth_with_extras(crates/ailang-codegen/src/lib.rs:3242, ~270 lines) +synth_arg_type(:3238), a second inference engine mirroring the checker's lookup ladder. - Callee resolution —
type_home_module(:2809), "mirrors the checker's TypeDef-first ladder", replicated at three sites, plusis_static_callee(:2830). - Uniqueness / mode —
infer_module_with_cross(:1002), the identical passailang-check::linearityalready ran; codegen re-runs it to recoverconsume_countfor drop placement (:2326comment: "the typechecker has already pinned uniqueness"). - Str representation — the
!is_strgate on theTerm::Recursuperseded-value dec (~:2131), a conservatism codegen cannot resolve because it lacks the heap/static distinction.
Every member of the raw-buf bug family (#43 / #46 / #47 / #49 / #51 /
#53 — all "check-clean, build-divergent") is a point where codegen's
re-derivation disagreed with what check knew, patched one leg at a
time. This milestone removes the re-derivation architecture itself.
Framing — these are regression guards, not RED→GREEN bugs
As of 2026-05-31 (HEAD) the acute crashes are already patched leg by leg:
| Program | Status at HEAD | Held green by |
|---|---|---|
#51 (new RawBuf (con Int) 3) used via size only |
builds | ee4107c (check-side — honours the written type-arg) |
#53 (new Counter 42) over a user ADT |
builds | 420703d (codegen-side mirror — "symmetric to check") |
#49 heap-Str loop binder across recur |
builds, leaks live=3 |
unfixed — the one open leg, #[ignore]'d (02775b5) |
So the milestone is not "make three crashing programs compile."
It is "remove the codegen re-derivation whose leg-by-leg patches
keep these programs green today — without breaking their correctness —
and close the one genuinely-open leg (#49) structurally instead of
with a fourth patch." The check-side corrections (ee4107c,
#46/744ad41) are healthy — they correctly populate what MIR will
carry, and they stay. The codegen-side mirrors (420703d, the
synth_with_extras loop-binder replay in db710b1, the recur dec
legs) are the disease, and they are deleted as their information
moves into MIR.
The empirical acceptance evidence is therefore: #51 and #53 keep
building while synth_with_extras / the type_home mirrors that hold
them green are deleted (correctness survives removal of the
re-derivation), plus #49 goes live=3 → live=0 (the one
RED→GREEN).
Architecture
A new leaf crate ailang-mir defines the IR types. It is a neutral
bridge: ailang-check depends on it to produce MIR, ailang-codegen
depends on it to consume MIR. The edge codegen → check already
exists (crates/ailang-codegen/Cargo.toml), so no cycle is created;
ailang-mir itself has no AILang-crate dependencies except
ailang-core (for Type).
.ail.json ─ parse ─ resolve ─ desugar ─ typecheck ─ lift ─ monomorphise ─┐
│ lower_to_mir (NEW, in ailang-check)
monomorphised AST ─────────────────────────── ┴──▶ MirModule ──▶ emit LLVM IR
(typed, mode/callee/rep-annotated) (codegen: reads, derives nothing)
- The authoring AST (Form A, content-addressed, hashable) is unchanged. MIR is a compiler-internal derived form — never authored, never hashed, never round-tripped. This preserves the authoring contract: an author writes no type the checker already infers (the feature-acceptance redundancy bar).
lower_to_mirruns after monomorphisation, on the post-mono workspace — the same artefact codegen lowers today. It is not a phase ofcheck_workspace(which stops at typecheck, pre-mono): if MIR were built pre-mono it would not carry the monomorphic specialisationsmonomorphise_workspaceappends and codegen emits, reintroducing the very check↔codegen gap this milestone closes. The build path is wrapped by one front-end entry point,elaborate_workspace, that runs check → lift → mono → lower_to_mir and yields aMirWorkspaceon success or the existingVec<Diagnostic>on a type error.- One type engine, not two.
lower_to_mirwalks the post-mono AST with check's own canonicalsynthto filltyon every node. This is still a type walk —synthis a pure&Term → Typefunction and the AST carries no node-ids, so MIR cannot be a side-table read; it is built inline by walking. What the milestone removes is not the walk but the second engine: codegen'ssynth_with_extrasis a hand-copied mirror ofsynththat drifts from it (every raw-buf bug is a drift point). Replacing the mirror with a single post-mono call to the canonicalsynth, materialised as MIR, deletes the drift class structurally — there is no longer a second implementation to disagree. lower_workspace*in codegen take&MirWorkspaceinstead of&Workspace. TheEmitterfields and methods that re-derive (locals' carriedType,synth_with_extras,type_home_module, the secondinfer_module_with_crossrun) are deleted; their consumers read the MIR annotation directly.
This also resolves a standing model drift:
design/models/0003-pipeline.md already advertises "lower to MIR
(SSA-like, named SSA values)" — an artefact that never existed. This
milestone makes the model true.
Concrete code shapes
User-facing programs (the boundary's witnesses — parse-gated)
These three .ail programs are the milestone's empirical anchor. All
three are ail check-clean today (the disagreement is downstream of
check), so they exercise exactly the boundary this milestone owns.
#51 — element type carried only by the author's annotation (a
RawBuf read only for its size; nothing else observes the element
type). Regression guard: must keep building as the codegen element-type
re-derivation is removed.
(module new_rawbuf_size_only
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(let b (new RawBuf (con Int) 3)
(app print (app RawBuf.size b))))))
#53 — monomorphic (new T …) over a user ADT, the dotted callee
Counter.new that codegen resolves today only via a hand-copied
mirror of the checker's ladder. Regression guard: must keep building
once the mirror is deleted and the callee arrives pre-resolved in MIR.
(module new_counter_user_adt
(data Counter (ctor MkCounter (con Int)))
(fn new (type (fn-type (params (con Int)) (ret (con Counter)))) (params n)
(body (term-ctor Counter MkCounter n)))
(fn main (type (fn-type (params) (ret (con Unit)) (effects IO))) (params)
(body (let c (new Counter 42) (do io/print_str "ok\n")))))
#49 — heap-Str loop binder replaced across recur (the seed is
a static literal, each recur rebinds to a fresh str_concat heap
slab). The one RED→GREEN: leaks live=3 today (one slab per recur
iteration superseded without a drop); must reach live=0.
(module loop_recur_str_binder
(fn main (type (fn-type (params) (ret (con Unit)) (effects IO))) (params)
(body
(let s
(loop (acc (con Str) "x") (i (con Int) 0)
(if (app ge i 3)
acc
(recur (app str_concat acc "y") (app + i 1))))
(app print s)))))
Implementation shape (secondary — before → after)
MIR node — the bridge type (new, ailang-mir). Each node carries
the facts codegen used to re-derive:
// ailang-mir/src/lib.rs (NEW)
pub struct MirModule { pub name: String, pub defs: Vec<MirDef> }
pub struct MirDef { pub name: String, pub sig: Type, pub body: MTerm }
pub enum MTerm {
Lit { lit: Literal, ty: Type },
Var { name: String, ty: Type },
Call { callee: Callee, args: Vec<MArg>, ty: Type, tail: bool }, // (2) callee pre-resolved
Let { name: String, mode: Mode, init: Box<MTerm>, body: Box<MTerm>, ty: Type }, // (3) mode
New { type_name: String, elem: Option<Type>, args: Vec<MTerm>, ty: Type }, // (1) elem carried
Recur{ args: Vec<MArg>, ty: Type },
Str { lit: String, rep: StrRep }, // (4) Heap | Static — set by lower_to_mir
// … one variant per Term node, each with `ty`
}
pub struct MArg { pub term: MTerm, pub mode: Mode, pub consume_count: u32 } // (3)
pub enum Callee { Static { module: String, fn_name: String, sig: Type }, Builtin { name: String, sig: Type }, Indirect(Box<MTerm>) } // (2) — sig carries the callee fn-type for drop ret_mode; Builtin for inline-lowered operators/intrinsics
pub enum StrRep { Heap, Static } // (4)
pub enum Mode { Owned, Borrow }
check boundary (ailang-check):
// before — crates/ailang-check/src/lib.rs:1283
pub struct CheckedModule { pub symbols: IndexMap<String, (Type, String)> } // signatures only
// after — one front-end entry wraps check → lift → mono → lower_to_mir
pub fn elaborate_workspace(ws: &Workspace) -> Result<MirWorkspace, Vec<Diagnostic>>;
// runs the full front end and lowers the POST-mono workspace to MIR.
// (check_workspace stays for the diagnostics-only callers — `ail check`,
// which stops at typecheck; elaborate_workspace is the build path.)
codegen boundary (ailang-codegen):
// before — crates/ailang-codegen/src/lib.rs:278 + the Emitter re-derivers
pub fn lower_workspace(ws: &Workspace) -> Result<String>;
fn synth_with_extras(&self, t: &Term, …) -> Result<Type>; // ~270 lines — DELETED
fn type_home_module(&self, …) -> Option<String>; // ×3 mirrors — DELETED
let uniqueness = infer_module_with_cross(module, …); // 2nd pass — DELETED
// after — codegen consumes MIR, reads annotations
pub fn lower_workspace(mir: &MirWorkspace) -> Result<String>;
// e.g. a Call's target is `callee` (no is_static_callee lookup);
// a node's type is `mterm.ty()` (no synth);
// drop placement reads `MArg.consume_count` (no re-inference).
Components
ailang-mir(new crate):MirModule/MirDef/MTerm/Callee/Mode/StrRep/MirWorkspace. Leaf; depends only onailang-core.ailang-check::lower_to_mir(new module): walks the monomorphised AST with check's canonicalsynthand emitsMTerm, fillingtyon every node. Later iterations fillcallee(mir.2),mode/consume_count(mir.3),rep(mir.4).ailang-check::elaborate_workspace(new entry): the single front-end function — runscheck_workspace(diagnostics gate),lift_letrecs,monomorphise_workspace, thenlower_to_miron the post-mono workspace. ReturnsMirWorkspaceor the diagnostics. This moves the mono orchestration out of the CLI and behind one boundary.ailang-codegen(rewired): entry points take&MirWorkspace; the four re-derivers are removed iteration by iteration as their annotation lands in MIR.- CLI (
crates/ail/src/main.rs): the build path callselaborate_workspace(which now subsumes the explicitlift+monosteps the CLI orchestrated) and threadsMirWorkspaceintolower_workspace*.check/ diagnostics paths stay oncheck_workspace; theemit-irpath builds MIR via the same front-end entry.
Data flow
Workspace (AST) → typecheck (unchanged) → lift (unchanged) →
monomorphise (unchanged) → lower_to_mir (post-mono AST + canonical
synth → MirWorkspace) → lower_workspace (MirWorkspace → LLVM IR
text) → clang. The single type-inference engine is check's synth:
lower_to_mir calls it once over the post-mono bodies to materialise
MIR, and codegen reads the result rather than running its own
synth_with_extras mirror. The remaining synth walk inside
typecheck (pre-mono, for diagnostics) and the lower_to_mir walk
(post-mono, for MIR) are not redundant — they run on different
workspaces for different purposes, and both are the same canonical
engine, never a divergent copy.
Error handling
lower_to_mir runs only on a typechecked module, so it encounters no
type errors. A failure to lower (e.g. an unresolved callee that the
checker accepted — exactly today's check↔codegen disagreement) is an
internal compiler error, surfaced as a distinct
MirLoweringError, not a user diagnostic: by construction, if check
passed, lowering must succeed. This is the structural property the
milestone buys — the class "check-clean but codegen-divergent" becomes
a single guarded invariant (elaborate_workspace returns Ok ⇒ MIR
is complete ⇒ codegen cannot fall through to an UnknownVar).
Testing strategy
- Regression guards (green → must stay green): the #51 and #53 programs above, plus the full existing workspace suite, run after each re-deriver removal. The property: deleting the codegen re-derivation does not change observable output.
- #49 RED→GREEN:
crates/ail/tests/loop_recur_str_binder_no_leak_pin.rs— the#[ignore]is lifted in mir.4;live=0underAILANG_RC_STATS. - Re-derivation-is-gone pins: each iteration adds a guard that the removed function is absent (e.g. a codegen unit test that the resolved callee comes from MIR, not a ladder walk) so the re-derivation cannot silently return.
elaborate ⇒ codegen-totalinvariant: a test that a curated set of check-clean programs all lower to MIR and emit IR — the structural replacement for the bug family.
Iteration decomposition
Strategy: MIR is structurally complete over all 17 Term variants
(crates/ailang-core/src/ast.rs:436) from mir.1 (codegen consumes it
fully — no AST/MIR dual path). Each iteration adds one annotation
class and deletes the matching codegen re-derivation.
| Iter | Adds to MIR | Deletes from codegen | Acceptance |
|---|---|---|---|
| mir.1 | structural MIR + ty on every node |
synth_with_extras + synth_arg_type |
whole suite green (regression); synth_with_extras gone |
| mir.2 | Callee::Static pre-resolved |
type_home_module ×3 + is_static_callee; lockstep pair lower_app ↔ is_static_callee struck from CLAUDE.md |
#53 guard green via MIR |
| mir.3 | Mode + consume_count |
second infer_module_with_cross run (:1002) |
#43/#47 guards green; drop placement reads MIR |
| mir.4 | StrRep (loop-carried Str ⇒ Heap via str_clone at loop entry) |
the !is_str recur dec gate |
#49 live=0; #[ignore] lifted |
| mir.5 | element-type / Term::New fully on MIR + ledger |
last re-derivation residue | #51 guard green via MIR; ledger consistent |
mir.2 refinement (discovered in planning,
docs/plans/0116-mir.2-callee-static.md):Calleegains aBuiltin { name, sig }variant (operators / str-num intrinsics have nomodule/fn_name) and asig: Typeon each resolved variant (codegen's drop path reads the calleeret_modeoff it — the lossless replacement for the pre-mir.2Indirect(inner).ty()read; this is NOT a mir.3 pull-forward, theMArgparam-modes stay at mir.1 defaults).type_home_moduleis fully deletable: its value-position consumer (resolve_top_level_fn) is unreachable for type-scoped names in the current corpus, so the acceptance "type_home_module grep-clean" holds.
mir.3 split (discovered in planning,
docs/plans/0117-mir.3a-consume-count-relocation.md): mir.3 ships in two iterations. mir.3a relocatesconsume_countonly — the deletion of the secondinfer_module_with_crossrun depends solely on it (all three codegen drop sites readconsume_countfromself.uniqueness; the modes they also test come from the type, not the uniqueness pass, which carries no mode). The per-binderconsume_countlands on a newMirDef.consume: BTreeMap<String, u32>(binder-keyed, matching how the drop sites and theUniquenessTablekey it — the spec'sMArg.consume_countfield is per-arg-position and does not reach the binder-keyed drop sites, so it stays a mir.1 default). lower_to_mir runs the single uniqueness pass on the post-mono body — a pure relocation of the identical computation codegen ran, so drop placement is byte-identical. mir.3b fillsMArg.mode/MTerm::Let.modefrom the type modes and switchesemit_call's anon-temp borrow-slot gate ontoMArg.mode(additive; no further re-deriver deletion).
mir.3b refinement (discovered in planning,
docs/plans/0118-mir.3b-marg-mode-and-table-delete.md):MArg.modeis filled for App args only — the callee'sType::Fn.param_modesis the sole real per-arg mode source; Do args (EffectOpSighas no param modes), Ctor args (no per-field mode), and Recur args (loop binders carry no mode) stayMode::Owned.MTerm::Let.modeis NOT filled: a let-binder has no author-declared mode and no codegen consumer (the let-drop gate readsconsume, notLet.mode), so it staysOwned. Switchingemit_call's anon-temp borrow-slot gate ontoMArg.moderemoves the only reader of codegen'smodule_def_ail_typestable, so that table is deleted in mir.3b, not mir.5 — the mir.5 row's "last re-derivation residue" shrinks to the element-type /Term::New(New.elem) work.
mir.5 carries the ledger work as part of the milestone (per CLAUDE.md: contract changes ship with the iteration that needs them):
- New contract
design/contracts/NNNN-check-codegen-boundary.md: the invariant "codegen re-derives nothing; MIR is total over what check proved;elaborate_workspace⇒ codegen cannot fall through." - Revise
design/contracts/0013-typeclasses.md:106-120: the passage that blesses codegen-side cross-module re-resolution as correct-by-design is retracted — resolution now lives in MIR. - Correct
design/models/0003-pipeline.md: the "lower to MIR" line becomes true (point at the real stage). - INDEX
design/INDEX.md: add the boundary contract row; thequalified-xref/ codegen rows re-point at MIR consumption. - Issue hygiene: close #51 / #53 (now structurally held, not patch-held); milestone #7 (raw-buf) is reset to not met and subsumed — #49/#51/#53 become MIR-iteration acceptance, not a separate patch track.
Acceptance criteria
ailang-codegencontains no type synthesis, no callee-ladder walk, and no uniqueness inference:synth_with_extras,synth_arg_type,type_home_module, and theinfer_module_with_crosscall are removed; grep-clean.- The build path is
Workspace → elaborate_workspace → MirWorkspace → lower_workspace; codegen's public entry points take&MirWorkspace. - The #51 and #53 programs build and run unchanged across every iteration (correctness survives re-deriver removal).
- The #49 program reaches
live=0; its test's#[ignore]is lifted. - The
lower_app ↔ is_static_calleelockstep pair is gone from CLAUDE.md (structurally unnecessary), and 0013's blessed re-resolution passage is retracted. design/models/0003-pipeline.md's MIR line names the real stage; the new boundary contract is in INDEX.- The full workspace test suite is green at each iteration close.