Iter 16b.7: nested LetRec mutual capture (params, not name)

Inner LetRec capturing outer LetRec's params now lifts cleanly via
the existing post-order traversal — outer's params are KnownType
in inner's outer-scope, so 16b.2's fast-path handles them. Inner
LetRec capturing outer's NAME remains rejected (closure-of-self
in body, deferred separately).

Plus hardening: lift.rs's deferred path tracks the outer LetRec
name in a parallel `enclosing_letrec_names` set so the symmetric
inner-captures-outer-name rejection fires there too — without it
a silent lift produced a runtime arity mismatch.

- examples/nested_let_rec.{ailx,ail.json}: outer counts down via
  inner that captures the outer's threshold param.
- desugar.rs: clarified panic message for outer-name capture.
- lift.rs: enclosing_letrec_names tracking.
- e2e + desugar tests: 120 → 122 (+2).

End-of-16b series: feature-complete except for closure-of-self
in body and closure-with-polymorphism, both queued separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 22:48:14 +02:00
parent 76d61aeced
commit e5f9828a04
6 changed files with 450 additions and 3 deletions
+153
View File
@@ -4342,3 +4342,156 @@ pair generic over the enclosing fn's type vars).
(chain-machinery exhaustiveness or `__unreachable__`), 16e
(`==` extension to Bool/Str/Unit), 17a (per-fn arena, gated)
unchanged.
## Iter 16b.7 — nested LetRec mutual capture (params, not name)
**Goal.** Lift the "nested LetRec mutual capture" rejection,
but only for the case where the inner LetRec captures the
OUTER LetRec's PARAMS. Capturing the OUTER's NAME from an
inner LetRec body remains rejected — it is the same
chicken-and-egg as 16b.5-body / closure-of-self (the value
form of the outer LetRec doesn't exist before its own lift
completes), and a clearer error is now emitted in its place.
**Architectural confirmation (the empirical result).** The
params-only case worked **out of the box**. The desugar pass
already enters outer's params into the inner's outer-scope as
`ScopeEntry::KnownType` while marking only the outer's NAME
as `EnclosingLetRec` (`desugar.rs:552-555`); the same shape
holds in the post-typecheck lifter via the `locals` map
(`lift.rs:373-378`). Post-order traversal then lifts the
inner LetRec first under the existing 16b.2 fast path, with
the outer's param appended to the inner's signature; once
the outer is then lifted, every call site inside outer's
body of the form `inner$lr_M(args, outer_param_x)` continues
to refer to `outer_param_x` because outer hasn't yet renamed
its params (outer's lift only renames its OWN name → lifted-
name, not its params). After outer's own lift, those param
references resolve to outer's lifted-fn's params (same name).
So: no implementation change was needed for the supported
case. The work in 16b.7 is the test/fixture/JOURNAL surface
plus tightening the rejection of the still-unsupported
sub-case (inner-captures-outer-NAME) to point at the right
follow-up.
**What shipped.**
- `examples/nested_let_rec.ailx` + `.ail.json` (50 LOC
source). `nested_sum(n)` returns the sum of `1..=n` by
combining two recursive helpers: outer LetRec `outer`
iterates `i` over `1..=n`; for each `i` it calls inner
LetRec `inner(j)` to count one for each `j` in `1..=i`.
Inner captures `i` (outer's PARAM) and produces the count
`i`; outer captures `n` (the enclosing fn's param) and
sums those counts. Total = `n*(n+1)/2`. Drives at
`n ∈ {1, 3, 5}` → output `1\n6\n15\n`. The fixture
exercises the post-order lift, with inner lifted first as
`inner$lr_0(j: Int, i: Int) -> Int` and outer lifted next
as `outer$lr_1(i: Int, n: Int) -> Int`.
- `crates/ail/tests/e2e.rs::nested_let_rec_demo` (+18 LOC):
e2e count 44 → 45.
- `crates/ailang-core/src/desugar.rs`: tightened the
`ScopeEntry::EnclosingLetRec` panic (lines 653-660) from
the 16b.4-era "nested mutual-capture is not supported,
queued for 16b.7" message to a 16b.7 message that names
the precise constraint ("captures outer LetRec name `<n>`
— closure conversion of a LetRec inside its own body
would be required") and points at the right follow-up
(`closure-of-self` / 16b.5-body / closure-poly).
- `crates/ailang-check/src/lift.rs`: added
`Lifter.enclosing_letrec_names: BTreeSet<String>`,
populated on entry to each LetRec arm via
`BTreeSet::insert(name)` and removed on exit, mirroring
the desugar pass's `EnclosingLetRec` marker. The capture
classification step now panics with the same 16b.7
message if any capture is in `enclosing_letrec_names`.
This closes a defensive hole the post-typecheck path
would otherwise leave open: without the marker, a
deferred outer LetRec (one with Let/Match captures) whose
inner LetRec captured the outer's name would silently
lift the inner with the outer's pre-lift fn-type as an
extra param, producing a type-correct AST that calls a
fn-value with the wrong arity at runtime once the outer
was itself lifted with captures. The new check rejects
that path symmetrically with the desugar path.
- `crates/ailang-core/src/desugar.rs::tests` (+2 net):
`nested_let_rec_inner_captures_outer_name_panics`
(`#[should_panic(expected = "16b.7")]`) and
`nested_let_rec_inner_captures_outer_param_lifts`
(positive — asserts two synthetic fns appended in
inner-first/outer-second order, and that the inner
lifted fn has params `[j, i]`).
**What stays rejected.** Inner LetRec capturing the OUTER
LetRec's NAME (`EnclosingLetRec` in the desugar marker;
`enclosing_letrec_names` set in the lifter). The proper fix
is closure conversion of a LetRec inside its own body — the
same shape as 16b.5-body, queued there. Both desugar and
lift now panic with the same clarified message.
**Tests: 120 → 122 (+2).**
- e2e: 44 → 45 (`nested_let_rec_demo`).
- `ailang-core::desugar::tests`: 23 → 25 (+2: one negative
panic test + one positive lift test).
- All other crates unchanged.
**Cross-iter regression check.** All eight prior LetRec
fixtures (`local_rec_demo`, `lit_pat`,
`local_rec_capture`, `local_rec_let_capture`,
`local_rec_match_capture`, `local_rec_as_value`,
`local_rec_as_value_capture`, `poly_rec_capture`) build,
run, and produce identical stdout. Their on-disk hashes
(verified via `ail manifest` on `local_rec_capture`) are
bit-identical: the desugar/lift edits change runtime
behaviour for previously-rejected cases only and never
touch canonical-bytes input.
**Cumulative state, post-16b.7.**
- Stdlib unchanged (5 modules, 29 combinators).
- `Term` enum: 11 variants (unchanged). All pre-16b.7
fixture hashes bit-identical.
- Compiler stages: load → desugar → typecheck →
lift_letrecs → codegen (unchanged).
- The four `unreachable!("Term::LetRec eliminated by
desugar")` arms in `ailang-codegen` remain correct.
- Compiler bugs surfaced and fixed in dogfood since 14a:
5/5 (unchanged).
**End-of-16b series note.** With 16b.7, the LetRec-capture
work is feature-complete except for the two deferred
closure-of-self sub-cases:
- **`closure-of-self` (informally 16b.5-body)**: name-as-
value of a LetRec INSIDE its own body, OR an inner
LetRec capturing an outer LetRec's NAME. Both reduce to
the same problem: the LetRec's value-form does not exist
before its own lift completes, so any non-callee use
inside its body needs either eta-conversion-of-self
(constructing the value form before the lift, with the
unlifted name in scope) or a true closure conversion
that doesn't lift the LetRec to a top-level fn at all.
- **`closure-poly` (informally 16b.5b)**: name-as-value of
a LetRec inside a polymorphic enclosing fn. Needs a
closure pair generic over the enclosing fn's type vars
— a meaningful ABI extension. Independent of
`closure-of-self` but closely related.
Every supported case is exercised by a fixture +
e2e + (where applicable) a desugar/lift unit test. The
16b.x sweep can be considered closed pending those two
deferrals; both are tracked separately in the queue and
neither blocks day-to-day authoring of recursive helpers.
**Queue update post-16b.7.** 16b.7 done. Open:
**`closure-of-self`** (informally 16b.5-body — name-as-
value of a LetRec inside its own body, including nested-
LetRec inner-captures-outer-name; needs eta-conversion-of-
self or true closure conversion). **`closure-poly`**
(informally 16b.5b — name-as-value of a LetRec inside a
polymorphic enclosing fn; needs polymorphic closure
pairs). 16d (chain-machinery exhaustiveness or
`__unreachable__`), 16e (`==` extension to
Bool/Str/Unit), 17a (per-fn arena, gated) unchanged.