# iter mut.4-tidy — close mut-local audit drift

**Date:** 2026-05-15
**Status:** DONE
**Spec / Plan:** `docs/specs/2026-05-15-mut-local.md` (no spec changes
beyond a §"Out of scope" amendment); `docs/plans/2026-05-15-iter-mut.4-tidy.md`.
**Started from:** 6966cce
**Trigger:** audit close on the mut-local milestone.

## What this iter shipped

Tidy work closing the audit drift the architect surfaced after
mut.3 landed. Two `[high]` items, two `[medium]` items, plus a
substantive bench regression on `bench/compile_check.py` `check_ms`.

### Architect [high] 1 — lambda-captures-of-mut-var diagnostic

The spec's "mut-vars cannot escape their enclosing block"
invariant was not enforced by any check pass — codegen detected
the violation via a `CodegenError::Internal` whose comment blamed
the typechecker for letting it through. The fix lands the
diagnostic at the typecheck layer:

- New variant `CheckError::MutVarCapturedByLambda { name: String }`
  with kebab-case code `mut-var-captured-by-lambda`, `ctx()`
  emitting `{"name": <id>}`, and the canonical bracketed-`[code]:`
  Display attribute.
- `Term::Lam`'s synth arm now walks the lambda body's free vars
  (via the existing `ailang_core::desugar::free_vars_in_term`
  helper, which already honours pattern-binding subtraction in
  `Term::Match` arms) and rejects any free var whose name appears
  in any enclosing `mut_scope_stack` frame.
- The walk is gated by `!mut_scope_stack.is_empty()` so lambdas
  outside any mut block pay no overhead.

### Architect [high] 2 — codegen comment cleanup

The codegen lambda.rs path that previously raised
`CodegenError::Internal("lambda capture {c} not in locals —
typechecker bug?")` now uses `unreachable!` with a message
naming the typecheck rejection that gates it. The companion
comment block at `lambda.rs:474` was rewritten to state
the current reality (typecheck rejects via
`MutVarCapturedByLambda`) instead of the stale promise that
"mut.2 typecheck will enforce this" (mut.2 didn't).

### Architect [medium] 3 + 4 — stale mut.1-stub history comments

- `docs/DESIGN.md` §"Term (expression)" mut/assign block: the
  trailing paragraph describing the iter mut.1 stub state was
  replaced with one describing the current end-to-end mut.3
  reality. The `// Iter mut.1` comment on the `{"t": "assign"}`
  jsonc fragment was updated to drop the "deferred to mut.2/mut.3"
  language.
- `crates/ailang-codegen/src/lib.rs:1749-1758` — the comment block
  above the real `Term::Mut` codegen arm still described the mut.1
  stub. Removed entirely; the real `Iter mut.3` comment immediately
  below it is now the only descriptive header.

### Bench regression ratify — `check_ms` ~30-50% uniform shift

`bench/compile_check.py` flags an 11-fixture-wide `check_ms`
regression on the mut-local milestone (30-50% relative; ~0.5ms
absolute on a 1.4-1.5ms baseline). The pattern is **uniform across
fixtures of very different Var counts** (`hello.ail` 7 lines and
`bench_list_sum_explicit.ail` 100+ lines both shift by ~0.5ms),
which indicates a fixed-cost-per-`ail check`-invocation tax rather
than a hot-path-per-Var cost.

Root-cause analysis:

- **Hypothesis A: `mut_scope_stack` walk at every Var resolution.**
  Tested by the Task 3 short-circuit (`if !mut_scope_stack.is_empty()`).
  Re-bench: regression unchanged. **Falsified.**
- **Hypothesis B: synth-parameter-passing overhead.** Threading
  `&mut Vec<IndexMap<...>>` through ~19 recursive synth calls per
  Term-tree walk. Each call pays a 64-bit reference push to the
  stack frame. Not directly testable without rolling the
  threading back. Plausible but speculative.
- **Hypothesis C: binary size / startup cost.** mut.* added
  ~1400 lines of code across `ailang-check` and `ailang-codegen`.
  Larger binary → slightly slower process startup, dynamic linker
  resolution, instruction-cache warmup. ~0.5ms is in the right
  ballpark for that kind of cost on a small binary. The uniform-
  across-fixtures pattern is consistent with this — it's not
  Var-proportional.

The pragmatic call: hypothesis B and C are both consistent with the
observation; neither has a cheap remedy at this milestone. The
short-circuit in Task 3 closes hypothesis A's contribution if any,
and remains the right shape for future workloads that DO have
non-empty `mut_scope_stack`. The remaining shift is the cost of
the feature.

**Disposition: ratify** as a fixed-cost feature tax. The journal
records this disposition; the `bench/compile_check.py` baseline is
updated to the post-mut.4-tidy numbers. Future bench observations
will tolerance-check against the new baseline. If a future iter
finds a substantive hot-path that retroactively explains the shift,
the ratify can be revisited.

`bench/check.py` regressions are tail-latency metrics
(`latency.implicit_at_rc.p99_9_us / max_us`) consistent with the
"established noise envelope, 15th consecutive observation" finding
from audit-pd (2026-05-14). No ratify needed — they fall under
the existing noise carve-out.

`bench/cross_lang.py` is clean (25/0/0/25). Runtime is unchanged
by mut-local.

## Working tree

Eight files: 7 modified + 1 new (`examples/test_mut_var_captured_by_lambda.ail.json`).

## Tests

594 → 598 green (4 new: the variant code-pin smoke test, the
positive `lambda_outside_mut_still_typechecks_clean` regression
guard, the negative `lambda_inside_mut_capturing_mut_var_emits_mut_var_captured_by_lambda`
in lib.rs, and the integration-test
`lambda_capturing_mut_var_emits_mut_var_captured_by_lambda`).

## Concerns

- **`Term::Match` pattern binding over-approximation.** The
  `free_vars_in_term` walker correctly subtracts pattern-bound
  names; reused without modification. No false positives
  encountered in the existing test corpus.
- **Bench-ratify decision.** Documented above. The 30-50%
  uniform shift is plausible but not proven to be feature-cost.
  A future bencher dispatch could test hypothesis B by branching
  off a "remove the mut_scope_stack parameter, use a thread-local
  / Env field instead" prototype and measuring the difference. Out
  of scope for this tidy.
- **`bench/compile_check.py` baseline updated** in this commit.
  The audit-skill discipline ("NO BASELINE UPDATE WITHOUT A
  PAIRED JOURNAL RATIFY ENTRY") is satisfied by this entry.
