Files
AILang/docs/plans/0024-23.3-ord-class-and-primitive-instances.md
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

1309 lines
48 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 23.3 — Ord Class + Primitive Instances — Implementation Plan
> **Parent spec:** `docs/specs/2026-05-10-23-eq-ord-prelude.md` (Components row 23.3)
>
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
> to run this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Ship `class Ord a extends Eq where compare : (a borrow, a
borrow) -> Ordering` in the auto-loaded prelude with three primitive
instances (Ord Int, Ord Bool, Ord Str), backed by a new
`ail_str_compare` in `runtime/str.c`. After this iter, a user
program can call `compare x y` on Int / Bool / Str and the
monomorphiser will synthesise `compare__Int` / `compare__Bool` /
`compare__Str` top-level fns in the prelude module that codegen
lowers as three-way branch ladders constructing the `Ordering` ADT
values (LT / EQ / GT).
**Architecture:** All three `compare__T` symbols are hand-rolled via
the codegen intercept established in iter 23.2 — `compare__Bool`
cannot ride the existing primitive `<` dispatch because
`builtin_binop_typed` (`crates/ailang-codegen/src/synth.rs:260-294`)
has Int and Float arms only, and bringing Bool into that table is a
language-wide decision that does not belong here. Hand-rolling all
three keeps the family consistent: the IR shape is identical (a
type-specific LT-test branch + an EQ-test branch + a GT default
branch, each constructing the matching Ordering ctor via
`lower_ctor` for alloc-strategy correctness). The new
`ail_str_compare` in `runtime/str.c` normalises libc strcmp's signed
result to exactly -1 / 0 / +1 so the codegen branch ladder can
compare against constant 0 cleanly. The intercept doc comment from
iter 23.2.2 is corrected (it speculated that compare__Int/Bool would
ride natural lowering; the language facts dictate otherwise).
Decision 11's single-superclass closure (`Ord` extends `Eq`
every `instance Ord T` requires `instance Eq T` to exist in the
workspace) is automatically satisfied: iter 23.2 shipped the
three Eq instances. No new check-side mechanism needed.
**Tech Stack:** `runtime/str.c` (one new C function),
`crates/ailang-codegen/src/lib.rs` (IR header decl + intercept
arms + a small block-emission helper),
`crates/ailang-core/src/workspace.rs` (prelude-load test extension),
`examples/prelude.ail.json` (class + 3 instances),
`examples/compare_primitives_smoke.ail.json` (new fixture).
---
**Files this plan creates or modifies:**
- Modify: `runtime/str.c` — append `ail_str_compare`.
- Modify: `crates/ailang-codegen/src/lib.rs` — header gains
`declare i32 @ail_str_compare(ptr, ptr)`; `try_emit_primitive_instance_body`
gains three arms (`compare__Int`, `compare__Bool`,
`compare__Str`); new private helper `emit_ordering_arm`; doc on
`try_emit_primitive_instance_body` corrected.
- Modify: `examples/prelude.ail.json` — append `class Ord a` +
three instance defs. Existing Eq / Ordering / class machinery
stays.
- Modify: `crates/ailang-core/src/workspace.rs` — extend
`loads_workspace_auto_injects_prelude` to assert `class Ord` +
Ord instances on Int / Bool / Str.
- Create: `examples/compare_primitives_smoke.ail.json` — E2E
fixture calling `compare` on each primitive with LT, EQ, GT
pairs.
- Modify: `crates/ail/tests/e2e.rs` — new E2E test
`compare_primitives_smoke_compiles_and_runs`.
- Modify: `crates/ailang-codegen/src/lib.rs` `mod tests` — three
IR-shape integration tests (one per primitive).
---
## Task 1: `runtime/str.c::ail_str_compare` + IR declaration
**Files:**
- Modify: `runtime/str.c` — append `ail_str_compare`.
- Modify: `crates/ailang-codegen/src/lib.rs:~482` — add
`declare i32 @ail_str_compare(ptr, ptr)` after the existing
`@ail_str_eq` declaration.
- Test: append a codegen unit test asserting the new IR header
line is present.
- [ ] **Step 1: Write the failing test**
Append to the existing `#[cfg(test)] mod tests` block in
`crates/ailang-codegen/src/lib.rs`:
```rust
/// Iter 23.3: `runtime/str.c::ail_str_compare` backs the prelude's
/// `compare__Str` mono symbol (see `try_emit_primitive_instance_body`).
/// The declaration is unconditional in the IR header alongside
/// `@ail_str_eq`; this test pins the header line so a regression
/// that drops it would fail at link time only on programs that
/// actually call `compare` on a Str.
#[test]
fn ir_header_declares_ail_str_compare() {
let m = Module {
schema: SCHEMA.into(),
name: "t".into(),
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "main".into(),
ty: Type::Fn {
params: vec![],
ret: Box::new(Type::unit()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec![],
body: Term::Lit { lit: Literal::Unit },
suppress: vec![],
doc: None,
})],
};
let ir = emit_ir(&m).unwrap();
assert!(
ir.contains("declare i32 @ail_str_compare(ptr, ptr)"),
"header missing @ail_str_compare declaration; ir was:\n{ir}"
);
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test --workspace --lib ir_header_declares_ail_str_compare`
Expected: FAIL — `header missing @ail_str_compare declaration`.
- [ ] **Step 3: Append `ail_str_compare` to `runtime/str.c`**
After the existing `ail_str_eq` function in `runtime/str.c`, append:
```c
/* Three-way comparison of NUL-terminated strings. Normalises libc
* strcmp's signed-int output to exactly -1 / 0 / +1 so the codegen
* caller can branch on `icmp slt i32 result, 0` and
* `icmp eq i32 result, 0` against constants directly without
* worrying about strcmp implementations that return values outside
* {-1, 0, +1}. Heap-Str ABI milestone will replace the body with
* length-prefixed comparison without changing the signature. */
int ail_str_compare(const char *a, const char *b) {
int r = strcmp(a, b);
if (r < 0) return -1;
if (r > 0) return 1;
return 0;
}
```
- [ ] **Step 4: Add the `@ail_str_compare` declaration to the IR header**
In `crates/ailang-codegen/src/lib.rs`, directly after the existing
`out.push_str("declare zeroext i1 @ail_str_eq(ptr, ptr)\n");`
line (~line 482, added in iter 23.2.2), insert:
```rust
// Iter 23.3: `runtime/str.c::ail_str_compare` backs the prelude's
// `compare__Str` mono symbol (see `try_emit_primitive_instance_body`
// `compare__Str` arm below). Declared unconditionally on the same
// rationale as `@ail_str_eq` — the .o is supplied by the
// unconditionally-linked `runtime/str.c`, and clang -O2 dead-
// strips the symbol when no caller exists. Returns i32 normalised
// to {-1, 0, +1} so the branch ladder in the intercept can compare
// against constant 0 directly.
out.push_str("declare i32 @ail_str_compare(ptr, ptr)\n");
```
- [ ] **Step 5: Run the test to verify it passes**
Run: `cargo test --workspace --lib ir_header_declares_ail_str_compare`
Expected: PASS.
- [ ] **Step 6: Re-run the existing test suite to confirm no regressions**
Run: `cargo build --workspace && cargo test --workspace`
Expected: PASS. Snapshot fixtures under `crates/ail/tests/snapshots/`
will gain one new line each (the new `declare` line) — refresh via
`UPDATE_SNAPSHOTS=1` if the snapshot tests fail and the only diff
is the additive `@ail_str_compare` declaration. Include refreshed
snapshots in the commit.
- [ ] **Step 7: Commit**
```bash
git add runtime/str.c crates/ailang-codegen/src/lib.rs crates/ail/tests/snapshots
git commit -m "iter 23.3.1: runtime ail_str_compare + IR header declaration"
```
(Omit the `crates/ail/tests/snapshots` path from `git add` if no
snapshot files were modified.)
---
## Task 2: Codegen — three `compare__T` arms in `try_emit_primitive_instance_body`
**Files:**
- Modify: `crates/ailang-codegen/src/lib.rs::try_emit_primitive_instance_body`
(~line 2452): add three new match arms (`compare__Int`,
`compare__Bool`, `compare__Str`); correct the doc comment at
~line 2437; add a private helper `emit_ordering_arm` directly
below the intercept.
- Modify: `crates/ailang-codegen/src/lib.rs` `mod tests` — three
unit tests on synthetic FnDefs (one per compare__T arm).
- [ ] **Step 1: Write the failing tests**
Append to the existing `#[cfg(test)] mod tests` block in
`crates/ailang-codegen/src/lib.rs`:
```rust
/// Iter 23.3: codegen intercepts a fn named `compare__Int` and
/// emits a three-way branch ladder: `icmp slt i64 a, b` →
/// LT-block; else `icmp eq i64 a, b` → EQ-block; else GT-block.
/// Each block constructs the matching Ordering ctor via the
/// existing `lower_ctor` path. Pinned with a synthetic FnDef so
/// the test does not depend on prelude auto-injection.
#[test]
fn compare_int_mono_symbol_emits_branch_ladder() {
let m = synth_compare_module("compare__Int", Type::int(), "i64");
let ir = emit_ir(&m).unwrap();
assert!(
ir.contains("icmp slt i64"),
"compare__Int body must contain `icmp slt i64`; ir:\n{ir}"
);
assert!(
ir.contains("icmp eq i64"),
"compare__Int body must contain `icmp eq i64`; ir:\n{ir}"
);
}
/// Iter 23.3: same shape for `compare__Bool`. The LT-test uses
/// `icmp ult i1` (unsigned: false=0 ult true=1 gives the natural
/// Bool ordering false < true); the EQ-test uses `icmp eq i1`.
#[test]
fn compare_bool_mono_symbol_emits_branch_ladder() {
let m = synth_compare_module("compare__Bool", Type::bool_(), "i1");
let ir = emit_ir(&m).unwrap();
assert!(
ir.contains("icmp ult i1"),
"compare__Bool body must contain `icmp ult i1`; ir:\n{ir}"
);
assert!(
ir.contains("icmp eq i1"),
"compare__Bool body must contain `icmp eq i1`; ir:\n{ir}"
);
}
/// Iter 23.3: `compare__Str` calls `@ail_str_compare` to get the
/// normalised {-1, 0, +1}, then branches: slt 0 → LT, eq 0 → EQ,
/// else GT.
#[test]
fn compare_str_mono_symbol_emits_ail_str_compare_call() {
let m = synth_compare_module("compare__Str", Type::str_(), "ptr");
let ir = emit_ir(&m).unwrap();
assert!(
ir.contains("call i32 @ail_str_compare("),
"compare__Str body must call @ail_str_compare; ir:\n{ir}"
);
assert!(
ir.contains("icmp slt i32"),
"compare__Str body must compare result `slt i32` against 0; ir:\n{ir}"
);
assert!(
ir.contains("icmp eq i32"),
"compare__Str body must compare result `eq i32` against 0; ir:\n{ir}"
);
}
/// Test helper: minimal two-module workspace where the "prelude"
/// module declares `data Ordering = LT | EQ | GT` and an
/// instance-fn shell (the intercept overrides the body), and the
/// entry module's `main` is a Unit no-op so `emit_ir` returns a
/// well-formed program. Used by the three `compare_*` tests above.
fn synth_compare_module(fn_name: &str, param_ail_ty: Type, _llvm_param_ty: &str) -> Module {
let ordering = Def::Type(TypeDef {
name: "Ordering".into(),
vars: vec![],
ctors: vec![
Ctor { name: "LT".into(), fields: vec![] },
Ctor { name: "EQ".into(), fields: vec![] },
Ctor { name: "GT".into(), fields: vec![] },
],
doc: None,
drop_iterative: false,
});
// Ordering is `ptr` at the LLVM level (boxed ADT).
let compare = Def::Fn(FnDef {
name: fn_name.into(),
ty: Type::Fn {
params: vec![param_ail_ty.clone(), param_ail_ty.clone()],
ret: Box::new(Type::Con { name: "Ordering".into(), args: vec![] }),
effects: vec![],
param_modes: vec![ParamMode::Borrow, ParamMode::Borrow],
ret_mode: ParamMode::Implicit,
},
params: vec!["x".into(), "y".into()],
body: Term::Lit { lit: Literal::Unit }, // placeholder; intercept overrides
suppress: vec![],
doc: None,
});
let main_def = Def::Fn(FnDef {
name: "main".into(),
ty: Type::Fn {
params: vec![],
ret: Box::new(Type::unit()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec![],
body: Term::Lit { lit: Literal::Unit },
suppress: vec![],
doc: None,
});
Module {
schema: SCHEMA.into(),
name: "prelude".into(),
imports: vec![],
defs: vec![ordering, compare, main_def],
}
}
```
Note: the exact field set on `TypeDef` may vary (`drop_iterative` is
the iter-18e field). If the field name differs in the current codebase,
inspect `crates/ailang-core/src/ast.rs::TypeDef` and adjust. If `Ctor`
has additional fields, default them as the rest of the codebase does.
- [ ] **Step 2: Run the three tests to verify they fail**
Run:
```
cargo test --workspace --lib compare_int_mono_symbol_emits_branch_ladder compare_bool_mono_symbol_emits_branch_ladder compare_str_mono_symbol_emits_ail_str_compare_call
```
Expected: ALL THREE FAIL — `compare__T body must contain ...` (intercept arms not yet wired).
- [ ] **Step 3: Add the private helper `emit_ordering_arm`**
In `crates/ailang-codegen/src/lib.rs`, directly below
`try_emit_primitive_instance_body` (currently ending ~line 2495),
insert:
```rust
/// Iter 23.3: emit one arm of the `compare__T` branch ladder.
/// Starts a fresh basic block labelled `label`, constructs the
/// matching `Ordering` ctor (LT / EQ / GT) via `lower_ctor`, and
/// emits a `ret ptr <ssa>`. Used three times per `compare__T`
/// arm in `try_emit_primitive_instance_body`. The ctor is a
/// zero-field allocation; `lower_ctor` handles alloc-strategy
/// variance (Gc / Bump / Rc) without the intercept duplicating
/// the per-strategy logic.
fn emit_ordering_arm(&mut self, label: &str, ctor: &str) -> Result<()> {
self.start_block(label);
// term_ptr 0: synthetic call site, not present in the
// escape-analysis result; falls back to the conservative
// "escapes → heap allocate" default. Safe for the Ordering
// return value (the caller owns it after `ret`).
let (ssa, _llvm_ty) = self.lower_ctor(
"Ordering",
ctor,
&[],
0,
)?;
self.body.push_str(&format!(" ret ptr {ssa}\n"));
self.block_terminated = true;
Ok(())
}
```
- [ ] **Step 4: Correct the iter 23.2.2 doc comment**
In `crates/ailang-codegen/src/lib.rs`, find the doc comment on
`try_emit_primitive_instance_body` (around line 2467 — the line
that reads `/// methods (\`eq__Int\`, \`eq__Bool\`, \`compare__Int\`, \`compare__Bool\`)`).
Replace the lines describing the future arms with the corrected
shape:
```rust
/// Currently inhabited arms: `eq__Str` (ships in 23.2.2) and
/// `compare__Int`, `compare__Bool`, `compare__Str` (this iter
/// 23.3). The two eq Int/Bool primitives (`eq__Int`, `eq__Bool`)
/// ride the natural `lower_eq` dispatch via their lambda body
/// `(== x y)` and do not need a hand-rolled body. The three
/// `compare` arms must be hand-rolled because there is no
/// type-polymorphic primitive that returns an Ordering ADT
/// value (Decision: `builtin_binop_typed` covers Int and Float
/// only — Bool is missing — so a surface-level
/// `if x < y { LT } else if x == y { EQ } else { GT }` body
/// would fail at codegen for `compare__Bool`). Hand-rolling all
/// three keeps the family consistent and the IR shape
/// predictable.
```
Use Edit with the precise old text in the current file to
substitute. Read the surrounding doc-comment context first to
make the old_string unique.
- [ ] **Step 5: Add the three compare arms to the match block**
Inside `try_emit_primitive_instance_body`'s `match fn_name { ... }`,
add three arms next to the existing `"eq__Str"` arm:
```rust
"compare__Int" => {
if param_tys != ["i64", "i64"] || ret_ty != "ptr" {
return Err(CodegenError::Internal(format!(
"compare__Int body intercept: unexpected signature \
({param_tys:?}) -> {ret_ty} (want (i64, i64) -> ptr)"
)));
}
let n = self.locals.len();
let a_ssa = self.locals[n - 2].1.clone();
let b_ssa = self.locals[n - 1].1.clone();
let lt_test = self.fresh_ssa();
self.body.push_str(&format!(
" {lt_test} = icmp slt i64 {a_ssa}, {b_ssa}\n"
));
let id = self.fresh_id();
let lt_label = format!("cmp_lt_{id}");
let after_lt_label = format!("cmp_after_lt_{id}");
self.body.push_str(&format!(
" br i1 {lt_test}, label %{lt_label}, label %{after_lt_label}\n"
));
self.emit_ordering_arm(&lt_label, "LT")?;
self.start_block(&after_lt_label);
let eq_test = self.fresh_ssa();
self.body.push_str(&format!(
" {eq_test} = icmp eq i64 {a_ssa}, {b_ssa}\n"
));
let eq_label = format!("cmp_eq_{id}");
let gt_label = format!("cmp_gt_{id}");
self.body.push_str(&format!(
" br i1 {eq_test}, label %{eq_label}, label %{gt_label}\n"
));
self.emit_ordering_arm(&eq_label, "EQ")?;
self.emit_ordering_arm(&gt_label, "GT")?;
self.body.push_str("}\n\n");
self.block_terminated = true;
Ok(true)
}
"compare__Bool" => {
if param_tys != ["i1", "i1"] || ret_ty != "ptr" {
return Err(CodegenError::Internal(format!(
"compare__Bool body intercept: unexpected signature \
({param_tys:?}) -> {ret_ty} (want (i1, i1) -> ptr)"
)));
}
let n = self.locals.len();
let a_ssa = self.locals[n - 2].1.clone();
let b_ssa = self.locals[n - 1].1.clone();
let lt_test = self.fresh_ssa();
self.body.push_str(&format!(
" {lt_test} = icmp ult i1 {a_ssa}, {b_ssa}\n"
));
let id = self.fresh_id();
let lt_label = format!("cmp_lt_{id}");
let after_lt_label = format!("cmp_after_lt_{id}");
self.body.push_str(&format!(
" br i1 {lt_test}, label %{lt_label}, label %{after_lt_label}\n"
));
self.emit_ordering_arm(&lt_label, "LT")?;
self.start_block(&after_lt_label);
let eq_test = self.fresh_ssa();
self.body.push_str(&format!(
" {eq_test} = icmp eq i1 {a_ssa}, {b_ssa}\n"
));
let eq_label = format!("cmp_eq_{id}");
let gt_label = format!("cmp_gt_{id}");
self.body.push_str(&format!(
" br i1 {eq_test}, label %{eq_label}, label %{gt_label}\n"
));
self.emit_ordering_arm(&eq_label, "EQ")?;
self.emit_ordering_arm(&gt_label, "GT")?;
self.body.push_str("}\n\n");
self.block_terminated = true;
Ok(true)
}
"compare__Str" => {
if param_tys != ["ptr", "ptr"] || ret_ty != "ptr" {
return Err(CodegenError::Internal(format!(
"compare__Str body intercept: unexpected signature \
({param_tys:?}) -> {ret_ty} (want (ptr, ptr) -> ptr)"
)));
}
let n = self.locals.len();
let a_ssa = self.locals[n - 2].1.clone();
let b_ssa = self.locals[n - 1].1.clone();
let cmp_res = self.fresh_ssa();
self.body.push_str(&format!(
" {cmp_res} = call i32 @ail_str_compare(ptr {a_ssa}, ptr {b_ssa})\n"
));
let lt_test = self.fresh_ssa();
self.body.push_str(&format!(
" {lt_test} = icmp slt i32 {cmp_res}, 0\n"
));
let id = self.fresh_id();
let lt_label = format!("cmp_lt_{id}");
let after_lt_label = format!("cmp_after_lt_{id}");
self.body.push_str(&format!(
" br i1 {lt_test}, label %{lt_label}, label %{after_lt_label}\n"
));
self.emit_ordering_arm(&lt_label, "LT")?;
self.start_block(&after_lt_label);
let eq_test = self.fresh_ssa();
self.body.push_str(&format!(
" {eq_test} = icmp eq i32 {cmp_res}, 0\n"
));
let eq_label = format!("cmp_eq_{id}");
let gt_label = format!("cmp_gt_{id}");
self.body.push_str(&format!(
" br i1 {eq_test}, label %{eq_label}, label %{gt_label}\n"
));
self.emit_ordering_arm(&eq_label, "EQ")?;
self.emit_ordering_arm(&gt_label, "GT")?;
self.body.push_str("}\n\n");
self.block_terminated = true;
Ok(true)
}
```
- [ ] **Step 6: Run the three unit tests to verify they pass**
Run:
```
cargo test --workspace --lib compare_int_mono_symbol_emits_branch_ladder compare_bool_mono_symbol_emits_branch_ladder compare_str_mono_symbol_emits_ail_str_compare_call
```
Expected: ALL THREE PASS.
- [ ] **Step 7: Run the full codegen test suite to confirm no regressions**
Run: `cargo test --workspace -p ailang-codegen`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add crates/ailang-codegen/src/lib.rs
git commit -m "iter 23.3.2: codegen — three compare__T intercept arms + emit_ordering_arm helper"
```
---
## Task 3: Prelude — `class Ord a extends Eq` + three instances
**Files:**
- Modify: `examples/prelude.ail.json` — append `class Ord` def +
three `instance Ord T` defs after the existing Eq defs.
- Modify: `crates/ailang-core/src/workspace.rs::loads_workspace_auto_injects_prelude`
test — append assertions for `class Ord` + Ord instances.
- [ ] **Step 1: Write the failing test**
In `crates/ailang-core/src/workspace.rs::loads_workspace_auto_injects_prelude`,
after the existing Eq-presence + Eq-instances assertion block
(added in iter 23.2.3), append:
```rust
// Iter 23.3: prelude also ships the `Ord` class plus three
// primitive instances (Ord Int, Ord Bool, Ord Str). Ord
// extends Eq (Decision 11 single-superclass closure), so its
// shape is the same as Eq's instance set; the loader uses the
// superclass walk to verify the Eq instances exist.
assert!(
prelude.defs.iter().any(|d| matches!(
d,
crate::ast::Def::Class(c) if c.name == "Ord"
)),
"prelude must contain Ord class def"
);
let ord_instance_types: Vec<&str> = prelude
.defs
.iter()
.filter_map(|d| match d {
crate::ast::Def::Instance(i) if i.class == "Ord" => {
if let crate::ast::Type::Con { name, .. } = &i.type_ {
Some(name.as_str())
} else {
None
}
}
_ => None,
})
.collect();
assert!(
ord_instance_types.contains(&"Int"),
"prelude must contain `instance Ord Int`; saw: {ord_instance_types:?}"
);
assert!(
ord_instance_types.contains(&"Bool"),
"prelude must contain `instance Ord Bool`; saw: {ord_instance_types:?}"
);
assert!(
ord_instance_types.contains(&"Str"),
"prelude must contain `instance Ord Str`; saw: {ord_instance_types:?}"
);
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cargo test --workspace -p ailang-core loads_workspace_auto_injects_prelude`
Expected: FAIL — `prelude must contain Ord class def`.
- [ ] **Step 3: Extend `examples/prelude.ail.json` with Ord + 3 instances**
After the last `Eq Str` instance def in `examples/prelude.ail.json`
(before the closing `]` of the outer `defs` array), append (with
a leading comma after the `Eq Str` instance's closing `}`):
```json
,
{
"kind": "class",
"name": "Ord",
"param": "a",
"superclass": { "class": "Eq", "type": "a" },
"doc": "Total ordering. Ships in milestone 23 alongside Eq. `compare x y` returns LT, EQ, or GT (the three-ctor Ordering ADT also in the prelude). Decision 11's single-superclass closure requires `instance Eq T` for every `instance Ord T` — the three Ord instances below pair with the three Eq instances shipped in iter 23.2.3.",
"methods": [
{
"name": "compare",
"type": {
"k": "fn",
"params": [
{ "k": "var", "name": "a" },
{ "k": "var", "name": "a" }
],
"param_modes": ["borrow", "borrow"],
"ret": { "k": "con", "name": "Ordering" },
"effects": []
}
}
]
},
{
"kind": "instance",
"class": "Ord",
"type": { "k": "con", "name": "Int" },
"doc": "Ord Int. The lambda body shape is a placeholder for round-trip stability; the codegen intercept `try_emit_primitive_instance_body::\"compare__Int\"` emits a three-way `icmp slt` / `icmp eq` branch ladder constructing LT / EQ / GT.",
"methods": [
{
"name": "compare",
"body": {
"t": "lam",
"params": ["x", "y"],
"paramTypes": [
{ "k": "var", "name": "a" },
{ "k": "var", "name": "a" }
],
"retType": { "k": "con", "name": "Ordering" },
"body": {
"t": "ctor",
"type": "Ordering",
"ctor": "EQ",
"args": []
}
}
}
]
},
{
"kind": "instance",
"class": "Ord",
"type": { "k": "con", "name": "Bool" },
"doc": "Ord Bool. Body lowered via `try_emit_primitive_instance_body::\"compare__Bool\"` — `icmp ult i1` LT-test, `icmp eq i1` EQ-test, GT default.",
"methods": [
{
"name": "compare",
"body": {
"t": "lam",
"params": ["x", "y"],
"paramTypes": [
{ "k": "var", "name": "a" },
{ "k": "var", "name": "a" }
],
"retType": { "k": "con", "name": "Ordering" },
"body": {
"t": "ctor",
"type": "Ordering",
"ctor": "EQ",
"args": []
}
}
}
]
},
{
"kind": "instance",
"class": "Ord",
"type": { "k": "con", "name": "Str" },
"doc": "Ord Str. Body lowered via `try_emit_primitive_instance_body::\"compare__Str\"` — `call i32 @ail_str_compare(ptr, ptr)` then branch on slt-0 / eq-0 against the normalised {-1, 0, +1} return.",
"methods": [
{
"name": "compare",
"body": {
"t": "lam",
"params": ["x", "y"],
"paramTypes": [
{ "k": "var", "name": "a" },
{ "k": "var", "name": "a" }
],
"retType": { "k": "con", "name": "Ordering" },
"body": {
"t": "ctor",
"type": "Ordering",
"ctor": "EQ",
"args": []
}
}
}
]
}
```
The placeholder body returns `EQ` — codegen never lowers it
(intercept fires first), but the typechecker validates the body
against the declared return type `Ordering`. `EQ` is a zero-arg
Ordering ctor, so the body is well-typed.
- [ ] **Step 4: Run the prelude-load test to verify it passes**
Run: `cargo test --workspace -p ailang-core loads_workspace_auto_injects_prelude`
Expected: PASS.
- [ ] **Step 5: Run the full workspace test suite**
Run: `cargo build --workspace && cargo test --workspace`
Expected: PASS.
If any pre-existing test fails because of the new Ord defs in the
prelude (e.g. a fixture that defines its own `class Ord` or method
`compare` and now collides on `MethodNameCollision`), STOP and
report `NEEDS_CONTEXT` with the failing test names — do NOT
modify unrelated tests to make them pass. (The iter 23.2.3-prep
commits already renamed fixture-side `class Eq.eq` and
`class Ord.lt` to `TEq.teq` / `TOrd.tlt`; if any test fixture
still defines `compare` as a method name on its own class, that
would be a new collision.)
- [ ] **Step 6: Commit**
```bash
git add examples/prelude.ail.json crates/ailang-core/src/workspace.rs
git commit -m "iter 23.3.3: prelude — class Ord a extends Eq + Ord Int/Bool/Str instances"
```
---
## Task 4: E2E fixture + IR-shape integration tests
**Files:**
- Create: `examples/compare_primitives_smoke.ail.json` — fixture
calling `compare` on Int/Bool/Str with LT/EQ/GT pairs, matching
the result against the three Ordering ctors, printing 1/2/3.
- Modify: `crates/ail/tests/e2e.rs` — new test
`compare_primitives_smoke_compiles_and_runs`.
- Modify: `crates/ailang-codegen/src/lib.rs` `mod tests` — three
IR-shape integration tests on the new fixture (mirror the iter
23.2.4 pattern).
- [ ] **Step 1: Write the failing E2E test**
In `crates/ail/tests/e2e.rs`, after the existing
`eq_primitives_smoke_compiles_and_runs` test (added in iter
23.2.4), append:
```rust
/// Iter 23.3: end-to-end coverage for the auto-loaded `Ord` class
/// + three primitive instances. The fixture calls `compare` on
/// Int / Bool / Str with three pairs each (an LT pair, an EQ
/// pair, a GT pair), matches the result against LT / EQ / GT,
/// and prints 1 / 2 / 3 respectively. The monomorphiser
/// synthesises `compare__Int` / `compare__Bool` / `compare__Str`
/// in the prelude module; codegen's `try_emit_primitive_instance_body`
/// intercept emits the branch ladder; the binary prints nine
/// lines in the order LT (1), EQ (2), GT (3), per type.
#[test]
fn compare_primitives_smoke_compiles_and_runs() {
let stdout = build_and_run("compare_primitives_smoke.ail.json");
assert_eq!(stdout, "1\n2\n3\n1\n2\n3\n1\n2\n3\n");
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `cargo test --workspace -p ail compare_primitives_smoke_compiles_and_runs`
Expected: FAIL — `examples/compare_primitives_smoke.ail.json` does
not exist; `ail build` exits non-zero.
- [ ] **Step 3: Write the fixture**
Create `examples/compare_primitives_smoke.ail.json`:
```json
{
"schema": "ailang/v0",
"name": "compare_primitives_smoke",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "ord_to_int",
"doc": "Map an Ordering to a printable Int. LT → 1, EQ → 2, GT → 3. Used by main to render the result of each `compare` call as a single line of stdout.",
"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": "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 } }
}
]
}
},
{
"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_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": "int", "value": 5 } },
{ "t": "lit", "lit": { "kind": "int", "value": 5 } }
]
}]
}]
},
"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": 9 } },
{ "t": "lit", "lit": { "kind": "int", "value": 3 } }
]
}]
}]
},
"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": "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": true } },
{ "t": "lit", "lit": { "kind": "bool", "value": true } }
]
}]
}]
},
"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": true } },
{ "t": "lit", "lit": { "kind": "bool", "value": false } }
]
}]
}]
},
"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": "str", "value": "alpha" } },
{ "t": "lit", "lit": { "kind": "str", "value": "beta" } }
]
}]
}]
},
"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": "str", "value": "same" } },
{ "t": "lit", "lit": { "kind": "str", "value": "same" } }
]
}]
}]
},
"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": "zulu" } },
{ "t": "lit", "lit": { "kind": "str", "value": "alpha" } }
]
}]
}]
}
}
}
}
}
}
}
}
}
}
]
}
```
Pair pattern per type: (LT-result, EQ-result, GT-result):
- Int: `(1,2)` → LT, `(5,5)` → EQ, `(9,3)` → GT.
- Bool: `(false, true)` → LT, `(true, true)` → EQ, `(true, false)` → GT.
- Str: `("alpha","beta")` → LT, `("same","same")` → EQ, `("zulu","alpha")` → GT.
Expected stdout: `"1\n2\n3\n1\n2\n3\n1\n2\n3\n"`.
- [ ] **Step 4: Run E2E test to verify it passes**
Run: `cargo test --workspace -p ail compare_primitives_smoke_compiles_and_runs`
Expected: PASS — stdout is `"1\n2\n3\n1\n2\n3\n1\n2\n3\n"`.
If the test fails with stdout `"2\n2\n2\n2\n2\n2\n2\n2\n2\n"`,
that means the intercept did not fire and the placeholder `EQ`
body in the prelude got lowered instead. Verify in
`try_emit_primitive_instance_body` that the new arms match the
fn names exactly (case-sensitive, double-underscore separator).
If the test fails with a different stdout pattern, inspect the
emitted IR by running:
```
ail emit-ir examples/compare_primitives_smoke.ail.json
```
and grep for `@ail_prelude_compare__T` to verify the branch ladder
shape. Do NOT loosen the assertion; understand the regression first.
- [ ] **Step 5: Add three codegen IR-shape integration tests**
Append to the existing `#[cfg(test)] mod tests` block in
`crates/ailang-codegen/src/lib.rs` (after the iter-23.2.4
integration tests for `eq__T`):
```rust
/// Iter 23.3: integration with the auto-loaded prelude. Compiling
/// a workspace that calls `compare` on Int produces a
/// mono-synthesised `@ail_prelude_compare__Int` whose body
/// contains the `icmp slt i64` LT-test and the `icmp eq i64`
/// EQ-test characteristic of the intercept's branch ladder.
#[test]
fn compare_int_call_produces_branch_ladder_mono_fn() {
use ailang_core::load_workspace;
use ailang_check::{check_workspace, monomorphise_workspace};
let manifest = env!("CARGO_MANIFEST_DIR");
let entry = std::path::Path::new(manifest)
.parent().unwrap().parent().unwrap()
.join("examples").join("compare_primitives_smoke.ail.json");
let ws = load_workspace(&entry).expect("load workspace");
let diags = check_workspace(&ws);
assert!(
diags.iter().all(|d| !matches!(d.severity, ailang_check::Severity::Error)),
"unexpected check_workspace errors: {diags:?}"
);
let ws = monomorphise_workspace(&ws).expect("monomorphise");
let ir = lower_workspace(&ws).expect("lower");
assert!(
ir.contains("@ail_prelude_compare__Int"),
"ir must contain mono-synthesised @ail_prelude_compare__Int; ir was:\n{ir}"
);
assert!(
ir.contains("icmp slt i64"),
"compare__Int body must contain `icmp slt i64`; ir was:\n{ir}"
);
assert!(
ir.contains("icmp eq i64"),
"compare__Int body must contain `icmp eq i64`; ir was:\n{ir}"
);
}
/// Iter 23.3: same shape for `compare__Bool`. `icmp ult i1`
/// LT-test + `icmp eq i1` EQ-test.
#[test]
fn compare_bool_call_produces_branch_ladder_mono_fn() {
use ailang_core::load_workspace;
use ailang_check::{check_workspace, monomorphise_workspace};
let manifest = env!("CARGO_MANIFEST_DIR");
let entry = std::path::Path::new(manifest)
.parent().unwrap().parent().unwrap()
.join("examples").join("compare_primitives_smoke.ail.json");
let ws = load_workspace(&entry).expect("load workspace");
let diags = check_workspace(&ws);
assert!(
diags.iter().all(|d| !matches!(d.severity, ailang_check::Severity::Error)),
"unexpected check_workspace errors: {diags:?}"
);
let ws = monomorphise_workspace(&ws).expect("monomorphise");
let ir = lower_workspace(&ws).expect("lower");
assert!(
ir.contains("@ail_prelude_compare__Bool"),
"ir must contain mono-synthesised @ail_prelude_compare__Bool; ir was:\n{ir}"
);
assert!(
ir.contains("icmp ult i1"),
"compare__Bool body must contain `icmp ult i1`; ir was:\n{ir}"
);
assert!(
ir.contains("icmp eq i1"),
"compare__Bool body must contain `icmp eq i1`; ir was:\n{ir}"
);
}
/// Iter 23.3: `compare__Str` calls `@ail_str_compare` (runtime
/// primitive in `runtime/str.c`) and branches on the normalised
/// {-1, 0, +1} result. Asserts the call site and the two branch
/// tests on the result.
#[test]
fn compare_str_call_produces_ail_str_compare_call_mono_fn() {
use ailang_core::load_workspace;
use ailang_check::{check_workspace, monomorphise_workspace};
let manifest = env!("CARGO_MANIFEST_DIR");
let entry = std::path::Path::new(manifest)
.parent().unwrap().parent().unwrap()
.join("examples").join("compare_primitives_smoke.ail.json");
let ws = load_workspace(&entry).expect("load workspace");
let diags = check_workspace(&ws);
assert!(
diags.iter().all(|d| !matches!(d.severity, ailang_check::Severity::Error)),
"unexpected check_workspace errors: {diags:?}"
);
let ws = monomorphise_workspace(&ws).expect("monomorphise");
let ir = lower_workspace(&ws).expect("lower");
assert!(
ir.contains("@ail_prelude_compare__Str"),
"ir must contain mono-synthesised @ail_prelude_compare__Str; ir was:\n{ir}"
);
assert!(
ir.contains("call i32 @ail_str_compare("),
"compare__Str body must call @ail_str_compare; ir was:\n{ir}"
);
assert!(
ir.contains("icmp slt i32"),
"compare__Str body must contain `icmp slt i32` (LT-test against 0); ir was:\n{ir}"
);
assert!(
ir.contains("icmp eq i32"),
"compare__Str body must contain `icmp eq i32` (EQ-test against 0); ir was:\n{ir}"
);
}
```
- [ ] **Step 6: Run the three integration tests**
Run:
```
cargo test --workspace --lib compare_int_call_produces_branch_ladder_mono_fn compare_bool_call_produces_branch_ladder_mono_fn compare_str_call_produces_ail_str_compare_call_mono_fn
```
Expected: ALL THREE PASS.
- [ ] **Step 7: Run the full workspace test suite**
Run: `cargo build --workspace && cargo test --workspace`
Expected: FULL workspace green.
- [ ] **Step 8: Commit**
```bash
git add examples/compare_primitives_smoke.ail.json crates/ail/tests/e2e.rs crates/ailang-codegen/src/lib.rs
git commit -m "iter 23.3.4: e2e smoke fixture + ir-shape integration tests for compare__T mono symbols"
```
---
## Task 5: JOURNAL entry
**Files:**
- Modify: `docs/JOURNAL.md` — append a new entry at the END
of the file (chronological order with most recent last,
matching iter 23.1 and 23.2 entries).
- [ ] **Step 1: Append the JOURNAL entry**
Append to `docs/JOURNAL.md`:
```markdown
## 2026-05-10 — Iteration 23.3: Ord class + three primitive instances
Third iteration of milestone 23. Ships `class Ord a extends Eq
where compare : (a borrow, a borrow) -> Ordering` in the auto-
loaded prelude plus three primitive instances (Ord Int, Ord Bool,
Ord Str). User programs can now call `compare x y` on any of the
three primitives; the monomorphiser synthesises
`compare__Int` / `compare__Bool` / `compare__Str` top-level fns
in the prelude module and codegen's
`try_emit_primitive_instance_body` intercept hand-rolls a
three-way branch ladder for each:
- `compare__Int` → `icmp slt i64` LT-test + `icmp eq i64`
EQ-test, constructing LT / EQ / GT via `lower_ctor`.
- `compare__Bool` → `icmp ult i1` LT-test + `icmp eq i1`
EQ-test, same ctor construction.
- `compare__Str` → `call i32 @ail_str_compare(...)` followed by
`icmp slt i32 _, 0` LT-test + `icmp eq i32 _, 0` EQ-test.
All three are hand-rolled, unlike iter 23.2's eq family where
`eq__Int` / `eq__Bool` rode the natural `lower_eq` dispatch.
The reason: `builtin_binop_typed`
(`crates/ailang-codegen/src/synth.rs:260-294`) only handles Int
and Float for the comparison ops `< / <= / > / >=` — Bool is
missing. A surface-level `if x < y { LT } else if x == y { EQ }
else { GT }` body for `compare__Bool` would fail at codegen. Bringing
Bool into the primitive comparison table is a language-wide
design decision that does not belong in this iter; hand-rolling
all three compare arms keeps the family consistent and the IR
shape predictable. The iter-23.2.2 doc comment that speculated
compare__Int/Bool would ride natural lowering is corrected.
The Str runtime primitive `ail_str_compare` lives in `runtime/str.c`
alongside `ail_str_eq`; it normalises libc strcmp's signed result
to exactly {-1, 0, +1} so the codegen branch ladder can compare
against constant 0 directly. The .o is supplied by the same
unconditional clang compile+link wired up in iter 23.2.1; no
additional build-side changes.
Decision 11's single-superclass closure (every `instance Ord T`
requires `instance Eq T`) is automatically satisfied — iter 23.2
shipped the three Eq instances on the same types. No new
check-side machinery.
Coverage:
- Three codegen unit tests on synthetic FnDefs pin each
compare arm's IR shape (`compare_*_mono_symbol_emits_*`).
- Three IR-shape integration tests on the real
`examples/compare_primitives_smoke.ail.json` workspace
(`compare_*_call_produces_*_mono_fn`) verify the full
typecheck → monomorphise → codegen pipeline.
- One E2E test `compare_primitives_smoke_compiles_and_runs`
exercises the full pipeline through clang + the produced
binary, asserting stdout `"1\n2\n3\n1\n2\n3\n1\n2\n3\n"`
(LT=1, EQ=2, GT=3 per type × 3 types).
- Prelude-load test extended to assert `class Ord` + Ord
instances on Int / Bool / Str.
Per-task commits:
- `<sha-1>` iter 23.3.1: runtime ail_str_compare + IR header
declaration
- `<sha-2>` iter 23.3.2: codegen — three compare__T intercept
arms + emit_ordering_arm helper
- `<sha-3>` iter 23.3.3: prelude — class Ord a extends Eq + Ord
Int/Bool/Str instances
- `<sha-4>` iter 23.3.4: e2e smoke fixture + ir-shape integration
tests for compare__T mono symbols
- `<sha-5>` iter 23.3.5: journal entry — Ord class + primitive
instances
(Replace `<sha-N>` with the actual commit SHAs before committing.
Use `git log --oneline -5` to retrieve them.)
Out of scope for this iter (covered by later 23.x):
- Free top-level utility functions `ne` / `lt` / `le` / `gt` /
`ge` (23.4).
- Float-NoInstance diagnostic + DESIGN.md amendment (23.5).
Workspace at iter-23.3 close: full `cargo test --workspace`
green. Cross-language and the three regression scripts not
re-run for this iter; they run at milestone-23 close.
```
- [ ] **Step 2: Replace `<sha-N>` placeholders with actual SHAs**
Run `git log --oneline -5` to retrieve the SHAs of the four prior
task commits (23.3.1 through 23.3.4). Edit `docs/JOURNAL.md` to
substitute the actual SHAs. (Iter 23.3.5 — this commit — gets its
SHA only after the commit lands; leave that one as a placeholder
or trim the bullet.)
- [ ] **Step 3: Commit**
```bash
git add docs/JOURNAL.md
git commit -m "iter 23.3.5: journal entry — Ord class + primitive instances"
```
---
## Self-review
1. **Spec coverage**: Components row 23.3 names five
deliverables — (a) `class Ord a extends Eq where compare`,
(b) three instances (Ord Int / Bool / Str), (c) extend
`runtime/str.c` with `ail_str_compare`, (d) codegen arms for
`compare__Int` / `compare__Bool` / `compare__Str`, (e) the
match-on-primitive-comparison-result branch ladder. Mapped:
(c) in Task 1; (d)+(e) in Task 2; (a)+(b) in Task 3.
Coverage tests in Task 4. ✓
2. **Placeholder scan**: no `TBD`, `TODO`, `similar to`,
`implement later`, `add appropriate ...` in the body. The
`<sha-N>` placeholders in Task 5 Step 1 are explicit and
resolved in Step 2. ✓
3. **Type consistency**: `compare__Int`, `compare__Bool`,
`compare__Str`, `ail_str_compare`, `emit_ordering_arm`,
`try_emit_primitive_instance_body`, `Ordering`, `LT/EQ/GT`,
`compare_primitives_smoke.ail.json`,
`loads_workspace_auto_injects_prelude` — names match across
tasks. ✓
4. **Step granularity**: every step is one shell command or one
small code insertion; longest insertions (the three intercept
arms in Task 2 Step 5, the fixture JSON in Task 4 Step 3) are
each a single Edit / Write. ✓