Files
AILang/docs/plans/0023-23.2-eq-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

36 KiB

23.2 — Eq Class + Primitive Instances — Implementation Plan

Parent spec: docs/specs/2026-05-10-23-eq-ord-prelude.md (Components row 23.2)

For agentic workers: REQUIRED SUB-SKILL: use skills/implement to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Ship class Eq a where eq : (a borrow, a borrow) -> Bool in the auto-loaded prelude with three primitive instances (Eq Int, Eq Bool, Eq Str), backed by a new runtime/str.c providing ail_str_eq. After this iter, a user program can call eq x y on Int / Bool / Str and the monomorphiser will synthesise eq__Int / eq__Bool / eq__Str top-level fns in the prelude module that codegen lowers to icmp eq i64 / icmp eq i1 / call zeroext i1 @ail_str_eq respectively.

Architecture: Eq Int and Eq Bool instance bodies are fn (x, y) { x == y } lambdas — they ride the existing lower_eq primitive dispatch and produce the right IR with zero new codegen mechanism. Eq Str cannot ride that path because the spec mandates @ail_str_eq rather than the existing @strcmp + icmp eq i32 0 shape; so codegen grows a single intercept in emit_fn keyed on the fn name eq__Str that emits a hand-rolled two-instruction body. The @ail_str_eq declaration is added to the IR header unconditionally and runtime/str.c is compiled+linked alongside every alloc strategy (Gc / Bump / Rc). The intercept is the narrowest possible change — == on Str stays on @strcmp this iter, the mono symbol eq__Str is the only path that goes through @ail_str_eq.

Tech Stack: crates/ailang-core (workspace + prelude JSON), crates/ailang-codegen (emit_fn intercept + IR header), crates/ail/src/main.rs (locate_str_runtime + clang link), new runtime/str.c.


Files this plan creates or modifies:

  • Create: runtime/str.c — single small C file with ail_str_eq(const char*, const char*) -> _Bool over libc strcmp.
  • Modify: crates/ail/src/main.rs — add locate_str_runtime helper (mirror of locate_rc_runtime); compile+link runtime/str.c unconditionally inside build_to for all three alloc strategies.
  • Modify: crates/ailang-codegen/src/lib.rs — IR header gains declare zeroext i1 @ail_str_eq(ptr, ptr); emit_fn gains a try_emit_primitive_instance_body intercept that hand-rolls the body for the fn name eq__Str.
  • Modify: examples/prelude.ail.json — add class Eq a + three instance defs (Eq Int, Eq Bool, Eq Str). Existing Ordering def stays.
  • Create: examples/eq_primitives_smoke.ail.json — E2E fixture calling eq on each primitive with both equal and unequal pairs.
  • Modify: crates/ailang-core/src/workspace.rs — extend loads_workspace_auto_injects_prelude to assert the Eq class is present.
  • Modify: crates/ail/tests/e2e.rs — new E2E test eq_primitives_smoke_compiles_and_runs.
  • Modify: crates/ailang-codegen/src/lib.rs mod tests — three new unit tests asserting the IR shape of eq__Int, eq__Bool, eq__Str.

Files:

  • Create: runtime/str.c

  • Modify: crates/ail/src/main.rs:1944-2136 (new locate_str_runtime helper near locate_rc_runtime; new clang compile+link block inside build_to)

  • Step 1: Write the failing test

// crates/ail/src/main.rs, inside the existing `#[cfg(test)] mod tests` block:
#[test]
fn locate_str_runtime_finds_workspace_str_c() {
    let p = super::locate_str_runtime().expect("locate runtime/str.c");
    assert!(
        p.ends_with("runtime/str.c"),
        "expected path ending in runtime/str.c, got {}",
        p.display()
    );
    assert!(p.exists(), "located path must exist on disk: {}", p.display());
}
  • Step 2: Run test to verify it fails

Run: cargo test --workspace -p ail locate_str_runtime_finds_workspace_str_c Expected: FAIL — compile error cannot find function locate_str_runtime in this scope (helper not declared yet).

  • Step 3: Write runtime/str.c
