Files
AILang/design/contracts/0012-tail-calls.md
T
Brummel 56e076b2ca docs(contracts): make 0012 tail-context list code-exhaustive
The §Typecheck enumeration in 0012-tail-calls.md presented the
tail-position rule as exhaustive ("The flag is `false` for: ...") but
omitted `Term::If` entirely — even though `verify_tail_positions`
propagates `is_tail` into both `If` branches (ailang-check/src/lib.rs),
and 0012's own motivating example (`if n == 0 then () else loop(n-1)`)
relies on the `else` branch being a tail position.

Restate the rule by principle (a sub-term is in tail position iff it is
the value of the whole expression) and enumerate the actual checker's
arms: `If` branches, `Match` arms, `Seq` right, `Let`/`LetRec`
in-clause, `Lam` body, and the identity/`Loop` propagators; every
argument position (incl. `If` condition, `Recur`/`New` args) is
non-tail. Matches the present `verify_tail_positions` walker.
2026-06-02 11:16:49 +02:00

3.2 KiB

Tail calls

Explicit, verified tail calls

For an LLM author, recursion is the natural iteration form (\n. if n == 0 then () else loop(n-1) is what I reach for, not a for-loop). Without a tail-call guarantee, every recursive program has a silent stack-depth ceiling that no compile-time diagnostic warns about. That is exactly the class of correctness hazard the language exists to eliminate.

Solution: explicit, verified tail calls.

  • AST. Term::App { fn, args, tail: bool } and Term::Do { op, args, tail: bool } (see Data model) gain a tail flag, serde-defaulting to false so existing fixtures load with tail: false and their hashes stay bit-identical.
  • Typecheck. A new pass verify_tail_positions(fn_body) runs after the main type-check. It walks the body with an is_tail_context: bool threaded down. The flag is true at the start and propagates to every sub-term that is the value of the whole expression: the body of every Term::Match arm, both branches of Term::If, the right operand of Term::Seq, the body of Term::Let and the in-clause of Term::LetRec, and the body of Term::Lam (each Lam opens its own tail scope); the identity nodes Term::Clone / Term::ReuseAs and a Term::Loop body propagate the enclosing flag unchanged. The flag is false for every argument position: args of any App/Do/Ctor/Recur/New, the scrutinee of Match, the condition of If, the left operand of Seq, and the value of a Let/LetRec binding. When the walker visits an App { tail: true } or Do { tail: true }, the flag must be true at that visit; otherwise emit diagnostic tail-call-not-in-tail-position.
  • Codegen. Emit musttail call (LLVM IR) for marked calls instead of plain call. LLVM rejects at IR-verification time if the call cannot physically be a tail call (calling convention mismatch, signature divergence, etc.). The reject surfaces as a hard build error, not a silent runtime surprise.
  • Form (A). Two new keywords: tail-app, tail-do. Productions are positional analogues of app/do with tail: true set on the resulting term. EBNF gains 2 lines; total production count goes from ~28 to ~30, still inside the constraint-1 budget (see authoring surface).

What this does NOT promise. Per the tail-call survey of existing fixtures, many existing recursive calls are not in tail position because they are arguments to constructor calls (e.g. Cons (f h) (map f t)). The current pipeline adds annotation + verification; it does not add a CPS transform or accumulator-form rewrite. Programs whose recursion is constructor-blocked will continue to be stack-bounded by recursion depth. The canonical authoring pattern in such cases is to write the accumulator-form variant (map_acc, fold_left, etc.) explicitly. The stdlib ships both forms where relevant.

Migration of existing fixtures is partial: only print_list-style terminal recursions get marked. The constructor-blocked recursions in map, sort, insert remain unmarked — they cannot benefit from musttail without a source-level rewrite.

Ratified by: crates/ailang-check/src/lib.rs.