832375f2ac
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.
1282 lines
59 KiB
Markdown
1282 lines
59 KiB
Markdown
# Iter 23.4 — Mono-Pass Unification — Implementation Plan
|
||
|
||
> **Parent spec:** `docs/specs/0014-23-eq-ord-prelude.md` (commit 841d65d, re-grounded 2026-05-11 against main HEAD 51da9fa, PASS)
|
||
>
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` to 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::Fn`s — 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) -> a` invoked at Int, proves cross-cutting in one fixpoint
|
||
- `examples/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 load
|
||
- `crates/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` — `MonoTarget` struct + `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 to `rewrite_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` — `use` import (drop `apply_subst_to_term`, `descriptor_for_subst`)
|
||
- `crates/ailang-codegen/src/lib.rs:285-352` — Pass-1 comment + `module_polymorphic_fns` population (delete)
|
||
- `crates/ailang-codegen/src/lib.rs:363,399` — `module_polymorphic_fns` insert + pass-into-Emitter (delete)
|
||
- `crates/ailang-codegen/src/lib.rs:558-566` — Emitter fields `module_polymorphic_fns`, `mono_queue`, `mono_emitted` (delete)
|
||
- `crates/ailang-codegen/src/lib.rs:726,777-779` — `Emitter::new` param + 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 of `lower_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_call` function (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.
|
||
|
||
1. **Mono-symbol naming for N-ary type vars.** Use the spec default: `<name>__<typesurfacename1>__<typesurfacename2>__…` in `Forall.vars` declaration order. The existing `mono_symbol` helper at `mono.rs:297-328` is 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).
|
||
2. **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 with `collect_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.
|
||
3. **Original Free-Fn `Def::Fn` retention post-mono.** Keep the original polymorphic `Def::Fn` in the workspace, parallel to `Def::Class` / `Def::Instance` retention (mono.rs:40-42). Codegen skips them via `is_static_callee`'s existing `Type::Forall` check (which today filters poly fns out of static-call lowering); after this iter, the static-call path resolves the rewritten mono-symbol name instead.
|
||
4. **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:
|
||
|
||
```json
|
||
{
|
||
"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:
|
||
|
||
```rust
|
||
//! 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`:
|
||
|
||
```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`:
|
||
|
||
```rust
|
||
//! 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_workspace` or `monomorphise_workspace` errors 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 handles `Type::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` (`MonoTarget` struct + `mono_target_key`)
|
||
- Modify: every site in mono.rs that constructs or pattern-matches `MonoTarget` (call sites of `mono_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::FreeFn` variant**
|
||
|
||
Add to `crates/ail/tests/mono_unification.rs`:
|
||
|
||
```rust
|
||
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 `MonoTarget` from struct to enum**
|
||
|
||
Edit `crates/ailang-check/src/mono.rs:345-357`:
|
||
|
||
```rust
|
||
/// 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`:
|
||
|
||
```rust
|
||
/// 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 `MonoTarget` construction + 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:
|
||
|
||
```rust
|
||
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 to `collect_mono_targets`
|
||
- Modify: `crates/ailang-check/src/mono.rs:224-252` — `collect_targets_workspace_wide` passes a new `env.polymorphic_free_fns` index
|
||
- Modify: `crates/ailang-check/src/mono.rs:374-384` — `build_workspace_env` adds 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`:
|
||
|
||
```rust
|
||
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:
|
||
|
||
1. Resolve each `args[i]`'s type via the type-environment cached at typecheck.
|
||
2. Unify against the polymorphic free fn's `Type::Forall { vars, body }` to derive a substitution `vars → concrete-types`.
|
||
3. If the substitution is fully-concrete (no `Type::Var` left), emit:
|
||
```rust
|
||
targets.push(MonoTarget::FreeFn {
|
||
name: name.clone(),
|
||
type_args: vars.iter().map(|v| subst[v].clone()).collect(),
|
||
defining_module: defining_module_of(name, env),
|
||
});
|
||
```
|
||
4. 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):
|
||
|
||
```rust
|
||
// 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_fns` to `env`**
|
||
|
||
Edit `crates/ailang-check/src/mono.rs:374-384` (`build_workspace_env`) — extend the returned `Env` with a new index field:
|
||
|
||
```rust
|
||
// 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::Fn`s 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_fn` extension)
|
||
|
||
- [ ] **Step 5.1: Write a unit-test for free-fn synthesis**
|
||
|
||
Add to `crates/ail/tests/mono_unification.rs`:
|
||
|
||
```rust
|
||
#[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:
|
||
|
||
```rust
|
||
/// 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__Int` and not `eq__Int__` or similar) — verify in Step 5.5 against the hash-stability pin.
|
||
- `crate::substitute_rigids` is the existing type-level substitution helper (defined at `crates/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` (or `AstFnDef`) struct: the class-method arm at mono.rs:598-605 produces `AstFnDef { name, ty, params, body, doc: None, suppress: Vec::new() }`. Mirror that exact shape — the field is `ty` (not `type_`) and `doc`/`suppress` defaults must be present.
|
||
|
||
- [ ] **Step 5.4: Update `mono_symbol` to N-ary**
|
||
|
||
Edit `crates/ailang-check/src/mono.rs:297-343`:
|
||
|
||
```rust
|
||
/// 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_calls` in mono.rs (rename + extended call signature)
|
||
|
||
- [ ] **Step 6.1: Write a unit-test for the rewrite**
|
||
|
||
Add to `crates/ail/tests/mono_unification.rs`:
|
||
|
||
```rust
|
||
#[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:
|
||
|
||
```rust
|
||
// 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_ordered` for 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_calls` to 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`:
|
||
|
||
```rust
|
||
#[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:
|
||
|
||
```rust
|
||
/// 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` — `use` import
|
||
- 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_fns` insert + 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_call` body
|
||
- Modify: `crates/ailang-codegen/src/lib.rs:2253-2263` — `is_static_callee` poly arm
|
||
|
||
- [ ] **Step 8.1: Delete `lower_polymorphic_call` and 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_call` function 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::new` parameter for `module_polymorphic_fns` at 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_fn` at 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_fns` declaration + per-module populate loop).
|
||
- Delete line 363 (`module_polymorphic_fns.insert(...)`).
|
||
- Delete line 399 (passing `module_polymorphic_fns` into `Emitter::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:
|
||
|
||
```rust
|
||
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 `use` import**
|
||
|
||
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.rs` and `mono_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_callee` behaviour (verify the simplification did not change semantics).
|
||
- A surface-level type mismatch from the `use` import 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`:
|
||
|
||
```rust
|
||
#[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:
|
||
|
||
```markdown
|
||
**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)
|
||
|
||
1. **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).
|
||
|
||
2. **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. ✓
|
||
|
||
3. **Type consistency:**
|
||
- `MonoTarget::ClassMethod` / `MonoTarget::FreeFn` used consistently across Tasks 3, 4, 5, 6.
|
||
- `mono_symbol(name, types: &[Type])` consistent across Steps 5.3 and 5.4.
|
||
- `polymorphic_free_fns` field consistent across Steps 4.4, 5.3.
|
||
- `synthesise_mono_fn_for_free_fn` named consistently in Steps 5.1, 5.3.
|
||
- `rewrite_mono_calls` (renamed from `rewrite_class_method_calls`) consistent across Step 6.3, 6.5.
|
||
- Test names verified unique across files (`mono_unification.rs` vs. `mono_hash_stability.rs`). ✓
|
||
|
||
4. **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_calls` helper" + "wire it into `collect_mono_targets`" inside the same step block. Acceptable.
|
||
|
||
5. **No commit steps:** ✓ No `git commit` instructions in any task. All work accumulates in the working tree.
|
||
|
||
6. **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::Fn` retention → 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.md`
|
||
- `iter_id`: `23.4`
|
||
- `task_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.
|