/* AILang Str runtime primitives.
 *
 * Provides ABI-stable string operations against the existing
 * constant-Str layout (NUL-terminated byte buffer, passed as
 * `ptr` in LLVM IR). Lives separately from runtime/rc.c so that
 * a future heap-Str ABI milestone can swap the implementations
 * without touching the RC runtime.
 *
 * Linked unconditionally by `ail build` (Gc, Bump, Rc) so that
 * any program that monomorphises `eq__Str` (auto-loaded from
 * the prelude) finds the symbol. Programs that never call
 * `eq` on Str leave @ail_str_eq as a dead-stripped no-op at
 * link time under clang -O2.
 */

#include <stdbool.h>
#include <string.h>

/* Byte-equality on NUL-terminated strings. Mirrors strcmp(...) == 0
 * but exposed under a stable AILang-namespaced ABI so the codegen
 * caller does not assume libc's strcmp shape. Heap-Str ABI milestone
 * will replace the body with length-prefixed comparison without
 * changing the signature. */
bool ail_str_eq(const char *a, const char *b) {
    return strcmp(a, b) == 0;
}
  • Step 4: Add locate_str_runtime helper

In crates/ail/src/main.rs directly after locate_rc_runtime (currently ending around line 1971), insert:

/// Locate the workspace-root `runtime/str.c` file relative to the
/// `ail` binary, mirroring [`locate_rc_runtime`]. Iter 23.2:
/// `runtime/str.c` houses Str-runtime primitives (`ail_str_eq`,
/// later `ail_str_compare`) and is linked unconditionally for every
/// alloc strategy — `@ail_str_eq` is declared in the emitted IR
/// header (codegen) without an alloc-strategy guard, so the symbol
/// must always be resolvable at link time.
fn locate_str_runtime() -> Result<PathBuf> {
    let candidates = [
        std::env::current_exe().ok(),
        std::env::current_dir().ok(),
    ];
    for start in candidates.iter().flatten() {
        let mut cur: &Path = start.as_path();
        loop {
            let candidate = cur.join("runtime").join("str.c");
            if candidate.exists() {
                return Ok(candidate);
            }
            match cur.parent() {
                Some(p) => cur = p,
                None => break,
            }
        }
    }
    anyhow::bail!(
        "could not locate `runtime/str.c` (linked unconditionally). \
         Run `ail` from inside the AILang workspace."
    )
}
  • Step 5: Run the locate-helper test to verify it passes

Run: cargo test --workspace -p ail locate_str_runtime_finds_workspace_str_c Expected: PASS.

  • Step 6: Wire str.c compile+link into build_to

In crates/ail/src/main.rs build_to, between the clang.arg(opt).arg("-o").arg(&out_bin).arg(&ll_path); line and the match alloc block (currently at line 2063), insert:

// Iter 23.2: compile and link `runtime/str.c` unconditionally —
// `@ail_str_eq` is emitted in the IR header without an alloc-strategy
// guard (it backs the prelude `eq__Str` mono symbol). The .o is
// cached at <tmpdir>/str.o per build invocation; if a program never
// monomorphises `eq__Str`, clang -O2 dead-strips the symbol so the
// linker overhead is fixed and negligible.
let str_src = locate_str_runtime()?;
let str_obj = tmpdir.join("str.o");
let cstatus = std::process::Command::new("clang")
    .arg("-O2")
    .arg("-c")
    .arg(&str_src)
    .arg("-o")
    .arg(&str_obj)
    .status()
    .context("compiling runtime/str.c")?;
if !cstatus.success() {
    anyhow::bail!(
        "clang failed compiling str.c (status {})",
        cstatus
    );
}
clang.arg(&str_obj);
  • Step 7: Verify the workspace still builds and existing E2E suite is green

Run: cargo build --workspace Expected: PASS.

Run: cargo test --workspace -p ail Expected: PASS (str.c is linked into every build but no IR yet calls @ail_str_eq; clang strips it cleanly).

  • Step 8: Commit
git add runtime/str.c crates/ail/src/main.rs
git commit -m "iter 23.2.1: runtime/str.c with ail_str_eq + unconditional link"

Task 2: Codegen — declare @ail_str_eq + eq__Str body intercept

