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:
+153
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user