Iter 14e: explicit, verified tail calls

Decision 8 ships. Term::App and Term::Do gain tail: bool with
serde-default false and skip-when-false serialisation. New
typecheck pass verify_tail_positions enforces tail-position rules
(Scheme-style propagation through match arms, seq.rhs, let body,
lam body). Codegen emits musttail call for marked App calls.

Hash invariance verified: only the two migrated print_list defs
(list_map_poly.print_list, sort.print_list) changed hashes; all
other defs across all 18 fixtures kept bit-identical hashes —
confirms the skip-when-false serialisation rule works.

Tests 76 -> 79: tail_call_in_non_tail_position_is_rejected,
tail_call_in_tail_position_is_accepted, plus an IR-grep e2e test
asserting that print_list's recursive call site emits musttail
in the lowered IR. Existing 25 e2e tests unchanged in behaviour
(map -> [2,3,4], sort -> sorted list).

IR evidence at the recursive site:
  %v7 = musttail call i8 @ail_list_map_poly_print_list(ptr %v6)
  ret i8 %v7

Two deviations called out in the implementer report and JOURNAL:
1. tail-do uses tail call, not musttail. Cross-type return
   (runtime helpers return i32, AILang Unit is i8) would have
   LLVM reject musttail. Path is implemented but not exercised
   by any current fixture; proper fix is runtime-helper signature
   change, punted.
2. block_terminated flag in codegen so tail-call emit
   (musttail call + ret) doesn't get a duplicate trailing ret
   from surrounding code (match-arm phi, fn-body, lambda thunk).
   Internal plumbing; required for IR well-formedness.

Form (A) productions now at ~30, exactly the constraint-1
budget. Future surface additions need to retire something or
explicit-budget-rebalance in DESIGN.md.

GC notes from implementer survey land in JOURNAL:
- Allocations cluster in lower_ctor; every term-ctor does
  malloc(8+8n).
- Tail recursion does not reduce alloc pressure, only stack.
  For map-style ctor-blocked recursions, allocation IS the
  bottleneck.
- Per-fn arena is sound only when fn return type contains no
  boxed ADT. Most current fixtures violate this.

Plan 14f: Boehm conservative GC (GC_malloc, -lgc) as a first
cut. Single-iter integration, no AST/schema change. Stress
test: build a 100k Cons list, observe RSS doesn't blow up.

After 14f the language is feature-complete enough for stdlib
work (15a).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 17:16:28 +02:00
parent 8d97a924de
commit d64031c234
16 changed files with 768 additions and 81 deletions
+50 -8
View File
@@ -34,18 +34,20 @@
//! effects-clause::= "(" "effects" ident+ ")"
//!
//! term ::= var-ref | int-lit | str-lit | bool-lit | unit-lit
//! | app-term | match-term | ctor-term | do-term | seq-term
//! | lam-term | let-term
//! | app-term | tail-app-term | match-term | ctor-term
//! | do-term | tail-do-term | seq-term | lam-term | let-term
//! var-ref ::= ident ; reserved: true/false → bool-lit
//! int-lit ::= integer ; numeric atom
//! str-lit ::= string ; string atom
//! bool-lit ::= "true" | "false"
//! unit-lit ::= "(" "lit-unit" ")"
//! app-term ::= "(" "app" term term+ ")"
//! tail-app-term ::= "(" "tail-app" term term+ ")" ; Iter 14e
//! ctor-term ::= "(" "term-ctor" ident ident term* ")"
//! match-term ::= "(" "match" term case-arm+ ")"
//! case-arm ::= "(" "case" pattern term ")"
//! do-term ::= "(" "do" ident term* ")"
//! tail-do-term ::= "(" "tail-do" ident term* ")" ; Iter 14e
//! seq-term ::= "(" "seq" term term ")"
//! lam-term ::= "(" "lam" "(" "params" typed-param* ")"
//! "(" "ret" type ")"
@@ -677,9 +679,11 @@ impl<'a> Parser<'a> {
match head {
"lit-unit" => self.parse_lit_unit(),
"app" => self.parse_app(),
"tail-app" => self.parse_tail_app(),
"term-ctor" => self.parse_term_ctor(),
"match" => self.parse_match(),
"do" => self.parse_do(),
"tail-do" => self.parse_tail_do(),
"seq" => self.parse_seq(),
"lam" => self.parse_lam(),
"let" => self.parse_let(),
@@ -689,8 +693,8 @@ impl<'a> Parser<'a> {
production: "term",
message: format!(
"unknown term head `{other}`; expected one of \
`app`, `lam`, `let`, `match`, `do`, `seq`, \
`term-ctor`, `lit-unit`"
`app`, `tail-app`, `lam`, `let`, `match`, `do`, \
`tail-do`, `seq`, `term-ctor`, `lit-unit`"
),
pos,
})
@@ -736,12 +740,32 @@ impl<'a> Parser<'a> {
fn parse_app(&mut self) -> Result<Term, ParseError> {
self.expect_lparen("app-term")?;
self.expect_keyword("app")?;
self.parse_app_body(false, "app-term")
}
/// Iter 14e: `(tail-app callee arg+)` — same shape as `app` but
/// constructs `Term::App { tail: true, .. }`. The typechecker's
/// tail-position pass verifies that the call really is in tail
/// position; an unmarked call in tail position is also legal.
fn parse_tail_app(&mut self) -> Result<Term, ParseError> {
self.expect_lparen("tail-app-term")?;
self.expect_keyword("tail-app")?;
self.parse_app_body(true, "tail-app-term")
}
/// Body shared by [`Self::parse_app`] and [`Self::parse_tail_app`]:
/// callee + 1+ args + closing `)`.
fn parse_app_body(
&mut self,
tail: bool,
production: &'static str,
) -> Result<Term, ParseError> {
let callee = self.parse_term()?;
// 1+ args
if matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
return Err(ParseError::Production {
production: "app-term",
production,
message: "expected at least one argument".into(),
pos,
});
@@ -750,10 +774,11 @@ impl<'a> Parser<'a> {
while !matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
args.push(self.parse_term()?);
}
self.expect_rparen("app-term")?;
self.expect_rparen(production)?;
Ok(Term::App {
callee: Box::new(callee),
args,
tail,
})
}
@@ -809,13 +834,30 @@ impl<'a> Parser<'a> {
fn parse_do(&mut self) -> Result<Term, ParseError> {
self.expect_lparen("do-term")?;
self.expect_keyword("do")?;
self.parse_do_body(false, "do-term")
}
/// Iter 14e: `(tail-do op arg*)` — same shape as `do` but
/// constructs `Term::Do { tail: true, .. }`.
fn parse_tail_do(&mut self) -> Result<Term, ParseError> {
self.expect_lparen("tail-do-term")?;
self.expect_keyword("tail-do")?;
self.parse_do_body(true, "tail-do-term")
}
/// Body shared by [`Self::parse_do`] and [`Self::parse_tail_do`].
fn parse_do_body(
&mut self,
tail: bool,
production: &'static str,
) -> Result<Term, ParseError> {
let op = self.expect_ident("effect op (e.g. `io/print_int`)")?;
let mut args = Vec::new();
while !matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
args.push(self.parse_term()?);
}
self.expect_rparen("do-term")?;
Ok(Term::Do { op, args })
self.expect_rparen(production)?;
Ok(Term::Do { op, args, tail })
}
fn parse_seq(&mut self) -> Result<Term, ParseError> {