Files:

  • Modify: crates/ailang-codegen/src/lib.rs:475 (IR header — add declare zeroext i1 @ail_str_eq(ptr, ptr))

  • Modify: crates/ailang-codegen/src/lib.rs emit_fn (currently at line 966) — insert intercept call after start_block("entry")

  • Modify: crates/ailang-codegen/src/lib.rs — new private method try_emit_primitive_instance_body

  • Modify: crates/ailang-codegen/src/lib.rs mod tests — new unit test eq_str_mono_symbol_emits_ail_str_eq_call

  • Step 1: Write the failing test

Append to the existing #[cfg(test)] mod tests block in crates/ailang-codegen/src/lib.rs:

/// Iter 23.2: codegen intercepts a fn named `eq__Str` (the
/// monomorphiser's synthesised symbol for `Eq Str.eq`) and emits
/// a two-instruction body that calls @ail_str_eq, regardless of
/// what the lambda body in the source would lower to. Pinned with
/// a synthetic FnDef so the test does not depend on prelude
/// auto-injection.
#[test]
fn eq_str_mono_symbol_emits_ail_str_eq_call() {
    let m = Module {
        schema: SCHEMA.into(),
        name: "prelude".into(),
        imports: vec![],
        defs: vec![
            Def::Fn(FnDef {
                name: "eq__Str".into(),
                ty: Type::Fn {
                    params: vec![Type::str_(), Type::str_()],
                    ret: Box::new(Type::bool_()),
                    effects: vec![],
                    param_modes: vec![ParamMode::Borrow, ParamMode::Borrow],
                    ret_mode: ParamMode::Implicit,
                },
                params: vec!["x".into(), "y".into()],
                // Body is a placeholder — the intercept must
                // ignore it. We use Lit::Bool false so even if the
                // intercept misfires the test still compiles.
                body: Term::Lit { lit: Literal::Bool { value: false } },
                suppress: vec![],
                doc: None,
            }),
            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 zeroext i1 @ail_str_eq(ptr, ptr)"),
        "header missing @ail_str_eq declaration; ir was:\n{ir}"
    );
    assert!(
        ir.contains("call zeroext i1 @ail_str_eq("),
        "eq__Str body must call @ail_str_eq; ir was:\n{ir}"
    );
}
  • Step 2: Run test to verify it fails

Run: cargo test --workspace -p ailang-codegen eq_str_mono_symbol_emits_ail_str_eq_call Expected: FAIL — header missing @ail_str_eq declaration (declaration not yet emitted) or eq__Str body must call @ail_str_eq (intercept not yet wired).

  • Step 3: Add the @ail_str_eq declaration to the IR header

In crates/ailang-codegen/src/lib.rs, directly after line 475 (out.push_str("declare i32 @strcmp(ptr, ptr)\n");), insert:

// Iter 23.2: `runtime/str.c::ail_str_eq` backs the prelude's
// `eq__Str` mono symbol (see `try_emit_primitive_instance_body`).
// Declared unconditionally — the codegen intercept emits a call
// to it whenever the monomorphiser synthesises `eq__Str` in any
// module; the .o supplying the symbol is linked unconditionally
// by `ail build` (see `crates/ail/src/main.rs::build_to`). The
// `zeroext i1` return matches clang's lowering of C `_Bool`.
out.push_str("declare zeroext i1 @ail_str_eq(ptr, ptr)\n");
  • Step 4: Add the try_emit_primitive_instance_body helper

In crates/ailang-codegen/src/lib.rs, inside the impl<'a> Emitter<'a> block (any location between existing methods is fine — recommend placing it directly above lower_eq at line 2430 since they share the "primitive-typed equality" topic), insert:

/// Iter 23.2: hand-rolled body for monomorphiser-synthesised
/// primitive instance methods whose natural lambda-lowering would
/// not produce the spec-mandated IR shape. Currently the only
/// inhabitant is `eq__Str`, which must call `@ail_str_eq` (the
/// stable AILang-namespaced Str-equality ABI in `runtime/str.c`)
/// rather than the `@strcmp + icmp eq i32 0` shape that
/// `lower_eq`'s Str arm would emit if we let the lambda body
/// `(== x y)` go through the normal path. Returns `Ok(true)` if
/// the body was emitted (caller must return immediately from
/// `emit_fn`); `Ok(false)` lets `emit_fn` continue with the
/// normal body lowering.
///
/// Future iters add `compare__Str` here (23.3); int/bool primitive
/// methods (`eq__Int`, `eq__Bool`, `compare__Int`, `compare__Bool`)
/// ride the natural `lower_eq` / `lower_compare` dispatch and do
/// not need a hand-rolled body.
fn try_emit_primitive_instance_body(
    &mut self,
    fn_name: &str,
    param_tys: &[String],
    ret_ty: &str,
) -> Result<bool> {
    match fn_name {
        "eq__Str" => {
            if param_tys != ["ptr", "ptr"] || ret_ty != "i1" {
                return Err(CodegenError::Internal(format!(
                    "eq__Str body intercept: unexpected signature \
                     ({param_tys:?}) -> {ret_ty} (want (ptr, ptr) -> i1)"
                )));
            }
            // The two params are the two most recently pushed locals;
            // `emit_fn` populated `self.locals` from `f.params` before
            // dispatching here. Pull their SSA names without assuming
            // a specific surface-level binder name.
            let n = self.locals.len();
            let a_ssa = self.locals[n - 2].1.clone();
            let b_ssa = self.locals[n - 1].1.clone();
            let dst = self.fresh_ssa();
            self.body.push_str(&format!(
                "  {dst} = call zeroext i1 @ail_str_eq(ptr {a_ssa}, ptr {b_ssa})\n"
            ));
            self.body.push_str(&format!("  ret i1 {dst}\n"));
            self.body.push_str("}\n\n");
            self.block_terminated = true;
            Ok(true)
        }
        _ => Ok(false),
    }
}
  • Step 5: Wire the intercept into emit_fn

In crates/ailang-codegen/src/lib.rs::emit_fn, directly after the self.start_block("entry"); call at line 1052 and before the let (val, val_ty) = self.lower_term(&f.body)?; line at 1054, insert:

// Iter 23.2: primitive-instance body intercept. Returns true if
// the body was emitted in full; we then skip the normal body
// lowering for this fn.
if self.try_emit_primitive_instance_body(&f.name, &llvm_param_tys, &llvm_ret)? {
    return Ok(());
}
  • Step 6: Run the unit test to verify it passes

Run: cargo test --workspace -p ailang-codegen eq_str_mono_symbol_emits_ail_str_eq_call Expected: PASS.

  • Step 7: Re-run the full codegen test suite to confirm no regressions

Run: cargo test --workspace -p ailang-codegen Expected: PASS — the intercept is keyed on the literal fn name eq__Str; no existing test fixture uses that name.

  • Step 8: Commit
git add crates/ailang-codegen/src/lib.rs
git commit -m "iter 23.2.2: codegen — declare @ail_str_eq + eq__Str body intercept"

Task 3: Prelude — class Eq a + three primitive instances

Files:

  • Modify: examples/prelude.ail.json — append class + 3 instance defs

  • Modify: crates/ailang-core/src/workspace.rs::loads_workspace_auto_injects_prelude test to also assert Eq class is present

  • Step 1: Write the failing test

In crates/ailang-core/src/workspace.rs, extend the existing loads_workspace_auto_injects_prelude test (currently at line 897). After the Ordering assertion block, append:

// Iter 23.2: prelude also ships the `Eq` class plus three
// primitive instances (Eq Int, Eq Bool, Eq Str).
assert!(
    prelude.defs.iter().any(|d| matches!(
        d,
        crate::ast::Def::Class(c) if c.name == "Eq"
    )),
    "prelude must contain Eq class def"
);
let eq_instance_types: Vec<&str> = prelude
    .defs
    .iter()
    .filter_map(|d| match d {
        crate::ast::Def::Instance(i) if i.class == "Eq" => {
            if let crate::ast::Type::Con { name, .. } = &i.type_ {
                Some(name.as_str())
            } else {
                None
            }
        }
        _ => None,
    })
    .collect();
assert!(
    eq_instance_types.contains(&"Int"),
    "prelude must contain `instance Eq Int`; saw Eq instances on: {eq_instance_types:?}"
);
assert!(
    eq_instance_types.contains(&"Bool"),
    "prelude must contain `instance Eq Bool`; saw Eq instances on: {eq_instance_types:?}"
);
assert!(
    eq_instance_types.contains(&"Str"),
    "prelude must contain `instance Eq Str`; saw Eq instances on: {eq_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 Eq class def (the prelude JSON has only Ordering today).

  • Step 3: Extend examples/prelude.ail.json

Replace the entire file (currently the Ordering type def only) with:

{
  "schema": "ailang/v0",
  "name": "prelude",
  "imports": [],
  "defs": [
    {
      "kind": "type",
      "name": "Ordering",
      "vars": [],
      "doc": "Result of a three-way comparison: LT (less than), EQ (equal), GT (greater than). Ships in milestone 23 as the codomain of Ord.compare.",
      "ctors": [
        { "name": "LT", "fields": [] },
        { "name": "EQ", "fields": [] },
        { "name": "GT", "fields": [] }
      ]
    },
    {
      "kind": "class",
      "name": "Eq",
      "param": "a",
      "doc": "Structural equality. Ships in milestone 23 alongside Ord. The primitive `==` operator stays as the surface-level comparator this iter; routing `==` through `Eq.eq` is the declared P2 follow-up (see docs/specs/2026-05-10-23-eq-ord-prelude.md, `Out of scope`).",
      "methods": [
        {
          "name": "eq",
          "type": {
            "k": "fn",
            "params": [
              { "k": "var", "name": "a" },
              { "k": "var", "name": "a" }
            ],
            "param_modes": ["borrow", "borrow"],
            "ret": { "k": "con", "name": "Bool" },
            "effects": []
          }
        }
      ]
    },
    {
      "kind": "instance",
      "class": "Eq",
      "type": { "k": "con", "name": "Int" },
      "doc": "Eq Int. Body lowers to `icmp eq i64` via the existing primitive `==` dispatch in `lower_eq`.",
      "methods": [
        {
          "name": "eq",
          "body": {
            "t": "lam",
            "params": ["x", "y"],
            "paramTypes": [
              { "k": "var", "name": "a" },
              { "k": "var", "name": "a" }
            ],
            "retType": { "k": "con", "name": "Bool" },
            "body": {
              "t": "app",
              "fn": { "t": "var", "name": "==" },
              "args": [
                { "t": "var", "name": "x" },
                { "t": "var", "name": "y" }
              ]
            }
          }
        }
      ]
    },
    {
      "kind": "instance",
      "class": "Eq",
      "type": { "k": "con", "name": "Bool" },
      "doc": "Eq Bool. Body lowers to `icmp eq i1` via the existing primitive `==` dispatch in `lower_eq`.",
      "methods": [
        {
          "name": "eq",
          "body": {
            "t": "lam",
            "params": ["x", "y"],
            "paramTypes": [
              { "k": "var", "name": "a" },
              { "k": "var", "name": "a" }
            ],
            "retType": { "k": "con", "name": "Bool" },
            "body": {
              "t": "app",
              "fn": { "t": "var", "name": "==" },
              "args": [
                { "t": "var", "name": "x" },
                { "t": "var", "name": "y" }
              ]
            }
          }
        }
      ]
    },
    {
      "kind": "instance",
      "class": "Eq",
      "type": { "k": "con", "name": "Str" },
      "doc": "Eq Str. The lambda body shape mirrors Int/Bool for round-trip stability, but the codegen intercept in `emit_fn` overrides the body to call `@ail_str_eq` directly — see `try_emit_primitive_instance_body` in `crates/ailang-codegen/src/lib.rs`.",
      "methods": [
        {
          "name": "eq",
          "body": {
            "t": "lam",
            "params": ["x", "y"],
            "paramTypes": [
              { "k": "var", "name": "a" },
              { "k": "var", "name": "a" }
            ],
            "retType": { "k": "con", "name": "Bool" },
            "body": {
              "t": "app",
              "fn": { "t": "var", "name": "==" },
              "args": [
                { "t": "var", "name": "x" },
                { "t": "var", "name": "y" }
              ]
            }
          }
        }
      ]
    }
  ]
}
  • 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: Re-run the full workspace test suite

Run: cargo build --workspace && cargo test --workspace Expected: PASS — the prelude is still parser-valid Form-A, no existing module imports Eq yet so no monomorphisation is triggered, every existing test stays green.

  • Step 6: Commit
git add examples/prelude.ail.json crates/ailang-core/src/workspace.rs
git commit -m "iter 23.2.3: prelude — class Eq a + Eq Int/Bool/Str instances"

Task 4: E2E fixture + integration tests (eq on three primitives)

Files:

  • Create: examples/eq_primitives_smoke.ail.json — calls eq on Int, Bool, Str (both equal and unequal pairs) and prints 1 / 0 via if.

  • Modify: crates/ail/tests/e2e.rs — new test eq_primitives_smoke_compiles_and_runs asserting stdout matches the expected six-line sequence.

  • Modify: crates/ailang-codegen/src/lib.rs mod tests — three integration tests asserting the IR shape of the monomorphiser- synthesised @ail_prelude_eq__Int, @ail_prelude_eq__Bool, @ail_prelude_eq__Str definitions when fed a fixture that calls eq on each primitive.

  • Step 1: Write the failing E2E test

In crates/ail/tests/e2e.rs, after the existing ordering_match_via_prelude_prints_1 test, append:

/// Iter 23.2: end-to-end coverage for the auto-loaded `Eq` class
/// + three primitive instances. The fixture calls `eq` on Int /
/// Bool / Str with both an equal and an unequal pair each; the
/// monomorphiser synthesises `eq__Int` / `eq__Bool` / `eq__Str`
/// in the prelude module; the binary prints six lines, one per
/// call. Equal-call lines are `1`, unequal-call lines are `0`.
#[test]
fn eq_primitives_smoke_compiles_and_runs() {
    let stdout = build_and_run("eq_primitives_smoke.ail.json");
    assert_eq!(stdout, "1\n0\n1\n0\n1\n0\n");
}
  • Step 2: Run test to verify it fails

Run: cargo test --workspace -p ail eq_primitives_smoke_compiles_and_runs Expected: FAIL — examples/eq_primitives_smoke.ail.json does not exist; ail build exits non-zero with a load error.

  • Step 3: Write the fixture

Create examples/eq_primitives_smoke.ail.json:

{
  "schema": "ailang/v0",
  "name": "eq_primitives_smoke",
  "imports": [],
  "defs": [
    {
      "kind": "fn",
      "name": "to_int",
      "doc": "1 if the bool is true, 0 otherwise. Used to render Eq results as printable Ints.",
      "type": {
        "k": "fn",
        "params": [{ "k": "con", "name": "Bool" }],
        "ret": { "k": "con", "name": "Int" },
        "effects": []
      },
      "params": ["b"],
      "body": {
        "t": "if",
        "cond": { "t": "var", "name": "b" },
        "then": { "t": "lit", "lit": { "kind": "int", "value": 1 } },
        "else": { "t": "lit", "lit": { "kind": "int", "value": 0 } }
      }
    },
    {
      "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": "to_int" },
            "args": [{
              "t": "app",
              "fn": { "t": "var", "name": "eq" },
              "args": [
                { "t": "lit", "lit": { "kind": "int", "value": 7 } },
                { "t": "lit", "lit": { "kind": "int", "value": 7 } }
              ]
            }]
          }]
        },
        "rhs": {
          "t": "seq",
          "lhs": {
            "t": "do",
            "op": "io/print_int",
            "args": [{
              "t": "app",
              "fn": { "t": "var", "name": "to_int" },
              "args": [{
                "t": "app",
                "fn": { "t": "var", "name": "eq" },
                "args": [
                  { "t": "lit", "lit": { "kind": "int", "value": 7 } },
                  { "t": "lit", "lit": { "kind": "int", "value": 8 } }
                ]
              }]
            }]
          },
          "rhs": {
            "t": "seq",
            "lhs": {
              "t": "do",
              "op": "io/print_int",
              "args": [{
                "t": "app",
                "fn": { "t": "var", "name": "to_int" },
                "args": [{
                  "t": "app",
                  "fn": { "t": "var", "name": "eq" },
                  "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": "to_int" },
                  "args": [{
                    "t": "app",
                    "fn": { "t": "var", "name": "eq" },
                    "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": "to_int" },
                    "args": [{
                      "t": "app",
                      "fn": { "t": "var", "name": "eq" },
                      "args": [
                        { "t": "lit", "lit": { "kind": "str", "value": "hello" } },
                        { "t": "lit", "lit": { "kind": "str", "value": "hello" } }
                      ]
                    }]
                  }]
                },
                "rhs": {
                  "t": "do",
                  "op": "io/print_int",
                  "args": [{
                    "t": "app",
                    "fn": { "t": "var", "name": "to_int" },
                    "args": [{
                      "t": "app",
                      "fn": { "t": "var", "name": "eq" },
                      "args": [
                        { "t": "lit", "lit": { "kind": "str", "value": "hello" } },
                        { "t": "lit", "lit": { "kind": "str", "value": "world" } }
                      ]
                    }]
                  }]
                }
              }
            }
          }
        }
      }
    }
  ]
}
  • Step 4: Run E2E test to verify it passes

