Files
AILang/docs/plans/2026-05-11-iter-23.4.md
T
Brummel ea0285b85f plan: 23.4 — mono-pass unification + prep2/prep3 rollback
Eleven tasks plus final regression smoke. Tasks 1-6 build the
unified mono pass incrementally (MonoTarget shape, N-ary naming,
free-fn synth, target collection, call-site rewrite, fixpoint
dispatch). Task 7 verifies poly_id/poly_apply/poly_rec_capture
regress green. Task 8 pins mono-symbol hashes. Task 9 removes
codegen-time lower_polymorphic_call + module_polymorphic_fns +
mono_queue. Task 10 reverts prep2 (a06159d) + prep3 (c42a0f5).
Task 11 amends DESIGN.md §Resolution-and-monomorphisation to
cover all Type::Forall Defs.

Implementer open commitments deferred per spec: MonoTarget shape
(enum vs Option), collector mechanism (synth-residual extension
vs separate walker), polymorphic Def::Fn retention post-mono,
subst.rs dead-code handling. Plan calls each out with a default
and NEEDS_CONTEXT permission for alternatives.
2026-05-11 21:42:14 +02:00

41 KiB

iter 23.4 — Mono-Pass Unification + prep2/prep3 Rollback — Implementation Plan

Parent spec: docs/specs/2026-05-11-23-eq-ord-prelude.md

For agentic workers: REQUIRED SUB-SKILL: use skills/implement to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Unify the two specialisers (typecheck-time mono pass + codegen-time lower_polymorphic_call) into one typecheck-time mono pass that handles every Type::Forall-quantified Def::Fn. Remove the codegen-time specialiser and revert the two anticipatory codegen prep commits.

Architecture: Mono pass gains a second source-body entry point (free-fn direct, in addition to class-method via Registry::entries). Target shape, dedup key, call-site rewriter, and mono_symbol naming all extend to accommodate free-fn targets with N-ary type vars. Codegen's lower_polymorphic_call, module_polymorphic_fns, and mono_queue are removed wholesale; the two codegen call sites (lines 1944, 1972 pre-edit) consume mono symbols rewritten by the unified pass. Anticipatory prep commits a06159d (prep2) and c42a0f5 (prep3) revert cleanly.

Tech Stack: crates/ailang-check/src/mono.rs (centrepiece), crates/ailang-codegen/src/lib.rs + crates/ailang-codegen/src/subst.rs (removals), crates/ail/tests/typeclass_22b3.rs + crates/ail/tests/e2e.rs (new tests), crates/ailang-core/src/hash.rs (mono-symbol hash pin), docs/DESIGN.md (Decision 11 §"Resolution and monomorphisation" amendment), examples/cmp_max_smoke.ail.json (new composition fixture).


Files this plan creates or modifies:

  • Modify: crates/ailang-check/src/mono.rs:1-870 — full restructure into unified specialiser.
  • Modify: crates/ailang-codegen/src/lib.rs:52, 287-330, 363-400, 555-780, 842-950, 1939-2100, 2260-2263 — removal of lower_polymorphic_call, module_polymorphic_fns, mono_queue.
  • Modify: crates/ailang-codegen/src/subst.rs:120-240, 270-320 — delete now-unused apply_subst_to_term + descriptor_for_subst.
  • Modify: crates/ail/tests/typeclass_22b3.rs — add free-fn-side test parity (mirror naming).
  • Test: crates/ail/tests/e2e.rs — new cmp_max_smoke_emits_both_mono_defs E2E.
  • Modify: crates/ailang-core/src/hash.rs:270-336 — new pin test mono_symbols_hash_stable_under_unified_pass.
  • Create: examples/cmp_max_smoke.ail.json — composition fixture (poly free-fn + nested class method).
  • Modify: docs/DESIGN.md:1513-1556 — §"Resolution and monomorphisation" amendment.
  • Revert: commits a06159d (iter 23.4-prep2) and c42a0f5 (iter 23.4-prep3).

Implementer Open commitments (per Spec §Open commitments — leave default-or-NEEDS_CONTEXT):

  • MonoTarget shape (enum variant vs Option-shaped fields) — implementer's call.
  • Polymorphic-free-fn detection mechanism (extend synth-residual pathway vs separate post-synth walker) — implementer's call.
  • Whether the original polymorphic Def::Fn source survives in the workspace post-mono — default: yes, parallel to Def::Class/Def::Instance preservation per mono.rs:40-42.
  • subst.rs dead-code handling — plan calls for outright deletion of unused fns, see Task 9.

Task 1: MonoTarget shape extension for free-fn variant

Files:

  • Modify: crates/ailang-check/src/mono.rs:351-372MonoTarget struct + mono_target_key.

  • Test: crates/ail/tests/typeclass_22b3.rs — add mono_target_key_distinguishes_class_method_from_free_fn.

  • Step 1.1: Write the failing test

Add to crates/ail/tests/typeclass_22b3.rs:

#[test]
fn mono_target_key_distinguishes_class_method_from_free_fn() {
    use ailang_check::mono::{MonoTarget, mono_target_key};
    use ailang_core::ast::Type;

    let int_ty = Type::Con { name: "Int".into() };

    let class_method_target = MonoTarget::class_method(
        "Eq".into(),
        "eq".into(),
        int_ty.clone(),
        "prelude".into(),
    );
    let free_fn_target = MonoTarget::free_fn(
        "ne".into(),
        vec![int_ty.clone()],
        "prelude".into(),
    );

    let k1 = mono_target_key(&class_method_target);
    let k2 = mono_target_key(&free_fn_target);
    assert_ne!(k1, k2, "free-fn and class-method targets must have distinct keys");
}
  • Step 1.2: Run test to verify it fails

