plan: drop 23.4 — design assumption broke at branch verification

The 23.4 mono-unification plan (ea0285b) assumed prep1 + prep2 +
prep3 were on main. They never were — they sit only on iter/23.4.
Spec component table was factually wrong. Plan Task 10 (revert prep
commits) was impossible on main. Discarding the plan; the spec is
next revised against the actual main state.
This commit is contained in:
2026-05-11 22:01:31 +02:00
parent ea0285b85f
commit 4bbcb947a3
-982
View File
@@ -1,982 +0,0 @@
# iter 23.4 — Mono-Pass Unification + prep2/prep3 Rollback — Implementation Plan
> **Parent spec:** `docs/specs/2026-05-11-23-eq-ord-prelude.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
> to run this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Unify the two specialisers (typecheck-time mono pass + codegen-time `lower_polymorphic_call`) into one typecheck-time mono pass that handles every `Type::Forall`-quantified `Def::Fn`. Remove the codegen-time specialiser and revert the two anticipatory codegen prep commits.
**Architecture:** Mono pass gains a second source-body entry point (free-fn direct, in addition to class-method via `Registry::entries`). Target shape, dedup key, call-site rewriter, and `mono_symbol` naming all extend to accommodate free-fn targets with N-ary type vars. Codegen's `lower_polymorphic_call`, `module_polymorphic_fns`, and `mono_queue` are removed wholesale; the two codegen call sites (lines 1944, 1972 pre-edit) consume mono symbols rewritten by the unified pass. Anticipatory prep commits `a06159d` (prep2) and `c42a0f5` (prep3) revert cleanly.
**Tech Stack:** `crates/ailang-check/src/mono.rs` (centrepiece), `crates/ailang-codegen/src/lib.rs` + `crates/ailang-codegen/src/subst.rs` (removals), `crates/ail/tests/typeclass_22b3.rs` + `crates/ail/tests/e2e.rs` (new tests), `crates/ailang-core/src/hash.rs` (mono-symbol hash pin), `docs/DESIGN.md` (Decision 11 §"Resolution and monomorphisation" amendment), `examples/cmp_max_smoke.ail.json` (new composition fixture).
---
**Files this plan creates or modifies:**
- Modify: `crates/ailang-check/src/mono.rs:1-870` — full restructure into unified specialiser.
- Modify: `crates/ailang-codegen/src/lib.rs:52, 287-330, 363-400, 555-780, 842-950, 1939-2100, 2260-2263` — removal of `lower_polymorphic_call`, `module_polymorphic_fns`, `mono_queue`.
- Modify: `crates/ailang-codegen/src/subst.rs:120-240, 270-320` — delete now-unused `apply_subst_to_term` + `descriptor_for_subst`.
- Modify: `crates/ail/tests/typeclass_22b3.rs` — add free-fn-side test parity (mirror naming).
- Test: `crates/ail/tests/e2e.rs` — new `cmp_max_smoke_emits_both_mono_defs` E2E.
- Modify: `crates/ailang-core/src/hash.rs:270-336` — new pin test `mono_symbols_hash_stable_under_unified_pass`.
- Create: `examples/cmp_max_smoke.ail.json` — composition fixture (poly free-fn + nested class method).
- Modify: `docs/DESIGN.md:1513-1556` — §"Resolution and monomorphisation" amendment.
- Revert: commits `a06159d` (iter 23.4-prep2) and `c42a0f5` (iter 23.4-prep3).
**Implementer Open commitments (per Spec §Open commitments — leave default-or-NEEDS_CONTEXT):**
- `MonoTarget` shape (enum variant vs Option-shaped fields) — implementer's call.
- Polymorphic-free-fn detection mechanism (extend `synth`-residual pathway vs separate post-synth walker) — implementer's call.
- Whether the original polymorphic `Def::Fn` source survives in the workspace post-mono — default: yes, parallel to `Def::Class`/`Def::Instance` preservation per `mono.rs:40-42`.
- `subst.rs` dead-code handling — plan calls for outright deletion of unused fns, see Task 9.
---
## Task 1: `MonoTarget` shape extension for free-fn variant
**Files:**
- Modify: `crates/ailang-check/src/mono.rs:351-372``MonoTarget` struct + `mono_target_key`.
- Test: `crates/ail/tests/typeclass_22b3.rs` — add `mono_target_key_distinguishes_class_method_from_free_fn`.
- [ ] **Step 1.1: Write the failing test**
Add to `crates/ail/tests/typeclass_22b3.rs`:
```rust
#[test]
fn mono_target_key_distinguishes_class_method_from_free_fn() {
use ailang_check::mono::{MonoTarget, mono_target_key};
use ailang_core::ast::Type;
let int_ty = Type::Con { name: "Int".into() };
let class_method_target = MonoTarget::class_method(
"Eq".into(),
"eq".into(),
int_ty.clone(),
"prelude".into(),
);
let free_fn_target = MonoTarget::free_fn(
"ne".into(),
vec![int_ty.clone()],
"prelude".into(),
);
let k1 = mono_target_key(&class_method_target);
let k2 = mono_target_key(&free_fn_target);
assert_ne!(k1, k2, "free-fn and class-method targets must have distinct keys");
}
```
- [ ] **Step 1.2: Run test to verify it fails**
Run: `cargo test --workspace -p ail mono_target_key_distinguishes_class_method_from_free_fn`
Expected: FAIL with compile error — `MonoTarget::class_method` and `MonoTarget::free_fn` constructors don't exist; `mono_target_key` may not be `pub`.
- [ ] **Step 1.3: Restructure `MonoTarget` and `mono_target_key`**
Edit `crates/ailang-check/src/mono.rs:351-372`. The struct currently is:
```rust
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:
```rust
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.:
```rust
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**
```bash
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` — add `mono_symbol_handles_n_ary_type_vars`.
- [ ] **Step 2.1: Write the failing test**
Add to `crates/ail/tests/typeclass_22b3.rs`:
```rust
#[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):
```rust
/// 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**
```bash
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` + new `synthesise_mono_fn_free`.
- Test: `crates/ail/tests/typeclass_22b3.rs` — add `synthesise_mono_fn_free_produces_substituted_body`.
- [ ] **Step 3.1: Write the failing test**
Add to `crates/ail/tests/typeclass_22b3.rs`:
```rust
#[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+):
```rust
/// 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`:
```rust
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**
```bash
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` — add `collect_mono_targets_emits_free_fn_target_for_concrete_call`.
- [ ] **Step 4.1: Write the failing test**
Add to `crates/ail/tests/typeclass_22b3.rs`:
```rust
#[test]
fn collect_mono_targets_emits_free_fn_target_for_concrete_call() {
// Workspace: prelude with `fn id : forall a. (a) -> a, body = x`.
// Driver module with `fn main : () -> Int, body = id(42)`.
// collect_mono_targets on `main` must emit a free-fn target for
// id at type [Int].
use ailang_check::mono::{collect_mono_targets, build_workspace_env};
let ws = build_test_workspace_with_poly_id_call();
let env = build_workspace_env(&ws);
let driver_module = ws.modules.get("driver").unwrap();
let main_fn = driver_module.defs.iter().find_map(|d| match d {
ailang_core::ast::Def::Fn(f) if f.name == "main" => Some(f),
_ => None,
}).unwrap();
let targets = collect_mono_targets(main_fn, "driver", &env).unwrap();
let has_free_fn_target = targets.iter().any(|t| {
t.free_fn_name() == Some("id") &&
t.free_fn_type_substs() == Some(&[Type::Con { name: "Int".into() }][..])
});
assert!(has_free_fn_target, "expected free-fn target for id at Int; got {:?}", targets);
}
// Helper: a minimal workspace with prelude::id (polymorphic) and
// driver::main calling id(42). Implementer mirrors the structure of
// the existing `build_test_workspace_with_class_method_call` helper
// at typeclass_22b3.rs:N (find existing helper via grep).
fn build_test_workspace_with_poly_id_call() -> Workspace { /* fill in */ }
```
- [ ] **Step 4.2: Run test to verify it fails**
Run: `cargo test --workspace -p ail collect_mono_targets_emits_free_fn_target_for_concrete_call`
Expected: FAIL — collector doesn't yet detect polymorphic free-fn calls. (May fail with compile error if `free_fn_name` helper isn't defined; add it as a thin accessor on `MonoTarget` alongside `free_fn_type_substs` from Task 3.)
- [ ] **Step 4.3: Extend `collect_mono_targets` with polymorphic-free-fn-call detection**
Per spec Open commitment, two viable shapes:
Option A — extend `synth`'s residual mechanism to surface polymorphic-free-fn-call residuals alongside class-constraint residuals. This is the deeper change; consult `crates/ailang-check/src/lib.rs` `synth` arms.
Option B — add a separate post-synth AST walker over `f.body`. Pattern-match `Term::App { callee: Term::Var { name }, .. }` where `name` resolves to a `Type::Forall` `Def::Fn` in `env.module_globals` (per-module, including implicit-import fall-through per prep1 commit `aef4ab8`). Use `synth`'s subst to recover the concrete type substitution at the call site.
Implementer picks A or B. Option B is the smaller delta and recommended unless plan-recon discoveries argue otherwise. Either way, the new code emits `MonoTarget::free_fn(...)` entries into `out` alongside the existing class-constraint targets (mono.rs:503).
- [ ] **Step 4.4: Mirror the extension in `collect_residuals_ordered`**
`collect_residuals_ordered` (mono.rs:793-870) MUST emit an aligned `Option<MonoTarget>` slot for every polymorphic-free-fn call site, in the same pre-order as `rewrite_class_method_calls` walks. Otherwise the cursor alignment Phase 3 depends on breaks. Mirror whichever mechanism (A or B) was chosen in Step 4.3.
- [ ] **Step 4.5: Run test to verify it passes**
Run: `cargo test --workspace -p ail collect_mono_targets_emits_free_fn_target_for_concrete_call`
Expected: PASS
- [ ] **Step 4.6: Verify class-method-side regression**
Run: `cargo test --workspace -p ail typeclass_22b3`
Expected: PASS — all existing 22b.3 tests still green.
- [ ] **Step 4.7: Commit**
```bash
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` — add `rewrite_replaces_free_fn_call_site_with_mono_symbol`.
- [ ] **Step 5.1: Write the failing test**
Add to `crates/ail/tests/typeclass_22b3.rs`:
```rust
#[test]
fn rewrite_replaces_free_fn_call_site_with_mono_symbol() {
use ailang_check::mono::{rewrite_class_method_calls, MonoTarget};
use ailang_core::ast::{Term, Type};
use std::collections::BTreeSet;
// Body: id(42)
let mut body = Term::App {
callee: Box::new(Term::Var { name: "id".into() }),
args: vec![Term::Lit { /* Int 42 */ }],
};
let int_ty = Type::Con { name: "Int".into() };
let ordered = vec![Some(MonoTarget::free_fn(
"id".into(),
vec![int_ty.clone()],
"prelude".into(),
))];
let class_methods = Default::default();
let mut cursor = 0;
let mut locals = BTreeSet::new();
rewrite_class_method_calls(
&mut body,
&class_methods,
"driver",
&ordered,
&mut cursor,
&mut locals,
);
match &body {
Term::App { callee, .. } => match callee.as_ref() {
Term::Var { name } => assert_eq!(name, "id__Int"),
other => panic!("expected Term::Var callee after rewrite, got {other:?}"),
},
other => panic!("expected Term::App, got {other:?}"),
}
}
```
- [ ] **Step 5.2: Run test to verify it fails**
Run: `cargo test --workspace -p ail rewrite_replaces_free_fn_call_site_with_mono_symbol`
Expected: FAIL — rewriter does not yet handle free-fn targets.
- [ ] **Step 5.3: Extend `rewrite_class_method_calls`**
Edit `crates/ailang-check/src/mono.rs:637-764`. The `Term::App { callee: Term::Var { name }, .. }` arm currently consults `class_methods` only (around lines 646-668). Extend so that when the `ordered[cursor]` slot is `Some(MonoTarget::FreeFn { ... })`, the callee is rewritten to `Term::Var { name: mono_symbol_n_ary(fn_name, type_substs) }`.
The function name `rewrite_class_method_calls` becomes a misnomer; rename to `rewrite_mono_targets_at_call_sites`. Update all call sites:
- `monomorphise_workspace` Phase 3 at `mono.rs:192-209` (two sites: `Def::Fn` and `Def::Const` branches).
- [ ] **Step 5.4: Run test to verify it passes**
Run: `cargo test --workspace -p ail rewrite_replaces_free_fn_call_site_with_mono_symbol`
Expected: PASS
- [ ] **Step 5.5: Verify class-method-side regression**
Run: `cargo test --workspace -p ail typeclass_22b3`
Expected: PASS
- [ ] **Step 5.6: Commit**
```bash
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_workspace` fixpoint dispatch.
- Create: `examples/cmp_max_smoke.ail.json`.
- Test: `crates/ail/tests/e2e.rs` — add `cmp_max_smoke_synthesises_both_mono_defs_in_one_fixpoint`.
- [ ] **Step 6.1: Create `examples/cmp_max_smoke.ail.json`**
Composition fixture: a polymorphic free fn whose body calls a class method, then a `main` that invokes it at `Int`. Structurally mirror `examples/test_22b3_chained_calls.ail.json` (per recon).
```json
{
"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`:
```rust
#[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:
```rust
for t in &new {
let key = mono_target_key(t);
let f: AstFnDef = if let Some(_class_name) = t.class_method_class() {
// Class-method target — existing path.
let t_ty_norm = ws_owned.registry.normalize_type_for_lookup(t.type_for_synth());
let registry_key = (t.class_method_class().unwrap().to_string(),
ailang_core::canonical::type_hash(&t_ty_norm));
let entry = ws_owned.registry.entries.get(&registry_key).ok_or_else(|| {
crate::CheckError::Internal(format!(
"monomorphise_workspace: target `{} {}` has no registry entry",
t.class_method_class().unwrap(),
ailang_core::pretty::type_to_string(t.type_for_synth()),
))
})?;
let class_def = class_index.get(t.class_method_class().unwrap()).ok_or_else(|| {
crate::CheckError::Internal(format!(
"monomorphise_workspace: class `{}` not found",
t.class_method_class().unwrap(),
))
})?;
synthesise_mono_fn(t, class_def, &entry.instance)?
} else {
// Free-fn target — new path.
let fn_name = t.free_fn_name().ok_or_else(|| {
crate::CheckError::Internal("monomorphise_workspace: target shape unrecognised".into())
})?;
let module = ws_owned.modules.get(t.defining_module()).ok_or_else(|| {
crate::CheckError::Internal(format!(
"monomorphise_workspace: defining module `{}` missing",
t.defining_module(),
))
})?;
let source_fn = module.defs.iter().find_map(|d| match d {
Def::Fn(f) if f.name == fn_name => Some(f),
_ => None,
}).ok_or_else(|| {
crate::CheckError::Internal(format!(
"monomorphise_workspace: source fn `{}::{}` not found",
t.defining_module(), fn_name,
))
})?;
synthesise_mono_fn_free(t, source_fn)?
};
let target_module = ws_owned.modules.get_mut(t.defining_module()).ok_or_else(|| {
crate::CheckError::Internal(format!(
"monomorphise_workspace: defining module `{}` missing",
t.defining_module(),
))
})?;
target_module.defs.push(Def::Fn(f));
synthesised.insert(key);
}
```
Update the module-level doc-comment block at `mono.rs:1-52` to describe the unified shape — the existing wording ("class-free workspaces", "class-method calls only", "Synthesised `Def::Fn` entries — one per unique `(class, method, type-hash)` triple") becomes inaccurate. Rewrite to "any `Type::Forall`-quantified `Def::Fn`".
- [ ] **Step 6.5: Run tests to verify they pass**
Run: `cargo test --workspace -p ail cmp_max_smoke`
Expected: PASS (both `cmp_max_smoke_synthesises_both_mono_defs_in_one_fixpoint` and `cmp_max_smoke_workspace_contains_cmp_max_int_and_compare_int_defs`).
- [ ] **Step 6.6: Run full workspace tests**
Run: `cargo test --workspace`
Expected: PASS. Codegen still routes polymorphic-free-fn calls through `lower_polymorphic_call` at this point (Task 9 removes that path); the call-site rewriter in Phase 3 produces the mono-symbol name, but the polymorphic-free-fn map in codegen still recognises the (now-rewritten) symbol as a non-polymorphic name, falling through to direct `module_user_fns` lookup. No collision expected. If a test fails here, BLOCK to Boss — the integration is non-trivial.
- [ ] **Step 6.7: Commit**
```bash
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**
```bash
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**
```bash
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`:
```rust
#[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**
```bash
git add crates/ailang-core/src/hash.rs
git commit -m "iter 23.4.8: hash.rs — pin mono-symbol hashes under unified pass"
```
---
## Task 9: Codegen removal — `lower_polymorphic_call` + `module_polymorphic_fns` + `mono_queue`
**Files:**
- Modify: `crates/ailang-codegen/src/lib.rs` — multiple line ranges (see recon).
- Modify: `crates/ailang-codegen/src/subst.rs` — delete `apply_subst_to_term`, `descriptor_for_subst`.
- [ ] **Step 9.1: Pre-removal smoke**
```bash
cargo test --workspace
```
Expected: PASS. This is the baseline; the removal must not regress it.
- [ ] **Step 9.2: Remove codegen-time specialiser code**
Apply in `crates/ailang-codegen/src/lib.rs`:
| Line range (pre-edit) | Action |
|---|---|
| 52 | Edit import: keep `apply_subst_to_type, derive_substitution,` (still used by `synth_arg_type` at ~2833-2834); remove `apply_subst_to_term, descriptor_for_subst,`. |
| 297 | Remove `let mut module_polymorphic_fns: BTreeMap<...> = BTreeMap::new();`. |
| 312 | Remove the `let mut poly_fns: BTreeMap<...> = BTreeMap::new();` init inside Pass 1. |
| 325-330 | Remove the `Type::Forall { .. } => { poly_fns.insert(...) }` arm in the `Def::Fn` walk. |
| 363 | Remove `module_polymorphic_fns.insert(mname.clone(), poly_fns);`. |
| 399 | Remove `&module_polymorphic_fns,` argument from `Emitter::new` call. |
| 560 | Remove `module_polymorphic_fns: &'a BTreeMap<...>` field from `Emitter`. |
| 565-566 | Remove `mono_queue: Vec<(String, String, BTreeMap<String, Type>, String)>` and `mono_emitted: BTreeSet<(String, String, String)>` fields. |
| 726 | Remove `module_polymorphic_fns: &'a BTreeMap<...>` parameter from `Emitter::new` signature. |
| 777-779 | Remove `module_polymorphic_fns,`, `mono_queue: Vec::new(),`, `mono_emitted: BTreeSet::new(),` initialisers. |
| 842-850 | Remove the `while let Some((owner_module, def_name, subst, descriptor)) = self.mono_queue.pop() { self.emit_specialised_fn(...) }` block in `emit_module`. |
| 893-950 | Remove the `emit_specialised_fn` function entirely. |
| 1939-1945 | Remove the cross-module `if self.module_polymorphic_fns.get(&target_module)...` early-return in `lower_call`. |
| 1965-1973 | Remove the current-module `if self.module_polymorphic_fns.get(self.module_name)...` early-return in `lower_call`. |
| 1994-2100+ | Remove `lower_polymorphic_call` function entirely (~200 LOC). |
| 2260-2263 | Remove the `self.module_polymorphic_fns.get(self.module_name).is_some_and(...)` arm in `is_static_callee`. The function returns `false` on the unmatched path. |
Apply in `crates/ailang-codegen/src/subst.rs`:
- Delete `apply_subst_to_term` function (entire body).
- Delete `descriptor_for_subst` function (entire body).
- `apply_subst_to_type` and `derive_substitution` stay — both still used.
- [ ] **Step 9.3: Run cargo build to verify clean compile**
```bash
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**
```bash
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**
```bash
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**
```bash
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.
```bash
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**
```bash
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:
```bash
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:
```markdown
**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**
```bash
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**
```bash
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**
```bash
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**
```bash
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.