Run: cargo test --workspace -p ail eq_primitives_smoke_compiles_and_runs Expected: PASS — stdout is "1\n0\n1\n0\n1\n0\n".

If FAIL: the most likely cause is the monomorphiser not finding the prelude's Eq instances for the implicit import. Re-read crates/ailang-check/src/lib.rs::build_check_env lines around the prelude-overlay added in iter 23.1 to confirm the implicit import is reaching the registry for class-instance lookup; the registry::build_registry path in ailang-core::workspace must also pick up the prelude's Def::Class and Def::Instance entries without an explicit user import. If a gap is found, report it via NEEDS_CONTEXT to the orchestrator (this is the kind of cross- crate wiring that is in scope for the iter but may need a small follow-up commit).

  • Step 5: Add three codegen-IR integration tests

Append to the existing #[cfg(test)] mod tests block in crates/ailang-codegen/src/lib.rs:

/// Iter 23.2: integration with the auto-loaded prelude. Compiling
/// a workspace that calls `eq` on an Int pair must produce a
/// mono-synthesised `@ail_prelude_eq__Int` whose body lowers to
/// `icmp eq i64`. The test runs the full pipeline end-of-typecheck →
/// monomorphise → codegen, then asserts substring shape on the IR.
#[test]
fn eq_int_call_produces_icmp_i64_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("eq_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_eq__Int"),
        "ir must contain mono-synthesised @ail_prelude_eq__Int; ir was:\n{ir}"
    );
    assert!(
        ir.contains("icmp eq i64"),
        "eq__Int body must lower to icmp eq i64; ir was:\n{ir}"
    );
}

