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

48 KiB
Raw Blame History

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:

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

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

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

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

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

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

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

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

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

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

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

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


## 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
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. ✓