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()); out.insert(name.clone());
} }
Term::App { callee, args } => { Term::App { callee, args, .. } => {
walk_term(callee, out, builtins, scope); walk_term(callee, out, builtins, scope);
for a in args { for a in args {
walk_term(a, out, builtins, scope); walk_term(a, out, builtins, scope);
@@ -878,7 +878,7 @@ fn walk_term(
scope.remove(name); scope.remove(name);
} }
} }
Term::Do { op, args } => { Term::Do { op, args, .. } => {
// Mark effect ops as `effect:io/print_int` so they can be // Mark effect ops as `effect:io/print_int` so they can be
// separated from normal function calls. // separated from normal function calls.
out.insert(format!("effect:{op}")); 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"]); 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. /// Iter 11 dogfood: insertion sort over an 11-element IntList.
/// Exercises `<=`, `if`, mutual-leaf recursion (`insert` and `sort`), /// Exercises `<=`, `if`, mutual-leaf recursion (`insert` and `sort`),
/// nested ctor construction, and the Iter 10 seq operator inside /// 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-cycle` — workspace loader (Iter 5b, in the CLI path)
//! - `module-name-mismatch` — 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) //! - `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; use serde::Serialize;
+222 -2
View File
@@ -395,6 +395,12 @@ pub enum CheckError {
/// for qualified cross-module references. Code: `invalid-def-name`. /// for qualified cross-module references. Code: `invalid-def-name`.
#[error("invalid def name `{name}`: contains `.` (reserved for qualified refs)")] #[error("invalid def name `{name}`: contains `.` (reserved for qualified refs)")]
InvalidDefName { name: String }, 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>; type Result<T> = std::result::Result<T, CheckError>;
@@ -430,6 +436,7 @@ impl CheckError {
CheckError::UnknownModule { .. } => "unknown-module", CheckError::UnknownModule { .. } => "unknown-module",
CheckError::UnknownImport { .. } => "unknown-import", CheckError::UnknownImport { .. } => "unknown-import",
CheckError::InvalidDefName { .. } => "invalid-def-name", 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)?; let body_ty = synth(&f.body, &env, &mut locals, &mut effects, &f.name, &mut subst, &mut counter)?;
unify(&ret_ty, &body_ty, &mut subst)?; 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(); let declared: BTreeSet<String> = declared_effs.into_iter().collect();
for e in &effects { for e in &effects {
if !declared.contains(e) { if !declared.contains(e) {
@@ -903,6 +915,77 @@ fn check_fn(f: &FnDef, env: &Env) -> Result<()> {
Ok(()) 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<()> { fn check_const(c: &ConstDef, env: &Env) -> Result<()> {
// Const types are never polymorphic — a Forall here is rejected // Const types are never polymorphic — a Forall here is rejected
// outright. Any other type passes through to `synth` as before. // outright. Any other type passes through to `synth` as before.
@@ -976,7 +1059,7 @@ fn synth(
}; };
Ok(maybe_instantiate(raw, counter)) 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 = synth(callee, env, locals, effects, in_def, subst, counter)?;
let cty = subst.apply(&cty); let cty = subst.apply(&cty);
let (params, ret, fx) = match &cty { let (params, ret, fx) = match &cty {
@@ -1035,7 +1118,7 @@ fn synth(
} }
Ok(r) Ok(r)
} }
Term::Do { op, args } => { Term::Do { op, args, .. } => {
let sig = env let sig = env
.effect_ops .effect_ops
.get(op) .get(op)
@@ -1425,6 +1508,7 @@ mod tests {
Term::Var { name: "a".into() }, Term::Var { name: "a".into() },
Term::Var { name: "b".into() }, Term::Var { name: "b".into() },
], ],
tail: false,
}, },
)], )],
}; };
@@ -1474,6 +1558,7 @@ mod tests {
args: vec![Term::Lit { args: vec![Term::Lit {
lit: Literal::Int { value: 1 }, lit: Literal::Int { value: 1 },
}], }],
tail: false,
}, },
)], )],
}; };
@@ -1711,6 +1796,7 @@ mod tests {
Term::App { Term::App {
callee: Box::new(Term::Var { name: "id".into() }), callee: Box::new(Term::Var { name: "id".into() }),
args: vec![Term::Lit { lit: Literal::Int { value: 42 } }], args: vec![Term::Lit { lit: Literal::Int { value: 42 } }],
tail: false,
}, },
); );
// `use_bool` returns id(true) :: Bool. // `use_bool` returns id(true) :: Bool.
@@ -1725,6 +1811,7 @@ mod tests {
Term::App { Term::App {
callee: Box::new(Term::Var { name: "id".into() }), callee: Box::new(Term::Var { name: "id".into() }),
args: vec![Term::Lit { lit: Literal::Bool { value: true } }], args: vec![Term::Lit { lit: Literal::Bool { value: true } }],
tail: false,
}, },
); );
let m = Module { let m = Module {
@@ -1766,6 +1853,7 @@ mod tests {
Term::App { Term::App {
callee: Box::new(Term::Var { name: "id".into() }), callee: Box::new(Term::Var { name: "id".into() }),
args: vec![Term::Lit { lit: Literal::Int { value: 42 } }], args: vec![Term::Lit { lit: Literal::Int { value: 42 } }],
tail: false,
}, },
); );
let m = Module { let m = Module {
@@ -1805,6 +1893,7 @@ mod tests {
Term::App { Term::App {
callee: Box::new(Term::Var { name: "f".into() }), callee: Box::new(Term::Var { name: "f".into() }),
args: vec![Term::Var { name: "x".into() }], args: vec![Term::Var { name: "x".into() }],
tail: false,
}, },
); );
// A monomorphic helper to be passed to apply. // A monomorphic helper to be passed to apply.
@@ -1822,6 +1911,7 @@ mod tests {
Term::Var { name: "n".into() }, Term::Var { name: "n".into() },
Term::Lit { lit: Literal::Int { value: 1 } }, Term::Lit { lit: Literal::Int { value: 1 } },
], ],
tail: false,
}, },
); );
let use_apply = fn_def( let use_apply = fn_def(
@@ -1838,6 +1928,7 @@ mod tests {
Term::Var { name: "succ".into() }, Term::Var { name: "succ".into() },
Term::Lit { lit: Literal::Int { value: 41 } }, Term::Lit { lit: Literal::Int { value: 41 } },
], ],
tail: false,
}, },
); );
let m = Module { let m = Module {
@@ -1945,6 +2036,7 @@ mod tests {
ctor: "MkBox".into(), ctor: "MkBox".into(),
args: vec![Term::Lit { lit: Literal::Int { value: 7 } }], args: vec![Term::Lit { lit: Literal::Int { value: 7 } }],
}], }],
tail: false,
}, },
); );
let use_bool = fn_def( let use_bool = fn_def(
@@ -1962,6 +2054,7 @@ mod tests {
ctor: "MkBox".into(), ctor: "MkBox".into(),
args: vec![Term::Lit { lit: Literal::Bool { value: true } }], args: vec![Term::Lit { lit: Literal::Bool { value: true } }],
}], }],
tail: false,
}, },
); );
let m = Module { let m = Module {
@@ -2041,4 +2134,131 @@ mod tests {
let msg = format!("{err}"); let msg = format!("{err}");
assert!(msg.contains("type mismatch"), "got: {msg}"); 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: 2 } },
Term::Lit { lit: Literal::Int { value: 3 } }, Term::Lit { lit: Literal::Int { value: 3 } },
], ],
tail: false,
}, },
doc: None, doc: None,
}); });
+194 -58
View File
@@ -359,6 +359,12 @@ struct Emitter<'a> {
/// Current basic block label. Set by `start_block` and is /// Current basic block label. Set by `start_block` and is
/// the single source of truth for `phi` operands. /// the single source of truth for `phi` operands.
current_block: String, 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 /// Iter 7: SSA value (or `@global`) -> its FnSig, for first-class
/// function values. Populated whenever we lower a `Term::Var` to a /// function values. Populated whenever we lower a `Term::Var` to a
/// top-level fn pointer or when a fn-typed parameter is bound at /// top-level fn pointer or when a fn-typed parameter is bound at
@@ -479,6 +485,7 @@ impl<'a> Emitter<'a> {
types, types,
ctor_index, ctor_index,
current_block: String::new(), current_block: String::new(),
block_terminated: false,
ssa_fn_sigs: BTreeMap::new(), ssa_fn_sigs: BTreeMap::new(),
current_def: String::new(), current_def: String::new(),
lam_counter: 0, lam_counter: 0,
@@ -490,6 +497,7 @@ impl<'a> Emitter<'a> {
self.body.push_str(label); self.body.push_str(label);
self.body.push_str(":\n"); self.body.push_str(":\n");
self.current_block = label.to_string(); self.current_block = label.to_string();
self.block_terminated = false;
} }
fn emit_module(&mut self) -> Result<()> { fn emit_module(&mut self) -> Result<()> {
@@ -694,14 +702,21 @@ impl<'a> Emitter<'a> {
self.start_block("entry"); self.start_block("entry");
let (val, val_ty) = self.lower_term(&f.body)?; let (val, val_ty) = self.lower_term(&f.body)?;
if val_ty != llvm_ret { if !self.block_terminated {
return Err(CodegenError::Internal(format!( if val_ty != llvm_ret {
"fn `{}`: body type {val_ty} != return type {llvm_ret}", return Err(CodegenError::Internal(format!(
f.name "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 // Iter 8b: flush lambda thunks collected while lowering this fn's
// body. They go after the closing `}` of the parent fn, before // body. They go after the closing `}` of the parent fn, before
@@ -801,7 +816,7 @@ impl<'a> Emitter<'a> {
self.locals.pop(); self.locals.pop();
r r
} }
Term::App { callee, args } => { Term::App { callee, args, tail } => {
// Direct call when the callee is a `Var` referring to a // Direct call when the callee is a `Var` referring to a
// statically-known target (builtin, current-module fn, // statically-known target (builtin, current-module fn,
// qualified cross-module fn) AND not shadowed by a local. // 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() { if let Term::Var { name } = callee.as_ref() {
let shadowed = self.locals.iter().any(|(n, _, _, _)| n == name); let shadowed = self.locals.iter().any(|(n, _, _, _)| n == name);
if !shadowed && self.is_static_callee(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)?; 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}`" "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::Ctor { type_name, ctor, args } => self.lower_ctor(type_name, ctor, args),
Term::Match { scrutinee, arms } => self.lower_match(scrutinee, arms), Term::Match { scrutinee, arms } => self.lower_match(scrutinee, arms),
Term::Lam { params, param_tys, ret_ty, effects: _, body } => { Term::Lam { params, param_tys, ret_ty, effects: _, body } => {
@@ -840,6 +855,12 @@ impl<'a> Emitter<'a> {
Term::Seq { lhs, rhs } => { Term::Seq { lhs, rhs } => {
// Iter 10: lower lhs for its effects, discard the SSA; // Iter 10: lower lhs for its effects, discard the SSA;
// lower rhs and return its value as the whole expression. // 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)?; let _ = self.lower_term(lhs)?;
self.lower_term(rhs) self.lower_term(rhs)
} }
@@ -1083,17 +1104,22 @@ impl<'a> Emitter<'a> {
for _ in 0..pushed { for _ in 0..pushed {
self.locals.pop(); self.locals.pop();
} }
phi_inputs.push((val, self.current_block.clone())); // Iter 14e: if the arm body lowered to a `musttail call` +
self.body // `ret`, the block is already terminated. Skip the
.push_str(&format!(" br label %{join_lbl}\n")); // fall-through `br` and exclude this arm from the join phi.
if let Some(rt) = &result_ty { if !self.block_terminated {
if rt != &vty { phi_inputs.push((val, self.current_block.clone()));
return Err(CodegenError::Internal(format!( self.body
"match arm result type {vty} != {rt}" .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 { for _ in 0..pushed {
self.locals.pop(); self.locals.pop();
} }
phi_inputs.push((val, self.current_block.clone())); if !self.block_terminated {
self.body phi_inputs.push((val, self.current_block.clone()));
.push_str(&format!(" br label %{join_lbl}\n")); self.body
if let Some(rt) = &result_ty { .push_str(&format!(" br label %{join_lbl}\n"));
if rt != &vty { if let Some(rt) = &result_ty {
return Err(CodegenError::Internal(format!( if rt != &vty {
"match default arm result type {vty} != {rt}" return Err(CodegenError::Internal(format!(
))); "match default arm result type {vty} != {rt}"
)));
}
} else {
result_ty = Some(vty);
} }
} else {
result_ty = Some(vty);
} }
} else { } else {
// Typechecker guarantees exhaustiveness, so unreachable. // Typechecker guarantees exhaustiveness, so unreachable.
@@ -1130,6 +1158,18 @@ impl<'a> Emitter<'a> {
} }
// join // 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); self.start_block(&join_lbl);
let phi = self.fresh_ssa(); let phi = self.fresh_ssa();
let rt = result_ty.unwrap_or_else(|| "i64".into()); let rt = result_ty.unwrap_or_else(|| "i64".into());
@@ -1195,20 +1235,43 @@ impl<'a> Emitter<'a> {
self.start_block(&then_lbl); self.start_block(&then_lbl);
let (then_v, then_ty) = self.lower_term(true_body)?; let (then_v, then_ty) = self.lower_term(true_body)?;
// Nested code in the `then` body may have changed the // Iter 14e: a tail-call in this arm already terminated its
// block label — phi must see the last actual block. // block; skip its branch to join and exclude from phi.
let then_terminated = self.block_terminated;
let then_block_end = self.current_block.clone(); 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); self.start_block(&else_lbl);
let (else_v, else_ty) = self.lower_term(false_body)?; 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!( return Err(CodegenError::Internal(format!(
"Bool-match arms type mismatch: {then_ty} vs {else_ty}" "Bool-match arms type mismatch: {then_ty} vs {else_ty}"
))); )));
} }
let else_block_end = self.current_block.clone(); if !else_terminated {
self.body.push_str(&format!(" br label %{join_lbl}\n")); 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); self.start_block(&join_lbl);
let phi = self.fresh_ssa(); let phi = self.fresh_ssa();
@@ -1235,7 +1298,7 @@ impl<'a> Emitter<'a> {
Ok((phi, then_ty)) 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. // Built-in arithmetic / comparison.
if let Some((instr, ret_ty)) = builtin_binop(name) { if let Some((instr, ret_ty)) = builtin_binop(name) {
if args.len() != 2 { if args.len() != 2 {
@@ -1249,6 +1312,11 @@ impl<'a> Emitter<'a> {
self.body.push_str(&format!( self.body.push_str(&format!(
" {dst} = {instr} i64 {a}, {b}\n" " {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())); return Ok((dst, ret_ty.into()));
} }
if name == "not" { if name == "not" {
@@ -1277,7 +1345,7 @@ impl<'a> Emitter<'a> {
.get(&target_module) .get(&target_module)
.is_some_and(|m| m.contains_key(suffix)) .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 let target_fns = self
.module_user_fns .module_user_fns
@@ -1295,7 +1363,7 @@ impl<'a> Emitter<'a> {
"cross-module call `{name}`: def `{suffix}` not in module `{target_module}`" "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? // Polymorphic def in the current module?
@@ -1305,7 +1373,7 @@ impl<'a> Emitter<'a> {
.is_some_and(|m| m.contains_key(name)) .is_some_and(|m| m.contains_key(name))
{ {
let owner = self.module_name.to_string(); 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? // User function in the current module?
@@ -1315,7 +1383,7 @@ impl<'a> Emitter<'a> {
.and_then(|m| m.get(name)) .and_then(|m| m.get(name))
.cloned() .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!( Err(CodegenError::Internal(format!(
@@ -1332,6 +1400,7 @@ impl<'a> Emitter<'a> {
owner_module: &str, owner_module: &str,
def_name: &str, def_name: &str,
args: &[Term], args: &[Term],
tail: bool,
) -> Result<(String, String)> { ) -> Result<(String, String)> {
let fdef = self let fdef = self
.module_polymorphic_fns .module_polymorphic_fns
@@ -1408,10 +1477,16 @@ impl<'a> Emitter<'a> {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
let dst = self.fresh_ssa(); let dst = self.fresh_ssa();
let call_kw = if tail { "musttail call" } else { "call" };
self.body.push_str(&format!( self.body.push_str(&format!(
" {dst} = call {ret} @{mangled}({arglist})\n", " {dst} = {call_kw} {ret} @{mangled}({arglist})\n",
ret = llvm_ret, 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)) Ok((dst, llvm_ret))
} }
@@ -1421,6 +1496,7 @@ impl<'a> Emitter<'a> {
target_def: &str, target_def: &str,
sig: &FnSig, sig: &FnSig,
args: &[Term], args: &[Term],
tail: bool,
) -> Result<(String, String)> { ) -> Result<(String, String)> {
let mut compiled_args = Vec::new(); let mut compiled_args = Vec::new();
for (a, exp_ty) in args.iter().zip(sig.params.iter()) { for (a, exp_ty) in args.iter().zip(sig.params.iter()) {
@@ -1438,12 +1514,23 @@ impl<'a> Emitter<'a> {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
let dst = self.fresh_ssa(); 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!( 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, ret = sig.ret,
module = target_module, module = target_module,
name = target_def, 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())) Ok((dst, sig.ret.clone()))
} }
@@ -1457,6 +1544,7 @@ impl<'a> Emitter<'a> {
callee_ssa: &str, callee_ssa: &str,
sig: &FnSig, sig: &FnSig,
args: &[Term], args: &[Term],
tail: bool,
) -> Result<(String, String)> { ) -> Result<(String, String)> {
if args.len() != sig.params.len() { if args.len() != sig.params.len() {
return Err(CodegenError::Internal(format!( return Err(CodegenError::Internal(format!(
@@ -1505,11 +1593,25 @@ impl<'a> Emitter<'a> {
param_tys.push_str(pt); param_tys.push_str(pt);
} }
let dst = self.fresh_ssa(); 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!( self.body.push_str(&format!(
" {dst} = call {ret} ({ptys}) {thunk}({arglist})\n", " {dst} = {call_kw} {ret} ({ptys}) {thunk}({arglist})\n",
ret = sig.ret, ret = sig.ret,
ptys = param_tys, 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())) Ok((dst, sig.ret.clone()))
} }
@@ -1594,6 +1696,8 @@ impl<'a> Emitter<'a> {
let saved_locals = std::mem::take(&mut self.locals); let saved_locals = std::mem::take(&mut self.locals);
let saved_counter = self.counter; let saved_counter = self.counter;
let saved_block = std::mem::take(&mut self.current_block); 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); let saved_sigs = std::mem::take(&mut self.ssa_fn_sigs);
// Lambdas inside lambdas are fine: they get their own counter // Lambdas inside lambdas are fine: they get their own counter
// namespace within the enclosing thunk. They share the // 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)?; let (body_v, body_ty) = self.lower_term(lam_body)?;
if body_ty != llvm_ret { if !self.block_terminated {
return Err(CodegenError::Internal(format!( if body_ty != llvm_ret {
"lambda `{thunk_name}`: body type {body_ty} != return type {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 // Park the thunk text in the deferred queue and restore outer
// emitter state. // emitter state.
@@ -1669,6 +1777,7 @@ impl<'a> Emitter<'a> {
self.locals = saved_locals; self.locals = saved_locals;
self.counter = saved_counter; self.counter = saved_counter;
self.current_block = saved_block; self.current_block = saved_block;
self.block_terminated = saved_terminated;
self.ssa_fn_sigs = saved_sigs; self.ssa_fn_sigs = saved_sigs;
// 3. Emit allocation + capture filling + closure-pair packing // 3. Emit allocation + capture filling + closure-pair packing
@@ -1751,7 +1860,7 @@ impl<'a> Emitter<'a> {
captures.push(name.clone()); captures.push(name.clone());
} }
} }
Term::App { callee, args } => { Term::App { callee, args, .. } => {
Self::collect_captures(callee, bound, captures, captures_set, builtins, top_level); Self::collect_captures(callee, bound, captures, captures_set, builtins, top_level);
for a in args { for a in args {
Self::collect_captures(a, bound, captures, captures_set, builtins, top_level); 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 { match op {
"io/print_int" => { "io/print_int" => {
if args.len() != 1 { if args.len() != 1 {
@@ -1889,8 +2010,12 @@ impl<'a> Emitter<'a> {
} }
let fmt = self.intern_string("fmt_int", "%lld\n"); let fmt = self.intern_string("fmt_int", "%lld\n");
self.body.push_str(&format!( 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())) Ok(("0".into(), "i8".into()))
} }
"io/print_str" => { "io/print_str" => {
@@ -1906,7 +2031,11 @@ impl<'a> Emitter<'a> {
)); ));
} }
self.body 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())) Ok(("0".into(), "i8".into()))
} }
"io/print_bool" => { "io/print_bool" => {
@@ -1942,6 +2071,10 @@ impl<'a> Emitter<'a> {
)); ));
self.body.push_str(&format!(" br label %{join_lbl}\n")); self.body.push_str(&format!(" br label %{join_lbl}\n"));
self.start_block(&join_lbl); self.start_block(&join_lbl);
if tail {
self.body.push_str(" ret i8 0\n");
self.block_terminated = true;
}
Ok(("0".into(), "i8".into())) Ok(("0".into(), "i8".into()))
} }
other => Err(CodegenError::Internal(format!( other => Err(CodegenError::Internal(format!(
@@ -2049,7 +2182,7 @@ impl<'a> Emitter<'a> {
ret: ret_ty.clone(), ret: ret_ty.clone(),
effects: effects.clone(), effects: effects.clone(),
}), }),
Term::App { callee, args } => { Term::App { callee, args, .. } => {
let cty = self.synth_with_extras(callee, extras)?; let cty = self.synth_with_extras(callee, extras)?;
match cty { match cty {
Type::Fn { ret, .. } => Ok(*ret), 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 { fn apply_subst_to_term(t: &Term, subst: &BTreeMap<String, Type>) -> Term {
match t { match t {
Term::Lit { .. } | Term::Var { .. } => t.clone(), 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)), callee: Box::new(apply_subst_to_term(callee, subst)),
args: args.iter().map(|a| apply_subst_to_term(a, subst)).collect(), args: args.iter().map(|a| apply_subst_to_term(a, subst)).collect(),
tail: *tail,
}, },
Term::Let { name, value, body } => Term::Let { Term::Let { name, value, body } => Term::Let {
name: name.clone(), name: name.clone(),
value: Box::new(apply_subst_to_term(value, subst)), value: Box::new(apply_subst_to_term(value, subst)),
body: Box::new(apply_subst_to_term(body, 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(), op: op.clone(),
args: args.iter().map(|a| apply_subst_to_term(a, subst)).collect(), args: args.iter().map(|a| apply_subst_to_term(a, subst)).collect(),
tail: *tail,
}, },
Term::Ctor { type_name, ctor, args } => Term::Ctor { Term::Ctor { type_name, ctor, args } => Term::Ctor {
type_name: type_name.clone(), type_name: type_name.clone(),
@@ -2559,6 +2694,7 @@ mod tests {
Term::Var { name: "a".into() }, Term::Var { name: "a".into() },
Term::Var { name: "b".into() }, Term::Var { name: "b".into() },
], ],
tail: false,
}, },
doc: None, doc: None,
}), }),
+22
View File
@@ -200,10 +200,18 @@ pub enum Term {
Var { name: String }, Var { name: String },
/// Function application. `callee` is evaluated to a function value; /// Function application. `callee` is evaluated to a function value;
/// `args` are evaluated left-to-right. /// `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 { App {
#[serde(rename = "fn")] #[serde(rename = "fn")]
callee: Box<Term>, callee: Box<Term>,
args: Vec<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-binding: `value` is evaluated and bound to `name` in `body`.
Let { Let {
@@ -213,9 +221,13 @@ pub enum Term {
}, },
/// Effect operation invocation (e.g. `do print "hi"`). The `op` is /// Effect operation invocation (e.g. `do print "hi"`). The `op` is
/// resolved against the effect-handler table at link time. /// resolved against the effect-handler table at link time.
///
/// Iter 14e: see [`Term::App`] for the `tail` field semantics.
Do { Do {
op: String, op: String,
args: Vec<Term>, args: Vec<Term>,
#[serde(default, skip_serializing_if = "is_false")]
tail: bool,
}, },
/// Constructor application. `type_name` binds the ADT, `ctor` the /// Constructor application. `type_name` binds the ADT, `ctor` the
/// variant. Example: `Some(42)` -> /// variant. Example: `Some(42)` ->
@@ -402,3 +414,13 @@ impl PartialEq for Type {
} }
} }
impl Eq 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: "a".into() },
Term::Var { name: "b".into() }, Term::Var { name: "b".into() },
], ],
tail: false,
}, },
doc: None, doc: None,
}) })
+5 -4
View File
@@ -175,7 +175,7 @@ fn term_block(t: &Term, indent: usize) -> String {
match t { match t {
Term::Lit { lit } => format!("{pad}{}", lit_to_string(lit)), Term::Lit { lit } => format!("{pad}{}", lit_to_string(lit)),
Term::Var { name } => format!("{pad}{name}"), Term::Var { name } => format!("{pad}{name}"),
Term::App { callee, args } => { Term::App { callee, args, .. } => {
let mut s = format!("{pad}("); let mut s = format!("{pad}(");
s.push_str(&term_inline(callee)); s.push_str(&term_inline(callee));
for a in args { for a in args {
@@ -193,7 +193,7 @@ fn term_block(t: &Term, indent: usize) -> String {
s.push(')'); s.push(')');
s s
} }
Term::Do { op, args } => { Term::Do { op, args, .. } => {
let mut s = format!("{pad}(do {op}"); let mut s = format!("{pad}(do {op}");
for a in args { for a in args {
s.push(' '); s.push(' ');
@@ -287,7 +287,7 @@ fn term_inline(t: &Term) -> String {
match t { match t {
Term::Lit { lit } => lit_to_string(lit), Term::Lit { lit } => lit_to_string(lit),
Term::Var { name } => name.clone(), Term::Var { name } => name.clone(),
Term::App { callee, args } => { Term::App { callee, args, .. } => {
let mut s = String::from("("); let mut s = String::from("(");
s.push_str(&term_inline(callee)); s.push_str(&term_inline(callee));
for a in args { for a in args {
@@ -297,7 +297,7 @@ fn term_inline(t: &Term) -> String {
s.push(')'); s.push(')');
s s
} }
Term::Do { op, args } => { Term::Do { op, args, .. } => {
let mut s = format!("(do {op}"); let mut s = format!("(do {op}");
for a in args { for a in args {
s.push(' '); s.push(' ');
@@ -414,6 +414,7 @@ mod tests {
Term::Var { name: "a".into() }, Term::Var { name: "a".into() },
Term::Var { name: "b".into() }, Term::Var { name: "b".into() },
], ],
tail: false,
}, },
doc: None, doc: None,
}), }),
+50 -8
View File
@@ -34,18 +34,20 @@
//! effects-clause::= "(" "effects" ident+ ")" //! effects-clause::= "(" "effects" ident+ ")"
//! //!
//! term ::= var-ref | int-lit | str-lit | bool-lit | unit-lit //! term ::= var-ref | int-lit | str-lit | bool-lit | unit-lit
//! | app-term | match-term | ctor-term | do-term | seq-term //! | app-term | tail-app-term | match-term | ctor-term
//! | lam-term | let-term //! | do-term | tail-do-term | seq-term | lam-term | let-term
//! var-ref ::= ident ; reserved: true/false → bool-lit //! var-ref ::= ident ; reserved: true/false → bool-lit
//! int-lit ::= integer ; numeric atom //! int-lit ::= integer ; numeric atom
//! str-lit ::= string ; string atom //! str-lit ::= string ; string atom
//! bool-lit ::= "true" | "false" //! bool-lit ::= "true" | "false"
//! unit-lit ::= "(" "lit-unit" ")" //! unit-lit ::= "(" "lit-unit" ")"
//! app-term ::= "(" "app" term term+ ")" //! app-term ::= "(" "app" term term+ ")"
//! tail-app-term ::= "(" "tail-app" term term+ ")" ; Iter 14e
//! ctor-term ::= "(" "term-ctor" ident ident term* ")" //! ctor-term ::= "(" "term-ctor" ident ident term* ")"
//! match-term ::= "(" "match" term case-arm+ ")" //! match-term ::= "(" "match" term case-arm+ ")"
//! case-arm ::= "(" "case" pattern term ")" //! case-arm ::= "(" "case" pattern term ")"
//! do-term ::= "(" "do" ident term* ")" //! do-term ::= "(" "do" ident term* ")"
//! tail-do-term ::= "(" "tail-do" ident term* ")" ; Iter 14e
//! seq-term ::= "(" "seq" term term ")" //! seq-term ::= "(" "seq" term term ")"
//! lam-term ::= "(" "lam" "(" "params" typed-param* ")" //! lam-term ::= "(" "lam" "(" "params" typed-param* ")"
//! "(" "ret" type ")" //! "(" "ret" type ")"
@@ -677,9 +679,11 @@ impl<'a> Parser<'a> {
match head { match head {
"lit-unit" => self.parse_lit_unit(), "lit-unit" => self.parse_lit_unit(),
"app" => self.parse_app(), "app" => self.parse_app(),
"tail-app" => self.parse_tail_app(),
"term-ctor" => self.parse_term_ctor(), "term-ctor" => self.parse_term_ctor(),
"match" => self.parse_match(), "match" => self.parse_match(),
"do" => self.parse_do(), "do" => self.parse_do(),
"tail-do" => self.parse_tail_do(),
"seq" => self.parse_seq(), "seq" => self.parse_seq(),
"lam" => self.parse_lam(), "lam" => self.parse_lam(),
"let" => self.parse_let(), "let" => self.parse_let(),
@@ -689,8 +693,8 @@ impl<'a> Parser<'a> {
production: "term", production: "term",
message: format!( message: format!(
"unknown term head `{other}`; expected one of \ "unknown term head `{other}`; expected one of \
`app`, `lam`, `let`, `match`, `do`, `seq`, \ `app`, `tail-app`, `lam`, `let`, `match`, `do`, \
`term-ctor`, `lit-unit`" `tail-do`, `seq`, `term-ctor`, `lit-unit`"
), ),
pos, pos,
}) })
@@ -736,12 +740,32 @@ impl<'a> Parser<'a> {
fn parse_app(&mut self) -> Result<Term, ParseError> { fn parse_app(&mut self) -> Result<Term, ParseError> {
self.expect_lparen("app-term")?; self.expect_lparen("app-term")?;
self.expect_keyword("app")?; 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()?; let callee = self.parse_term()?;
// 1+ args // 1+ args
if matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) { if matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
let pos = self.peek().map(|t| t.span.start).unwrap_or(0); let pos = self.peek().map(|t| t.span.start).unwrap_or(0);
return Err(ParseError::Production { return Err(ParseError::Production {
production: "app-term", production,
message: "expected at least one argument".into(), message: "expected at least one argument".into(),
pos, pos,
}); });
@@ -750,10 +774,11 @@ impl<'a> Parser<'a> {
while !matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) { while !matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
args.push(self.parse_term()?); args.push(self.parse_term()?);
} }
self.expect_rparen("app-term")?; self.expect_rparen(production)?;
Ok(Term::App { Ok(Term::App {
callee: Box::new(callee), callee: Box::new(callee),
args, args,
tail,
}) })
} }
@@ -809,13 +834,30 @@ impl<'a> Parser<'a> {
fn parse_do(&mut self) -> Result<Term, ParseError> { fn parse_do(&mut self) -> Result<Term, ParseError> {
self.expect_lparen("do-term")?; self.expect_lparen("do-term")?;
self.expect_keyword("do")?; 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 op = self.expect_ident("effect op (e.g. `io/print_int`)")?;
let mut args = Vec::new(); let mut args = Vec::new();
while !matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) { while !matches!(self.peek(), Some(Token { tok: Tok::RParen, .. })) {
args.push(self.parse_term()?); args.push(self.parse_term()?);
} }
self.expect_rparen("do-term")?; self.expect_rparen(production)?;
Ok(Term::Do { op, args }) Ok(Term::Do { op, args, tail })
} }
fn parse_seq(&mut self) -> Result<Term, ParseError> { 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 { match t {
Term::Lit { lit } => write_lit(out, lit), Term::Lit { lit } => write_lit(out, lit),
Term::Var { name } => out.push_str(name), Term::Var { name } => out.push_str(name),
Term::App { callee, args } => { Term::App { callee, args, tail } => {
out.push_str("(app "); // 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); write_term(out, callee, level);
for a in args { for a in args {
out.push(' '); out.push(' ');
@@ -241,8 +245,9 @@ fn write_term(out: &mut String, t: &Term, level: usize) {
write_term(out, body, level); write_term(out, body, level);
out.push(')'); out.push(')');
} }
Term::Do { op, args } => { Term::Do { op, args, tail } => {
out.push_str("(do "); // Iter 14e: `tail-do` mirrors `tail-app` for effect ops.
out.push_str(if *tail { "(tail-do " } else { "(do " });
out.push_str(op); out.push_str(op);
for a in args { for a in args {
out.push(' '); 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`; the three hand-written `.ailx` exhibits parse to canonical
JSON identical to their corresponding `.ail.json` files. 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 ## Decision 7: redundancy removal — `Term::If` is not a primitive
`Term::If { cond, then, else_ }` is semantically a subset of `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`, Hash invalidation for the three migrated fixtures (`sum`, `sort`,
`max3`) is intentional; the new hashes become the new identity. `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) ## Mangling scheme (Iter 5c)
All AILang functions are mangled to `@ail_<module>_<def>` — even in the 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 which makes 14f (GC) the more important iter for handling
long lists than 14e by itself. 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": { "rhs": {
"t": "app", "t": "app",
"fn": { "t": "var", "name": "print_list" }, "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) (case (pat-ctor Cons h t)
(seq (seq
(do io/print_int h) (do io/print_int h)
(app print_list t)))))) (tail-app print_list t))))))
(fn main (fn main
(doc "Map inc over [1,2,3] via polymorphic List, then print: 2,3,4.") (doc "Map inc over [1,2,3] via polymorphic List, then print: 2,3,4.")
+2 -1
View File
@@ -204,7 +204,8 @@
"rhs": { "rhs": {
"t": "app", "t": "app",
"fn": { "t": "var", "name": "print_list" }, "fn": { "t": "var", "name": "print_list" },
"args": [{ "t": "var", "name": "t" }] "args": [{ "t": "var", "name": "t" }],
"tail": true
} }
} }
} }