/// Iter 23.2: same shape as `eq_int_call_produces_icmp_i64_mono_fn`
/// for Bool. Asserts the mono-synthesised `@ail_prelude_eq__Bool`
/// lowers to `icmp eq i1`.
#[test]
fn eq_bool_call_produces_icmp_i1_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("eq_primitives_smoke.ail.json");
    let ws = load_workspace(&entry).expect("load workspace");
    let _ = check_workspace(&ws);
    let ws = monomorphise_workspace(&ws).expect("monomorphise");
    let ir = lower_workspace(&ws).expect("lower");
    assert!(
        ir.contains("@ail_prelude_eq__Bool"),
        "ir must contain mono-synthesised @ail_prelude_eq__Bool; ir was:\n{ir}"
    );
    assert!(
        ir.contains("icmp eq i1"),
        "eq__Bool body must lower to icmp eq i1; ir was:\n{ir}"
    );
}

/// Iter 23.2: Str's mono-symbol body is hand-rolled by the
/// `try_emit_primitive_instance_body` intercept. Asserts the
/// intercept fires for the prelude-loaded `eq__Str` synthesised
/// against a real user fixture that calls `eq` on Str.
#[test]
fn eq_str_call_produces_ail_str_eq_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("eq_primitives_smoke.ail.json");
    let ws = load_workspace(&entry).expect("load workspace");
    let _ = check_workspace(&ws);
    let ws = monomorphise_workspace(&ws).expect("monomorphise");
    let ir = lower_workspace(&ws).expect("lower");
    assert!(
        ir.contains("@ail_prelude_eq__Str"),
        "ir must contain mono-synthesised @ail_prelude_eq__Str; ir was:\n{ir}"
    );
    assert!(
        ir.contains("call zeroext i1 @ail_str_eq("),
        "eq__Str body must call @ail_str_eq; ir was:\n{ir}"
    );
}