Run: cargo test --workspace -p ail mono_target_key_distinguishes_class_method_from_free_fn Expected: FAIL with compile error — MonoTarget::class_method and MonoTarget::free_fn constructors don't exist; mono_target_key may not be pub.

  • Step 1.3: Restructure MonoTarget and mono_target_key

Edit crates/ailang-check/src/mono.rs:351-372. The struct currently is:

pub struct MonoTarget {
    pub class: String,
    pub method: String,
    pub type_: Type,
    pub defining_module: String,
}

Replace with the implementer's choice of enum or Option-shaped variant. Per spec Open commitment, both are viable. The constructor surface MUST provide:

impl MonoTarget {
    pub fn class_method(class: String, method: String, type_: Type, defining_module: String) -> Self { /* ... */ }
    pub fn free_fn(fn_name: String, type_subst: Vec<Type>, defining_module: String) -> Self { /* ... */ }
    pub fn defining_module(&self) -> &str { /* ... */ }
    pub fn type_for_synth(&self) -> &Type { /* ... — class_method returns type_; free_fn returns first subst or canonical hash anchor */ }
}

Update mono_target_key to a discriminating shape, e.g.:

pub(crate) fn mono_target_key(t: &MonoTarget) -> (String, String, String) {
    // For class-method: (class, method, type-hash)
    // For free-fn:      ("__freefn__", fn_name, concat-type-hashes)
    // The first component discriminates; the rest is the identifier-tuple.
    // ... implementer fills in based on chosen MonoTarget shape.
}

NOTE: Keep all today's class-method-shaped call sites compiling — every constructor of MonoTarget in this file (line 503 in collect_mono_targets, line 139 in monomorphise_workspace's loop) must adapt to the new shape. This is mechanical adaptation, not redesign — the class-method constructor path stays identical in semantics.

  • Step 1.4: Run test to verify it passes

Run: cargo test --workspace -p ail mono_target_key_distinguishes_class_method_from_free_fn Expected: PASS

  • Step 1.5: Verify no regression

Run: cargo test --workspace -p ailang-check Expected: PASS (all 22b.3 tests still green; MonoTarget reshape preserves class-method behaviour).

  • Step 1.6: Commit
git add crates/ailang-check/src/mono.rs crates/ail/tests/typeclass_22b3.rs
git commit -m "iter 23.4.1: MonoTarget shape — accommodate free-fn variant"

Task 2: mono_symbol extension for N-ary type vars

Files:

  • Modify: crates/ailang-check/src/mono.rs:319-343mono_symbol + primitive_surface_name.

  • Test: crates/ail/tests/typeclass_22b3.rs — add mono_symbol_handles_n_ary_type_vars.

  • Step 2.1: Write the failing test

Add to crates/ail/tests/typeclass_22b3.rs:

#[test]
fn mono_symbol_handles_n_ary_type_vars() {
    use ailang_check::mono::mono_symbol_n_ary;
    use ailang_core::ast::Type;

    let int_ty = Type::Con { name: "Int".into() };
    let bool_ty = Type::Con { name: "Bool".into() };

    // Two-ary case: poly_apply with (a=Int, b=Bool).
    let sym = mono_symbol_n_ary("apply", &[int_ty.clone(), bool_ty.clone()]);
    assert_eq!(sym, "apply__Int__Bool");

    // Single-arity case matches the existing single-var helper.
    let sym = mono_symbol_n_ary("id", &[int_ty.clone()]);
    assert_eq!(sym, "id__Int");
}
  • Step 2.2: Run test to verify it fails

Run: cargo test --workspace -p ail mono_symbol_handles_n_ary_type_vars Expected: FAIL with compile error — mono_symbol_n_ary does not exist.

  • Step 2.3: Add mono_symbol_n_ary

Add to crates/ailang-check/src/mono.rs near mono_symbol (line 319):

/// Iter 23.4: N-ary mono-symbol naming for polymorphic free fns.
/// `<fn-name>__<typename1>__<typename2>__…` in Forall-vars declaration
/// order. Each `typename_i` is `primitive_surface_name(t)` if `t` is a
/// primitive, otherwise the 8-hex prefix of `type_hash(t)` (mirrors
/// the existing single-var `mono_symbol` fallback).
pub fn mono_symbol_n_ary(fn_name: &str, type_substs: &[Type]) -> String {
    let mut parts: Vec<String> = Vec::with_capacity(type_substs.len() + 1);
    parts.push(fn_name.to_string());
    for t in type_substs {
        parts.push(match primitive_surface_name(t) {
            Some(name) => name.to_string(),
            None => format!("{:016x}", ailang_core::canonical::type_hash(t))[..8].to_string(),
        });
    }
    parts.join("__")
}

Verify the single-var case still matches mono_symbol semantics — both should return identical strings for a single-type-var input.

  • Step 2.4: Run test to verify it passes

Run: cargo test --workspace -p ail mono_symbol_handles_n_ary_type_vars Expected: PASS

  • Step 2.5: Commit
git add crates/ailang-check/src/mono.rs crates/ail/tests/typeclass_22b3.rs
git commit -m "iter 23.4.2: mono_symbol_n_ary — naming for polymorphic free fns"

