From ea0285b85f14f4880e45c08956ccfb8ffc97455a Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 11 May 2026 21:42:14 +0200 Subject: [PATCH] =?UTF-8?q?plan:=2023.4=20=E2=80=94=20mono-pass=20unificat?= =?UTF-8?q?ion=20+=20prep2/prep3=20rollback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eleven tasks plus final regression smoke. Tasks 1-6 build the unified mono pass incrementally (MonoTarget shape, N-ary naming, free-fn synth, target collection, call-site rewrite, fixpoint dispatch). Task 7 verifies poly_id/poly_apply/poly_rec_capture regress green. Task 8 pins mono-symbol hashes. Task 9 removes codegen-time lower_polymorphic_call + module_polymorphic_fns + mono_queue. Task 10 reverts prep2 (a06159d) + prep3 (c42a0f5). Task 11 amends DESIGN.md §Resolution-and-monomorphisation to cover all Type::Forall Defs. Implementer open commitments deferred per spec: MonoTarget shape (enum vs Option), collector mechanism (synth-residual extension vs separate walker), polymorphic Def::Fn retention post-mono, subst.rs dead-code handling. Plan calls each out with a default and NEEDS_CONTEXT permission for alternatives. --- docs/plans/2026-05-11-iter-23.4.md | 982 +++++++++++++++++++++++++++++ 1 file changed, 982 insertions(+) create mode 100644 docs/plans/2026-05-11-iter-23.4.md diff --git a/docs/plans/2026-05-11-iter-23.4.md b/docs/plans/2026-05-11-iter-23.4.md new file mode 100644 index 0000000..12e8732 --- /dev/null +++ b/docs/plans/2026-05-11-iter-23.4.md @@ -0,0 +1,982 @@ +# 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, 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. +/// `______…` 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 = 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 { + // 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` 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(®istry_key).ok_or_else(|| { + crate::CheckError::Internal(format!( + "monomorphise_workspace: target `{} {}` has no registry entry", + t.class_method_class().unwrap(), + ailang_core::pretty::type_to_string(t.type_for_synth()), + )) + })?; + let class_def = class_index.get(t.class_method_class().unwrap()).ok_or_else(|| { + crate::CheckError::Internal(format!( + "monomorphise_workspace: class `{}` not found", + t.class_method_class().unwrap(), + )) + })?; + synthesise_mono_fn(t, class_def, &entry.instance)? + } else { + // Free-fn target — new path. + let fn_name = t.free_fn_name().ok_or_else(|| { + crate::CheckError::Internal("monomorphise_workspace: target shape unrecognised".into()) + })?; + let module = ws_owned.modules.get(t.defining_module()).ok_or_else(|| { + crate::CheckError::Internal(format!( + "monomorphise_workspace: defining module `{}` missing", + t.defining_module(), + )) + })?; + let source_fn = module.defs.iter().find_map(|d| match d { + Def::Fn(f) if f.name == fn_name => Some(f), + _ => None, + }).ok_or_else(|| { + crate::CheckError::Internal(format!( + "monomorphise_workspace: source fn `{}::{}` not found", + t.defining_module(), fn_name, + )) + })?; + synthesise_mono_fn_free(t, source_fn)? + }; + + let target_module = ws_owned.modules.get_mut(t.defining_module()).ok_or_else(|| { + crate::CheckError::Internal(format!( + "monomorphise_workspace: defining module `{}` missing", + t.defining_module(), + )) + })?; + target_module.defs.push(Def::Fn(f)); + synthesised.insert(key); +} +``` + +Update the module-level doc-comment block at `mono.rs:1-52` to describe the unified shape — the existing wording ("class-free workspaces", "class-method calls only", "Synthesised `Def::Fn` entries — one per unique `(class, method, type-hash)` triple") becomes inaccurate. Rewrite to "any `Type::Forall`-quantified `Def::Fn`". + +- [ ] **Step 6.5: Run tests to verify they pass** + +Run: `cargo test --workspace -p ail cmp_max_smoke` +Expected: PASS (both `cmp_max_smoke_synthesises_both_mono_defs_in_one_fixpoint` and `cmp_max_smoke_workspace_contains_cmp_max_int_and_compare_int_defs`). + +- [ ] **Step 6.6: Run full workspace tests** + +Run: `cargo test --workspace` +Expected: PASS. Codegen still routes polymorphic-free-fn calls through `lower_polymorphic_call` at this point (Task 9 removes that path); the call-site rewriter in Phase 3 produces the mono-symbol name, but the polymorphic-free-fn map in codegen still recognises the (now-rewritten) symbol as a non-polymorphic name, falling through to direct `module_user_fns` lookup. No collision expected. If a test fails here, BLOCK to Boss — the integration is non-trivial. + +- [ ] **Step 6.7: Commit** + +```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", ""), + ("eq__Bool", ""), + ("eq__Str", ""), + ("compare__Int", ""), + ("compare__Bool", ""), + ("compare__Str", ""), + ]; + + 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)>` 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.