Executable plan for the second of two iterations delivering spec 0060's mir.3 row. A focused recon (the anon-temp gate + the per-arg mode sources) established two refinements to the spec sketch, both locked in the plan: 1. module_def_ail_types is deleted in mir.3b, not mir.5. A grep proved self.module_def_ail_types has exactly ONE reader — the emit_call anon-temp borrow-slot drop gate. The own-param drop reads param_modes from the def's own f.ty, not this table. So switching that gate onto MArg.mode leaves the table dead; deleting it now is the clean move. mir.5's "last re-derivation residue" shrinks to element-type/Term::New. 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/Ctor/Recur args have no per-arg mode (EffectOpSig/Ctor.fields/loop-binders carry none) -> stay Owned default. Let.mode has no source (ast::Term::Let has no mode field) and no consumer (the let-drop gate reads consume, not Let.mode) -> stays Owned. Safety property (recon-confirmed on the #43 anon-temp witness): 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 -> args[0].mode = Borrow, exactly the value module_def_ail_types yielded. Four tasks: (1) producer fills App-arg MArg.mode via a param_mode_at helper; (2) codegen gate reads arg.mode + delete module_def_ail_types (field + construction + threading, compiler-completeness-checked); (3) producer pin (App arg mode == Borrow) + the live=0 anon-temp acceptance (raw_buf_owned_drop_balances_rc_stats); (4) record the refinements in spec 0060. No new .ail fixture (reuses raw_buf_drop_min.ail).
20 KiB
mir.3b — fill MArg.mode, switch the anon-temp drop gate onto it, delete module_def_ail_types — Implementation Plan
Parent spec:
docs/specs/0060-typed-mir.md(Iteration decomposition table, mir.3 row — second of two iterations; see mir.3a plandocs/plans/0117-*)For agentic workers: REQUIRED SUB-SKILL: use the
implementskill to run this plan. Steps use- [ ]checkboxes for tracking.
Goal: Fill MArg.mode for App args from the callee's param modes,
switch codegen's emit_call anon-temp borrow-slot drop gate to read
MArg.mode instead of re-looking-up the callee's param_modes from
the codegen-side module_def_ail_types table, and — since that gate is
the table's only reader — delete module_def_ail_types entirely.
Architecture: lower_to_mir's Term::App arm already holds the
resolved callee fn-type (sig = synth_pure(callee), a Type::Fn with
param_modes). mir.3b reads sig.param_modes[i] into each App arg's
MArg.mode. Codegen's anon-temp gate then tests arg.mode == Borrow
in place of the module_def_ail_types[target][def].param_modes[i]
lookup. With that read gone, module_def_ail_types has zero readers
and is removed (field + construction + threading). This is a faithful
swap: MArg.mode is filled from the same callee fn-type the gate read
from the table, so the gate's behaviour is unchanged.
Tech Stack: ailang-check::lower_to_mir (producer: App-arg mode
fill), ailang-codegen (consumer: the gate + the deleted table).
Design decisions locked before this plan (orchestrator)
Two refinements to the spec's mir.3b sketch, settled in planning from a focused recon:
-
module_def_ail_typesis deleted in mir.3b, not mir.5. The spec parked it as mir.5's "last re-derivation residue", assuming multiple readers. A grep provedself.module_def_ail_typesis read at exactly one site — theemit_callanon-temp gate (lib.rs:2701). The own-param drop readsparam_modesfrom the def's ownf.ty(lib.rs:1356,1507), not from this table. So once mir.3b switches the gate ontoMArg.mode, the table is dead; leaving it behind would be dead code with a#[allow]. Deleting it now is the clean, telos-aligned move (codegen reads the param mode off MIR, not a parallel sig table). mir.5's residue shrinks to element-type /Term::New(New.elem). -
MArg.modeis filled for App args only;MTerm::Let.modeis NOT filled. Of the fourarg()call sites, only App args have a real per-arg mode source (the calleeType::Fn.param_modes). Do args (EffectOpSighas noparam_modes), Ctor args (ast::Ctor.fieldsisVec<Type>, no per-field mode), and Recur args (loop binders carry no mode) have no mode to fill → they stayMode::Owned(documented default; these positions consume their arg). AndMTerm::Let.modehas no source (ast::Term::Letcarries no mode field) and no consumer (the let-drop gate readsself.consume, notLet.mode) — filling it would be inventing a value nobody reads. So mir.3b leavesLet.modeat itsOwneddefault.
The safety property (recon-confirmed on the #43 anon-temp witness): for
(app RawBuf.get (app RawBuf.set buf 0 10) 0), sig = synth_pure(RawBuf.get)
carries param_modes[0] = Borrow (kernel sig
crates/ailang-kernel/src/raw_buf/source.ail:14), so args[0].mode
becomes Borrow — exactly the value the gate currently reads from
module_def_ail_types. Same source (the callee fn-type), so the drop
fires identically.
Files this plan creates or modifies:
- Modify:
crates/ailang-check/src/lower_to_mir.rs— App-armMArg.modefill + aparam_mode_athelper. - Modify:
crates/ailang-codegen/src/lib.rs— the anon-temp gate (:2699-2723); deletemodule_def_ail_types(:328,332,401,439,782,985,1045). - Test:
crates/ailang-check/tests/lower_to_mir_ty.rs— producer pin (App arg mode == Borrow). - Modify (docs):
docs/specs/0060-typed-mir.md— record the mir.3b refinements.
Task 1: Producer — fill MArg.mode for App args
Files:
-
Modify:
crates/ailang-check/src/lower_to_mir.rs(theTerm::Apparm + a new helper) -
Step 1: Add the
param_mode_athelper
Insert above fn lower_term (near the arg() helper at
crates/ailang-check/src/lower_to_mir.rs:103-107):
/// The MIR `Mode` of a callee's parameter at position `i`, read off
/// the callee's `Type::Fn.param_modes`. `Borrow` maps to
/// `Mode::Borrow`; `Own` and `Implicit` (the `Implicit ≡ Own`
/// contract, `ast.rs` ParamMode) both map to `Mode::Owned`. Out of
/// range / non-fn → `Owned` (the consume default). Used by the App arm
/// to fill `MArg.mode` so codegen reads the slot mode off MIR.
fn param_mode_at(sig: &Type, i: usize) -> Mode {
match sig {
Type::Fn { param_modes, .. } => {
match param_modes.get(i) {
Some(ailang_core::ast::ParamMode::Borrow) => Mode::Borrow,
_ => Mode::Owned,
}
}
_ => Mode::Owned,
}
}
Implementer note:
Modeis already imported inlower_to_mir.rs(thearg()helper buildsMode::Owned).Typeis imported (the file'suseline).ailang_core::ast::ParamModeis referenced fully-qualified above; if the file already importsParamMode, use the short name.Type::Fn's field isparam_modes: Vec<ParamMode>(crates/ailang-core/src/ast.rs:781).
- Step 2: Fill the App args with their slot modes
In the Term::App arm of lower_term (the arm landed by mir.2 — it
binds let sig = ctx.synth_pure(callee)?; and builds m_args via the
arg() helper over args.iter()), replace the m_args construction:
let m_args = args
.iter()
.map(|a| Ok(arg(lower_term(ctx, a)?)))
.collect::<Result<Vec<_>>>()?;
with a per-arg mode read off the callee sig already in scope:
// mir.3b: each App arg carries the callee's slot mode, read
// off the resolved callee fn-type. codegen's anon-temp drop
// gate reads this instead of re-looking-up the callee's
// param_modes from a sig table.
let m_args = args
.iter()
.enumerate()
.map(|(i, a)| {
Ok(MArg {
term: lower_term(ctx, a)?,
mode: param_mode_at(&sig, i),
consume_count: 1,
})
})
.collect::<Result<Vec<_>>>()?;
Implementer note: this is the ONLY
arg()call site changed. The Do, Ctor, and Recur arms keeparg(...)(their args have no per-arg mode source —Mode::Owneddefault).MArgis already imported.sigis thelet sig = ctx.synth_pure(callee)?;binding the mir.2 arm introduced — confirm it is in scope at them_argsconstruction (it is used just above to buildm_callee).
- Step 3: Build the producer crate
Run: cargo build -p ailang-check
Expected: PASS. (codegen still reads the gate from module_def_ail_types
— unchanged until Task 2 — so the workspace also still builds; this is
a clean partial gate.)
- Step 4: Build the workspace (still green — gate not yet switched)
Run: cargo build --workspace
Expected: PASS. MArg.mode is now filled but the codegen gate still
reads the table; behaviour is unchanged.
Task 2: Consumer — gate reads MArg.mode; delete module_def_ail_types
Files:
-
Modify:
crates/ailang-codegen/src/lib.rs:2699-2723(the gate) -
Modify:
crates/ailang-codegen/src/lib.rs:328-330,332,401,439,782,985,1045(delete the table) -
Step 1: Switch the anon-temp gate to read
MArg.mode
At crates/ailang-codegen/src/lib.rs:2699-2723, replace:
if !tail {
if let Some(Type::Fn { param_modes, .. }) = self
.module_def_ail_types
.get(target_module)
.and_then(|m| m.get(target_def))
.cloned()
{
for (i, (arg, (arg_ssa, arg_ty))) in
args.iter().zip(compiled_args.iter()).enumerate()
{
let is_borrow_slot =
matches!(param_modes.get(i), Some(ParamMode::Borrow));
if is_borrow_slot
&& arg_ty == "ptr"
&& arg_ssa != &dst
&& self.is_rc_heap_allocated(&arg.term)
{
let drop_sym = self.drop_symbol_for_binder(&arg.term, arg_ssa);
self.body.push_str(&format!(
" call void @{drop_sym}(ptr {arg_ssa})\n"
));
}
}
}
}
with:
if !tail {
// mir.3b: the per-arg slot mode rides on the MIR arg
// (`MArg.mode`, filled by lower_to_mir from the callee's
// `param_modes`), so the borrow-slot test reads it directly
// — no re-lookup of the callee's signature from a codegen
// sig table. `Borrow` slots borrow the arg, so an Own-ret
// heap temp landing in one is dropped here; `Own`/`Implicit`
// slots consume the arg (the callee dec's it).
for (arg, (arg_ssa, arg_ty)) in args.iter().zip(compiled_args.iter()) {
let is_borrow_slot = matches!(arg.mode, Mode::Borrow);
if is_borrow_slot
&& arg_ty == "ptr"
&& arg_ssa != &dst
&& self.is_rc_heap_allocated(&arg.term)
{
let drop_sym = self.drop_symbol_for_binder(&arg.term, arg_ssa);
self.body.push_str(&format!(
" call void @{drop_sym}(ptr {arg_ssa})\n"
));
}
}
}
Implementer note:
Modemust be in scope — add it to theuse ailang_mir::{...}line atlib.rs:40(currentlyCallee, MArg, MTerm, MirDef, MirWorkspace→ addMode).argis already&MArgin the loop, soarg.modeis direct.ParamModeandTyperemain imported (still used by the own-param drop at:1356,:1507and elsewhere) — do not remove their imports.
- Step 2: Delete the
module_def_ail_typesfield and all its plumbing
self.module_def_ail_types now has zero readers (Step 1 removed the
only one). Remove the field and every construction/threading site.
Read each region and delete cleanly:
lib.rs:782— theEmitterfield declarationmodule_def_ail_types: &'a BTreeMap<...>,. Delete the field line (and its doc comment if separable).lib.rs:985— theEmitter::newparametermodule_def_ail_types: &'a BTreeMap<...>,. Delete the parameter.lib.rs:1045— themodule_def_ail_types,field init in theSelf { ... }literal. Delete.lib.rs:439— the&module_def_ail_types,argument at theEmitter::newcall site. Delete the argument.lib.rs:401—module_def_ail_types.insert(mname.clone(), ail_types);. Delete; and read upward (:390-401) to delete theail_typeslocal it inserts IF that local is built solely to feed this insert (it is the AILang-Type-per-fn map; confirm no other use before deleting its construction loop).lib.rs:332—let mut module_def_ail_types: BTreeMap<...> = BTreeMap::new();. Delete.lib.rs:328-330— the doc comment describingmodule_def_ail_types. Delete (it documents a now-removed field).
Implementer note: the compiler is the completeness check here — after the field/param/init/arg deletions, any residual reference is a hard error naming the exact line. Work outside-in (field → param → init → call-arg → construction) and let
cargo buildflag anything missed. Do NOT touchmodule_user_fns,module_ctor_index,module_consts, orcurrent_param_modes— those are different tables with live readers.
- Step 3: Build the workspace (the consuming gate)
Run: cargo build --workspace
Expected: PASS. Zero errors, zero unused/dead_code warnings for
module_def_ail_types (it is fully removed) or ail_types (its
construction removed in Step 2.5). If a Type/ParamMode import is
now unused, the compiler will say so — but both have other live readers
(:1356,:1507), so they should remain used.
Task 3: Acceptance — anon-temp drop still fires, now from MArg.mode
Files:
-
Test:
crates/ailang-check/tests/lower_to_mir_ty.rs(producer pin) -
Step 1: Producer pin — the borrow-slot App arg carries
Mode::Borrow
Add a test to crates/ailang-check/tests/lower_to_mir_ty.rs that
elaborates the existing anon-temp witness fixture and asserts the
borrow-slot arg's mode. The fixture examples/raw_buf_drop_min.ail has
main body (app RawBuf.get (app RawBuf.set buf 0 10) 0) — RawBuf.get
param 0 is (borrow (con RawBuf a)), so the outer App's arg 0 must be
Mode::Borrow:
#[test]
fn app_arg_carries_callee_borrow_mode() {
// mir.3b: lower_to_mir fills MArg.mode from the callee's param_modes.
// In `(app RawBuf.get <set-result> 0)`, RawBuf.get's param 0 is a
// borrow receiver, so the outer App's arg 0 must be Mode::Borrow —
// the value codegen's anon-temp drop gate now reads off MIR.
let ws = elaborate_fixture("raw_buf_drop_min");
let m = ws.modules.get("raw_buf_drop_min").expect("module");
let main = m.defs.iter().find(|d| d.name == "main").expect("main");
// Navigate to the outer App (the `RawBuf.get` call). Read the
// sibling tests for the helper that reaches a named call node; the
// body is `(let buf … (app RawBuf.get (app RawBuf.set …) 0))`, so
// the outer App is the Let body.
let outer = find_app_with_callee(&main.body, "RawBuf.get")
.expect("RawBuf.get App");
if let MTerm::App { args, .. } = outer {
assert!(
matches!(args[0].mode, Mode::Borrow),
"RawBuf.get arg 0 (borrow receiver) must be Mode::Borrow, got {:?}",
args[0].mode,
);
} else {
panic!("expected MTerm::App");
}
}
Implementer note: match the file's real fixture helper (
elaborate_fixture) and add a small navigation helperfind_app_with_callee(t: &MTerm, name: &str) -> Option<&MTerm>that walks the MTerm tree (recursing through Let body/init, App args, If branches, etc.) returning the firstMTerm::Appwhose callee is aCallee::Static/Builtinwith the given name — OR, simpler, reach the node structurally by matchingmain.bodyasMTerm::Let { body, .. }and then the body as theRawBuf.getApp (read the fixture:mainis(let buf (new RawBuf …) (app RawBuf.get …))). ImportMTerm,Mode,Calleefromailang_mir. The exact navigation is an implementer choice; the assertion (arg 0 mode == Borrow) is the fixed target.
- Step 2: Run the producer pin
Run: cargo test -p ailang-check --test lower_to_mir_ty
Expected: PASS — including app_arg_carries_callee_borrow_mode.
- Step 3: Full suite — the anon-temp drop acceptance
Run: cargo test --workspace
Expected: PASS. The drop-correctness witness for this gate is
raw_buf_owned_drop_balances_rc_stats (crates/ail/tests/e2e.rs,
fixture examples/raw_buf_drop_min.ail, asserts live == 0): the
Own-ret RawBuf.set temp passed into RawBuf.get's borrow slot must
still be dropped — now driven by MArg.mode. A wrong mode fill →
either a leak (live > 0, drop skipped) or a double-free (drop fired on
an owned slot). The broader RC-stats leak pins
(raw_buf_loop_no_leak_pin, loop_recur_heap_binder_no_leak_pin,
print_no_leak_pin) stay green; loop_recur_str_binder_no_leak_pin
stays #[ignore] (#49, mir.4); #51/#53 build guards green.
Run the FULL workspace suite — this perturbs the call-site drop path.
Task 4: Record the mir.3b refinements in the spec
Files:
-
Modify:
docs/specs/0060-typed-mir.md -
Step 1: Note the App-only mode fill + the early table deletion
In docs/specs/0060-typed-mir.md, after the mir.3-split note (added by
the mir.3a plan), append:
> mir.3b refinement (discovered in planning, `docs/plans/0118-mir.3b-marg-mode-and-table-delete.md`):
> `MArg.mode` is filled for App args only — the callee's `Type::Fn.param_modes`
> is the sole real per-arg mode source; Do args (`EffectOpSig` has no
> param modes), Ctor args (no per-field mode), and Recur args (loop
> binders carry no mode) stay `Mode::Owned`. `MTerm::Let.mode` is NOT
> filled: a let-binder has no author-declared mode and no codegen
> consumer (the let-drop gate reads `consume`, not `Let.mode`), so it
> stays `Owned`. Switching `emit_call`'s anon-temp borrow-slot gate
> onto `MArg.mode` removes the only reader of codegen's
> `module_def_ail_types` table, 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.
- Step 2: No-op re-gate
Run: cargo test --workspace
Expected: PASS (doc-only edit).
Self-review (planner, inline)
- Spec coverage. mir.3 row's
ModeMIR addition is delivered for the position that has a real source (App args) and wired to its one consumer (the anon-temp gate); the gate switch additionally retiresmodule_def_ail_types. The non-filled positions (Do/Ctor/Recur/Let) are justified by the absence of a mode source/consumer, not by effort, and recorded in the spec (Task 4). - Placeholder scan. No "TBD/TODO/similar to". Exact before→after for the substantive edits (the helper, the App arm, the gate); the field-deletion (Task 2 Step 2) is an enumerated outside-in site list the compiler completeness-checks, and the test navigation (Task 3) is a framed implementer-choice with a fixed assertion target — neither is an open design decision.
- Type/name consistency.
param_mode_at,MArg.mode,Mode::Borrow,module_def_ail_types,arg.mode,is_rc_heap_allocated,drop_symbol_for_binderconsistent across tasks. - Step granularity. Each step is one edit or one command.
- No commit steps. None present.
- Pin/replacement substring contiguity. No grep-presence pin paired
with a wrapped replacement. The producer pin asserts on
args[0].modematchingMode::Borrow(a pattern match, not a substring). - Compile-gate vs deferred-caller ordering. Task 1 changes no
signature (the
arg()helper is untouched; the App arm buildsMArginline) and its gate is a clean per-crate then workspace build. Task 2 deletes the field and threads every construction/init/arg site in the same task, closing with the workspace build — no caller deferred past a build gate. TheEmitter::newsignature change (Step 2.2) and its one call site (Step 2.4) are in the same task. - Verification-command filter strings resolve.
cargo test -p ailang-check --test lower_to_mir_tytargets a real file;app_arg_carries_callee_borrow_modeis the exact new test;raw_buf_owned_drop_balances_rc_statsis the real anon-temp witness (recon-confirmed,e2e.rs); the workspace gates are unfiltered. - Parse-the-bytes gate. mir.3b inlines NO new
.ailfixture (the producer pin reuses the committedexamples/raw_buf_drop_min.ail). Documented no-op for thespec_validationgate.
Handoff
Plan sits unstaged at docs/plans/0118-mir.3b-marg-mode-and-table-delete.md.
Hand to implement (standard mode, all four tasks). The orchestrator
commits the plan when handing it forward and the iteration diff at iter
close.