Add at the top of crates/ailang-codegen/Cargo.toml [dev-dependencies] if not already present:

ailang-core = { path = "../ailang-core" }
ailang-check = { path = "../ailang-check" }

Check before adding — these may already be in [dependencies], in which case the test can use them directly without a separate dev-dependency entry. Run cargo metadata --format-version 1 | grep -E 'ailang-(core|check)' to confirm.

  • Step 6: Run the three integration tests to verify they pass

Run: cargo test --workspace -p ailang-codegen eq_int_call_produces_icmp_i64_mono_fn eq_bool_call_produces_icmp_i1_mono_fn eq_str_call_produces_ail_str_eq_call_mono_fn Expected: PASS for all three.

  • Step 7: Run the full workspace test suite once more to confirm no cross-test regressions

Run: cargo build --workspace && cargo test --workspace Expected: PASS.

  • Step 8: Commit
git add examples/eq_primitives_smoke.ail.json crates/ail/tests/e2e.rs crates/ailang-codegen/src/lib.rs crates/ailang-codegen/Cargo.toml
git commit -m "iter 23.2.4: e2e smoke fixture + ir-shape integration tests for eq__T mono symbols"

(If Cargo.toml didn't need updating, omit it from the git add.)


Task 5: JOURNAL entry for iter 23.2

Files:

  • Modify: docs/JOURNAL.md — append a new entry at the top of the reverse-chronological list.

  • Step 1: Append the JOURNAL entry

At the top of docs/JOURNAL.md (after the existing iter-23.1 entry), insert:

## 2026-05-10 — Iteration 23.2: Eq class + three primitive instances

Shipped `class Eq a where eq : (a borrow, a borrow) -> Bool` in the
auto-loaded prelude plus three primitive instances (Eq Int, Eq
Bool, Eq Str). User programs can now call `eq x y` on any of the
three primitives; the monomorphiser synthesises `eq__Int` /
`eq__Bool` / `eq__Str` top-level fns in the prelude module and
codegen lowers them as follows:

- `eq__Int``icmp eq i64` via existing `lower_eq` Int arm.
- `eq__Bool``icmp eq i1` via existing `lower_eq` Bool arm.
- `eq__Str``call zeroext i1 @ail_str_eq(...)` via a new codegen
  intercept (`try_emit_primitive_instance_body` in
  `crates/ailang-codegen/src/lib.rs`).

The intercept is the narrowest possible change — `==` on Str stays
on `@strcmp + icmp eq i32 0` this iter; only the mono-synthesised
`eq__Str` symbol goes through `@ail_str_eq`. The new
`runtime/str.c` houses the C-side primitive (a one-liner wrapping
libc `strcmp`); the .o is linked unconditionally by `ail build` for
all alloc strategies (Gc, Bump, Rc).

Per-task commits: 23.2.1 (runtime/str.c + locate_str_runtime +
unconditional link), 23.2.2 (codegen — `@ail_str_eq` declaration +
`eq__Str` intercept), 23.2.3 (prelude — class Eq a + three
instances), 23.2.4 (e2e fixture + IR-shape integration tests),
23.2.5 (this JOURNAL entry).

Known debt: `compare__Str` will reuse the same intercept mechanism
in iter 23.3 (adds the `compare` arm + `ail_str_compare` in
`runtime/str.c`). The `==``eq x y` redundancy on Int/Bool/Str is
ratified as transitional per spec `Out of scope` until the P2
operator-routing milestone.
  • Step 2: Commit
git add docs/JOURNAL.md
git commit -m "iter 23.2.5: journal entry — Eq class + primitive instances"

Self-review

  1. Spec coverage: Components row 23.2 names six deliverables — (a) class Eq a where eq, (b) three instances (Int/Bool/Str), (c) runtime/str.c with ail_str_eq, (d) codegen arm for eq__Str emitting call zext i1 @ail_str_eq, (e) codegen unit tests for eq__Int, eq__Bool, eq__Str. Mapped: (a)+(b) in Task 3; (c) in Task 1; (d) in Task 2; (e) in Task 4. ✓
  2. Placeholder scan: no TBD, TODO, similar to, implement later, add appropriate ... in the body. ✓
  3. Type consistency: eq__Str, @ail_str_eq, try_emit_primitive_instance_body, locate_str_runtime, loads_workspace_auto_injects_prelude, eq_primitives_smoke.ail.json — names match across tasks. ✓
  4. Step granularity: every step is one shell command or one small code insertion; longest insertion is the fixture JSON in Task 4 step 3 (still a single Write). ✓