iter loop-recur.tidy GREEN: reject lambda-captures-loop-binder at check + milestone-close audit resolution

GREEN side of the RED audit-trail commit 39380d3. The loop/recur
milestone-close audit found a [high] correctness defect — a lambda
capturing a loop binder passed `ail check` then panicked
unreachable!() at codegen (crates/ailang-codegen/src/lambda.rs:102)
on type-correct input. Fixed symmetrically to mut.4-tidy: new
CheckError::LoopBinderCapturedByLambda (code
loop-binder-captured-by-lambda, bracket-free F2 Display), the
Term::Lam escape guard gained a parallel loop_stack pass. Minimal
mechanism: loop_stack element Vec<Type> -> Vec<(String,Type)> so
the already-threaded per-loop frame carries binder names; recur
still reads .1 by position (Boss-call-2 positional invariant
preserved verbatim — the name is a second field for the escape
guard only, a consumer iter-2 did not anticipate). Codegen
byte-unchanged (typecheck-only fix). carve_out 17->18 + ct1-F2 +
DESIGN.md note lockstep.

Folded in (Boss-side, audit resolution): the two [medium]
doc-honesty edits the audit surfaced (mut_var_allocas rustdoc now
states its mut-var+loop-binder dual use; Term::Loop doc-comment
now describes the real shipped state, not iter-1's stale
"per-binder phi" forward-look) + a [low] P2 roadmap todo
(plan-recon undercount countermeasure, pairs with planner Step-5
items 7+8). Bench gate: all 3 scripts exit 0, 25 metrics 0
regressed — pristine, carry-on (no baseline/ratify).

