Files
AILang/docs/plans/0033-iter-23.4.md
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

59 KiB
Raw Permalink Blame History

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::Fns — 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-217monomorphise_workspace (rebuild fixpoint+rewrite for both target kinds)
  • crates/ailang-check/src/mono.rs:224-252collect_targets_workspace_wide (call new free-fn collector arm)
  • crates/ailang-check/src/mono.rs:291-295workspace_has_typeclasses (generalise predicate)
  • crates/ailang-check/src/mono.rs:297-343mono_symbol + primitive_surface_name (extend for N-ary type vars)
  • crates/ailang-check/src/mono.rs:345-372MonoTarget struct + mono_target_key (widen for free-fn case)
  • crates/ailang-check/src/mono.rs:413-511collect_mono_targets (add free-fn arm)
  • crates/ailang-check/src/mono.rs:513-606synthesise_mono_fn (route by target kind to class or free-fn source body)
  • crates/ailang-check/src/mono.rs:608-770rewrite_class_method_calls (rename to rewrite_mono_calls, fire on bare poly free-fn names too)
  • crates/ailang-check/src/mono.rs:793-870collect_residuals_ordered (lockstep-extend for free-fn rewrites)
  • crates/ailang-codegen/src/lib.rs:52use 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,399module_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-779Emitter::new param + init (delete)
  • crates/ailang-codegen/src/lib.rs:839-850 — mono drain loop (delete)
  • crates/ailang-codegen/src/lib.rs:888-950emit_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-2104lower_polymorphic_call function (delete)
  • crates/ailang-codegen/src/lib.rs:2253-2263is_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 38 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:

{
  "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:

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


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:

{
  "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:

//! 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 38).


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:

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:

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

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

Pattern for each site:

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 46"),
}

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-252collect_targets_workspace_wide passes a new env.polymorphic_free_fns index

  • Modify: crates/ailang-check/src/mono.rs:374-384build_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:

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

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

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

#[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:

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

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

#[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:

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

#[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:

/// 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 37, 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:52use 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,399module_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-950emit_specialised_fn

  • Modify: crates/ailang-codegen/src/lib.rs:1939-1945,1965-1973 — call sites

  • Modify: crates/ailang-codegen/src/lib.rs:1990-2104lower_polymorphic_call body

  • Modify: crates/ailang-codegen/src/lib.rs:2253-2263is_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:

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:

#[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:

**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 38. ✓
    • §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 25 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 111)
  • Notes for the orchestrator-agent:
    • Tasks 36 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 38; 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.