Executable plan for the first of two iterations delivering spec 0060's mir.3 row. Two plan-recon passes (mode/consume/drop-placement surface + the uniqueness-pass signature) established that the named deletion (the second infer_module_with_cross run) depends ONLY on consume_count: all three codegen scope-close drop sites read consume_count from self.uniqueness, while the modes they also gate on (param_modes for the own-param dec, scrutinee_is_owned for the match-arm dec) come from the type, not the uniqueness pass (UniquenessInfo carries no mode). So mir.3 splits: - mir.3a (this plan): relocate consume_count; delete the second run. - mir.3b (next): fill MArg.mode / MTerm::Let.mode from the type modes and switch emit_call's anon-temp borrow-slot gate onto MArg.mode. The split halves the change at the codebase's highest-risk surface (RC/drop correctness, where the raw-buf leak saga lived) and makes each half independently verifiable against the live=0 leak pins. Design decisions locked in the plan: - consume_count lands on a new binder-keyed MirDef.consume: BTreeMap<String,u32>, NOT on MArg. The recon surfaced the central structural tension: the drop sites key consume_count by (def, binder_name) — a per-binder aggregate — while the spec-sketched MArg.consume_count is per-arg-position and never reaches them. The per-def binder map matches both the UniquenessTable's own keying and all three binder kinds (param/let/pattern) uniformly. MArg.consume_count stays a mir.1 default (no consumer). Spec 0060 is updated to record this (Task 5). - Source = run check's infer_module_with_cross inside lower_to_mir on the post-mono module (the synth_pure single-engine-re-walked pattern). The alternative of retaining a check-time result is blocked: check's uniqueness runs pre-mono and on-demand-from-codegen, never during check_workspace, so a retained result would miss the mono specialisations. cross_module_types (= codegen's module_def_ail_types, module->def->Type) is rebuilt in elaborate_workspace from the post-mono workspace. Key safety property carried in the plan: this is a PURE RELOCATION of an identical computation. infer_module_with_cross ran on the post-mono module in codegen and now runs on the same post-mono module in lower_to_mir — same function, same input, same output — so drop placement is byte-identical. The live=0 leak pins (raw_buf_loop_no_leak, loop_recur_heap_binder, print_no_leak) are the value-correctness gate. Five tasks: (1) add MirDef.consume; (2) elaborate_workspace builds cross_module_types + lower_module runs the pass and fills consume; (3) codegen builds its (def,binder)->consume lookup from MIR, the three drop sites read it, delete the run + field + import; (4) producer pin + leak-pin acceptance + grep-clean; (5) record the split in spec 0060. No new .ail fixture (producer pin reuses new_counter_user_adt.ail).
23 KiB
mir.3a — relocate consume_count into MIR, delete codegen's second uniqueness run — Implementation Plan
Parent spec:
docs/specs/0060-typed-mir.md(Iteration decomposition table, mir.3 row — first of two iterations; see "Split" below)For agentic workers: REQUIRED SUB-SKILL: use the
implementskill to run this plan. Steps use- [ ]checkboxes for tracking.
Goal: Move the per-binder consume_count (the only thing codegen
reads from its second infer_module_with_cross run) into MIR, so
lower_to_mir computes it once on the post-mono module and codegen's
three scope-close drop sites read it off MirDef.consume — then delete
codegen's infer_module_with_cross call.
Architecture: lower_to_mir runs ailang_check::uniqueness:: infer_module_with_cross (the same pass, same crate) on the post-mono
module it already walks, and attaches each def's binder→consume_count
map to a new MirDef.consume field. Codegen builds its existing
(def, binder) → consume_count lookup from those maps instead of
re-running the pass; the three drop gates (own-param, let-binder,
match-arm) are otherwise unchanged. This is a pure relocation of an
identical computation — infer_module_with_cross ran on the post-mono
module in codegen and now runs on the same post-mono module in
lower_to_mir; same function, same input, same output, so drop
placement is byte-identical.
Tech Stack: ailang-mir (MirDef.consume carrier),
ailang-check::lower_to_mir + elaborate_workspace (producer),
ailang-codegen (consumer: the three drop sites + the deleted run).
Split — mir.3 ships in two iterations
The spec's mir.3 row bundles "Mode + consume_count" as MIR
additions and names "delete the second infer_module_with_cross
run" as the deletion. Investigation during planning established that
the deletion depends ONLY on consume_count: all three codegen drop
sites read consume_count from self.uniqueness, while the modes
they also test (param_modes for the own-param gate, scrutinee_is_owned
for the match gate) come from the type, not from
infer_module_with_cross. UniquenessInfo carries no mode.
So mir.3 is delivered as:
- mir.3a (this plan): relocate
consume_count; delete the secondinfer_module_with_crossrun. This is the spec's named deletion. - mir.3b (next plan): fill
MArg.mode/MTerm::Let.modefrom the type's param/binder modes and switch codegen's per-arg mode reads (emit_call's anon-temp borrow-slot gate) ontoMArg.mode. Additive; no further re-deriver deletion.
The split halves the change at the codebase's highest-risk surface (RC/drop correctness) and makes each half independently verifiable against the leak pins. mir.3a carries the deletion; mir.3b is pure annotation + read-relocation.
Files this plan creates or modifies:
- Modify:
crates/ailang-mir/src/lib.rs:52-58— addconsumetoMirDef. - Modify:
crates/ailang-check/src/lower_to_mir.rs:411(lower_modulesignature + body) — run the uniqueness pass, fillMirDef.consume. - Modify:
crates/ailang-check/src/lib.rs:1317-1352(elaborate_workspace) — buildcross_module_types, thread it intolower_module. - Modify:
crates/ailang-codegen/src/lib.rs:42,848,1029,1057— delete the import / field-type / run / init; build the lookup from MIR. - Modify:
crates/ailang-codegen/src/lib.rs:1511,:1878,crates/ailang-codegen/src/match_lower.rs:797— the three consume_count reads. - Test:
crates/ailang-check/tests/lower_to_mir_ty.rs— producer pin (consume populated). - Modify (docs):
docs/specs/0060-typed-mir.md— record the split + the consume relocation.
Task 1: Add the consume carrier to MirDef
Files:
-
Modify:
crates/ailang-mir/src/lib.rs:52-58 -
Step 1: Add the field
Replace MirDef at crates/ailang-mir/src/lib.rs:50-58:
/// One lowered top-level fn. `sig` is the declared signature; `body`
/// is the lowered fn body with `ty` on every node.
#[derive(Debug, Clone)]
pub struct MirDef {
pub name: String,
pub sig: Type,
pub params: Vec<String>,
pub body: MTerm,
}
with:
/// One lowered top-level fn. `sig` is the declared signature; `body`
/// is the lowered fn body with `ty` on every node.
#[derive(Debug, Clone)]
pub struct MirDef {
pub name: String,
pub sig: Type,
pub params: Vec<String>,
pub body: MTerm,
/// Per-binder consume count for this def's body, keyed by binder
/// name (let-binders, params, pattern-binders). `0` means the
/// binder is exclusively borrowed/unused in its scope and codegen
/// must emit the scope-close `dec` under `--alloc=rc`. Filled by
/// `lower_to_mir` from `ailang_check::uniqueness::infer_module_with_cross`
/// (mir.3a) — the single uniqueness engine, run once on the
/// post-mono body. Codegen reads it instead of re-running the pass.
pub consume: BTreeMap<String, u32>,
}
Implementer note:
BTreeMapis already imported atcrates/ailang-mir/src/lib.rs:12(use std::collections::BTreeMap;).
- Step 2: Build the leaf crate (partial gate —
ailang-checkred until Task 2)
Run: cargo build -p ailang-mir
Expected: PASS.
NOTE:
ailang-checkwill NOT build until Task 2 —lower_moduleconstructsMirDefwithout the newconsumefield. That is expected and closed in Task 2.
Task 2: Producer — lower_to_mir fills MirDef.consume
Files:
-
Modify:
crates/ailang-check/src/lib.rs:1317-1352(elaborate_workspace) -
Modify:
crates/ailang-check/src/lower_to_mir.rs:411(lower_module) -
Step 1:
elaborate_workspacebuildscross_module_typesand threads it in
In crates/ailang-check/src/lib.rs, inside elaborate_workspace,
replace the post-mono lowering loop (the block at :1344-1352
beginning let env = build_check_env(&mono);):
let env = build_check_env(&mono);
let mut modules = std::collections::BTreeMap::new();
for (name, m) in &mono.modules {
let mir = lower_to_mir::lower_module(m, &env)
.map_err(|e| vec![internal_diag(format!("lower_to_mir in `{name}`: {e}"))])?;
modules.insert(name.clone(), mir);
}
Ok(ailang_mir::MirWorkspace { entry: mono.entry.clone(), modules })
with:
let env = build_check_env(&mono);
// Workspace-flat `module -> def -> Type` table — the shape
// `uniqueness::infer_module_with_cross` consumes to resolve a
// cross-module callee's `param_modes` (so a Borrow-mode arg is
// walked as a borrow, not a consume). Identical in content to
// codegen's `module_def_ail_types`; built here from the post-mono
// workspace so the uniqueness pass runs in the producer (mir.3a).
let mut cross_module_types: std::collections::BTreeMap<
String,
std::collections::BTreeMap<String, ailang_core::ast::Type>,
> = std::collections::BTreeMap::new();
for (mname, m) in &mono.modules {
let mut def_tys = std::collections::BTreeMap::new();
for def in &m.defs {
if let ailang_core::ast::Def::Fn(f) = def {
def_tys.insert(f.name.clone(), f.ty.clone());
}
}
cross_module_types.insert(mname.clone(), def_tys);
}
let mut modules = std::collections::BTreeMap::new();
for (name, m) in &mono.modules {
let mir = lower_to_mir::lower_module(m, &env, &cross_module_types)
.map_err(|e| vec![internal_diag(format!("lower_to_mir in `{name}`: {e}"))])?;
modules.insert(name.clone(), mir);
}
Ok(ailang_mir::MirWorkspace { entry: mono.entry.clone(), modules })
Implementer note: confirm
Def::Fn(f)carriesname: Stringandty: Type(it is the sameFnDefcodegen reads atlib.rs:334to buildmodule_def_ail_types). Theailang_core::ast::Type/Def::Fnpaths may already be imported inlib.rsunder shorter names — use whatever the file already uses (e.g. a bareType/Def); the fully-qualified forms above are correct regardless.
- Step 2:
lower_moduleruns the uniqueness pass and fills eachMirDef.consume
In crates/ailang-check/src/lower_to_mir.rs, change the lower_module
signature at :411:
pub fn lower_module(module: &Module, env: &Env) -> Result<MirModule> {
to:
pub fn lower_module(
module: &Module,
env: &Env,
cross_module_types: &std::collections::BTreeMap<String, std::collections::BTreeMap<String, Type>>,
) -> Result<MirModule> {
Immediately after the permissive-import seeding loop (the
for m in all_modules { env.imports.entry(...) ... } block ending at
:460) and before let mut defs = Vec::new(); (:461), insert:
// mir.3a: run the single uniqueness engine ONCE on this post-mono
// module. The resulting per-(def, binder) consume_count is attached
// to each `MirDef.consume` below; codegen reads it instead of
// re-running `infer_module_with_cross`. Same pass, same post-mono
// input as codegen used — a pure relocation, so drop placement is
// unchanged.
let uniqueness =
crate::uniqueness::infer_module_with_cross(module, cross_module_types);
Then, at the MirDef construction site inside the for def in &module.defs loop (the Def::Fn arm — read the region from :461
onward to find the defs.push(ailang_mir::MirDef { ... }) literal),
add the consume field to the constructed MirDef. The value is this
def's slice of the uniqueness table, keyed by binder name:
consume: uniqueness
.iter()
.filter(|((d, _), _)| d == &f.name)
.map(|((_, b), info)| (b.clone(), info.consume_count))
.collect(),
Implementer note: bind the field to whatever the
Def::Fnarm calls the fn def (the plan usesfper theDef::Fn(f)convention; match the arm's actual binding — it may befdordef).uniquenessis aBTreeMap<(String, String), UniquenessInfo>;UniquenessInfo.consume_countis au32(crates/ailang-check/src/uniqueness.rs:87,99). The.filter(...).map(...).collect()yields theBTreeMap<String, u32>the field wants. If the file does not alreadyusetheuniquenessmodule, thecrate::uniqueness::path above is fully qualified and needs no import.
- Step 3: Build the workspace (full gate — codegen still uses its own run, so green)
Run: cargo build --workspace
Expected: PASS. After this task the producer fills MirDef.consume but
codegen still computes its own self.uniqueness; nothing reads
MirDef.consume yet, so behaviour is unchanged.
- Step 4: Confirm no behaviour change
Run: cargo test --workspace
Expected: PASS — the full suite green, identical to pre-Task-2 (the new
field is filled but unread).
Task 3: Consumer — codegen reads MirDef.consume; delete the second run
Files:
-
Modify:
crates/ailang-codegen/src/lib.rs:42(import),:848(field),:1029(the run),:1057(init) -
Modify:
crates/ailang-codegen/src/lib.rs:1511,:1878,crates/ailang-codegen/src/match_lower.rs:797(the three reads) -
Step 1: Replace the Emitter
uniquenessfield type
In crates/ailang-codegen/src/lib.rs, the Emitter field at :848:
uniqueness: UniquenessTable,
Replace with a plain (def, binder) → consume_count map (the only
thing the three drop sites read off it):
// Per-(def, binder) consume_count, built from `MirDef.consume`
// (mir.3a) — codegen no longer re-runs `infer_module_with_cross`.
consume: std::collections::BTreeMap<(String, String), u32>,
- Step 2: Build
consumefrom MIR instead of running the pass
At crates/ailang-codegen/src/lib.rs:1029, replace:
let uniqueness = infer_module_with_cross(module, module_def_ail_types);
with a fold over the module's MirDef.consume maps:
// mir.3a: read the per-binder consume_count off MIR (filled by
// lower_to_mir from the single uniqueness pass) instead of
// re-running `infer_module_with_cross`.
let consume: std::collections::BTreeMap<(String, String), u32> = mir_module
.defs
.iter()
.flat_map(|d| {
let dname = d.name.clone();
d.consume
.iter()
.map(move |(b, c)| ((dname.clone(), b.clone()), *c))
})
.collect();
Implementer note:
mir_moduleis the name this plan uses for the&ailang_mir::MirModulethe Emitter setup holds at this point (mir.1b wired codegen to consume MIR). Read the region around:1029to find the actual binding — it is the MIR module whosemodule(the post-mono AST) was being passed to the deleted call; theMirModulecarries both.astand.defs. Use its real name.
- Step 3: Update the Emitter init
At crates/ailang-codegen/src/lib.rs:1057, replace the field init:
uniqueness,
with:
consume,
- Step 4: Delete the now-unused import
At crates/ailang-codegen/src/lib.rs:42:
use ailang_check::uniqueness::{infer_module_with_cross, UniquenessTable};
infer_module_with_cross and UniquenessTable are no longer
referenced in codegen after Steps 1-3. Remove the import. If
ParamMode or another item was glob-adjacent, keep only what survives
(this line imports only those two names, so delete the whole line).
- Step 5: Rewire the three consume_count reads
Each of the three drop sites reads
self.uniqueness.get(&(def, binder)).map(|info| info.consume_count).unwrap_or(u32::MAX).
The value is now a bare u32, so the read becomes
self.consume.get(&(def, binder)).copied().unwrap_or(u32::MAX).
crates/ailang-codegen/src/lib.rs:1511(own-param scope-close dec):
let consume_count = self
.uniqueness
.get(&(self.current_def.clone(), pname.clone()))
.map(|info| info.consume_count)
.unwrap_or(u32::MAX);
→
let consume_count = self
.consume
.get(&(self.current_def.clone(), pname.clone()))
.copied()
.unwrap_or(u32::MAX);
crates/ailang-codegen/src/lib.rs:1878(let-binder scope-close dec):
let consume_count = self
.uniqueness
.get(&(self.current_def.clone(), name.clone()))
.map(|info| info.consume_count)
.unwrap_or(u32::MAX); // Defensive: skip if missing.
→
let consume_count = self
.consume
.get(&(self.current_def.clone(), name.clone()))
.copied()
.unwrap_or(u32::MAX); // Defensive: skip if missing.
crates/ailang-codegen/src/match_lower.rs:797(match arm-close dec):
let consume_count = self
.uniqueness
.get(&(self.current_def.clone(), bname.clone()))
.map(|info| info.consume_count)
.unwrap_or(u32::MAX);
→
let consume_count = self
.consume
.get(&(self.current_def.clone(), bname.clone()))
.copied()
.unwrap_or(u32::MAX);
- Step 6: Build the workspace (the consuming gate)
Run: cargo build --workspace
Expected: PASS. Zero errors, zero unused import / unused variable
warnings for module_def_ail_types (it survives — still used by
emit_call's anon-temp param_modes gate; if the compiler now flags it
unused, a read was missed and the leak pins in Task 4 will catch the
behavioural regression).
Task 4: Acceptance — leak pins green, producer pin, grep-clean
Files:
-
Test:
crates/ailang-check/tests/lower_to_mir_ty.rs(producer pin) -
Step 1: Producer pin —
MirDef.consumeis populated
Add a test to crates/ailang-check/tests/lower_to_mir_ty.rs that
elaborates an existing fixture with a let-binder and asserts its
MirDef.consume carries that binder. Reuse the file's
elaborate_fixture helper and the new_counter_user_adt fixture
(its main body is (let c (new Counter 42) ...) — binder c):
#[test]
fn mirdef_consume_is_populated_for_let_binder() {
// mir.3a: lower_to_mir runs the uniqueness pass and attaches the
// per-binder consume_count to MirDef.consume. `main`'s `(let c …)`
// binder must appear in the map (the exact count is validated
// end-to-end by the RC-stats leak pins; this pin guards that the
// producer fills the field at all, so codegen has it to read).
let ws = elaborate_fixture("new_counter_user_adt");
let m = ws.modules.get("new_counter_user_adt").expect("module");
let main = m.defs.iter().find(|d| d.name == "main").expect("main");
assert!(
main.consume.contains_key("c"),
"MirDef.consume must carry the `c` let-binder, got {:?}",
main.consume,
);
}
Implementer note: match
elaborate_fixture's real name/signature (the sibling tests in this file use it).ws.modulesis aBTreeMap<String, MirModule>;MirDef.consumeis theBTreeMap<String, u32>added in Task 1.
- Step 2: Run the producer pin
Run: cargo test -p ailang-check --test lower_to_mir_ty
Expected: PASS — including mirdef_consume_is_populated_for_let_binder.
- Step 3: Full suite — the drop-correctness acceptance
Run: cargo test --workspace
Expected: PASS. The RC-stats leak pins are the value-correctness gate
for the relocation (a wrong consume_count → leak or double-free):
alloc_rc_loop_valued_rawbuf_binder_drops_at_scope_close
(raw_buf_loop_no_leak_pin.rs, #43/#47),
alloc_rc_heap_loop_binder_replaced_by_recur_does_not_leak
(loop_recur_heap_binder_no_leak_pin.rs),
alloc_rc_print_int_does_not_leak_show_result_str
(print_no_leak_pin.rs). All live == 0. loop_recur_str_binder_no_leak_pin
stays #[ignore] (#49 lifts at mir.4 — do not touch). #51/#53 build
guards stay green.
Run the FULL workspace suite — drop placement is perturbed by this change (even though it should be byte-identical), so the broad RC-stats suite, not just a targeted filter, is the real gate.
- Step 4: grep-clean — the second run is gone from codegen
Run: rg -n 'infer_module_with_cross|UniquenessTable' crates/ailang-codegen/
Expected: no matches. (The pass and its table type are no longer
referenced anywhere in codegen; they remain ailang-check-internal.)
Task 5: Record the consume relocation + the mir.3 split in the spec
Files:
-
Modify:
docs/specs/0060-typed-mir.md -
Step 1: Note the split and the consume relocation
In docs/specs/0060-typed-mir.md, under the Iteration-decomposition
table (after the mir.2 refinement note added in the previous
iteration), append:
> mir.3 split (discovered in planning, `docs/plans/0117-mir.3a-consume-count-relocation.md`):
> mir.3 ships in two iterations. **mir.3a** relocates `consume_count`
> only — the deletion of the second `infer_module_with_cross` run
> depends solely on it (all three codegen drop sites read `consume_count`
> from `self.uniqueness`; the *modes* they also test come from the
> type, not the uniqueness pass, which carries no mode). The per-binder
> `consume_count` lands on a new `MirDef.consume: BTreeMap<String, u32>`
> (binder-keyed, matching how the drop sites and the `UniquenessTable`
> key it — the spec's `MArg.consume_count` field 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** fills
> `MArg.mode` / `MTerm::Let.mode` from the type modes and switches
> `emit_call`'s anon-temp borrow-slot gate onto `MArg.mode` (additive;
> no further re-deriver deletion).
- Step 2: No-op re-gate
Run: cargo test --workspace
Expected: PASS (doc-only edit; fast re-confirm).
Self-review (planner, inline)
- Spec coverage. mir.3 row's named deletion ("second
infer_module_with_crossrun") + theconsume_countMIR addition are covered (Tasks 1-3); theModehalf is explicitly deferred to mir.3b and recorded in the spec (Task 5). The split is justified by the consumer analysis, not by effort. - Placeholder scan. No "TBD/TODO/similar to". Every code step
carries exact before→after bytes. The two read-the-region points
(the
Def::FnMirDef construction site inlower_module; themir_modulebinding name at codegen:1029) are framed as read-then-apply with the exact transformation, not open decisions. - Type/name consistency.
MirDef.consume,cross_module_types,infer_module_with_cross,UniquenessInfo.consume_count,self.consume,current_def, the three site paths — consistent across tasks. - Step granularity. Each step is one edit or one command.
- No commit steps. None present.
- Pin/replacement substring contiguity. The grep gate (Task 4
Step 4) asserts on
infer_module_with_cross/UniquenessTable— single tokens, contiguous. The producer pin assertsconsume.contains_key("c")— a string key, not a wrapped substring. - Compile-gate vs deferred-caller ordering. Task 1's gate is
explicitly partial (
-p ailang-mir); Task 2 threads everyMirDefconstruction site (there is one fn arm) and the singlelower_modulecaller (elaborate_workspace) in the same task, closing with a workspace build. Task 2 changeslower_module's signature and updates its only caller in the same task — no deferred caller past a build gate. Task 3's first gate is the workspace build after all three reads + the field/import deletions are threaded together. - Verification-command filter strings resolve.
cargo test -p ailang-check --test lower_to_mir_tytargets a real file;mirdef_consume_is_populated_for_let_binderis the exact new test name; the leak pins are named by their real test fns (recon-confirmed:alloc_rc_loop_valued_rawbuf_binder_drops_at_scope_close,alloc_rc_heap_loop_binder_replaced_by_recur_does_not_leak,alloc_rc_print_int_does_not_leak_show_result_str); the workspace gates are unfiltered; therggate asserts on real present symbols expected to reach zero in codegen. - Parse-the-bytes gate. mir.3a inlines NO new surface-language
.ailfixture (the producer pin reuses the existing committedexamples/new_counter_user_adt.ail). Thespec_validationgate has no surface body to parse — documented no-op for this plan.
Handoff
Plan sits unstaged at docs/plans/0117-mir.3a-consume-count-relocation.md.
Hand to implement (standard mode, all five tasks). The orchestrator
commits the plan when handing it forward and the iteration diff at iter
close.