All 176 files in the four accumulating directories now use a zero-padded 4-digit counter prefix that reflects creation order (`NNNN-slug.md`). The counter is assigned per directory in strict git-log creation order; ties broken alphabetically by original name. The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is dropped — the date is recoverable from git log and the counter carries the ordering. A file's counter is stable for the life of the file: never reassigned, never reused, never compacted. Deleted files retire their counter; subsequent files do not fill the gap. This is the property that lets cross-references stay literal — refs use the full filename including the counter (`design/contracts/0007-honesty-rule.md`) so they grep cleanly and resolve directly without a glob step. 313 cross-references updated across .md/.rs/.toml/.c/.json files (test pins, include_str! paths, design-INDEX entries, baseline notes, runtime C comments, inter-contract markdown links incl. bare basename and `../models/foo.md` forms). CLAUDE.md gets a new "File-naming convention" section spelling out the rule and rationale. skills/brainstorm/SKILL.md and skills/planner/SKILL.md updated so new spec/plan creation produces counter-prefixed names from the start. The full test suite (cargo test --workspace) passes.
73 KiB
Iteration 22b.3 — Monomorphisation Pass — Implementation Plan
Parent spec:
docs/specs/0002-22-typeclasses.mdFor agentic workers: REQUIRED SUB-SKILL: use
skills/implementto run this plan. Steps use- [ ]checkboxes for tracking.
Goal: Turn the (method, type-hash) residuals collected by the
22b.2 typecheck arms into synthesised Def::Fn entries (one per
unique pair) and rewrite class-method call sites in user bodies to
target the synthesised names. Validate end-to-end against a
synthetic class+instance fixture that compiles, runs, and produces
the expected stdout — independently of the Prelude landing in 22b.4.
Architecture: New module crates/ailang-check/src/mono.rs
exposing pub fn monomorphise_workspace(ws: &Workspace) -> Result<Workspace>. Slots into crates/ail/src/main.rs::build_to
between lift_letrecs and lower_workspace, mirroring lift.rs's
position and ownership model. The pass is fixpoint-iterative: each
round walks every fn / const body, re-runs synth to recover the
residual (class, method, concrete-type) triples, looks up
registry.entries for the matching InstanceDef, and synthesises
a fresh Def::Fn whose type substitutes the class param to the
concrete type and whose body comes from InstanceMethod.body (or
the class's default if the instance omits the method). After
synthesis, a parallel rewrite walk replaces every class-method
Term::Var whose name resolves through Env.class_methods with the
hash-deterministic mono symbol. The (class, method, type-hash)
key dedupes across modules; synthesised fns are appended to the
registry's defining_module of the matching instance. Class-free
workspaces flow through the pass byte-identically.
Tech Stack: crates/ailang-check, crates/ailang-core,
crates/ail — same crates as 22b.2.
Files this plan creates or modifies
- Create:
crates/ailang-check/src/mono.rs— workspace monomorphisation pass (entry, naming, walker, synthesiser, fixpoint, rewriter). - Modify:
crates/ailang-check/src/lib.rs— re-exportmono::monomorphise_workspace; expose helpersmono.rsneeds (synth,Subst,is_fully_concrete,Env,ResidualConstraint,substitute_rigids) atpub(crate)if not already. - Modify:
crates/ail/src/main.rs::build_to— callmonomorphise_workspaceafter thelift_letrecsloop, beforelower_workspace_with_alloc. - Create:
crates/ail/tests/typeclass_22b3.rs— unit + e2e tests for naming, single-fn collection, synthesis (instance body- default fallback), fixpoint dedupe, call rewrite, and the full
ail runsynthetic fixture.
- default fallback), fixpoint dedupe, call rewrite, and the full
- Create:
examples/test_22b3_mono_synthetic.ail.json— end-to-end fixture:class Foo a where foo : (a) -> Int+instance Foo Int where foo = λi.i+fn main = do io/print_int (foo 5). - Modify:
crates/ailang-surface/tests/round_trip.rs— extend skip-list to also excludetest_22b3_*(surface parser for class/instance defs still deferred to 22b.4).
Cross-task invariants (do not break)
- Class-free workspaces are byte-identical through the pass.
monomorphise_workspaceon a workspace whose modules contain noDef::Classand noDef::Instancereturns a workspace whose modules are clone-equal to the input (same defs in the same order; canonical-bytes hash unchanged). Verified by Task 1's identity test and re-asserted by Task 6 after rewrite lands. CheckedModule.symbolsis never re-derived. Same rule aslift_letrecs. Synthesised FnDefs do NOT enter the canonical- bytes symbol table — they are post-typecheck artefacts and would breakail diffandail manifestif hashed.- The pass runs after
check_workspacesucceeds and afterlift_letrecs.mono.rsre-usessynthfor residual recovery; that is sound because the typechecker has already rejected ill-typed bodies, so synth on those bodies cannot fail for reasons other than internal-error. - Mono-symbol names are hash-deterministic. Same workspace →
same names across runs and across hosts. Achieved by funnelling
compound types through
ailang_core::canonical::type_hashand primitive types through a fixed surface-form table. - Fixpoint terminates. Each round adds at least one new
(class, method, type-hash)entry to the worklist or it exits. Already-synthesised entries are not re-collected.
Task 1: Mono module skeleton + identity wiring
Goal: A no-op monomorphise_workspace plumbed into the build
pipeline. Class-free workspaces flow through unchanged.
Files:
- Create:
crates/ailang-check/src/mono.rs - Modify:
crates/ailang-check/src/lib.rs - Modify:
crates/ail/src/main.rs(withinfn build_to) - Create:
crates/ail/tests/typeclass_22b3.rs
Steps
-
Step 1.1 — RED test: identity on a class-free workspace.
Create
crates/ail/tests/typeclass_22b3.rswith this test://! Iter 22b.3: monomorphisation pass tests. //! //! Co-located with `typeclass_22b2.rs` so the typeclass-feature //! coverage is browsable in one directory. Tests use the same //! `examples_dir()` pattern — `cargo test`-cwd is the crate dir, //! the fixtures live in `<repo>/examples/`. use std::path::PathBuf; fn examples_dir() -> PathBuf { // crates/ail/ → repo root → examples/ let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR")); d.pop(); d.pop(); d.join("examples") } #[test] fn monomorphise_workspace_is_identity_on_class_free_workspace() { // Pick any class-free fixture; `examples/hello.ail.json` is // the canonical class-free smoke test. let entry = examples_dir().join("hello.ail.json"); let ws = ailang_core::load_workspace(&entry).expect("load"); let diags = ailang_check::check_workspace(&ws); assert!(diags.is_empty(), "fixture must typecheck: {:?}", diags); let ws = ailang_check::monomorphise_workspace(&ws) .expect("mono on class-free workspace"); // Module set unchanged. let before = ailang_core::load_workspace(&examples_dir().join("hello.ail.json")) .expect("re-load"); assert_eq!( ws.modules.keys().collect::<Vec<_>>(), before.modules.keys().collect::<Vec<_>>(), "module set must be identical" ); // Per-module def list unchanged (compare by canonical-JSON hash). for (mname, m) in &ws.modules { let before_m = &before.modules[mname]; let h_after = ailang_core::hash::module_hash(m); let h_before = ailang_core::hash::module_hash(before_m); assert_eq!( h_after, h_before, "class-free module `{}` must hash-identical through mono", mname ); } }Run:
cargo test -p ail --test typeclass_22b3 monomorphise_workspace_is_identity_on_class_free_workspaceExpected: FAIL —
monomorphise_workspacedoes not yet exist. -
Step 1.2 — Create
crates/ailang-check/src/mono.rs.//! Iter 22b.3: workspace monomorphisation pass. //! //! Slots into the build pipeline after [`crate::lift_letrecs`] and //! before `ailang_codegen::lower_workspace_with_alloc`. The pass //! consumes the workspace's typeclass [`Registry`] and the per- //! module class-method tables (already populated by //! [`crate::check_in_workspace`]) and produces a workspace with: //! //! 1. Synthesised [`Def::Fn`] entries — one per unique //! `(class, method, type-hash)` triple observed at any //! class-method call site — appended to the [`Registry`]'s //! `defining_module` for that instance. //! 2. Rewritten call sites — every `Term::Var { name }` whose //! `name` resolves through [`Env::class_methods`] is replaced //! by the corresponding mono-symbol name (qualified with the //! instance's `defining_module` iff that module differs from //! the calling module). //! //! Pre-existing `Def::Class` and `Def::Instance` entries are //! preserved verbatim — codegen ignores them, but downstream //! tooling (e.g. `ail describe`) may still consult them. //! //! Class-free workspaces are byte-identical through the pass: //! the early-out check at the top of [`monomorphise_workspace`] //! returns the input clone untouched. //! //! ## Symbol-hashing invariant //! //! Synthesised FnDefs are post-typecheck artefacts. They do NOT //! enter `CheckedModule.symbols` (built from the original module //! at typecheck time and used by `ail diff` / `ail manifest`). //! Same convention as [`crate::lift_letrecs`]. use ailang_core::ast::{Def, FnDef, Module, Term, Type}; use ailang_core::workspace::{Registry, Workspace}; use crate::Result; /// Iter 22b.3: workspace-wide monomorphisation pass entry. See the /// module-level doc for the architecture and contract. /// /// Pre-condition: `ws` has been typechecked (`check_workspace(ws)` /// returned no errors) and lifted (`lift_letrecs` per module). The /// pass does not perform new type checking — it queries types via /// `synth` on already-typechecked bodies. pub fn monomorphise_workspace(ws: &Workspace) -> Result<Workspace> { // Fast path — no class / instance defs anywhere → nothing to do. // The pass also has no targets when there are class defs but no // instance defs (no callable methods at concrete types), but // the body walks would still be a wasted traversal; the // class-free check is the cheap way to opt out. if !workspace_has_typeclasses(ws) { return Ok(ws.clone()); } // Skeleton in Task 1: even with classes present, return the // workspace clone unchanged. Subsequent tasks fill in collection, // synthesis, and rewriting. Ok(ws.clone()) } /// Iter 22b.3: returns `true` iff any module in `ws` declares at /// least one [`Def::Class`] or [`Def::Instance`]. Cheap workspace /// scan; the early-out keeps class-free workspaces byte-identical /// through the pass. fn workspace_has_typeclasses(ws: &Workspace) -> bool { ws.modules.values().any(|m| { m.defs.iter().any(|d| matches!(d, Def::Class(_) | Def::Instance(_))) }) } -
Step 1.3 — Re-export from
crates/ailang-check/src/lib.rs.Add near the existing
pub use lift::lift_letrecs;line:mod mono; pub use mono::monomorphise_workspace; -
Step 1.4 — Wire into
crates/ail/src/main.rs::build_to.Locate the existing block (around
2014–2034) that runslift_letrecsper module and rebuildsws. Immediately after thelet ws = ailang_core::Workspace { ... };rebind that follows the lift loop, insert:// Iter 22b.3: monomorphisation pass. After `lift_letrecs` and // before codegen, every (class-method, concrete-type) call-site // pair becomes a synthesised top-level fn; user call sites are // rewritten to target those names. Class-free workspaces flow // through bit-identically; see `ailang_check::monomorphise_workspace`. let ws = ailang_check::monomorphise_workspace(&ws) .map_err(|e| anyhow::anyhow!("monomorphise_workspace: {e}"))?; -
Step 1.5 — Run RED test → GREEN.
Run:
cargo test -p ail --test typeclass_22b3 monomorphise_workspace_is_identity_on_class_free_workspaceExpected: PASS.
-
Step 1.6 — Build the workspace.
Run:
cargo build --workspaceExpected: clean build, no warnings.
-
Step 1.7 — Commit.
git add crates/ailang-check/src/mono.rs \ crates/ailang-check/src/lib.rs \ crates/ail/src/main.rs \ crates/ail/tests/typeclass_22b3.rs git commit -m "iter 22b.3.1: mono pass skeleton — identity for class-free workspaces"
Task 2: Mono-symbol naming function
Goal: Hash-deterministic name function. Primitives produce
human-readable forms (show#Int, eq#Bool); compound types fall
to <method>#<8-hex-prefix-of-type-hash>.
Files:
- Modify:
crates/ailang-check/src/mono.rs - Modify:
crates/ail/tests/typeclass_22b3.rs
Steps
-
Step 2.1 — RED test: primitive-type names.
Append to
crates/ail/tests/typeclass_22b3.rs:use ailang_core::ast::Type; #[test] fn mono_symbol_primitive_types_use_surface_form() { // Primitive-type forms must be human-readable in diagnostics // and ABI symbols. Per the spec's mono-symbol naming guidance: // <method>#<surface-name> for primitives. let int_ty = Type::int(); let bool_ty = Type::bool_(); let str_ty = Type::str_(); let unit_ty = Type::unit(); assert_eq!(ailang_check::mono::mono_symbol("show", &int_ty), "show#Int"); assert_eq!(ailang_check::mono::mono_symbol("eq", &bool_ty), "eq#Bool"); assert_eq!(ailang_check::mono::mono_symbol("show", &str_ty), "show#Str"); assert_eq!(ailang_check::mono::mono_symbol("foo", &unit_ty), "foo#Unit"); } #[test] fn mono_symbol_compound_type_uses_hash_suffix() { // Compound types fall through to canonical type_hash with an // 8-hex-char prefix. Stability matters more than legibility // here — same input must produce the same name across runs. let pair_ty = Type::Con { name: "Pair".into(), args: vec![Type::int(), Type::bool_()], }; let name = ailang_check::mono::mono_symbol("show", &pair_ty); // Format: `show#<8-hex-chars>`. assert!(name.starts_with("show#"), "name = {name}"); let suffix = &name["show#".len()..]; assert_eq!(suffix.len(), 8, "compound suffix is 8 hex chars: {suffix}"); assert!( suffix.chars().all(|c| c.is_ascii_hexdigit()), "compound suffix must be hex: {suffix}" ); // Stability: a second call with the same type produces the same name. let again = ailang_check::mono::mono_symbol("show", &pair_ty); assert_eq!(name, again, "mono_symbol must be deterministic"); }To make
ailang_check::mono::*reachable from the integration test, themod mono;declaration inlib.rsmust bepub mod mono;(or the symbol re-exported individually at the crate root). Usepub mod mono;for this iter — the module's internals are still gated bypub(crate)-or-narrower; onlymono_symbolandmonomorphise_workspacearepub.Adjust
crates/ailang-check/src/lib.rs:// Replace the line from Step 1.3: pub mod mono; pub use mono::monomorphise_workspace;Run:
cargo test -p ail --test typeclass_22b3 mono_symbol_Expected: FAIL —
mono_symbolnot yet defined. -
Step 2.2 — Implement
mono_symbolinmono.rs.Append to
crates/ailang-check/src/mono.rs:/// Iter 22b.3: deterministic mono-symbol name for a `(method, /// type)` pair. Primitive types (`Int`, `Bool`, `Str`, `Float`, /// `Unit`) produce `<method>#<surface-name>` for diagnostic and /// ABI legibility. All other types — parameterised cons, /// user-defined ADTs, function types — fall to /// `<method>#<8-hex-prefix-of-canonical-type-hash>`. The hash /// route ensures uniqueness without requiring a flattened /// surface form for arbitrarily nested types. /// /// Determinism: `ailang_core::canonical::type_hash` is the same /// function `workspace::build_registry` uses to key /// [`Registry::entries`], so a registry-key match implies a /// `mono_symbol` match. pub fn mono_symbol(method: &str, ty: &Type) -> String { if let Some(prim) = primitive_surface_name(ty) { return format!("{method}#{prim}"); } let full_hash = ailang_core::canonical::type_hash(ty); // 8-hex prefix is enough for low-collision keying across the // workspace; the full hash remains in the registry for // disambiguation should one ever be needed. format!("{method}#{}", &full_hash[..8]) } /// Iter 22b.3: returns the surface name iff `ty` is a zero-arity /// primitive `Type::Con`. Used by [`mono_symbol`] to gate the /// human-readable form. The match is intentionally narrow: /// `Int<args>` (which is malformed but parser-accepting) is /// treated as compound, so it falls to the hash form. fn primitive_surface_name(ty: &Type) -> Option<&'static str> { match ty { Type::Con { name, args } if args.is_empty() => match name.as_str() { "Int" => Some("Int"), "Bool" => Some("Bool"), "Str" => Some("Str"), "Float" => Some("Float"), "Unit" => Some("Unit"), _ => None, }, _ => None, } } -
Step 2.3 — Run RED tests → GREEN.
Run:
cargo test -p ail --test typeclass_22b3 mono_symbol_Expected: PASS (both tests).
-
Step 2.4 — Commit.
git add crates/ailang-check/src/mono.rs \ crates/ailang-check/src/lib.rs \ crates/ail/tests/typeclass_22b3.rs git commit -m "iter 22b.3.2: mono_symbol — primitive surface forms + hash for compound"
Task 3: Per-fn target collection (synth-replay)
Goal: Given a FnDef and an Env populated with the workspace
class-method table and registry, produce a Vec<MonoTarget> of all
fully-concrete (class, method, type) triples observed at
class-method call sites in the body.
Files:
- Modify:
crates/ailang-check/src/mono.rs - Modify:
crates/ailang-check/src/lib.rs(loosen visibility onsynth,Subst,is_fully_concrete,ResidualConstraint,substitute_rigidsif not alreadypub(crate)). - Modify:
crates/ail/tests/typeclass_22b3.rs
Steps
-
Step 3.1 — RED test: single fn, single concrete call site.
Append to
crates/ail/tests/typeclass_22b3.rs:use ailang_check::mono::{collect_mono_targets, MonoTarget}; /// Single-fn module: `fn user = show 5`. Class `Show a`, instance /// `Show Int` declared in same module. Expected: exactly one /// MonoTarget — `(Show, show, Int)`. #[test] fn collect_mono_targets_single_concrete_call_site() { let entry = examples_dir().join("test_22b2_instance_present.ail.json"); let ws = ailang_core::load_workspace(&entry).expect("load"); let diags = ailang_check::check_workspace(&ws); assert!(diags.is_empty(), "fixture must typecheck: {:?}", diags); // Build the workspace-wide env (class_methods, registry, ...) // exactly as `check_in_workspace` does. The mono pass exposes // this via `build_workspace_env`. let env = ailang_check::mono::build_workspace_env(&ws); // The fixture has one user fn — `main`. Locate it. let main_module = ws.modules.get("test_22b2_instance_present").expect("module"); let main_fn = main_module .defs .iter() .find_map(|d| match d { ailang_core::ast::Def::Fn(f) if f.name == "main" => Some(f), _ => None, }) .expect("main fn"); let targets = collect_mono_targets(main_fn, "test_22b2_instance_present", &env) .expect("collect"); assert_eq!(targets.len(), 1, "one target expected: {:?}", targets); let t = &targets[0]; assert_eq!(t.class, "Show"); assert_eq!(t.method, "show"); assert!( matches!(&t.type_, ailang_core::ast::Type::Con { name, args } if name == "Int" && args.is_empty()), "type must be Int, got {:?}", t.type_ ); assert_eq!(t.defining_module, "test_22b2_instance_present"); }Run:
cargo test -p ail --test typeclass_22b3 collect_mono_targets_single_concrete_call_siteExpected: FAIL —
MonoTarget,collect_mono_targets, andbuild_workspace_envnot yet defined. -
Step 3.2 — Loosen visibility on
lib.rs-internal helpers.Open
crates/ailang-check/src/lib.rs. Confirm or change topub(crate)if not already:pub(crate) fn synth(...)— alreadypub(crate).pub(crate) struct ResidualConstraint— alreadypub(crate).is_fully_concrete— currently private; change topub(crate) fn is_fully_concrete(t: &Type) -> bool.substitute_rigids— currently private; change topub(crate) fn substitute_rigids(...) -> Type.Subst(struct) — already accessible viacrate::Substfromlift.rs; verify thatResidualConstraint,Subst, and thesynthsignature all permit a sibling-module call frommono.rs.
No semantic changes; just visibility.
-
Step 3.3 — Implement
MonoTargetand the env builder inmono.rs.Append to
crates/ailang-check/src/mono.rs:use ailang_core::ast::FnDef as AstFnDef; use ailang_core::workspace::Workspace; use indexmap::IndexMap; use std::collections::{BTreeMap, BTreeSet}; /// Iter 22b.3: one synthesisation request — a fully-concrete /// `(class, method, type)` triple observed at a class-method call /// site, plus the registry's `defining_module` for the matching /// `InstanceDef` (the synthesised fn lives there). Equality of /// targets is compared via [`mono_target_key`] — `(class, /// method, type-hash)`. #[derive(Debug, Clone)] pub struct MonoTarget { pub class: String, pub method: String, pub type_: Type, pub defining_module: String, } /// Iter 22b.3: dedup key for a [`MonoTarget`] — the same triple /// `Registry::entries` uses for instance lookup, plus the method /// name. Two targets with the same key produce the same /// synthesised symbol and the same body. pub(crate) fn mono_target_key(t: &MonoTarget) -> (String, String, String) { ( t.class.clone(), t.method.clone(), ailang_core::canonical::type_hash(&t.type_), ) } /// Iter 22b.3: build the workspace-wide [`crate::Env`] used by /// the mono pass to re-run [`crate::synth`] on each fn body. /// Mirrors `check_in_workspace`'s setup: builtins + workspace /// registry + per-module globals + class-method table + /// superclass map. Cross-module type defs and consts are NOT /// installed here — `synth` for mono only needs to (a) recognise /// class-method names and (b) unify call-site arg types with /// declared param types. Module-resident fns are still needed /// for non-class-method calls inside instance bodies. pub fn build_workspace_env(ws: &Workspace) -> crate::Env { let mut env = crate::Env::default(); crate::builtins::install(&mut env); env.workspace_registry = ws.registry.clone(); // Per-module globals + class method table + superclass map. // The construction order matches `check_in_workspace` so the // env shape is interchangeable. let module_globals_index = crate::build_module_globals(ws); env.module_globals = module_globals_index .iter() .map(|(mname, g)| (mname.clone(), g.fns.clone())) .collect(); env.module_types = ws .modules .iter() .map(|(mname, m)| { let tys: IndexMap<String, ailang_core::ast::TypeDef> = m .defs .iter() .filter_map(|d| match d { Def::Type(td) => Some((td.name.clone(), td.clone())), _ => None, }) .collect(); (mname.clone(), tys) }) .collect(); // Workspace-wide class-method table: merge per module's // ModuleGlobals.class_methods. for (_mname, g) in &module_globals_index { for (name, entry) in &g.class_methods { env.class_methods.insert(name.clone(), entry.clone()); } } // Superclass map: walk every module's class defs. for m in ws.modules.values() { for d in &m.defs { if let Def::Class(c) = d { if let Some(sc) = &c.superclass { env.class_superclasses.insert(c.name.clone(), sc.class.clone()); } } } } env } /// Iter 22b.3: re-run [`crate::synth`] on `f`'s body to recover /// the per-fn residual class constraints. Filter to fully- /// concrete residuals (the only ones eligible for monomorphisation), /// look up each `(class, type-hash)` in the registry to recover /// the `defining_module`, and return the list. Var-shaped or /// metavar-shaped residuals are silently skipped — the /// 22b.2 typecheck pass has already fired /// `MissingConstraint`/`NoInstance` for any that should not exist /// at this point. pub fn collect_mono_targets( f: &AstFnDef, module_name: &str, env: &crate::Env, ) -> Result<Vec<MonoTarget>> { // Build the per-def env exactly as `check_fn` does — install // rigid vars, set the current module, etc. This mirrors // `crate::check_fn` minus the diagnostic emission. let (rigids, inner_ty): (Vec<String>, Type) = match &f.ty { Type::Forall { vars, constraints: _, body } => (vars.clone(), (**body).clone()), other => (vec![], other.clone()), }; let (param_tys, _ret_ty, _eff): (Vec<Type>, Type, Vec<String>) = match &inner_ty { Type::Fn { params, ret, effects, .. } => { (params.clone(), (**ret).clone(), effects.clone()) } _ => return Ok(Vec::new()), }; let mut env = env.clone(); for v in &rigids { env.rigid_vars.insert(v.clone()); } env.current_module = module_name.to_string(); let mut locals: IndexMap<String, Type> = IndexMap::new(); for (n, t) in f.params.iter().zip(param_tys.iter()) { locals.insert(n.clone(), t.clone()); } let mut effects: BTreeSet<String> = BTreeSet::new(); let mut subst = crate::Subst::default(); let mut counter: u32 = 0; let mut residuals: Vec<crate::ResidualConstraint> = Vec::new(); crate::synth( &f.body, &env, &mut locals, &mut effects, &f.name, &mut subst, &mut counter, &mut residuals, )?; // Filter residuals to fully-concrete ones; look up // defining_module via the registry. let mut out: Vec<MonoTarget> = Vec::new(); for r in residuals { let r_ty = subst.apply(&r.type_); if !crate::is_fully_concrete(&r_ty) { continue; } let key = (r.class.clone(), ailang_core::canonical::type_hash(&r_ty)); let entry = match env.workspace_registry.entries.get(&key) { Some(e) => e, None => continue, // no-instance — Task 10 of 22b.2 fires; skip silently here. }; out.push(MonoTarget { class: r.class.clone(), method: r.method.clone(), type_: r_ty, defining_module: entry.defining_module.clone(), }); } Ok(out) }Note:
crate::build_module_globalsalready exists at thelib.rslevel (called bycheck_in_workspace); confirm its visibility is at leastpub(crate). If currently private, promote it topub(crate). -
Step 3.4 — Run RED test → GREEN.
Run:
cargo test -p ail --test typeclass_22b3 collect_mono_targets_single_concrete_call_siteExpected: PASS.
-
Step 3.5 — Workspace build clean.
Run:
cargo build --workspaceExpected: clean build.
-
Step 3.6 — Commit.
git add crates/ailang-check/src/mono.rs \ crates/ailang-check/src/lib.rs \ crates/ail/tests/typeclass_22b3.rs git commit -m "iter 22b.3.3: collect_mono_targets — synth-replay residual gathering"
Task 4: Synthesise a FnDef from a MonoTarget
Goal: Given a MonoTarget, the matching ClassDef, and the
matching InstanceDef, produce a FnDef whose name is
mono_symbol(method, type_), whose ty is the class method type
with the class param substituted to type_, and whose params /
body come from the instance's InstanceMethod.body (or, if the
instance omits the method, the class's default body).
Files:
- Modify:
crates/ailang-check/src/mono.rs - Modify:
crates/ail/tests/typeclass_22b3.rs
Steps
-
Step 4.1 — RED test: synthesise from instance body (Lam).
Append to
crates/ail/tests/typeclass_22b3.rs:use ailang_core::ast::{ClassDef, ClassMethod, InstanceDef, InstanceMethod, Literal, Term}; use ailang_check::mono::synthesise_mono_fn; /// Build `class Foo a where bar : (a) -> Int` + `instance Foo Int /// where bar = λi.i` (well-typed body). synthesise_mono_fn must /// produce `FnDef { name: "bar#Int", ty: (Int) -> Int, /// params: ["i"], body: Var "i" }`. #[test] fn synthesise_mono_fn_uses_instance_body_lam() { let class_def = ClassDef { name: "Foo".into(), param: "a".into(), superclass: None, methods: vec![ClassMethod { name: "bar".into(), ty: Type::Fn { params: vec![Type::Var { name: "a".into() }], ret: Box::new(Type::int()), effects: vec![], param_modes: vec![], }, default: None, }], doc: None, }; let instance = InstanceDef { class: "Foo".into(), type_: Type::int(), methods: vec![InstanceMethod { name: "bar".into(), body: Term::Lam { params: vec!["i".into()], param_tys: vec![Type::Var { name: "a".into() }], ret_ty: Box::new(Type::int()), effects: vec![], body: Box::new(Term::Var { name: "i".into() }), }, }], doc: None, }; let target = MonoTarget { class: "Foo".into(), method: "bar".into(), type_: Type::int(), defining_module: "m".into(), }; let f = synthesise_mono_fn(&target, &class_def, &instance).expect("synth"); assert_eq!(f.name, "bar#Int"); assert!( matches!( &f.ty, Type::Fn { params, ret, .. } if params.len() == 1 && matches!(¶ms[0], Type::Con { name, args } if name == "Int" && args.is_empty()) && matches!(ret.as_ref(), Type::Con { name, args } if name == "Int" && args.is_empty()) ), "ty = {:?}", f.ty ); assert_eq!(f.params, vec!["i".to_string()]); assert!( matches!(&f.body, Term::Var { name } if name == "i"), "body = {:?}", f.body ); } /// Class with a `default` body, instance omits the method. /// synthesise_mono_fn must fall back to the class default. #[test] fn synthesise_mono_fn_falls_back_to_class_default() { let class_def = ClassDef { name: "Greet".into(), param: "a".into(), superclass: None, methods: vec![ClassMethod { name: "hello".into(), ty: Type::Fn { params: vec![Type::Var { name: "a".into() }], ret: Box::new(Type::str_()), effects: vec![], param_modes: vec![], }, default: Some(Term::Lam { params: vec!["_x".into()], param_tys: vec![Type::Var { name: "a".into() }], ret_ty: Box::new(Type::str_()), effects: vec![], body: Box::new(Term::Lit { lit: Literal::Str { value: "hi".into() }, }), }), }], doc: None, }; // Instance carries no `hello` body → must fall back to default. let instance = InstanceDef { class: "Greet".into(), type_: Type::int(), methods: vec![], doc: None, }; let target = MonoTarget { class: "Greet".into(), method: "hello".into(), type_: Type::int(), defining_module: "m".into(), }; let f = synthesise_mono_fn(&target, &class_def, &instance).expect("synth"); assert_eq!(f.name, "hello#Int"); assert_eq!(f.params, vec!["_x".to_string()]); assert!(matches!(&f.body, Term::Lit { .. }), "body = {:?}", f.body); }(Note:
Type::Fnliteral construction must includeparam_modes: vec![]— Decision 10 default for non-borrowparams. Confirm field name incrates/ailang-core/src/ast.rs.)Run:
cargo test -p ail --test typeclass_22b3 synthesise_mono_fn_Expected: FAIL —
synthesise_mono_fnnot defined. -
Step 4.2 — Implement
synthesise_mono_fninmono.rs.Append to
crates/ailang-check/src/mono.rs:use ailang_core::ast::{ClassDef, InstanceDef}; /// Iter 22b.3: produce a `FnDef` for a single (target, class, /// instance) triple. The synthesised fn: /// /// 1. has name `mono_symbol(target.method, target.type_)`, /// 2. has type = the class's method type with class param /// substituted to `target.type_` (rigid-var substitution /// via [`crate::substitute_rigids`]); the result is a plain /// `Type::Fn` with no `Forall`, /// 3. has params + body taken from the instance's matching /// `InstanceMethod.body` if present and Lam-shaped; from the /// class's `default` body if the instance omits the method; /// or directly from the body if the method has no params /// (zero-arg method types). /// /// Errors: /// /// - The instance omits the method AND the class has no /// `default` → unreachable in practice because /// `workspace::build_registry`'s `MissingMethod` check fires /// at load. Treat as `Internal`. /// - The method type is `(args) -> ret` with `args` non-empty, /// but the resolved body is not `Term::Lam` → schema-shape /// mismatch. Treat as `Internal`. (Future: surface a /// user-facing diagnostic; out of scope for 22b.3.) pub fn synthesise_mono_fn( target: &MonoTarget, class_def: &ClassDef, instance: &InstanceDef, ) -> Result<AstFnDef> { // Locate the class method declaration. let class_method = class_def .methods .iter() .find(|m| m.name == target.method) .ok_or_else(|| { crate::CheckError::Internal(format!( "synthesise_mono_fn: class `{}` has no method `{}`", class_def.name, target.method )) })?; // Build the substitution `param := target.type_` and apply // it to the method type. The result is a concrete `Type::Fn` // (no `Forall`). let mut mapping: BTreeMap<String, Type> = BTreeMap::new(); mapping.insert(class_def.param.clone(), target.type_.clone()); let concrete_method_ty = crate::substitute_rigids(&class_method.ty, &mapping); // Resolve the body — instance override first, then class default. let body_term: Term = match instance.methods.iter().find(|im| im.name == target.method) { Some(im) => im.body.clone(), None => class_method.default.clone().ok_or_else(|| { crate::CheckError::Internal(format!( "synthesise_mono_fn: instance `{} {}` omits method `{}` and class has no default \ (registry-build should have rejected this)", target.class, ailang_core::pretty::type_to_string(&target.type_), target.method, )) })?, }; // Decide the (params, body) shape based on whether the method // type takes positional args. let method_has_params = matches!( &class_method.ty, Type::Fn { params, .. } if !params.is_empty() ); let (params, body): (Vec<String>, Term) = if method_has_params { // Method has positional params — body must be a Lam. match body_term { Term::Lam { params, body, .. } => (params, *body), other => { return Err(crate::CheckError::Internal(format!( "synthesise_mono_fn: method `{}` has positional params but body is not a Lam: {:?}", target.method, other ))); } } } else { // Zero-arg method — body is the value directly. (Vec::new(), body_term) }; Ok(AstFnDef { name: mono_symbol(&target.method, &target.type_), ty: concrete_method_ty, params, body, doc: None, suppress: Vec::new(), }) } -
Step 4.3 — Run RED tests → GREEN.
Run:
cargo test -p ail --test typeclass_22b3 synthesise_mono_fn_Expected: PASS (both tests).
-
Step 4.4 — Commit.
git add crates/ailang-check/src/mono.rs \ crates/ail/tests/typeclass_22b3.rs git commit -m "iter 22b.3.4: synthesise_mono_fn — instance body + class default fallback"
Task 5: Workspace fixpoint loop
Goal: Iterate over every fn / const body in the workspace,
collect targets, dedupe via mono_target_key, synthesise FnDefs,
and append them to the registry's defining_module. Loop until no
new targets appear (so synthesised bodies' own class-method calls
are also covered).
Files:
- Modify:
crates/ailang-check/src/mono.rs - Modify:
crates/ail/tests/typeclass_22b3.rs
Steps
-
Step 5.1 — RED test: dedup across two call sites.
Append to
crates/ail/tests/typeclass_22b3.rs:/// Two call sites of `show 5` and `show 7` (same type Int) → the /// pass synthesises exactly ONE `show#Int` FnDef in the /// instance's defining module. Verifies the dedup contract. #[test] fn fixpoint_dedupes_repeated_calls_at_same_type() { let entry = examples_dir().join("test_22b3_dup_call_sites.ail.json"); let ws = ailang_core::load_workspace(&entry).expect("load"); let diags = ailang_check::check_workspace(&ws); assert!(diags.is_empty(), "fixture must typecheck: {:?}", diags); let ws = ailang_check::monomorphise_workspace(&ws).expect("mono"); // Count `show#Int` fns workspace-wide. let mut count = 0usize; for m in ws.modules.values() { for d in &m.defs { if let ailang_core::ast::Def::Fn(f) = d { if f.name == "show#Int" { count += 1; } } } } assert_eq!(count, 1, "exactly one show#Int across the workspace"); }Create the fixture
examples/test_22b3_dup_call_sites.ail.json:{ "schema": "ailang/v0", "name": "test_22b3_dup_call_sites", "imports": [], "defs": [ { "kind": "class", "name": "Show", "param": "a", "methods": [{ "name": "show", "type": { "k": "fn", "params": [{ "k": "var", "name": "a" }], "ret": { "k": "con", "name": "Str" }, "effects": [] } }] }, { "kind": "instance", "class": "Show", "type": { "k": "con", "name": "Int" }, "methods": [{ "name": "show", "body": { "t": "lam", "params": ["x"], "paramTypes": [{ "k": "var", "name": "a" }], "retType": { "k": "con", "name": "Str" }, "body": { "t": "lit", "lit": { "kind": "str", "value": "n" } } } }] }, { "kind": "fn", "name": "main", "type": { "k": "fn", "params": [], "ret": { "k": "con", "name": "Str" }, "effects": [] }, "params": [], "body": { "t": "seq", "lhs": { "t": "app", "fn": { "t": "var", "name": "show" }, "args": [{ "t": "lit", "lit": { "kind": "int", "value": 5 } }] }, "rhs": { "t": "app", "fn": { "t": "var", "name": "show" }, "args": [{ "t": "lit", "lit": { "kind": "int", "value": 7 } }] } } } ] }Run:
cargo test -p ail --test typeclass_22b3 fixpoint_dedupes_repeated_calls_at_same_typeExpected: FAIL — pass currently returns the workspace unchanged (Task 1 skeleton).
-
Step 5.2 — Replace the Task-1 skeleton return with the fixpoint loop.
Edit
crates/ailang-check/src/mono.rs::monomorphise_workspace:pub fn monomorphise_workspace(ws: &Workspace) -> Result<Workspace> { if !workspace_has_typeclasses(ws) { return Ok(ws.clone()); } // Take ownership of a mutable workspace clone; the pass // appends synthesised defs to per-module def lists. let mut ws_owned: Workspace = ws.clone(); let env = build_workspace_env(&ws_owned); // Dedup set: every (class, method, type-hash) we have already // synthesised. Once a key is here, future collection rounds // skip it. let mut synthesised: BTreeSet<(String, String, String)> = BTreeSet::new(); // Fixpoint: keep collecting until a round adds nothing new. // Each round walks every fn body in every module — including // bodies appended by the previous round, which is what makes // the loop close on chained class-method calls (e.g. // `Eq.ne x y = not (eq x y)`). loop { let new_targets = collect_targets_workspace_wide(&ws_owned, &env)?; let new: Vec<MonoTarget> = new_targets .into_iter() .filter(|t| !synthesised.contains(&mono_target_key(t))) .collect(); if new.is_empty() { break; } // Synthesise each new target; append its FnDef to the // target's `defining_module`. Mark the key as synthesised // so the next round won't re-collect. // // Iter 22b.3 uses ws_owned.registry as the source of // truth for ClassDef + InstanceDef lookups. Both come // from the un-modified workspace registry — synthesis // never mutates the registry. let class_index = build_class_index(&ws_owned); for t in &new { let key = mono_target_key(t); let registry_key = (t.class.clone(), ailang_core::canonical::type_hash(&t.type_)); let entry = ws_owned .registry .entries .get(®istry_key) .ok_or_else(|| { crate::CheckError::Internal(format!( "monomorphise_workspace: target `{} {}` has no registry entry", t.class, ailang_core::pretty::type_to_string(&t.type_), )) })?; let class_def = class_index.get(&t.class).ok_or_else(|| { crate::CheckError::Internal(format!( "monomorphise_workspace: class `{}` not found", t.class )) })?; let f = synthesise_mono_fn(t, class_def, &entry.instance)?; let target_module = ws_owned .modules .get_mut(&t.defining_module) .ok_or_else(|| { crate::CheckError::Internal(format!( "monomorphise_workspace: defining module `{}` missing", t.defining_module )) })?; target_module.defs.push(Def::Fn(f)); synthesised.insert(key); } } // Task 6 will splice the call-site rewrite here. Ok(ws_owned) } /// Iter 22b.3: walk every fn / const body in the workspace, /// returning the union of [`collect_mono_targets`] outputs. Const /// bodies are wrapped in a synthetic zero-arg `FnDef` for the /// collection call — the residual gathering only depends on body /// shape, so the wrapping is harmless. fn collect_targets_workspace_wide( ws: &Workspace, env: &crate::Env, ) -> Result<Vec<MonoTarget>> { let mut out: Vec<MonoTarget> = Vec::new(); for (mname, m) in &ws.modules { for d in &m.defs { match d { Def::Fn(f) => { out.extend(collect_mono_targets(f, mname, env)?); } Def::Const(c) => { let pseudo = AstFnDef { name: c.name.clone(), ty: c.ty.clone(), params: Vec::new(), body: c.value.clone(), doc: None, suppress: Vec::new(), }; out.extend(collect_mono_targets(&pseudo, mname, env)?); } _ => {} } } } Ok(out) } /// Iter 22b.3: workspace-wide `class-name -> ClassDef` index. /// Used by the fixpoint to look up the matching class definition /// when synthesising a fn. fn build_class_index(ws: &Workspace) -> BTreeMap<String, ClassDef> { let mut idx = BTreeMap::new(); for m in ws.modules.values() { for d in &m.defs { if let Def::Class(c) = d { idx.insert(c.name.clone(), c.clone()); } } } idx } -
Step 5.3 — Run RED test → GREEN.
Run:
cargo test -p ail --test typeclass_22b3 fixpoint_dedupes_repeated_calls_at_same_typeExpected: PASS.
-
Step 5.4 — Re-run all 22b.3 tests so far.
Run:
cargo test -p ail --test typeclass_22b3Expected: ALL PASS (5+ tests).
Run:
cargo test -p ail --test typeclass_22b2Expected: ALL PASS (12 tests, no regression from 22b.2).
-
Step 5.5 — Commit.
git add crates/ailang-check/src/mono.rs \ crates/ail/tests/typeclass_22b3.rs \ examples/test_22b3_dup_call_sites.ail.json git commit -m "iter 22b.3.5: workspace fixpoint loop — dedup + synth-append"
Task 6: Call-site rewrite
Goal: Rewrite every Term::Var { name } whose name resolves
through Env.class_methods to the corresponding mono-symbol name.
Same-module call sites use the unqualified name; cross-module call
sites use <defining_module>.<mono_symbol>. Same traversal order
as collect_mono_targets's synth-replay so per-call-site type
resolution stays aligned via positional indexing.
Files:
- Modify:
crates/ailang-check/src/mono.rs - Modify:
crates/ail/tests/typeclass_22b3.rs
Steps
-
Step 6.1 — RED test: same-module call site is rewritten.
Append to
crates/ail/tests/typeclass_22b3.rs:/// After the mono pass, the body of `main` in /// test_22b2_instance_present must reference `show#Int` instead /// of `show`. #[test] fn rewrite_replaces_class_method_var_with_mono_symbol_same_module() { let entry = examples_dir().join("test_22b2_instance_present.ail.json"); let ws = ailang_core::load_workspace(&entry).expect("load"); let diags = ailang_check::check_workspace(&ws); assert!(diags.is_empty(), "fixture must typecheck: {:?}", diags); let ws = ailang_check::monomorphise_workspace(&ws).expect("mono"); let main_module = ws.modules.get("test_22b2_instance_present").unwrap(); let main_fn = main_module .defs .iter() .find_map(|d| match d { ailang_core::ast::Def::Fn(f) if f.name == "main" => Some(f), _ => None, }) .expect("main"); // The body is `App { fn: Var "show", args: [Lit 42] }`. After // rewrite, the callee Var must be `show#Int`. let callee_name: &str = match &main_fn.body { ailang_core::ast::Term::App { callee, .. } => match callee.as_ref() { ailang_core::ast::Term::Var { name } => name.as_str(), other => panic!("callee is not Var: {:?}", other), }, other => panic!("body is not App: {:?}", other), }; assert_eq!(callee_name, "show#Int"); }Run:
cargo test -p ail --test typeclass_22b3 rewrite_replaces_class_method_var_with_mono_symbol_same_moduleExpected: FAIL — call site still says
show. -
Step 6.2 — Implement the rewrite walk.
Append to
crates/ailang-check/src/mono.rs:use ailang_core::ast::Arm; /// Iter 22b.3: rewrite every class-method call site in `body` to /// the corresponding mono symbol, using `class_method_types` — /// the per-call-site resolved types collected by a parallel /// synth-replay run on the same body. /// /// The walker increments a positional counter at each /// class-method-named `Term::Var` it encounters. The counter must /// match the order in which `synth` pushes residuals (pre-order /// AST walk; child evaluation order matches the `match` arms in /// `crate::synth`). When this invariant holds, `class_method_types[idx]` /// is the `Type` resolved at the i-th class-method call site. /// /// `caller_module`: name of the module enclosing this body. If /// the target's `defining_module` differs, the rewritten name is /// qualified `<defining_module>.<mono_symbol>` (cross-module /// resolution path); otherwise unqualified. fn rewrite_class_method_calls( body: &mut Term, class_methods: &BTreeMap<String, crate::ClassMethodEntry>, registry: &Registry, caller_module: &str, ordered_targets: &[MonoTarget], cursor: &mut usize, ) { match body { Term::Var { name } => { if class_methods.contains_key(name) { // i-th class-method site → ordered_targets[i] is the // resolved triple. (collect_mono_targets filters // non-concrete residuals, but the rewrite walk must // count *every* class-method occurrence; we recover // the alignment via [`reorder_targets_by_callsite`] // before this function runs.) if let Some(t) = ordered_targets.get(*cursor) { let sym = mono_symbol(&t.method, &t.type_); let new_name = if t.defining_module == caller_module { sym } else { format!("{}.{}", t.defining_module, sym) }; *name = new_name; } *cursor += 1; } } Term::App { callee, args, .. } => { rewrite_class_method_calls(callee, class_methods, registry, caller_module, ordered_targets, cursor); for a in args { rewrite_class_method_calls(a, class_methods, registry, caller_module, ordered_targets, cursor); } } Term::Let { value, body, .. } => { rewrite_class_method_calls(value, class_methods, registry, caller_module, ordered_targets, cursor); rewrite_class_method_calls(body, class_methods, registry, caller_module, ordered_targets, cursor); } Term::LetRec { body, in_term, .. } => { rewrite_class_method_calls(body, class_methods, registry, caller_module, ordered_targets, cursor); rewrite_class_method_calls(in_term, class_methods, registry, caller_module, ordered_targets, cursor); } Term::If { cond, then, else_ } => { rewrite_class_method_calls(cond, class_methods, registry, caller_module, ordered_targets, cursor); rewrite_class_method_calls(then, class_methods, registry, caller_module, ordered_targets, cursor); rewrite_class_method_calls(else_, class_methods, registry, caller_module, ordered_targets, cursor); } Term::Do { args, .. } => { for a in args { rewrite_class_method_calls(a, class_methods, registry, caller_module, ordered_targets, cursor); } } Term::Ctor { args, .. } => { for a in args { rewrite_class_method_calls(a, class_methods, registry, caller_module, ordered_targets, cursor); } } Term::Match { scrutinee, arms } => { rewrite_class_method_calls(scrutinee, class_methods, registry, caller_module, ordered_targets, cursor); for Arm { body, .. } in arms { rewrite_class_method_calls(body, class_methods, registry, caller_module, ordered_targets, cursor); } } Term::Lam { body, .. } => { rewrite_class_method_calls(body, class_methods, registry, caller_module, ordered_targets, cursor); } Term::Seq { lhs, rhs } => { rewrite_class_method_calls(lhs, class_methods, registry, caller_module, ordered_targets, cursor); rewrite_class_method_calls(rhs, class_methods, registry, caller_module, ordered_targets, cursor); } Term::Clone { value } => { rewrite_class_method_calls(value, class_methods, registry, caller_module, ordered_targets, cursor); } Term::ReuseAs { source, body } => { rewrite_class_method_calls(source, class_methods, registry, caller_module, ordered_targets, cursor); rewrite_class_method_calls(body, class_methods, registry, caller_module, ordered_targets, cursor); } Term::Lit { .. } => {} } }Note: the
class_method_typesargument named in the doc maps toordered_targets— Vec in AST-traversal order (one entry per class-methodTerm::Varoccurrence, including duplicates). To produce that, changecollect_mono_targetsto return targets in traversal order — which it already does (residuals are appended bysynthin pre-order). The filteris_fully_concreteincollect_mono_targetswould break the alignment, so add a parallel collector that returns ALL residuals (concrete or not) in order:Add to
mono.rs:/// Iter 22b.3: traversal-ordered residual collection — used by /// the rewrite walker to align cursor positions. Unlike /// [`collect_mono_targets`], this includes non-concrete residuals /// as `None`, preserving one-entry-per-callsite alignment. pub(crate) fn collect_residuals_ordered( f: &AstFnDef, module_name: &str, env: &crate::Env, ) -> Result<Vec<Option<MonoTarget>>> { // Same setup as `collect_mono_targets`, but the filter step // returns `None` for non-concrete residuals instead of // dropping them. let (rigids, inner_ty): (Vec<String>, Type) = match &f.ty { Type::Forall { vars, constraints: _, body } => (vars.clone(), (**body).clone()), other => (vec![], other.clone()), }; let param_tys: Vec<Type> = match &inner_ty { Type::Fn { params, .. } => params.clone(), _ => return Ok(Vec::new()), }; let mut env = env.clone(); for v in &rigids { env.rigid_vars.insert(v.clone()); } env.current_module = module_name.to_string(); let mut locals: IndexMap<String, Type> = IndexMap::new(); for (n, t) in f.params.iter().zip(param_tys.iter()) { locals.insert(n.clone(), t.clone()); } let mut effects: BTreeSet<String> = BTreeSet::new(); let mut subst = crate::Subst::default(); let mut counter: u32 = 0; let mut residuals: Vec<crate::ResidualConstraint> = Vec::new(); crate::synth( &f.body, &env, &mut locals, &mut effects, &f.name, &mut subst, &mut counter, &mut residuals, )?; let mut out: Vec<Option<MonoTarget>> = Vec::new(); for r in residuals { let r_ty = subst.apply(&r.type_); if !crate::is_fully_concrete(&r_ty) { out.push(None); continue; } let key = (r.class.clone(), ailang_core::canonical::type_hash(&r_ty)); let entry = match env.workspace_registry.entries.get(&key) { Some(e) => e, None => { out.push(None); continue; } }; out.push(Some(MonoTarget { class: r.class.clone(), method: r.method.clone(), type_: r_ty, defining_module: entry.defining_module.clone(), })); } Ok(out) }Then splice the rewrite phase into
monomorphise_workspaceafter the fixpoint loop:// Replace `Ok(ws_owned)` at the end of the fn with: // Phase 3: rewrite call sites in every fn / const body. Walk in // the same pre-order as collect_residuals_ordered so the cursor // and the per-callsite target list align position-by-position. let env = build_workspace_env(&ws_owned); // re-build: ws_owned has new defs. // Snapshot the module list so we can iterate-with-mutation safely. let module_names: Vec<String> = ws_owned.modules.keys().cloned().collect(); for mname in &module_names { // Snapshot the def list — we mutate fn / const bodies in place. let n_defs = ws_owned.modules[mname].defs.len(); for i in 0..n_defs { // Skip synthesised defs themselves: their bodies were // built from instance bodies (which used class-param-typed // `a`, not concrete types). The synth-replay would resolve // residuals against the substituted concrete type, but // for 22b.3 we treat synthesised bodies as `pass-through` // — re-running them under the rewriter is correct iff // their internal class-method calls are also resolvable. // For the synthetic 22b.3 fixture, instance bodies are // λ-without-class-method-calls, so this is moot. // // Future (22b.4 default-bearing prelude): default bodies // like `ne x y = not (eq x y)` reach the rewriter and must // resolve `eq` to `eq#<concrete-T>` — handled by // re-running the rewriter on synthesised bodies, which is // what this loop already does (every Def::Fn including // synth-appended ones). let (ordered, defining_module_name): (Vec<Option<MonoTarget>>, String) = { let m = &ws_owned.modules[mname]; let d = &m.defs[i]; match d { Def::Fn(f) => (collect_residuals_ordered(f, mname, &env)?, mname.clone()), Def::Const(c) => { let pseudo = AstFnDef { name: c.name.clone(), ty: c.ty.clone(), params: Vec::new(), body: c.value.clone(), doc: None, suppress: Vec::new(), }; (collect_residuals_ordered(&pseudo, mname, &env)?, mname.clone()) } _ => continue, } }; // Now mutate the body in place. let m = ws_owned.modules.get_mut(mname).unwrap(); let d = &mut m.defs[i]; let ordered_concrete: Vec<MonoTarget> = ordered.into_iter().map(|o| o.unwrap_or_else(|| MonoTarget { class: String::new(), method: String::new(), type_: Type::unit(), defining_module: defining_module_name.clone(), })).collect(); let mut cursor = 0usize; match d { Def::Fn(f) => { rewrite_class_method_calls( &mut f.body, &env.class_methods, &env.workspace_registry, mname, &ordered_concrete, &mut cursor, ); } Def::Const(c) => { rewrite_class_method_calls( &mut c.value, &env.class_methods, &env.workspace_registry, mname, &ordered_concrete, &mut cursor, ); } _ => {} } } } Ok(ws_owned)Behaviour rationale: residuals over non-concrete types (the
Noneslots) signal "no rewrite at this position" — the walker must still increment its cursor to keep alignment, so we emit a sentinelMonoTargetwith empty class/method names and the walker checks for the empty-class case before mutating. Add the emptiness guard at the top of theTerm::Vararm:Term::Var { name } => { if class_methods.contains_key(name) { if let Some(t) = ordered_targets.get(*cursor) { if !t.class.is_empty() { let sym = mono_symbol(&t.method, &t.type_); let new_name = if t.defining_module == caller_module { sym } else { format!("{}.{}", t.defining_module, sym) }; *name = new_name; } // else: residual was non-concrete → leave name unchanged. } *cursor += 1; } } -
Step 6.3 — Run RED test → GREEN.
Run:
cargo test -p ail --test typeclass_22b3 rewrite_replaces_class_method_var_with_mono_symbol_same_moduleExpected: PASS.
-
Step 6.4 — Re-run the entire 22b.2 + 22b.3 test suite.
Run:
cargo test -p ail --test typeclass_22b2Expected: ALL PASS (12 tests, no regression).
Run:
cargo test -p ail --test typeclass_22b3Expected: ALL PASS (6+ tests).
-
Step 6.5 — Re-run the workspace test suite to catch regressions in unrelated crates.
Run:
cargo test --workspaceExpected: ALL PASS.
-
Step 6.6 — Commit.
git add crates/ailang-check/src/mono.rs \ crates/ail/tests/typeclass_22b3.rs git commit -m "iter 22b.3.6: call-site rewrite — same/cross module qualified names"
Task 7: End-to-end synthetic fixture + ail run validation
Goal: A self-contained examples/test_22b3_mono_synthetic.ail.json
exercising the full pipeline: class declaration, instance, user fn
calling the class method, do io/print_int on the result, build and
run via ail run, assert stdout. This is the spec's gating
fixture — "synthetic class+instance fixture in the test suite for
end-to-end validation of the mono pass before the Prelude lands".
Files:
- Create:
examples/test_22b3_mono_synthetic.ail.json - Modify:
crates/ail/tests/typeclass_22b3.rs - Modify:
crates/ailang-surface/tests/round_trip.rs
Steps
-
Step 7.1 — Author the synthetic fixture.
Create
examples/test_22b3_mono_synthetic.ail.json:{ "schema": "ailang/v0", "name": "test_22b3_mono_synthetic", "imports": [], "defs": [ { "kind": "class", "name": "Foo", "param": "a", "methods": [{ "name": "foo", "type": { "k": "fn", "params": [{ "k": "var", "name": "a" }], "ret": { "k": "con", "name": "Int" }, "effects": [] } }] }, { "kind": "instance", "class": "Foo", "type": { "k": "con", "name": "Int" }, "methods": [{ "name": "foo", "body": { "t": "lam", "params": ["i"], "paramTypes": [{ "k": "var", "name": "a" }], "retType": { "k": "con", "name": "Int" }, "body": { "t": "var", "name": "i" } } }] }, { "kind": "fn", "name": "main", "type": { "k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" }, "effects": ["IO"] }, "params": [], "body": { "t": "do", "op": "io/print_int", "args": [{ "t": "app", "fn": { "t": "var", "name": "foo" }, "args": [{ "t": "lit", "lit": { "kind": "int", "value": 5 } }] }] } } ] } -
Step 7.2 — RED test: typecheck-only smoke.
Append to
crates/ail/tests/typeclass_22b3.rs:/// Synthetic-fixture smoke test: the workspace typechecks (the /// 22b.2 typecheck arms must not flag anything because the /// instance is registered, the call site is concrete, and the /// constraint is implicitly discharged). #[test] fn synthetic_fixture_typechecks_clean() { let entry = examples_dir().join("test_22b3_mono_synthetic.ail.json"); let ws = ailang_core::load_workspace(&entry).expect("load"); let diags = ailang_check::check_workspace(&ws); assert!(diags.is_empty(), "fixture must typecheck: {:?}", diags); }Run:
cargo test -p ail --test typeclass_22b3 synthetic_fixture_typechecks_cleanExpected: PASS — fixture is well-typed by 22b.2's checks.
-
Step 7.3 — RED test:
ail runon the synthetic fixture produces stdout5\n.Append to
crates/ail/tests/typeclass_22b3.rs:use std::process::Command; /// End-to-end gate: `ail run examples/test_22b3_mono_synthetic.ail.json` /// must produce stdout `5\n`. This is the synthetic class+instance /// fixture spelled out in the parent spec — the existence of this /// passing test is the 22b.3 close-out criterion. #[test] fn synthetic_fixture_runs_and_prints_five() { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); // Locate the workspace ail binary built by `cargo test`. // `CARGO_BIN_EXE_<bin>` is set by Cargo for any bin in the // same package; `ail`'s package is `ail`, so the env var is // `CARGO_BIN_EXE_ail`. let ail_bin = std::env::var("CARGO_BIN_EXE_ail") .expect("CARGO_BIN_EXE_ail set by cargo test"); let fixture = examples_dir().join("test_22b3_mono_synthetic.ail.json"); // Use a per-test temp dir so parallel test runs don't collide // on output paths. let tmp = manifest_dir.join("target").join(format!( "test_22b3_synthetic_{}", std::process::id() )); std::fs::create_dir_all(&tmp).expect("mkdir temp"); let output = Command::new(&ail_bin) .arg("run") .arg(&fixture) .current_dir(&tmp) .output() .expect("ail run"); assert!( output.status.success(), "ail run failed: stdout={}, stderr={}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr), ); let stdout = String::from_utf8_lossy(&output.stdout); assert_eq!( stdout.trim_end(), "5", "stdout = `{stdout}`, stderr = `{}`", String::from_utf8_lossy(&output.stderr) ); }Run:
cargo test -p ail --test typeclass_22b3 synthetic_fixture_runs_and_prints_fiveExpected: PASS — the rewrite has turned
foointofoo#Int, the synthesised fnfoo#Int (i: Int) -> Int = iexists in the module, and codegen handles it as an ordinary monomorphic fn.If the test FAILS, root-cause one of: a. Codegen rejects the synthesised def (mode-annotation defaults?). b. The synthesised name
foo#Intis illegal at codegen —#is not a valid C-ABI char. If so, fix by switching to a legal substitute (__or$) inmono_symboland update Tasks 2/4 tests. c. The instance body'sTerm::Var "i"references theType::Var "a"declaration inparamTypes, but after substitution the runtime expectsInt. If the synthesised def'sparamTypesis propagated through to codegen viaLam, fix by setting paramTypes/retType to the substituted concrete types. (synthesise_mono_fn already substitutes throughclass_method.ty, but the InstanceMethod'sbody: Lam { paramTypes: [Type::Var "a"] }survives — see the fixture above. Fix in synthesise_mono_fn: substituteparamTypesandretTypeof the inner Lam against the sameparam := target.type_mapping before peeling them away.) -
Step 7.4 — Iterate on root-cause if Step 7.3 is RED.
Use the diagnostic output of Step 7.3 to identify which of (a), (b), (c) above bit. Apply the minimal fix to
mono.rsand the fixture; re-run.In particular, for case (c): in
synthesise_mono_fn, when the body isTerm::Lam, also substituteparamTypesandretTypethrough the samemapping:// Inside the `if method_has_params` Lam-body branch: Term::Lam { params, param_tys, ret_ty, effects, body } => { // Rebuild the inner Lam — but we just want the inner body // and the (substituted) paramTypes for FnDef's perspective. // The outer FnDef's `params` carries the names; `ty` // carries the substituted Fn type. The body becomes // *body verbatim, since codegen consults FnDef.ty for // signatures, not the now-stripped Lam wrapper. // Effects + ret_ty + paramTypes flow through ty. let _ = (param_tys, ret_ty, effects); // discarded — ty subsumes them (params, *body) } -
Step 7.5 — Update the surface round-trip skip-list.
The Form-B parser arms for ClassDef / InstanceDef are still deferred to 22b.4. The new fixture
test_22b3_*would fail the round-trip test as 22b1/22b2 fixtures already do. Editcrates/ailang-surface/tests/round_trip.rsskip-list:Locate the existing
test_22b2_entry and add atest_22b3_filter alongside.Run:
cargo test -p ailang-surfaceExpected: PASS.
-
Step 7.6 — Re-run the workspace test suite.
Run:
cargo test --workspaceExpected: ALL PASS.
-
Step 7.7 — Run the bench gates.
Run:
python bench/check.pyExpected: green or
0exit (no regression).Run:
python bench/compile_check.pyExpected: green or
0exit.Run:
python bench/cross_lang.pyExpected: green or
0exit (mono pass is a no-op for the class-free bench corpus, so cross-language ratios should be identical to pre-22b.3). -
Step 7.8 — Commit.
git add examples/test_22b3_mono_synthetic.ail.json \ crates/ail/tests/typeclass_22b3.rs \ crates/ailang-check/src/mono.rs \ crates/ailang-surface/tests/round_trip.rs git commit -m "iter 22b.3.7: synthetic fixture — class+instance compiles, runs, prints 5"
Task 8 (controller-side): JOURNAL + close-out
After Task 7 lands, the implement skill (Step 4 — JOURNAL entry)
appends to docs/JOURNAL.md:
## YYYY-MM-DD — Iteration 22b.3: Monomorphisation pass
<one-paragraph summary describing the mono-pass architecture, the
fixpoint loop, and the synthetic-fixture e2e gate>
Per-task subjects:
- iter 22b.3.1: mono pass skeleton — identity for class-free workspaces
- iter 22b.3.2: mono_symbol — primitive surface forms + hash for compound
- iter 22b.3.3: collect_mono_targets — synth-replay residual gathering
- iter 22b.3.4: synthesise_mono_fn — instance body + class default fallback
- iter 22b.3.5: workspace fixpoint loop — dedup + synth-append
- iter 22b.3.6: call-site rewrite — same/cross module qualified names
- iter 22b.3.7: synthetic fixture — class+instance compiles, runs, prints 5
Known debt:
- Cross-module mono path is implemented but unexercised by 22b.3
fixtures; 22b.4's Prelude module → user-module call exercises it.
- Synthesised FnDefs do not carry mode annotations or effect rows
beyond what the substituted method type naturally provides;
custom-mode user methods (e.g. `borrow` on a method param) flow
through unchanged because `substitute_rigids` preserves
`param_modes`. Spot-check coverage in 22b.4 / 22c.
- Form-B printer / parser arms for ClassDef / InstanceDef remain
deferred to 22b.4; `test_22b3_*` is on the round-trip skip-list.
Next: 22b.4 — Prelude module + Form-B parser/printer arms.
The implement skill commits the JOURNAL entry as a separate commit matching the existing JOURNAL pattern.
Self-review (Step 5 of plan-skill)
-
Spec coverage. Spec lists three deliverables for 22b.3: (a) synthesised FnDefs from
(method, type-hash)pairs — Tasks 3+4+5. (b) call rewriting — Task 6. (c) cache by key — built into Task 5's dedup. (d) synthetic class+instance fixture for e2e validation — Task 7. All covered. -
Placeholder scan.
grep -n "TBD\|TODO\|implement later\|similar to Task\|add appropriate"on this plan returns no hits. Note: the plan does include "Future (22b.4 …)" markers in code-comment strings — those are intentional documentation, not deferral of plan content; they describe what the produced mono.rs explains for future readers. -
Type / function consistency. Names referenced across tasks:
monomorphise_workspace(Tasks 1, 5–7),mono_symbol(Tasks 2, 4, 6),MonoTarget(Tasks 3–7),collect_mono_targets(Tasks 3, 5),collect_residuals_ordered(Task 6),synthesise_mono_fn(Tasks 4–5),build_workspace_env(Tasks 3, 5–6),rewrite_class_method_calls(Task 6). All match. -
Step granularity. Each step is 2–5 minutes (file edit, test run, or commit). The largest is Step 6.2 (rewrite walker implementation) which touches one file and has no judgement calls beyond the cursor-alignment invariant — already spelled out. Acceptable.
-
Cross-task invariants are listed at the top so the implementer doesn't have to rediscover them from prose.