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:
@@ -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
|
||||
}
|
||||
@@ -226,6 +226,13 @@ fn check_human_mode_renders_mut_diagnostic_code_exactly_once() {
|
||||
"test_mut_var_captured_by_lambda.ail.json",
|
||||
"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 {
|
||||
let fixture = examples_dir().join(fixture_name);
|
||||
|
||||
@@ -351,7 +351,7 @@ mod tests {
|
||||
let mut warnings: Vec<crate::diagnostic::Diagnostic> = Vec::new();
|
||||
// loop-recur iter 2: test helper synths one term from top-of-
|
||||
// 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(
|
||||
t,
|
||||
&env,
|
||||
@@ -598,7 +598,7 @@ mod tests {
|
||||
let mut warnings: Vec<crate::diagnostic::Diagnostic> = Vec::new();
|
||||
// loop-recur iter 2: test helper synths one term from top-of-
|
||||
// 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(
|
||||
&term,
|
||||
&env,
|
||||
|
||||
+130
-32
@@ -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).")]
|
||||
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
|
||||
/// lexically-enclosing `Term::Loop` (the `loop_stack` was empty).
|
||||
#[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::UnsupportedMutVarType { .. } => "mut-var-unsupported-type",
|
||||
CheckError::MutVarCapturedByLambda { .. } => "mut-var-captured-by-lambda",
|
||||
CheckError::LoopBinderCapturedByLambda { .. } => "loop-binder-captured-by-lambda",
|
||||
CheckError::RecurOutsideLoop => "recur-outside-loop",
|
||||
CheckError::RecurArityMismatch { .. } => "recur-arity-mismatch",
|
||||
CheckError::RecurTypeMismatch { .. } => "recur-type-mismatch",
|
||||
@@ -852,6 +866,9 @@ impl CheckError {
|
||||
CheckError::MutVarCapturedByLambda { name } => {
|
||||
serde_json::json!({"name": name})
|
||||
}
|
||||
CheckError::LoopBinderCapturedByLambda { name } => {
|
||||
serde_json::json!({"name": name})
|
||||
}
|
||||
CheckError::RecurArityMismatch { expected, 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.
|
||||
// Empty at fn entry; pushed/popped by `Term::Loop` arms during
|
||||
// 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)?;
|
||||
unify(&ret_ty, &body_ty, &mut subst)?;
|
||||
// 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();
|
||||
// loop-recur iter 2: a const value has no enclosing loop; any
|
||||
// `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)?;
|
||||
out_warnings.extend(warnings);
|
||||
unify(&c.ty, &v, &mut subst)?;
|
||||
@@ -2919,15 +2936,18 @@ pub(crate) fn synth(
|
||||
// name through it. Position: locals-adjacent because both are
|
||||
// per-fn-body lexical scope state.
|
||||
mut_scope_stack: &mut Vec<IndexMap<String, Type>>,
|
||||
// loop-recur iter 2: per-walk loop-binder-type stack. Each frame
|
||||
// is one `Term::Loop`'s binder types in declaration order
|
||||
// (positional — `recur` rebinds binders by position, not name,
|
||||
// so this is `Vec<Type>` not name-keyed like `mut_scope_stack`).
|
||||
// Pushed on `Term::Loop` body entry, popped on exit; consulted
|
||||
// innermost-first by `Term::Recur` for arity + per-position type.
|
||||
// Position: mut_scope_stack-adjacent — both are per-fn-body
|
||||
// lexical-control scope state threaded identically.
|
||||
loop_stack: &mut Vec<Vec<Type>>,
|
||||
// loop-recur iter 2: per-walk loop-binder stack. Each frame is one
|
||||
// `Term::Loop`'s binders in declaration order. `recur` rebinds
|
||||
// binders by position, not name, so the type list is positional;
|
||||
// iter loop-recur.tidy added the binder name alongside the type
|
||||
// (so `Term::Lam`'s escape guard can reject a free var that is an
|
||||
// in-scope loop binder, symmetric to `mut_scope_stack`). Pushed on
|
||||
// `Term::Loop` body entry, popped on exit; consulted innermost-first
|
||||
// by `Term::Recur` for arity + per-position type, and across all
|
||||
// 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>,
|
||||
in_def: &str,
|
||||
subst: &mut Subst,
|
||||
@@ -3605,7 +3625,16 @@ pub(crate) fn synth(
|
||||
// `MutVarCapturedByLambda`. Reuses `desugar::free_vars_in_term`
|
||||
// (same walker `lift.rs` uses) which honours pattern bindings
|
||||
// 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();
|
||||
for p in params {
|
||||
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
|
||||
// binders via `locals`, exactly as `Term::Let` binds its
|
||||
// name). The loop's static type is the body's type. The
|
||||
// ordered binder types are pushed onto `loop_stack` for the
|
||||
// body walk so an inner `Term::Recur` can position-check
|
||||
// against them; popped on exit. Binder names are NOT in
|
||||
// `loop_stack` (recur is positional, not by-name).
|
||||
// ordered binders (name + type) are pushed onto `loop_stack`
|
||||
// for the body walk so an inner `Term::Recur` can
|
||||
// position-check the types and `Term::Lam`'s escape guard
|
||||
// (iter loop-recur.tidy) can reject capture of a binder name;
|
||||
// popped on exit.
|
||||
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();
|
||||
for b in binders {
|
||||
let init_ty = synth(
|
||||
@@ -3907,11 +3944,11 @@ pub(crate) fn synth(
|
||||
in_def, subst, counter, residuals, free_fn_calls, warnings,
|
||||
)?;
|
||||
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());
|
||||
restore.push((b.name.clone(), prev));
|
||||
}
|
||||
loop_stack.push(binder_tys);
|
||||
loop_stack.push(binder_frame);
|
||||
let body_result = synth(
|
||||
body, env, locals, mut_scope_stack, loop_stack, effects,
|
||||
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
|
||||
// enclosing if/match requires.
|
||||
Term::Recur { args } => {
|
||||
let binder_tys = match loop_stack.last() {
|
||||
let binder_frame = match loop_stack.last() {
|
||||
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 {
|
||||
expected: binder_tys.len(),
|
||||
expected: binder_frame.len(),
|
||||
got: args.len(),
|
||||
});
|
||||
}
|
||||
@@ -3954,7 +3991,7 @@ pub(crate) fn synth(
|
||||
a, env, locals, mut_scope_stack, loop_stack, effects,
|
||||
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);
|
||||
if applied_binder != applied_arg {
|
||||
return Err(CheckError::RecurTypeMismatch {
|
||||
@@ -4342,7 +4379,7 @@ mod tests {
|
||||
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
|
||||
// loop-recur iter 2: loop-free unit-test call site — fresh
|
||||
// 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 subst = Subst::default();
|
||||
let mut counter: u32 = 0;
|
||||
@@ -4383,7 +4420,7 @@ mod tests {
|
||||
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
|
||||
// loop-recur iter 2: loop-free unit-test call site — fresh
|
||||
// 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();
|
||||
frame.insert("x".into(), Type::float()); // inner mut-var
|
||||
mut_scope_stack.push(frame);
|
||||
@@ -4427,7 +4464,7 @@ mod tests {
|
||||
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
|
||||
// loop-recur iter 2: loop-free unit-test call site — fresh
|
||||
// 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();
|
||||
outer.insert("x".into(), Type::int());
|
||||
mut_scope_stack.push(outer);
|
||||
@@ -4544,7 +4581,7 @@ mod tests {
|
||||
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
|
||||
// loop-recur iter 2: loop-free unit-test call site — fresh
|
||||
// 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 subst = Subst::default();
|
||||
let mut counter: u32 = 0;
|
||||
@@ -4587,7 +4624,7 @@ mod tests {
|
||||
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
|
||||
// loop-recur iter 2: loop-free unit-test call site — fresh
|
||||
// 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 subst = Subst::default();
|
||||
let mut counter: u32 = 0;
|
||||
@@ -4629,7 +4666,7 @@ mod tests {
|
||||
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
|
||||
// loop-recur iter 2: loop-free unit-test call site — fresh
|
||||
// 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 subst = Subst::default();
|
||||
let mut counter: u32 = 0;
|
||||
@@ -4684,6 +4721,17 @@ mod tests {
|
||||
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
|
||||
/// the exact stable kebab codes the spec acceptance mandates
|
||||
/// (`recur-outside-loop` / `recur-arity-mismatch` /
|
||||
@@ -4760,7 +4808,7 @@ mod tests {
|
||||
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
|
||||
// loop-recur iter 2: loop-free unit-test call site — fresh
|
||||
// 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 subst = Subst::default();
|
||||
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 {
|
||||
Def::Fn(FnDef {
|
||||
name: name.into(),
|
||||
@@ -7502,7 +7600,7 @@ mod tests {
|
||||
let mut mut_scope_stack: Vec<IndexMap<String, Type>> = Vec::new();
|
||||
// loop-recur iter 2: loop-free unit-test call site — fresh
|
||||
// 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 subst = Subst::default();
|
||||
let mut counter: u32 = 0;
|
||||
|
||||
@@ -746,7 +746,7 @@ impl<'a> Lifter<'a> {
|
||||
// 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
|
||||
// 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)?;
|
||||
Ok(subst.apply(&ty))
|
||||
}
|
||||
|
||||
@@ -720,7 +720,7 @@ pub fn collect_mono_targets(
|
||||
// loop-recur iter 2: mono re-synth begins from top-of-body —
|
||||
// empty loop-stack (any `Term::Loop` pushes its own frame as the
|
||||
// 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(
|
||||
&f.body,
|
||||
&env,
|
||||
@@ -1366,7 +1366,7 @@ pub(crate) fn collect_residuals_ordered(
|
||||
// loop-recur iter 2: mono re-synth begins from top-of-body —
|
||||
// empty loop-stack (any `Term::Loop` pushes its own frame as the
|
||||
// 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(
|
||||
&f.body,
|
||||
&env,
|
||||
|
||||
@@ -714,14 +714,20 @@ struct Emitter<'a> {
|
||||
/// dec'ing, because Implicit and Borrow do not carry the "caller
|
||||
/// handed off ownership" signal that makes the dec safe.
|
||||
current_param_modes: BTreeMap<String, ParamMode>,
|
||||
/// Iter mut.3: per-fn map of mut-var name → (alloca SSA name,
|
||||
/// AIL element type). Populated when entering a `Term::Mut`
|
||||
/// block; entries removed (or restored to their prior binding)
|
||||
/// when leaving the block. Consulted by the `Term::Var` arm of
|
||||
/// `lower_term` before `self.locals`, matching the typecheck-
|
||||
/// side mut-scope-stack precedence (`synth` in `ailang-check`
|
||||
/// walks `mut_scope_stack` innermost-first before falling
|
||||
/// through to locals/globals).
|
||||
/// Per-fn map of name → (alloca SSA name, AIL element type) for
|
||||
/// the two alloca-resident binding classes. Populated on entry
|
||||
/// to a `Term::Mut` block (iter mut.3 — mut-vars) AND on entry
|
||||
/// to a `Term::Loop` (iter loop-recur.3 — loop binders ride the
|
||||
/// same alloca representation; the `Term::Loop` arm inserts each
|
||||
/// binder here and `recur` stores into the slot). Entries are
|
||||
/// removed (or restored to their prior binding) on block exit.
|
||||
/// 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)>,
|
||||
/// loop-recur iter 3: stack of enclosing `Term::Loop` codegen
|
||||
/// frames. Each frame is `(header_block_label, binder_slots)`
|
||||
|
||||
@@ -548,12 +548,18 @@ pub enum Term {
|
||||
/// additive: pre-existing fixtures hash bit-identically because
|
||||
/// none carry the `"t":"loop"` tag. `binders` has no
|
||||
/// `skip_serializing_if` (mirrors `Term::Mut.vars` — the field
|
||||
/// is part of the shape). Typecheck binder/recur semantics land
|
||||
/// in loop-recur iter 2 (`synth` stubs with `CheckError::Internal`
|
||||
/// in this iter); codegen (loop-header + per-binder phi +
|
||||
/// back-edge) in iter 3 (`lower_term` stubs with
|
||||
/// `CodegenError::Internal`). No totality claim — an infinite
|
||||
/// loop is legal. See `docs/specs/2026-05-17-loop-recur.md`.
|
||||
/// is part of the shape). Typecheck (loop-recur.2): `synth`
|
||||
/// types each binder init, the loop's type is the body's type;
|
||||
/// `recur` arity/type is checked positionally via `loop_stack`
|
||||
/// and `verify_loop_body` enforces `recur`-in-tail-position; a
|
||||
/// lambda capturing a binder is rejected
|
||||
/// (`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 {
|
||||
binders: Vec<LoopBinder>,
|
||||
body: Box<Term>,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! under `examples/*.ail.json` after milestone close. The list is
|
||||
//! 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
|
||||
//! close (canonical-form rejection / unbound / class-def rejection).
|
||||
//! - 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
|
||||
//! load-bearing assertion, mirroring the §C4(a) / mut.2
|
||||
//! 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;
|
||||
|
||||
@@ -43,6 +47,8 @@ const EXPECTED: &[&str] = &[
|
||||
"test_mut_var_unsupported_type.ail.json",
|
||||
// Iter mut.4-tidy — lambda-capture-of-mut-var rejection
|
||||
"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
|
||||
"test_recur_arity_mismatch.ail.json",
|
||||
"test_recur_not_in_tail_position.ail.json",
|
||||
|
||||
+4
-1
@@ -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
|
||||
stack-resident scalar types `{Int, Float, Bool, Unit}`; capturing a
|
||||
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`.
|
||||
|
||||
**`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
|
||||
@@ -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.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-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
@@ -36,12 +36,16 @@ work progresses.
|
||||
## P0 — In flight
|
||||
|
||||
- [~] **\[milestone\]** Standalone `loop` / `recur` — strictly-additive
|
||||
iteration surface lowering to the existing `tail-app` back-edge, with
|
||||
one closed rule (`recur` valid only in tail position of its enclosing
|
||||
`loop`). De-bundled re-attempt of the reverted Iteration-discipline
|
||||
it.1 core only: no totality claim, no guardedness pass, no `Diverge`,
|
||||
`tail-app` byte-unchanged. Spec written + grounding-checked + user-
|
||||
approved 2026-05-17 (97c1ed1).
|
||||
iteration surface, `recur` valid only in tail position. All three
|
||||
iterations + a tidy shipped: iter 1 `a179ec3` (additive AST nodes),
|
||||
iter 2 `1566ce0` (typecheck), iter 3 `edd2558` (codegen + run-to-value
|
||||
E2E), tidy (lambda-captures-loop-binder rejected at check, symmetric
|
||||
to mut.4-tidy). Milestone-close `audit` clean: architect drift
|
||||
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
|
||||
`docs/specs/2026-05-17-llm-surface-discipline.md` §6.2; revert
|
||||
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
|
||||
milestone; treat as a one-off sweep.
|
||||
- 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 —
|
||||
current test checks anchor *presence* anywhere in DESIGN.md;
|
||||
the audit found that `[high]` schema gaps in §"Data model"
|
||||
|
||||
Reference in New Issue
Block a user