Files
AILang/docs/plans/0126-branch-consume-split-drop.md
T
Brummel 3447ac8039 plan: branch-consume-split drop soundness (#63 leg 3)
Five-task plan for spec 0068, decomposed RED-first:
1. RED — new if-branch fixture + pin, un-ignore the committed match pin.
2. CAPTURE — retain per-branch consume snapshots in uniqueness (new
   BranchConsume + infer_module_with_cross_branches; old API delegates).
3. CARRY — additive MArm.consume / MTerm::If.{then,else}_consume,
   attached by a traversal-order cursor in lower_to_mir (post-order pop,
   exhaustion assert, neutral for const bodies) + lock-step test.
4. GATE — emit_leakclass_branch_param_drops helper + :823 gate-source
   switch to per-arm; fall-through drops in match arms + if branches.
   Disjoint-by-aggregate from the unchanged fn-return dec.
5. VERIFY — series_sma live=0 pin, full workspace suite, lockspair check.

All inlined code lifted from the live source bodies (uniqueness.rs,
lower_to_mir.rs, match_lower.rs, lib.rs) read this session, not from
memory. The one new surface fixture (if-branch) parse-checks clean; the
correspondence test reuses the existing match fixture via the
lower_to_mir_ty.rs elaborate_fixture/body helpers (verified present).

refs #63
2026-06-02 15:25:58 +02:00

37 KiB

Branch-Consume-Split Drop Soundness (#63 leg 3) — Implementation Plan

Parent spec: docs/specs/0068-branch-consume-split-drop.md

For agentic workers: REQUIRED SUB-SKILL: use the implement skill to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Drop an (own ...) heap param exactly once per control-flow path when it is consumed on one match-arm / if-branch but stays live on a sibling — closing the last leg of #63 and the residual live=2 leak in series_sma (#61).

Architecture: The uniqueness pass already computes a per-branch consume snapshot for every if/match branch and discards it at the max merge in merge_states. Retain those snapshots, carry them additively on MArm.consume / MTerm::If.{then,else}_consume (attached by lower_to_mir in pre-order traversal-order lock-step with the uniqueness walk, since the AST carries no node id), and let codegen emit a NEW fall-through drop for the leak class — branch_consume[p] == 0 && aggregate >= 1. The drop sites partition by aggregate consume (== 0 → existing fn-return/pre-tail-call machinery; >= 1 → per-branch), so no param is dropped twice and no tail-position analysis is needed; the checker's use-after-consume rejection guarantees an aggregate >= 1 param is dead past the construct, so the per-branch drop is path-terminal.

Tech Stack: ailang-check (uniqueness capture + MIR lowering cursor), ailang-mir (additive branch-consume fields), ailang-codegen (leak-class drop helper + gate-source switch), ail integration pins.


Files this plan creates or modifies:

  • Create: examples/if_branch_consume_split_no_leak_pin.ail — new RED fixture (if-branch split).
  • Create: crates/ail/tests/if_branch_consume_split_no_leak_pin.rs — new RED pin, live==0.
  • Create: crates/ail/tests/series_sma_no_leak_pin.rs — #61 close pin, series_sma live==0.
  • Modify: crates/ail/tests/match_arm_consume_split_no_leak_pin.rs:74 — remove #[ignore].
  • Modify: crates/ailang-check/src/uniqueness.rsBranchConsume enum, Walker.branch_consumes, capture at If/Match, UniquenessResult + infer_module_with_cross_branches.
  • Modify: crates/ailang-mir/src/lib.rs:91,116 — additive fields on MTerm::If and MArm.
  • Modify: crates/ailang-check/src/lower_to_mir.rsCtx cursor fields, attach at If/Match, switch to the branches API, exhaustion guard.
  • Modify: crates/ailang-codegen/src/escape.rs:612,737,747 — thread the new MArm field in test-mod constructions.
  • Modify: crates/ailang-codegen/src/match_lower.rs:823,~965,~1009:823 gate-source switch; new fall-through drop in ctor-arms loop + default/open arm.
  • Modify: crates/ailang-codegen/src/lib.rs:1906MTerm::If destructure + per-branch drop in both branches; add the emit_leakclass_branch_param_drops helper.
  • Test: crates/ailang-check/tests/lower_to_mir_ty.rs — lock-step correspondence test.

Task 1: RED — if-branch fixture + pin, un-ignore match pin

Files:

  • Create: examples/if_branch_consume_split_no_leak_pin.ail

  • Create: crates/ail/tests/if_branch_consume_split_no_leak_pin.rs

  • Modify: crates/ail/tests/match_arm_consume_split_no_leak_pin.rs:74

  • Step 1: Write the if-branch RED fixture

Create examples/if_branch_consume_split_no_leak_pin.ail verbatim (this is the spec's Concrete-code block 2; ail check passes today with only an over-strict-mode warning on sink):

(module if_branch_consume_split_no_leak_pin
  (data Box (ctor B (con Int)))
  (fn sink
    (type (fn-type (params (own (con Box))) (ret (own (con Unit))) (effects IO)))
    (params b)
    (body (match b (case (pat-ctor B n) (do io/print_str "x")))))
  (fn run_if
    (type (fn-type (params (own (con Box)) (own (con Int))) (ret (own (con Unit))) (effects IO)))
    (params b k)
    (body
      (if (app ge k 0)
          (do io/print_str "")
          (app sink b))))
  (fn main
    (type (fn-type (params) (ret (own (con Unit))) (effects IO)))
    (params)
    (body
      (let b (term-ctor Box B 7)
        (seq
          (app run_if b 0)
          (do io/print_str "done\n"))))))
  • Step 2: Write the if-branch RED pin

Create crates/ail/tests/if_branch_consume_split_no_leak_pin.rs. This is a structural copy of the committed match_arm_consume_split_no_leak_pin.rs (same build/run/parse-stats body), with the fixture name swapped and NO #[ignore]. Header doc explains: b is consumed on the else branch (sink b), live on the then branch (ge 0 0 true → then runs), so b stays live and must be dropped on the then branch:

//! RED-pin for leg 3 of bug #63 — the per-branch-vs-per-fn consume-
//! accounting leak at an `if`-branch fall-through (the `if` half of the
//! branch-consume-split class; the `match` half is
//! match_arm_consume_split_no_leak_pin.rs).
//!
//! Property protected: under `--alloc=rc`, when an `(own ...)` heap
//! param is consumed on ONE `if` branch but is LIVE on the sibling
//! branch, it MUST be dropped on the live branch before the fn returns.
//! `run_if b 0` takes the `then` branch (`ge 0 0` is true) where `b` is
//! live; the `else` branch (`sink b`) consumes it. As of HEAD this fails:
//! the binary reports `live=1` because the per-fn aggregate consume of
//! `b` is `>= 1` (the `else` branch consumes it) so the fn-return dec
//! skips `b` and nothing drops it on the `then` path.
//!
//! `if` is a first-class `MTerm::If` (it does NOT desugar to `match`),
//! so it needs the per-branch drop in its own codegen node. Goes green
//! with the #63-leg-3 fix.

use std::path::Path;
use std::process::Command;

fn ail_bin() -> &'static str {
    env!("CARGO_BIN_EXE_ail")
}

#[test]
fn alloc_rc_owned_param_consumed_on_one_if_branch_live_on_sibling_does_not_leak() {
    let manifest_dir = env!("CARGO_MANIFEST_DIR");
    let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap();
    let src = workspace
        .join("examples")
        .join("if_branch_consume_split_no_leak_pin.ail");
    let tmp = std::env::temp_dir().join(format!(
        "ailang_if_branch_consume_split_no_leak_pin_{}",
        std::process::id()
    ));
    std::fs::create_dir_all(&tmp).unwrap();
    let out = tmp.join("bin");
    let status = Command::new(ail_bin())
        .args(["build", src.to_str().unwrap(), "--alloc=rc", "-o"])
        .arg(&out)
        .status()
        .expect("ail build failed to run");
    assert!(
        status.success(),
        "ail build --alloc=rc failed for if_branch_consume_split_no_leak_pin.ail"
    );
    let output = Command::new(&out)
        .env("AILANG_RC_STATS", "1")
        .output()
        .expect("execute binary");
    assert!(
        output.status.success(),
        "binary exited non-zero: status {:?}",
        output.status
    );
    let stderr = String::from_utf8(output.stderr).expect("stderr utf8");
    let stats_line = stderr
        .lines()
        .find(|l| l.starts_with("ailang_rc_stats:"))
        .unwrap_or_else(|| {
            panic!("missing ailang_rc_stats line in stderr; stderr was:\n{stderr}")
        });
    let mut allocs: Option<u64> = None;
    let mut frees: Option<u64> = None;
    let mut live: Option<i64> = None;
    for tok in stats_line.split_whitespace() {
        if let Some(v) = tok.strip_prefix("allocs=") {
            allocs = v.parse().ok();
        } else if let Some(v) = tok.strip_prefix("frees=") {
            frees = v.parse().ok();
        } else if let Some(v) = tok.strip_prefix("live=") {
            live = v.parse().ok();
        }
    }
    let allocs = allocs.expect("missing allocs= field");
    let frees = frees.expect("missing frees= field");
    let live = live.expect("missing live= field");
    assert_eq!(
        live, 0,
        "an (own) heap param consumed on one if branch but live on the \
         sibling branch leaks {live} slab(s) (allocs={allocs} frees={frees}): \
         the per-branch live param is never dropped because the fn-return \
         dec gates on the per-fn aggregate consume (>= 1 from the consuming \
         branch) instead of the per-branch consume."
    );
    assert_eq!(
        allocs, frees,
        "alloc/free mismatch (allocs={allocs} frees={frees} live={live})"
    );
}
  • Step 3: Un-ignore the committed match pin

In crates/ail/tests/match_arm_consume_split_no_leak_pin.rs, delete line 74:

#[ignore = "leg 3 of #63 — design fork (per-arm consume dataflow); un-ignore when fixed"]

(Leave the #[test] and everything else intact.)

  • Step 4: Run the two leak pins — expect RED

Run: cargo test -p ail --test if_branch_consume_split_no_leak_pin --test match_arm_consume_split_no_leak_pin Expected: BOTH FAIL with live=1 (assertion left: 1, right: 0). This is the RED baseline — the leak is reproduced for both constructs.

  • Step 5: Confirm legs 1/2 still pass (regression baseline)

Run: cargo test -p ail --test tail_recur_owned_param_no_leak_pin --test tail_recur_let_wrapped_no_leak_pin Expected: BOTH PASS (these are unaffected by Task 1; they are the behaviour-preservation baseline for Task 4).


Task 2: CAPTURE — retain per-branch consume in the uniqueness pass

Files:

  • Modify: crates/ailang-check/src/uniqueness.rs

  • Step 1: Add the BranchConsume type and a snapshot helper

After the BinderState struct (around line 248) add:

/// Per-branch consume snapshots retained for codegen's leak-class drop
/// siting (#63 leg 3). Each map is the absolute per-binder
/// `consume_count` at a branch's end-state — the value the `max` merge
/// in `merge_states` would otherwise collapse. The pass emits one entry
/// per `if`/`match` construct in PRE-ORDER of branch constructs (the
/// same order `lower_to_mir` lowers them), so `lower_to_mir` correlates
/// entries to MIR branch nodes by a traversal-order cursor (the AST
/// carries no node id).
#[derive(Debug, Clone)]
pub enum BranchConsume {
    If {
        then: BTreeMap<String, u32>,
        else_: BTreeMap<String, u32>,
    },
    Match {
        arms: Vec<BTreeMap<String, u32>>,
    },
}

fn snapshot_consume(binders: &BTreeMap<String, BinderState>) -> BTreeMap<String, u32> {
    binders
        .iter()
        .map(|(k, v)| (k.clone(), v.consume_count))
        .collect()
}
  • Step 2: Add the accumulator field to Walker

In struct Walker<'a> (around line 250) add a field after out:

    /// Per-branch consume snapshots, accumulated in pre-order during the
    /// walk (#63 leg 3). Drained per fn by `infer_fn`.
    branch_consumes: Vec<BranchConsume>,
  • Step 3: Capture at Term::If

Replace the Term::If arm (currently lines 297-304) with:

            Term::If { cond, then, else_ } => {
                self.walk(cond, Position::Consume);
                let saved = self.binders.clone();
                self.walk(then, pos);
                let after_then = std::mem::replace(&mut self.binders, saved);
                self.walk(else_, pos);
                // #63 leg 3: retain both branch end-states (pre-order push
                // AFTER walking both children, so nested branches inside
                // then/else are already pushed — matches lower_to_mir's
                // post-order pop).
                self.branch_consumes.push(BranchConsume::If {
                    then: snapshot_consume(&after_then),
                    else_: snapshot_consume(&self.binders),
                });
                merge_states(&mut self.binders, &after_then);
            }
  • Step 4: Capture at Term::Match

Replace the Term::Match arm (currently lines 305-322) with:

            Term::Match { scrutinee, arms } => {
                self.walk(scrutinee, Position::Borrow);
                let saved = self.binders.clone();
                let mut acc: Option<BTreeMap<String, BinderState>> = None;
                let mut arm_maps: Vec<BTreeMap<String, u32>> =
                    Vec::with_capacity(arms.len());
                for arm in arms {
                    self.binders = saved.clone();
                    self.walk_arm(arm, pos);
                    // #63 leg 3: snapshot this arm's end-state (pattern
                    // binders already popped by walk_arm) before the merge.
                    arm_maps.push(snapshot_consume(&self.binders));
                    match acc.as_mut() {
                        None => acc = Some(self.binders.clone()),
                        Some(m) => merge_states(m, &self.binders),
                    }
                }
                // pre-order push AFTER all arms (nested arm-body branches
                // already pushed) — matches lower_to_mir's post-order pop.
                self.branch_consumes
                    .push(BranchConsume::Match { arms: arm_maps });
                if let Some(merged) = acc {
                    self.binders = merged;
                } else {
                    self.binders = saved;
                }
            }
  • Step 5: Drain the accumulator per fn in infer_fn

infer_fn gains a branches_out param and drains walker.branch_consumes. Change the signature (line 181-186) to:

fn infer_fn(
    f: &FnDef,
    globals: &BTreeMap<String, Type>,
    cross_module_types: &BTreeMap<String, BTreeMap<String, Type>>,
    out: &mut UniquenessTable,
    branches_out: &mut BTreeMap<String, Vec<BranchConsume>>,
) {

Replace the param_states block (lines 192-214) so it also returns the drained branch vec, and init the new Walker field:

    let (param_states, branch_consumes) = {
        let mut walker = Walker {
            globals,
            cross_module_types,
            binders: BTreeMap::new(),
            def_name: f.name.clone(),
            out,
            branch_consumes: Vec::new(),
        };

        // Install fn parameters. Their consume counter starts at 0;
        // the walk over the body increments it.
        for name in f.params.iter() {
            walker.binders.insert(name.clone(), BinderState::default());
        }

        walker.walk(&f.body, Position::Consume);

        // Snapshot params before dropping the walker.
        let ps = f
            .params
            .iter()
            .filter_map(|name| walker.binders.get(name).map(|s| (name.clone(), s.clone())))
            .collect::<Vec<_>>();
        (ps, walker.branch_consumes)
    };

Then after the existing for (name, s) in param_states { ... } loop (ends line 223), append:

    branches_out.insert(f.name.clone(), branch_consumes);
  • Step 6: Add UniquenessResult + infer_module_with_cross_branches; delegate the old API

Add the result type near UniquenessTable (after line 100):

/// The full uniqueness output: the per-`(def, binder)` aggregate table
/// (codegen's classification + scope-close consume) AND the per-fn
/// ordered list of per-branch consume snapshots (#63 leg 3).
pub struct UniquenessResult {
    pub table: UniquenessTable,
    pub branches: BTreeMap<String, Vec<BranchConsume>>,
}

Rename the current infer_module_with_cross body to infer_module_with_cross_branches returning UniquenessResult, and make infer_module_with_cross a thin delegate. Concretely, change the signature line (133-136) to:

pub fn infer_module_with_cross_branches(
    m: &Module,
    cross_module_types: &BTreeMap<String, BTreeMap<String, Type>>,
) -> UniquenessResult {

Replace the table-build tail (lines 170-177) with:

    let mut table: UniquenessTable = BTreeMap::new();
    let mut branches: BTreeMap<String, Vec<BranchConsume>> = BTreeMap::new();
    for def in &m.defs {
        if let Def::Fn(f) = def {
            infer_fn(f, &globals, cross_module_types, &mut table, &mut branches);
        }
    }
    UniquenessResult { table, branches }
}

/// Aggregate-only convenience used by existing callers (codegen tests,
/// etc.). Delegates to [`infer_module_with_cross_branches`] and drops the
/// per-branch channel.
pub fn infer_module_with_cross(
    m: &Module,
    cross_module_types: &BTreeMap<String, BTreeMap<String, Type>>,
) -> UniquenessTable {
    infer_module_with_cross_branches(m, cross_module_types).table
}
  • Step 7: Build the crate, confirm existing uniqueness tests stay green

Run: cargo build -p ailang-check && cargo test -p ailang-check Expected: PASS (the change is purely additive — the new channel is produced but not yet consumed; the aggregate table is unchanged, so every existing test is unaffected).


Task 3: CARRY — additive MIR fields + lower_to_mir traversal-order cursor

Files:

  • Modify: crates/ailang-mir/src/lib.rs:91,116

  • Modify: crates/ailang-codegen/src/escape.rs:612,737,747

  • Modify: crates/ailang-check/src/lower_to_mir.rs

  • Test: crates/ailang-check/tests/lower_to_mir_ty.rs

  • Step 1: Add the additive fields on MArm and MTerm::If

In crates/ailang-mir/src/lib.rs, change MTerm::If (line 91) to:

    If {
        cond: Box<MTerm>,
        then: Box<MTerm>,
        else_: Box<MTerm>,
        /// #63 leg 3: per-branch consume of each owned binder on the
        /// `then` / `else` branch (absolute count at the branch end).
        /// Codegen drops a param live here (`0`) but consumed on the
        /// sibling (`MirDef.consume >= 1`). Empty for const bodies.
        then_consume: BTreeMap<String, u32>,
        else_consume: BTreeMap<String, u32>,
        ty: Type,
    },

Change struct MArm (lines 116-119) to:

pub struct MArm {
    pub pat: Pattern,
    pub body: MTerm,
    /// #63 leg 3: per-arm consume of each owned binder (absolute count at
    /// this arm's end). See `MTerm::If.then_consume`. Empty for const bodies.
    pub consume: BTreeMap<String, u32>,
}

(BTreeMap is already imported in this file — MirDef.consume uses it.)

  • Step 2: Build the workspace to enumerate the break sites

Run: cargo build --workspace 2>&1 | grep -E "error\[|missing field" | head -30 Expected: errors at the literal-construction sites only — lower_to_mir.rs (the MTerm::If at ~326 and MArm at ~381) and the three test-mod MArm literals in escape.rs (~612, ~737, ~747). All ..-pattern read sites compile clean. (This step just confirms the break set before threading.)

  • Step 3: Thread the escape.rs test-mod MArm constructions

In crates/ailang-codegen/src/escape.rs, the three MArm { pat, body } literals (around lines 612, 737, 747) gain an empty consume map. For each, add the field:

                    consume: std::collections::BTreeMap::new(),

(These are #[cfg(test)] fixtures; an empty map is correct — the escape tests do not exercise branch-consume drops.)

  • Step 4: Add the cursor fields to Ctx and a pop helper

In crates/ailang-check/src/lower_to_mir.rs, add to the Ctx struct (the struct holding env, in_def, locals, loop_stack) these fields:

    /// #63 leg 3: per-branch consume snapshots for the current def, in the
    /// uniqueness pass's pre-order. Consumed by a traversal-order cursor
    /// as `lower_term` lowers each `if`/`match` (post-order pop). Empty +
    /// `track_branches=false` for const bodies (uniqueness walks only fns).
    branch_consumes: Vec<crate::uniqueness::BranchConsume>,
    branch_idx: usize,
    track_branches: bool,

Add an impl block with the two pop helpers (place near the other Ctx methods):

impl Ctx<'_> {
    /// Pop the next `If` branch-consume entry (post-order: called AFTER
    /// lowering cond/then/else). Returns empty maps for const bodies. A
    /// kind/cursor mismatch is a compiler-internal desync — panic loudly
    /// rather than silently mis-attach a drop map.
    fn next_branch_if(
        &mut self,
    ) -> (std::collections::BTreeMap<String, u32>, std::collections::BTreeMap<String, u32>) {
        use crate::uniqueness::BranchConsume;
        if !self.track_branches {
            return (std::collections::BTreeMap::new(), std::collections::BTreeMap::new());
        }
        match self.branch_consumes.get(self.branch_idx) {
            Some(BranchConsume::If { then, else_ }) => {
                let r = (then.clone(), else_.clone());
                self.branch_idx += 1;
                r
            }
            other => panic!(
                "lower_to_mir branch-consume desync in `{}` at idx {}: expected If, got {}",
                self.in_def,
                self.branch_idx,
                match other {
                    Some(BranchConsume::Match { .. }) => "Match",
                    Some(BranchConsume::If { .. }) => "If",
                    None => "end-of-list",
                }
            ),
        }
    }

    /// Pop the next `Match` branch-consume entry (post-order: called AFTER
    /// lowering scrutinee + all arm bodies). Returns `n_arms` empty maps
    /// for const bodies. Arm-count / kind mismatch is a desync — panic.
    fn next_branch_match(
        &mut self,
        n_arms: usize,
    ) -> Vec<std::collections::BTreeMap<String, u32>> {
        use crate::uniqueness::BranchConsume;
        if !self.track_branches {
            return vec![std::collections::BTreeMap::new(); n_arms];
        }
        match self.branch_consumes.get(self.branch_idx) {
            Some(BranchConsume::Match { arms }) if arms.len() == n_arms => {
                let r = arms.clone();
                self.branch_idx += 1;
                r
            }
            other => panic!(
                "lower_to_mir branch-consume desync in `{}` at idx {}: expected Match[{}], got {}",
                self.in_def,
                self.branch_idx,
                n_arms,
                match other {
                    Some(BranchConsume::Match { arms }) => format!("Match[{}]", arms.len()),
                    Some(BranchConsume::If { .. }) => "If".into(),
                    None => "end-of-list".into(),
                }
            ),
        }
    }
}
  • Step 5: Attach per-branch maps at Term::If lowering

Replace the Term::If lowering (lines 326-331) with (lower children first, then pop — post-order, matching the uniqueness push order):

        Term::If { cond, then, else_ } => {
            let m_cond = lower_term(ctx, cond)?;
            let m_then = lower_term(ctx, then)?;
            let m_else = lower_term(ctx, else_)?;
            let (then_consume, else_consume) = ctx.next_branch_if();
            MTerm::If {
                cond: Box::new(m_cond),
                then: Box::new(m_then),
                else_: Box::new(m_else),
                then_consume,
                else_consume,
                ty,
            }
        }
  • Step 6: Attach per-arm maps at Term::Match lowering

In the Term::Match lowering (lines 359-383), build each MArm with an empty consume, then fill from the popped entry after the arm loop. Change the m_arms.push(...) line (381) to:

                m_arms.push(MArm {
                    pat: a.pat.clone(),
                    body: m_body,
                    consume: BTreeMap::new(),
                });

and insert, between the for a in arms { ... } loop close (382) and the MTerm::Match { ... } construction (383):

            let arm_maps = ctx.next_branch_match(arms.len());
            for (marm, cmap) in m_arms.iter_mut().zip(arm_maps) {
                marm.consume = cmap;
            }

(Ensure use std::collections::BTreeMap; is in scope in this file; if not, qualify as std::collections::BTreeMap::new().)

  • Step 7: Switch lower_module to the branches API + seed the cursor + exhaustion guard

In lower_module, change the uniqueness call (lines 602-603) to:

    let result =
        crate::uniqueness::infer_module_with_cross_branches(module, cross_module_types);

In the const-lowering Ctx construction (around line 612-617) add the new fields (const bodies are not walked by uniqueness → neutral cursor):

                let mut ctx = Ctx {
                    env: &env,
                    in_def: &c.name,
                    locals: IndexMap::new(),
                    loop_stack: Vec::new(),
                    branch_consumes: Vec::new(),
                    branch_idx: 0,
                    track_branches: false,
                };

In the fn-lowering Ctx construction (around line 655-660) add:

        let mut ctx = Ctx {
            env: &env,
            in_def: &f.name,
            locals,
            loop_stack: Vec::new(),
            branch_consumes: result.branches.get(&f.name).cloned().unwrap_or_default(),
            branch_idx: 0,
            track_branches: true,
        };
        let body = lower_term(&mut ctx, &f.body)?;
        assert!(
            ctx.branch_idx == ctx.branch_consumes.len(),
            "lower_to_mir branch-consume cursor desync for `{}`: consumed {} of {} entries",
            f.name,
            ctx.branch_idx,
            ctx.branch_consumes.len()
        );

(The let body = lower_term(&mut ctx, &f.body)?; already exists at line 661 — replace it with the version above that adds the trailing assert!.)

Finally, change the MirDef.consume fill (lines 667-671) to read result.table instead of the old uniqueness binding:

            consume: result
                .table
                .iter()
                .filter(|((d, _), _)| d == &f.name)
                .map(|((_, b), info)| (b.clone(), info.consume_count))
                .collect(),
  • Step 8: Build the workspace

Run: cargo build --workspace Expected: 0 errors. (Codegen still reads the aggregate MirDef.consume; the new per-branch fields are populated but not yet consumed by codegen — so behaviour is unchanged and the leak pins stay RED.)

  • Step 9: Add the lock-step correspondence test

Append to crates/ailang-check/tests/lower_to_mir_ty.rs a test that elaborates the existing match_arm_consume_split_no_leak_pin fixture and asserts the per-arm maps attach to the correct arms (guards the traversal-order cursor). Use the file's existing elaborate_fixture and body helpers — do NOT invent a new API. The fixture's run body is directly a (match f ...) with arms in source order: Off (b live) at arms[0], On (consumes b via sink) at arms[1].

/// #63 leg 3 lock-step: the per-arm consume map attaches to the matching
/// MArm in traversal order. In the `match_arm_consume_split_no_leak_pin`
/// fixture's `run`, `b` is consumed on the `On` arm (forwarded into
/// `sink`) and live on the `Off` arm — so traversal-order attachment must
/// land MArm.consume[b] == 0 on `Off` (source-order arms[0]) and >= 1 on
/// `On` (arms[1]). A cursor desync would mis-attach and flip these.
#[test]
fn branch_consume_maps_attach_to_matching_arms() {
    let m = "match_arm_consume_split_no_leak_pin";
    let mir = elaborate_fixture(m);
    let arms = match body(&mir, m, "run") {
        MTerm::Match { arms, .. } => arms,
        other => panic!("run body is not a Match: {other:?}"),
    };
    assert_eq!(
        arms[0].consume.get("b").copied().unwrap_or(99),
        0,
        "Off arm (arms[0]) must leave `b` live (consume 0); got {:?}",
        arms[0].consume.get("b")
    );
    assert!(
        arms[1].consume.get("b").copied().unwrap_or(0) >= 1,
        "On arm (arms[1]) must consume `b` (>= 1); got {:?}",
        arms[1].consume.get("b")
    );
}
  • Step 10: Run the correspondence test

Run: cargo test -p ailang-check --test lower_to_mir_ty branch_consume_maps_attach_to_matching_arms Expected: PASS.


Task 4: GATE — codegen leak-class drop (turns the RED pins GREEN)

Files:

  • Modify: crates/ailang-codegen/src/lib.rs (helper + MTerm::If branches)

  • Modify: crates/ailang-codegen/src/match_lower.rs (:823 switch + fall-through drops)

  • Step 1: Add the emit_leakclass_branch_param_drops helper

Add this method to the codegen impl in crates/ailang-codegen/src/lib.rs (near the other drop-emitting helpers — it reuses field_drop_call, partial_drop_symbol_for_type, build_moved_mask, moved_slots, current_param_modes, current_def, consume, locals, alloc):

    /// #63 leg 3: drop owned heap params that are LIVE on this branch
    /// (`branch_consume[p] == 0`) but CONSUMED on a sibling branch (per-fn
    /// aggregate `self.consume[p] >= 1`) — the leak class. The
    /// `aggregate >= 1` guard keeps this disjoint from the fn-return dec
    /// (which owns the `aggregate == 0` class), so no param is double-
    /// dropped — no fn-return disable / tail-position analysis is needed.
    /// And because the checker rejects use-after-consume, an
    /// `aggregate >= 1` param is provably dead past the branch construct,
    /// so dropping it at the branch close is safe regardless of tail
    /// position. `tail_val` is the branch's own tail SSA (a param returned
    /// by the branch transfers ownership to the join consumer — skip it);
    /// `scrutinee` is the match scrutinee binder to skip (None for `if`).
    fn emit_leakclass_branch_param_drops(
        &mut self,
        branch_consume: &std::collections::BTreeMap<String, u32>,
        tail_val: &str,
        scrutinee: Option<&str>,
    ) {
        if !matches!(self.alloc, AllocStrategy::Rc) {
            return;
        }
        let owned_params: Vec<(String, ParamMode)> = self
            .current_param_modes
            .iter()
            .map(|(n, m)| (n.clone(), *m))
            .collect();
        for (pname, mode) in &owned_params {
            if !matches!(mode, ParamMode::Own) {
                continue;
            }
            if scrutinee == Some(pname.as_str()) {
                continue;
            }
            let branch_cc = branch_consume.get(pname).copied().unwrap_or(u32::MAX);
            if branch_cc != 0 {
                continue; // consumed on this branch — ownership transferred
            }
            let agg = self
                .consume
                .get(&(self.current_def.clone(), pname.clone()))
                .copied()
                .unwrap_or(0);
            if agg == 0 {
                continue; // live on every path — the fn-return dec owns this
            }
            let p_ssa = format!("%arg_{}", pname);
            if p_ssa == tail_val {
                continue; // param is this branch's return value
            }
            let p_ail = self
                .locals
                .iter()
                .find(|(_, ssa, _, _)| ssa == &p_ssa)
                .map(|(_, _, lty, ail)| (lty.clone(), ail.clone()));
            let (p_lty, p_ail) = match p_ail {
                Some(t) => t,
                None => continue,
            };
            if p_lty != "ptr" {
                continue;
            }
            let moves = self.moved_slots.get(pname).cloned().unwrap_or_default();
            if moves.is_empty() {
                let drop_call = self.field_drop_call(&p_ail);
                self.body
                    .push_str(&format!("  call void @{drop_call}(ptr {p_ssa})\n"));
            } else {
                let sym = self.partial_drop_symbol_for_type(&p_ail);
                let mask = Self::build_moved_mask(&moves);
                if let (Some(sym), Some(mask)) = (sym, mask) {
                    self.body
                        .push_str(&format!("  call void @{sym}(ptr {p_ssa}, i64 {mask})\n"));
                } else {
                    self.body
                        .push_str(&format!("  call void @ailang_rc_dec(ptr {p_ssa})\n"));
                }
            }
        }
    }
  • Step 2: Switch the pre-tail-call dec gate source to per-arm

In crates/ailang-codegen/src/match_lower.rs, replace the aggregate lookup in the pre-tail-call Own-param dec (lines 823-827):

                    let consume_count = self
                        .consume
                        .get(&(self.current_def.clone(), pname.clone()))
                        .copied()
                        .unwrap_or(u32::MAX);

with the per-arm lookup (arm is the ctor-arms loop variable, in scope here):

                    let consume_count = arm.consume.get(pname).copied().unwrap_or(u32::MAX);

(For an aggregate == 0 param — legs 1/2 — arm.consume[p] is 0 on every arm, so this is the identical decision. It additionally drops a param live on this tail-call arm but consumed on a sibling.)

  • Step 3: Add the fall-through drop in the ctor-arms loop

In lower_match, after the arm-close pattern-binder dec block closes (immediately before the // pop bindings comment / the for _ in 0..pushed { self.locals.pop(); } loop, around line 966), insert:

            // #63 leg 3: a fall-through (non-tail-call) arm that left an
            // owned param live (arm.consume == 0) but consumed it on a
            // sibling arm (aggregate >= 1) must drop it here, before the
            // br to the join. The aggregate >= 1 guard inside the helper
            // keeps this disjoint from the fn-return dec.
            if !self.block_terminated {
                self.emit_leakclass_branch_param_drops(
                    &arm.consume,
                    &val,
                    scrutinee_binder.as_deref(),
                );
            }
  • Step 4: Add the fall-through drop in the default/open arm

In the default-block handling, inside if let Some(arm) = open_arm { ... }, after let (val, vty) = self.lower_term(&arm.body)?; and the for _ in 0..pushed { self.locals.pop(); } (around line 1009), before the if !self.block_terminated { phi_inputs.push(...) ... } block (line 1010), insert:

            // #63 leg 3: same leak-class drop for a Wild/Var default arm.
            if !self.block_terminated {
                self.emit_leakclass_branch_param_drops(
                    &arm.consume,
                    &val,
                    scrutinee_binder.as_deref(),
                );
            }
  • Step 5: Add the per-branch drops to MTerm::If codegen

In crates/ailang-codegen/src/lib.rs, change the MTerm::If destructure (line 1906) to bind the new fields:

            MTerm::If { cond, then, else_, then_consume, else_consume, .. } => {

In the then-branch emission (lines 1928-1930), emit the drop before the br:

                if !then_terminated {
                    self.emit_leakclass_branch_param_drops(then_consume, &then_v, None);
                    self.body.push_str(&format!("  br label %{join_lbl}\n"));
                }

In the else-branch emission (lines 1941-1943):

                if !else_terminated {
                    self.emit_leakclass_branch_param_drops(else_consume, &else_v, None);
                    self.body.push_str(&format!("  br label %{join_lbl}\n"));
                }
  • Step 6: Run the two leak pins — expect GREEN

Run: cargo test -p ail --test if_branch_consume_split_no_leak_pin --test match_arm_consume_split_no_leak_pin Expected: BOTH PASS (live=0, allocs==frees).

  • Step 7: Run legs 1/2 — expect still GREEN (behaviour-preservation)

Run: cargo test -p ail --test tail_recur_owned_param_no_leak_pin --test tail_recur_let_wrapped_no_leak_pin Expected: BOTH PASS (the :823 gate-source switch is identical for aggregate == 0 params; the new fall-through drop never fires for them).


Task 5: Verify — series_sma close + full workspace suite

Files:

  • Create: crates/ail/tests/series_sma_no_leak_pin.rs

  • Step 1: Write the series_sma RC-stats close pin

Create crates/ail/tests/series_sma_no_leak_pin.rs — structural copy of the if/match leak pins, building examples/series_sma.ail and asserting live==0. (series_sma_pin.rs already pins stdout; this pin is the RC ledger, distinct.) Header doc: "Pins that the #61 headline series_sma example is leak-clean under --alloc=rc. The residual live=2 was #63 leg 3 (branch-consume-split); this pin closes that leak tail." Body identical to the if-pin's build/run/parse-stats, with:

    let src = workspace.join("examples").join("series_sma.ail");

and the temp-dir / test-fn names adjusted (series_sma_no_leak_pin, series_sma_is_leak_clean), asserting assert_eq!(live, 0, ...) and assert_eq!(allocs, frees, ...).

  • Step 2: Run the series_sma pin

Run: cargo test -p ail --test series_sma_no_leak_pin Expected: PASS (live=0). This clears the #61 leak tail.

  • Step 3: Run the full workspace suite

Run: cargo test --workspace Expected: ALL PASS. (The typed-MIR re-synth strictness family means a MIR-node change can ripple — this is the gate that catches a regression the targeted pins miss, including any double-free/leak from the new drop interacting with an existing branch-tailed fn in prelude/kernel/examples.)

  • Step 4: Confirm no lockstep-pair drift

Run: cargo test -p ailang-codegen intercepts_bijection_with_intrinsic_markers Expected: PASS. (Confirms the INTERCEPTS(intrinsic) bijection is intact — this change adds no intrinsic. The Pattern::Litpre_desugar_validation pair is likewise untouched: no Pattern::Lit reject arm is added.)

  • Step 5: Spot-check the diff for the double-free invariant

Read the codegen diff and confirm, by eye, that every new drop call sits inside a branch_cc == 0 && agg >= 1 gate (the helper) and that the fn-return dec (lib.rs:1489) and arm-close pattern-binder dec (match_lower.rs:915) were NOT modified. This is the human check that the disjointness-by-aggregate invariant holds in the shipped code.