cargo test --workspace 619 -> 622 / 0 red (Boss-reran
independently, hash_pin 11/0). The loop/recur milestone is
audit-clean; fieldtest is the only remaining step before close.
This commit is contained in:
2026-05-18 00:21:06 +02:00
parent 39380d361d
commit 2ee97943bd
13 changed files with 352 additions and 59 deletions
@@ -0,0 +1,12 @@
{
"iter_id": "loop-recur.tidy",
"date": "2026-05-17",
"mode": "mini",
"outcome": "DONE",
"tasks_total": 1,
"tasks_completed": 1,
"reloops_per_task": { "1": 0 },
"review_loops_spec": 0,
"review_loops_quality": 0,
"blocked_reason": null
}
+7
View File
@@ -226,6 +226,13 @@ fn check_human_mode_renders_mut_diagnostic_code_exactly_once() {
"test_mut_var_captured_by_lambda.ail.json", "test_mut_var_captured_by_lambda.ail.json",
"mut-var-captured-by-lambda", "mut-var-captured-by-lambda",
), ),
// Iter loop-recur.tidy: symmetric loop-binder-capture variant.
// Its Display body is likewise bracket-`[code]`-free so the
// human formatter supplies `[<code>]` exactly once.
(
"test_loop_binder_captured_by_lambda.ail.json",
"loop-binder-captured-by-lambda",
),
]; ];
for (fixture_name, code) in cases { for (fixture_name, code) in cases {
let fixture = examples_dir().join(fixture_name); let fixture = examples_dir().join(fixture_name);
+2 -2
View File
@@ -351,7 +351,7 @@ mod tests {
let mut warnings: Vec<crate::diagnostic::Diagnostic> = Vec::new(); let mut warnings: Vec<crate::diagnostic::Diagnostic> = Vec::new();
// loop-recur iter 2: test helper synths one term from top-of- // loop-recur iter 2: test helper synths one term from top-of-
// body — fresh empty loop-stack (mirrors mut_scope_stack). // body — fresh empty loop-stack (mirrors mut_scope_stack).
let mut loop_stack: Vec<Vec<Type>> = Vec::new(); let mut loop_stack: Vec<Vec<(String, Type)>> = Vec::new();
let ty = crate::synth( let ty = crate::synth(
t, t,
&env, &env,
@@ -598,7 +598,7 @@ mod tests {
let mut warnings: Vec<crate::diagnostic::Diagnostic> = Vec::new(); let mut warnings: Vec<crate::diagnostic::Diagnostic> = Vec::new();
// loop-recur iter 2: test helper synths one term from top-of- // loop-recur iter 2: test helper synths one term from top-of-
// body — fresh empty loop-stack (mirrors mut_scope_stack). // body — fresh empty loop-stack (mirrors mut_scope_stack).
let mut loop_stack: Vec<Vec<Type>> = Vec::new(); let mut loop_stack: Vec<Vec<(String, Type)>> = Vec::new();
let err = crate::synth( let err = crate::synth(
&term, &term,
&env, &env,
+130 -32
View File
@@ -696,6 +696,19 @@ pub enum CheckError {
#[error("mut-var `{name}` cannot be captured by a lambda — mut-vars are alloca-resident and do not escape their enclosing mut block. Either move the lambda outside the mut block, or restructure to pass `{name}` as a lambda parameter (deferring escape to a future milestone with ref-types and the !Mut effect).")] #[error("mut-var `{name}` cannot be captured by a lambda — mut-vars are alloca-resident and do not escape their enclosing mut block. Either move the lambda outside the mut block, or restructure to pass `{name}` as a lambda parameter (deferring escape to a future milestone with ref-types and the !Mut effect).")]
MutVarCapturedByLambda { name: String }, MutVarCapturedByLambda { name: String },
/// loop-recur iter loop-recur.tidy: `Term::Lam` reached typecheck
/// inside a lexically-enclosing `Term::Loop`, and the lambda's body
/// references a loop binder declared by that enclosing loop. Loop
/// binders are alloca-resident (codegen reuses the
/// `mut_var_allocas` registry, `std::mem::take`-emptied at the
/// lambda frame boundary), exactly the mut-var escape rule. Symmetric
/// to `MutVarCapturedByLambda`: capturing one into a closure would
/// require lifting it to the heap (deferred) or rejecting the
/// capture; this milestone takes the latter route rather than
/// panicking codegen on type-correct input.
#[error("loop binder `{name}` cannot be captured by a lambda — loop binders are alloca-resident and do not escape their enclosing loop. Either move the lambda outside the loop, or restructure to pass `{name}` as a lambda parameter (deferring escape to a future milestone with ref-types and the !Mut effect).")]
LoopBinderCapturedByLambda { name: String },
/// loop-recur iter 2: `Term::Recur` reached typecheck with no /// loop-recur iter 2: `Term::Recur` reached typecheck with no
/// lexically-enclosing `Term::Loop` (the `loop_stack` was empty). /// lexically-enclosing `Term::Loop` (the `loop_stack` was empty).
#[error("`recur` is only valid inside a `loop` — there is no enclosing loop here")] #[error("`recur` is only valid inside a `loop` — there is no enclosing loop here")]
@@ -774,6 +787,7 @@ impl CheckError {
CheckError::AssignTypeMismatch { .. } => "assign-type-mismatch", CheckError::AssignTypeMismatch { .. } => "assign-type-mismatch",
CheckError::UnsupportedMutVarType { .. } => "mut-var-unsupported-type", CheckError::UnsupportedMutVarType { .. } => "mut-var-unsupported-type",
CheckError::MutVarCapturedByLambda { .. } => "mut-var-captured-by-lambda", CheckError::MutVarCapturedByLambda { .. } => "mut-var-captured-by-lambda",
CheckError::LoopBinderCapturedByLambda { .. } => "loop-binder-captured-by-lambda",
CheckError::RecurOutsideLoop => "recur-outside-loop", CheckError::RecurOutsideLoop => "recur-outside-loop",
CheckError::RecurArityMismatch { .. } => "recur-arity-mismatch", CheckError::RecurArityMismatch { .. } => "recur-arity-mismatch",
CheckError::RecurTypeMismatch { .. } => "recur-type-mismatch", CheckError::RecurTypeMismatch { .. } => "recur-type-mismatch",
@@ -852,6 +866,9 @@ impl CheckError {
CheckError::MutVarCapturedByLambda { name } => { CheckError::MutVarCapturedByLambda { name } => {
serde_json::json!({"name": name}) serde_json::json!({"name": name})
} }
CheckError::LoopBinderCapturedByLambda { name } => {
serde_json::json!({"name": name})
}
CheckError::RecurArityMismatch { expected, got } => { CheckError::RecurArityMismatch { expected, got } => {
serde_json::json!({"expected": expected, "actual": got}) serde_json::json!({"expected": expected, "actual": got})
} }
@@ -2006,7 +2023,7 @@ fn check_fn(f: &FnDef, env: &Env, out_warnings: &mut Vec<Diagnostic>) -> Result<
// loop-recur iter 2: fresh per-fn-body loop-binder-type stack. // loop-recur iter 2: fresh per-fn-body loop-binder-type stack.
// Empty at fn entry; pushed/popped by `Term::Loop` arms during // Empty at fn entry; pushed/popped by `Term::Loop` arms during
// the synth walk; discarded after the body type-checks. // the synth walk; discarded after the body type-checks.
let mut loop_stack: Vec<Vec<Type>> = Vec::new(); let mut loop_stack: Vec<Vec<(String, Type)>> = Vec::new();
let body_ty = synth(&f.body, &env, &mut locals, &mut mut_scope_stack, &mut loop_stack, &mut effects, &f.name, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings)?; let body_ty = synth(&f.body, &env, &mut locals, &mut mut_scope_stack, &mut loop_stack, &mut effects, &f.name, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings)?;
unify(&ret_ty, &body_ty, &mut subst)?; unify(&ret_ty, &body_ty, &mut subst)?;
// mq.3: surface synth-time warnings into the caller-supplied // mq.3: surface synth-time warnings into the caller-supplied
@@ -2893,7 +2910,7 @@ fn check_const(c: &ConstDef, env: &Env, out_warnings: &mut Vec<Diagnostic>) -> R
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
// loop-recur iter 2: a const value has no enclosing loop; any // loop-recur iter 2: a const value has no enclosing loop; any
// `recur` in a const body correctly fires `RecurOutsideLoop`. // `recur` in a const body correctly fires `RecurOutsideLoop`.
let mut loop_stack: Vec<Vec<Type>> = Vec::new(); let mut loop_stack: Vec<Vec<(String, Type)>> = Vec::new();
let v = synth(&c.value, env, &mut locals, &mut mut_scope_stack, &mut loop_stack, &mut effects, &c.name, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings)?; let v = synth(&c.value, env, &mut locals, &mut mut_scope_stack, &mut loop_stack, &mut effects, &c.name, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings)?;
out_warnings.extend(warnings); out_warnings.extend(warnings);
unify(&c.ty, &v, &mut subst)?; unify(&c.ty, &v, &mut subst)?;
@@ -2919,15 +2936,18 @@ pub(crate) fn synth(
// name through it. Position: locals-adjacent because both are // name through it. Position: locals-adjacent because both are
// per-fn-body lexical scope state. // per-fn-body lexical scope state.
mut_scope_stack: &mut Vec<IndexMap<String, Type>>, mut_scope_stack: &mut Vec<IndexMap<String, Type>>,
// loop-recur iter 2: per-walk loop-binder-type stack. Each frame // loop-recur iter 2: per-walk loop-binder stack. Each frame is one
// is one `Term::Loop`'s binder types in declaration order // `Term::Loop`'s binders in declaration order. `recur` rebinds
// (positional — `recur` rebinds binders by position, not name, // binders by position, not name, so the type list is positional;
// so this is `Vec<Type>` not name-keyed like `mut_scope_stack`). // iter loop-recur.tidy added the binder name alongside the type
// Pushed on `Term::Loop` body entry, popped on exit; consulted // (so `Term::Lam`'s escape guard can reject a free var that is an
// innermost-first by `Term::Recur` for arity + per-position type. // in-scope loop binder, symmetric to `mut_scope_stack`). Pushed on
// Position: mut_scope_stack-adjacent — both are per-fn-body // `Term::Loop` body entry, popped on exit; consulted innermost-first
// lexical-control scope state threaded identically. // by `Term::Recur` for arity + per-position type, and across all
loop_stack: &mut Vec<Vec<Type>>, // frames by `Term::Lam` for the escape check. Position:
// mut_scope_stack-adjacent — both are per-fn-body lexical-control
// scope state threaded identically.
loop_stack: &mut Vec<Vec<(String, Type)>>,
effects: &mut BTreeSet<String>, effects: &mut BTreeSet<String>,
in_def: &str, in_def: &str,
subst: &mut Subst, subst: &mut Subst,
@@ -3605,7 +3625,16 @@ pub(crate) fn synth(
// `MutVarCapturedByLambda`. Reuses `desugar::free_vars_in_term` // `MutVarCapturedByLambda`. Reuses `desugar::free_vars_in_term`
// (same walker `lift.rs` uses) which honours pattern bindings // (same walker `lift.rs` uses) which honours pattern bindings
// inside `Term::Match` arms. // inside `Term::Match` arms.
if !mut_scope_stack.is_empty() { // Iter loop-recur.tidy: symmetrically reject
// lambda-captures-of-loop-binder. Loop binders are
// alloca-resident and `std::mem::take`-emptied at the
// lambda frame boundary in codegen, exactly like mut-vars
// — capturing one panics `unreachable!()` at codegen, so
// the type checker rejects it here instead. The free-var
// scan is shared with the mut-scope check below; gated on
// a non-empty enclosing scope so lambdas outside any
// mut/loop scope typecheck unchanged.
if !mut_scope_stack.is_empty() || !loop_stack.is_empty() {
let mut lam_bound: BTreeSet<String> = BTreeSet::new(); let mut lam_bound: BTreeSet<String> = BTreeSet::new();
for p in params { for p in params {
lam_bound.insert(p.clone()); lam_bound.insert(p.clone());
@@ -3620,6 +3649,13 @@ pub(crate) fn synth(
}); });
} }
} }
for frame in loop_stack.iter() {
if frame.iter().any(|(n, _)| n == free_name) {
return Err(CheckError::LoopBinderCapturedByLambda {
name: free_name.clone(),
});
}
}
} }
} }
@@ -3894,12 +3930,13 @@ pub(crate) fn synth(
// loop (declaration order; later inits + body see earlier // loop (declaration order; later inits + body see earlier
// binders via `locals`, exactly as `Term::Let` binds its // binders via `locals`, exactly as `Term::Let` binds its
// name). The loop's static type is the body's type. The // name). The loop's static type is the body's type. The
// ordered binder types are pushed onto `loop_stack` for the // ordered binders (name + type) are pushed onto `loop_stack`
// body walk so an inner `Term::Recur` can position-check // for the body walk so an inner `Term::Recur` can
// against them; popped on exit. Binder names are NOT in // position-check the types and `Term::Lam`'s escape guard
// `loop_stack` (recur is positional, not by-name). // (iter loop-recur.tidy) can reject capture of a binder name;
// popped on exit.
Term::Loop { binders, body } => { Term::Loop { binders, body } => {
let mut binder_tys: Vec<Type> = Vec::new(); let mut binder_frame: Vec<(String, Type)> = Vec::new();
let mut restore: Vec<(String, Option<Type>)> = Vec::new(); let mut restore: Vec<(String, Option<Type>)> = Vec::new();
for b in binders { for b in binders {
let init_ty = synth( let init_ty = synth(
@@ -3907,11 +3944,11 @@ pub(crate) fn synth(
in_def, subst, counter, residuals, free_fn_calls, warnings, in_def, subst, counter, residuals, free_fn_calls, warnings,
)?; )?;
unify(&b.ty, &init_ty, subst)?; unify(&b.ty, &init_ty, subst)?;
binder_tys.push(b.ty.clone()); binder_frame.push((b.name.clone(), b.ty.clone()));
let prev = locals.insert(b.name.clone(), b.ty.clone()); let prev = locals.insert(b.name.clone(), b.ty.clone());
restore.push((b.name.clone(), prev)); restore.push((b.name.clone(), prev));
} }
loop_stack.push(binder_tys); loop_stack.push(binder_frame);
let body_result = synth( let body_result = synth(
body, env, locals, mut_scope_stack, loop_stack, effects, body, env, locals, mut_scope_stack, loop_stack, effects,
in_def, subst, counter, residuals, free_fn_calls, warnings, in_def, subst, counter, residuals, free_fn_calls, warnings,
@@ -3939,13 +3976,13 @@ pub(crate) fn synth(
// through, so it unifies with whatever sibling branch the // through, so it unifies with whatever sibling branch the
// enclosing if/match requires. // enclosing if/match requires.
Term::Recur { args } => { Term::Recur { args } => {
let binder_tys = match loop_stack.last() { let binder_frame = match loop_stack.last() {
None => return Err(CheckError::RecurOutsideLoop), None => return Err(CheckError::RecurOutsideLoop),
Some(tys) => tys.clone(), Some(frame) => frame.clone(),
}; };
if args.len() != binder_tys.len() { if args.len() != binder_frame.len() {
return Err(CheckError::RecurArityMismatch { return Err(CheckError::RecurArityMismatch {
expected: binder_tys.len(), expected: binder_frame.len(),
got: args.len(), got: args.len(),
}); });
} }
@@ -3954,7 +3991,7 @@ pub(crate) fn synth(
a, env, locals, mut_scope_stack, loop_stack, effects, a, env, locals, mut_scope_stack, loop_stack, effects,
in_def, subst, counter, residuals, free_fn_calls, warnings, in_def, subst, counter, residuals, free_fn_calls, warnings,
)?; )?;
let applied_binder = subst.apply(&binder_tys[i]); let applied_binder = subst.apply(&binder_frame[i].1);
let applied_arg = subst.apply(&arg_ty); let applied_arg = subst.apply(&arg_ty);
if applied_binder != applied_arg { if applied_binder != applied_arg {
return Err(CheckError::RecurTypeMismatch { return Err(CheckError::RecurTypeMismatch {
@@ -4342,7 +4379,7 @@ mod tests {
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
// loop-recur iter 2: loop-free unit-test call site — fresh // loop-recur iter 2: loop-free unit-test call site — fresh
// empty loop-stack, mirroring the mut-scope above. // empty loop-stack, mirroring the mut-scope above.
let mut loop_stack: Vec<Vec<Type>> = Vec::new(); let mut loop_stack: Vec<Vec<(String, Type)>> = Vec::new();
let mut effects: BTreeSet<String> = BTreeSet::new(); let mut effects: BTreeSet<String> = BTreeSet::new();
let mut subst = Subst::default(); let mut subst = Subst::default();
let mut counter: u32 = 0; let mut counter: u32 = 0;
@@ -4383,7 +4420,7 @@ mod tests {
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
// loop-recur iter 2: loop-free unit-test call site — fresh // loop-recur iter 2: loop-free unit-test call site — fresh
// empty loop-stack, mirroring the mut-scope above. // empty loop-stack, mirroring the mut-scope above.
let mut loop_stack: Vec<Vec<Type>> = Vec::new(); let mut loop_stack: Vec<Vec<(String, Type)>> = Vec::new();
let mut frame: IndexMap<String, Type> = IndexMap::new(); let mut frame: IndexMap<String, Type> = IndexMap::new();
frame.insert("x".into(), Type::float()); // inner mut-var frame.insert("x".into(), Type::float()); // inner mut-var
mut_scope_stack.push(frame); mut_scope_stack.push(frame);
@@ -4427,7 +4464,7 @@ mod tests {
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
// loop-recur iter 2: loop-free unit-test call site — fresh // loop-recur iter 2: loop-free unit-test call site — fresh
// empty loop-stack, mirroring the mut-scope above. // empty loop-stack, mirroring the mut-scope above.
let mut loop_stack: Vec<Vec<Type>> = Vec::new(); let mut loop_stack: Vec<Vec<(String, Type)>> = Vec::new();
let mut outer: IndexMap<String, Type> = IndexMap::new(); let mut outer: IndexMap<String, Type> = IndexMap::new();
outer.insert("x".into(), Type::int()); outer.insert("x".into(), Type::int());
mut_scope_stack.push(outer); mut_scope_stack.push(outer);
@@ -4544,7 +4581,7 @@ mod tests {
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
// loop-recur iter 2: loop-free unit-test call site — fresh // loop-recur iter 2: loop-free unit-test call site — fresh
// empty loop-stack, mirroring the mut-scope above. // empty loop-stack, mirroring the mut-scope above.
let mut loop_stack: Vec<Vec<Type>> = Vec::new(); let mut loop_stack: Vec<Vec<(String, Type)>> = Vec::new();
let mut effects: BTreeSet<String> = BTreeSet::new(); let mut effects: BTreeSet<String> = BTreeSet::new();
let mut subst = Subst::default(); let mut subst = Subst::default();
let mut counter: u32 = 0; let mut counter: u32 = 0;
@@ -4587,7 +4624,7 @@ mod tests {
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
// loop-recur iter 2: loop-free unit-test call site — fresh // loop-recur iter 2: loop-free unit-test call site — fresh
// empty loop-stack, mirroring the mut-scope above. // empty loop-stack, mirroring the mut-scope above.
let mut loop_stack: Vec<Vec<Type>> = Vec::new(); let mut loop_stack: Vec<Vec<(String, Type)>> = Vec::new();
let mut effects: BTreeSet<String> = BTreeSet::new(); let mut effects: BTreeSet<String> = BTreeSet::new();
let mut subst = Subst::default(); let mut subst = Subst::default();
let mut counter: u32 = 0; let mut counter: u32 = 0;
@@ -4629,7 +4666,7 @@ mod tests {
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
// loop-recur iter 2: loop-free unit-test call site — fresh // loop-recur iter 2: loop-free unit-test call site — fresh
// empty loop-stack, mirroring the mut-scope above. // empty loop-stack, mirroring the mut-scope above.
let mut loop_stack: Vec<Vec<Type>> = Vec::new(); let mut loop_stack: Vec<Vec<(String, Type)>> = Vec::new();
let mut effects: BTreeSet<String> = BTreeSet::new(); let mut effects: BTreeSet<String> = BTreeSet::new();
let mut subst = Subst::default(); let mut subst = Subst::default();
let mut counter: u32 = 0; let mut counter: u32 = 0;
@@ -4684,6 +4721,17 @@ mod tests {
let _ = e.ctx(); let _ = e.ctx();
} }
/// Iter loop-recur.tidy: `CheckError::LoopBinderCapturedByLambda`
/// carries the stable kebab code `loop-binder-captured-by-lambda`
/// and a non-panicking `ctx()` arm carrying the captured name —
/// symmetric to `mut_var_captured_by_lambda_diagnostic_has_kebab_code`.
#[test]
fn loop_binder_captured_by_lambda_diagnostic_has_kebab_code() {
let e = CheckError::LoopBinderCapturedByLambda { name: "acc".into() };
assert_eq!(e.code(), "loop-binder-captured-by-lambda");
let _ = e.ctx();
}
/// loop-recur iter 2: the four `Recur*` `CheckError` variants carry /// loop-recur iter 2: the four `Recur*` `CheckError` variants carry
/// the exact stable kebab codes the spec acceptance mandates /// the exact stable kebab codes the spec acceptance mandates
/// (`recur-outside-loop` / `recur-arity-mismatch` / /// (`recur-outside-loop` / `recur-arity-mismatch` /
@@ -4760,7 +4808,7 @@ mod tests {
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
// loop-recur iter 2: loop-free unit-test call site — fresh // loop-recur iter 2: loop-free unit-test call site — fresh
// empty loop-stack, mirroring the mut-scope above. // empty loop-stack, mirroring the mut-scope above.
let mut loop_stack: Vec<Vec<Type>> = Vec::new(); let mut loop_stack: Vec<Vec<(String, Type)>> = Vec::new();
let mut effects: BTreeSet<String> = BTreeSet::new(); let mut effects: BTreeSet<String> = BTreeSet::new();
let mut subst = Subst::default(); let mut subst = Subst::default();
let mut counter: u32 = 0; let mut counter: u32 = 0;
@@ -4779,6 +4827,56 @@ mod tests {
} }
} }
/// Iter loop-recur.tidy: a lambda nested inside a `Term::Loop`
/// whose body references a loop binder declared by the enclosing
/// loop is rejected with `LoopBinderCapturedByLambda`. The
/// captured-name field reports the offending binder. Structurally
/// symmetric to
/// `lambda_inside_mut_capturing_mut_var_emits_mut_var_captured_by_lambda`.
#[test]
fn lambda_inside_loop_capturing_binder_emits_loop_binder_captured_by_lambda() {
// (loop (acc : Int = 0)
// (let f = (lam () : Int -> acc) in 0))
let term = Term::Loop {
binders: vec![ailang_core::ast::LoopBinder {
name: "acc".into(),
ty: Type::int(),
init: Term::Lit { lit: Literal::Int { value: 0 } },
}],
body: Box::new(Term::Let {
name: "f".into(),
value: Box::new(Term::Lam {
params: vec![],
param_tys: vec![],
ret_ty: Box::new(Type::int()),
effects: vec![],
body: Box::new(Term::Var { name: "acc".into() }),
}),
body: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }),
}),
};
let env = Env::default();
let mut locals: IndexMap<String, Type> = IndexMap::new();
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
let mut loop_stack: Vec<Vec<(String, Type)>> = Vec::new();
let mut effects: BTreeSet<String> = BTreeSet::new();
let mut subst = Subst::default();
let mut counter: u32 = 0;
let mut residuals: Vec<ResidualConstraint> = Vec::new();
let mut free_fn_calls: Vec<FreeFnCall> = Vec::new();
let mut warnings: Vec<Diagnostic> = Vec::new();
let err = synth(
&term, &env, &mut locals, &mut mut_scope_stack, &mut loop_stack, &mut effects,
"<test>", &mut subst, &mut counter, &mut residuals,
&mut free_fn_calls, &mut warnings,
)
.expect_err("lambda capturing loop binder must be rejected");
match err {
CheckError::LoopBinderCapturedByLambda { name } => assert_eq!(name, "acc"),
other => panic!("expected LoopBinderCapturedByLambda, got {other:?}"),
}
}
fn fn_def(name: &str, ty: Type, params: Vec<&str>, body: Term) -> Def { fn fn_def(name: &str, ty: Type, params: Vec<&str>, body: Term) -> Def {
Def::Fn(FnDef { Def::Fn(FnDef {
name: name.into(), name: name.into(),
@@ -7502,7 +7600,7 @@ mod tests {
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new(); let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
// loop-recur iter 2: loop-free unit-test call site — fresh // loop-recur iter 2: loop-free unit-test call site — fresh
// empty loop-stack, mirroring the mut-scope above. // empty loop-stack, mirroring the mut-scope above.
let mut loop_stack: Vec<Vec<Type>> = Vec::new(); let mut loop_stack: Vec<Vec<(String, Type)>> = Vec::new();
let mut effects: BTreeSet<String> = BTreeSet::new(); let mut effects: BTreeSet<String> = BTreeSet::new();
let mut subst = Subst::default(); let mut subst = Subst::default();
let mut counter: u32 = 0; let mut counter: u32 = 0;
+1 -1
View File
@@ -746,7 +746,7 @@ impl<'a> Lifter<'a> {
// loop-recur iter 2: lift's letrec-capture synth re-entry walks // loop-recur iter 2: lift's letrec-capture synth re-entry walks
// one term at a time from a top-of-body position; fresh empty // one term at a time from a top-of-body position; fresh empty
// loop-stack is correct (any `Term::Loop` pushes its own frame). // loop-stack is correct (any `Term::Loop` pushes its own frame).
let mut loop_stack: Vec<Vec<crate::Type>> = Vec::new(); let mut loop_stack: Vec<Vec<(String, crate::Type)>> = Vec::new();
let ty = synth(t, &self.env, locals, &mut mut_scope_stack, &mut loop_stack, &mut effects, in_def, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings_discarded)?; let ty = synth(t, &self.env, locals, &mut mut_scope_stack, &mut loop_stack, &mut effects, in_def, &mut subst, &mut counter, &mut residuals, &mut free_fn_calls, &mut warnings_discarded)?;
Ok(subst.apply(&ty)) Ok(subst.apply(&ty))
} }
+2 -2
View File
@@ -720,7 +720,7 @@ pub fn collect_mono_targets(
// loop-recur iter 2: mono re-synth begins from top-of-body — // loop-recur iter 2: mono re-synth begins from top-of-body —
// empty loop-stack (any `Term::Loop` pushes its own frame as the // empty loop-stack (any `Term::Loop` pushes its own frame as the
// walk descends), mirroring the fresh mut-scope above. // walk descends), mirroring the fresh mut-scope above.
let mut loop_stack: Vec<Vec<crate::Type>> = Vec::new(); let mut loop_stack: Vec<Vec<(String, crate::Type)>> = Vec::new();
crate::synth( crate::synth(
&f.body, &f.body,
&env, &env,
@@ -1366,7 +1366,7 @@ pub(crate) fn collect_residuals_ordered(
// loop-recur iter 2: mono re-synth begins from top-of-body — // loop-recur iter 2: mono re-synth begins from top-of-body —
// empty loop-stack (any `Term::Loop` pushes its own frame as the // empty loop-stack (any `Term::Loop` pushes its own frame as the
// walk descends), mirroring the fresh mut-scope above. // walk descends), mirroring the fresh mut-scope above.
let mut loop_stack: Vec<Vec<crate::Type>> = Vec::new(); let mut loop_stack: Vec<Vec<(String, crate::Type)>> = Vec::new();
crate::synth( crate::synth(
&f.body, &f.body,
&env, &env,
+14 -8
View File
@@ -714,14 +714,20 @@ struct Emitter<'a> {
/// dec'ing, because Implicit and Borrow do not carry the "caller /// dec'ing, because Implicit and Borrow do not carry the "caller
/// handed off ownership" signal that makes the dec safe. /// handed off ownership" signal that makes the dec safe.
current_param_modes: BTreeMap<String, ParamMode>, current_param_modes: BTreeMap<String, ParamMode>,
/// Iter mut.3: per-fn map of mut-var name → (alloca SSA name, /// Per-fn map of name → (alloca SSA name, AIL element type) for
/// AIL element type). Populated when entering a `Term::Mut` /// the two alloca-resident binding classes. Populated on entry
/// block; entries removed (or restored to their prior binding) /// to a `Term::Mut` block (iter mut.3 — mut-vars) AND on entry
/// when leaving the block. Consulted by the `Term::Var` arm of /// to a `Term::Loop` (iter loop-recur.3 — loop binders ride the
/// `lower_term` before `self.locals`, matching the typecheck- /// same alloca representation; the `Term::Loop` arm inserts each
/// side mut-scope-stack precedence (`synth` in `ailang-check` /// binder here and `recur` stores into the slot). Entries are
/// walks `mut_scope_stack` innermost-first before falling /// removed (or restored to their prior binding) on block exit.
/// through to locals/globals). /// Consulted by the `Term::Var` arm of `lower_term` before
/// `self.locals` — the single load path serving both classes.
/// Note: only the mut-var half mirrors a typecheck-side
/// `mut_scope_stack`; loop binders are tracked typecheck-side in
/// the ordinary `locals` plus `loop_stack` (positional), not in
/// `mut_scope_stack` — the shared codegen map is a
/// representation detail, not a scope-precedence claim.
mut_var_allocas: BTreeMap<String, (String, Type)>, mut_var_allocas: BTreeMap<String, (String, Type)>,
/// loop-recur iter 3: stack of enclosing `Term::Loop` codegen /// loop-recur iter 3: stack of enclosing `Term::Loop` codegen
/// frames. Each frame is `(header_block_label, binder_slots)` /// frames. Each frame is `(header_block_label, binder_slots)`
+12 -6
View File
@@ -548,12 +548,18 @@ pub enum Term {
/// additive: pre-existing fixtures hash bit-identically because /// additive: pre-existing fixtures hash bit-identically because
/// none carry the `"t":"loop"` tag. `binders` has no /// none carry the `"t":"loop"` tag. `binders` has no
/// `skip_serializing_if` (mirrors `Term::Mut.vars` — the field /// `skip_serializing_if` (mirrors `Term::Mut.vars` — the field
/// is part of the shape). Typecheck binder/recur semantics land /// is part of the shape). Typecheck (loop-recur.2): `synth`
/// in loop-recur iter 2 (`synth` stubs with `CheckError::Internal` /// types each binder init, the loop's type is the body's type;
/// in this iter); codegen (loop-header + per-binder phi + /// `recur` arity/type is checked positionally via `loop_stack`
/// back-edge) in iter 3 (`lower_term` stubs with /// and `verify_loop_body` enforces `recur`-in-tail-position; a
/// `CodegenError::Internal`). No totality claim — an infinite /// lambda capturing a binder is rejected
/// loop is legal. See `docs/specs/2026-05-17-loop-recur.md`. /// (`loop-binder-captured-by-lambda`, loop-recur.tidy). Codegen
/// (loop-recur.3): binders lower to entry-block allocas reached
/// from a `loop.header` block; `recur` stores + back-edges; the
/// loop-carried SSA / phi form is produced by `clang -O2`
/// mem2reg (NOT hand-emitted phi). No totality claim — an
/// infinite loop is legal. See
/// `docs/specs/2026-05-17-loop-recur.md`.
Loop { Loop {
binders: Vec<LoopBinder>, binders: Vec<LoopBinder>,
body: Box<Term>, body: Box<Term>,
@@ -3,7 +3,7 @@
//! under `examples/*.ail.json` after milestone close. The list is //! under `examples/*.ail.json` after milestone close. The list is
//! hardcoded; any change is a deliberate, brainstorm-level decision. //! hardcoded; any change is a deliberate, brainstorm-level decision.
//! //!
//! Seventeen carve-outs post iter loop-recur.2 (2026-05-17): //! Eighteen carve-outs post iter loop-recur.tidy (2026-05-18):
//! - §C4 (a) subject-matter: 7 fixtures from form-a.1 milestone //! - §C4 (a) subject-matter: 7 fixtures from form-a.1 milestone
//! close (canonical-form rejection / unbound / class-def rejection). //! close (canonical-form rejection / unbound / class-def rejection).
//! - Iter mut.2: 5 negative typecheck fixtures for the new mut / //! - Iter mut.2: 5 negative typecheck fixtures for the new mut /
@@ -23,6 +23,10 @@
//! recur-not-in-tail-position) — the diagnostic code is the //! recur-not-in-tail-position) — the diagnostic code is the
//! load-bearing assertion, mirroring the §C4(a) / mut.2 //! load-bearing assertion, mirroring the §C4(a) / mut.2
//! negative-fixture convention. //! negative-fixture convention.
//! - loop-recur iter loop-recur.tidy: 1 negative typecheck fixture
//! for the lambda-capture-of-loop-binder rejection
//! (loop-binder-captured-by-lambda) — symmetric to the mut.4-tidy
//! `test_mut_var_captured_by_lambda.ail.json` carve-out.
use std::collections::BTreeSet; use std::collections::BTreeSet;
@@ -43,6 +47,8 @@ const EXPECTED: &[&str] = &[
"test_mut_var_unsupported_type.ail.json", "test_mut_var_unsupported_type.ail.json",
// Iter mut.4-tidy — lambda-capture-of-mut-var rejection // Iter mut.4-tidy — lambda-capture-of-mut-var rejection
"test_mut_var_captured_by_lambda.ail.json", "test_mut_var_captured_by_lambda.ail.json",
// Iter loop-recur.tidy — lambda-capture-of-loop-binder rejection
"test_loop_binder_captured_by_lambda.ail.json",
// loop-recur iter 2 — negative typecheck fixtures // loop-recur iter 2 — negative typecheck fixtures
"test_recur_arity_mismatch.ail.json", "test_recur_arity_mismatch.ail.json",
"test_recur_not_in_tail_position.ail.json", "test_recur_not_in_tail_position.ail.json",
+4 -1
View File
@@ -2451,7 +2451,10 @@ them as entry-block allocas, and `Term::Assign` emits a `store`
yielding the canonical Unit SSA. Mut-vars must be of one of the yielding the canonical Unit SSA. Mut-vars must be of one of the
stack-resident scalar types `{Int, Float, Bool, Unit}`; capturing a stack-resident scalar types `{Int, Float, Bool, Unit}`; capturing a
mut-var into a lambda body is rejected at typecheck via mut-var into a lambda body is rejected at typecheck via
`CheckError::MutVarCapturedByLambda` (iter mut.4-tidy). See `CheckError::MutVarCapturedByLambda` (iter mut.4-tidy). Loop binders
are alloca-resident under the same scheme, so capturing a `loop`
binder into a lambda body is rejected by the symmetric
`CheckError::LoopBinderCapturedByLambda` (iter loop-recur.tidy). See
`docs/specs/2026-05-15-mut-local.md`. `docs/specs/2026-05-15-mut-local.md`.
**`Literal`**: **`Literal`**:
@@ -0,0 +1,133 @@
# iter loop-recur.tidy — lambda capturing a loop binder must fail `ail check`, not panic codegen
**Date:** 2026-05-18
**Started from:** 39380d361d530a95a4f1c718c0e201f8312d3ac4
**Status:** DONE
**Tasks completed:** 1 of 1
## Summary
A `Term::Lam` whose body captured an enclosing `Term::Loop` binder
passed `ail check` (exit 0) and then panicked `unreachable!()` at
`crates/ailang-codegen/src/lambda.rs:102` — type-correct input
crashing the compiler. This iter adds the symmetric escape guard:
such a capture now fails `ail check` with the stable kebab code
`loop-binder-captured-by-lambda`, exactly as mut.4-tidy did for
`mut-var-captured-by-lambda`. The fix is the minimal mechanism that
reuses the scope structure the `Term::Loop` arm already maintains.
## Per-task notes
- iter loop-recur.tidy.1: GREEN side of the debug RED test
`lambda_capturing_loop_binder_emits_loop_binder_captured_by_lambda`.
Added `CheckError::LoopBinderCapturedByLambda { name }`
(bracket-free F2 Display), its `code()` arm
(`loop-binder-captured-by-lambda`), and its `ctx()` arm
(`{name}`, mirroring `MutVarCapturedByLambda`). Extended
`loop_stack`'s element type from `Vec<Type>` to
`Vec<(String, Type)>` so the existing per-loop scope frame also
carries binder names (the `Term::Recur` arm reads `.1` for the
positional type check; the `Term::Lam` escape guard reads `.0`).
The `Term::Lam` arm's existing free-var scan (shared
`free_vars_in_term` walker) gained a parallel pass over
`loop_stack` next to the `mut_scope_stack` pass; its gate widened
to `!mut_scope_stack.is_empty() || !loop_stack.is_empty()`.
Mirrored the two mut.4-tidy in-source unit tests
(kebab-code test + synth-level capturing test). Lockstep:
`carve_out_inventory.rs` 17→18 (header count word + new bullet +
EXPECTED entry), `ct1_check_cli.rs` mut-F2 sibling `cases` gained
the new fixture (where `mut-var-captured-by-lambda` is asserted —
the exact precedent). `docs/DESIGN.md` one-line note adjacent to
the `MutVarCapturedByLambda` mention.
## Root cause
The `Term::Lam` escape guard iterated only `mut_scope_stack` to
reject lambda captures of alloca-resident locals. Loop binders are
inserted into the ordinary `locals` by the `Term::Loop` arm and
never entered any escape-checked scope, so a lambda body referencing
a loop binder was accepted by `ail check`. Codegen's lambda capture
resolver searches only `self.locals` after `mut_var_allocas` is
`std::mem::take`-emptied at the lambda frame boundary, so the
loop-binder reference resolved to nothing and hit `unreachable!()`.
Loop binders are alloca-resident under the same scheme as mut-vars
and obey the same no-escape-into-lambda rule.
## Design note — why extend `loop_stack` rather than add a parallel stack
The carrier offered "reuse whatever scope structure the `Term::Loop`
arm already maintains" as an explicit minimal option. `loop_stack`
is already threaded through every `synth` recursive call and is
pushed/popped at exactly the loop scope boundary. Extending its
frame element from `Type` to `(String, Type)` preserves all existing
push/pop discipline and `Term::Recur` positional-type semantics
unchanged — it is not a redesign. The alternative (a parallel
`mut_scope_stack`-shaped parameter) would have touched all 35
recursive `synth` call sites plus declarations, strictly larger.
Element-type extension is the minimal correct mechanism.
## Concerns
(none)
## Known debt
(none)
## Files touched
- `crates/ailang-check/src/lib.rs` — variant, code/ctx/Display, the
`Term::Loop` push + `Term::Recur` read + `Term::Lam` escape guard,
two in-source unit tests
- `crates/ailang-check/src/builtins.rs``loop_stack` decl type
- `crates/ailang-check/src/lift.rs``loop_stack` decl type
- `crates/ailang-check/src/mono.rs``loop_stack` decl type (x2)
- `crates/ailang-core/tests/carve_out_inventory.rs` — 17→18 lockstep
- `crates/ail/tests/ct1_check_cli.rs` — mut-F2 sibling `cases`
- `docs/DESIGN.md` — one-line symmetric-rule note
## Milestone-close audit resolution (Boss-side)
The loop/recur milestone-close `audit` (architect drift review +
bench gate) produced four items; this tidy commit resolves all:
- **`[high]` lambda-captures-loop-binder codegen crash** — fixed by
this iter's GREEN (the symmetric `loop-binder-captured-by-lambda`
guard). RED test committed separately as the audit trail
(`39380d3`); GREEN + the items below fold into this commit.
- **`[medium]` `mut_var_allocas` rustdoc lied about dual use** —
`crates/ailang-codegen/src/lib.rs` rustdoc said "Populated when
entering a `Term::Mut` block" / "matching the typecheck-side
mut-scope-stack precedence"; as of iter-3 loop binders ride the
same map. Boss-edited to present-tense dual-use truth (mut-vars
AND loop binders; the shared map is a representation detail, not
a scope-precedence claim). Doc-honesty class.
- **`[medium]` `Term::Loop` doc-comment stale "per-binder phi"** —
`crates/ailang-core/src/ast.rs` still described iter-1's
forward-looking `synth`/`lower_term` `Internal` stubs + "per-binder
phi"; iters 2/3 shipped real typecheck + alloca+mem2reg lowering
(Boss-call-1 explicitly rejected hand-emitted phi). Boss-edited to
the real shipped state. Doc-honesty class.
- **`[low]` recon-undercount 3× pattern** — added a P2 `[todo]` to
`docs/roadmap.md` (`ailang-plan-recon` cross-crate-caller-undercount
countermeasure; pairs with the planner Step-5 items 7+8 added this
milestone — those scrub the plan, this scrubs the recon). Not a
milestone-blocking fix; tracked forward.
- **Bench gate (audit Step 2)** — `bench/check.py` /
`compile_check.py` / `cross_lang.py` all exit 0; 25 metrics, 0
regressed, 25 stable. **Pristine** — exactly as the spec's
acceptance criteria expect for a strictly-additive surface no
hot-path bench exercises; the pre-existing P2 `*.bump_s`
hardware-staleness drift did not trip this run. **carry-on**: no
baseline update, no ratify entry (Iron Law — nothing intentionally
moved a metric).
**Milestone-loop-recur tidy: resolved.** Architect drift cleared
(the `[high]` is fixed code, the two `[medium]` are doc-honest, the
`[low]` is roadmap-tracked); bench pristine carry-on. The loop/recur
milestone is audit-clean; the only remaining pipeline step before
full close is `fieldtest` (user-visible Form-A surface).
## Stats
bench/orchestrator-stats/2026-05-17-iter-loop-recur.tidy.json
+1
View File
@@ -84,3 +84,4 @@
- 2026-05-17 — iter loop-recur.1: standalone-`loop`/`recur` milestone (1 of 3) — the strictly-additive AST-node foundation. `Term::Loop { binders: Vec<LoopBinder>, body }` / `Term::Recur { args }` / `struct LoopBinder` (mirrors `MutVar`'s `(name,type,init)` triple; `binders` no `skip_serializing_if`) added end-to-end across all six crates' no-`_`-wildcard exhaustive `Term` matches (~30 sites incl. one recon-undercount integration-test pin): Form-A `parse_loop`/`parse_recur` (binder/body boundary disambiguated by an `is_term_head_kw` guard — the plan's flagged sole parser judgement) + print + `form_a.md` grammar/notes + prose render/free-var/subst lockstep + canonical-JSON serde/round-trip + DESIGN.md §Data-model `"t":"loop"`/`"t":"recur"` blocks + design_schema_drift/spec_drift/schema_coverage anchors + a new `loop_recur` hash pin (additivity proven: iter13a + new pin both green, pre-existing canonical-JSON bytes byte-stable) + `examples/loop_sum_to.ail` round-trip fixture. NO typecheck semantics + NO real codegen by design: the two semantic dispatch points stubbed `synth``CheckError::Internal` / `lower_term``CodegenError::Internal` exactly as mut.1; pure analysis walkers (escape/lambda/`synth_with_extras`) get real structural pass-through. Two binding Boss design calls (mirrored into the journal): (1) `verify_tail_positions` "byte-unchanged" = tail-app *role* unchanged not source-frozen — it is an exhaustive no-wildcard match so two additive descent-only arms are mandatory; acceptance evidence is the tail-app non-regression test (all `tail`-filtered tests byte-identical), mut.1 set the precedent; (2) codegen IS iter-1 scope as stubs/pass-throughs (no real loop-header/phi/back-edge — that is iter 3). Three plan-pseudo-vs-reality substitutions, all intent-preserving, recurring `feedback_specs_need_concrete_code`/`feedback_plan_pseudo_vs_reality` class: serde `Type::Con` literal `{"t":"con",…,"args":[]}`→real `{"k":"con","name":"Int"}`; bare `Int``(con Int)` in binder triples (bare `Int` parses as `Type::Var`); `ailang_core::ast::LoopBinder`→unqualified inside ailang-core. Boss forward-fix folded in: Concern 2 surfaced that the committed specs themselves (`2026-05-17-loop-recur.md` headline + `bad_recur`, `2026-05-17-llm-surface-discipline.md` §5) wrote bare `Int` — corrected all three snippets to `(con Int)` (doc-honesty, same class as the mono.rs header; no design/schema/assumption change, no re-brainstorm). `cargo test --workspace` 600→608 / 0 red (Boss-reran independently). Component 4 (typecheck) = iter 2; Component 5 (codegen) + positive/deep-`n` E2E = iter 3 → 2026-05-17-iter-loop-recur.1.md - 2026-05-17 — iter loop-recur.1: standalone-`loop`/`recur` milestone (1 of 3) — the strictly-additive AST-node foundation. `Term::Loop { binders: Vec<LoopBinder>, body }` / `Term::Recur { args }` / `struct LoopBinder` (mirrors `MutVar`'s `(name,type,init)` triple; `binders` no `skip_serializing_if`) added end-to-end across all six crates' no-`_`-wildcard exhaustive `Term` matches (~30 sites incl. one recon-undercount integration-test pin): Form-A `parse_loop`/`parse_recur` (binder/body boundary disambiguated by an `is_term_head_kw` guard — the plan's flagged sole parser judgement) + print + `form_a.md` grammar/notes + prose render/free-var/subst lockstep + canonical-JSON serde/round-trip + DESIGN.md §Data-model `"t":"loop"`/`"t":"recur"` blocks + design_schema_drift/spec_drift/schema_coverage anchors + a new `loop_recur` hash pin (additivity proven: iter13a + new pin both green, pre-existing canonical-JSON bytes byte-stable) + `examples/loop_sum_to.ail` round-trip fixture. NO typecheck semantics + NO real codegen by design: the two semantic dispatch points stubbed `synth``CheckError::Internal` / `lower_term``CodegenError::Internal` exactly as mut.1; pure analysis walkers (escape/lambda/`synth_with_extras`) get real structural pass-through. Two binding Boss design calls (mirrored into the journal): (1) `verify_tail_positions` "byte-unchanged" = tail-app *role* unchanged not source-frozen — it is an exhaustive no-wildcard match so two additive descent-only arms are mandatory; acceptance evidence is the tail-app non-regression test (all `tail`-filtered tests byte-identical), mut.1 set the precedent; (2) codegen IS iter-1 scope as stubs/pass-throughs (no real loop-header/phi/back-edge — that is iter 3). Three plan-pseudo-vs-reality substitutions, all intent-preserving, recurring `feedback_specs_need_concrete_code`/`feedback_plan_pseudo_vs_reality` class: serde `Type::Con` literal `{"t":"con",…,"args":[]}`→real `{"k":"con","name":"Int"}`; bare `Int``(con Int)` in binder triples (bare `Int` parses as `Type::Var`); `ailang_core::ast::LoopBinder`→unqualified inside ailang-core. Boss forward-fix folded in: Concern 2 surfaced that the committed specs themselves (`2026-05-17-loop-recur.md` headline + `bad_recur`, `2026-05-17-llm-surface-discipline.md` §5) wrote bare `Int` — corrected all three snippets to `(con Int)` (doc-honesty, same class as the mono.rs header; no design/schema/assumption change, no re-brainstorm). `cargo test --workspace` 600→608 / 0 red (Boss-reran independently). Component 4 (typecheck) = iter 2; Component 5 (codegen) + positive/deep-`n` E2E = iter 3 → 2026-05-17-iter-loop-recur.1.md
- 2026-05-17 — iter loop-recur.2: standalone-`loop`/`recur` milestone (2 of 3) — Component 4 typecheck semantics. The iter-1 `synth` `CheckError::Internal` stub for `Term::Loop`/`Term::Recur` is replaced with real binder typing (each init synthed in outer scope + already-declared binder names of THIS loop via ordinary `locals` save/restore mirroring `Term::Let` verbatim; loop's static type = body type) + positional `recur` arity/per-arg-type checking via a new `loop_stack: &mut Vec<Vec<Type>>` frame threaded exactly as mut.2's `mut_scope_stack` (every recursive + cross-module + test synth caller, compile-swept). New private `verify_loop_body(t,in_loop_tail)` pass — a SIBLING of the byte-frozen `verify_tail_positions` (0 deletions, tail-app role untouched), invoked in `check_fn` after it so synth-raised codes take precedence by pass ordering. Four `Recur*` `CheckError` variants (`RecurOutsideLoop`, `RecurArityMismatch{expected,got}`, `RecurTypeMismatch{position,expected,got}`, `RecurNotInTailPosition`) + `code()` + 2 `ctx()` arms, bracket-`[code]`-free Display (F2); four negative `.ail.json` fixtures fire their codes point-exactly (`assert_eq!`, non-vacuous) via a new `loop_recur_typecheck_pin.rs` (7 tests) + a ct1 F2 human-mode sibling; `carve_out_inventory` 13→17 with the pre-existing mut.4-tidy "Twelve"-vs-13 stale-header drift corrected in passing. iter-1's `loop_sum_to.ail` round-trip fixture now ALSO typechecks clean (one fixture, two properties — no near-duplicate); `loop_forever.ail` (loop whose only path is `recur`) typechecks clean, asserting the spec "no termination claim is made or enforced". Three binding Boss design calls (mirrored into the journal): (1) `RecurTypeMismatch` is an Assign-style `subst.apply`-both-then-structural-`!=` pre-check, NOT a `unify`-propagate (a propagate would surface generic `type-mismatch` and fail the point-exact-code acceptance); (2) `loop_stack` element type is positional `Vec<Type>` NOT name-keyed `IndexMap` (recur rebinds by position; binder names live in `locals`); (3) diagnostic-code precedence is by pass ordering (synth before verify_loop_body), no explicit logic. NO codegen (iter-1 `lower_term` `CodegenError::Internal` stub stays — iter 3); NO `Diverge`/`verify_structural_recursion`/guardedness (spec deliberate boundary). Two DONE_WITH_CONCERNS, both Task 2, both journalled: (a) a plan-ordering defect — Task 2's `cargo build` 0-errors gate is unsatisfiable while `check_fn` (deferred by the plan to Task 4) is an unthreaded caller; orchestrator pulled the byte-identical declaration+threading forward to Task 2, left only the `verify_loop_body` invocation in Task 4 (no semantic change, only sequencing); (b) recon-undercount of cross-module synth callers (`builtins.rs`×2, `lift.rs:746`, `mono.rs:720`/`:1361`), the recurring mut.2-class, resolved via the plan's own designated compile-sweep oracle. Boss systemic fix folded into this commit: planner SKILL.md Step-5 self-review gains item 7 (compile-gate vs. deferred-caller ordering) so the Concern-(a) class is scrubbed at plan time, not discovered at execution. `cargo test --workspace` 608→616 / 0 red (Boss-reran independently); `verify_tail_positions`/tail-app byte-frozen confirmed by diff-hunk inspection. Component 5 (codegen loop-header/phi/back-edge) + the positive RUN-to-value / deep-`n` E2E = iteration 3 → 2026-05-17-iter-loop-recur.2.md - 2026-05-17 — iter loop-recur.2: standalone-`loop`/`recur` milestone (2 of 3) — Component 4 typecheck semantics. The iter-1 `synth` `CheckError::Internal` stub for `Term::Loop`/`Term::Recur` is replaced with real binder typing (each init synthed in outer scope + already-declared binder names of THIS loop via ordinary `locals` save/restore mirroring `Term::Let` verbatim; loop's static type = body type) + positional `recur` arity/per-arg-type checking via a new `loop_stack: &mut Vec<Vec<Type>>` frame threaded exactly as mut.2's `mut_scope_stack` (every recursive + cross-module + test synth caller, compile-swept). New private `verify_loop_body(t,in_loop_tail)` pass — a SIBLING of the byte-frozen `verify_tail_positions` (0 deletions, tail-app role untouched), invoked in `check_fn` after it so synth-raised codes take precedence by pass ordering. Four `Recur*` `CheckError` variants (`RecurOutsideLoop`, `RecurArityMismatch{expected,got}`, `RecurTypeMismatch{position,expected,got}`, `RecurNotInTailPosition`) + `code()` + 2 `ctx()` arms, bracket-`[code]`-free Display (F2); four negative `.ail.json` fixtures fire their codes point-exactly (`assert_eq!`, non-vacuous) via a new `loop_recur_typecheck_pin.rs` (7 tests) + a ct1 F2 human-mode sibling; `carve_out_inventory` 13→17 with the pre-existing mut.4-tidy "Twelve"-vs-13 stale-header drift corrected in passing. iter-1's `loop_sum_to.ail` round-trip fixture now ALSO typechecks clean (one fixture, two properties — no near-duplicate); `loop_forever.ail` (loop whose only path is `recur`) typechecks clean, asserting the spec "no termination claim is made or enforced". Three binding Boss design calls (mirrored into the journal): (1) `RecurTypeMismatch` is an Assign-style `subst.apply`-both-then-structural-`!=` pre-check, NOT a `unify`-propagate (a propagate would surface generic `type-mismatch` and fail the point-exact-code acceptance); (2) `loop_stack` element type is positional `Vec<Type>` NOT name-keyed `IndexMap` (recur rebinds by position; binder names live in `locals`); (3) diagnostic-code precedence is by pass ordering (synth before verify_loop_body), no explicit logic. NO codegen (iter-1 `lower_term` `CodegenError::Internal` stub stays — iter 3); NO `Diverge`/`verify_structural_recursion`/guardedness (spec deliberate boundary). Two DONE_WITH_CONCERNS, both Task 2, both journalled: (a) a plan-ordering defect — Task 2's `cargo build` 0-errors gate is unsatisfiable while `check_fn` (deferred by the plan to Task 4) is an unthreaded caller; orchestrator pulled the byte-identical declaration+threading forward to Task 2, left only the `verify_loop_body` invocation in Task 4 (no semantic change, only sequencing); (b) recon-undercount of cross-module synth callers (`builtins.rs`×2, `lift.rs:746`, `mono.rs:720`/`:1361`), the recurring mut.2-class, resolved via the plan's own designated compile-sweep oracle. Boss systemic fix folded into this commit: planner SKILL.md Step-5 self-review gains item 7 (compile-gate vs. deferred-caller ordering) so the Concern-(a) class is scrubbed at plan time, not discovered at execution. `cargo test --workspace` 608→616 / 0 red (Boss-reran independently); `verify_tail_positions`/tail-app byte-frozen confirmed by diff-hunk inspection. Component 5 (codegen loop-header/phi/back-edge) + the positive RUN-to-value / deep-`n` E2E = iteration 3 → 2026-05-17-iter-loop-recur.2.md
- 2026-05-17 — iter loop-recur.3: standalone-`loop`/`recur` milestone (3 of 3, TERMINAL) — Component 5 codegen + run-to-value E2E. The iter-1 `lower_term` `CodegenError::Internal` stub for `Term::Loop`/`Term::Recur` is replaced with real LLVM-IR lowering: loop binders are loop-carried values lowered as entry-block allocas (the mut.3 `pending_entry_allocas` mechanism) registered in the EXISTING `mut_var_allocas` map so the EXISTING `Term::Var` load path resolves them with zero new Var code (representation-sharing only — Var-lowering byte-unchanged); a fresh `loop.header.<id>` block reached by an unconditional `br` from the pre-header; `recur` lowers ALL args to SSA BEFORE any store (simultaneous positional rebind), stores into the binder allocas, back-edges `br` to the header, and sets the SINGLE existing `block_terminated` field at its OWN new emit site (a parallel SET call-site) so the loop's exit value is the emergent product of the existing `if`/`match` join — no separate loop-result phi/exit-block. A new `loop_frames` codegen stack lets `recur` find its target header+slots; saved/reset/restored at the single lambda-lowering boundary exactly as mut.3's `mut_var_allocas` triple. `clang -O2` mem2reg promotes the binder allocas to the phi nodes the spec's secondary implementation-shape describes. Four binding Boss design calls (mirrored into the journal), all on architectural-consistency / spec-pinned-invariant grounds (NOT effort): (1) alloca+mem2reg NOT hand-emitted phi (the linear-emit architecture has no phi-with-body-discovered-back-edge-predecessor precedent; the `if` arm sidesteps by opening its join last — a loop header cannot be opened last; mem2reg yields identical optimized output; spec's "phi" is the explicitly-secondary impl-shape the spec subordinates to the planner's exact-bytes authority); (2) reuse `mut_var_allocas` + the existing `Term::Var` load path (byte-unchanged; representation-sharing, the iter-2 AST/typecheck binder distinction is post-typecheck-irrelevant to codegen); (3) emergent loop-exit via the existing if/match join once recur terminates its block, no separate result phi; (4) reuse the single `block_terminated` field, recur sets it at its own emit site, ZERO edits to any existing SET/READ site (a second field would force editing every READ site — the opposite of the spec-pinned "tail-app's use byte-unchanged"). Diff confirmed surgical by hunk-header inspection: codegen/lib.rs = exactly 3 hunks (field + init + the stub→2-arms replacement), codegen/lambda.rs = exactly 2 hunks (save + restore); NO hunk at any existing `block_terminated` SET/READ site, at tail-app/tail-do lowering, or at `verify_tail_positions`. Three new `.ail` fixtures: `loop_sum_to_run.ail``55` (run-to-value), `loop_sum_to_deep.ail``500000500000` (1e6 iterations via the back-edge, no stack growth — the spec clause-2 correctness claim made executable), `loop_forever_build.ail` (infinite loop, no non-recur exit, `ail build` exit 0 — "typechecks AND compiles, no termination claim"; never executed). Codegen-ONLY iteration: no schema/AST/serde/typecheck change; hash pins (`loop_recur` + iter13a) + drift trio + `carve_out_inventory` confirmed green UNTOUCHED (not modified); the 3 new `.ail` fixtures are round-trip-auto-covered, NOT carve-outs. One DONE_WITH_CONCERNS (T3): the plan's literal `cargo test --workspace tail` filter resolves to ZERO tests (real guards are `*_tail_position_*`/`*musttail*`); orchestrator ran the gate via real names + the authoritative full-suite 619/0 which subsumes it — recurring `feedback_plan_pseudo_vs_reality` class, no behaviour change. Boss systemic fix folded into this commit: planner SKILL.md Step-5 gains item 8 (verification-command filter strings must resolve to ≥1 named test, or use the unfiltered suite + a count assertion) — the SECOND planner-meta-gap this milestone surfaced (iter-2 added item 7). `cargo test --workspace` 616→619 / 0 red (Boss-reran independently); the 3 loop/recur e2e explicitly green (55 / 500000500000 / infinite-compiles). **All three components shipped — the loop/recur milestone is structurally complete; milestone-close (mandatory `audit`, then `fieldtest` since loop/recur is user-visible Form-A surface) is the Boss's next pipeline step.** → 2026-05-17-iter-loop-recur.3.md - 2026-05-17 — iter loop-recur.3: standalone-`loop`/`recur` milestone (3 of 3, TERMINAL) — Component 5 codegen + run-to-value E2E. The iter-1 `lower_term` `CodegenError::Internal` stub for `Term::Loop`/`Term::Recur` is replaced with real LLVM-IR lowering: loop binders are loop-carried values lowered as entry-block allocas (the mut.3 `pending_entry_allocas` mechanism) registered in the EXISTING `mut_var_allocas` map so the EXISTING `Term::Var` load path resolves them with zero new Var code (representation-sharing only — Var-lowering byte-unchanged); a fresh `loop.header.<id>` block reached by an unconditional `br` from the pre-header; `recur` lowers ALL args to SSA BEFORE any store (simultaneous positional rebind), stores into the binder allocas, back-edges `br` to the header, and sets the SINGLE existing `block_terminated` field at its OWN new emit site (a parallel SET call-site) so the loop's exit value is the emergent product of the existing `if`/`match` join — no separate loop-result phi/exit-block. A new `loop_frames` codegen stack lets `recur` find its target header+slots; saved/reset/restored at the single lambda-lowering boundary exactly as mut.3's `mut_var_allocas` triple. `clang -O2` mem2reg promotes the binder allocas to the phi nodes the spec's secondary implementation-shape describes. Four binding Boss design calls (mirrored into the journal), all on architectural-consistency / spec-pinned-invariant grounds (NOT effort): (1) alloca+mem2reg NOT hand-emitted phi (the linear-emit architecture has no phi-with-body-discovered-back-edge-predecessor precedent; the `if` arm sidesteps by opening its join last — a loop header cannot be opened last; mem2reg yields identical optimized output; spec's "phi" is the explicitly-secondary impl-shape the spec subordinates to the planner's exact-bytes authority); (2) reuse `mut_var_allocas` + the existing `Term::Var` load path (byte-unchanged; representation-sharing, the iter-2 AST/typecheck binder distinction is post-typecheck-irrelevant to codegen); (3) emergent loop-exit via the existing if/match join once recur terminates its block, no separate result phi; (4) reuse the single `block_terminated` field, recur sets it at its own emit site, ZERO edits to any existing SET/READ site (a second field would force editing every READ site — the opposite of the spec-pinned "tail-app's use byte-unchanged"). Diff confirmed surgical by hunk-header inspection: codegen/lib.rs = exactly 3 hunks (field + init + the stub→2-arms replacement), codegen/lambda.rs = exactly 2 hunks (save + restore); NO hunk at any existing `block_terminated` SET/READ site, at tail-app/tail-do lowering, or at `verify_tail_positions`. Three new `.ail` fixtures: `loop_sum_to_run.ail``55` (run-to-value), `loop_sum_to_deep.ail``500000500000` (1e6 iterations via the back-edge, no stack growth — the spec clause-2 correctness claim made executable), `loop_forever_build.ail` (infinite loop, no non-recur exit, `ail build` exit 0 — "typechecks AND compiles, no termination claim"; never executed). Codegen-ONLY iteration: no schema/AST/serde/typecheck change; hash pins (`loop_recur` + iter13a) + drift trio + `carve_out_inventory` confirmed green UNTOUCHED (not modified); the 3 new `.ail` fixtures are round-trip-auto-covered, NOT carve-outs. One DONE_WITH_CONCERNS (T3): the plan's literal `cargo test --workspace tail` filter resolves to ZERO tests (real guards are `*_tail_position_*`/`*musttail*`); orchestrator ran the gate via real names + the authoritative full-suite 619/0 which subsumes it — recurring `feedback_plan_pseudo_vs_reality` class, no behaviour change. Boss systemic fix folded into this commit: planner SKILL.md Step-5 gains item 8 (verification-command filter strings must resolve to ≥1 named test, or use the unfiltered suite + a count assertion) — the SECOND planner-meta-gap this milestone surfaced (iter-2 added item 7). `cargo test --workspace` 616→619 / 0 red (Boss-reran independently); the 3 loop/recur e2e explicitly green (55 / 500000500000 / infinite-compiles). **All three components shipped — the loop/recur milestone is structurally complete; milestone-close (mandatory `audit`, then `fieldtest` since loop/recur is user-visible Form-A surface) is the Boss's next pipeline step.** → 2026-05-17-iter-loop-recur.3.md
- 2026-05-18 — iter loop-recur.tidy: milestone-close audit resolution (RED `39380d3` + this GREEN). Architect drift review found a `[high]` correctness defect: a `(lam ...)` body capturing an enclosing `Term::Loop` binder passed `ail check` (exit 0) then panicked `unreachable!()` at `crates/ailang-codegen/src/lambda.rs:102` on type-correct input — a DESIGN.md "Robustness against hallucinations" violation. Root cause (debugger-confirmed RED-first): the `Term::Lam` escape guard at `crates/ailang-check/src/lib.rs:3608` iterated only `mut_scope_stack`; loop binders live in the ordinary `locals` (iter-2 design) and never entered any escape-checked scope, so the mut.4-tidy `MutVarCapturedByLambda`-class rejection had no loop-binder counterpart. Fix (GREEN, implement mini-mode): new `CheckError::LoopBinderCapturedByLambda { name }` (bracket-`[code]`-free F2 Display) + `code()` `loop-binder-captured-by-lambda` + `ctx() {name}`, symmetric to `MutVarCapturedByLambda`; the `Term::Lam` escape guard gained a parallel pass over `loop_stack` (gate widened to `!mut_scope_stack.is_empty() || !loop_stack.is_empty()`). Minimal-mechanism design call: `loop_stack`'s frame element extended `Vec<Type>``Vec<(String, Type)>` so the already-threaded per-loop scope frame also carries binder names (`Term::Recur` still reads `.1` BY POSITION — Boss-call-2's positional-recur invariant preserved verbatim, the name is a second field consumed only by the escape guard, a consumer iter-2 did not anticipate; the parallel-stack alternative would touch all 35 `synth` call sites, strictly larger). Codegen byte-unchanged (typecheck-only fix; the lambda.rs:102 `unreachable!()` is now genuinely unreachable, kept as a defensive assertion). Two mut.4-tidy-mirrored in-source unit tests; lockstep `carve_out_inventory` 17→18 + `ct1_check_cli` mut-F2 sibling `cases`; DESIGN.md one-line symmetric-rule note. Boss folded into this GREEN commit: the two `[medium]` doc-honesty edits the audit also surfaced (`mut_var_allocas` rustdoc now states its mut-var+loop-binder dual use truthfully; `Term::Loop` doc-comment now describes the real shipped iter-2/3 typecheck+alloca+mem2reg state, not iter-1's stale "per-binder phi" stub forward-look) + a `[low]` P2 roadmap `[todo]` (the recon-undercount 3×-pattern → an `ailang-plan-recon` agent-definition countermeasure; pairs with planner Step-5 items 7+8). Bench gate (audit Step 2): `check.py`/`compile_check.py`/`cross_lang.py` all exit 0, 25 metrics 0 regressed — **pristine** (strictly-additive surface, no hot-path; the pre-existing P2 `*.bump_s` hardware-staleness drift did not trip) → carry-on, no baseline/ratify. `cargo test --workspace` 619→622 / 0 red (Boss-reran independently incl. hash_pin 11/0 — the ast.rs/codegen doc-comment edits moved no canonical-JSON hash). **The loop/recur milestone is audit-clean** — architect drift cleared, bench pristine; the only remaining pipeline step before full close is `fieldtest` (loop/recur is user-visible Form-A surface) → 2026-05-17-iter-loop-recur.tidy.md
+27 -6
View File
@@ -36,12 +36,16 @@ work progresses.
## P0 — In flight ## P0 — In flight
- [~] **\[milestone\]** Standalone `loop` / `recur` — strictly-additive - [~] **\[milestone\]** Standalone `loop` / `recur` — strictly-additive
iteration surface lowering to the existing `tail-app` back-edge, with iteration surface, `recur` valid only in tail position. All three
one closed rule (`recur` valid only in tail position of its enclosing iterations + a tidy shipped: iter 1 `a179ec3` (additive AST nodes),
`loop`). De-bundled re-attempt of the reverted Iteration-discipline iter 2 `1566ce0` (typecheck), iter 3 `edd2558` (codegen + run-to-value
it.1 core only: no totality claim, no guardedness pass, no `Diverge`, E2E), tidy (lambda-captures-loop-binder rejected at check, symmetric
`tail-app` byte-unchanged. Spec written + grounding-checked + user- to mut.4-tidy). Milestone-close `audit` clean: architect drift
approved 2026-05-17 (97c1ed1). resolved (the `[high]` codegen crash fixed in the tidy + two
`[medium]` doc-honesty edits folded in), bench pristine (25 metrics,
0 regressed — strictly-additive surface, no hot-path touched).
**Remaining: `fieldtest`** (loop/recur is user-visible Form-A
surface) — the only step before full close.
- context: `docs/specs/2026-05-17-loop-recur.md`; principles entry - context: `docs/specs/2026-05-17-loop-recur.md`; principles entry
`docs/specs/2026-05-17-llm-surface-discipline.md` §6.2; revert `docs/specs/2026-05-17-llm-surface-discipline.md` §6.2; revert
rationale `docs/specs/2026-05-16-iteration-discipline-revert.md`. rationale `docs/specs/2026-05-16-iteration-discipline-revert.md`.
@@ -254,6 +258,23 @@ work progresses.
intra-crate links). All predate the design-md-consolidation intra-crate links). All predate the design-md-consolidation
milestone; treat as a one-off sweep. milestone; treat as a one-off sweep.
- context: JOURNAL 2026-05-10 ("Audit close: design-md-consolidation"). - context: JOURNAL 2026-05-10 ("Audit close: design-md-consolidation").
- [ ] **\[todo\]** `ailang-plan-recon` cross-crate-caller-undercount
countermeasure — the recon agent hand-lists exhaustive-`match`/caller
sites and has under-counted the true blast radius **three times in
one milestone** (loop-recur.1 walker arms, loop-recur.2 cross-module
`synth` callers, loop-recur.tidy implicit). Each was caught only by
the implement orchestrator's compile-driven sweep, not by the plan.
The pattern is structural: a hand-enumerated site list for a
workspace-wide signature/exhaustive-match change is inherently
lossy. Tighten `skills/planner/agents/ailang-plan-recon.md` so that
for any signature-change / additive-enum-variant scope the recon
REPORTS the compile-driven enumeration command as the authoritative
site set (and frames its own hand-list as advisory), rather than
presenting the hand-list as complete. No language change; an
agent-definition discipline fix.
- context: loop/recur milestone-close audit (architect `[low]`,
2026-05-18); pairs with the planner Step-5 items 7+8 added the
same milestone (those scrub the *plan*; this scrubs the *recon*).
- [ ] **\[todo\]** `design_schema_drift.rs` fidelity widening — - [ ] **\[todo\]** `design_schema_drift.rs` fidelity widening —
current test checks anchor *presence* anywhere in DESIGN.md; current test checks anchor *presence* anywhere in DESIGN.md;
the audit found that `[high]` schema gaps in §"Data model" the audit found that `[high]` schema gaps in §"Data model"