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.
59 KiB
Iter 23.4 — Mono-Pass Unification — Implementation Plan
Parent spec:
docs/specs/0014-23-eq-ord-prelude.md(commit841d65d, re-grounded 2026-05-11 against main HEAD51da9fa, PASS)For agentic workers: REQUIRED SUB-SKILL: use
skills/implementto 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) -> ainvoked at Int, proves cross-cutting in one fixpointexamples/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 loadcrates/ail/tests/mono_unification.rs— workspace-level tests for free-fn mono behaviour (the cmp_max_smoke RED → GREEN test and the chained-fixpoint extension)crates/ail/tests/mono_hash_stability.rs— regression pin asserting the six primitive mono-symbol body hashes stay bit-stable under the unified pass
Modify:
crates/ailang-check/src/mono.rs:1-52— module docstring (extend to cover free-fn entry)crates/ailang-check/src/mono.rs:67-217—monomorphise_workspace(rebuild fixpoint+rewrite for both target kinds)crates/ailang-check/src/mono.rs:224-252—collect_targets_workspace_wide(call new free-fn collector arm)crates/ailang-check/src/mono.rs:291-295—workspace_has_typeclasses(generalise predicate)crates/ailang-check/src/mono.rs:297-343—mono_symbol+primitive_surface_name(extend for N-ary type vars)crates/ailang-check/src/mono.rs:345-372—MonoTargetstruct +mono_target_key(widen for free-fn case)crates/ailang-check/src/mono.rs:413-511—collect_mono_targets(add free-fn arm)crates/ailang-check/src/mono.rs:513-606—synthesise_mono_fn(route by target kind to class or free-fn source body)crates/ailang-check/src/mono.rs:608-770—rewrite_class_method_calls(rename torewrite_mono_calls, fire on bare poly free-fn names too)crates/ailang-check/src/mono.rs:793-870—collect_residuals_ordered(lockstep-extend for free-fn rewrites)crates/ailang-codegen/src/lib.rs:52—useimport (dropapply_subst_to_term,descriptor_for_subst)crates/ailang-codegen/src/lib.rs:285-352— Pass-1 comment +module_polymorphic_fnspopulation (delete)crates/ailang-codegen/src/lib.rs:363,399—module_polymorphic_fnsinsert + pass-into-Emitter (delete)crates/ailang-codegen/src/lib.rs:558-566— Emitter fieldsmodule_polymorphic_fns,mono_queue,mono_emitted(delete)crates/ailang-codegen/src/lib.rs:726,777-779—Emitter::newparam + init (delete)crates/ailang-codegen/src/lib.rs:839-850— mono drain loop (delete)crates/ailang-codegen/src/lib.rs:888-950—emit_specialised_fn(delete)crates/ailang-codegen/src/lib.rs:1939-1945— call-site #1 oflower_polymorphic_call(delete; flow falls through to user-fn path)crates/ailang-codegen/src/lib.rs:1965-1973— call-site #2 (delete)crates/ailang-codegen/src/lib.rs:1990-2104—lower_polymorphic_callfunction (delete)crates/ailang-codegen/src/lib.rs:2253-2263—is_static_callee(collapse poly-path arm)docs/DESIGN.md:1513-1556— §"Monomorphisation (post-typecheck, pre-codegen)" amendment
Open commitments resolved by this plan
The spec lists four implementer-decisions. This plan resolves the first three; the fourth is out of scope for 23.4.
- Mono-symbol naming for N-ary type vars. Use the spec default:
<name>__<typesurfacename1>__<typesurfacename2>__…inForall.varsdeclaration order. The existingmono_symbolhelper atmono.rs:297-328is 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). - 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 withcollect_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. - Original Free-Fn
Def::Fnretention post-mono. Keep the original polymorphicDef::Fnin the workspace, parallel toDef::Class/Def::Instanceretention (mono.rs:40-42). Codegen skips them viais_static_callee's existingType::Forallcheck (which today filters poly fns out of static-call lowering); after this iter, the static-call path resolves the rewritten mono-symbol name instead. - Float-NoInstance diagnostic wording. OUT OF SCOPE for 23.4. Deferred to 23.5 where the negative E2E fixture exercises it.
Tasks
Task 1: Hash-stability regression pin
The unified pass must produce bit-identical mono-symbol bodies for the six existing primitive Eq/Ord symbols. This task adds the pin BEFORE the refactor so the pin starts green; if the refactor in tasks 3–8 perturbs a mono body, this test fails and the implementer must investigate.
Files:
-
Create:
examples/mono_hash_pin_smoke.ail.json— fixture exercising all six primitive mono symbols -
Create:
crates/ail/tests/mono_hash_stability.rs— the pin test -
Test name:
primitive_eq_ord_mono_symbol_hashes_stay_bit_identical -
Step 1.1: Create the smoke fixture
Write examples/mono_hash_pin_smoke.ail.json with content:
{
"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 3–8.
Task 2: cmp_max_smoke fixture + RED workspace-level test
Add the cross-cutting fixture from spec §Testing strategy §23.4 (b). Asserts that after monomorphise_workspace, the workspace contains both cmp_max__Int and compare__Int as Def::Fn. Today this is RED because cmp_max is a polymorphic free fn handled by codegen, not by mono.
Files:
-
Create:
examples/cmp_max_smoke.ail.json— composition fixture -
Create:
crates/ail/tests/mono_unification.rs— new integration-test file -
Test name in that file:
cmp_max_smoke_produces_both_mono_symbols_in_one_fixpoint -
Step 2.1: Create the fixture
Write examples/cmp_max_smoke.ail.json:
{
"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_workspaceormonomorphise_workspaceerrors 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 handlesType::Forall { constraints }resolution).
This test stays RED until Task 9 (after the unification lands in Tasks 3–8).
Task 3: Widen MonoTarget to carry both class-method and free-fn kinds
Today MonoTarget (mono.rs:345-357) has a class: String field assuming every target comes from a class-method residual. The new free-fn arm needs to carry a polymorphic free-fn callsite. Widen via enum variants.
Files:
-
Modify:
crates/ailang-check/src/mono.rs:345-372(MonoTargetstruct +mono_target_key) -
Modify: every site in mono.rs that constructs or pattern-matches
MonoTarget(call sites ofmono_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::FreeFnvariant
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
MonoTargetfrom 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
MonoTargetconstruction + pattern-match sites in mono.rs
The grep target is mono.rs itself. Every existing MonoTarget { class, method, ... } construction becomes MonoTarget::ClassMethod { class, method, ... }. Every existing field-access on target.class / target.method / target.type_ / target.defining_module becomes a match against the ClassMethod arm (free-fn arm gets a todo!() placeholder filled by Tasks 4–6).
Pattern for each site:
match target {
MonoTarget::ClassMethod { class, method, type_, defining_module } => {
// existing logic, now using bindings from the destructure
}
MonoTarget::FreeFn { .. } => todo!("free-fn arm — filled in Tasks 4–6"),
}
After the edits, cargo build -p ailang-check should succeed with todo!()s in place.
- Step 3.5: Verify build green + Task-3 unit test green
Run: cargo build -p ailang-check
Expected: PASS (warnings about unreachable todo!() arms are acceptable).
Run: cargo test -p ail --test mono_unification mono_target_free_fn_variant_keys_distinctly_from_class_method
Expected: PASS.
Run: cargo test -p ail --test mono_hash_stability primitive_eq_ord_mono_symbol_hashes_stay_bit_identical
Expected: PASS (mono pass logic for class methods unchanged behaviourally).
Run: cargo test -p ail --test mono_unification cmp_max_smoke_produces_both_mono_symbols_in_one_fixpoint
Expected: still FAILS at the cmp_max__Int assertion (free-fn arm not yet wired).
Task 4: Free-fn arm in collect_mono_targets (+ workspace-wide collector)
Today collect_mono_targets (mono.rs:413-511) re-runs synth and filters class-constraint residuals. Add a sibling walker over Term::App { fn: Term::Var { name }, args } sites that resolve to a polymorphic free Def::Fn with a fully-concrete substitution at the call site.
Files:
-
Modify:
crates/ailang-check/src/mono.rs:413-511— add free-fn arm tocollect_mono_targets -
Modify:
crates/ailang-check/src/mono.rs:224-252—collect_targets_workspace_widepasses a newenv.polymorphic_free_fnsindex -
Modify:
crates/ailang-check/src/mono.rs:374-384—build_workspace_envadds 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:
- Resolve each
args[i]'s type via the type-environment cached at typecheck. - Unify against the polymorphic free fn's
Type::Forall { vars, body }to derive a substitutionvars → concrete-types. - If the substitution is fully-concrete (no
Type::Varleft), 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), }); - 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_fnstoenv
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_fnextension) -
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__Intand noteq__Int__or similar) — verify in Step 5.5 against the hash-stability pin. -
crate::substitute_rigidsis the existing type-level substitution helper (defined atcrates/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(orAstFnDef) struct: the class-method arm at mono.rs:598-605 producesAstFnDef { name, ty, params, body, doc: None, suppress: Vec::new() }. Mirror that exact shape — the field isty(nottype_) anddoc/suppressdefaults must be present. -
Step 5.4: Update
mono_symbolto 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_calls→rewrite_mono_calls) -
Modify:
crates/ailang-check/src/mono.rs:793-870(collect_residuals_ordered) -
Modify: every caller of
rewrite_class_method_callsin 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_orderedfor 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_callsto 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 3–7, the mono pass leaves NO polymorphic call sites in the workspace passed to codegen. The codegen-time specialiser is fully redundant. Remove it.
Files:
-
Modify:
crates/ailang-codegen/src/lib.rs:52—useimport -
Modify:
crates/ailang-codegen/src/lib.rs:285-352— Pass-1 comment + per-module poly populate -
Modify:
crates/ailang-codegen/src/lib.rs:363,399—module_polymorphic_fnsinsert + Emitter param -
Modify:
crates/ailang-codegen/src/lib.rs:558-566— Emitter struct fields -
Modify:
crates/ailang-codegen/src/lib.rs:726,777-779— Emitter::new -
Modify:
crates/ailang-codegen/src/lib.rs:839-850— mono drain loop -
Modify:
crates/ailang-codegen/src/lib.rs:888-950—emit_specialised_fn -
Modify:
crates/ailang-codegen/src/lib.rs:1939-1945,1965-1973— call sites -
Modify:
crates/ailang-codegen/src/lib.rs:1990-2104—lower_polymorphic_callbody -
Modify:
crates/ailang-codegen/src/lib.rs:2253-2263—is_static_calleepoly arm -
Step 8.1: Delete
lower_polymorphic_calland 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_callfunction 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::newparameter formodule_polymorphic_fnsat 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_fnat 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_fnsdeclaration + per-module populate loop). -
Delete line 363 (
module_polymorphic_fns.insert(...)). -
Delete line 399 (passing
module_polymorphic_fnsintoEmitter::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
useimport
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.rsandmono_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_calleebehaviour (verify the simplification did not change semantics). - A surface-level type mismatch from the
useimport 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)
-
Spec coverage:
- §Architecture §1 (Mono-Pass unification) → Tasks 3, 4, 5, 6, 7 + DESIGN.md amendment in Task 10. ✓
- §Architecture §1 (codegen-time specialiser removed) → Task 8. ✓
- §Data flow §"Polymorphic free-fn call" → exercised end-to-end in Task 9 (cmp_max_smoke runs at Int). ✓
- §Testing strategy §23.4 (a) regression for poly_id/poly_apply/poly_rec_capture → Step 9.3. ✓
- §Testing strategy §23.4 (b) cmp_max_smoke fixture + workspace-level assertion → Tasks 2 + 9.1-9.2. ✓
- §Testing strategy §23.4 (c) hash-stability pin → Task 1. ✓
- §Testing strategy §23.4 (d) rollback proof (cargo test green after removals) → Step 8.6. ✓
- §Testing strategy §23.4 (e) bench check → Task 11. ✓
- §Acceptance #1 (mono unification + codegen removal) → Tasks 3–8. ✓
- §Acceptance #2 (DESIGN.md amendment) → Task 10. ✓
- §Acceptance #5 (cargo test green) → Step 8.6 + Step 9.4. ✓
- §Acceptance #6 (bench ratified) → Task 11. ✓
- §Acceptance #3, #4, #7, #8 → out of scope (23.5).
-
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. ✓
-
Type consistency:
MonoTarget::ClassMethod/MonoTarget::FreeFnused consistently across Tasks 3, 4, 5, 6.mono_symbol(name, types: &[Type])consistent across Steps 5.3 and 5.4.polymorphic_free_fnsfield consistent across Steps 4.4, 5.3.synthesise_mono_fn_for_free_fnnamed consistently in Steps 5.1, 5.3.rewrite_mono_calls(renamed fromrewrite_class_method_calls) consistent across Step 6.3, 6.5.- Test names verified unique across files (
mono_unification.rsvs.mono_hash_stability.rs). ✓
-
Step granularity: Each step is 2–5 minutes for the actor (write code shown, run one command, observe result). The exception is Step 4.3 (free-fn arm implementation) which is structurally larger; it's split into "write
walk_free_fn_callshelper" + "wire it intocollect_mono_targets" inside the same step block. Acceptable. -
No commit steps: ✓ No
git commitinstructions in any task. All work accumulates in the working tree. -
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::Fnretention → 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.mditer_id:23.4task_range: full (Tasks 1–11)- Notes for the orchestrator-agent:
- Tasks 3–6 are tightly coupled; Tasks 7+8 follow from their landing.
- Task 8 is a pure-removal task — its TDD shape is "cargo build green + full test suite green" rather than RED-first unit-test.
- The hash-stability pin (Task 1) is a regression tracker that should stay green throughout Tasks 3–8; if it RED-fires at any task, that task perturbed a mono body that should have been bit-stable. Investigate before proceeding.
- Open commitment §4 (Float diagnostic wording) is deferred to iter 23.5 and must not be addressed in this plan.