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
+2 -2
View File
@@ -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}"));
+56
View File
@@ -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
+1
View File
@@ -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;
+222 -2
View File
@@ -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<T> = std::result::Result<T, CheckError>;
@@ -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<String> = 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");
}
}
+1
View File
@@ -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,
});
+194 -58
View File
@@ -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::<Vec<_>>()
.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::<Vec<_>>()
.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<String, Type>) -> Type {
fn apply_subst_to_term(t: &Term, subst: &BTreeMap<String, Type>) -> 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,
}),
+22
View File
@@ -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<Term>,
args: Vec<Term>,
#[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<Term>,
#[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
}
+1
View File
@@ -67,6 +67,7 @@ mod tests {
Term::Var { name: "a".into() },
Term::Var { name: "b".into() },
],
tail: false,
},
doc: None,
})
+5 -4
View File
@@ -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,
}),
+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> {
+9 -4
View File
@@ -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(' ');
+72
View File
@@ -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_<module>_<def>` — even in the
+128
View File
@@ -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.
+2 -1
View File
@@ -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
}
}
}
+1 -1
View File
@@ -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.")
+2 -1
View File
@@ -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
}
}
}