Tidy iter addressing the audit-mut-local drift. Plus paired baseline
update on bench/baseline_compile.json (audit-skill discipline).
Architect [high] items closed:
1. CheckError::MutVarCapturedByLambda rejects lambdas whose body
free vars include a mut-var of the enclosing mut_scope_stack.
Uses the existing ailang_core::desugar::free_vars_in_term walker
(which honours Term::Match pattern bindings). The scan runs only
when mut_scope_stack is non-empty.
2. crates/ailang-codegen/src/lambda.rs: the capture-not-in-locals
path that previously raised CodegenError::Internal blaming the
typechecker now uses unreachable!. The companion comment block
was rewritten to state the current reality.
Architect [medium] items closed (stale mut.1-stub history comments):
3. 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 mut.3-end-state. The
inline jsonc comment on {'t': 'assign'} was updated to drop the
'deferred to mut.2/mut.3' language.
4. crates/ailang-codegen/src/lib.rs: the stale comment block above
the real Term::Mut arm describing the mut.1 stub was removed
entirely.
Other:
- Short-circuit on empty mut_scope_stack in synth's Term::Var arm:
the iter mut.2 prepend now skips the iter-and-find walk when
the stack is empty, eliminating any per-Var-resolution overhead
for the common case (programs with no mut blocks). The
short-circuit did NOT close the check_ms regression — see ratify
below.
- Spec docs/specs/2026-05-15-mut-local.md §'Out of scope' amended
with the lambda-capture rejection bullet.
- Negative fixture examples/test_mut_var_captured_by_lambda.ail.json
+ driver test extension in crates/ailang-check/tests/
mut_typecheck_pin.rs (6th test) +
crates/ailang-core/tests/carve_out_inventory.rs EXPECTED bumped
12 → 13.
Bench-regression ratify:
bench/compile_check.py check_ms showed a uniform ~30-50% relative
shift across the entire 11-fixture suite (~0.5ms absolute on a
1.4-1.5ms baseline). The uniformity across fixtures of very
different Var counts argues for a fixed-cost-per-invocation tax,
not a Var-proportional hot path. The Term::Var short-circuit
falsified the hot-path hypothesis. The plausible remaining causes
(synth-parameter-passing through ~19 recursive sites, and binary-
size startup tax from mut-* adding ~1400 LOC to typecheck/codegen)
are both feature-cost, not pathological. Ratified by paired
journal entry; bench/baseline_compile.json updated to the post-
mut-local numbers via 'bench/compile_check.py --update-baseline'.
bench/check.py continues to show the established tail-latency-noise
envelope from audit-pd (2026-05-14) — no separate ratify needed.
bench/cross_lang.py clean.
Tests: 594 → 598 green (4 new lib.rs mod tests + 1 driver test +
1 fixture-corpus uptake).
Journal: docs/journals/2026-05-15-iter-mut.4-tidy.md.
mut-local milestone end-to-end status:
- mut.1 (7b92719): AST + Form A surface.
- mut.2 (b24718a): typecheck.
- mut.3 (03fb633): codegen + e2e.
- mut.4-tidy (this commit): audit-drift close + bench ratify.
mut-local milestone CLOSED.
Refs: docs/specs/2026-05-15-mut-local.md,
docs/plans/2026-05-15-iter-mut.4-tidy.md.
6.2 KiB
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 codemut-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 existingailang_core::desugar::free_vars_in_termhelper, which already honours pattern-binding subtraction inTerm::Matcharms) and rejects any free var whose name appears in any enclosingmut_scope_stackframe.- 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.1comment 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 realTerm::Mutcodegen arm still described the mut.1 stub. Removed entirely; the realIter mut.3comment 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_stackwalk 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-checkandailang-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::Matchpattern binding over-approximation. Thefree_vars_in_termwalker 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.pybaseline updated in this commit. The audit-skill discipline ("NO BASELINE UPDATE WITHOUT A PAIRED JOURNAL RATIFY ENTRY") is satisfied by this entry.