Iter 16b.6: LetRec inside polymorphic enclosing fn
Lifts the monomorphic-only restriction. Lifted top-level fn now
becomes Forall(α..., Fn(t..., T...) -> tr) when the enclosing fn
is polymorphic; capture types may mention outer type vars and
the existing mono pipeline (Iter 12b/14a) specializes them
correctly at call sites.
- desugar.rs / lift.rs: scope-building unified — Forall fn-params
are now KnownType, not LetBound. Lift wraps augmented_ty in
Forall mirroring the enclosing fn.
- Name-as-value-in-in-term in a polymorphic enclosing fn is
rejected (eta-Lam wrap from 16b.5 has no Forall). Queued
separately as closure-conversion-with-polymorphism.
- examples/poly_rec_capture.{ailx,ail.json}: apply_n_times :
Forall(a). drives at Int and Bool to exercise two mono
instantiations.
- e2e + check + desugar tests: 116 → 120 (+4).
Codegen pipeline untouched — apply_subst_to_type substitutes
through Fn.params uniformly (original params + appended captures).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+179
@@ -4163,3 +4163,182 @@ inside the body before the lift completes is order-sensitive) or
|
||||
a true closure conversion that doesn't lift to a top-level fn at
|
||||
all. **16b.6** (Forall-typed LetRec), **16b.7** (nested mutual
|
||||
LetRec capture), 16d, 16e, 17a unchanged.
|
||||
|
||||
## Iter 16b.6 — LetRec inside polymorphic enclosing fn
|
||||
|
||||
**Goal.** Lift the "monomorphic enclosing fn only" restriction
|
||||
that 16b.2 introduced. A `(let-rec ...)` inside a fn whose
|
||||
declared type is `Forall(α1..αn, Fn(...))` is now supported,
|
||||
provided (a) the LetRec name is callee-only (any non-callee use
|
||||
in `body` was already rejected by 16b.5; non-callee use in
|
||||
`in_term` is rejected for the polymorphic case here — see
|
||||
"closure-poly" below), and (b) other 16b.x restrictions still
|
||||
apply (no nested LetRec mutual capture → 16b.7).
|
||||
|
||||
**Architectural choice.** The lifted top-level fn is built as
|
||||
`Forall(α1..αn, Fn(t1..tk, T1..Tm) → tr)` where `α1..αn` are
|
||||
exactly the enclosing fn's type vars. Capture types `T1..Tm`
|
||||
may mention any of those vars; the outer `Forall` binds them
|
||||
back. Inside the enclosing fn's body every call site of the
|
||||
lifted fn is monomorphic relative to the enclosing fn's current
|
||||
instantiation, so codegen's existing Iter-12b/14a
|
||||
monomorphisation machinery picks up `f$lr_N` from the
|
||||
`mono_queue`, substitutes type args into both the original
|
||||
params and the appended capture types (a single
|
||||
`apply_subst_to_type` traversal handles both — captures live
|
||||
in the same `Type::Fn.params` list as originals), and emits
|
||||
specialised `f$lr_N__I`, `f$lr_N__B`, … per instantiation.
|
||||
**No codegen changes were needed**: the lifted fn becomes an
|
||||
ordinary `Forall` top-level fn and flows through the same
|
||||
pipeline as a user-written polymorphic def.
|
||||
|
||||
**Scope-building change.** 16b.2 entered fn-params of a
|
||||
`Forall`-typed enclosing fn as `ScopeEntry::LetBound` —
|
||||
defensive, because the param types could mention outer type
|
||||
vars and the lift had no way to bind them. With the
|
||||
`Forall(vars, Fn(t1..tk, T1..Tm) → tr)` synthesis above, that
|
||||
bind site now exists. Fn-params of a polymorphic enclosing fn
|
||||
therefore enter the scope as `ScopeEntry::KnownType(t)`
|
||||
(matching the monomorphic case). The `LetBound` fallback is
|
||||
reserved for `Term::Let`-bound names and for malformed fn
|
||||
types (arity mismatch, non-Fn). Both `Desugarer` and `Lifter`
|
||||
gained a `current_def_forall_vars: Vec<String>` field that's
|
||||
populated on entry to each `Def::Fn` and read by the LetRec
|
||||
arm to decide whether to wrap the augmented type in a
|
||||
`Forall`.
|
||||
|
||||
**Why name-as-value-in-in-term is excluded for the polymorphic
|
||||
case.** The 16b.5 wrap synthesises a `Term::Lam` whose body
|
||||
calls the lifted fn positionally. `Term::Lam` has no
|
||||
`Forall`-quantification slot — wrapping a polymorphic lifted
|
||||
fn into a monomorphic Lam would lose the type vars. The
|
||||
proper fix is closure conversion with polymorphism (a closure
|
||||
pair generic over `α1..αn`); that's a separate, harder iter.
|
||||
Both desugar and lift now panic with a clear message
|
||||
referencing queue tag `closure-poly` (informally `16b.5b`)
|
||||
when this combination is detected. Name-as-value-in-in-term
|
||||
in a MONOMORPHIC enclosing fn continues to work via the
|
||||
16b.5 eta-Lam wrap.
|
||||
|
||||
**What shipped.**
|
||||
|
||||
- `crates/ailang-core/src/desugar.rs`
|
||||
(1931 → 1939 LOC + tests; +50/+135 net of test additions).
|
||||
- New `Desugarer.current_def_forall_vars` field. Populated
|
||||
on entry to each `Def::Fn` (cloned from `f.ty`'s
|
||||
`Forall.vars`, or empty for monomorphic), restored on
|
||||
exit.
|
||||
- `desugar_module`'s per-def loop unified: instead of
|
||||
branching `Type::Fn` vs `Type::Forall`, it peels the
|
||||
inner `Type::Fn` once and treats both shapes uniformly
|
||||
for fn-param scope-building. The defensive `LetBound`
|
||||
fallback now fires only on a malformed fn type
|
||||
(arity mismatch or non-Fn).
|
||||
- `Term::LetRec` arm: when
|
||||
`!current_def_forall_vars.is_empty() && in_has_value_use`,
|
||||
panic with the new `Iter 16b.6` / `closure-poly` message.
|
||||
When the fast-path lift fires, the `augmented_ty` is
|
||||
`Type::Forall { vars: current_def_forall_vars.clone(),
|
||||
body: Box::new(Type::Fn { ... }) }` if `vars` is non-
|
||||
empty, else the bare `Type::Fn` as before.
|
||||
- `crates/ailang-check/src/lift.rs`
|
||||
(757 → 791 LOC; +34). Symmetric changes to `Lifter`:
|
||||
new `current_def_forall_vars` field, populated alongside
|
||||
the existing `rigid_vars` install in the per-def loop;
|
||||
same Forall-wrap on `augmented_ty`; same `closure-poly`
|
||||
panic for the polymorphic + name-as-value-in-in-term
|
||||
combination. The lift's `synth_type` and
|
||||
`type_check_pattern_for_lift` were unchanged — capture
|
||||
types containing type vars flow through verbatim because
|
||||
the typechecker installs the same rigid vars on entry.
|
||||
- `examples/poly_rec_capture.ailx` + `.ail.json` (49 LOC
|
||||
source). `apply_n_times : Forall(a). Fn(Int, a, Fn(a) -> a) -> a`
|
||||
applies `f` to `x` exactly `n` times. The recursive helper
|
||||
`loop` captures `f` from the enclosing fn's params. `f`'s
|
||||
type is `Fn(a) -> a`, mentioning `a`. The lift produces
|
||||
`loop$lr_0 : Forall(a). Fn(Int, a, Fn(a) -> a) -> a`.
|
||||
`main` drives `apply_n_times` at TWO instantiations
|
||||
(`a = Int` with `succ` to compute `succ` applied 5x to 0
|
||||
→ 5; `a = Bool` with `flip` (a top-level wrapper around
|
||||
`not`, since `not` is a builtin without a value-position
|
||||
adapter) to compute `flip` applied 4x to false → false).
|
||||
Codegen specialises `loop$lr_0` twice, emitting
|
||||
`loop$lr_0__I` and `loop$lr_0__B` — confirmed in the
|
||||
generated IR. The `flip` indirection is incidental (it
|
||||
works around an unrelated builtin-as-value gap, not a
|
||||
16b.6 limitation).
|
||||
- `crates/ail/tests/e2e.rs::poly_rec_capture_demo` (+25):
|
||||
e2e count 43 → 44.
|
||||
- `crates/ailang-core/src/desugar.rs::tests` (+2):
|
||||
`let_rec_capture_under_polymorphic_enclosing_fn_lifts_to_forall_fn`
|
||||
(positive — asserts the lifted FnDef's type is
|
||||
`Forall(a, Fn(Int, a) -> a)` with the `a`-typed capture
|
||||
appended), and
|
||||
`let_rec_name_as_value_in_polymorphic_enclosing_fn_panics`
|
||||
(`#[should_panic(expected = "16b.6")]`).
|
||||
- `crates/ailang-check/src/lib.rs::tests` (+1):
|
||||
`lift_letrecs_under_polymorphic_enclosing_fn_produces_forall_fn`
|
||||
exercises the post-typecheck lift path (a `Term::Let`-
|
||||
bound capture forces deferral to `lift_letrecs`); asserts
|
||||
the lifted ty is `Forall(a, Fn(Int, a, Int) -> a)`.
|
||||
|
||||
**Codegen integration: did anything need to change?** No.
|
||||
Verified end-to-end: the lifted `Forall` fn is registered in
|
||||
`module_polymorphic_fns` by `lower_workspace`'s pass-1 (no
|
||||
distinction between user-written and synthetic Forall fns —
|
||||
both are `FnDef`s with `Type::Forall` types). At each call
|
||||
site inside the enclosing fn, `lower_polymorphic_call`
|
||||
derives the substitution from the actual arg types,
|
||||
specialises through `apply_subst_to_type` (which substitutes
|
||||
through `Type::Fn.params` uniformly — original params and
|
||||
appended captures share the same list), and queues a
|
||||
specialisation. `emit_specialised_fn` consumes the queue,
|
||||
substitutes type vars in both the body and the type, and
|
||||
emits the LLVM fn. `poly_rec_capture` produces
|
||||
`loop$lr_0__I` and `loop$lr_0__B` in the IR, both correctly
|
||||
typed (capture `f: ptr` carries through unchanged because
|
||||
`Fn(_) -> _` is `ptr` at the LLVM level).
|
||||
|
||||
**Cross-iter regression check.** All seven prior 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`) build,
|
||||
run, and produce identical stdout. Their on-disk hashes
|
||||
(`ail manifest`) are bit-identical: hashes flow from
|
||||
canonical JSON, untouched by 16b.6's desugar/lift changes.
|
||||
|
||||
**Tests: 116 → 120 (+4).**
|
||||
- e2e: 43 → 44 (`poly_rec_capture_demo`).
|
||||
- `ailang-check::tests`: 28 → 29
|
||||
(`lift_letrecs_under_polymorphic_enclosing_fn_produces_forall_fn`).
|
||||
- `ailang-core::desugar::tests`: 21 → 23 (positive lift +
|
||||
negative panic). Net +2.
|
||||
|
||||
**Cumulative state, post-16b.6.**
|
||||
|
||||
- Stdlib unchanged (5 modules, 29 combinators).
|
||||
- `Term` enum: 11 variants (unchanged). All pre-16b.6
|
||||
fixture hashes bit-identical.
|
||||
- Compiler stages: load → desugar → typecheck →
|
||||
lift_letrecs → codegen (unchanged pipeline; only the
|
||||
per-stage logic at the LetRec arm changed).
|
||||
- The four `unreachable!("Term::LetRec eliminated by
|
||||
desugar")` arms in `ailang-codegen` remain correct: every
|
||||
surviving LetRec is still gone before codegen runs (the
|
||||
poly fast-path lifts here in desugar; the deferred path
|
||||
is handled by `lift_letrecs`). The panic message in
|
||||
codegen could be updated post-16b.3 to "by desugar OR
|
||||
lift_letrecs"; left as-is for now (the invariant holds
|
||||
either way).
|
||||
- Compiler bugs surfaced and fixed in dogfood since 14a:
|
||||
5/5 (unchanged).
|
||||
|
||||
**Queue update post-16b.6.** 16b.6 done. Open:
|
||||
**`closure-poly`** (informally 16b.5b — name-as-value of a
|
||||
LetRec inside a polymorphic enclosing fn; needs closure
|
||||
pair generic over the enclosing fn's type vars).
|
||||
**16b.5-body** (name-as-value INSIDE the LetRec's own body).
|
||||
**16b.7** (nested LetRec mutual capture). 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