Task 3: Free-fn synth path in synthesise_mono_fn

Files:

  • Modify: crates/ailang-check/src/mono.rs:539-606synthesise_mono_fn + new synthesise_mono_fn_free.

  • Test: crates/ail/tests/typeclass_22b3.rs — add synthesise_mono_fn_free_produces_substituted_body.

  • Step 3.1: Write the failing test

Add to crates/ail/tests/typeclass_22b3.rs:

#[test]
fn synthesise_mono_fn_free_produces_substituted_body() {
    use ailang_check::mono::{MonoTarget, synthesise_mono_fn_free};
    use ailang_core::ast::{FnDef as AstFnDef, Type, Term};

    let int_ty = Type::Con { name: "Int".into() };

    // Source: polymorphic fn id : forall a. (a) -> a, body = x.
    let source_fn = AstFnDef {
        name: "id".into(),
        ty: Type::Forall {
            vars: vec!["a".into()],
            constraints: vec![],
            body: Box::new(Type::Fn {
                params: vec![Type::Var { name: "a".into() }],
                ret: Box::new(Type::Var { name: "a".into() }),
                effects: vec![],
            }),
        },
        params: vec!["x".into()],
        body: Term::Var { name: "x".into() },
    };

    let target = MonoTarget::free_fn("id".into(), vec![int_ty.clone()], "test".into());
    let synthesised = synthesise_mono_fn_free(&target, &source_fn).unwrap();

    assert_eq!(synthesised.name, "id__Int");
    match &synthesised.ty {
        Type::Fn { params, ret, .. } => {
            assert_eq!(params, &vec![int_ty.clone()]);
            assert_eq!(**ret, int_ty);
        }
        other => panic!("expected Type::Fn after substitution, got {other:?}"),
    }
    // Body unchanged (no class-method calls to rewrite at synth time;
    // call-site rewriter handles those in Phase 3 of the pass).
    assert!(matches!(synthesised.body, Term::Var { ref name } if name == "x"));
}
  • Step 3.2: Run test to verify it fails

Run: cargo test --workspace -p ail synthesise_mono_fn_free_produces_substituted_body Expected: FAIL with compile error — synthesise_mono_fn_free does not exist.

  • Step 3.3: Add synthesise_mono_fn_free

Add to crates/ailang-check/src/mono.rs after synthesise_mono_fn (line 606+):

/// Iter 23.4: synth a monomorphic FnDef for a polymorphic free fn at
/// a concrete substitution. Mirror of `synthesise_mono_fn` for the
/// non-class-method case: body is taken directly from the source Def,
/// rigid-vars in the type are substituted via `crate::substitute_rigids`.
///
/// The body is NOT walked here — nested class-method calls and other
/// polymorphic-free-fn calls are rewritten in Phase 3 of
/// `monomorphise_workspace`, the same way they are for synthesised
/// class-method bodies.
///
/// Errors are `CheckError::Internal` — caller-contract violations.
pub fn synthesise_mono_fn_free(
    target: &MonoTarget,
    source_fn: &AstFnDef,
) -> Result<AstFnDef> {
    // Verify target shape matches caller contract.
    let type_substs = target.free_fn_type_substs().ok_or_else(|| {
        crate::CheckError::Internal(
            "synthesise_mono_fn_free: target is not a free-fn target".into(),
        )
    })?;

    let (forall_vars, inner_ty) = match &source_fn.ty {
        Type::Forall { vars, body, .. } => (vars.clone(), (**body).clone()),
        other => return Err(crate::CheckError::Internal(format!(
            "synthesise_mono_fn_free: source fn `{}` is not polymorphic, ty = {:?}",
            source_fn.name, other,
        ))),
    };

    if forall_vars.len() != type_substs.len() {
        return Err(crate::CheckError::Internal(format!(
            "synthesise_mono_fn_free: arity mismatch — forall has {} vars, target has {} substs",
            forall_vars.len(), type_substs.len(),
        )));
    }

    // Substitute every rigid-var in the Type::Fn body. Reuse
    // crate::substitute_rigids — same call shape used by
    // synthesise_mono_fn for class-method types.
    let mut substituted_ty = inner_ty;
    for (var, subst) in forall_vars.iter().zip(type_substs.iter()) {
        substituted_ty = crate::substitute_rigids(&substituted_ty, var, subst);
    }

    Ok(AstFnDef {
        name: mono_symbol_n_ary(&source_fn.name, type_substs),
        ty: substituted_ty,
        params: source_fn.params.clone(),
        body: source_fn.body.clone(),
    })
}

Add a helper on MonoTarget:

impl MonoTarget {
    /// Iter 23.4: free-fn substitutions, in Forall-vars declaration order.
    /// Returns `None` for class-method targets.
    pub fn free_fn_type_substs(&self) -> Option<&[Type]> { /* implementer fills in */ }
}
  • Step 3.4: Run test to verify it passes

Run: cargo test --workspace -p ail synthesise_mono_fn_free_produces_substituted_body Expected: PASS

  • Step 3.5: Verify class-method synth regression

Run: cargo test --workspace -p ail synthesise_mono_fn_uses_instance_body_lam Expected: PASS — the unrelated synthesise_mono_fn path is untouched.

  • Step 3.6: Commit
git add crates/ailang-check/src/mono.rs crates/ail/tests/typeclass_22b3.rs
git commit -m "iter 23.4.3: synthesise_mono_fn_free — substituted body for poly free fns"

