Files
AILang/docs/plans/0028-ct.4-milestone-close.md
T
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
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.
2026-05-28 13:31:31 +02:00

35 KiB
Raw Blame History

Iteration ct.4: Milestone Close — Implementation Plan

Parent spec: docs/specs/0007-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_modulewrite_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:

**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 `<owning_module>.<TypeName>`
where `<owning_module>` 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
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:

    #[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::<def_name>. 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:

    /// 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:

    /// 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
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):

    /// 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:

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:

        Type::Con { name, args } => {
            out.push_str(name);
            // ... args writing
        }

Replace with:

        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:

// 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/<stem>.ail.json > examples/<stem>.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 preludeordering_match).

  • Step 9: Register the new snapshot test

Edit crates/ailang-prose/tests/snapshot.rs. Append:

#[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
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):

{
  "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:

{
  "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": <case 1>, "rhs": { "t": "seq", "lhs": <case 2>, "rhs": ... <case 9> ... } }
    }
  ]
}

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):

/// 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:

/// 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 <fixture> 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
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/0007-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:

## 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
  (`<owner>.<TypeName>`, 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
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.