Iter 13b: codegen for parameterised ADTs

Closes the gap between Iter 13a (parameterised ADTs in the
checker) and end-to-end execution. ADT ctor code stays inlined at
every use site — no mono-queue for types, no new symbols — but
LLVM field types are now derived per use site via substitution.

`CtorRef` gains `type_vars: Vec<String>` so use sites can identify
which fields reference rigid vars. `cref.fields` (precomputed
LLVM strings) is documented as monomorphic-only and read past for
parameterised ADTs.

`lower_ctor`: derives a `BTreeMap<String, Type>` substitution from
`synth_arg_type` of each arg via `unify_for_subst`, then maps every
`ail_field` through `apply_subst_to_type` + `llvm_type` for the
per-store types. Monomorphic ADTs hit the original fast path.

`lower_match`: builds `arm_subst` from `s_ail.args` ↔ `cref.type_vars`,
substitutes through each `cref.ail_fields[idx]`, and uses the
substituted type both for the LLVM `load` AND as the AILang slot
of the local — so a downstream `unbox(b)` sees `b: Int`, not
`b: a`.

`synth_arg_type` for `Term::Ctor`: returns concrete type-args
derived from the ctor's term-args. Vars left unpinned (e.g. `None`
for `Maybe a`) fall back to `Type::unit()`.

`llvm_type(Type::Var)` now hard-errors. Earlier this silently
fell through to `ptr` (the ADT-via-ptr fallback), producing
garbage IR. Failing loudly here surfaces missed substitutions in
the test suite.

Two e2e tests (`box.ail.json`, `maybe_int.ail.json`) cover ctor
lower with substituted field types and match-arm field
substitution. 64 tests green; clippy clean (two pre-existing
warnings untouched). Hash invariant holds.

