Files
AILang/docs/plans/0118-mir.3b-marg-mode-and-table-delete.md
T
Brummel 0bd7f47fad plan: mir.3b fill MArg.mode, switch the anon-temp gate onto it, delete module_def_ail_types
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).
2026-05-31 20:00:12 +02:00

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 plan docs/plans/0117-*)

For agentic workers: REQUIRED SUB-SKILL: use the implement skill 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:

  1. module_def_ail_types is deleted in mir.3b, not mir.5. The spec parked it as mir.5's "last re-derivation residue", assuming multiple readers. A grep proved self.module_def_ail_types is read at exactly one site — the emit_call anon-temp gate (lib.rs:2701). The own-param drop reads param_modes from the def's own f.ty (lib.rs:1356,1507), not from this table. So once mir.3b switches the gate onto MArg.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).

  2. MArg.mode is filled for App args only; MTerm::Let.mode is NOT filled. Of the four arg() call sites, 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 (ast::Ctor.fields is Vec<Type>, no per-field mode), and Recur args (loop binders carry no mode) have no mode to fill → they stay Mode::Owned (documented default; these positions consume their arg). And MTerm::Let.mode has no source (ast::Term::Let carries no mode field) and no consumer (the let-drop gate reads self.consume, not Let.mode) — filling it would be inventing a value nobody reads. So mir.3b leaves Let.mode at its Owned default.

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-arm MArg.mode fill + a param_mode_at helper.
  • Modify: crates/ailang-codegen/src/lib.rs — the anon-temp gate (:2699-2723); delete module_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 (the Term::App arm + a new helper)

  • Step 1: Add the param_mode_at helper

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: Mode is already imported in lower_to_mir.rs (the arg() helper builds Mode::Owned). Type is imported (the file's use line). ailang_core::ast::ParamMode is referenced fully-qualified above; if the file already imports ParamMode, use the short name. Type::Fn's field is param_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 keep arg(...) (their args have no per-arg mode source — Mode::Owned default). MArg is already imported. sig is the let sig = ctx.synth_pure(callee)?; binding the mir.2 arm introduced — confirm it is in scope at the m_args construction (it is used just above to build m_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: Mode must be in scope — add it to the use ailang_mir::{...} line at lib.rs:40 (currently Callee, MArg, MTerm, MirDef, MirWorkspace → add Mode). arg is already &MArg in the loop, so arg.mode is direct. ParamMode and Type remain imported (still used by the own-param drop at :1356,:1507 and elsewhere) — do not remove their imports.

  • Step 2: Delete the module_def_ail_types field 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:

  1. lib.rs:782 — the Emitter field declaration module_def_ail_types: &'a BTreeMap<...>,. Delete the field line (and its doc comment if separable).
  2. lib.rs:985 — the Emitter::new parameter module_def_ail_types: &'a BTreeMap<...>,. Delete the parameter.
  3. lib.rs:1045 — the module_def_ail_types, field init in the Self { ... } literal. Delete.
  4. lib.rs:439 — the &module_def_ail_types, argument at the Emitter::new call site. Delete the argument.
  5. lib.rs:401module_def_ail_types.insert(mname.clone(), ail_types);. Delete; and read upward (:390-401) to delete the ail_types local 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).
  6. lib.rs:332let mut module_def_ail_types: BTreeMap<...> = BTreeMap::new();. Delete.
  7. lib.rs:328-330 — the doc comment describing module_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 build flag anything missed. Do NOT touch module_user_fns, module_ctor_index, module_consts, or current_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 helper find_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 first MTerm::App whose callee is a Callee::Static/Builtin with the given name — OR, simpler, reach the node structurally by matching main.body as MTerm::Let { body, .. } and then the body as the RawBuf.get App (read the fixture: main is (let buf (new RawBuf …) (app RawBuf.get …))). Import MTerm, Mode, Callee from ailang_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)

  1. Spec coverage. mir.3 row's Mode MIR 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 retires module_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).
  2. 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.
  3. Type/name consistency. param_mode_at, MArg.mode, Mode::Borrow, module_def_ail_types, arg.mode, is_rc_heap_allocated, drop_symbol_for_binder consistent across tasks.
  4. Step granularity. Each step is one edit or one command.
  5. No commit steps. None present.
  6. Pin/replacement substring contiguity. No grep-presence pin paired with a wrapped replacement. The producer pin asserts on args[0].mode matching Mode::Borrow (a pattern match, not a substring).
  7. Compile-gate vs deferred-caller ordering. Task 1 changes no signature (the arg() helper is untouched; the App arm builds MArg inline) 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. The Emitter::new signature change (Step 2.2) and its one call site (Step 2.4) are in the same task.
  8. Verification-command filter strings resolve. cargo test -p ailang-check --test lower_to_mir_ty targets a real file; app_arg_carries_callee_borrow_mode is the exact new test; raw_buf_owned_drop_balances_rc_stats is the real anon-temp witness (recon-confirmed, e2e.rs); the workspace gates are unfiltered.
  9. Parse-the-bytes gate. mir.3b inlines NO new .ail fixture (the producer pin reuses the committed examples/raw_buf_drop_min.ail). Documented no-op for the spec_validation gate.

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.