Iter 15b: std_list ships, three more compiler gaps closed

Second stdlib module. Tester wrote std_list.ailx (10 combinators,
164 LOC) and a consumer demo. std_list typechecked standalone;
demo did not, surfacing three compiler bugs:

1. Check-side: Iter 14h's qualify_local_types was applied to
   Term::Var cross-module lookup but not to ctor-field types in
   Term::Ctor synth or Pattern::Ctor resolution. First recursive
   cross-module ADT (List has Cons a (List a) — recursive Con
   self-ref) triggers the bug. std_maybe slipped through because
   Maybe's ctors have no recursive Con field.
2. Codegen-side: same gap mirrored across 4 sites in codegen
   (Term::Ctor synth, lower_ctor, lower_match) plus a tweak to
   unify_for_subst (recurse on re-bind instead of strict equality
   so sibling-derived List<Int> accepts nullary-ctor's List<$u>
   wildcard).
3. Const codegen: emit_const rejected non-literal const bodies.
   The demo's xs : List<Int> = Cons 1 (...) requires it. Fix:
   per-module const table, Term::Var resolution loads literal
   consts from global, inlines non-literal bodies. Bare and
   qualified refs both supported.

All three fixes carry an "Iter 15b" code comment at their site.
~349/25 LOC across ailang-check, ailang-codegen, e2e.rs.

Tests 85 -> 87. New e2e std_list_demo asserts 11-line stdout:
length 5, is_empty false/true, head via from_maybe, tail length,
append length, reverse head, map double head, filter is_even
length, fold_left sum, fold_right sum. New ailang-check unit
test cross_module_recursive_adt_term_and_pat_ctor covers both
the original bug and the symmetric pat-ctor latent twin.

Hash invariance: all pre-15b fixtures + std_maybe defs
bit-identical. 14a / 14e / 14h regressions all green.

Cumulative state: 2 stdlib modules (std_maybe, std_list), 14
combinators, cross-module recursive ADT working end-to-end.
Three compiler bugs surfaced + fixed in dogfood since 14a (each
dogfood iter has surfaced ≥1).

Authoring observation: form (A) at 10 combinators is fine; main
friction is paren-counting in nested seq chains, not the form
itself. n-ary seq would help but is sugar.

