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.
41 KiB
iter 23.4 — Mono-Pass Unification + prep2/prep3 Rollback — Implementation Plan
Parent spec:
docs/specs/2026-05-11-23-eq-ord-prelude.mdFor agentic workers: REQUIRED SUB-SKILL: use
skills/implementto 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 oflower_polymorphic_call,module_polymorphic_fns,mono_queue. - Modify:
crates/ailang-codegen/src/subst.rs:120-240, 270-320— delete now-unusedapply_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— newcmp_max_smoke_emits_both_mono_defsE2E. - Modify:
crates/ailang-core/src/hash.rs:270-336— new pin testmono_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) andc42a0f5(iter 23.4-prep3).
Implementer Open commitments (per Spec §Open commitments — leave default-or-NEEDS_CONTEXT):
MonoTargetshape (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::Fnsource survives in the workspace post-mono — default: yes, parallel toDef::Class/Def::Instancepreservation permono.rs:40-42. subst.rsdead-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-372—MonoTargetstruct +mono_target_key. -
Test:
crates/ail/tests/typeclass_22b3.rs— addmono_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
MonoTargetandmono_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-343—mono_symbol+primitive_surface_name. -
Test:
crates/ail/tests/typeclass_22b3.rs— addmono_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-606—synthesise_mono_fn+ newsynthesise_mono_fn_free. -
Test:
crates/ail/tests/typeclass_22b3.rs— addsynthesise_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-511—collect_mono_targets. -
Modify:
crates/ailang-check/src/mono.rs:793-870—collect_residuals_ordered. -
Test:
crates/ail/tests/typeclass_22b3.rs— addcollect_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_targetswith 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-764—rewrite_class_method_calls. -
Test:
crates/ail/tests/typeclass_22b3.rs— addrewrite_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_workspacePhase 3 atmono.rs:192-209(two sites:Def::FnandDef::Constbranches). -
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-217—monomorphise_workspacefixpoint dispatch. -
Create:
examples/cmp_max_smoke.ail.json. -
Test:
crates/ail/tests/e2e.rs— addcmp_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(®istry_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— deleteapply_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_termfunction (entire body). -
Delete
descriptor_for_substfunction (entire body). -
apply_subst_to_typeandderive_substitutionstay — 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.