Task 4: Free-fn target collection in collect_mono_targets + collect_residuals_ordered

Files:

  • Modify: crates/ailang-check/src/mono.rs:422-511collect_mono_targets.

  • Modify: crates/ailang-check/src/mono.rs:793-870collect_residuals_ordered.

  • Test: crates/ail/tests/typeclass_22b3.rs — add collect_mono_targets_emits_free_fn_target_for_concrete_call.

  • Step 4.1: Write the failing test

Add to crates/ail/tests/typeclass_22b3.rs:

#[test]
fn collect_mono_targets_emits_free_fn_target_for_concrete_call() {
    // Workspace: prelude with `fn id : forall a. (a) -> a, body = x`.
    // Driver module with `fn main : () -> Int, body = id(42)`.
    // collect_mono_targets on `main` must emit a free-fn target for
    // id at type [Int].
    use ailang_check::mono::{collect_mono_targets, build_workspace_env};
    let ws = build_test_workspace_with_poly_id_call();

    let env = build_workspace_env(&ws);
    let driver_module = ws.modules.get("driver").unwrap();
    let main_fn = driver_module.defs.iter().find_map(|d| match d {
        ailang_core::ast::Def::Fn(f) if f.name == "main" => Some(f),
        _ => None,
    }).unwrap();

    let targets = collect_mono_targets(main_fn, "driver", &env).unwrap();

    let has_free_fn_target = targets.iter().any(|t| {
        t.free_fn_name() == Some("id") &&
        t.free_fn_type_substs() == Some(&[Type::Con { name: "Int".into() }][..])
    });
    assert!(has_free_fn_target, "expected free-fn target for id at Int; got {:?}", targets);
}

// Helper: a minimal workspace with prelude::id (polymorphic) and
// driver::main calling id(42). Implementer mirrors the structure of
// the existing `build_test_workspace_with_class_method_call` helper
// at typeclass_22b3.rs:N (find existing helper via grep).
fn build_test_workspace_with_poly_id_call() -> Workspace { /* fill in */ }
  • Step 4.2: Run test to verify it fails

Run: cargo test --workspace -p ail collect_mono_targets_emits_free_fn_target_for_concrete_call Expected: FAIL — collector doesn't yet detect polymorphic free-fn calls. (May fail with compile error if free_fn_name helper isn't defined; add it as a thin accessor on MonoTarget alongside free_fn_type_substs from Task 3.)

  • Step 4.3: Extend collect_mono_targets with polymorphic-free-fn-call detection

Per spec Open commitment, two viable shapes:

Option A — extend synth's residual mechanism to surface polymorphic-free-fn-call residuals alongside class-constraint residuals. This is the deeper change; consult crates/ailang-check/src/lib.rs synth arms.

Option B — add a separate post-synth AST walker over f.body. Pattern-match Term::App { callee: Term::Var { name }, .. } where name resolves to a Type::Forall Def::Fn in env.module_globals (per-module, including implicit-import fall-through per prep1 commit aef4ab8). Use synth's subst to recover the concrete type substitution at the call site.

Implementer picks A or B. Option B is the smaller delta and recommended unless plan-recon discoveries argue otherwise. Either way, the new code emits MonoTarget::free_fn(...) entries into out alongside the existing class-constraint targets (mono.rs:503).

  • Step 4.4: Mirror the extension in collect_residuals_ordered

collect_residuals_ordered (mono.rs:793-870) MUST emit an aligned Option<MonoTarget> slot for every polymorphic-free-fn call site, in the same pre-order as rewrite_class_method_calls walks. Otherwise the cursor alignment Phase 3 depends on breaks. Mirror whichever mechanism (A or B) was chosen in Step 4.3.

  • Step 4.5: Run test to verify it passes

Run: cargo test --workspace -p ail collect_mono_targets_emits_free_fn_target_for_concrete_call Expected: PASS

  • Step 4.6: Verify class-method-side regression

Run: cargo test --workspace -p ail typeclass_22b3 Expected: PASS — all existing 22b.3 tests still green.

  • Step 4.7: Commit
git add crates/ailang-check/src/mono.rs crates/ail/tests/typeclass_22b3.rs
git commit -m "iter 23.4.4: collect_mono_targets — emit free-fn targets for concrete calls"

Task 5: Free-fn call-site rewrite in rewrite_class_method_calls

Files:

  • Modify: crates/ailang-check/src/mono.rs:637-764rewrite_class_method_calls.

  • Test: crates/ail/tests/typeclass_22b3.rs — add rewrite_replaces_free_fn_call_site_with_mono_symbol.

  • Step 5.1: Write the failing test

Add to crates/ail/tests/typeclass_22b3.rs:

#[test]
fn rewrite_replaces_free_fn_call_site_with_mono_symbol() {
    use ailang_check::mono::{rewrite_class_method_calls, MonoTarget};
    use ailang_core::ast::{Term, Type};
    use std::collections::BTreeSet;

    // Body: id(42)
    let mut body = Term::App {
        callee: Box::new(Term::Var { name: "id".into() }),
        args: vec![Term::Lit { /* Int 42 */ }],
    };
    let int_ty = Type::Con { name: "Int".into() };
    let ordered = vec![Some(MonoTarget::free_fn(
        "id".into(),
        vec![int_ty.clone()],
        "prelude".into(),
    ))];
    let class_methods = Default::default();
    let mut cursor = 0;
    let mut locals = BTreeSet::new();

    rewrite_class_method_calls(
        &mut body,
        &class_methods,
        "driver",
        &ordered,
        &mut cursor,
        &mut locals,
    );

    match &body {
        Term::App { callee, .. } => match callee.as_ref() {
            Term::Var { name } => assert_eq!(name, "id__Int"),
            other => panic!("expected Term::Var callee after rewrite, got {other:?}"),
        },
        other => panic!("expected Term::App, got {other:?}"),
    }
}
  • Step 5.2: Run test to verify it fails

