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.
48 KiB
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/implementto 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— appendail_str_compare. - Modify:
crates/ailang-codegen/src/lib.rs— header gainsdeclare i32 @ail_str_compare(ptr, ptr);try_emit_primitive_instance_bodygains three arms (compare__Int,compare__Bool,compare__Str); new private helperemit_ordering_arm; doc ontry_emit_primitive_instance_bodycorrected. - Modify:
examples/prelude.ail.json— appendclass Ord a+ three instance defs. Existing Eq / Ordering / class machinery stays. - Modify:
crates/ailang-core/src/workspace.rs— extendloads_workspace_auto_injects_preludeto assertclass Ord+ Ord instances on Int / Bool / Str. - Create:
examples/compare_primitives_smoke.ail.json— E2E fixture callingcompareon each primitive with LT, EQ, GT pairs. - Modify:
crates/ail/tests/e2e.rs— new E2E testcompare_primitives_smoke_compiles_and_runs. - Modify:
crates/ailang-codegen/src/lib.rsmod tests— three IR-shape integration tests (one per primitive).
Task 1: runtime/str.c::ail_str_compare + IR declaration
Files:
-
Modify:
runtime/str.c— appendail_str_compare. -
Modify:
crates/ailang-codegen/src/lib.rs:~482— adddeclare i32 @ail_str_compare(ptr, ptr)after the existing@ail_str_eqdeclaration. -
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_comparetoruntime/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_comparedeclaration 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 helperemit_ordering_armdirectly below the intercept. -
Modify:
crates/ailang-codegen/src/lib.rsmod 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(<_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(>_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(<_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(>_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(<_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(>_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— appendclass Orddef + threeinstance Ord Tdefs after the existing Eq defs. -
Modify:
crates/ailang-core/src/workspace.rs::loads_workspace_auto_injects_preludetest — append assertions forclass 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.jsonwith 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 callingcompareon 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 testcompare_primitives_smoke_compiles_and_runs. -
Modify:
crates/ailang-codegen/src/lib.rsmod 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
- 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) extendruntime/str.cwithail_str_compare, (d) codegen arms forcompare__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. ✓ - 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. ✓ - 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. ✓ - 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. ✓