Files
AILang/docs/journals/2026-05-17-iter-loop-recur.tidy.md
T
Brummel 2ee97943bd 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.
2026-05-18 00:21:06 +02:00

6.3 KiB
Raw Blame History

iter loop-recur.tidy — lambda capturing a loop binder must fail ail check, not panic codegen

Date: 2026-05-18 Started from: 39380d361d 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.rsloop_stack decl type
  • crates/ailang-check/src/lift.rsloop_stack decl type
  • crates/ailang-check/src/mono.rsloop_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 usecrates/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