Run: cargo test --workspace -p ail rewrite_replaces_free_fn_call_site_with_mono_symbol Expected: FAIL — rewriter does not yet handle free-fn targets.

  • Step 5.3: Extend rewrite_class_method_calls

Edit crates/ailang-check/src/mono.rs:637-764. The Term::App { callee: Term::Var { name }, .. } arm currently consults class_methods only (around lines 646-668). Extend so that when the ordered[cursor] slot is Some(MonoTarget::FreeFn { ... }), the callee is rewritten to Term::Var { name: mono_symbol_n_ary(fn_name, type_substs) }.

The function name rewrite_class_method_calls becomes a misnomer; rename to rewrite_mono_targets_at_call_sites. Update all call sites:

  • monomorphise_workspace Phase 3 at mono.rs:192-209 (two sites: Def::Fn and Def::Const branches).

  • Step 5.4: Run test to verify it passes

Run: cargo test --workspace -p ail rewrite_replaces_free_fn_call_site_with_mono_symbol Expected: PASS

  • Step 5.5: Verify class-method-side regression

Run: cargo test --workspace -p ail typeclass_22b3 Expected: PASS

  • Step 5.6: Commit
git add crates/ailang-check/src/mono.rs crates/ail/tests/typeclass_22b3.rs
git commit -m "iter 23.4.5: rewrite_mono_targets_at_call_sites — also rewrite free-fn calls"

Task 6: Wire up the fixpoint dispatch + composition E2E

Files:

  • Modify: crates/ailang-check/src/mono.rs:67-217monomorphise_workspace fixpoint dispatch.

  • Create: examples/cmp_max_smoke.ail.json.

  • Test: crates/ail/tests/e2e.rs — add cmp_max_smoke_synthesises_both_mono_defs_in_one_fixpoint.

  • Step 6.1: Create examples/cmp_max_smoke.ail.json

Composition fixture: a polymorphic free fn whose body calls a class method, then a main that invokes it at Int. Structurally mirror examples/test_22b3_chained_calls.ail.json (per recon).

{
  "schema": "ailang/v0",
  "name": "cmp_max_smoke",
  "imports": [],
  "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": { /* match compare x y with LT -> y | _ -> x */ }
    },
    {
      "kind": "fn",
      "name": "main",
      "type": {"k": "fn", "params": [], "ret": {"k": "con", "name": "Int"}, "effects": []},
      "params": [],
      "body": { /* cmp_max 3 7 */ }
    }
  ]
}

Implementer fills in the body Term shapes using the canonical Form-A schema — mirror test_22b3_chained_calls.ail.json for the match-statement body and eq_primitives_smoke.ail.json for the toplevel-call body. Expected stdout: 7.

  • Step 6.2: Write the failing test

Add to crates/ail/tests/e2e.rs:

#[test]
fn cmp_max_smoke_synthesises_both_mono_defs_in_one_fixpoint() {
    let stdout = build_and_run("cmp_max_smoke.ail.json");
    assert_eq!(stdout.trim(), "7");
}

#[test]
fn cmp_max_smoke_workspace_contains_cmp_max_int_and_compare_int_defs() {
    use ailang_check::mono::monomorphise_workspace;
    let ws = load_workspace_for_fixture("cmp_max_smoke.ail.json");
    let ws = monomorphise_workspace(&ws).unwrap();

    let cmp_max_module = ws.modules.get("cmp_max_smoke").unwrap();
    let has_cmp_max_int = cmp_max_module.defs.iter().any(|d| matches!(d,
        ailang_core::ast::Def::Fn(f) if f.name == "cmp_max__Int"
    ));

    let prelude_module = ws.modules.get("prelude").unwrap();
    let has_compare_int = prelude_module.defs.iter().any(|d| matches!(d,
        ailang_core::ast::Def::Fn(f) if f.name == "compare__Int"
    ));

    assert!(has_cmp_max_int, "expected cmp_max__Int as workspace Def::Fn");
    assert!(has_compare_int, "expected compare__Int as workspace Def::Fn (nested specialisation)");
}
  • Step 6.3: Run test to verify it fails

Run: cargo test --workspace -p ail cmp_max_smoke Expected: FAIL — the fixpoint loop body in monomorphise_workspace (lines 116-151) currently calls synthesise_mono_fn unconditionally with class_def + &entry.instance; this errors on free-fn targets that have no class.

  • Step 6.4: Branch the fixpoint synth dispatch

Edit crates/ailang-check/src/mono.rs:116-151. Replace the unconditional class-method synth with branching:

