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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user