# Iter 23.4 — Mono-Pass Unification — Implementation Plan

> **Parent spec:** `docs/specs/0014-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: `<name>__<typesurfacename1>__<typesurfacename2>__…` 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, "<<FILL_FROM_FIRST_RUN>>", "{sym} body hash drifted");
    }
}
```

Implementer note: the `<<FILL_FROM_FIRST_RUN>>` 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<Diagnostic>`, NOT `Result`), `ailang_check::monomorphise_workspace` (re-exported at root, returns `Result<Workspace>`), 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 `<<FILL_FROM_FIRST_RUN>>` 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 `<<FILL_FROM_FIRST_RUN>>` 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<Type>,
        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", "<class>.<method>", <type-hash>)`, free-fn keys are
/// `("free", "<name>", <joined-type-hashes>)`.
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::<Vec<_>>()
                .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<MonoTarget>,
) {
    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<Type>, 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<String, (FnDef, String /* defining module */)>,
```

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<FnDef>` 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<AstFnDef> {
    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<String, Type> = 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
/// `<name>__<primitive>`; 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<Option<MonoTarget>>` 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<String> = 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<String, BTreeMap<String, FnDef>>` 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 `<method>__<typesurfacename>`.
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
   `<name>__<typesurfacename1>__<typesurfacename2>__…` (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:**
   - `<<FILL_FROM_FIRST_RUN>>` 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/0033-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/0033-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.
