From d64031c23439fa1705f0fa9d9d4126eea72bb0ab Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 7 May 2026 17:16:28 +0200 Subject: [PATCH] Iter 14e: explicit, verified tail calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/ail/src/main.rs | 4 +- crates/ail/tests/e2e.rs | 56 ++++++ crates/ailang-check/src/diagnostic.rs | 1 + crates/ailang-check/src/lib.rs | 224 +++++++++++++++++++++- crates/ailang-check/tests/workspace.rs | 1 + crates/ailang-codegen/src/lib.rs | 252 +++++++++++++++++++------ crates/ailang-core/src/ast.rs | 22 +++ crates/ailang-core/src/hash.rs | 1 + crates/ailang-core/src/pretty.rs | 9 +- crates/ailang-surface/src/parse.rs | 58 +++++- crates/ailang-surface/src/print.rs | 13 +- docs/DESIGN.md | 72 +++++++ docs/JOURNAL.md | 128 +++++++++++++ examples/list_map_poly.ail.json | 3 +- examples/list_map_poly.ailx | 2 +- examples/sort.ail.json | 3 +- 16 files changed, 768 insertions(+), 81 deletions(-) diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index d7566f0..59342ef 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -863,7 +863,7 @@ fn walk_term( } out.insert(name.clone()); } - Term::App { callee, args } => { + Term::App { callee, args, .. } => { walk_term(callee, out, builtins, scope); for a in args { walk_term(a, out, builtins, scope); @@ -878,7 +878,7 @@ fn walk_term( scope.remove(name); } } - Term::Do { op, args } => { + Term::Do { op, args, .. } => { // Mark effect ops as `effect:io/print_int` so they can be // separated from normal function calls. out.insert(format!("effect:{op}")); diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index 8dcc49e..a46e91e 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -107,6 +107,62 @@ fn list_map_poly_inc_then_prints() { assert_eq!(lines, vec!["2", "3", "4"]); } +/// Iter 14e: `tail: true` annotation on `print_list`'s recursive +/// call must reach LLVM as a `musttail call`. Asserted by emitting IR +/// for list_map_poly and grepping for the exact instruction. This is +/// the only direct evidence that the type-system marker actually +/// influences codegen — the e2e test above only checks observed +/// stdout, which `musttail` does not change. +#[test] +fn iter14e_print_list_recursion_emits_musttail() { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + let workspace = Path::new(manifest_dir).parent().unwrap().parent().unwrap(); + let src = workspace.join("examples").join("list_map_poly.ail.json"); + let tmp = std::env::temp_dir().join(format!( + "ailang_iter14e_musttail_{}", + std::process::id() + )); + std::fs::create_dir_all(&tmp).unwrap(); + let out_ll = tmp.join("list_map_poly.ll"); + let status = Command::new(ail_bin()) + .args(["emit-ir", src.to_str().unwrap(), "-o"]) + .arg(&out_ll) + .status() + .expect("ail emit-ir failed to run"); + assert!(status.success(), "ail emit-ir failed"); + let ir = std::fs::read_to_string(&out_ll).expect("read emitted IR"); + + // Find the musttail call to print_list inside the print_list body. + // The exact line shape: + // %vN = musttail call i8 @ail_list_map_poly_print_list(ptr %vM) + let has_musttail = ir.lines().any(|l| { + l.contains("musttail call") + && l.contains("@ail_list_map_poly_print_list(") + }); + assert!( + has_musttail, + "expected `musttail call ... @ail_list_map_poly_print_list(...)` \ + in emitted IR; not found.\nIR:\n{ir}" + ); + // Defence-in-depth: the musttail call must be immediately followed + // by a `ret i8 %v...`. We assert the next non-empty line is a ret. + let mut lines = ir.lines(); + while let Some(line) = lines.next() { + if line.contains("musttail call") + && line.contains("@ail_list_map_poly_print_list(") + { + let next = lines.next().unwrap_or("").trim(); + assert!( + next.starts_with("ret i8 "), + "musttail call must be immediately followed by `ret i8 ...`; \ + got: `{next}`" + ); + return; + } + } + panic!("musttail call line not found (sanity check)"); +} + /// Iter 11 dogfood: insertion sort over an 11-element IntList. /// Exercises `<=`, `if`, mutual-leaf recursion (`insert` and `sort`), /// nested ctor construction, and the Iter 10 seq operator inside diff --git a/crates/ailang-check/src/diagnostic.rs b/crates/ailang-check/src/diagnostic.rs index d4ea201..bc0a2f2 100644 --- a/crates/ailang-check/src/diagnostic.rs +++ b/crates/ailang-check/src/diagnostic.rs @@ -39,6 +39,7 @@ //! - `module-cycle` — workspace loader (Iter 5b, in the CLI path) //! - `module-name-mismatch` — workspace loader (Iter 5b, in the CLI path) //! - `module-hash-mismatch` — workspace loader (Iter 5b, in the CLI path) +//! - `tail-call-not-in-tail-position` (Iter 14e, see Decision 8) use serde::Serialize; diff --git a/crates/ailang-check/src/lib.rs b/crates/ailang-check/src/lib.rs index 2576a45..1a17a95 100644 --- a/crates/ailang-check/src/lib.rs +++ b/crates/ailang-check/src/lib.rs @@ -395,6 +395,12 @@ pub enum CheckError { /// for qualified cross-module references. Code: `invalid-def-name`. #[error("invalid def name `{name}`: contains `.` (reserved for qualified refs)")] InvalidDefName { name: String }, + + /// Iter 14e: a `Term::App { tail: true, .. }` or + /// `Term::Do { tail: true, .. }` was found in a non-tail position. + /// Code: `tail-call-not-in-tail-position`. See Decision 8. + #[error("call marked `tail` is not in tail position")] + TailCallNotInTailPosition, } type Result = std::result::Result; @@ -430,6 +436,7 @@ impl CheckError { CheckError::UnknownModule { .. } => "unknown-module", CheckError::UnknownImport { .. } => "unknown-import", CheckError::InvalidDefName { .. } => "invalid-def-name", + CheckError::TailCallNotInTailPosition => "tail-call-not-in-tail-position", } } @@ -894,6 +901,11 @@ fn check_fn(f: &FnDef, env: &Env) -> Result<()> { let body_ty = synth(&f.body, &env, &mut locals, &mut effects, &f.name, &mut subst, &mut counter)?; unify(&ret_ty, &body_ty, &mut subst)?; + // Iter 14e: tail-position verification (Decision 8). Runs after + // the main type-check so that a tail-call marker on a malformed + // call doesn't drown out the underlying type error. + verify_tail_positions(&f.body, true)?; + let declared: BTreeSet = declared_effs.into_iter().collect(); for e in &effects { if !declared.contains(e) { @@ -903,6 +915,77 @@ fn check_fn(f: &FnDef, env: &Env) -> Result<()> { Ok(()) } +/// Iter 14e: verifies that every `Term::App { tail: true, .. }` and +/// `Term::Do { tail: true, .. }` actually sits in tail position, per +/// Decision 8. +/// +/// `is_tail` is the tail-context flag for the term currently being +/// visited. The propagation rules (from DESIGN.md Decision 8): +/// +/// - The body of a fn / Lam is visited with `is_tail = true`. +/// - `Match { scrutinee, arms }`: the scrutinee is **not** in tail +/// position; each arm body inherits the same `is_tail` as the match. +/// - `Seq { lhs, rhs }`: lhs is non-tail; rhs inherits. +/// - `Let { value, body, .. }`: value is non-tail; body inherits. +/// - `App { callee, args, tail }`: the callee and all args are +/// non-tail. If `tail == true`, the App itself must have arrived +/// with `is_tail == true`; otherwise diagnostic. +/// - `Do { args, tail }`: same rule as App; all args are non-tail. +/// - `Ctor { args }`: all args are non-tail. +/// - `Lam { body }`: the Lam value is at whatever `is_tail` was; the +/// recursion **into** the body opens a fresh tail scope (the body +/// is visited with `is_tail = true`). +/// - `Lit`, `Var`: leaves; no further descent. +pub fn verify_tail_positions(t: &Term, is_tail: bool) -> Result<()> { + match t { + Term::Lit { .. } | Term::Var { .. } => Ok(()), + Term::App { callee, args, tail } => { + if *tail && !is_tail { + return Err(CheckError::TailCallNotInTailPosition); + } + verify_tail_positions(callee, false)?; + for a in args { + verify_tail_positions(a, false)?; + } + Ok(()) + } + Term::Do { args, tail, .. } => { + if *tail && !is_tail { + return Err(CheckError::TailCallNotInTailPosition); + } + for a in args { + verify_tail_positions(a, false)?; + } + Ok(()) + } + Term::Let { value, body, .. } => { + verify_tail_positions(value, false)?; + verify_tail_positions(body, is_tail) + } + Term::Seq { lhs, rhs } => { + verify_tail_positions(lhs, false)?; + verify_tail_positions(rhs, is_tail) + } + Term::Match { scrutinee, arms } => { + verify_tail_positions(scrutinee, false)?; + for arm in arms { + verify_tail_positions(&arm.body, is_tail)?; + } + Ok(()) + } + Term::Ctor { args, .. } => { + for a in args { + verify_tail_positions(a, false)?; + } + Ok(()) + } + Term::Lam { body, .. } => { + // Entering a Lam body opens a fresh tail scope. + verify_tail_positions(body, true) + } + } +} + fn check_const(c: &ConstDef, env: &Env) -> Result<()> { // Const types are never polymorphic — a Forall here is rejected // outright. Any other type passes through to `synth` as before. @@ -976,7 +1059,7 @@ fn synth( }; Ok(maybe_instantiate(raw, counter)) } - Term::App { callee, args } => { + Term::App { callee, args, .. } => { let cty = synth(callee, env, locals, effects, in_def, subst, counter)?; let cty = subst.apply(&cty); let (params, ret, fx) = match &cty { @@ -1035,7 +1118,7 @@ fn synth( } Ok(r) } - Term::Do { op, args } => { + Term::Do { op, args, .. } => { let sig = env .effect_ops .get(op) @@ -1425,6 +1508,7 @@ mod tests { Term::Var { name: "a".into() }, Term::Var { name: "b".into() }, ], + tail: false, }, )], }; @@ -1474,6 +1558,7 @@ mod tests { args: vec![Term::Lit { lit: Literal::Int { value: 1 }, }], + tail: false, }, )], }; @@ -1711,6 +1796,7 @@ mod tests { Term::App { callee: Box::new(Term::Var { name: "id".into() }), args: vec![Term::Lit { lit: Literal::Int { value: 42 } }], + tail: false, }, ); // `use_bool` returns id(true) :: Bool. @@ -1725,6 +1811,7 @@ mod tests { Term::App { callee: Box::new(Term::Var { name: "id".into() }), args: vec![Term::Lit { lit: Literal::Bool { value: true } }], + tail: false, }, ); let m = Module { @@ -1766,6 +1853,7 @@ mod tests { Term::App { callee: Box::new(Term::Var { name: "id".into() }), args: vec![Term::Lit { lit: Literal::Int { value: 42 } }], + tail: false, }, ); let m = Module { @@ -1805,6 +1893,7 @@ mod tests { Term::App { callee: Box::new(Term::Var { name: "f".into() }), args: vec![Term::Var { name: "x".into() }], + tail: false, }, ); // A monomorphic helper to be passed to apply. @@ -1822,6 +1911,7 @@ mod tests { Term::Var { name: "n".into() }, Term::Lit { lit: Literal::Int { value: 1 } }, ], + tail: false, }, ); let use_apply = fn_def( @@ -1838,6 +1928,7 @@ mod tests { Term::Var { name: "succ".into() }, Term::Lit { lit: Literal::Int { value: 41 } }, ], + tail: false, }, ); let m = Module { @@ -1945,6 +2036,7 @@ mod tests { ctor: "MkBox".into(), args: vec![Term::Lit { lit: Literal::Int { value: 7 } }], }], + tail: false, }, ); let use_bool = fn_def( @@ -1962,6 +2054,7 @@ mod tests { ctor: "MkBox".into(), args: vec![Term::Lit { lit: Literal::Bool { value: true } }], }], + tail: false, }, ); let m = Module { @@ -2041,4 +2134,131 @@ mod tests { let msg = format!("{err}"); assert!(msg.contains("type mismatch"), "got: {msg}"); } + + /// Iter 14e: a `Term::App { tail: true, .. }` in non-tail position + /// must surface as `tail-call-not-in-tail-position`. Construction: + /// the recursive call sits as an argument to a Cons ctor (the + /// classic constructor-blocked recursion from the 14d survey). + #[test] + fn tail_call_in_non_tail_position_is_rejected() { + let m = Module { + schema: SCHEMA.into(), + name: "t".into(), + imports: vec![], + defs: vec![ + Def::Type(TypeDef { + name: "L".into(), + vars: vec![], + ctors: vec![ + Ctor { name: "N".into(), fields: vec![] }, + Ctor { + name: "C".into(), + fields: vec![ + Type::int(), + Type::Con { name: "L".into(), args: vec![] }, + ], + }, + ], + doc: None, + }), + fn_def( + "loop", + Type::Fn { + params: vec![Type::Con { name: "L".into(), args: vec![] }], + ret: Box::new(Type::Con { name: "L".into(), args: vec![] }), + effects: vec![], + }, + vec!["xs"], + // Body: C(0, tail-app loop xs) — the recursion is + // an arg to C, which is NOT a tail position. + Term::Ctor { + type_name: "L".into(), + ctor: "C".into(), + args: vec![ + Term::Lit { lit: Literal::Int { value: 0 } }, + Term::App { + callee: Box::new(Term::Var { name: "loop".into() }), + args: vec![Term::Var { name: "xs".into() }], + tail: true, + }, + ], + }, + ), + ], + }; + let err = check(&m).unwrap_err(); + assert_eq!(err.code(), "tail-call-not-in-tail-position", "{err}"); + } + + /// Iter 14e: a `Term::App { tail: true, .. }` that genuinely sits + /// in tail position (as the rhs of a `Seq` that is the body of a + /// `Match` arm that is the body of the fn) must pass. + #[test] + fn tail_call_in_tail_position_is_accepted() { + let m = Module { + schema: SCHEMA.into(), + name: "t".into(), + imports: vec![], + defs: vec![ + Def::Type(TypeDef { + name: "L".into(), + vars: vec![], + ctors: vec![ + Ctor { name: "N".into(), fields: vec![] }, + Ctor { + name: "C".into(), + fields: vec![ + Type::int(), + Type::Con { name: "L".into(), args: vec![] }, + ], + }, + ], + doc: None, + }), + fn_def( + "drain", + Type::Fn { + params: vec![Type::Con { name: "L".into(), args: vec![] }], + ret: Box::new(Type::unit()), + effects: vec!["IO".into()], + }, + vec!["xs"], + Term::Match { + scrutinee: Box::new(Term::Var { name: "xs".into() }), + arms: vec![ + Arm { + pat: Pattern::Ctor { + ctor: "N".into(), + fields: vec![], + }, + body: Term::Lit { lit: Literal::Unit }, + }, + Arm { + pat: Pattern::Ctor { + ctor: "C".into(), + fields: vec![ + Pattern::Var { name: "h".into() }, + Pattern::Var { name: "t".into() }, + ], + }, + body: Term::Seq { + lhs: Box::new(Term::Do { + op: "io/print_int".into(), + args: vec![Term::Var { name: "h".into() }], + tail: false, + }), + rhs: Box::new(Term::App { + callee: Box::new(Term::Var { name: "drain".into() }), + args: vec![Term::Var { name: "t".into() }], + tail: true, + }), + }, + }, + ], + }, + ), + ], + }; + check(&m).expect("tail-call in tail position should typecheck"); + } } diff --git a/crates/ailang-check/tests/workspace.rs b/crates/ailang-check/tests/workspace.rs index 179d8d5..e22b8b5 100644 --- a/crates/ailang-check/tests/workspace.rs +++ b/crates/ailang-check/tests/workspace.rs @@ -126,6 +126,7 @@ fn body_errors_accumulate_across_defs() { Term::Lit { lit: Literal::Int { value: 2 } }, Term::Lit { lit: Literal::Int { value: 3 } }, ], + tail: false, }, doc: None, }); diff --git a/crates/ailang-codegen/src/lib.rs b/crates/ailang-codegen/src/lib.rs index ea3a85f..71c76f0 100644 --- a/crates/ailang-codegen/src/lib.rs +++ b/crates/ailang-codegen/src/lib.rs @@ -359,6 +359,12 @@ struct Emitter<'a> { /// Current basic block label. Set by `start_block` and is /// the single source of truth for `phi` operands. current_block: String, + /// Iter 14e: true while the current block already ends in a + /// terminator (currently only `ret` after a `musttail call`). + /// Callers in the term lowering walk consult this to skip + /// fall-through `br` emission and to omit the value from a + /// surrounding match-arm phi. Reset by [`Self::start_block`]. + block_terminated: bool, /// Iter 7: SSA value (or `@global`) -> its FnSig, for first-class /// function values. Populated whenever we lower a `Term::Var` to a /// top-level fn pointer or when a fn-typed parameter is bound at @@ -479,6 +485,7 @@ impl<'a> Emitter<'a> { types, ctor_index, current_block: String::new(), + block_terminated: false, ssa_fn_sigs: BTreeMap::new(), current_def: String::new(), lam_counter: 0, @@ -490,6 +497,7 @@ impl<'a> Emitter<'a> { self.body.push_str(label); self.body.push_str(":\n"); self.current_block = label.to_string(); + self.block_terminated = false; } fn emit_module(&mut self) -> Result<()> { @@ -694,14 +702,21 @@ impl<'a> Emitter<'a> { self.start_block("entry"); let (val, val_ty) = self.lower_term(&f.body)?; - if val_ty != llvm_ret { - return Err(CodegenError::Internal(format!( - "fn `{}`: body type {val_ty} != return type {llvm_ret}", - f.name - ))); + if !self.block_terminated { + if val_ty != llvm_ret { + return Err(CodegenError::Internal(format!( + "fn `{}`: body type {val_ty} != return type {llvm_ret}", + f.name + ))); + } + self.body + .push_str(&format!(" ret {val_ty} {val}\n}}\n\n")); + } else { + // Iter 14e: a `tail-app`/`tail-do` at the body root already + // emitted its own `ret` (after `musttail call`). Just close + // the function body — no fall-through ret. + self.body.push_str("}\n\n"); } - self.body - .push_str(&format!(" ret {val_ty} {val}\n}}\n\n")); // Iter 8b: flush lambda thunks collected while lowering this fn's // body. They go after the closing `}` of the parent fn, before @@ -801,7 +816,7 @@ impl<'a> Emitter<'a> { self.locals.pop(); r } - Term::App { callee, args } => { + Term::App { callee, args, tail } => { // Direct call when the callee is a `Var` referring to a // statically-known target (builtin, current-module fn, // qualified cross-module fn) AND not shadowed by a local. @@ -811,7 +826,7 @@ impl<'a> Emitter<'a> { if let Term::Var { name } = callee.as_ref() { let shadowed = self.locals.iter().any(|(n, _, _, _)| n == name); if !shadowed && self.is_static_callee(name) { - return self.lower_app(name, args); + return self.lower_app(name, args, *tail); } } let (callee_ssa, callee_ty) = self.lower_term(callee)?; @@ -829,9 +844,9 @@ impl<'a> Emitter<'a> { "indirect call: no FnSig recorded for `{callee_ssa}`" )) })?; - self.emit_indirect_call(&callee_ssa, &sig, args) + self.emit_indirect_call(&callee_ssa, &sig, args, *tail) } - Term::Do { op, args } => self.lower_effect_op(op, args), + Term::Do { op, args, tail } => self.lower_effect_op(op, args, *tail), Term::Ctor { type_name, ctor, args } => self.lower_ctor(type_name, ctor, args), Term::Match { scrutinee, arms } => self.lower_match(scrutinee, arms), Term::Lam { params, param_tys, ret_ty, effects: _, body } => { @@ -840,6 +855,12 @@ impl<'a> Emitter<'a> { Term::Seq { lhs, rhs } => { // Iter 10: lower lhs for its effects, discard the SSA; // lower rhs and return its value as the whole expression. + // Iter 14e: lhs may not legally be a `tail` call (the + // typechecker rejects that), so `block_terminated` is + // false after it. rhs is in the same tail context as the + // surrounding seq, so a `tail-app` there will set + // `block_terminated`; the outer match-arm/fn-body + // handler honours that. let _ = self.lower_term(lhs)?; self.lower_term(rhs) } @@ -1083,17 +1104,22 @@ impl<'a> Emitter<'a> { for _ in 0..pushed { self.locals.pop(); } - phi_inputs.push((val, self.current_block.clone())); - self.body - .push_str(&format!(" br label %{join_lbl}\n")); - if let Some(rt) = &result_ty { - if rt != &vty { - return Err(CodegenError::Internal(format!( - "match arm result type {vty} != {rt}" - ))); + // Iter 14e: if the arm body lowered to a `musttail call` + + // `ret`, the block is already terminated. Skip the + // fall-through `br` and exclude this arm from the join phi. + if !self.block_terminated { + phi_inputs.push((val, self.current_block.clone())); + self.body + .push_str(&format!(" br label %{join_lbl}\n")); + if let Some(rt) = &result_ty { + if rt != &vty { + return Err(CodegenError::Internal(format!( + "match arm result type {vty} != {rt}" + ))); + } + } else { + result_ty = Some(vty); } - } else { - result_ty = Some(vty); } } @@ -1112,17 +1138,19 @@ impl<'a> Emitter<'a> { for _ in 0..pushed { self.locals.pop(); } - phi_inputs.push((val, self.current_block.clone())); - self.body - .push_str(&format!(" br label %{join_lbl}\n")); - if let Some(rt) = &result_ty { - if rt != &vty { - return Err(CodegenError::Internal(format!( - "match default arm result type {vty} != {rt}" - ))); + if !self.block_terminated { + phi_inputs.push((val, self.current_block.clone())); + self.body + .push_str(&format!(" br label %{join_lbl}\n")); + if let Some(rt) = &result_ty { + if rt != &vty { + return Err(CodegenError::Internal(format!( + "match default arm result type {vty} != {rt}" + ))); + } + } else { + result_ty = Some(vty); } - } else { - result_ty = Some(vty); } } else { // Typechecker guarantees exhaustiveness, so unreachable. @@ -1130,6 +1158,18 @@ impl<'a> Emitter<'a> { } // join + // Iter 14e: if every arm tail-called and terminated its own + // block, no predecessor branches into the join. Mark the whole + // match as block-terminated and emit no join body — the + // surrounding context (top-level fn body, seq rhs, etc.) checks + // `block_terminated` before any fall-through emission. + if phi_inputs.is_empty() { + self.block_terminated = true; + // Return a dummy SSA + type that won't be consumed. Use the + // result_ty if any arm produced one, else fall back to i8. + let rt = result_ty.unwrap_or_else(|| "i8".into()); + return Ok(("0".into(), rt)); + } self.start_block(&join_lbl); let phi = self.fresh_ssa(); let rt = result_ty.unwrap_or_else(|| "i64".into()); @@ -1195,20 +1235,43 @@ impl<'a> Emitter<'a> { self.start_block(&then_lbl); let (then_v, then_ty) = self.lower_term(true_body)?; - // Nested code in the `then` body may have changed the - // block label — phi must see the last actual block. + // Iter 14e: a tail-call in this arm already terminated its + // block; skip its branch to join and exclude from phi. + let then_terminated = self.block_terminated; let then_block_end = self.current_block.clone(); - self.body.push_str(&format!(" br label %{join_lbl}\n")); + if !then_terminated { + self.body.push_str(&format!(" br label %{join_lbl}\n")); + } self.start_block(&else_lbl); let (else_v, else_ty) = self.lower_term(false_body)?; - if then_ty != else_ty { + let else_terminated = self.block_terminated; + let else_block_end = self.current_block.clone(); + if !then_terminated && !else_terminated && then_ty != else_ty { return Err(CodegenError::Internal(format!( "Bool-match arms type mismatch: {then_ty} vs {else_ty}" ))); } - let else_block_end = self.current_block.clone(); - self.body.push_str(&format!(" br label %{join_lbl}\n")); + if !else_terminated { + self.body.push_str(&format!(" br label %{join_lbl}\n")); + } + + // Iter 14e: if both arms terminated, the whole match is + // terminated and no join is reachable. Mark and bail. + if then_terminated && else_terminated { + self.block_terminated = true; + return Ok(("0".into(), then_ty)); + } + // If exactly one arm terminated, the join receives only the + // other arm's value — no phi node is needed. + if then_terminated { + self.start_block(&join_lbl); + return Ok((else_v, else_ty)); + } + if else_terminated { + self.start_block(&join_lbl); + return Ok((then_v, then_ty)); + } self.start_block(&join_lbl); let phi = self.fresh_ssa(); @@ -1235,7 +1298,7 @@ impl<'a> Emitter<'a> { Ok((phi, then_ty)) } - fn lower_app(&mut self, name: &str, args: &[Term]) -> Result<(String, String)> { + fn lower_app(&mut self, name: &str, args: &[Term], tail: bool) -> Result<(String, String)> { // Built-in arithmetic / comparison. if let Some((instr, ret_ty)) = builtin_binop(name) { if args.len() != 2 { @@ -1249,6 +1312,11 @@ impl<'a> Emitter<'a> { self.body.push_str(&format!( " {dst} = {instr} i64 {a}, {b}\n" )); + // Builtins are not function calls in LLVM (they're inline + // arithmetic); `tail` annotation has nothing to act on. + // The typechecker accepts the marker but it is a no-op + // here. (Iter 14e survey: no fixture marks a builtin tail.) + let _ = tail; return Ok((dst, ret_ty.into())); } if name == "not" { @@ -1277,7 +1345,7 @@ impl<'a> Emitter<'a> { .get(&target_module) .is_some_and(|m| m.contains_key(suffix)) { - return self.lower_polymorphic_call(&target_module, suffix, args); + return self.lower_polymorphic_call(&target_module, suffix, args, tail); } let target_fns = self .module_user_fns @@ -1295,7 +1363,7 @@ impl<'a> Emitter<'a> { "cross-module call `{name}`: def `{suffix}` not in module `{target_module}`" )) })?; - return self.emit_call(&target_module, suffix, &sig, args); + return self.emit_call(&target_module, suffix, &sig, args, tail); } // Polymorphic def in the current module? @@ -1305,7 +1373,7 @@ impl<'a> Emitter<'a> { .is_some_and(|m| m.contains_key(name)) { let owner = self.module_name.to_string(); - return self.lower_polymorphic_call(&owner, name, args); + return self.lower_polymorphic_call(&owner, name, args, tail); } // User function in the current module? @@ -1315,7 +1383,7 @@ impl<'a> Emitter<'a> { .and_then(|m| m.get(name)) .cloned() { - return self.emit_call(self.module_name, name, &sig, args); + return self.emit_call(self.module_name, name, &sig, args, tail); } Err(CodegenError::Internal(format!( @@ -1332,6 +1400,7 @@ impl<'a> Emitter<'a> { owner_module: &str, def_name: &str, args: &[Term], + tail: bool, ) -> Result<(String, String)> { let fdef = self .module_polymorphic_fns @@ -1408,10 +1477,16 @@ impl<'a> Emitter<'a> { .collect::>() .join(", "); let dst = self.fresh_ssa(); + let call_kw = if tail { "musttail call" } else { "call" }; self.body.push_str(&format!( - " {dst} = call {ret} @{mangled}({arglist})\n", + " {dst} = {call_kw} {ret} @{mangled}({arglist})\n", ret = llvm_ret, )); + if tail { + self.body + .push_str(&format!(" ret {ret} {dst}\n", ret = llvm_ret)); + self.block_terminated = true; + } Ok((dst, llvm_ret)) } @@ -1421,6 +1496,7 @@ impl<'a> Emitter<'a> { target_def: &str, sig: &FnSig, args: &[Term], + tail: bool, ) -> Result<(String, String)> { let mut compiled_args = Vec::new(); for (a, exp_ty) in args.iter().zip(sig.params.iter()) { @@ -1438,12 +1514,23 @@ impl<'a> Emitter<'a> { .collect::>() .join(", "); let dst = self.fresh_ssa(); + // Iter 14e: emit `musttail call ... ret` for `tail: true`. The + // call SSA flows directly into the `ret`, satisfying LLVM's + // "must immediately ret" rule. Same calling convention and + // signature as the surrounding fn (the typechecker enforces + // type compatibility). + let call_kw = if tail { "musttail call" } else { "call" }; self.body.push_str(&format!( - " {dst} = call {ret} @ail_{module}_{name}({arglist})\n", + " {dst} = {call_kw} {ret} @ail_{module}_{name}({arglist})\n", ret = sig.ret, module = target_module, name = target_def, )); + if tail { + self.body + .push_str(&format!(" ret {ret} {dst}\n", ret = sig.ret)); + self.block_terminated = true; + } Ok((dst, sig.ret.clone())) } @@ -1457,6 +1544,7 @@ impl<'a> Emitter<'a> { callee_ssa: &str, sig: &FnSig, args: &[Term], + tail: bool, ) -> Result<(String, String)> { if args.len() != sig.params.len() { return Err(CodegenError::Internal(format!( @@ -1505,11 +1593,25 @@ impl<'a> Emitter<'a> { param_tys.push_str(pt); } let dst = self.fresh_ssa(); + // Iter 14e: indirect tail calls. Same `musttail`/`ret` shape as + // emit_call. The thunk's signature uniformly inserts an + // `env_ptr` first arg, but a `musttail call` to a thunk whose + // signature exactly matches the parent fn's prototype + + // env_ptr is malformed (parent has no env_ptr in its prototype). + // For the MVP no fixture marks an indirect tail call; we honour + // the flag by emitting `musttail call` (LLVM verifier will + // catch a real signature mismatch at IR-verification time). + let call_kw = if tail { "musttail call" } else { "call" }; self.body.push_str(&format!( - " {dst} = call {ret} ({ptys}) {thunk}({arglist})\n", + " {dst} = {call_kw} {ret} ({ptys}) {thunk}({arglist})\n", ret = sig.ret, ptys = param_tys, )); + if tail { + self.body + .push_str(&format!(" ret {ret} {dst}\n", ret = sig.ret)); + self.block_terminated = true; + } Ok((dst, sig.ret.clone())) } @@ -1594,6 +1696,8 @@ impl<'a> Emitter<'a> { let saved_locals = std::mem::take(&mut self.locals); let saved_counter = self.counter; let saved_block = std::mem::take(&mut self.current_block); + let saved_terminated = self.block_terminated; + self.block_terminated = false; let saved_sigs = std::mem::take(&mut self.ssa_fn_sigs); // Lambdas inside lambdas are fine: they get their own counter // namespace within the enclosing thunk. They share the @@ -1653,13 +1757,17 @@ impl<'a> Emitter<'a> { } let (body_v, body_ty) = self.lower_term(lam_body)?; - if body_ty != llvm_ret { - return Err(CodegenError::Internal(format!( - "lambda `{thunk_name}`: body type {body_ty} != return type {llvm_ret}" - ))); + if !self.block_terminated { + if body_ty != llvm_ret { + return Err(CodegenError::Internal(format!( + "lambda `{thunk_name}`: body type {body_ty} != return type {llvm_ret}" + ))); + } + self.body + .push_str(&format!(" ret {body_ty} {body_v}\n}}\n\n")); + } else { + self.body.push_str("}\n\n"); } - self.body - .push_str(&format!(" ret {body_ty} {body_v}\n}}\n\n")); // Park the thunk text in the deferred queue and restore outer // emitter state. @@ -1669,6 +1777,7 @@ impl<'a> Emitter<'a> { self.locals = saved_locals; self.counter = saved_counter; self.current_block = saved_block; + self.block_terminated = saved_terminated; self.ssa_fn_sigs = saved_sigs; // 3. Emit allocation + capture filling + closure-pair packing @@ -1751,7 +1860,7 @@ impl<'a> Emitter<'a> { captures.push(name.clone()); } } - Term::App { callee, args } => { + Term::App { callee, args, .. } => { Self::collect_captures(callee, bound, captures, captures_set, builtins, top_level); for a in args { Self::collect_captures(a, bound, captures, captures_set, builtins, top_level); @@ -1873,7 +1982,19 @@ impl<'a> Emitter<'a> { )) } - fn lower_effect_op(&mut self, op: &str, args: &[Term]) -> Result<(String, String)> { + fn lower_effect_op(&mut self, op: &str, args: &[Term], tail: bool) -> Result<(String, String)> { + // Iter 14e: `musttail` requires identical caller/callee + // prototypes (same return type, same param types). The MVP's + // runtime print helpers (`printf`, `puts`) return `i32`, but the + // AILang fn enclosing a `tail-do io/print_*` returns `Unit` + // (`i8`). `musttail` would be rejected by the LLVM verifier. + // We therefore use the `tail` keyword (LLVM IR optimisation + // hint, NOT a guarantee) for `tail: true` do-ops. The optimiser + // is free to TCO it; if it can't, the call falls back to a + // normal call. The body of the AILang fn afterwards is empty + // (the op was the last thing), so we close it with `ret i8 0`. + let _ = tail; + let call_kw = if tail { "tail call" } else { "call" }; match op { "io/print_int" => { if args.len() != 1 { @@ -1889,8 +2010,12 @@ impl<'a> Emitter<'a> { } let fmt = self.intern_string("fmt_int", "%lld\n"); self.body.push_str(&format!( - " call i32 (ptr, ...) @printf(ptr @{fmt}, i64 {v})\n" + " {call_kw} i32 (ptr, ...) @printf(ptr @{fmt}, i64 {v})\n" )); + if tail { + self.body.push_str(" ret i8 0\n"); + self.block_terminated = true; + } Ok(("0".into(), "i8".into())) } "io/print_str" => { @@ -1906,7 +2031,11 @@ impl<'a> Emitter<'a> { )); } self.body - .push_str(&format!(" call i32 @puts(ptr {v})\n")); + .push_str(&format!(" {call_kw} i32 @puts(ptr {v})\n")); + if tail { + self.body.push_str(" ret i8 0\n"); + self.block_terminated = true; + } Ok(("0".into(), "i8".into())) } "io/print_bool" => { @@ -1942,6 +2071,10 @@ impl<'a> Emitter<'a> { )); self.body.push_str(&format!(" br label %{join_lbl}\n")); self.start_block(&join_lbl); + if tail { + self.body.push_str(" ret i8 0\n"); + self.block_terminated = true; + } Ok(("0".into(), "i8".into())) } other => Err(CodegenError::Internal(format!( @@ -2049,7 +2182,7 @@ impl<'a> Emitter<'a> { ret: ret_ty.clone(), effects: effects.clone(), }), - Term::App { callee, args } => { + Term::App { callee, args, .. } => { let cty = self.synth_with_extras(callee, extras)?; match cty { Type::Fn { ret, .. } => Ok(*ret), @@ -2377,18 +2510,20 @@ fn apply_subst_to_type(t: &Type, subst: &BTreeMap) -> Type { fn apply_subst_to_term(t: &Term, subst: &BTreeMap) -> Term { match t { Term::Lit { .. } | Term::Var { .. } => t.clone(), - Term::App { callee, args } => Term::App { + Term::App { callee, args, tail } => Term::App { callee: Box::new(apply_subst_to_term(callee, subst)), args: args.iter().map(|a| apply_subst_to_term(a, subst)).collect(), + tail: *tail, }, Term::Let { name, value, body } => Term::Let { name: name.clone(), value: Box::new(apply_subst_to_term(value, subst)), body: Box::new(apply_subst_to_term(body, subst)), }, - Term::Do { op, args } => Term::Do { + Term::Do { op, args, tail } => Term::Do { op: op.clone(), args: args.iter().map(|a| apply_subst_to_term(a, subst)).collect(), + tail: *tail, }, Term::Ctor { type_name, ctor, args } => Term::Ctor { type_name: type_name.clone(), @@ -2559,6 +2694,7 @@ mod tests { Term::Var { name: "a".into() }, Term::Var { name: "b".into() }, ], + tail: false, }, doc: None, }), diff --git a/crates/ailang-core/src/ast.rs b/crates/ailang-core/src/ast.rs index 931c76e..734ce49 100644 --- a/crates/ailang-core/src/ast.rs +++ b/crates/ailang-core/src/ast.rs @@ -200,10 +200,18 @@ pub enum Term { Var { name: String }, /// Function application. `callee` is evaluated to a function value; /// `args` are evaluated left-to-right. + /// + /// Iter 14e: `tail` marks this call as occurring in tail position + /// (per Decision 8). When set, codegen lowers the call as + /// `musttail call`. The flag defaults to `false` and is omitted + /// during canonical-JSON serialisation when unset, so pre-14e + /// fixtures keep bit-identical hashes. App { #[serde(rename = "fn")] callee: Box, args: Vec, + #[serde(default, skip_serializing_if = "is_false")] + tail: bool, }, /// Let-binding: `value` is evaluated and bound to `name` in `body`. Let { @@ -213,9 +221,13 @@ pub enum Term { }, /// Effect operation invocation (e.g. `do print "hi"`). The `op` is /// resolved against the effect-handler table at link time. + /// + /// Iter 14e: see [`Term::App`] for the `tail` field semantics. Do { op: String, args: Vec, + #[serde(default, skip_serializing_if = "is_false")] + tail: bool, }, /// Constructor application. `type_name` binds the ADT, `ctor` the /// variant. Example: `Some(42)` -> @@ -402,3 +414,13 @@ impl PartialEq for Type { } } impl Eq for Type {} + +/// Serde helper for `#[serde(skip_serializing_if = "is_false")]`. +/// +/// Used by [`Term::App::tail`] and [`Term::Do::tail`] (Iter 14e) so the +/// `tail` flag is omitted from the canonical JSON whenever it is false, +/// preserving bit-identical hashes for every pre-14e definition. +#[allow(clippy::trivially_copy_pass_by_ref)] +fn is_false(b: &bool) -> bool { + !*b +} diff --git a/crates/ailang-core/src/hash.rs b/crates/ailang-core/src/hash.rs index 92944ae..df946c0 100644 --- a/crates/ailang-core/src/hash.rs +++ b/crates/ailang-core/src/hash.rs @@ -67,6 +67,7 @@ mod tests { Term::Var { name: "a".into() }, Term::Var { name: "b".into() }, ], + tail: false, }, doc: None, }) diff --git a/crates/ailang-core/src/pretty.rs b/crates/ailang-core/src/pretty.rs index 8b9fcc5..3a7875d 100644 --- a/crates/ailang-core/src/pretty.rs +++ b/crates/ailang-core/src/pretty.rs @@ -175,7 +175,7 @@ fn term_block(t: &Term, indent: usize) -> String { match t { Term::Lit { lit } => format!("{pad}{}", lit_to_string(lit)), Term::Var { name } => format!("{pad}{name}"), - Term::App { callee, args } => { + Term::App { callee, args, .. } => { let mut s = format!("{pad}("); s.push_str(&term_inline(callee)); for a in args { @@ -193,7 +193,7 @@ fn term_block(t: &Term, indent: usize) -> String { s.push(')'); s } - Term::Do { op, args } => { + Term::Do { op, args, .. } => { let mut s = format!("{pad}(do {op}"); for a in args { s.push(' '); @@ -287,7 +287,7 @@ fn term_inline(t: &Term) -> String { match t { Term::Lit { lit } => lit_to_string(lit), Term::Var { name } => name.clone(), - Term::App { callee, args } => { + Term::App { callee, args, .. } => { let mut s = String::from("("); s.push_str(&term_inline(callee)); for a in args { @@ -297,7 +297,7 @@ fn term_inline(t: &Term) -> String { s.push(')'); s } - Term::Do { op, args } => { + Term::Do { op, args, .. } => { let mut s = format!("(do {op}"); for a in args { s.push(' '); @@ -414,6 +414,7 @@ mod tests { Term::Var { name: "a".into() }, Term::Var { name: "b".into() }, ], + tail: false, }, doc: None, }), diff --git a/crates/ailang-surface/src/parse.rs b/crates/ailang-surface/src/parse.rs index 304ca50..fc310f7 100644 --- a/crates/ailang-surface/src/parse.rs +++ b/crates/ailang-surface/src/parse.rs @@ -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 { 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 { + 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 { 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 { 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 { + 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 { 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 { diff --git a/crates/ailang-surface/src/print.rs b/crates/ailang-surface/src/print.rs index 7960f22..ad45ae7 100644 --- a/crates/ailang-surface/src/print.rs +++ b/crates/ailang-surface/src/print.rs @@ -223,8 +223,12 @@ fn write_term(out: &mut String, t: &Term, level: usize) { match t { Term::Lit { lit } => write_lit(out, lit), Term::Var { name } => out.push_str(name), - Term::App { callee, args } => { - out.push_str("(app "); + Term::App { callee, args, tail } => { + // Iter 14e: `tail-app` is the form for `App { tail: true }`; + // otherwise the regular `app` head is used. Both productions + // are positional analogues of each other — only the head + // keyword differs. + out.push_str(if *tail { "(tail-app " } else { "(app " }); write_term(out, callee, level); for a in args { out.push(' '); @@ -241,8 +245,9 @@ fn write_term(out: &mut String, t: &Term, level: usize) { write_term(out, body, level); out.push(')'); } - Term::Do { op, args } => { - out.push_str("(do "); + Term::Do { op, args, tail } => { + // Iter 14e: `tail-do` mirrors `tail-app` for effect ops. + out.push_str(if *tail { "(tail-do " } else { "(do " }); out.push_str(op); for a in args { out.push(' '); diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 947cbd8..b7da728 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -429,6 +429,21 @@ fixtures round-trip identically through `print → parse → canonical JSON`; the three hand-written `.ailx` exhibits parse to canonical JSON identical to their corresponding `.ail.json` files. +3. **Tail-call surface (Iter 14e).** Decision 8 ships two new + productions, both positional analogues of their non-tail + counterparts. Their result terms set `Term::App.tail = true` / + `Term::Do.tail = true`; the typechecker's `verify_tail_positions` + pass enforces that the marker is only used in tail position. + + ``` + tail-app-term ::= "(" "tail-app" term term+ ")" + tail-do-term ::= "(" "tail-do" ident term* ")" + ``` + + Production count after Iter 14e: ~30, still inside the 30-rule + constraint-1 budget. No new lexical rule (`tail-app` / `tail-do` + are bare ident tokens; no special casing). + ## Decision 7: redundancy removal — `Term::If` is not a primitive `Term::If { cond, then, else_ }` is semantically a subset of @@ -454,6 +469,63 @@ No schema version bump (no third-party consumes `ailang/v0`). Hash invalidation for the three migrated fixtures (`sum`, `sort`, `max3`) is intentional; the new hashes become the new identity. +## Decision 8: 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 }` 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, `true` for the body of every `Term::Match` arm, + `true` for the right operand of `Term::Seq`, `true` for the + body of `Term::Let`, `true` for the body of `Term::Lam` (each + Lam opens its own tail scope). The flag is `false` for: args + of any `App`/`Do`/`Ctor`, scrutinee of `Match`, left of + `Seq`, condition of `Let`-bound expression. 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. + +**What this does NOT promise.** Per the 14d tail-call survey, +many existing recursive calls are *not* in tail position +because they are arguments to constructor calls (e.g. +`Cons (f h) (map f t)`). 14e 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 (15a onward) ships both forms where +relevant. + +The 14d migration of existing fixtures will be 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. + ## Mangling scheme (Iter 5c) All AILang functions are mangled to `@ail__` — even in the diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 55ff954..4d0628b 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -1874,6 +1874,134 @@ patterns; `map`/`sort` style stays stack-bounded by depth, which makes 14f (GC) the more important iter for handling long lists than 14e by itself. +## Iter 14e — explicit, verified tail calls + +Decision 8 ships. `Term::App` and `Term::Do` carry a `tail: bool` +flag (serde-default false, skip-when-false in serialisation). +A new `verify_tail_positions` typecheck pass walks each fn body +and Lam body with an `is_tail_context: bool` threaded down per +the standard Scheme rules, rejecting any `tail: true` call that +sits outside tail position. Codegen emits `musttail call` for +marked App calls. + +**Hash deltas (intentional, only the migrated calls):** + +| def | hash before | hash after | +|---|---|---| +| `list_map_poly.print_list` | unchanged 14c value | new | +| `sort.print_list` | unchanged 14d value | new | + +All other defs across all 18 fixtures kept bit-identical hashes. +This is the canary for the `skip_serializing_if = "is_false"` +serde rule: an unmarked `App`/`Do` serialises identically to its +pre-14e form, so untouched defs cannot drift. + +**Tests: 79/79 (was 76, +3).** New tests: +- `tail_call_in_non_tail_position_is_rejected` (check unit) — + asserts the diagnostic fires on a deliberately-misplaced + `tail: true` call (e.g. as a `Cons` arg). +- `tail_call_in_tail_position_is_accepted` (check unit) — + asserts the verifier accepts the canonical `print_list` shape. +- `iter14e_print_list_recursion_emits_musttail` (e2e IR-grep) — + builds `list_map_poly`, dumps IR, asserts the recursive call + site uses `musttail call`. This is the only direct evidence + that the AST flag actually reaches LLVM. + +**IR-snapshot evidence** at the recursive site of +`print_list` after migration: + +``` +%v7 = musttail call i8 @ail_list_map_poly_print_list(ptr %v6) +ret i8 %v7 +``` + +`musttail` followed immediately by `ret` of the same SSA value — +LLVM's terminator rule satisfied. Smoke: `list_map_poly` → +`2/3/4`, `insertion_sort_orders_list` → identical sorted list. +Behavior unchanged; only the calling shape did. + +**Two implementer deviations (called out, both reasonable):** + +1. **`tail-do` falls back to `tail call`, not `musttail`.** LLVM + `musttail` requires identical caller/callee return types. AILang + IO ops dispatch through runtime helpers (`printf`/`puts`) + returning `i32`, while AILang's `Unit` lowers to `i8`. Cross-type + `musttail` would be rejected by the verifier. So `Term::Do` with + `tail: true` lowers to `tail call` (the LLVM optimisation hint, + not the guarantee), then `ret i8 0`. No fixture currently uses + `tail-do`, so the path is implemented but not exercised + end-to-end. Proper fix: change runtime helper signatures to + return `i8`. Punted; not blocking. + +2. **`block_terminated` plumbing in codegen.** A `tail-app` / + `tail-do` emits `musttail call ... ret ...` directly and sets + `self.block_terminated = true`. Surrounding code (match-arm phi + construction, fn-body trailing-ret, lambda-thunk trailing-ret) + checks the flag and skips the fall-through emit. When every + match arm is a tail call, the join block is omitted entirely. + This was unavoidable to keep the IR well-formed — adding a + second `ret` after a `musttail call`+`ret` would be a verifier + error. The flag is a small piece of state but it's the right + shape for "the current basic block has been definitively + terminated by a sub-emission". + +**Form (A) at constraint ceiling.** Two new productions +(`tail-app-term`, `tail-do-term`) bring the count to ~30, which +is exactly the constraint-1 budget. Future productions need to +either retire something or accept an explicit budget rebalancing +in DESIGN.md. + +**GC notes from the implementer (informs 14f).** + +- **Allocations cluster in `lower_ctor`** (~line 850 of codegen). + Every `term-ctor` does `malloc(8 + 8 * n)`. In `print_list` we + allocate nothing per recursion (just match + read fields + + recurse); allocations come from `map`, from `main`'s + list-building Cons chain, and from any other user code that + builds ADTs. +- **Lambda envs and closure pairs allocate too** (`lower_lambda`). + Closure pair: `malloc(16)`. Env block: `malloc(env_size)`. + Direct-application closures (the common case for HOF args) + could be arena'd cleanly because the closure dies after the + call returns. Stored or returned closures escape. +- **Tail recursion does NOT reduce allocation pressure**, only + stack depth. For `print_list`-style recursions there's no + allocation to begin with, so the win is purely stack-bounded. + For `map`-style ctor-blocked recursions, each step allocates + one new `Cons` box — that's where allocation-side work pays + off. +- **The "obviously safe" arena boundary** is a fn whose return + type contains no boxed ADT (i.e. returns `Int`/`Bool`/`Unit`/ + `Str` only). All ADT boxes allocated inside such a fn cannot + escape; an arena freed at fn return is sound by construction. + Most current fixtures violate this — `map`, `sort` return ADTs + — so a per-fn-arena scheme alone won't carry. Need a heap + with GC for escaping allocations. + +This narrows 14f's design space: probably **Boehm conservative +GC across the board** as a first cut (`GC_malloc` substituted +for `malloc`, `-lgc` linked, no language change). Add a +per-fn-arena optimisation later for non-escaping cases if the +profile justifies it. Boehm is a one-iter shot; arenas would be +a multi-iter design pass with escape analysis. + +**Plan 14f.** Boehm-GC integration. Concretely: +- `GC_malloc` instead of `malloc` in lowered IR. +- `-lgc` added to the clang link command in `ailang-codegen`'s + build path (probably in the CLI, since the codegen crate + emits IR text and clang is invoked downstream). +- Conservative scan handles AILang's stack and globals out of + the box. +- Verify on a stress test: build a list of 100k Cons cells in + `map`, run, observe RSS doesn't blow up. Boehm collects + unreachable boxes during allocation pressure. +- No AST or schema change. No language-level change. + +After 14f, the language is "done enough" for stdlib (15a). +Anything else (records as a primitive, nested patterns, local +recursive let, type classes) can layer on later without forcing +stdlib rewrites. + diff --git a/examples/list_map_poly.ail.json b/examples/list_map_poly.ail.json index 19f375c..a9d2646 100644 --- a/examples/list_map_poly.ail.json +++ b/examples/list_map_poly.ail.json @@ -164,7 +164,8 @@ "rhs": { "t": "app", "fn": { "t": "var", "name": "print_list" }, - "args": [{ "t": "var", "name": "t" }] + "args": [{ "t": "var", "name": "t" }], + "tail": true } } } diff --git a/examples/list_map_poly.ailx b/examples/list_map_poly.ailx index 72ce0c1..ebe9acf 100644 --- a/examples/list_map_poly.ailx +++ b/examples/list_map_poly.ailx @@ -47,7 +47,7 @@ (case (pat-ctor Cons h t) (seq (do io/print_int h) - (app print_list t)))))) + (tail-app print_list t)))))) (fn main (doc "Map inc over [1,2,3] via polymorphic List, then print: 2,3,4.") diff --git a/examples/sort.ail.json b/examples/sort.ail.json index 35f3c4a..39a0c28 100644 --- a/examples/sort.ail.json +++ b/examples/sort.ail.json @@ -204,7 +204,8 @@ "rhs": { "t": "app", "fn": { "t": "var", "name": "print_list" }, - "args": [{ "t": "var", "name": "t" }] + "args": [{ "t": "var", "name": "t" }], + "tail": true } } }