for t in &new {
    let key = mono_target_key(t);
    let f: AstFnDef = if let Some(_class_name) = t.class_method_class() {
        // Class-method target — existing path.
        let t_ty_norm = ws_owned.registry.normalize_type_for_lookup(t.type_for_synth());
        let registry_key = (t.class_method_class().unwrap().to_string(),
                            ailang_core::canonical::type_hash(&t_ty_norm));
        let entry = ws_owned.registry.entries.get(&registry_key).ok_or_else(|| {
            crate::CheckError::Internal(format!(
                "monomorphise_workspace: target `{} {}` has no registry entry",
                t.class_method_class().unwrap(),
                ailang_core::pretty::type_to_string(t.type_for_synth()),
            ))
        })?;
        let class_def = class_index.get(t.class_method_class().unwrap()).ok_or_else(|| {
            crate::CheckError::Internal(format!(
                "monomorphise_workspace: class `{}` not found",
                t.class_method_class().unwrap(),
            ))
        })?;
        synthesise_mono_fn(t, class_def, &entry.instance)?
    } else {
        // Free-fn target — new path.
        let fn_name = t.free_fn_name().ok_or_else(|| {
            crate::CheckError::Internal("monomorphise_workspace: target shape unrecognised".into())
        })?;
        let module = ws_owned.modules.get(t.defining_module()).ok_or_else(|| {
            crate::CheckError::Internal(format!(
                "monomorphise_workspace: defining module `{}` missing",
                t.defining_module(),
            ))
        })?;
        let source_fn = module.defs.iter().find_map(|d| match d {
            Def::Fn(f) if f.name == fn_name => Some(f),
            _ => None,
        }).ok_or_else(|| {
            crate::CheckError::Internal(format!(
                "monomorphise_workspace: source fn `{}::{}` not found",
                t.defining_module(), fn_name,
            ))
        })?;
        synthesise_mono_fn_free(t, source_fn)?
    };

    let target_module = ws_owned.modules.get_mut(t.defining_module()).ok_or_else(|| {
        crate::CheckError::Internal(format!(
            "monomorphise_workspace: defining module `{}` missing",
            t.defining_module(),
        ))
    })?;
    target_module.defs.push(Def::Fn(f));
    synthesised.insert(key);
}

Update the module-level doc-comment block at mono.rs:1-52 to describe the unified shape — the existing wording ("class-free workspaces", "class-method calls only", "Synthesised Def::Fn entries — one per unique (class, method, type-hash) triple") becomes inaccurate. Rewrite to "any Type::Forall-quantified Def::Fn".

  • Step 6.5: Run tests to verify they pass

Run: cargo test --workspace -p ail cmp_max_smoke Expected: PASS (both cmp_max_smoke_synthesises_both_mono_defs_in_one_fixpoint and cmp_max_smoke_workspace_contains_cmp_max_int_and_compare_int_defs).

  • Step 6.6: Run full workspace tests

Run: cargo test --workspace Expected: PASS. Codegen still routes polymorphic-free-fn calls through lower_polymorphic_call at this point (Task 9 removes that path); the call-site rewriter in Phase 3 produces the mono-symbol name, but the polymorphic-free-fn map in codegen still recognises the (now-rewritten) symbol as a non-polymorphic name, falling through to direct module_user_fns lookup. No collision expected. If a test fails here, BLOCK to Boss — the integration is non-trivial.

  • Step 6.7: Commit
git add crates/ailang-check/src/mono.rs crates/ail/tests/e2e.rs examples/cmp_max_smoke.ail.json
git commit -m "iter 23.4.6: monomorphise_workspace — branch synth dispatch on target shape"

Task 7: Verify existing polymorphic fixtures stay green

Files:

  • Test: crates/ail/tests/e2e.rs — no code change; verify-only.

  • Step 7.1: Run the three regression fixtures

cargo test --workspace -p ail polymorphic_id_at_int_and_bool
cargo test --workspace -p ail polymorphic_apply_with_fn_param
cargo test --workspace -p ail poly_rec_capture_demo

Expected: all PASS. These three are the load-bearing proof that codegen-time specialisation was structurally replaceable. They previously routed through lower_polymorphic_call; under the unified pass, the calls are rewritten by mono Phase 3 to direct mono-symbol calls, which lower_call resolves via module_user_fns.

If any fails, BLOCK to Boss. The unification has a regression to debug.

  • Step 7.2: Run the full typeclass_22b3 suite
cargo test --workspace -p ail typeclass_22b3

Expected: PASS. All seven pre-existing class-method-side mono tests still green.

  • Step 7.3: No commit (verify-only task)

No file changes in this task. Proceed to Task 8.


Task 8: Hash-stability pin for existing mono symbols

Files:

  • Modify: crates/ailang-core/src/hash.rs:270-336 — append new test.

  • Test: crates/ailang-core/src/hash.rs::tests::mono_symbols_hash_stable_under_unified_pass.

  • Step 8.1: Capture the post-Task-6 hash values

Run a small driver locally that prints the canonical hashes of eq__Int, compare__Int, eq__Str, compare__Str, eq__Bool, compare__Bool from the mono'd examples/prelude.ail.json. The implementer instruments monomorphise_workspace temporarily or uses an existing ail manifest-style path.

  • Step 8.2: Write the pin test

Append to crates/ailang-core/src/hash.rs after ct4_unmigrated_fixtures_remain_bit_identical (lines 319-335). Structural mirror of ct4_migrated_fixtures_have_canonical_form_hashes:

