From f7791e6cbdff806071eb6b04c9316ce819f50330 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 9 May 2026 20:08:01 +0200 Subject: [PATCH] plan: 22b.3 monomorphisation pass --- .../plans/2026-05-09-22b3-monomorphisation.md | 1985 +++++++++++++++++ 1 file changed, 1985 insertions(+) create mode 100644 docs/plans/2026-05-09-22b3-monomorphisation.md diff --git a/docs/plans/2026-05-09-22b3-monomorphisation.md b/docs/plans/2026-05-09-22b3-monomorphisation.md new file mode 100644 index 0000000..77dc1ae --- /dev/null +++ b/docs/plans/2026-05-09-22b3-monomorphisation.md @@ -0,0 +1,1985 @@ +# Iteration 22b.3 — Monomorphisation Pass — Implementation Plan + +> **Parent spec:** `docs/specs/2026-05-09-22-typeclasses.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` +> to 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`. 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-export + `mono::monomorphise_workspace`; expose helpers `mono.rs` needs + (`synth`, `Subst`, `is_fully_concrete`, `Env`, `ResidualConstraint`, + `substitute_rigids`) at `pub(crate)` if not already. +- **Modify:** `crates/ail/src/main.rs::build_to` — call + `monomorphise_workspace` after the `lift_letrecs` loop, before + `lower_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 run` synthetic fixture. +- **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 exclude `test_22b3_*` (surface parser + for class/instance defs still deferred to 22b.4). + +--- + +## Cross-task invariants (do not break) + +1. **Class-free workspaces are byte-identical through the pass.** + `monomorphise_workspace` on a workspace whose modules contain + no `Def::Class` and no `Def::Instance` returns 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. +2. **`CheckedModule.symbols` is never re-derived.** Same rule as + `lift_letrecs`. Synthesised FnDefs do NOT enter the canonical- + bytes symbol table — they are post-typecheck artefacts and + would break `ail diff` and `ail manifest` if hashed. +3. **The pass runs *after* `check_workspace` succeeds and *after* + `lift_letrecs`.** `mono.rs` re-uses `synth` for 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. +4. **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_hash` and + primitive types through a fixed surface-form table. +5. **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` (within `fn 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.rs` with this test: + + ```rust + //! 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 `/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::>(), + before.modules.keys().collect::>(), + "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_workspace` + + Expected: FAIL — `monomorphise_workspace` does not yet exist. + +- [ ] **Step 1.2 — Create `crates/ailang-check/src/mono.rs`.** + + ```rust + //! 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 { + // 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: + + ```rust + 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 runs + `lift_letrecs` per module and rebuilds `ws`. Immediately after + the `let ws = ailang_core::Workspace { ... };` rebind that + follows the lift loop, insert: + + ```rust + // 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_workspace` + + Expected: PASS. + +- [ ] **Step 1.6 — Build the workspace.** + + Run: `cargo build --workspace` + + Expected: clean build, no warnings. + +- [ ] **Step 1.7 — Commit.** + + ```bash + 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 `#<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`: + + ```rust + 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: + // # 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, the `mod mono;` declaration in `lib.rs` must be + `pub mod mono;` (or the symbol re-exported individually at the + crate root). Use `pub mod mono;` for this iter — the module's + internals are still gated by `pub(crate)`-or-narrower; only + `mono_symbol` and `monomorphise_workspace` are `pub`. + + Adjust `crates/ailang-check/src/lib.rs`: + + ```rust + // 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_symbol` not yet defined. + +- [ ] **Step 2.2 — Implement `mono_symbol` in `mono.rs`.** + + Append to `crates/ailang-check/src/mono.rs`: + + ```rust + /// Iter 22b.3: deterministic mono-symbol name for a `(method, + /// type)` pair. Primitive types (`Int`, `Bool`, `Str`, `Float`, + /// `Unit`) produce `#` for diagnostic and + /// ABI legibility. All other types — parameterised cons, + /// user-defined ADTs, function types — fall to + /// `#<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` (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.** + + ```bash + 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` 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 on + `synth`, `Subst`, `is_fully_concrete`, `ResidualConstraint`, + `substitute_rigids` if not already `pub(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`: + + ```rust + 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_site` + + Expected: FAIL — `MonoTarget`, `collect_mono_targets`, and + `build_workspace_env` not yet defined. + +- [ ] **Step 3.2 — Loosen visibility on `lib.rs`-internal helpers.** + + Open `crates/ailang-check/src/lib.rs`. Confirm or change to + `pub(crate)` if not already: + + - `pub(crate) fn synth(...)` — already `pub(crate)`. + - `pub(crate) struct ResidualConstraint` — already `pub(crate)`. + - `is_fully_concrete` — currently private; change to + `pub(crate) fn is_fully_concrete(t: &Type) -> bool`. + - `substitute_rigids` — currently private; change to + `pub(crate) fn substitute_rigids(...) -> Type`. + - `Subst` (struct) — already accessible via `crate::Subst` from + `lift.rs`; verify that `ResidualConstraint`, `Subst`, and the + `synth` signature all permit a sibling-module call from + `mono.rs`. + + No semantic changes; just visibility. + +- [ ] **Step 3.3 — Implement `MonoTarget` and the env builder in `mono.rs`.** + + Append to `crates/ailang-check/src/mono.rs`: + + ```rust + 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 = 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> { + // 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, 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, Vec) = 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 = IndexMap::new(); + for (n, t) in f.params.iter().zip(param_tys.iter()) { + locals.insert(n.clone(), t.clone()); + } + let mut effects: BTreeSet = BTreeSet::new(); + let mut subst = crate::Subst::default(); + let mut counter: u32 = 0; + let mut residuals: Vec = 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 = 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_globals` already exists at the + `lib.rs` level (called by `check_in_workspace`); confirm its + visibility is at least `pub(crate)`. If currently private, + promote it to `pub(crate)`. + +- [ ] **Step 3.4 — Run RED test → GREEN.** + + Run: `cargo test -p ail --test typeclass_22b3 + collect_mono_targets_single_concrete_call_site` + + Expected: PASS. + +- [ ] **Step 3.5 — Workspace build clean.** + + Run: `cargo build --workspace` + + Expected: clean build. + +- [ ] **Step 3.6 — Commit.** + + ```bash + 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`: + + ```rust + 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::Fn` literal construction must include + `param_modes: vec![]` — Decision 10 default for non-`borrow` + params. Confirm field name in `crates/ailang-core/src/ast.rs`.) + + Run: `cargo test -p ail --test typeclass_22b3 synthesise_mono_fn_` + + Expected: FAIL — `synthesise_mono_fn` not defined. + +- [ ] **Step 4.2 — Implement `synthesise_mono_fn` in `mono.rs`.** + + Append to `crates/ailang-check/src/mono.rs`: + + ```rust + 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 { + // 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 = 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, 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.** + + ```bash + 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`: + + ```rust + /// 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`: + + ```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_type` + + Expected: 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`: + + ```rust + pub fn monomorphise_workspace(ws: &Workspace) -> Result { + 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 = 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> { + let mut out: Vec = 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 { + 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_type` + + Expected: PASS. + +- [ ] **Step 5.4 — Re-run all 22b.3 tests so far.** + + Run: `cargo test -p ail --test typeclass_22b3` + + Expected: ALL PASS (5+ tests). + + Run: `cargo test -p ail --test typeclass_22b2` + + Expected: ALL PASS (12 tests, no regression from 22b.2). + +- [ ] **Step 5.5 — Commit.** + + ```bash + 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 `.`. 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`: + + ```rust + /// 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_module` + + Expected: FAIL — call site still says `show`. + +- [ ] **Step 6.2 — Implement the rewrite walk.** + + Append to `crates/ailang-check/src/mono.rs`: + + ```rust + 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 `.` (cross-module + /// resolution path); otherwise unqualified. + fn rewrite_class_method_calls( + body: &mut Term, + class_methods: &BTreeMap, + 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_types` argument named in the doc maps to + `ordered_targets` — Vec in *AST-traversal order* (one + entry per class-method `Term::Var` occurrence, including + duplicates). To produce that, change `collect_mono_targets` to + return targets in traversal order — which it already does + (residuals are appended by `synth` in pre-order). The filter + `is_fully_concrete` in `collect_mono_targets` would break the + alignment, so add a parallel collector that returns ALL residuals + (concrete or not) in order: + + Add to `mono.rs`: + + ```rust + /// 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>> { + // 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, Type) = match &f.ty { + Type::Forall { vars, constraints: _, body } => (vars.clone(), (**body).clone()), + other => (vec![], other.clone()), + }; + let param_tys: Vec = 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 = IndexMap::new(); + for (n, t) in f.params.iter().zip(param_tys.iter()) { + locals.insert(n.clone(), t.clone()); + } + let mut effects: BTreeSet = BTreeSet::new(); + let mut subst = crate::Subst::default(); + let mut counter: u32 = 0; + let mut residuals: Vec = Vec::new(); + crate::synth( + &f.body, + &env, + &mut locals, + &mut effects, + &f.name, + &mut subst, + &mut counter, + &mut residuals, + )?; + + let mut out: Vec> = 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_workspace` + *after* the fixpoint loop: + + ```rust + // 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 = 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#` — 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>, 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 = + 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 + `None` slots) signal "no rewrite at this position" — the walker + must still increment its cursor to keep alignment, so we emit a + sentinel `MonoTarget` with empty class/method names and the + walker checks for the empty-class case before mutating. Add the + emptiness guard at the top of the `Term::Var` arm: + + ```rust + 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_module` + + Expected: PASS. + +- [ ] **Step 6.4 — Re-run the entire 22b.2 + 22b.3 test suite.** + + Run: `cargo test -p ail --test typeclass_22b2` + + Expected: ALL PASS (12 tests, no regression). + + Run: `cargo test -p ail --test typeclass_22b3` + + Expected: ALL PASS (6+ tests). + +- [ ] **Step 6.5 — Re-run the workspace test suite to catch + regressions in unrelated crates.** + + Run: `cargo test --workspace` + + Expected: ALL PASS. + +- [ ] **Step 6.6 — Commit.** + + ```bash + 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`: + + ```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`: + + ```rust + /// 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_clean` + + Expected: PASS — fixture is well-typed by 22b.2's checks. + +- [ ] **Step 7.3 — RED test: `ail run` on the synthetic fixture + produces stdout `5\n`.** + + Append to `crates/ail/tests/typeclass_22b3.rs`: + + ```rust + 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_` 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_five` + + Expected: PASS — the rewrite has turned `foo` into `foo#Int`, + the synthesised fn `foo#Int (i: Int) -> Int = i` exists 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#Int` is illegal at codegen — `#` + is not a valid C-ABI char. If so, fix by switching to a + legal substitute (`__` or `$`) in `mono_symbol` and update + Tasks 2/4 tests. + c. The instance body's `Term::Var "i"` references the + `Type::Var "a"` declaration in `paramTypes`, but after + substitution the runtime expects `Int`. If the synthesised + def's `paramTypes` is propagated through to codegen via + `Lam`, fix by setting paramTypes/retType to the substituted + concrete types. (synthesise_mono_fn already substitutes + through `class_method.ty`, but the InstanceMethod's `body: + Lam { paramTypes: [Type::Var "a"] }` survives — see the + fixture above. Fix in synthesise_mono_fn: substitute + `paramTypes` and `retType` of the inner Lam against the + same `param := 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.rs` and the + fixture; re-run. + + In particular, for case (c): in `synthesise_mono_fn`, when the + body is `Term::Lam`, also substitute `paramTypes` and `retType` + through the same `mapping`: + + ```rust + // 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. Edit + `crates/ailang-surface/tests/round_trip.rs` skip-list: + + Locate the existing `test_22b2_` entry and add a + `test_22b3_` filter alongside. + + Run: `cargo test -p ailang-surface` + + Expected: PASS. + +- [ ] **Step 7.6 — Re-run the workspace test suite.** + + Run: `cargo test --workspace` + + Expected: ALL PASS. + +- [ ] **Step 7.7 — Run the bench gates.** + + Run: `python bench/check.py` + + Expected: green or `0` exit (no regression). + + Run: `python bench/compile_check.py` + + Expected: green or `0` exit. + + Run: `python bench/cross_lang.py` + + Expected: green or `0` exit (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.** + + ```bash + 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`: + +```markdown +## YYYY-MM-DD — Iteration 22b.3: Monomorphisation pass + + + +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) + +1. **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. + +2. **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. + +3. **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. + +4. **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. + +5. **Cross-task invariants** are listed at the top so the + implementer doesn't have to rediscover them from prose.