Files
AILang/docs/plans/0003-22b3-monomorphisation.md
T
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
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.
2026-05-28 13:31:31 +02:00

73 KiB
Raw Blame History

Iteration 22b.3 — Monomorphisation Pass — Implementation Plan

Parent spec: docs/specs/0002-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<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-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:

    //! 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_workspace

    Expected: FAIL — monomorphise_workspace does 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 20142034) that runs lift_letrecs per module and rebuilds ws. Immediately after the let 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_workspace

    Expected: PASS.

  • Step 1.6 — Build the workspace.

    Run: cargo build --workspace

    Expected: 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, 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:

    // 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:

    /// 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 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:

    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:

    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_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.

    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!(&params[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:

    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_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:

    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(&registry_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_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.

    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_module

    Expected: 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_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:

    /// 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_workspace after 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 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:

            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.

    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_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:

    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_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:

    // 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.

    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)

  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, 57), mono_symbol (Tasks 2, 4, 6), MonoTarget (Tasks 37), collect_mono_targets (Tasks 3, 5), collect_residuals_ordered (Task 6), synthesise_mono_fn (Tasks 45), build_workspace_env (Tasks 3, 56), rewrite_class_method_calls (Task 6). All match.

  4. Step granularity. Each step is 25 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.