#[test]
fn mono_symbols_hash_stable_under_unified_pass() {
    // Iter 23.4: pin the canonical hashes of the six mono symbols
    // shipped in iters 23.2 (eq__T) and 23.3 (compare__T). The
    // unified mono pass must produce identical bodies (and therefore
    // identical hashes) to the class-method-only pass that preceded
    // it. A drift here means the unification changed observable
    // class-method specialisation, which the spec explicitly
    // forbids (§"Hash-stability of existing mono symbols").
    use ailang_check::mono::monomorphise_workspace;
    use ailang_core::workspace::Workspace;

    let prelude = load_prelude_workspace(); // helper from tests
    let mono_ws = monomorphise_workspace(&prelude).unwrap();
    let prelude_module = mono_ws.modules.get("prelude").unwrap();

    let expected_hashes: &[(&str, &str)] = &[
        ("eq__Int",       "<captured value from Step 8.1>"),
        ("eq__Bool",      "<captured value>"),
        ("eq__Str",       "<captured value>"),
        ("compare__Int",  "<captured value>"),
        ("compare__Bool", "<captured value>"),
        ("compare__Str",  "<captured value>"),
    ];

    for (name, expected) in expected_hashes {
        let def = prelude_module.defs.iter().find_map(|d| match d {
            ailang_core::ast::Def::Fn(f) if &f.name == name => Some(f),
            _ => None,
        }).unwrap_or_else(|| panic!("mono symbol `{}` not found in prelude module post-mono", name));

        let actual = format!("{:016x}", ailang_core::canonical::fn_hash(def));
        assert_eq!(&actual, expected, "mono symbol `{}` hash drifted from baseline", name);
    }
}
  • Step 8.3: Run test to verify it passes

Run: cargo test --workspace -p ailang-core mono_symbols_hash_stable_under_unified_pass Expected: PASS — values pinned to the captured values from Step 8.1.

  • Step 8.4: Commit
git add crates/ailang-core/src/hash.rs
git commit -m "iter 23.4.8: hash.rs — pin mono-symbol hashes under unified pass"

Task 9: Codegen removal — lower_polymorphic_call + module_polymorphic_fns + mono_queue

Files:

  • Modify: crates/ailang-codegen/src/lib.rs — multiple line ranges (see recon).

  • Modify: crates/ailang-codegen/src/subst.rs — delete apply_subst_to_term, descriptor_for_subst.

  • Step 9.1: Pre-removal smoke

cargo test --workspace

Expected: PASS. This is the baseline; the removal must not regress it.

  • Step 9.2: Remove codegen-time specialiser code

Apply in crates/ailang-codegen/src/lib.rs:

Line range (pre-edit) Action
52 Edit import: keep apply_subst_to_type, derive_substitution, (still used by synth_arg_type at ~2833-2834); remove apply_subst_to_term, descriptor_for_subst,.
297 Remove let mut module_polymorphic_fns: BTreeMap<...> = BTreeMap::new();.
312 Remove the let mut poly_fns: BTreeMap<...> = BTreeMap::new(); init inside Pass 1.
325-330 Remove the Type::Forall { .. } => { poly_fns.insert(...) } arm in the Def::Fn walk.
363 Remove module_polymorphic_fns.insert(mname.clone(), poly_fns);.
399 Remove &module_polymorphic_fns, argument from Emitter::new call.
560 Remove module_polymorphic_fns: &'a BTreeMap<...> field from Emitter.
565-566 Remove mono_queue: Vec<(String, String, BTreeMap<String, Type>, String)> and mono_emitted: BTreeSet<(String, String, String)> fields.
726 Remove module_polymorphic_fns: &'a BTreeMap<...> parameter from Emitter::new signature.
777-779 Remove module_polymorphic_fns,, mono_queue: Vec::new(),, mono_emitted: BTreeSet::new(), initialisers.
842-850 Remove the while let Some((owner_module, def_name, subst, descriptor)) = self.mono_queue.pop() { self.emit_specialised_fn(...) } block in emit_module.
893-950 Remove the emit_specialised_fn function entirely.
1939-1945 Remove the cross-module if self.module_polymorphic_fns.get(&target_module)... early-return in lower_call.
1965-1973 Remove the current-module if self.module_polymorphic_fns.get(self.module_name)... early-return in lower_call.
1994-2100+ Remove lower_polymorphic_call function entirely (~200 LOC).
2260-2263 Remove the self.module_polymorphic_fns.get(self.module_name).is_some_and(...) arm in is_static_callee. The function returns false on the unmatched path.

Apply in crates/ailang-codegen/src/subst.rs:

  • Delete apply_subst_to_term function (entire body).

  • Delete descriptor_for_subst function (entire body).

  • apply_subst_to_type and derive_substitution stay — both still used.

  • Step 9.3: Run cargo build to verify clean compile

cargo build --workspace

Expected: clean compile. If any unused import or dead code warnings fire, fix at this step (e.g. drop use lines that pointed to removed items).

  • Step 9.4: Run full workspace tests
cargo test --workspace

Expected: PASS — same set as Step 9.1, no regression. The mono pass's call-site rewrite is now load-bearing for every polymorphic-free-fn call; if any fixture fails, BLOCK to Boss.

  • Step 9.5: Commit
git add crates/ailang-codegen/src/lib.rs crates/ailang-codegen/src/subst.rs
git commit -m "iter 23.4.9: codegen — remove lower_polymorphic_call + module_polymorphic_fns + mono_queue"

Task 10: Revert prep2 + prep3

Files:

  • Revert: commit a06159d (iter 23.4-prep2).

  • Revert: commit c42a0f5 (iter 23.4-prep3).

  • Step 10.1: Pre-revert smoke

cargo test --workspace

Expected: PASS. Baseline.

  • Step 10.2: Revert prep3 first, then prep2

Recon verified git log a06159d..HEAD -- crates/ailang-codegen/src/lib.rs is empty (no intervening commits touched the file). Reverts apply cleanly.

