From 4845f913b6d343f7a83f8cd77e57b2bb96db09e4 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 11 May 2026 09:46:11 +0200 Subject: [PATCH] plan: ct.4 milestone close --- docs/plans/2026-05-11-ct.4-milestone-close.md | 930 ++++++++++++++++++ 1 file changed, 930 insertions(+) create mode 100644 docs/plans/2026-05-11-ct.4-milestone-close.md diff --git a/docs/plans/2026-05-11-ct.4-milestone-close.md b/docs/plans/2026-05-11-ct.4-milestone-close.md new file mode 100644 index 0000000..e14761d --- /dev/null +++ b/docs/plans/2026-05-11-ct.4-milestone-close.md @@ -0,0 +1,930 @@ +# Iteration ct.4: Milestone Close — Implementation Plan + +> **Parent spec:** `docs/specs/2026-05-10-canonical-type-names.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` +> to run this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Land the milestone-closing consolidation: amend DESIGN.md +with the canonical-form rule, pin hashes for the two migrated +cross-module fixtures, add qualifier-trim-on-print to the prose +printer with a snapshot test, and recreate +`examples/compare_primitives_smoke.ail.json` as the end-to-end +demonstration that closes the iter-23.3-Task-4 bug. + +**Architecture:** Four tasks across DESIGN, hash regression, prose +printer, and end-to-end fixture. Task 1 is design writing. Task 2 +adds new hash-pin tests (the existing pins on `sum`/`list` are +intra-module and unaffected; the migrated `ordering_match` / +`test_22b1_dup_a` get new pins to lock the post-migration state). +Task 3 threads `module_name` context through the prose printer so +intra-module qualified Type::Cons get trimmed on print, plus a +snapshot for `ordering_match`. Task 4 authors the +`compare_primitives_smoke` fixture (9 Ord.compare invocations +across Int/Bool/Str, expected stdout `1\n2\n3\n1\n2\n3\n1\n2\n3\n`) +and adds an E2E test plus an IR-shape integration test asserting +the compare__T mono symbols are emitted. + +**Tech Stack:** `docs/DESIGN.md`, `crates/ailang-core/src/hash.rs`, +`crates/ailang-prose/src/lib.rs`, `crates/ailang-prose/tests/snapshot.rs`, +`examples/*.ail.json` + `.prose.txt`, `crates/ail/tests/e2e.rs`. + +--- + +## Files this plan creates or modifies + +- Modify: `docs/DESIGN.md` — amend Decision 2 (around line 1067) + with the canonical-form rule for `Type::Con` names; review the + surrounding hash-stability promises at lines 1121, 1136-1139 to + make sure they explicitly cite the migrated fixtures as the + named exception. +- Modify: `crates/ailang-core/src/hash.rs` — append three new pin + tests near the existing `iter22b1_schema_extension_preserves_pre_22b_hashes` + block: one pinning the new canonical hashes of the two migrated + fixtures, one asserting `sum.ail.json` and `list.ail.json` (both + intra-module-only) still hash bit-identically. +- Modify: `crates/ailang-prose/src/lib.rs` — thread an + `owning_module: &str` context through `write_module` → `write_def` + → ... → `write_type`; in `write_type`'s `Type::Con` arm, trim a + qualifier whose owner matches `owning_module`. +- Modify: `crates/ailang-prose/tests/snapshot.rs` — add one test + `snapshot_ordering_match` consuming the new + `examples/ordering_match.prose.txt` snapshot. +- Create: `examples/ordering_match.prose.txt` — committed prose + snapshot for the migrated cross-module fixture. +- Create: `examples/compare_primitives_smoke.ail.json` — the + iter-23.3 demonstration fixture. +- Modify: `crates/ail/tests/e2e.rs` — append two tests: + `compare_primitives_smoke_prints_1_2_3_thrice` (E2E) and + `compare_primitives_smoke_emits_compare_mono_symbols` (IR shape). + +No files are deleted. + +--- + +## Task 1: DESIGN.md Decision 2 amendment + +Decision 2 (content-addressed defs) in DESIGN.md is the spec of +the `.ail.json` canonical form. It currently does not pin down +how `Type::Con` names are scoped — that policy lived in +JOURNAL-level prose until ct.1 made it normative. This task +writes the rule into DESIGN.md so future iters check against it +during architect drift review. + +**Files:** +- Modify: `docs/DESIGN.md` near line 1067 (Decision 2 / canonical + form) — add a new subsection or paragraph naming the rule. +- Modify: `docs/DESIGN.md` line 1136-1139 — update the + `iter19b_*` regression-pin citation to also name the new + ct.1-era pins for migrated fixtures. + +- [ ] **Step 1: Read the Decision 2 surrounding context** + +Read `docs/DESIGN.md` lines 1050-1145 (approximately). Identify +the natural insertion point for the canonical-form rule — likely +adjacent to the existing "JSON canonical hash for every existing +fixture stays bit-identical" paragraph at 1067-1070. + +- [ ] **Step 2: Insert the canonical-form rule** + +Edit `docs/DESIGN.md`. After the paragraph at lines 1067-1070 +("JSON canonical hash for every existing fixture stays bit- +identical..."), insert a new paragraph block: + +```markdown +**Type::Con name scoping (canonical form, since ct.1).** Within a +`.ail.json`, a `Type::Con.name` is interpreted relative to the +file's top-level `"name"` field (the owning module). Bare names +(no `.`) refer to a TypeDef in the owning module's own `defs`. +Cross-module references MUST be qualified `.` +where `` is a known module in the workspace. +Primitives (`Int`, `Bool`, `Str`, `Unit`, `Float`) are bare and +have no module qualifier. Bare cross-module references are a +schema violation (`WorkspaceLoadError::BareCrossModuleTypeRef`); +qualified references whose owner is unknown are also a violation +(`WorkspaceLoadError::BadCrossModuleTypeRef`). The same rule +applies to `Term::Ctor.type_name`. + +Class names (`ClassDef.name`, `InstanceDef.class`, +`SuperclassRef.class`, `Constraint.class`) are NOT module-scoped +under this rule; they remain workspace-flat with +`MethodNameCollision` enforced at load. Class-name scoping is a +future milestone with its own DESIGN amendment. +``` + +- [ ] **Step 3: Update the regression-pin citation at 1136-1139** + +Edit `docs/DESIGN.md`. Find the paragraph at lines 1136-1139 that +ends with "regression-pinned by `iter19b_empty_suppress_preserves_pre_19b_hashes` +and `iter19b_schema_extension_preserves_pre_19b_hashes`." + +Append at the end of that paragraph: + +``` +The canonical-form tightening in ct.1 shifted the hashes of two +cross-module fixtures (`ordering_match.ail.json` and +`test_22b1_dup_a.ail.json`); all intra-module fixtures, including +the regression-pinned `sum.ail.json` and `list.ail.json`, remain +bit-identical. The new pins are +`ct4_migrated_fixtures_have_canonical_form_hashes` (locks the +post-migration hashes) and +`ct4_unmigrated_fixtures_remain_bit_identical` (re-asserts the +existing 13a/19b/22b.1 hashes still hold). +``` + +- [ ] **Step 4: Verify DESIGN.md still parses cleanly** + +Run: `cargo doc --workspace --no-deps 2>&1 | grep -i 'DESIGN\.md\|warning' | head` + +Expected: no new errors (DESIGN.md is rustdoc-rendered in places; +the additions are plain markdown with no special syntax). + +- [ ] **Step 5: Commit** + +```bash +git add docs/DESIGN.md +git commit -m "iter ct.4.1: DESIGN.md Decision 2 — canonical Type::Con name scoping rule" +``` + +--- + +## Task 2: Hash pins for migrated fixtures + +The existing pin tests in `crates/ailang-core/src/hash.rs` use +`sum.ail.json` and `list.ail.json` — both intra-module, neither +touched by the ct.1 migration. Their pinned hashes remain valid +and don't need re-baselining. This task ADDS new pins that lock +in the canonical-form hashes of the two migrated fixtures, plus +re-asserts the unmigrated pins still hold. + +**Files:** +- Modify: `crates/ailang-core/src/hash.rs` — append two new tests + at the end of `mod tests`. + +- [ ] **Step 1: Capture the current hashes of the migrated fixtures** + +Run the following one-liner to print the canonical hash of each +migrated fixture's relevant def. From a Rust REPL is overkill; +write a single throwaway test in `crates/ailang-core/src/hash.rs` +that asserts known-wrong hashes (so it fails and prints the +actual hash): + +Add inside `mod tests`: + +```rust + #[test] + #[ignore] // helper: run manually with --ignored + fn print_migrated_fixture_hashes() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let examples = manifest_dir.join("../../examples"); + + let ord_src = std::fs::read(examples.join("ordering_match.ail.json")).unwrap(); + let ord_mod: crate::ast::Module = serde_json::from_slice(&ord_src).unwrap(); + let main_def = ord_mod.defs.iter().find(|d| d.name() == "main").unwrap(); + eprintln!("ordering_match::main: {}", def_hash(main_def)); + + let dup_a_src = std::fs::read(examples.join("test_22b1_dup_a.ail.json")).unwrap(); + let dup_a_mod: crate::ast::Module = serde_json::from_slice(&dup_a_src).unwrap(); + for d in &dup_a_mod.defs { + eprintln!("test_22b1_dup_a::{}: {}", d.name(), def_hash(d)); + } + + panic!("captured hashes — see stderr"); + } +``` + +Run: `cargo test --workspace -p ailang-core print_migrated_fixture_hashes --ignored -- --nocapture 2>&1 | grep '::'` + +Expected: prints hash lines for `ordering_match::main` and each +`test_22b1_dup_a::`. Record the hashes for use in Step 2. + +- [ ] **Step 2: Replace the helper with the real pin test** + +Edit `crates/ailang-core/src/hash.rs` — replace the throwaway +helper test with the real pin: + +```rust + /// Iter ct.4 (canonical-type-names milestone close): pin the + /// canonical-form hashes of the two cross-module fixtures + /// migrated by `ail migrate-canonical-types` in ct.1. These + /// hashes are the post-migration state; pre-migration values + /// (when both fixtures carried bare cross-module Type::Con) + /// are no longer reproducible because the ct.1 validator + /// rejects that shape upstream. + #[test] + fn ct4_migrated_fixtures_have_canonical_form_hashes() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let examples = manifest_dir.join("../../examples"); + + let ord_src = std::fs::read(examples.join("ordering_match.ail.json")) + .expect("examples/ordering_match.ail.json present"); + let ord_mod: crate::ast::Module = serde_json::from_slice(&ord_src).unwrap(); + let main_def = ord_mod.defs.iter().find(|d| d.name() == "main").unwrap(); + assert_eq!( + def_hash(main_def), + // Replace this string with the actual hash printed in Step 1. + "REPLACE_WITH_CAPTURED_HASH_FROM_STEP_1", + "ordering_match::main canonical hash must match captured post-migration value" + ); + + let dup_a_src = std::fs::read(examples.join("test_22b1_dup_a.ail.json")) + .expect("examples/test_22b1_dup_a.ail.json present"); + let dup_a_mod: crate::ast::Module = serde_json::from_slice(&dup_a_src).unwrap(); + // Pin the first def's hash. If test_22b1_dup_a has multiple defs, + // pin only the type def that carries the migrated qualifier. + let first_def = dup_a_mod.defs.first().expect("at least one def"); + assert_eq!( + def_hash(first_def), + "REPLACE_WITH_SECOND_CAPTURED_HASH_FROM_STEP_1", + "test_22b1_dup_a::{} canonical hash must match captured post-migration value", + first_def.name(), + ); + } +``` + +You'll need to substitute the actual hash strings from Step 1's +captured output and remove the placeholder strings. If +`test_22b1_dup_a` has multiple defs of relevance, pin each (one +`assert_eq!` per def). + +- [ ] **Step 3: Add a re-assertion pin for unmigrated fixtures** + +Edit `crates/ailang-core/src/hash.rs` — append a second new test: + +```rust + /// Iter ct.4: re-assert that the canonical-form tightening + /// did NOT change hashes of intra-module-only fixtures. The + /// existing iter-13a / iter-19b / iter-22b.1 pin tests already + /// assert these; this one names the canonical-type-names + /// milestone explicitly so future archaeology finds the + /// connection. + #[test] + fn ct4_unmigrated_fixtures_remain_bit_identical() { + let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let examples = manifest_dir.join("../../examples"); + + let sum_src = std::fs::read(examples.join("sum.ail.json")).unwrap(); + let sum_mod: crate::ast::Module = serde_json::from_slice(&sum_src).unwrap(); + let sum_def = sum_mod.defs.iter().find(|d| d.name() == "sum").unwrap(); + assert_eq!(def_hash(sum_def), "db33f57cb329935e", + "sum.sum hash drifted across canonical-form tightening — unexpected"); + + let list_src = std::fs::read(examples.join("list.ail.json")).unwrap(); + let list_mod: crate::ast::Module = serde_json::from_slice(&list_src).unwrap(); + let int_list_def = list_mod.defs.iter().find(|d| d.name() == "IntList").unwrap(); + assert_eq!(def_hash(int_list_def), "b082192bd0c99202", + "list.IntList hash drifted across canonical-form tightening — unexpected"); + } +``` + +- [ ] **Step 4: Run the pin tests** + +Run: `cargo test --workspace -p ailang-core ct4_migrated_fixtures_have_canonical_form_hashes ct4_unmigrated_fixtures_remain_bit_identical` + +Expected: BOTH PASS. + +- [ ] **Step 5: Run the full test suite** + +Run: `cargo test --workspace` + +Expected: all pass (test count grows by 2). + +- [ ] **Step 6: Commit** + +```bash +git add crates/ailang-core/src/hash.rs +git commit -m "iter ct.4.2: pin canonical-form hashes for migrated fixtures + unmigrated sanity" +``` + +--- + +## Task 3: Prose qualifier-trim-on-print + snapshot + +The prose printer at `crates/ailang-prose/src/lib.rs:462` writes +`Type::Con.name` verbatim. When the canonical `.ail.json` already +has bare names for intra-module Type::Cons (the canonical +convention), this is fine. But the validator does not reject +qualified intra-module refs (it only rejects bare cross-module +and bad-qualified); a hand-authored fixture could legally have +`prelude.Ordering` inside prelude's own file. The prose form +should print this as bare `Ordering` so the prose surface stays +LLM-author-friendly (qualifier omitted because it follows from +the file's module name). + +This task threads `owning_module` context through the prose +printer and trims at `write_type`'s `Type::Con` arm. Plus a +snapshot test that pins the print behaviour for the migrated +cross-module fixture (`ordering_match.ail.json`, which uses +`prelude.Ordering` cross-module qualifier — the trim does NOT +fire here because the qualifier's owner differs from the file's +module). + +**Files:** +- Modify: `crates/ailang-prose/src/lib.rs` — thread `owning_module` + through every `write_*` fn signature from `write_module` down to + `write_type`; trim in `write_type`'s `Type::Con` arm. +- Create: `examples/ordering_match.prose.txt` — committed + snapshot. +- Modify: `crates/ailang-prose/tests/snapshot.rs` — register one + new snapshot test. + +- [ ] **Step 1: Write the RED test for trim behaviour** + +Append to `crates/ailang-prose/src/lib.rs` `mod tests` (or wherever +the existing unit tests live — search for `mod tests` near the +bottom of the file): + +```rust + /// ct.4 Task 3: when a `Type::Con.name` is qualified with the + /// owning module's own name (e.g. `prelude.Ordering` inside + /// `prelude`'s own file), the prose printer must trim the + /// qualifier and emit bare `Ordering`. Cross-module qualified + /// refs (`prelude.Ordering` inside any non-prelude file) + /// round-trip verbatim. + #[test] + fn type_con_qualifier_trimmed_when_owner_matches_module() { + use ailang_core::ast::{Module, Def, FnDef, Type, Term, Literal, ParamMode}; + + let m = Module { + schema: "ailang/v0".into(), + name: "prelude".into(), + imports: vec![], + defs: vec![Def::Fn(FnDef { + name: "noop".into(), + ty: Type::Fn { + params: vec![], + ret: Box::new(Type::Con { + name: "prelude.Ordering".into(), + args: vec![], + }), + effects: vec![], + param_modes: vec![], + ret_mode: ParamMode::Implicit, + }, + params: vec![], + body: Term::Ctor { + type_name: "prelude.Ordering".into(), + ctor: "EQ".into(), + args: vec![], + }, + doc: None, + suppress: vec![], + })], + }; + + let prose = module_to_prose(&m); + assert!( + prose.contains("-> Ordering"), + "expected bare `Ordering` in fn return type (owner == file's \ + module); got prose:\n{}", + prose + ); + assert!( + !prose.contains("prelude.Ordering"), + "expected NO `prelude.Ordering` after trim; got prose:\n{}", + prose + ); + } + + /// ct.4 Task 3: a cross-module Type::Con (owner != current + /// file's module) must round-trip verbatim — no trim. + #[test] + fn type_con_qualifier_preserved_when_owner_differs() { + use ailang_core::ast::{Module, Def, FnDef, Type, Term, Literal, ParamMode, Import}; + + let m = Module { + schema: "ailang/v0".into(), + name: "user".into(), + imports: vec![Import { module: "prelude".into(), alias: None }], + defs: vec![Def::Fn(FnDef { + name: "lt".into(), + ty: Type::Fn { + params: vec![], + ret: Box::new(Type::Con { + name: "prelude.Ordering".into(), + args: vec![], + }), + effects: vec![], + param_modes: vec![], + ret_mode: ParamMode::Implicit, + }, + params: vec![], + body: Term::Ctor { + type_name: "prelude.Ordering".into(), + ctor: "LT".into(), + args: vec![], + }, + doc: None, + suppress: vec![], + })], + }; + + let prose = module_to_prose(&m); + assert!( + prose.contains("prelude.Ordering"), + "expected qualified `prelude.Ordering` preserved (owner != file's \ + module); got prose:\n{}", + prose + ); + } +``` + +- [ ] **Step 2: Run the RED tests to verify they fail** + +Run: `cargo test --workspace -p ailang-prose type_con_qualifier_trimmed type_con_qualifier_preserved` + +Expected: +- `type_con_qualifier_trimmed_when_owner_matches_module`: FAIL (current printer writes `prelude.Ordering` verbatim). +- `type_con_qualifier_preserved_when_owner_differs`: PASS (current printer already writes qualified verbatim). + +- [ ] **Step 3: Thread `owning_module` through the print signatures** + +Edit `crates/ailang-prose/src/lib.rs`. Modify the following +signatures to add `owning_module: &str` as the last parameter +(after `level: usize` where present, before `out`-mutating return +of `()`): + +- `write_def` +- `write_type_def` +- `write_fn_def` +- `write_const_def` +- `write_class_def` +- `write_instance_def` +- `write_type` +- `write_mode_type` (if it exists; check) +- any other `write_*` that consumes a `Type` or `Term` + +For each call site within the same file, add the propagated +`m.name.as_str()` argument from the entry point (`module_to_prose` +passes `m.name.as_str()` to `write_module`'s helper, which then +threads it to every `write_def` call). + +Refactor the entry point `module_to_prose`: + +```rust +pub fn module_to_prose(m: &Module) -> String { + let mut out = String::new(); + write_module(&mut out, m); + out +} +``` + +stays the same; the threading begins inside `write_module`. + +`write_module`'s body — the `m.name.as_str()` is captured locally +(let `module_name = m.name.as_str();`) and passed to each +`write_def(&mut out, def, level, module_name)` call. + +- [ ] **Step 4: Apply the trim in `write_type`'s `Type::Con` arm** + +Edit `crates/ailang-prose/src/lib.rs` around line 465. The `Type::Con` +arm currently reads: + +```rust + Type::Con { name, args } => { + out.push_str(name); + // ... args writing + } +``` + +Replace with: + +```rust + Type::Con { name, args } => { + // Trim a qualifier whose owner matches the current + // file's module: `prelude.Ordering` printed from + // inside `prelude` becomes bare `Ordering`. Cross- + // module qualifiers (`prelude.Ordering` printed from + // inside any non-prelude module) survive verbatim. + let display_name = match name.split_once('.') { + Some((owner, suffix)) if owner == owning_module => suffix, + _ => name.as_str(), + }; + out.push_str(display_name); + // ... args writing (preserved verbatim, recursing + // into write_type with the same owning_module) + } +``` + +Where the args recursion calls `write_type(out, a)`, update to +`write_type(out, a, owning_module)`. + +- [ ] **Step 5: Apply the same trim in `Term::Ctor`'s type_name (in write_term, wherever it lives)** + +The prose printer also writes `Term::Ctor.type_name` somewhere +in its term-lowering. Search for `Term::Ctor` in `crates/ailang-prose/src/lib.rs` +and find the analogous `out.push_str(type_name)` site. Apply the +same trim logic: + +```rust +// in the Term::Ctor write arm — find the existing string-push site +let display_type = match type_name.split_once('.') { + Some((owner, suffix)) if owner == owning_module => suffix, + _ => type_name.as_str(), +}; +out.push_str(display_type); +``` + +- [ ] **Step 6: Run the RED tests now expecting GREEN** + +Run: `cargo test --workspace -p ailang-prose type_con_qualifier_trimmed type_con_qualifier_preserved` + +Expected: BOTH PASS. + +- [ ] **Step 7: Regenerate existing prose snapshots** + +The trim changes how `prelude.Ordering` (and any other +qualifier-matching pattern) renders. Most existing prose +snapshots are intra-module-only fixtures with no qualified +Type::Cons — they should be byte-identical. Verify: + +Run: `cargo test --workspace -p ailang-prose` + +Expected: all existing snapshot tests still pass. If any fail +with a `.prose.txt.actual` byte diff, manually inspect the diff: +- If the diff is ONLY the qualifier-trim behaviour we just added + (and the snapshot input file does have qualified intra-module + Type::Cons), regenerate the snapshot: + `cargo run -p ail -- prose examples/.ail.json > examples/.prose.txt`. + Confirm the diff post-regenerate is what you expect. +- If the diff is OTHERWISE unexpected, that is a bug — investigate. + +For each snapshot whose `.actual` file appeared, decide whether +to regenerate or revert. + +- [ ] **Step 8: Create the `ordering_match` snapshot** + +Run: `cargo run -p ail -- prose examples/ordering_match.ail.json > examples/ordering_match.prose.txt` + +Expected: command succeeds, the output file is non-empty and +contains `prelude.Ordering` (cross-module qualifier preserved +because `prelude` ≠ `ordering_match`). + +- [ ] **Step 9: Register the new snapshot test** + +Edit `crates/ailang-prose/tests/snapshot.rs`. Append: + +```rust +#[test] +fn snapshot_ordering_match() { + check_snapshot("ordering_match"); +} +``` + +- [ ] **Step 10: Run the prose test suite** + +Run: `cargo test --workspace -p ailang-prose` + +Expected: all pass, including the new `snapshot_ordering_match`. + +- [ ] **Step 11: Run the full test suite** + +Run: `cargo test --workspace` + +Expected: all pass. + +- [ ] **Step 12: Commit** + +```bash +git add crates/ailang-prose/src/lib.rs \ + crates/ailang-prose/tests/snapshot.rs \ + examples/ordering_match.prose.txt +git commit -m "iter ct.4.3: prose qualifier-trim-on-print + ordering_match snapshot" +``` + +--- + +## Task 4: Recreate `compare_primitives_smoke.ail.json` + E2E + +This task resumes the paused iter 23.3 work (TaskList #15+#16): +recreate the end-to-end fixture that demonstrates the +iter-23.1/23.3 bug closure, run it through the full pipeline, +and lock the IR-shape integration assertion. + +The fixture: consumer module `compare_primitives_smoke` imports +prelude, calls `Ord.compare` on 9 pairs (3 each of Int/Bool/Str +arranged as LT-case / EQ-case / GT-case), pattern-matches each +result on `LT | EQ | GT`, prints 1 / 2 / 3 respectively. Expected +stdout: `1\n2\n3\n1\n2\n3\n1\n2\n3\n`. + +**Files:** +- Create: `examples/compare_primitives_smoke.ail.json` — the + fixture. +- Modify: `crates/ail/tests/e2e.rs` — append the E2E test and the + IR-shape integration test. + +- [ ] **Step 1: Author the canonical-form fixture** + +Create `examples/compare_primitives_smoke.ail.json` with the +structure below. The fixture has one fn `main` with a `body` +that is a sequence of 9 `do io/print_int` statements; each +`print_int` wraps a `match` on `(call prelude.compare A B)` +returning a `prelude.Ordering`. Use qualified +`prelude.Ordering` throughout (canonical post-ct.1 since the +type's owner ≠ this file's module). + +For brevity, build the fixture incrementally — start with one +case, verify it loads, then add the rest. The shape of a single +case (Int, LT): + +```json +{ + "t": "do", + "op": "io/print_int", + "args": [ + { + "t": "match", + "scrutinee": { + "t": "app", + "fn": { "t": "var", "name": "prelude.compare" }, + "args": [ + { "t": "lit", "lit": { "kind": "int", "value": 1 } }, + { "t": "lit", "lit": { "kind": "int", "value": 2 } } + ] + }, + "arms": [ + { + "pat": { "p": "ctor", "ctor": "LT", "fields": [] }, + "body": { "t": "lit", "lit": { "kind": "int", "value": 1 } } + }, + { + "pat": { "p": "ctor", "ctor": "EQ", "fields": [] }, + "body": { "t": "lit", "lit": { "kind": "int", "value": 2 } } + }, + { + "pat": { "p": "ctor", "ctor": "GT", "fields": [] }, + "body": { "t": "lit", "lit": { "kind": "int", "value": 3 } } + } + ] + } + ] +} +``` + +Compose 9 such blocks (Int LT/EQ/GT, Bool LT/EQ/GT, Str LT/EQ/GT) +in a `seq` chain. For Bool, use `bool` literals (`{"kind": "bool", "value": false}`); +for Str, use `str` literals (`{"kind": "str", "value": "a"}`). + +The fn `main` returns `Unit` with `IO` effect: + +```json +{ + "schema": "ailang/v0", + "name": "compare_primitives_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": , "rhs": { "t": "seq", "lhs": , "rhs": ... ... } } + } + ] +} +``` + +The 9 cases pair-pattern (which value, which compare, expected output): + +| # | type | A | B | expected match | print | +|---|------|--------|--------|----------------|-------| +| 1 | Int | 1 | 2 | LT | 1 | +| 2 | Int | 1 | 1 | EQ | 2 | +| 3 | Int | 2 | 1 | GT | 3 | +| 4 | Bool | false | true | LT | 1 | +| 5 | Bool | false | false | EQ | 2 | +| 6 | Bool | true | false | GT | 3 | +| 7 | Str | "a" | "b" | LT | 1 | +| 8 | Str | "a" | "a" | EQ | 2 | +| 9 | Str | "b" | "a" | GT | 3 | + +You'll write this manually as one big JSON file. Use 2-space +indentation and standard `serde_json`-canonical key ordering +(the validator + manifest test will fail otherwise; alternatively, +run the file through `cargo run -p ail -- manifest examples/compare_primitives_smoke.ail.json` +to canonicalise once it loads, then commit the canonicalised +form). + +- [ ] **Step 2: Verify the fixture loads + typechecks** + +Run: `cargo run -p ail -- check examples/compare_primitives_smoke.ail.json` + +Expected: exit 0, no diagnostics. If validator complains about +canonical form (e.g. bare `Ordering` somewhere), fix and re-run. +If typechecker complains about an unknown ctor or pattern type +mismatch, that is the iter-23.1 bug — investigate (it should +NOT happen post-ct.2). + +- [ ] **Step 3: Verify the fixture runs end-to-end** + +Run: `cargo run -p ail -- run examples/compare_primitives_smoke.ail.json` + +Expected: stdout exactly `1\n2\n3\n1\n2\n3\n1\n2\n3\n` (nine +lines, no extra newline at the very end depending on the runtime +printer convention — match whatever the existing +`ordering_match_via_prelude_prints_1` test asserts for trailing +newline). + +If the run fails, the iter-23.1 bug closure is NOT complete — +escalate to orchestrator. + +- [ ] **Step 4: Append the E2E test to e2e.rs** + +Edit `crates/ail/tests/e2e.rs`. Append after the +`ordering_match_via_prelude_prints_1` test (around line 920): + +```rust +/// Iter 23.3 Task 4 (resumed in ct.4): the end-to-end +/// demonstration that the iter-23.1 cross-module Type::Con +/// mismatch bug is closed. `examples/compare_primitives_smoke.ail.json` +/// imports prelude, calls `prelude.compare` on three pairs each +/// of Int / Bool / Str (arranged LT / EQ / GT per type), and +/// pattern-matches the result. Expected stdout encodes the +/// 9-case decision lattice as `1\n2\n3` × 3. +/// +/// Pre-ct.1 / pre-ct.2, the bare `Ordering` returned by +/// `compare` mismatched the qualified `prelude.Ordering` +/// scrutinee at the user-side pattern lookup. Post-canonical- +/// type-names, the scrutinee is qualified throughout, the +/// pattern lookup is type-driven, and the run completes cleanly. +#[test] +fn compare_primitives_smoke_prints_1_2_3_thrice() { + let stdout = build_and_run("compare_primitives_smoke.ail.json"); + assert_eq!(stdout, "1\n2\n3\n1\n2\n3\n1\n2\n3\n"); +} +``` + +- [ ] **Step 5: Append the IR-shape integration test** + +Append immediately after the E2E test: + +```rust +/// Iter 23.3 Task 4 / IR-shape: the mono pass must emit +/// `compare__Int`, `compare__Bool`, and `compare__Str` symbols +/// driven by the three `prelude.compare` call sites in the +/// smoke fixture. Codegen's `try_emit_primitive_instance_body` +/// intercepts each one (iter 23.3.2). This test pins the +/// presence of those symbols in the emitted IR so a future +/// refactor cannot silently drop one. +#[test] +fn compare_primitives_smoke_emits_compare_mono_symbols() { + let ir = emit_ir("compare_primitives_smoke.ail.json"); + assert!( + ir.contains("compare__Int") || ir.contains("@compare__Int"), + "compare__Int symbol missing from emitted IR" + ); + assert!( + ir.contains("compare__Bool") || ir.contains("@compare__Bool"), + "compare__Bool symbol missing from emitted IR" + ); + assert!( + ir.contains("compare__Str") || ir.contains("@compare__Str"), + "compare__Str symbol missing from emitted IR" + ); +} +``` + +If a helper `emit_ir(fixture: &str) -> String` does not already +exist in `e2e.rs`, add it adjacent to `build_and_run` (it should +invoke `cargo run -p ail -- emit-ir ` and capture +stdout). Use the existing `build_and_run`'s implementation as a +template. + +- [ ] **Step 6: Run the new E2E + IR tests** + +Run: `cargo test --workspace -p ail compare_primitives_smoke_prints compare_primitives_smoke_emits` + +Expected: BOTH PASS. + +- [ ] **Step 7: Run the full test suite** + +Run: `cargo test --workspace` + +Expected: all pass. + +- [ ] **Step 8: Commit** + +```bash +git add examples/compare_primitives_smoke.ail.json crates/ail/tests/e2e.rs +git commit -m "iter ct.4.4: compare_primitives_smoke E2E fixture + IR-shape test (closes iter 23.3 Task 4)" +``` + +--- + +## Closing checks + +- [ ] **Step C1: Bench script smoke run** + +Run: `bash bench/run.sh` (or whichever entry point the project +uses; consult `bench/README.md` or `skills/audit/SKILL.md`). + +Expected: exit 0, or a ratified non-zero per the audit-skill +policy. No new regressions vs. baseline. + +- [ ] **Step C2: Confirm all acceptance criteria from the spec** + +Verify each item in `docs/specs/2026-05-10-canonical-type-names.md` +§ "Acceptance criteria": + +1. ✅ `load_workspace` validates every `Type::Con` (ct.1). +2. ✅ Every fixture migrated to qualified form (ct.1). +3. ✅ Four obsolete mechanisms deleted (ct.2.2, ct.2.3, ct.3.1, ct.3.2). +4. ✅ `qualify_local_types` audited and applied uniformly (ct.2.1). +5. ✅ `cargo build --workspace && cargo test --workspace` green (this commit). +6. ✅ Iter 23.3 Task 4+5 resumed and complete (ct.4 Task 4 above). +7. ✅ DESIGN.md amended (ct.4 Task 1). +8. ✅ JOURNAL entry (ct.4 Task C3 below). +9. ⏳ Bench scripts exit 0 (verify via C1). + +- [ ] **Step C3: JOURNAL entry** + +Append to `docs/JOURNAL.md`: + +```markdown +## 2026-05-11 — Iteration ct.4: canonical-type-names milestone close + +Four tasks landed, closing the milestone: + +- **ct.4.1**: amended DESIGN.md Decision 2 with the canonical + Type::Con name scoping rule (bare = local, qualified = + cross-module, primitives bare). Updated the regression-pin + citation at the surrounding "hashes stay bit-identical" + paragraph to name the two new ct.4 pins. +- **ct.4.2**: added two new pin tests in + `crates/ailang-core/src/hash.rs` — + `ct4_migrated_fixtures_have_canonical_form_hashes` (locks the + post-migration hashes of `ordering_match.ail.json` and + `test_22b1_dup_a.ail.json`) and + `ct4_unmigrated_fixtures_remain_bit_identical` (re-asserts the + unchanged `sum`/`list` hashes). +- **ct.4.3**: added qualifier-trim-on-print to the prose + printer. `write_type` (and `Term::Ctor.type_name` writing) + now trims a qualifier whose owner matches the current file's + module. The `owning_module` context is threaded from + `write_module` down through the call chain. Two unit tests + pin the trim and the cross-module preserve behaviours; the + `ordering_match.prose.txt` snapshot is committed. +- **ct.4.4**: recreated `examples/compare_primitives_smoke.ail.json` + (the iter-23.3 Task 4 demonstration fixture). 9 cases across + Int / Bool / Str, expected stdout `1\n2\n3` × 3. Two new + E2E tests in `crates/ail/tests/e2e.rs` — + `compare_primitives_smoke_prints_1_2_3_thrice` (output + assertion) and `compare_primitives_smoke_emits_compare_mono_symbols` + (IR-shape assertion that mono emits compare__Int / + compare__Bool / compare__Str symbols). + +The canonical-type-names milestone closes: +- The structural invariant: every `Type::Con` in canonical + `.ail.json` is bare (= local) or qualified + (`.`, with primitives bare). +- The four obsolete imports-fallback / overlay mechanisms named + in the ct.1 catalogue are gone (Pattern::Ctor, Term::Ctor + synth, codegen `lookup_ctor_by_type`, mono + `apply_per_module_ctor_index_overlay`'s ctor_index half). +- The iter-23.1-Task-4 bug is empirically closed: the smoke + fixture compiles, runs, and prints the expected 9-line output + end-to-end without any further compiler changes. + +Workspace at milestone close: full `cargo build --workspace` and +`cargo test --workspace` green, 453+ tests passing +(ct.4 adds 4 new tests: 2 pin + 2 prose + 2 E2E, minus or plus +any shifts in other test count). + +The skill-system pipeline calls for `audit` next (drift review + +bench regression + rustdoc), then `fieldtest` (real-world `.ailx` +examples authored against DESIGN.md only). + +Out of scope, deferred: +- The codegen `lookup_ctor_in_pattern` helper (imports-walk, but + no scrutinee type to type-anchor; a later iter can plumb Type + through pattern lowering if desired). +- `check_in_workspace`'s analogous per-module overlay in + `crates/ailang-check/src/lib.rs:1234` (its env.ctor_index half + serves the duplicate-detection diagnostic at workspace-build + time, not the runtime ctor lookup; not in the canonical-type- + names scope). +- Class names (`ClassDef.name`, `InstanceDef.class`, etc.) are + intentionally bare and workspace-flat post-ct.1; type-driven + method dispatch is its own milestone (already named in + `docs/roadmap.md` as a P2 item). +``` + +- [ ] **Step C4: Commit the JOURNAL entry** + +```bash +git add docs/JOURNAL.md +git commit -m "journal: ct.4 canonical-type-names milestone close" +``` + +- [ ] **Step C5: Optional roadmap update** + +If the user wants the milestone removed from +`docs/roadmap.md` (or marked done with a checkmark), make that +edit now and commit alongside the JOURNAL entry. Otherwise leave +the roadmap as-is — the orchestrator typically prunes finished +entries in a small follow-up commit.