Plan 15c: 1000-element list stress test for fold_left (tail-call-
marked) vs fold_right (constructor-blocked).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 18:34:49 +02:00
parent 12e9a9c0cc
commit 92f4b4f8c7
8 changed files with 694 additions and 25 deletions
+160 -17
View File
@@ -187,6 +187,12 @@ pub fn lower_workspace(ws: &Workspace) -> Result<String> {
// in that module). Cross-module ctor lookups resolve through this
// table instead of the per-Emitter `ctor_index`.
let mut module_ctor_index: BTreeMap<String, BTreeMap<String, CtorRef>> = BTreeMap::new();
// Iter 15b: per-module const table. Used to resolve `Term::Var`
// references to const defs (literal or non-literal) at lowering
// time. Literal consts emit a global and are loaded; non-literal
// consts (e.g. ctor expressions) are inlined at every reference
// site since check_const guarantees their bodies are pure.
let mut module_consts: BTreeMap<String, BTreeMap<String, ConstDef>> = BTreeMap::new();
for (mname, m) in &ws.modules {
let mut user_fns = BTreeMap::new();
let mut ail_types = BTreeMap::new();
@@ -231,10 +237,19 @@ pub fn lower_workspace(ws: &Workspace) -> Result<String> {
}
}
}
// Iter 15b: collect const defs for this module so non-literal
// consts can be inlined at `Term::Var` reference sites.
let mut consts: BTreeMap<String, ConstDef> = BTreeMap::new();
for def in &m.defs {
if let Def::Const(c) = def {
consts.insert(c.name.clone(), c.clone());
}
}
module_user_fns.insert(mname.clone(), user_fns);
module_def_ail_types.insert(mname.clone(), ail_types);
module_polymorphic_fns.insert(mname.clone(), poly_fns);
module_ctor_index.insert(mname.clone(), ctors);
module_consts.insert(mname.clone(), consts);
}
// Pass 2: lower per module. Globals/strings are accumulated per module,
@@ -256,6 +271,7 @@ pub fn lower_workspace(ws: &Workspace) -> Result<String> {
&module_def_ail_types,
&module_polymorphic_fns,
&module_ctor_index,
&module_consts,
import_map,
);
emitter
@@ -388,6 +404,12 @@ struct Emitter<'a> {
/// emitter `ctor_index` of pre-15a — that table only knew the
/// current module's ctors and broke on cross-module references.
module_ctor_index: &'a BTreeMap<String, BTreeMap<String, CtorRef>>,
/// Iter 15b: per-module const defs, used to resolve `Term::Var`
/// references (bare or qualified) to a const's body. Literal
/// consts emit a global and are loaded via `@ail_<m>_<name>`;
/// non-literal consts are inlined at every reference site (sound
/// because `check_const` rejects effects, so the body is pure).
module_consts: &'a BTreeMap<String, BTreeMap<String, ConstDef>>,
/// Current basic block label. Set by `start_block` and is
/// the single source of truth for `phi` operands.
current_block: String,
@@ -459,6 +481,7 @@ impl<'a> Emitter<'a> {
module_def_ail_types: &'a BTreeMap<String, BTreeMap<String, Type>>,
module_polymorphic_fns: &'a BTreeMap<String, BTreeMap<String, FnDef>>,
module_ctor_index: &'a BTreeMap<String, BTreeMap<String, CtorRef>>,
module_consts: &'a BTreeMap<String, BTreeMap<String, ConstDef>>,
import_map: BTreeMap<String, String>,
) -> Self {
let mut types: BTreeMap<String, Vec<CtorInfo>> = BTreeMap::new();
@@ -506,6 +529,7 @@ impl<'a> Emitter<'a> {
import_map,
types,
module_ctor_index,
module_consts,
current_block: String::new(),
block_terminated: false,
ssa_fn_sigs: BTreeMap::new(),
@@ -627,14 +651,20 @@ impl<'a> Emitter<'a> {
}
fn emit_const(&mut self, c: &ConstDef) -> Result<()> {
// Iter 15b: non-literal const values (e.g. ctor expressions) are
// not emitted as globals. They are inlined at every `Term::Var`
// reference site — sound because `check_const` rejects effectful
// bodies, so re-evaluating the body at each use is observably
// equivalent to a single computation. Trade-off: a long
// recursive const evaluated in many places duplicates work,
// but the demo-scale workloads shipped in the stdlib
// examples are small enough that this is a non-issue. A
// future iter may layer a `@llvm.global_ctors`-style init
// path on top to share the result across reference sites.
let lty = llvm_type(&c.ty)?;
let lit = match &c.value {
Term::Lit { lit } => lit,
_ => {
return Err(CodegenError::Internal(
"MVP: const must be a literal".into(),
));
}
_ => return Ok(()),
};
let (val_ty, val) = match lit {
Literal::Int { value } => ("i64".to_string(), value.to_string()),
@@ -828,6 +858,31 @@ impl<'a> Emitter<'a> {
self.ssa_fn_sigs.entry(global.clone()).or_insert(sig);
return Ok((global, "ptr".into()));
}
// Iter 15b: const lookup. Both bare (`xs`) and qualified
// (`prefix.xs`) forms resolve through `module_consts`.
// Literal-bodied consts get a load from the global; non-
// literal bodies (e.g. ctor expressions) are inlined.
if let Some((owner_module, cdef)) = self.resolve_const(name) {
let lty = llvm_type(&cdef.ty)?;
if matches!(&cdef.value, Term::Lit { .. }) {
let v = self.fresh_ssa();
self.body.push_str(&format!(
" {v} = load {lty}, ptr @ail_{owner_module}_{cname}, align 8\n",
cname = cdef.name,
));
return Ok((v, lty));
} else {
// Inline the const body. Switch module context to
// the owning module while lowering so any nested
// bare references resolve in the const's home
// namespace. Simpler approach: call lower_term
// directly; the current emitter's module context
// is fine because cross-module ctors are already
// qualified in the AST after typecheck.
let value = cdef.value.clone();
return self.lower_term(&value);
}
}
Err(CodegenError::UnknownVar(name.clone()))
}
Term::Let { name, value, body } => {
@@ -1107,6 +1162,22 @@ impl<'a> Emitter<'a> {
// re-lower each field type. Monomorphic ADTs hit the fast path
// (no var-set, substitution is empty, ail_fields lower exactly
// like cref.fields).
// Iter 15b: for cross-module ctors, qualify any local type-cons
// in `cref.ail_fields` (symmetric to the term-ctor synth fix).
let qualified_ail_fields: Vec<Type> = if type_name.matches('.').count() == 1 {
let (prefix, _) = type_name.split_once('.').expect("checked");
if let Some(target) = self.import_map.get(prefix) {
let owner_local_types = self.collect_owner_local_types(target);
cref.ail_fields
.iter()
.map(|f| qualify_local_types_codegen(f, target, &owner_local_types))
.collect()
} else {
cref.ail_fields.clone()
}
} else {
cref.ail_fields.clone()
};
let expected_llvm_tys: Vec<String> = if cref.type_vars.is_empty() {
cref.fields.clone()
} else {
@@ -1117,10 +1188,10 @@ impl<'a> Emitter<'a> {
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()) {
for (exp, actual) in qualified_ail_fields.iter().zip(arg_ail_tys.iter()) {
unify_for_subst(exp, actual, &var_set, &mut subst)?;
}
cref.ail_fields
qualified_ail_fields
.iter()
.map(|f| llvm_type(&apply_subst_to_type(f, &subst)))
.collect::<Result<_>>()?
@@ -1265,14 +1336,32 @@ impl<'a> Emitter<'a> {
}
};
// Load fields and bind as locals.
// Iter 15b: when the scrutinee's ADT lives in another module,
// `cref.ail_fields[idx]` carries the field type written in
// the owner's local namespace. Qualify it before substituting
// — symmetric to the term-ctor and pat-ctor fixes in
// ailang-check.
let owning_module: Option<String> = match &s_ail {
Type::Con { name, .. } if name.matches('.').count() == 1 => name
.split_once('.')
.map(|(p, _)| p.to_string()),
_ => None,
};
let mut pushed = 0usize;
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 qualified_ail = match &owning_module {
Some(m) => {
let owner_local_types = self.collect_owner_local_types(m);
qualify_local_types_codegen(&raw_ail, m, &owner_local_types)
}
None => raw_ail,
};
let bind_ail = if arm_subst.is_empty() {
raw_ail
qualified_ail
} else {
apply_subst_to_type(&raw_ail, &arm_subst)
apply_subst_to_type(&qualified_ail, &arm_subst)
};
let fty = llvm_type(&bind_ail)?;
let off = 8 + idx as i64 * 8;
@@ -2079,6 +2168,26 @@ impl<'a> Emitter<'a> {
))
}
/// Iter 15b: resolve a `Term::Var` reference to a const def. Returns
/// `(owning_module, ConstDef)` on hit. Both bare current-module
/// references and qualified `prefix.name` cross-module references
/// resolve through the same path; the prefix routes through the
/// emitter's `import_map` to the actual module.
fn resolve_const(&self, name: &str) -> Option<(String, ConstDef)> {
if name.matches('.').count() == 1 {
let (prefix, suffix) = name.split_once('.')?;
let target = self.import_map.get(prefix)?;
let cdef = self.module_consts.get(target)?.get(suffix)?.clone();
return Some((target.clone(), cdef));
}
let cdef = self
.module_consts
.get(self.module_name)?
.get(name)?
.clone();
Some((self.module_name.to_string(), cdef))
}
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
@@ -2275,6 +2384,16 @@ impl<'a> Emitter<'a> {
{
return Ok(ty.clone());
}
// Iter 15b: const refs participate in arg-type
// synthesis. Bare or qualified, both forms route
// through `resolve_const` and yield the const's
// declared type. Const types are already qualified
// (the AST writes them in the consumer's namespace
// via `module.Type`), so no further qualification
// is needed.
if let Some((_, cdef)) = self.resolve_const(name) {
return Ok(cdef.ty);
}
if let Some(t) = builtin_ail_type(name) {
return Ok(t);
}
@@ -2337,6 +2456,13 @@ impl<'a> Emitter<'a> {
// Iter 15a: a qualified `type_name` resolves through the
// cross-module ctor index. The result `Type::Con.name`
// stays qualified to match what the typechecker emits.
// Iter 15b: when the ctor is cross-module, `cref.ail_fields`
// is written in the owning module's local namespace, so a
// recursive self-reference like `Cons a (List a)` carries
// a bare `Con("List", _)` even though every other place
// sees the qualified `std_list.List<...>`. Apply
// `qualify_local_types_codegen` before `unify_for_subst`
// so the unification doesn't fail on name mismatch.
let cref = self.lookup_ctor_by_type(type_name, ctor)?;
if cref.type_vars.is_empty() {
return Ok(Type::Con {
@@ -2344,6 +2470,20 @@ impl<'a> Emitter<'a> {
args: vec![],
});
}
let qualified_ail_fields: Vec<Type> = if type_name.matches('.').count() == 1 {
let (prefix, _) = type_name.split_once('.').expect("checked");
if let Some(target) = self.import_map.get(prefix) {
let owner_local_types = self.collect_owner_local_types(target);
cref.ail_fields
.iter()
.map(|f| qualify_local_types_codegen(f, target, &owner_local_types))
.collect()
} else {
cref.ail_fields.clone()
}
} else {
cref.ail_fields.clone()
};
let arg_tys: Vec<Type> = args
.iter()
.map(|a| self.synth_with_extras(a, extras))
@@ -2351,7 +2491,7 @@ impl<'a> Emitter<'a> {
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()) {
for (exp, actual) in qualified_ail_fields.iter().zip(arg_tys.iter()) {
unify_for_subst(exp, actual, &var_set, &mut subst)?;
}
// Vars not pinned by ctor args (e.g. `Nil` for `List a`,
@@ -2544,13 +2684,16 @@ fn unify_for_subst(
}
match (param, arg) {
(Type::Var { name }, _) if vars.contains(name.as_str()) => {
if let Some(prev) = subst.get(name) {
if prev != arg {
return Err(CodegenError::Internal(format!(
"monomorphisation: var `{name}` bound to two distinct types"
)));
}
return Ok(());
if let Some(prev) = subst.get(name).cloned() {
// Iter 15b: the previously-bound type may be more
// concrete than `arg` (e.g. `prev = List<Int>` from a
// sibling binding, `arg = List<$u>` from a synth-
// wildcard nullary ctor). Use recursive unification
// instead of strict equality so the inner `$u`
// wildcard matches `Int`. The previous strict-
// equality check rejected such overlaps as bogus
// duplicate bindings.
return unify_for_subst(&prev, arg, vars, subst);
}
subst.insert(name.clone(), arg.clone());
Ok(())