git revert --no-edit c42a0f5
git revert --no-edit a06159d

If either revert reports a merge conflict, BLOCK to Boss. The recon expected clean reverts; a conflict means the workspace has drifted in a way recon did not predict (likely from this iter's earlier tasks touching adjacent regions).

  • Step 10.3: Run cargo test to verify revert is benign
cargo test --workspace

Expected: PASS. The two prep commits added codegen bare-name fall-through arms + their tests. After mono unification, both arms are dead code (no call site reaches them) and the tests were testing the dead arms directly — they get removed by the revert. No other test relies on the reverted code.

  • Step 10.4: No additional commit needed

git revert --no-edit creates two revert commits (iter 23.4.10a: Revert "iter 23.4-prep3..." and iter 23.4.10b: Revert "iter 23.4-prep2..."); rename them with git commit --amend if cleaner messages are wanted:

git log -2 --oneline
# If renaming desired:
# git commit --amend -m "iter 23.4.10: revert prep2 + prep3 — codegen bare-name fall-throughs now dead"
# (one amend per revert; or fold both reverts into one if the implementer prefers a single squash)

Default: keep the two reverts as separate commits matching git revert's output.


Task 11: DESIGN.md amendment — §"Resolution and monomorphisation"

Files:

  • Modify: docs/DESIGN.md:1513-1556.

  • Step 11.1: Edit the section

Current text at line 1513-1528 describes the pass on (method, concrete-type) pairs and "the IR contains no class machinery". The amendment must say the pass covers all Type::Forall-quantified Def::Fn. Replace the first paragraph of "Monomorphisation (post-typecheck, pre-codegen)" (lines 1513-1528) with:

**Monomorphisation (post-typecheck, pre-codegen).** A pass between
typechecking and codegen replaces every resolved polymorphic call —
both class-method calls and polymorphic-free-fn calls — with a call
to a synthesised monomorphic `FnDef`. The pass operates on all
`Type::Forall`-quantified `Def::Fn`s.

For each unique target encountered:

1. Synthesises a top-level `FnDef` named deterministically from the
   target identifier and the canonical hash of the instance type(s).
   For class methods, the source body is the resolved instance
   method (or default body), with the class parameter substituted
   to the concrete type. For polymorphic free fns, the source body
   is the polymorphic `Def::Fn`'s body, with the Forall-bound type
   variables substituted to their concrete types at the call site.
2. Caches the synthesised def by a structurally-discriminated key so
   the same target is not emitted twice.
3. Rewrites the original `Call` to target the synthesised name.

After this pass, the IR contains no class machinery and no
polymorphic call sites — only ordinary monomorphic functions and
direct calls. Codegen sees no difference between a hand-written
`show_int`, a synthesised `show__Int` (from a class-method
specialisation), or a synthesised `id__Int` (from a polymorphic-
free-fn specialisation).

Verify the surrounding paragraphs ("Why mono, not virtual dispatch" at line 1530+, separator paragraph, "No runtime dispatch, no dictionary passing" at line 1550+) still read consistently — adjust pronouns ("the call", "a call that cannot be monomorphised") if they tacitly assumed class-methods-only. The "No runtime dispatch" claim is strengthened: codegen now has zero polymorphism path.

  • Step 11.2: Verify DESIGN.md round-trip stays green

Run: cargo test --workspace -p ailang-prose round_trip Expected: PASS — DESIGN.md isn't prose-processed but the amendment must not break neighbouring markdown.

  • Step 11.3: Commit
git add docs/DESIGN.md
git commit -m "iter 23.4.11: DESIGN.md — Resolution and monomorphisation covers all Type::Forall Defs"

Final regression smoke: F.1 - F.4

  • F.1: Full workspace test suite
cargo test --workspace

Expected: PASS. All pre-existing tests green; the seven new tests (Tasks 1-8) green; the two prep-revert removals reflected in counts.

  • F.2: Bench regression
python3 bench/check.py
python3 bench/compile_check.py
python3 bench/cross_lang.py

Expected: each script exit 0, or audit-ratified with a follow-up JOURNAL entry naming the metric and rationale. Spec §"Bench regression check" anticipates a compile-time shift (mono pass does more work); a single ratification on bench/compile_check.py is acceptable. Hard regressions on bench/check.py or bench/cross_lang.py BLOCK to Boss.

  • F.3: Manual diff inspection
git log --oneline 18b49e1..HEAD

Expected: ~11 task commits + 2 revert commits + 1 DESIGN.md commit. Inspect the codegen-diff (Task 9) end-to-end: confirm no unrelated removals leaked in.

  • F.4: Spec-acceptance partial check

The plan covers Acceptance Criteria 1, 2, 5 (partial), 6 (partial) of the spec. Criterion 3 (prelude free fns shipped), Criterion 4 (Decision-11 amendment), Criterion 7 (full E2E with Float diagnostic), Criterion 8 (roadmap update) are 23.5 scope — NOT verified here.

Confirm in the per-iter journal:

  • Criterion 1: lower_polymorphic_call + module_polymorphic_fns + mono_queue removed (grep verifies absence); prep commits reverted (git log --oneline | grep "Revert").
  • Criterion 2: DESIGN.md §"Resolution and monomorphisation" amendment lands.
  • Criterion 5 (partial): poly_id / poly_apply / poly_rec_capture green; cmp_max_smoke green.
  • Criterion 6 (partial): three bench scripts at expected exit codes.