Out of scope per agent assignment: poly-fn-as-value, higher-rank
polymorphism, DESIGN/JOURNAL updates (deferred to 13c).
This commit is contained in:
2026-05-07 14:45:35 +02:00
parent 3df943d17f
commit 1631f6065c
4 changed files with 364 additions and 15 deletions
+142 -15
View File
@@ -296,10 +296,24 @@ struct CtorInfo {
struct CtorRef {
type_name: String,
tag: u32,
/// Precomputed LLVM field types. Valid only for monomorphic ADTs
/// (`type_vars.is_empty()`). For parameterised ADTs the entries are
/// meaningless (free `Type::Var` lowers via the `_ => ptr` fallback)
/// and must be re-derived per use site after substituting through
/// `ail_fields`.
fields: Vec<String>,
/// AILang-level field types (parallel to `fields`). Needed for
/// codegen-side type tracking — match field bindings inherit these.
/// AILang-level field types (parallel to `fields`). Carries
/// `Type::Var` references for parameterised ADTs (Iter 13b); these
/// are substituted at every ctor / match-arm use site.
ail_fields: Vec<Type>,
/// Iter 13b: type parameters of the owning TypeDef, in declaration
/// order. Empty for monomorphic ADTs (`type IntList = ...`); non-
/// empty for parameterised ADTs (`type Box[a] = MkBox(a)` →
/// `["a"]`). Used as the var-set for `unify_for_subst` when deriving
/// substitutions at a use site, and to map type-args
/// (`Type::Con.args[i]`) back to the right var when lowering match
/// arms against a parameterised scrutinee.
type_vars: Vec<String>,
}
#[derive(Debug, Clone)]
@@ -323,10 +337,19 @@ impl<'a> Emitter<'a> {
if let Def::Type(td) = def {
let mut infos = Vec::new();
for (i, c) in td.ctors.iter().enumerate() {
// Iter 13b: precomputed LLVM field types are only
// meaningful for monomorphic ADTs. For parameterised
// ADTs the field types reference free `Type::Var`s
// and must be derived per use site after
// substituting; we still populate the slot with
// `i64`/`ptr` placeholders so the index shape stays
// uniform, but neither `lower_ctor` nor
// `lower_match` reads from it when `type_vars` is
// non-empty.
let fields: Vec<String> = c
.fields
.iter()
.map(|t| llvm_type(t).unwrap_or_else(|_| "i64".into()))
.map(|t| llvm_type(t).unwrap_or_else(|_| "ptr".into()))
.collect();
infos.push(CtorInfo {
name: c.name.clone(),
@@ -339,6 +362,7 @@ impl<'a> Emitter<'a> {
tag: i as u32,
fields,
ail_fields: c.fields.clone(),
type_vars: td.vars.clone(),
},
);
}
@@ -813,15 +837,39 @@ impl<'a> Emitter<'a> {
cref.type_name
)));
}
if args.len() != cref.fields.len() {
if args.len() != cref.ail_fields.len() {
return Err(CodegenError::Internal(format!(
"ctor `{type_name}/{ctor_name}` arity"
)));
}
// Iter 13b: for parameterised ADTs the precomputed `cref.fields`
// is meaningless because field types reference rigid type vars.
// Derive a per-use-site substitution from the arg types and
// re-lower each field type. Monomorphic ADTs hit the fast path
// (no var-set, substitution is empty, ail_fields lower exactly
// like cref.fields).
let expected_llvm_tys: Vec<String> = if cref.type_vars.is_empty() {
cref.fields.clone()
} else {
let arg_ail_tys: Vec<Type> = args
.iter()
.map(|a| self.synth_arg_type(a))
.collect::<Result<_>>()?;
let var_set: BTreeSet<&str> =
cref.type_vars.iter().map(|s| s.as_str()).collect();
let mut subst: BTreeMap<String, Type> = BTreeMap::new();
for (exp, actual) in cref.ail_fields.iter().zip(arg_ail_tys.iter()) {
unify_for_subst(exp, actual, &var_set, &mut subst)?;
}
cref.ail_fields
.iter()
.map(|f| llvm_type(&apply_subst_to_type(f, &subst)))
.collect::<Result<_>>()?
};
// Evaluate arguments up front so allocation and store stay close
// together.
let mut compiled = Vec::new();
for (a, exp) in args.iter().zip(cref.fields.iter()) {
for (a, exp) in args.iter().zip(expected_llvm_tys.iter()) {
let (v, vty) = self.lower_term(a)?;
if &vty != exp {
return Err(CodegenError::Internal(format!(
@@ -935,12 +983,45 @@ impl<'a> Emitter<'a> {
for (i, (cref, arm, bindings)) in ctor_arms.iter().enumerate() {
self.start_block(&arm_labels[i]);
// Iter 13b: derive substitution for parameterised ADTs from
// the scrutinee's concrete type-args. Map `cref.type_vars[i]`
// → `s_ail.args[i]`. For monomorphic ADTs the mapping is
// empty and substitution is a no-op. The substituted AILang
// types are used both for the LLVM `load` instruction and as
// the AILang-type slot of the local — without the latter, a
// downstream `unbox(b)` would see `b: a` (rigid var) instead
// of `b: Int`.
let arm_subst: BTreeMap<String, Type> = if cref.type_vars.is_empty() {
BTreeMap::new()
} else {
match &s_ail {
Type::Con { args, .. } if args.len() == cref.type_vars.len() => cref
.type_vars
.iter()
.cloned()
.zip(args.iter().cloned())
.collect(),
_ => {
return Err(CodegenError::Internal(format!(
"match: scrutinee type `{}` not aligned with ctor `{}`'s {} type vars",
ailang_core::pretty::type_to_string(&s_ail),
cref.type_name,
cref.type_vars.len(),
)));
}
}
};
// Load fields and bind as locals.
let mut pushed = 0usize;
for (idx, (binding, fty)) in
bindings.iter().zip(cref.fields.iter()).enumerate()
{
for (idx, binding) in bindings.iter().enumerate() {
if let Some(bname) = binding {
let raw_ail = cref.ail_fields.get(idx).cloned().unwrap_or(Type::unit());
let bind_ail = if arm_subst.is_empty() {
raw_ail
} else {
apply_subst_to_type(&raw_ail, &arm_subst)
};
let fty = llvm_type(&bind_ail)?;
let off = 8 + idx as i64 * 8;
let addr = self.fresh_ssa();
self.body.push_str(&format!(
@@ -950,9 +1031,7 @@ impl<'a> Emitter<'a> {
self.body.push_str(&format!(
" {v} = load {fty}, ptr {addr}, align 8\n"
));
let fail_ail = cref.ail_fields.get(idx).cloned().unwrap_or(Type::unit());
self.locals
.push((bname.clone(), v, fty.clone(), fail_ail));
self.locals.push((bname.clone(), v, fty, bind_ail));
pushed += 1;
}
}
@@ -1879,10 +1958,51 @@ impl<'a> Emitter<'a> {
"synth_arg_type: unknown effect op `{op}`"
))
}),
Term::Ctor { type_name, .. } => Ok(Type::Con {
name: type_name.clone(),
args: vec![],
}),
Term::Ctor { type_name, ctor, args } => {
// Iter 13b: derive concrete type-args of a parameterised
// ADT instance from the recursively-synthesised arg
// types. For monomorphic ADTs (`type_vars.is_empty()`)
// we keep the pre-13b shape `Type::Con { args: vec![] }`
// — matching what the typechecker produces.
let cref = self.ctor_index.get(ctor).cloned().ok_or_else(|| {
CodegenError::Internal(format!(
"synth_arg_type: unknown ctor `{ctor}`"
))
})?;
if cref.type_vars.is_empty() {
return Ok(Type::Con {
name: type_name.clone(),
args: vec![],
});
}
let arg_tys: Vec<Type> = args
.iter()
.map(|a| self.synth_with_extras(a, extras))
.collect::<Result<_>>()?;
let var_set: BTreeSet<&str> =
cref.type_vars.iter().map(|s| s.as_str()).collect();
let mut subst: BTreeMap<String, Type> = BTreeMap::new();
for (exp, actual) in cref.ail_fields.iter().zip(arg_tys.iter()) {
unify_for_subst(exp, actual, &var_set, &mut subst)?;
}
// Vars not pinned by ctor args (e.g. `None` for
// `Maybe a`) are filled with `Type::unit()` as a
// placeholder. That's harmless for monomorphic call
// sites — codegen never instantiates a polymorphic fn
// off this un-pinned shape because the typechecker
// would have rejected an under-determined call —
// and keeps the function total for the well-formed
// ones (e.g. `Some(7)` pins `a` from the arg).
let resolved: Vec<Type> = cref
.type_vars
.iter()
.map(|v| subst.get(v).cloned().unwrap_or_else(Type::unit))
.collect();
Ok(Type::Con {
name: type_name.clone(),
args: resolved,
})
}
Term::Match { arms, .. } => {
if let Some(first) = arms.first() {
self.synth_with_extras(&first.body, extras)
@@ -1913,6 +2033,13 @@ fn llvm_type(t: &Type) -> Result<String> {
// at the LLVM level. The actual signature travels via the
// emitter's `ssa_fn_sigs` sidetable.
Type::Fn { .. } => Ok("ptr".into()),
// Iter 13b: an unresolved rigid `Type::Var` reaching codegen is
// a substitution bug. Earlier this silently lowered as `ptr`
// (via the ADT fallback) and produced garbage IR; failing loudly
// here surfaces the bug in the test suite.
Type::Var { name } => Err(CodegenError::UnsupportedType(format!(
"unresolved type var `{name}` in codegen"
))),
other => Err(CodegenError::UnsupportedType(
ailang_core::pretty::type_to_string(other),
)),