diff --git a/docs/plans/2026-05-11-iter-23.4.md b/docs/plans/2026-05-11-iter-23.4.md new file mode 100644 index 0000000..7be6194 --- /dev/null +++ b/docs/plans/2026-05-11-iter-23.4.md @@ -0,0 +1,1281 @@ +# Iter 23.4 — Mono-Pass Unification — Implementation Plan + +> **Parent spec:** `docs/specs/2026-05-11-23-eq-ord-prelude.md` (commit 841d65d, re-grounded 2026-05-11 against main HEAD 51da9fa, PASS) +> +> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` to run this plan. Steps use `- [ ]` checkboxes for tracking. The implementer does NOT commit — work accumulates in the working tree; the Boss commits the iter at the end. + +**Goal:** Restructure `crates/ailang-check/src/mono.rs::monomorphise_workspace` into one specialiser for every `Type::Forall`-quantified `Def::Fn` (class-method residuals + polymorphic free fns) in one fixpoint pass, and remove the codegen-time specialiser (`lower_polymorphic_call` + `module_polymorphic_fns` + `mono_queue`) entirely. + +**Architecture:** The unified pass has two source-body entry points sharing rigid-substitution + fixpoint + cache + rewrite mechanics. Class-method entry looks up bodies via `Registry::entries`; free-fn entry takes bodies directly from the polymorphic `Def::Fn`. After the pass, codegen sees only monomorphic `Def::Fn`s — no polymorphic call sites, no codegen-time specialisation machinery. + +**Tech Stack:** `ailang-check` (mono.rs extension), `ailang-codegen` (lib.rs cleanup), `ailang-core` (canonical hash pin pattern), AILang fixtures + workspace-level integration tests under `crates/ail/tests/`. + +--- + +## Files this plan creates or modifies + +**Create:** + +- `examples/cmp_max_smoke.ail.json` — composition fixture: `fn cmp_max : forall a. Ord a => (a, a) -> a` invoked at Int, proves cross-cutting in one fixpoint +- `examples/mono_hash_pin_smoke.ail.json` — auxiliary fixture exercising all six existing primitive Eq/Ord mono symbols (`eq__Int`/`Bool`/`Str`, `compare__Int`/`Bool`/`Str`) so the hash-stability test can pin them in one workspace load +- `crates/ail/tests/mono_unification.rs` — workspace-level tests for free-fn mono behaviour (the cmp_max_smoke RED → GREEN test and the chained-fixpoint extension) +- `crates/ail/tests/mono_hash_stability.rs` — regression pin asserting the six primitive mono-symbol body hashes stay bit-stable under the unified pass + +**Modify:** + +- `crates/ailang-check/src/mono.rs:1-52` — module docstring (extend to cover free-fn entry) +- `crates/ailang-check/src/mono.rs:67-217` — `monomorphise_workspace` (rebuild fixpoint+rewrite for both target kinds) +- `crates/ailang-check/src/mono.rs:224-252` — `collect_targets_workspace_wide` (call new free-fn collector arm) +- `crates/ailang-check/src/mono.rs:291-295` — `workspace_has_typeclasses` (generalise predicate) +- `crates/ailang-check/src/mono.rs:297-343` — `mono_symbol` + `primitive_surface_name` (extend for N-ary type vars) +- `crates/ailang-check/src/mono.rs:345-372` — `MonoTarget` struct + `mono_target_key` (widen for free-fn case) +- `crates/ailang-check/src/mono.rs:413-511` — `collect_mono_targets` (add free-fn arm) +- `crates/ailang-check/src/mono.rs:513-606` — `synthesise_mono_fn` (route by target kind to class or free-fn source body) +- `crates/ailang-check/src/mono.rs:608-770` — `rewrite_class_method_calls` (rename to `rewrite_mono_calls`, fire on bare poly free-fn names too) +- `crates/ailang-check/src/mono.rs:793-870` — `collect_residuals_ordered` (lockstep-extend for free-fn rewrites) +- `crates/ailang-codegen/src/lib.rs:52` — `use` import (drop `apply_subst_to_term`, `descriptor_for_subst`) +- `crates/ailang-codegen/src/lib.rs:285-352` — Pass-1 comment + `module_polymorphic_fns` population (delete) +- `crates/ailang-codegen/src/lib.rs:363,399` — `module_polymorphic_fns` insert + pass-into-Emitter (delete) +- `crates/ailang-codegen/src/lib.rs:558-566` — Emitter fields `module_polymorphic_fns`, `mono_queue`, `mono_emitted` (delete) +- `crates/ailang-codegen/src/lib.rs:726,777-779` — `Emitter::new` param + init (delete) +- `crates/ailang-codegen/src/lib.rs:839-850` — mono drain loop (delete) +- `crates/ailang-codegen/src/lib.rs:888-950` — `emit_specialised_fn` (delete) +- `crates/ailang-codegen/src/lib.rs:1939-1945` — call-site #1 of `lower_polymorphic_call` (delete; flow falls through to user-fn path) +- `crates/ailang-codegen/src/lib.rs:1965-1973` — call-site #2 (delete) +- `crates/ailang-codegen/src/lib.rs:1990-2104` — `lower_polymorphic_call` function (delete) +- `crates/ailang-codegen/src/lib.rs:2253-2263` — `is_static_callee` (collapse poly-path arm) +- `docs/DESIGN.md:1513-1556` — §"Monomorphisation (post-typecheck, pre-codegen)" amendment + +--- + +## Open commitments resolved by this plan + +The spec lists four implementer-decisions. This plan resolves the first three; the fourth is out of scope for 23.4. + +1. **Mono-symbol naming for N-ary type vars.** Use the spec default: `______…` in `Forall.vars` declaration order. The existing `mono_symbol` helper at `mono.rs:297-328` is extended to accept a slice of types; today's single-type call sites pass a one-element slice and produce identical output (no hash drift for existing class-method mono symbols). +2. **Target-collector heuristic.** Extend the existing `collect_mono_targets` (mono.rs:413-511) with a free-fn arm — same walker, same cursor invariant, new branch. Rationale: lockstep with `collect_residuals_ordered` (mono.rs:793) which must be extended in identical shape so the positional-cursor rewrite invariant survives. A separate walker would double the walking cost and force the cursor invariant to be maintained across two pairs. +3. **Original Free-Fn `Def::Fn` retention post-mono.** Keep the original polymorphic `Def::Fn` in the workspace, parallel to `Def::Class` / `Def::Instance` retention (mono.rs:40-42). Codegen skips them via `is_static_callee`'s existing `Type::Forall` check (which today filters poly fns out of static-call lowering); after this iter, the static-call path resolves the rewritten mono-symbol name instead. +4. **Float-NoInstance diagnostic wording.** OUT OF SCOPE for 23.4. Deferred to 23.5 where the negative E2E fixture exercises it. + +--- + +## Tasks + +### Task 1: Hash-stability regression pin + +The unified pass must produce bit-identical mono-symbol bodies for the six existing primitive Eq/Ord symbols. This task adds the pin BEFORE the refactor so the pin starts green; if the refactor in tasks 3–8 perturbs a mono body, this test fails and the implementer must investigate. + +**Files:** +- Create: `examples/mono_hash_pin_smoke.ail.json` — fixture exercising all six primitive mono symbols +- Create: `crates/ail/tests/mono_hash_stability.rs` — the pin test +- Test name: `primitive_eq_ord_mono_symbol_hashes_stay_bit_identical` + +- [ ] **Step 1.1: Create the smoke fixture** + +Write `examples/mono_hash_pin_smoke.ail.json` with content: + +```json +{ + "schema": "ailang/v0", + "name": "mono_hash_pin_smoke", + "imports": [{ "module": "prelude" }], + "defs": [ + { + "kind": "fn", + "name": "main", + "type": { + "k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" }, + "effects": ["IO"] + }, + "params": [], + "body": { + "t": "seq", + "lhs": { + "t": "do", "op": "io/print_bool", + "args": [{ + "t": "app", "fn": { "t": "var", "name": "eq" }, + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 1 } }, + { "t": "lit", "lit": { "kind": "int", "value": 1 } } + ] + }] + }, + "rhs": { + "t": "seq", + "lhs": { + "t": "do", "op": "io/print_bool", + "args": [{ + "t": "app", "fn": { "t": "var", "name": "eq" }, + "args": [ + { "t": "lit", "lit": { "kind": "bool", "value": true } }, + { "t": "lit", "lit": { "kind": "bool", "value": true } } + ] + }] + }, + "rhs": { + "t": "seq", + "lhs": { + "t": "do", "op": "io/print_bool", + "args": [{ + "t": "app", "fn": { "t": "var", "name": "eq" }, + "args": [ + { "t": "lit", "lit": { "kind": "str", "value": "a" } }, + { "t": "lit", "lit": { "kind": "str", "value": "a" } } + ] + }] + }, + "rhs": { + "t": "seq", + "lhs": { + "t": "do", "op": "io/print_int", + "args": [{ + "t": "app", "fn": { "t": "var", "name": "ord_to_int" }, + "args": [{ + "t": "app", "fn": { "t": "var", "name": "compare" }, + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 1 } }, + { "t": "lit", "lit": { "kind": "int", "value": 2 } } + ] + }] + }] + }, + "rhs": { + "t": "seq", + "lhs": { + "t": "do", "op": "io/print_int", + "args": [{ + "t": "app", "fn": { "t": "var", "name": "ord_to_int" }, + "args": [{ + "t": "app", "fn": { "t": "var", "name": "compare" }, + "args": [ + { "t": "lit", "lit": { "kind": "bool", "value": false } }, + { "t": "lit", "lit": { "kind": "bool", "value": true } } + ] + }] + }] + }, + "rhs": { + "t": "do", "op": "io/print_int", + "args": [{ + "t": "app", "fn": { "t": "var", "name": "ord_to_int" }, + "args": [{ + "t": "app", "fn": { "t": "var", "name": "compare" }, + "args": [ + { "t": "lit", "lit": { "kind": "str", "value": "a" } }, + { "t": "lit", "lit": { "kind": "str", "value": "b" } } + ] + }] + }] + } + } + } + } + } + } + }, + { + "kind": "fn", + "name": "ord_to_int", + "type": { + "k": "fn", + "params": [{ "k": "con", "name": "Ordering" }], + "ret": { "k": "con", "name": "Int" }, + "effects": [] + }, + "params": ["o"], + "body": { + "t": "match", + "scrutinee": { "t": "var", "name": "o" }, + "arms": [ + { "pat": { "p": "con", "name": "LT", "args": [] }, "body": { "t": "lit", "lit": { "kind": "int", "value": -1 } } }, + { "pat": { "p": "con", "name": "EQ", "args": [] }, "body": { "t": "lit", "lit": { "kind": "int", "value": 0 } } }, + { "pat": { "p": "con", "name": "GT", "args": [] }, "body": { "t": "lit", "lit": { "kind": "int", "value": 1 } } } + ] + } + } + ] +} +``` + +Note: implementer may simplify the AST shape (e.g. switch `seq` chains to a `let`-block) if the prelude convention differs from this draft. The mono-symbol output is the test target, not the source shape. + +- [ ] **Step 1.2: Write the pin test (must be green from day one)** + +Create `crates/ail/tests/mono_hash_stability.rs` with content: + +```rust +//! Regression pin for primitive Eq/Ord mono-symbol body hashes. +//! +//! Locks the six primitive mono symbols (`eq__Int`, `eq__Bool`, `eq__Str`, +//! `compare__Int`, `compare__Bool`, `compare__Str`) to their pre-iter-23.4 +//! body hashes. The unified mono pass MUST produce bit-identical bodies. + +use ailang_core::ast::Def; +use ailang_core::hash::def_hash; +use ailang_core::workspace::load_workspace; + +#[test] +fn primitive_eq_ord_mono_symbol_hashes_stay_bit_identical() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture = manifest_dir.join("../../examples/mono_hash_pin_smoke.ail.json"); + let ws = load_workspace(&fixture).expect("workspace loads"); + let diags = ailang_check::check_workspace(&ws); + assert!(diags.is_empty(), "typecheck diagnostics: {diags:?}"); + let post_mono = ailang_check::monomorphise_workspace(&ws).expect("mono green"); + + let main_mod = post_mono + .modules + .iter() + .find(|m| m.name == "mono_hash_pin_smoke") + .expect("main module present"); + + let pins = [ + ("eq__Int", "PIN_eq_Int"), + ("eq__Bool", "PIN_eq_Bool"), + ("eq__Str", "PIN_eq_Str"), + ("compare__Int", "PIN_compare_Int"), + ("compare__Bool","PIN_compare_Bool"), + ("compare__Str", "PIN_compare_Str"), + ]; + + for (sym, pin_label) in pins { + let def = main_mod + .defs + .iter() + .find(|d| matches!(d, Def::Fn(f) if f.name == sym)) + .unwrap_or_else(|| panic!("mono symbol {sym} not found in workspace")); + let h = def_hash(def); + // First run: capture the actual hash via `eprintln!`; commit the + // pinned value, then remove the eprintln. + eprintln!("{pin_label}: {h}"); + // Once captured, replace the next line with the captured 16-hex hash: + assert_eq!(h, "<>", "{sym} body hash drifted"); + } +} +``` + +Implementer note: the `<>` placeholders are filled during Step 1.4. The public APIs used here are `ailang_core::workspace::load_workspace` (auto-injects the prelude per `crates/ailang-core/src/workspace.rs:442-453`), `ailang_check::check_workspace` (returns `Vec`, NOT `Result`), `ailang_check::monomorphise_workspace` (re-exported at root, returns `Result`), and `ailang_core::hash::def_hash`. The existing test pattern in `crates/ail/tests/typeclass_22b3.rs:20-30` is the canonical mirror. + +- [ ] **Step 1.3: Run the test to capture the six hashes** + +Run: `cargo test -p ail --test mono_hash_stability primitive_eq_ord_mono_symbol_hashes_stay_bit_identical -- --nocapture` + +Expected: test FAILS at the first `assert_eq!` (placeholder `<>` does not match), but `eprintln!` prints the six actual hashes on stderr. + +- [ ] **Step 1.4: Fill the pins and re-run** + +Edit `crates/ail/tests/mono_hash_stability.rs`: replace each `<>` with the actual 16-hex hash captured in Step 1.3. Also delete the `eprintln!` line (only needed for capture). + +Run: `cargo test -p ail --test mono_hash_stability primitive_eq_ord_mono_symbol_hashes_stay_bit_identical` + +Expected: PASS. This test is now the regression pin for tasks 3–8. + +--- + +### Task 2: cmp_max_smoke fixture + RED workspace-level test + +Add the cross-cutting fixture from spec §Testing strategy §23.4 (b). Asserts that after `monomorphise_workspace`, the workspace contains both `cmp_max__Int` and `compare__Int` as `Def::Fn`. Today this is RED because `cmp_max` is a polymorphic free fn handled by codegen, not by mono. + +**Files:** +- Create: `examples/cmp_max_smoke.ail.json` — composition fixture +- Create: `crates/ail/tests/mono_unification.rs` — new integration-test file +- Test name in that file: `cmp_max_smoke_produces_both_mono_symbols_in_one_fixpoint` + +- [ ] **Step 2.1: Create the fixture** + +Write `examples/cmp_max_smoke.ail.json`: + +```json +{ + "schema": "ailang/v0", + "name": "cmp_max_smoke", + "imports": [{ "module": "prelude" }], + "defs": [ + { + "kind": "fn", + "name": "cmp_max", + "type": { + "k": "forall", + "vars": ["a"], + "constraints": [{ "class": "Ord", "type": { "k": "var", "name": "a" } }], + "body": { + "k": "fn", + "params": [{ "k": "var", "name": "a" }, { "k": "var", "name": "a" }], + "ret": { "k": "var", "name": "a" }, + "effects": [] + } + }, + "params": ["x", "y"], + "body": { + "t": "match", + "scrutinee": { + "t": "app", + "fn": { "t": "var", "name": "compare" }, + "args": [{ "t": "var", "name": "x" }, { "t": "var", "name": "y" }] + }, + "arms": [ + { "pat": { "p": "con", "name": "LT", "args": [] }, "body": { "t": "var", "name": "y" } }, + { "pat": { "p": "wild" }, "body": { "t": "var", "name": "x" } } + ] + } + }, + { + "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": "cmp_max" }, + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 3 } }, + { "t": "lit", "lit": { "kind": "int", "value": 7 } } + ] + }] + } + } + ] +} +``` + +Implementer note: the exact key for the `constraints` field on `Type::Forall` must match `ailang-core/src/ast.rs` (schema may use `"constraints"` or `"where"` — confirm by reading the schema definition). + +- [ ] **Step 2.2: Write the workspace-level RED test** + +Create `crates/ail/tests/mono_unification.rs`: + +```rust +//! Tests for the unified mono pass over polymorphic free fns. +//! +//! These tests assert workspace-level invariants AFTER +//! `monomorphise_workspace` has run: synthesised mono `Def::Fn`s for +//! both class-method residuals AND polymorphic free-fn calls live in +//! the post-mono workspace. + +use ailang_core::ast::Def; +use ailang_core::workspace::load_workspace; + +#[test] +fn cmp_max_smoke_produces_both_mono_symbols_in_one_fixpoint() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture = manifest_dir.join("../../examples/cmp_max_smoke.ail.json"); + let ws = load_workspace(&fixture).expect("workspace loads"); + let diags = ailang_check::check_workspace(&ws); + assert!(diags.is_empty(), "typecheck diagnostics: {diags:?}"); + let post_mono = ailang_check::monomorphise_workspace(&ws).expect("mono green"); + + let main_mod = post_mono + .modules + .iter() + .find(|m| m.name == "cmp_max_smoke") + .expect("main module present"); + + let names: Vec<&str> = main_mod + .defs + .iter() + .filter_map(|d| if let Def::Fn(f) = d { Some(f.name.as_str()) } else { None }) + .collect(); + + assert!( + names.contains(&"cmp_max__Int"), + "post-mono workspace must contain free-fn mono symbol cmp_max__Int; got {names:?}" + ); + assert!( + names.contains(&"compare__Int"), + "post-mono workspace must contain class-method mono symbol compare__Int (closed transitively from cmp_max__Int body); got {names:?}" + ); +} +``` + +- [ ] **Step 2.3: Run the RED test and capture the failure mode** + +Run: `cargo test -p ail --test mono_unification cmp_max_smoke_produces_both_mono_symbols_in_one_fixpoint` + +Expected: FAILS with one of two modes: +- **Mode A (most likely)**: assertion `names.contains(&"cmp_max__Int")` fires — the workspace lacks the free-fn mono symbol because today's mono pass does not emit free-fn mono Defs. The poly call is instead specialised at codegen time, leaving no Workspace::Def trace. +- **Mode B**: `check_workspace` or `monomorphise_workspace` errors before reaching the assertion. If this happens, capture the exact error and adjust Task 4 design (the collector arm may need to be more lenient in how it handles `Type::Forall { constraints }` resolution). + +This test stays RED until Task 9 (after the unification lands in Tasks 3–8). + +--- + +### Task 3: Widen `MonoTarget` to carry both class-method and free-fn kinds + +Today `MonoTarget` (mono.rs:345-357) has a `class: String` field assuming every target comes from a class-method residual. The new free-fn arm needs to carry a polymorphic free-fn callsite. Widen via enum variants. + +**Files:** +- Modify: `crates/ailang-check/src/mono.rs:345-372` (`MonoTarget` struct + `mono_target_key`) +- Modify: every site in mono.rs that constructs or pattern-matches `MonoTarget` (call sites of `mono_target_key`, `synthesise_mono_fn`, `rewrite_class_method_calls`, `collect_mono_targets`, `collect_residuals_ordered`) + +- [ ] **Step 3.1: Write a unit-test for the new `MonoTarget::FreeFn` variant** + +Add to `crates/ail/tests/mono_unification.rs`: + +```rust +use ailang_check::mono::{MonoTarget, mono_target_key}; +use ailang_core::ast::Type; + +#[test] +fn mono_target_free_fn_variant_keys_distinctly_from_class_method() { + let int_ty = Type::Con { name: "Int".into() }; + let class_tgt = MonoTarget::ClassMethod { + class: "Eq".into(), + method: "eq".into(), + type_: int_ty.clone(), + defining_module: "prelude".into(), + }; + let free_tgt = MonoTarget::FreeFn { + name: "cmp_max".into(), + type_args: vec![int_ty.clone()], + defining_module: "prelude".into(), + }; + assert_ne!( + mono_target_key(&class_tgt), + mono_target_key(&free_tgt), + "class-method and free-fn targets must have distinct dedup keys" + ); +} +``` + +- [ ] **Step 3.2: Run the test and verify it FAILS** + +Run: `cargo test -p ail --test mono_unification mono_target_free_fn_variant_keys_distinctly_from_class_method` + +Expected: compile error — `MonoTarget::ClassMethod` and `MonoTarget::FreeFn` variants do not exist yet, today's `MonoTarget` is a plain struct. + +- [ ] **Step 3.3: Convert `MonoTarget` from struct to enum** + +Edit `crates/ailang-check/src/mono.rs:345-357`: + +```rust +/// A specialisation target — one synthesised mono `Def::Fn` will be +/// produced per unique `mono_target_key(t)`. Two kinds: +/// - `ClassMethod`: a class-constraint residual resolved to a concrete +/// `(class, type)` pair via `Registry::entries`. +/// - `FreeFn`: a call site to a polymorphic free `Def::Fn` whose +/// substitution is fully concrete. +#[derive(Debug, Clone)] +pub enum MonoTarget { + ClassMethod { + class: String, + method: String, + type_: Type, + defining_module: String, + }, + FreeFn { + name: String, + type_args: Vec, + defining_module: String, + }, +} +``` + +Edit `crates/ailang-check/src/mono.rs:359-372` — extend `mono_target_key`: + +```rust +/// Dedup key: distinct in both arms; class-method keys are +/// `("class", ".", )`, free-fn keys are +/// `("free", "", )`. +pub(crate) fn mono_target_key(t: &MonoTarget) -> (String, String, String) { + match t { + MonoTarget::ClassMethod { class, method, type_, .. } => ( + "class".into(), + format!("{class}.{method}"), + ailang_core::canonical::type_hash(type_), + ), + MonoTarget::FreeFn { name, type_args, .. } => { + let joined = type_args + .iter() + .map(ailang_core::canonical::type_hash) + .collect::>() + .join("|"); + ("free".into(), name.clone(), joined) + } + } +} +``` + +- [ ] **Step 3.4: Update all `MonoTarget` construction + pattern-match sites in mono.rs** + +The grep target is mono.rs itself. Every existing `MonoTarget { class, method, ... }` construction becomes `MonoTarget::ClassMethod { class, method, ... }`. Every existing field-access on `target.class` / `target.method` / `target.type_` / `target.defining_module` becomes a match against the `ClassMethod` arm (free-fn arm gets a `todo!()` placeholder filled by Tasks 4–6). + +Pattern for each site: + +```rust +match target { + MonoTarget::ClassMethod { class, method, type_, defining_module } => { + // existing logic, now using bindings from the destructure + } + MonoTarget::FreeFn { .. } => todo!("free-fn arm — filled in Tasks 4–6"), +} +``` + +After the edits, `cargo build -p ailang-check` should succeed with `todo!()`s in place. + +- [ ] **Step 3.5: Verify build green + Task-3 unit test green** + +Run: `cargo build -p ailang-check` +Expected: PASS (warnings about unreachable `todo!()` arms are acceptable). + +Run: `cargo test -p ail --test mono_unification mono_target_free_fn_variant_keys_distinctly_from_class_method` +Expected: PASS. + +Run: `cargo test -p ail --test mono_hash_stability primitive_eq_ord_mono_symbol_hashes_stay_bit_identical` +Expected: PASS (mono pass logic for class methods unchanged behaviourally). + +Run: `cargo test -p ail --test mono_unification cmp_max_smoke_produces_both_mono_symbols_in_one_fixpoint` +Expected: still FAILS at the `cmp_max__Int` assertion (free-fn arm not yet wired). + +--- + +### Task 4: Free-fn arm in `collect_mono_targets` (+ workspace-wide collector) + +Today `collect_mono_targets` (mono.rs:413-511) re-runs `synth` and filters class-constraint residuals. Add a sibling walker over `Term::App { fn: Term::Var { name }, args }` sites that resolve to a polymorphic free `Def::Fn` with a fully-concrete substitution at the call site. + +**Files:** +- Modify: `crates/ailang-check/src/mono.rs:413-511` — add free-fn arm to `collect_mono_targets` +- Modify: `crates/ailang-check/src/mono.rs:224-252` — `collect_targets_workspace_wide` passes a new `env.polymorphic_free_fns` index +- Modify: `crates/ailang-check/src/mono.rs:374-384` — `build_workspace_env` adds the free-fn index + +- [ ] **Step 4.1: Write a workspace-level RED test that pins targets-emitted-per-fixpoint-round** + +Add to `crates/ail/tests/mono_unification.rs`: + +```rust +use ailang_check::mono::collect_mono_targets; + +#[test] +fn collect_mono_targets_emits_free_fn_target_for_cmp_max_at_int() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture = manifest_dir.join("../../examples/cmp_max_smoke.ail.json"); + let ws = load_workspace(&fixture).expect("workspace loads"); + let diags = ailang_check::check_workspace(&ws); + assert!(diags.is_empty(), "typecheck diagnostics: {diags:?}"); + let env = ailang_check::build_check_env(&ws); + + let main_mod = ws.modules + .iter().find(|m| m.name == "cmp_max_smoke").unwrap(); + let main_fn = main_mod.defs.iter().find_map(|d| { + if let Def::Fn(f) = d { (f.name == "main").then_some(f) } else { None } + }).unwrap(); + + let targets = collect_mono_targets(main_fn, "cmp_max_smoke", &env) + .expect("collector runs"); + + let has_free_fn_target = targets.iter().any(|t| matches!( + t, MonoTarget::FreeFn { name, type_args, .. } + if name == "cmp_max" && type_args.len() == 1 + )); + assert!( + has_free_fn_target, + "expected MonoTarget::FreeFn {{ name: \"cmp_max\", type_args: [Int] }}; got {targets:?}" + ); +} +``` + +- [ ] **Step 4.2: Run the test and verify it FAILS** + +Run: `cargo test -p ail --test mono_unification collect_mono_targets_emits_free_fn_target_for_cmp_max_at_int` + +Expected: FAIL — `has_free_fn_target == false`, the collector emits zero `FreeFn` variants today. + +- [ ] **Step 4.3: Implement the free-fn arm** + +Extend `collect_mono_targets` (mono.rs:413-511) with a second walker pass over the fn body, AFTER the existing class-residual collection. The new walker visits every `Term::App { fn: Term::Var { name }, args }`, looks up `name` in `env.polymorphic_free_fns` (the new index built in Step 4.4), and if found: + +1. Resolve each `args[i]`'s type via the type-environment cached at typecheck. +2. Unify against the polymorphic free fn's `Type::Forall { vars, body }` to derive a substitution `vars → concrete-types`. +3. If the substitution is fully-concrete (no `Type::Var` left), emit: + ```rust + targets.push(MonoTarget::FreeFn { + name: name.clone(), + type_args: vars.iter().map(|v| subst[v].clone()).collect(), + defining_module: defining_module_of(name, env), + }); + ``` +4. If the substitution is NOT fully concrete, do nothing — the outer call site will eventually present a fully-concrete substitution if reachable. + +Sketch of the new code at mono.rs:413-511 (the existing class-residual walker stays untouched above this addition): + +```rust +// After the class-residual collection (existing code), add: + +// --- Free-fn arm (B1) --- +// +// Walk the body a second time looking for bare-name calls to +// polymorphic free Def::Fns. For each fully-concrete substitution, +// emit a MonoTarget::FreeFn. +fn walk_free_fn_calls( + term: &Term, + env: &crate::Env, + caller_module: &str, + targets: &mut Vec, +) { + if let Term::App { fn_: callee, args, .. } = term { + if let Term::Var { name, .. } = callee.as_ref() { + if let Some(poly_def) = env.polymorphic_free_fns.get(name) { + if let Some((type_args, defining_module)) = + derive_free_fn_substitution(poly_def, args, env) + { + if type_args.iter().all(crate::is_fully_concrete) { + targets.push(MonoTarget::FreeFn { + name: name.clone(), + type_args, + defining_module, + }); + } + } + } + } + } + // Recurse into sub-terms (Match arms, Let bodies, Seq, App args, etc.) + walk_subterms(term, |sub| walk_free_fn_calls(sub, env, caller_module, targets)); +} + +walk_free_fn_calls(&f.body, env, module_name, &mut targets); +``` + +`derive_free_fn_substitution` is a new helper that takes the poly `Def::Fn`, the call-site args, and the type environment; returns `(Vec, defining_module)` if a substitution exists. It mirrors `crates/ailang-codegen/src/lib.rs:1994`'s logic but operates on the typecheck-time `Type` rather than codegen-time substitution descriptors. + +The implementer reads the existing `derive_substitution` in `crates/ailang-codegen/src/subst.rs` for the algorithm and ports it to typecheck-time types. + +- [ ] **Step 4.4: Add `polymorphic_free_fns` to `env`** + +Edit `crates/ailang-check/src/mono.rs:374-384` (`build_workspace_env`) — extend the returned `Env` with a new index field: + +```rust +// In Env (whose definition lives in crates/ailang-check/src/lib.rs or env.rs): +pub polymorphic_free_fns: BTreeMap, +``` + +Populate it in `build_workspace_env` by walking every module's `Def::Fn`s and recording those whose `type` is `Type::Forall`. (Implementer determines exact path — `Env` lives in `crates/ailang-check/src/lib.rs` per the existing `crate::build_check_env` reference at mono.rs:374-384.) + +- [ ] **Step 4.5: Run the test and verify it now PASSES** + +Run: `cargo test -p ail --test mono_unification collect_mono_targets_emits_free_fn_target_for_cmp_max_at_int` +Expected: PASS. + +Run: `cargo test -p ail --test mono_hash_stability primitive_eq_ord_mono_symbol_hashes_stay_bit_identical` +Expected: PASS (the new arm does not affect class-method behaviour). + +--- + +### Task 5: Free-fn entry in `synthesise_mono_fn` + +Today `synthesise_mono_fn` (mono.rs:513-606, body starts at line 539) takes `(target, class_def, instance)` and looks up the body via `Registry::entries`. Add a free-fn arm that takes the source body directly from the polymorphic `Def::Fn`. + +**Files:** +- Modify: `crates/ailang-check/src/mono.rs:513-606` (`synthesise_mono_fn` extension) + +- [ ] **Step 5.1: Write a unit-test for free-fn synthesis** + +Add to `crates/ail/tests/mono_unification.rs`: + +```rust +#[test] +fn synthesise_mono_fn_free_fn_arm_substitutes_rigid_vars() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture = manifest_dir.join("../../examples/cmp_max_smoke.ail.json"); + let ws = load_workspace(&fixture).expect("workspace loads"); + let diags = ailang_check::check_workspace(&ws); + assert!(diags.is_empty(), "typecheck diagnostics: {diags:?}"); + let env = ailang_check::build_check_env(&ws); + + let int_ty = ailang_core::ast::Type::Con { name: "Int".into() }; + let target = MonoTarget::FreeFn { + name: "cmp_max".into(), + type_args: vec![int_ty.clone()], + defining_module: "cmp_max_smoke".into(), + }; + let synth = ailang_check::mono::synthesise_mono_fn_for_free_fn(&target, &env) + .expect("synthesis succeeds"); + + assert_eq!(synth.name, "cmp_max__Int"); + // Body must contain a Term::App referring to `compare` (the inner call + // site). After Task 6 it gets rewritten to `compare__Int`; Task 5 just + // produces the rigid-substituted body unchanged in inner calls. + let body_str = format!("{:?}", synth.body); + assert!(body_str.contains("compare"), "body still references compare bare-name pre-rewrite"); + // Param types must be Int (post-rigid-substitution). + assert!(matches!( + synth.ty, + ailang_core::ast::Type::Fn { params, .. } if matches!(params.as_slice(), [ + ailang_core::ast::Type::Con { name: a }, ailang_core::ast::Type::Con { name: b } + ] if a == "Int" && b == "Int") + )); +} +``` + +- [ ] **Step 5.2: Run the test and verify FAIL** + +Run: `cargo test -p ail --test mono_unification synthesise_mono_fn_free_fn_arm_substitutes_rigid_vars` +Expected: FAIL — `synthesise_mono_fn_for_free_fn` does not exist. + +- [ ] **Step 5.3: Implement `synthesise_mono_fn_for_free_fn` (or a unified entry)** + +Two structurally viable choices: + +**Choice A (recommended):** Add a new pub fn `synthesise_mono_fn_for_free_fn(target: &MonoTarget, env: &Env) -> Result` next to the existing `synthesise_mono_fn`. The caller (the fixpoint loop in `monomorphise_workspace`) matches on the target variant and dispatches. + +**Choice B:** Unify `synthesise_mono_fn` to take `target: &MonoTarget` and route internally. This collapses the two entry points into one signature but blurs the source-body-origin distinction. + +Choice A keeps the change local and preserves the existing class-method API; the unified call shape lives at the fixpoint-loop level in `monomorphise_workspace`. + +Implementation sketch: + +```rust +/// Synthesise a monomorphic `FnDef` for a polymorphic free-fn target. +/// The body is taken directly from the polymorphic source `Def::Fn` +/// (NOT from `Registry::entries`); rigid-var substitution applies the +/// `target.type_args` to the source body. +pub fn synthesise_mono_fn_for_free_fn( + target: &MonoTarget, + env: &crate::Env, +) -> Result { + let MonoTarget::FreeFn { name, type_args, defining_module } = target else { + unreachable!("synthesise_mono_fn_for_free_fn called with non-FreeFn variant"); + }; + let (source_fn, _module) = env.polymorphic_free_fns.get(name) + .ok_or_else(|| CheckError::Internal(format!( + "synthesise_mono_fn_for_free_fn: no source fn for {name}" + )))?; + + // Build the substitution {vars[i] -> type_args[i]} from the source's + // Type::Forall declaration order. + let Type::Forall { vars, body, .. } = &source_fn.type_ else { + return Err(CheckError::Internal(format!( + "synthesise_mono_fn_for_free_fn: source fn {name} is not Type::Forall" + ))); + }; + if vars.len() != type_args.len() { + return Err(CheckError::Internal(format!( + "synthesise_mono_fn_for_free_fn: arity mismatch for {name}" + ))); + } + let subst: BTreeMap = vars.iter().cloned() + .zip(type_args.iter().cloned()) + .collect(); + + // Apply rigid-var substitution on the function type. The body is + // passed through unchanged — the rewrite walker (Task 6) handles + // inner class-method / poly-call rewrites in a subsequent fixpoint + // round, mirroring the class-method arm at mono.rs:561-575 which + // also substitutes only on the type and copies the body verbatim. + let new_type = crate::substitute_rigids(body, &subst); + let new_body = source_fn.body.clone(); + + let mono_name = mono_symbol(name, type_args.as_slice()); + + Ok(AstFnDef { + name: mono_name, + ty: new_type, + params: source_fn.params.clone(), + body: new_body, + doc: None, + suppress: Vec::new(), + }) +} +``` + +Implementer notes: +- `mono_symbol` (mono.rs:297-328) today takes `(method: &str, ty: &Type)` — extend to `(name: &str, types: &[Type])` so the N-ary case works. For single-type-var class methods, callers pass `&[ty]` (one-element slice); the new signature must produce the SAME output for the single-type-var case (i.e. `eq__Int` and not `eq__Int__` or similar) — verify in Step 5.5 against the hash-stability pin. +- `crate::substitute_rigids` is the existing type-level substitution helper (defined at `crates/ailang-check/src/lib.rs:127`); reuse it. The body is NOT substituted at this stage — the rewrite walker in Task 6 rewrites inner calls in a separate fixpoint round, mirroring the class-method arm's body-passthrough pattern at mono.rs:564-575. +- `FnDef` (or `AstFnDef`) struct: the class-method arm at mono.rs:598-605 produces `AstFnDef { name, ty, params, body, doc: None, suppress: Vec::new() }`. Mirror that exact shape — the field is `ty` (not `type_`) and `doc`/`suppress` defaults must be present. + +- [ ] **Step 5.4: Update `mono_symbol` to N-ary** + +Edit `crates/ailang-check/src/mono.rs:297-343`: + +```rust +/// Produce a deterministic mono-symbol name from a base name and one or +/// more concrete types. For a single primitive type, returns +/// `__`; for N types, joins with `__`. For non-primitive +/// types, falls back to an 8-hex prefix of the canonical type hash. +pub fn mono_symbol(name: &str, types: &[Type]) -> String { + let mut parts = vec![name.to_string()]; + for ty in types { + parts.push(type_to_mono_suffix(ty)); + } + parts.join("__") +} + +fn type_to_mono_suffix(ty: &Type) -> String { + match primitive_surface_name(ty) { + Some(p) => p.to_string(), + None => { + let h = ailang_core::canonical::type_hash(ty); + h[..8].to_string() + } + } +} +``` + +Update every call site of `mono_symbol` in mono.rs to pass `&[ty]` instead of `&ty`. Bit-stability: for single-primitive types, the new output equals the old; for the existing six primitive Eq/Ord symbols, hashes do not drift. Verified by Step 5.5. + +- [ ] **Step 5.5: Run the tests and verify** + +Run: `cargo test -p ail --test mono_unification synthesise_mono_fn_free_fn_arm_substitutes_rigid_vars` +Expected: PASS. + +Run: `cargo test -p ail --test mono_hash_stability primitive_eq_ord_mono_symbol_hashes_stay_bit_identical` +Expected: PASS (the new `mono_symbol` produces identical output for single-primitive types). + +--- + +### Task 6: Free-fn arm in `rewrite_class_method_calls` (+ ordered residual collector) + +Today `rewrite_class_method_calls` (mono.rs:608-770) only fires on `Term::Var` names that exist in `env.class_methods`. Extend it to also fire on bare polymorphic free-fn names. Rename to `rewrite_mono_calls`. The sibling `collect_residuals_ordered` (mono.rs:793-870) must be extended in lockstep so the positional-cursor invariant survives. + +**Files:** +- Modify: `crates/ailang-check/src/mono.rs:608-770` (`rewrite_class_method_calls` → `rewrite_mono_calls`) +- Modify: `crates/ailang-check/src/mono.rs:793-870` (`collect_residuals_ordered`) +- Modify: every caller of `rewrite_class_method_calls` in mono.rs (rename + extended call signature) + +- [ ] **Step 6.1: Write a unit-test for the rewrite** + +Add to `crates/ail/tests/mono_unification.rs`: + +```rust +#[test] +fn rewrite_mono_calls_rewrites_bare_poly_free_fn_to_mono_symbol() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture = manifest_dir.join("../../examples/cmp_max_smoke.ail.json"); + let ws = load_workspace(&fixture).expect("workspace loads"); + let diags = ailang_check::check_workspace(&ws); + assert!(diags.is_empty(), "typecheck diagnostics: {diags:?}"); + let post_mono = ailang_check::monomorphise_workspace(&ws).expect("mono green"); + + let main_mod = post_mono + .modules + .iter() + .find(|m| m.name == "cmp_max_smoke") + .unwrap(); + let main_fn = main_mod.defs.iter().find_map(|d| { + if let Def::Fn(f) = d { (f.name == "main").then_some(f) } else { None } + }).unwrap(); + + let body_str = format!("{:?}", main_fn.body); + assert!( + body_str.contains("cmp_max__Int"), + "main body must call cmp_max__Int after rewrite; got: {body_str}" + ); + assert!( + !body_str.contains("Term::Var { name: \"cmp_max\""), + "main body must NOT still call bare cmp_max after rewrite; got: {body_str}" + ); +} +``` + +- [ ] **Step 6.2: Run the test and verify FAIL** + +Run: `cargo test -p ail --test mono_unification rewrite_mono_calls_rewrites_bare_poly_free_fn_to_mono_symbol` +Expected: FAIL (the rewrite walker today does not handle free-fn names). + +- [ ] **Step 6.3: Rename + extend the rewrite walker** + +In mono.rs:608-770, rename `rewrite_class_method_calls` to `rewrite_mono_calls`. Extend the per-`Term::Var` decision logic at the walker step (the positional-cursor consumption point) to: + +```rust +// Existing: only class-method names triggered a rewrite. +// New: both class-method and free-fn mono targets trigger. + +match ordered_targets[*cursor] { + Some(MonoTarget::ClassMethod { method, type_, .. }) => { + // unchanged behaviour from today's branch + *name = mono_symbol(method, std::slice::from_ref(type_)); + *cursor += 1; + } + Some(MonoTarget::FreeFn { name: fn_name, type_args, .. }) => { + *name = mono_symbol(fn_name, type_args.as_slice()); + *cursor += 1; + } + None => { + // Site is not a mono target — leave the name alone. + } +} +``` + +The cursor invariant: each call site visited by the walker must be paired with exactly one slot in `ordered_targets`, in the same order. `collect_residuals_ordered` (Step 6.4) is the producer. + +- [ ] **Step 6.4: Extend `collect_residuals_ordered` for free-fn sites** + +`collect_residuals_ordered` (mono.rs:793-870) today produces a `Vec>` aligned with the rewrite walker's cursor: one slot per `Term::Var` callable name encountered, `Some(target)` if it's a class-method residual, `None` otherwise. + +Extend the slot-producing step: in addition to checking `env.class_methods`, also check `env.polymorphic_free_fns`. If the name resolves to a polymorphic free fn AND the substitution at the call site is fully concrete, emit `Some(MonoTarget::FreeFn { ... })`. Otherwise `None`. + +The order of slot emission must remain stable — the walker traverses subterms in a fixed order; mirror that exactly. + +- [ ] **Step 6.5: Update all callers of `rewrite_class_method_calls` to use the new name** + +Grep mono.rs for `rewrite_class_method_calls` (was at line 637; called from within `monomorphise_workspace` lines 154-214). Rename every call site. + +- [ ] **Step 6.6: Run the tests** + +Run: `cargo build -p ailang-check` +Expected: PASS. + +Run: `cargo test -p ail --test mono_unification rewrite_mono_calls_rewrites_bare_poly_free_fn_to_mono_symbol` +Expected: PASS. + +Run: `cargo test -p ail --test mono_unification` +Expected: PASS (all four tests added so far: targets distinct keys, collector emits free-fn target, synthesis works, rewrite walker fires). + +Run: `cargo test -p ail --test mono_hash_stability` +Expected: PASS (still bit-identical). + +Run: `cargo test -p ail --test mono_unification cmp_max_smoke_produces_both_mono_symbols_in_one_fixpoint` +Expected: PASS — the workspace-level test from Task 2 turns GREEN now. + +--- + +### Task 7: Generalise `workspace_has_typeclasses` predicate + +Today `workspace_has_typeclasses` (mono.rs:291-295) is the early-out for `monomorphise_workspace`. With free-fn mono targets, a workspace with poly free fns but no class/instance still needs the pass. + +**Files:** +- Modify: `crates/ailang-check/src/mono.rs:291-295` + +- [ ] **Step 7.1: Write a regression test pinning early-out behaviour for poly-id alone** + +Add to `crates/ail/tests/mono_unification.rs`: + +```rust +#[test] +fn workspace_with_only_poly_free_fn_and_no_classes_still_monomorphises() { + // poly_id has a Type::Forall free fn and NO class/instance defs. + // Today the early-out fires and codegen handles specialisation. + // After Task 7, the early-out is generalised — the unified mono + // pass produces `id__Int` and `id__Bool` as workspace Def::Fn. + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let fixture = manifest_dir.join("../../examples/poly_id.ail.json"); + let ws = load_workspace(&fixture).expect("workspace loads"); + let diags = ailang_check::check_workspace(&ws); + assert!(diags.is_empty(), "typecheck diagnostics: {diags:?}"); + let post_mono = ailang_check::monomorphise_workspace(&ws).expect("mono green"); + + let names: Vec = post_mono.modules.iter() + .flat_map(|m| m.defs.iter().filter_map(|d| { + if let Def::Fn(f) = d { Some(f.name.clone()) } else { None } + })) + .collect(); + assert!(names.iter().any(|n| n == "id__Int"), + "poly_id must produce id__Int via unified mono; got {names:?}"); + assert!(names.iter().any(|n| n == "id__Bool"), + "poly_id must produce id__Bool via unified mono; got {names:?}"); +} +``` + +- [ ] **Step 7.2: Run the test and verify FAIL** + +Run: `cargo test -p ail --test mono_unification workspace_with_only_poly_free_fn_and_no_classes_still_monomorphises` +Expected: FAIL — `workspace_has_typeclasses` returns false on `poly_id.ail.json`, mono pass takes the early-out, no Def::Fn `id__Int` is produced. The codegen-time path still handles it for now (since we have not yet removed it in Task 8), but the workspace-level assertion targets the post-mono workspace state, which is empty of free-fn mono Defs. + +- [ ] **Step 7.3: Generalise the predicate** + +Edit mono.rs:291-295: + +```rust +/// Cheap early-out predicate: returns true iff the workspace has at +/// least one specialisable target — a `Def::Class`/`Def::Instance` +/// (class-method residuals) OR a polymorphic `Def::Fn` (free-fn case). +fn workspace_has_specialisable_targets(ws: &Workspace) -> bool { + ws.modules.iter().any(|m| m.defs.iter().any(|d| match d { + Def::Class(_) | Def::Instance(_) => true, + Def::Fn(f) => matches!(f.type_, Type::Forall { .. }), + _ => false, + })) +} +``` + +Rename the call site in `monomorphise_workspace` (mono.rs:73). + +- [ ] **Step 7.4: Run the test** + +Run: `cargo test -p ail --test mono_unification workspace_with_only_poly_free_fn_and_no_classes_still_monomorphises` +Expected: PASS. + +Run: `cargo test -p ail --test mono_hash_stability` +Expected: PASS. + +--- + +### Task 8: Codegen cleanup — remove `lower_polymorphic_call` + supporting state + +After Tasks 3–7, the mono pass leaves NO polymorphic call sites in the workspace passed to codegen. The codegen-time specialiser is fully redundant. Remove it. + +**Files:** +- Modify: `crates/ailang-codegen/src/lib.rs:52` — `use` import +- Modify: `crates/ailang-codegen/src/lib.rs:285-352` — Pass-1 comment + per-module poly populate +- Modify: `crates/ailang-codegen/src/lib.rs:363,399` — `module_polymorphic_fns` insert + Emitter param +- Modify: `crates/ailang-codegen/src/lib.rs:558-566` — Emitter struct fields +- Modify: `crates/ailang-codegen/src/lib.rs:726,777-779` — Emitter::new +- Modify: `crates/ailang-codegen/src/lib.rs:839-850` — mono drain loop +- Modify: `crates/ailang-codegen/src/lib.rs:888-950` — `emit_specialised_fn` +- Modify: `crates/ailang-codegen/src/lib.rs:1939-1945,1965-1973` — call sites +- Modify: `crates/ailang-codegen/src/lib.rs:1990-2104` — `lower_polymorphic_call` body +- Modify: `crates/ailang-codegen/src/lib.rs:2253-2263` — `is_static_callee` poly arm + +- [ ] **Step 8.1: Delete `lower_polymorphic_call` and its two call sites** + +Edit `crates/ailang-codegen/src/lib.rs`: + +- Delete lines 1939-1945 entirely (call-site #1 — the `if self.module_polymorphic_fns.get(&target_module)...` block). +- Delete lines 1965-1973 entirely (call-site #2 — the same-module variant). +- Delete lines 1990-2104 entirely (`lower_polymorphic_call` function body). + +After deletion, the surrounding context falls through to the existing `target_fns.get(suffix)` / `module_user_fns.get(name)` paths. The unified mono pass has already rewritten poly call sites to their mono symbols, which appear as regular user fns in the post-mono workspace. + +- [ ] **Step 8.2: Delete the Emitter fields and machinery** + +- Delete `module_polymorphic_fns: BTreeMap>` field at line 560. +- Delete `mono_queue: ...` field at line 565. +- Delete `mono_emitted: ...` field at line 566. +- Delete `Emitter::new` parameter for `module_polymorphic_fns` at line 726 (signature) and the init assignments at lines 777-779. +- Delete the mono drain loop at lines 839-850 (`while let Some(...) = self.mono_queue.pop() { ... }`). +- Delete `emit_specialised_fn` at lines 888-950. + +- [ ] **Step 8.3: Delete Pass-1 population** + +- Delete lines 285-294 (stale symbol-table comment about codegen-time poly handling). +- Delete lines 297-352 (`module_polymorphic_fns` declaration + per-module populate loop). +- Delete line 363 (`module_polymorphic_fns.insert(...)`). +- Delete line 399 (passing `module_polymorphic_fns` into `Emitter::new`). + +- [ ] **Step 8.4: Collapse `is_static_callee`** + +Edit lines 2253-2263 — remove the `self.module_polymorphic_fns.get(...).is_some_and(...)` arm; the function becomes simpler: + +```rust +fn is_static_callee(&self, name: &str) -> bool { + self.module_user_fns + .get(self.module_name) + .is_some_and(|m| m.contains_key(name)) +} +``` + +- [ ] **Step 8.5: Shrink the `use` import** + +Edit line 52 — drop `apply_subst_to_term` and `descriptor_for_subst` from the `use crate::subst::...` line. Keep `apply_subst_to_type` and `derive_substitution` if and only if they are still referenced (they are — the plan-recon noted `match_lower.rs:28` uses them and lib.rs:2833-2834 has a non-removable second site). + +Read lib.rs:2820-2860 to confirm `derive_substitution` survives. If, contrary to plan-recon's expectation, the second site only existed to support `lower_polymorphic_call`, it goes too — and the import drops further. + +- [ ] **Step 8.6: Run the full test suite** + +Run: `cargo build --workspace` +Expected: PASS. + +Run: `cargo test --workspace` +Expected: PASS — including: +- All existing tests (`crates/ail/tests/e2e.rs`: `polymorphic_id_at_int_and_bool`, `polymorphic_apply_with_fn_param`, `poly_rec_capture_demo`). +- All typeclass tests (`typeclass_22b3.rs`, `typeclass_22b2.rs`, `typeclass_22c.rs`). +- All new tests in `mono_unification.rs` and `mono_hash_stability.rs`. +- Hash tests (`hash::tests::ct4_migrated_fixtures_have_canonical_form_hashes`, `ct4_unmigrated_fixtures_remain_bit_identical`). + +If any test fails, the failure is either: +- A latent dependency on the removed codegen-time path (rare — most tests go through `cargo build` + run, exercising the full pipeline). +- A drift in `is_static_callee` behaviour (verify the simplification did not change semantics). +- A surface-level type mismatch from the `use` import shrink. + +--- + +### Task 9: End-to-end verification — fixtures run + +Beyond unit-level tests, run the cmp_max_smoke fixture end-to-end (compile + run binary + check stdout) to prove the unified pass produces correct LLVM. + +**Files:** +- Modify: `crates/ail/tests/mono_unification.rs` — add an end-to-end test + +- [ ] **Step 9.1: Write the end-to-end test** + +Add to `crates/ail/tests/mono_unification.rs`: + +```rust +#[test] +fn cmp_max_smoke_runs_end_to_end() { + // Mirror the run-fixture pattern from e2e.rs::polymorphic_id_at_int_and_bool. + let out = run_fixture("examples/cmp_max_smoke.ail.json"); + assert_eq!(out.stdout.trim(), "7", "cmp_max(3, 7) at Int prints 7"); + assert_eq!(out.exit_code, 0); +} +``` + +The helper `run_fixture(...)` is the same one used by `crates/ail/tests/e2e.rs`. Implementer reads e2e.rs to see its exact signature and either reuses it via `use crate::common::*` (if there's a shared test helper module) or duplicates the minimal compile-and-run scaffold. + +- [ ] **Step 9.2: Run the test** + +Run: `cargo test -p ail --test mono_unification cmp_max_smoke_runs_end_to_end` +Expected: PASS. + +- [ ] **Step 9.3: Verify the three existing poly fixtures still run** + +Run: `cargo test -p ail --test e2e polymorphic_id_at_int_and_bool polymorphic_apply_with_fn_param poly_rec_capture_demo` +Expected: PASS (all three). + +- [ ] **Step 9.4: Verify the full workspace test suite** + +Run: `cargo test --workspace` +Expected: PASS. + +If anything is RED, capture the failure mode in the journal Concerns section and consult Boss before proceeding to Task 10. + +--- + +### Task 10: DESIGN.md amendment — §"Monomorphisation (post-typecheck, pre-codegen)" + +Spec Acceptance Criterion 2. + +**Files:** +- Modify: `docs/DESIGN.md:1513-1556` + +- [ ] **Step 10.1: Re-read the current section** + +Read `docs/DESIGN.md:1475-1556` to ground the amendment in the surrounding context. + +- [ ] **Step 10.2: Amend the §"Monomorphisation" subsection** + +Edit `docs/DESIGN.md:1513-1524`. Replace the existing first paragraph with: + +```markdown +**Monomorphisation (post-typecheck, pre-codegen).** A pass between +typechecking and codegen replaces every call to a +`Type::Forall`-quantified `Def::Fn` with a call to a synthesised +monomorphic `FnDef`. Two source-body entry points share the same +mechanics: + +1. **Class-method entry.** For each unique `(method, concrete-type)` + pair produced by a class-constraint residual, the pass looks up + the resolved instance body via `Registry::entries[(class, type-hash)]`, + substitutes the class parameter to the concrete type, and + synthesises a top-level `FnDef` named `__`. +2. **Free-fn entry.** For each call site to a polymorphic free + `Def::Fn` with a fully-concrete substitution, the pass takes the + source body directly from the polymorphic `Def::Fn`, applies + rigid-var substitution, and synthesises a top-level `FnDef` named + `______…` (concatenated + in `Type::Forall.vars` declaration order; N-ary case extends the + single-type-var class-method shape bit-stably). + +Both arms share: + +- A fixpoint loop that keeps collecting targets until a round adds + nothing new (a synthesised free-fn body may invoke class methods + at concrete types, scheduling new class-method targets; a + class-method body may invoke polymorphic free fns at concrete + types, scheduling new free-fn targets). +- A dedup cache keyed by `(kind, base-name, type-hash-or-joined-hashes)`. +- A call-site rewrite walker that rewrites bare polymorphic call sites + to their mono symbols before codegen runs. + +The mono pass is the only specialiser. After it runs, codegen sees no +class machinery and no polymorphic call sites — every `Def::Fn` in +the workspace is monomorphic. No runtime dispatch, no dictionary +passing. +``` + +The "No runtime dispatch, no dictionary passing." sentence already exists at line ~1550; ensure it is preserved (the amended block ends with it, not duplicates it). Adjust according to the actual surrounding paragraph structure. + +- [ ] **Step 10.3: Verify DESIGN.md parses + spec references resolve** + +Run: `grep -n 'Monomorphisation' /home/brummel/dev/ailang/docs/DESIGN.md` +Expected: still finds the subsection — exact line may shift by ~10 lines from 1513. + +Run: `grep -n 'free-fn entry' /home/brummel/dev/ailang/docs/DESIGN.md` +Expected: finds the new paragraph. + +Run: `grep -n 'lower_polymorphic_call' /home/brummel/dev/ailang/docs/DESIGN.md` +Expected: zero hits (any prior reference to codegen-time specialisation must be gone or recast). + +--- + +### Task 11: Bench-regression check + +Spec Testing strategy §23.4 (e) + Acceptance Criterion #6. The unified pass does more work per fixpoint round (additional body walks); a compile-time shift is expected and must be either tolerated by the existing baselines or audit-ratified. + +**Files:** none modified by this task — only bench scripts are run. + +- [ ] **Step 11.1: Run the three bench scripts** + +Run, in order: + +``` +bench/check.py +bench/compile_check.py +bench/cross_lang.py +``` + +Capture each exit code. + +- [ ] **Step 11.2: Classify each result** + +| Exit code | Meaning | Action | +|-----------|---------|--------| +| 0 | All metrics within tolerance | proceed | +| 1 | At least one metric regressed past tolerance | record the metric + delta in the per-iter journal under Concerns; flag for `audit` follow-up at milestone close | +| 2 | Infrastructure failure | STOP — fix the infrastructure first per `skills/audit/SKILL.md` Iron Law; do NOT report as regression | + +- [ ] **Step 11.3: Record numbers in the per-iter journal** + +The implement orchestrator's terminal Phase 4 (journal write) includes a "Bench" section. Record raw numbers from each script + classification (green/regression/infra). The audit at milestone close decides whether a regression is ratified or fixed. + +--- + +## Self-review (planner side, run before hand-off) + +1. **Spec coverage:** + - §Architecture §1 (Mono-Pass unification) → Tasks 3, 4, 5, 6, 7 + DESIGN.md amendment in Task 10. ✓ + - §Architecture §1 (codegen-time specialiser removed) → Task 8. ✓ + - §Data flow §"Polymorphic free-fn call" → exercised end-to-end in Task 9 (cmp_max_smoke runs at Int). ✓ + - §Testing strategy §23.4 (a) regression for poly_id/poly_apply/poly_rec_capture → Step 9.3. ✓ + - §Testing strategy §23.4 (b) cmp_max_smoke fixture + workspace-level assertion → Tasks 2 + 9.1-9.2. ✓ + - §Testing strategy §23.4 (c) hash-stability pin → Task 1. ✓ + - §Testing strategy §23.4 (d) rollback proof (cargo test green after removals) → Step 8.6. ✓ + - §Testing strategy §23.4 (e) bench check → Task 11. ✓ + - §Acceptance #1 (mono unification + codegen removal) → Tasks 3–8. ✓ + - §Acceptance #2 (DESIGN.md amendment) → Task 10. ✓ + - §Acceptance #5 (cargo test green) → Step 8.6 + Step 9.4. ✓ + - §Acceptance #6 (bench ratified) → Task 11. ✓ + - §Acceptance #3, #4, #7, #8 → out of scope (23.5). + +2. **Placeholder scan:** + - `<>` in Step 1.2 is a deliberate placeholder for a value captured in Step 1.3 and filled in Step 1.4 — by design, not a plan failure. + - No "TBD", "TODO", "fill in later", "implement later", "similar to Task", or "add appropriate error handling" anywhere else. ✓ + +3. **Type consistency:** + - `MonoTarget::ClassMethod` / `MonoTarget::FreeFn` used consistently across Tasks 3, 4, 5, 6. + - `mono_symbol(name, types: &[Type])` consistent across Steps 5.3 and 5.4. + - `polymorphic_free_fns` field consistent across Steps 4.4, 5.3. + - `synthesise_mono_fn_for_free_fn` named consistently in Steps 5.1, 5.3. + - `rewrite_mono_calls` (renamed from `rewrite_class_method_calls`) consistent across Step 6.3, 6.5. + - Test names verified unique across files (`mono_unification.rs` vs. `mono_hash_stability.rs`). ✓ + +4. **Step granularity:** Each step is 2–5 minutes for the actor (write code shown, run one command, observe result). The exception is Step 4.3 (free-fn arm implementation) which is structurally larger; it's split into "write `walk_free_fn_calls` helper" + "wire it into `collect_mono_targets`" inside the same step block. Acceptable. + +5. **No commit steps:** ✓ No `git commit` instructions in any task. All work accumulates in the working tree. + +6. **Open commitments addressed:** + - N-ary naming → resolved per spec default (Step 5.4). + - Target-collector heuristic → resolved as collector extension (Step 4.3). + - Original poly `Def::Fn` retention → resolved as kept-alongside (Open commitments §3 above; no explicit deletion in tasks). + - Float diagnostic → out of scope (23.5). + +--- + +## Hand-off + +This plan is written to the working tree at `docs/plans/2026-05-11-iter-23.4.md` (uncommitted). The Boss reviews and commits when handing off to `implement` (suggested commit subject: `plan: 23.4 mono-pass unification`). + +Hand-off to `implement` carries: + +- `plan_path`: `docs/plans/2026-05-11-iter-23.4.md` +- `iter_id`: `23.4` +- `task_range`: full (Tasks 1–11) +- Notes for the orchestrator-agent: + - Tasks 3–6 are tightly coupled; Tasks 7+8 follow from their landing. + - Task 8 is a pure-removal task — its TDD shape is "cargo build green + full test suite green" rather than RED-first unit-test. + - The hash-stability pin (Task 1) is a regression tracker that should stay green throughout Tasks 3–8; if it RED-fires at any task, that task perturbed a mono body that should have been bit-stable. Investigate before proceeding. + - Open commitment §4 (Float diagnostic wording) is deferred to iter 23.5 and must not be addressed in this plan.