spec: typed-mir check→codegen boundary contract
Open a new milestone introducing a typed mid-level IR (`ailang-mir`) as the single artefact crossing the check → codegen boundary, so codegen re-derives nothing. Root cause (architect drift review): the boundary today carries no typed artefact. `crates/ail/src/main.rs:2293` calls `check_workspace`, discards the result, and hands `lower_workspace` the raw `Workspace`. `CheckedModule` (ailang-check/src/lib.rs:1283) carries only top-level (Type, hash). Codegen therefore re-runs four independent derivations of what check already proved — type synthesis (`synth_with_extras`), callee resolution (`type_home_module` ×3 + `is_static_callee`), uniqueness/mode (`infer_module_with_cross`, second run), and Str representation (`!is_str` recur gate). Every raw-buf bug (#43/#46/#47/#49/#51/#53, all "check-clean, build-divergent") is a point where one of those re-derivations disagreed with check and got patched one leg at a time. Honest framing: this is removal of a re-derivation architecture, not a RED→GREEN of crashing programs. #51 and #53 build today (patched byee4107c/420703d) and are regression guards — they must keep building as the codegen mirrors that hold them green are deleted. #49 is the one open leg (builds, leaks live=3, test #[ignore]'d) and is the single RED→GREEN. Five iterations, each adding one MIR annotation class and deleting the matching codegen re-deriver: mir.1 structural MIR + `ty`; mir.2 `Callee::Static`; mir.3 `Mode`/`consume_count`; mir.4 `StrRep` (#49 live=0); mir.5 element-type residue + ledger (new boundary contract, retract 0013:106-120, fix 0003-pipeline model, INDEX, close #51/#53, reset milestone #7 to not-met). Forward rewrite, not revert (the prior raw-buf milestone close was wrong; the language infrastructure is healthy and survives — only the codegen re-derivation is the disease). Approach B: a separate typed MIR carrier, authoring AST unchanged. Gates: parse-every-block clean (all three .ail witnesses `ail check`-clean, exit 0); grounding-check PASS (13 load-bearing assumptions ratified against green tests / verified code facts).
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
# 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:
|
||||
|
||||
1. **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.
|
||||
2. **Callee resolution** — `type_home_module` (`:2809`), "mirrors the
|
||||
checker's TypeDef-first ladder", replicated at three sites, plus
|
||||
`is_static_callee` (`:2830`).
|
||||
3. **Uniqueness / mode** — `infer_module_with_cross` (`:1002`), the
|
||||
*identical* pass `ailang-check::linearity` already ran; codegen
|
||||
re-runs it to recover `consume_count` for drop placement
|
||||
(`:2326` comment: "the typechecker has already pinned uniqueness").
|
||||
4. **Str representation** — the `!is_str` gate on the `Term::Recur`
|
||||
superseded-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 ─┐
|
||||
│ lower_to_mir (NEW, in ailang-check)
|
||||
AST + type env ───┴──▶ 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_mir` runs as the final phase of `check_workspace`, after
|
||||
typecheck + lift, reusing the type environment the checker already
|
||||
built. On success it yields a `MirWorkspace`; on type error it
|
||||
yields the existing `Vec<Diagnostic>` and no MIR.
|
||||
- `lower_workspace*` in codegen take `&MirWorkspace` instead of
|
||||
`&Workspace`. The `Emitter` fields and methods that re-derive
|
||||
(`locals`' carried `Type`, `synth_with_extras`, `type_home_module`,
|
||||
the second `infer_module_with_cross` run) 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.
|
||||
|
||||
```ail
|
||||
(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.
|
||||
|
||||
```ail
|
||||
(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`.
|
||||
|
||||
```ail
|
||||
(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:
|
||||
|
||||
```rust
|
||||
// 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 }, Indirect(Box<MTerm>) } // (2)
|
||||
pub enum StrRep { Heap, Static } // (4)
|
||||
pub enum Mode { Owned, Borrow }
|
||||
```
|
||||
|
||||
**check boundary** (`ailang-check`):
|
||||
|
||||
```rust
|
||||
// before — crates/ailang-check/src/lib.rs:1283
|
||||
pub struct CheckedModule { pub symbols: IndexMap<String, (Type, String)> } // signatures only
|
||||
|
||||
// after — check_workspace yields MIR on success
|
||||
pub fn elaborate_workspace(ws: &Workspace) -> Result<MirWorkspace, Vec<Diagnostic>>;
|
||||
// (check_workspace stays for the diagnostics-only callers; elaborate_* is the build path)
|
||||
```
|
||||
|
||||
**codegen boundary** (`ailang-codegen`):
|
||||
|
||||
```rust
|
||||
// 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 on
|
||||
`ailang-core`.
|
||||
- **`ailang-check::lower_to_mir` (new module):** walks the
|
||||
typechecked, lifted AST with the live type environment and emits
|
||||
`MTerm`, filling `ty` on every node. Later iterations fill `callee`
|
||||
(mir.2), `mode` / `consume_count` (mir.3), `rep` (mir.4).
|
||||
- **`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 calls
|
||||
`elaborate_workspace` and threads `MirWorkspace` into
|
||||
`lower_workspace*`. `check` / `emit-ir` / diagnostics paths
|
||||
unchanged where they only need diagnostics.
|
||||
|
||||
## Data flow
|
||||
|
||||
`Workspace (AST)` → `typecheck` (unchanged) → `lift` (unchanged) →
|
||||
`lower_to_mir` (type env → `MirWorkspace`) → `lower_workspace`
|
||||
(`MirWorkspace` → LLVM IR text) → `clang`. The type environment that
|
||||
`synth` builds inside check is consumed in-process by `lower_to_mir`
|
||||
rather than discarded; nothing re-walks the AST for types after MIR
|
||||
exists.
|
||||
|
||||
## 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=0` under `AILANG_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-total` invariant:** 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 27 node kinds 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.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; the
|
||||
`qualified-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
|
||||
|
||||
1. `ailang-codegen` contains **no** type synthesis, no callee-ladder
|
||||
walk, and no uniqueness inference: `synth_with_extras`,
|
||||
`synth_arg_type`, `type_home_module`, and the
|
||||
`infer_module_with_cross` call are removed; grep-clean.
|
||||
2. The build path is `Workspace → elaborate_workspace → MirWorkspace →
|
||||
lower_workspace`; codegen's public entry points take
|
||||
`&MirWorkspace`.
|
||||
3. The #51 and #53 programs build and run unchanged across every
|
||||
iteration (correctness survives re-deriver removal).
|
||||
4. The #49 program reaches `live=0`; its test's `#[ignore]` is lifted.
|
||||
5. The `lower_app ↔ is_static_callee` lockstep pair is gone from
|
||||
CLAUDE.md (structurally unnecessary), and 0013's blessed
|
||||
re-resolution passage is retracted.
|
||||
6. `design/models/0003-pipeline.md`'s MIR line names the real stage;
|
||||
the new boundary contract is in INDEX.
|
||||
7. The full workspace test suite is green at each iteration close.
|
||||
Reference in New Issue
Block a user