Files
AILang/docs/plans/0035-iter-23.5.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

46 KiB

Iter 23.5 — Prelude free fns + E2E — Implementation Plan

Parent spec: docs/specs/0014-23-eq-ord-prelude.md

For agentic workers: REQUIRED SUB-SKILL: use skills/implement to run this plan. Steps use - [ ] checkboxes for tracking. No task contains a git commit step — the implementer never commits (disc.1 discipline). The Boss commits the iter at the end.

Goal: Ship the five polymorphic free fns ne/lt/le/gt/ge in examples/prelude.ail.json plus the three E2E fixtures (positive, user-ADT, negative-Float) that close milestone 23.

Architecture: All work rides on top of the unified mono pass shipped in iter 23.4 (commit a1692a4). The five free fns are polymorphic Def::Fns with class constraints; monomorphise_workspace already specialises them via the free-fn arm. Bodies are LLM-natural expressions: ne calls the not builtin over eq; lt/le/gt/ge pattern-match on compare against LT/GT plus a wildcard. The Float-NoInstance acceptance is a typecheck-time gate — the mono pass never runs because constraint resolution fails first.

Tech Stack:

  • examples/prelude.ail.json — five new top-level kind: "fn" entries
  • examples/eq_ord_polymorphic.ail.json (new) — positive E2E fixture
  • examples/eq_ord_user_adt.ail.json (new) — user-ADT E2E fixture
  • examples/eq_float_noinstance.ail.json (new) — negative-Float fixture
  • crates/ail/tests/prelude_free_fns.rs (new) — workspace tests per fn
  • crates/ail/tests/eq_ord_e2e.rs (new) — three E2E binary-run tests
  • crates/ail/tests/eq_float_noinstance.rs (new) — diagnostic pin
  • crates/ailang-check/src/lib.rs:1607-1613 — Float-aware NoInstance message addendum at construction site
  • docs/DESIGN.md:1702 — §"Decision 11 / Prelude classes" amendment
  • docs/roadmap.md:44-64 — Post-22 Prelude entry split

Files this plan creates or modifies

Create:

  • examples/prelude.ail.json — extend with 5 new Def::Fns (modify, not create)
  • examples/eq_ord_polymorphic.ail.json — positive E2E
  • examples/eq_ord_user_adt.ail.json — user-ADT E2E
  • examples/eq_float_noinstance.ail.json — negative-Float E2E
  • crates/ail/tests/prelude_free_fns.rs — workspace tests per fn
  • crates/ail/tests/eq_ord_e2e.rs — binary-run E2E tests
  • crates/ail/tests/eq_float_noinstance.rs — diagnostic pin

Modify:

  • examples/prelude.ail.json:221 — insertion point for the five free fns is between line 220 (} closing last instance) and line 221 (] closing defs)
  • crates/ailang-check/src/lib.rs:1607-1613 — Float-aware NoInstance addendum at construction site (current bare-message anchor at lib.rs:563-566)
  • docs/DESIGN.md:1702-1704 — insertion of Milestone-23 amendment paragraph between "see typeclasses spec amendments..." and "Primitive output goes through io/print_int..."
  • docs/roadmap.md:44-64 — split the Post-22 Prelude entry into shipped Eq/Ord half ([x]) and pending Show/print half ([ ])

Test:

  • crates/ail/tests/prelude_free_fns.rs — 5 functions: ne_at_int, lt_at_int, le_at_str, gt_at_bool, ge_at_int (and a hash-stability pin for ne__Int mirroring mono_hash_stability.rs:34-41)
  • crates/ail/tests/eq_ord_e2e.rs — 3 functions: eq_ord_polymorphic_runs_end_to_end, eq_ord_user_adt_runs_end_to_end, eq_ord_user_adt_eq_intbox_hash_stable
  • crates/ail/tests/eq_float_noinstance.rs — 1 function: eq_at_float_fires_float_aware_noinstance

Task 1: Add ne free fn to prelude

The simplest free fn: ne : forall a. Eq a => (a borrow, a borrow) -> Bool with body not(eq(x, y)). Uses the not builtin (registered at crates/ailang-check/src/builtins.rs:127, signature (Bool) -> Bool). Tests ne at Int (true and false cases) and pins ne__Int's mono-symbol hash.

Files:

  • Modify: examples/prelude.ail.json:221 (insert before closing ] of defs)

  • Create: crates/ail/tests/prelude_free_fns.rs (this task scaffolds the file; later tasks extend it)

  • Step 1: Write the failing test

Create crates/ail/tests/prelude_free_fns.rs:

use ailang_core::workspace::load_workspace;
use ailang_check::{check_workspace, monomorphise_workspace};
use std::path::PathBuf;

fn fixture(name: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../../examples")
        .join(name)
}

fn mono_symbol_names(workspace_name: &str) -> Vec<String> {
    let ws = load_workspace(&fixture(workspace_name)).expect("load");
    let diags = check_workspace(&ws);
    assert!(diags.is_empty(), "typecheck failed: {:#?}", diags);
    let mono = monomorphise_workspace(&ws).expect("mono");
    mono.modules
        .iter()
        .flat_map(|m| m.defs.iter())
        .filter_map(|d| match d {
            ailang_core::ast::Def::Fn(f) => Some(f.name.clone()),
            _ => None,
        })
        .collect()
}

#[test]
fn ne_at_int_produces_mono_symbol() {
    let names = mono_symbol_names("ne_at_int_smoke.ail.json");
    assert!(
        names.iter().any(|n| n == "ne__Int"),
        "expected ne__Int in workspace, found: {names:#?}"
    );
}

Also create examples/ne_at_int_smoke.ail.json:

{
  "schema": "ailang/v0",
  "name": "ne_at_int_smoke",
  "imports": [],
  "defs": [
    {
      "kind": "fn",
      "name": "main",
      "type": {
        "k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" },
        "effects": ["IO"]
      },
      "params": [],
      "body": {
        "t": "do", "op": "io/print_int",
        "args": [{
          "t": "if",
          "cond": {
            "t": "app",
            "fn": { "t": "var", "name": "ne" },
            "args": [
              { "t": "lit", "lit": { "kind": "int", "value": 3 } },
              { "t": "lit", "lit": { "kind": "int", "value": 5 } }
            ]
          },
          "then": { "t": "lit", "lit": { "kind": "int", "value": 1 } },
          "else": { "t": "lit", "lit": { "kind": "int", "value": 0 } }
        }]
      }
    }
  ]
}
  • Step 2: Run test to verify it fails

Run: cargo test --workspace -p ail --test prelude_free_fns ne_at_int_produces_mono_symbol

Expected: FAIL with "typecheck failed" (the prelude has no ne yet so ne is unresolved).

  • Step 3: Write minimal implementation

Edit examples/prelude.ail.json. Before the closing ] of defs (line 221), insert a new top-level Def::Fn:

    ,
    {
      "kind": "fn",
      "name": "ne",
      "doc": "Polymorphic disequality. `ne x y` ≡ not (eq x y). Ships in milestone 23 as the Eq-class free helper.",
      "type": {
        "k": "forall",
        "vars": ["a"],
        "constraints": [{ "class": "Eq", "type": { "k": "var", "name": "a" } }],
        "body": {
          "k": "fn",
          "params": [
            { "k": "var", "name": "a" },
            { "k": "var", "name": "a" }
          ],
          "param_modes": ["borrow", "borrow"],
          "ret": { "k": "con", "name": "Bool" },
          "effects": []
        }
      },
      "params": ["x", "y"],
      "body": {
        "t": "app",
        "fn": { "t": "var", "name": "not" },
        "args": [{
          "t": "app",
          "fn": { "t": "var", "name": "eq" },
          "args": [
            { "t": "var", "name": "x" },
            { "t": "var", "name": "y" }
          ]
        }]
      }
    }

(The leading comma terminates the previous instance entry.)

  • Step 4: Run test to verify it passes

Run: cargo test --workspace -p ail --test prelude_free_fns ne_at_int_produces_mono_symbol

Expected: PASS


Task 2: Add lt / le / gt / ge free fns to prelude

Four polymorphic free fns with the same structural pattern: forall a. Ord a => (a borrow, a borrow) -> Bool, body pattern-matches compare(x, y) against an Ordering ctor and a wildcard. The match-term shape mirrors the existing examples/cmp_max_smoke.ail.json:21-32 precedent (proven case-on-compare; bare ctor LT works without prelude qualification per examples/ordering_match.ail.json).

Pattern table:

Fn Hit-ctor Hit-body Wildcard-body
lt LT true false
le GT false true
gt GT true false
ge LT false true

Files:

  • Modify: examples/prelude.ail.json (insert four more Def::Fns after ne)

  • Modify: crates/ail/tests/prelude_free_fns.rs (extend with four tests)

  • Step 1: Write the failing tests

Extend crates/ail/tests/prelude_free_fns.rs with:

#[test]
fn lt_at_int_produces_mono_symbol() {
    let names = mono_symbol_names("lt_at_int_smoke.ail.json");
    assert!(
        names.iter().any(|n| n == "lt__Int"),
        "expected lt__Int in workspace, found: {names:#?}"
    );
}

#[test]
fn le_at_str_produces_mono_symbol() {
    let names = mono_symbol_names("le_at_str_smoke.ail.json");
    assert!(
        names.iter().any(|n| n == "le__Str"),
        "expected le__Str in workspace, found: {names:#?}"
    );
}

#[test]
fn gt_at_bool_produces_mono_symbol() {
    let names = mono_symbol_names("gt_at_bool_smoke.ail.json");
    assert!(
        names.iter().any(|n| n == "gt__Bool"),
        "expected gt__Bool in workspace, found: {names:#?}"
    );
}

#[test]
fn ge_at_int_produces_mono_symbol() {
    let names = mono_symbol_names("ge_at_int_smoke.ail.json");
    assert!(
        names.iter().any(|n| n == "ge__Int"),
        "expected ge__Int in workspace, found: {names:#?}"
    );
}

Create the four corresponding smoke fixtures. Each follows the same shape as ne_at_int_smoke.ail.json from Task 1, with the fn name and arg types changed appropriately. For example, examples/lt_at_int_smoke.ail.json:

{
  "schema": "ailang/v0",
  "name": "lt_at_int_smoke",
  "imports": [],
  "defs": [
    {
      "kind": "fn",
      "name": "main",
      "type": {
        "k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" },
        "effects": ["IO"]
      },
      "params": [],
      "body": {
        "t": "do", "op": "io/print_int",
        "args": [{
          "t": "if",
          "cond": {
            "t": "app",
            "fn": { "t": "var", "name": "lt" },
            "args": [
              { "t": "lit", "lit": { "kind": "int", "value": 3 } },
              { "t": "lit", "lit": { "kind": "int", "value": 5 } }
            ]
          },
          "then": { "t": "lit", "lit": { "kind": "int", "value": 1 } },
          "else": { "t": "lit", "lit": { "kind": "int", "value": 0 } }
        }]
      }
    }
  ]
}

For le_at_str_smoke.ail.json, swap "name": "lt""name": "le" and the two int literals for two str literals: { "t": "lit", "lit": { "kind": "str", "value": "abc" } } and { "t": "lit", "lit": { "kind": "str", "value": "abd" } }.

For gt_at_bool_smoke.ail.json, use "name": "gt" and two bool literals: { "t": "lit", "lit": { "kind": "bool", "value": true } } and { "t": "lit", "lit": { "kind": "bool", "value": false } }.

For ge_at_int_smoke.ail.json, use "name": "ge" and two int literals.

  • Step 2: Run tests to verify they fail

Run: cargo test --workspace -p ail --test prelude_free_fns lt_at_int_produces_mono_symbol

Expected: FAIL with "typecheck failed" (lt not yet in prelude).

Same for le_at_str, gt_at_bool, ge_at_int. All four FAIL.

  • Step 3: Write minimal implementation

Edit examples/prelude.ail.json. After the ne entry from Task 1 (before the closing ] of defs), insert four more Def::Fns. First, lt:

    ,
    {
      "kind": "fn",
      "name": "lt",
      "doc": "Polymorphic strict-less-than. `lt x y` ≡ case compare x y of LT -> True; _ -> False. Ships in milestone 23 as the Ord-class free helper.",
      "type": {
        "k": "forall",
        "vars": ["a"],
        "constraints": [{ "class": "Ord", "type": { "k": "var", "name": "a" } }],
        "body": {
          "k": "fn",
          "params": [
            { "k": "var", "name": "a" },
            { "k": "var", "name": "a" }
          ],
          "param_modes": ["borrow", "borrow"],
          "ret": { "k": "con", "name": "Bool" },
          "effects": []
        }
      },
      "params": ["x", "y"],
      "body": {
        "t": "match",
        "scrutinee": {
          "t": "app",
          "fn": { "t": "var", "name": "compare" },
          "args": [
            { "t": "var", "name": "x" },
            { "t": "var", "name": "y" }
          ]
        },
        "arms": [
          {
            "pat": { "p": "ctor", "ctor": "LT", "fields": [] },
            "body": { "t": "lit", "lit": { "kind": "bool", "value": true } }
          },
          {
            "pat": { "p": "wild" },
            "body": { "t": "lit", "lit": { "kind": "bool", "value": false } }
          }
        ]
      }
    }

Then le (hit-ctor GT, hit-body false, wild-body true):

    ,
    {
      "kind": "fn",
      "name": "le",
      "doc": "Polymorphic less-than-or-equal. `le x y` ≡ case compare x y of GT -> False; _ -> True.",
      "type": {
        "k": "forall",
        "vars": ["a"],
        "constraints": [{ "class": "Ord", "type": { "k": "var", "name": "a" } }],
        "body": {
          "k": "fn",
          "params": [
            { "k": "var", "name": "a" },
            { "k": "var", "name": "a" }
          ],
          "param_modes": ["borrow", "borrow"],
          "ret": { "k": "con", "name": "Bool" },
          "effects": []
        }
      },
      "params": ["x", "y"],
      "body": {
        "t": "match",
        "scrutinee": {
          "t": "app",
          "fn": { "t": "var", "name": "compare" },
          "args": [
            { "t": "var", "name": "x" },
            { "t": "var", "name": "y" }
          ]
        },
        "arms": [
          {
            "pat": { "p": "ctor", "ctor": "GT", "fields": [] },
            "body": { "t": "lit", "lit": { "kind": "bool", "value": false } }
          },
          {
            "pat": { "p": "wild" },
            "body": { "t": "lit", "lit": { "kind": "bool", "value": true } }
          }
        ]
      }
    }

Then gt (hit-ctor GT, hit-body true, wild-body false):

    ,
    {
      "kind": "fn",
      "name": "gt",
      "doc": "Polymorphic strict-greater-than. `gt x y` ≡ case compare x y of GT -> True; _ -> False.",
      "type": {
        "k": "forall",
        "vars": ["a"],
        "constraints": [{ "class": "Ord", "type": { "k": "var", "name": "a" } }],
        "body": {
          "k": "fn",
          "params": [
            { "k": "var", "name": "a" },
            { "k": "var", "name": "a" }
          ],
          "param_modes": ["borrow", "borrow"],
          "ret": { "k": "con", "name": "Bool" },
          "effects": []
        }
      },
      "params": ["x", "y"],
      "body": {
        "t": "match",
        "scrutinee": {
          "t": "app",
          "fn": { "t": "var", "name": "compare" },
          "args": [
            { "t": "var", "name": "x" },
            { "t": "var", "name": "y" }
          ]
        },
        "arms": [
          {
            "pat": { "p": "ctor", "ctor": "GT", "fields": [] },
            "body": { "t": "lit", "lit": { "kind": "bool", "value": true } }
          },
          {
            "pat": { "p": "wild" },
            "body": { "t": "lit", "lit": { "kind": "bool", "value": false } }
          }
        ]
      }
    }

Then ge (hit-ctor LT, hit-body false, wild-body true):

    ,
    {
      "kind": "fn",
      "name": "ge",
      "doc": "Polymorphic greater-than-or-equal. `ge x y` ≡ case compare x y of LT -> False; _ -> True.",
      "type": {
        "k": "forall",
        "vars": ["a"],
        "constraints": [{ "class": "Ord", "type": { "k": "var", "name": "a" } }],
        "body": {
          "k": "fn",
          "params": [
            { "k": "var", "name": "a" },
            { "k": "var", "name": "a" }
          ],
          "param_modes": ["borrow", "borrow"],
          "ret": { "k": "con", "name": "Bool" },
          "effects": []
        }
      },
      "params": ["x", "y"],
      "body": {
        "t": "match",
        "scrutinee": {
          "t": "app",
          "fn": { "t": "var", "name": "compare" },
          "args": [
            { "t": "var", "name": "x" },
            { "t": "var", "name": "y" }
          ]
        },
        "arms": [
          {
            "pat": { "p": "ctor", "ctor": "LT", "fields": [] },
            "body": { "t": "lit", "lit": { "kind": "bool", "value": false } }
          },
          {
            "pat": { "p": "wild" },
            "body": { "t": "lit", "lit": { "kind": "bool", "value": true } }
          }
        ]
      }
    }
  • Step 4: Run tests to verify they pass

Run: cargo test --workspace -p ail --test prelude_free_fns

Expected: all 5 tests (ne_at_int, lt_at_int, le_at_str, gt_at_bool, ge_at_int) PASS.


Task 3: Positive E2E — eq_ord_polymorphic

End-to-end test that compiles a workspace with a user-defined polymorphic helper invoking the new prelude fns at three primitive types (Int / Bool / Str), runs the resulting binary, and pins stdout. The helper deliberately combines lt + eq to exercise both Eq and Ord routes through the unified mono pass.

Helper signature: fn at_most : forall a. Ord a => (a borrow, a borrow) -> Bool with body not (gt x y) — same as le x y but composed from prelude fns explicitly (proves composition works, not just direct prelude-fn calls).

Files:

  • Create: examples/eq_ord_polymorphic.ail.json

  • Create: crates/ail/tests/eq_ord_e2e.rs

  • Step 1: Write the failing test

Create crates/ail/tests/eq_ord_e2e.rs:

use std::path::PathBuf;
use std::process::Command;

fn fixture_path(name: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../../examples")
        .join(name)
}

fn build_and_run(fixture: &str) -> String {
    let src = fixture_path(fixture);
    let out = std::env::temp_dir().join(format!("ail_{}.bin", fixture.replace('.', "_")));

    let build = Command::new(env!("CARGO_BIN_EXE_ail"))
        .args(["build", src.to_str().unwrap(), "-o", out.to_str().unwrap()])
        .output()
        .expect("ail build");
    assert!(
        build.status.success(),
        "ail build failed:\nstdout: {}\nstderr: {}",
        String::from_utf8_lossy(&build.stdout),
        String::from_utf8_lossy(&build.stderr),
    );

    let run = Command::new(&out).output().expect("run binary");
    assert!(
        run.status.success(),
        "binary exited non-zero:\nstdout: {}\nstderr: {}",
        String::from_utf8_lossy(&run.stdout),
        String::from_utf8_lossy(&run.stderr),
    );
    String::from_utf8_lossy(&run.stdout).trim().to_string()
}

#[test]
fn eq_ord_polymorphic_runs_end_to_end() {
    let stdout = build_and_run("eq_ord_polymorphic.ail.json");
    // Helper `at_most(3, 5)` at Int = true → 1
    // `at_most(true, false)` at Bool = false → 0
    // `at_most("abc", "abd")` at Str = true → 1
    // Concatenated via three io/print_int = "101"
    assert_eq!(stdout, "101", "expected 101, got {stdout:?}");
}
  • Step 2: Run test to verify it fails

Run: cargo test --workspace -p ail --test eq_ord_e2e eq_ord_polymorphic_runs_end_to_end

Expected: FAIL with "ail build failed" (fixture file does not exist yet).

  • Step 3: Write minimal implementation

Create examples/eq_ord_polymorphic.ail.json:

{
  "schema": "ailang/v0",
  "name": "eq_ord_polymorphic",
  "imports": [],
  "defs": [
    {
      "kind": "fn",
      "name": "at_most",
      "doc": "Composition of prelude `gt` with `not` — semantically equivalent to `le`. Used to exercise polymorphic-helper-over-prelude-fns composition through the unified mono pass.",
      "type": {
        "k": "forall",
        "vars": ["a"],
        "constraints": [{ "class": "Ord", "type": { "k": "var", "name": "a" } }],
        "body": {
          "k": "fn",
          "params": [
            { "k": "var", "name": "a" },
            { "k": "var", "name": "a" }
          ],
          "param_modes": ["borrow", "borrow"],
          "ret": { "k": "con", "name": "Bool" },
          "effects": []
        }
      },
      "params": ["x", "y"],
      "body": {
        "t": "app",
        "fn": { "t": "var", "name": "not" },
        "args": [{
          "t": "app",
          "fn": { "t": "var", "name": "gt" },
          "args": [
            { "t": "var", "name": "x" },
            { "t": "var", "name": "y" }
          ]
        }]
      }
    },
    {
      "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": "if",
            "cond": {
              "t": "app",
              "fn": { "t": "var", "name": "at_most" },
              "args": [
                { "t": "lit", "lit": { "kind": "int", "value": 3 } },
                { "t": "lit", "lit": { "kind": "int", "value": 5 } }
              ]
            },
            "then": { "t": "lit", "lit": { "kind": "int", "value": 1 } },
            "else": { "t": "lit", "lit": { "kind": "int", "value": 0 } }
          }]
        },
        "rhs": {
          "t": "seq",
          "lhs": {
            "t": "do", "op": "io/print_int",
            "args": [{
              "t": "if",
              "cond": {
                "t": "app",
                "fn": { "t": "var", "name": "at_most" },
                "args": [
                  { "t": "lit", "lit": { "kind": "bool", "value": true } },
                  { "t": "lit", "lit": { "kind": "bool", "value": false } }
                ]
              },
              "then": { "t": "lit", "lit": { "kind": "int", "value": 1 } },
              "else": { "t": "lit", "lit": { "kind": "int", "value": 0 } }
            }]
          },
          "rhs": {
            "t": "do", "op": "io/print_int",
            "args": [{
              "t": "if",
              "cond": {
                "t": "app",
                "fn": { "t": "var", "name": "at_most" },
                "args": [
                  { "t": "lit", "lit": { "kind": "str", "value": "abc" } },
                  { "t": "lit", "lit": { "kind": "str", "value": "abd" } }
                ]
              },
              "then": { "t": "lit", "lit": { "kind": "int", "value": 1 } },
              "else": { "t": "lit", "lit": { "kind": "int", "value": 0 } }
            }]
          }
        }
      }
    }
  ]
}

NOTE: "t": "seq" is binary (lhs/rhs), not items-array. The right-nested chain above matches the existing precedent in examples/eq_primitives_smoke.ail.json:34-90 and examples/compare_primitives_smoke.ail.json. The io/print_int sequence does NOT print a separator — the three printed ints concatenate into one string. Helper to flatten an N-step chain: write the first as lhs, then the rest as a right-nested seq in rhs.

  • Step 4: Run test to verify it passes

Run: cargo test --workspace -p ail --test eq_ord_e2e eq_ord_polymorphic_runs_end_to_end

Expected: PASS, stdout "101".


Task 4: User-ADT E2E — eq_ord_user_adt

End-to-end test that defines a user ADT IntBox = MkIntBox Int, gives it user-written instance Eq IntBox and instance Ord IntBox, invokes a polymorphic helper at IntBox, and pins both the binary stdout and the mono-symbol eq__IntBox hash for stability.

Verifies that the unified mono pass synthesises eq__IntBox from the user instance body — not from the primitive == operator (which has no IntBox dispatch). Mirrors the hash-pin pattern from crates/ail/tests/mono_hash_stability.rs:34-41.

Files:

  • Create: examples/eq_ord_user_adt.ail.json

  • Modify: crates/ail/tests/eq_ord_e2e.rs (add two tests)

  • Step 1: Write the failing tests

Extend crates/ail/tests/eq_ord_e2e.rs with:

use ailang_core::workspace::load_workspace;
use ailang_check::{check_workspace, monomorphise_workspace};
use ailang_core::canonical::type_hash;
use ailang_core::ast::Def;

#[test]
fn eq_ord_user_adt_runs_end_to_end() {
    let stdout = build_and_run("eq_ord_user_adt.ail.json");
    // Helper invoked at IntBox(3) vs IntBox(3) → eq = true → 1
    // Helper invoked at IntBox(3) vs IntBox(5) → eq = false → 0
    assert_eq!(stdout, "10", "expected 10, got {stdout:?}");
}

#[test]
fn eq_ord_user_adt_eq_intbox_hash_stable() {
    let ws = load_workspace(&fixture_path("eq_ord_user_adt.ail.json")).expect("load");
    let diags = check_workspace(&ws);
    assert!(diags.is_empty(), "typecheck failed: {diags:#?}");
    let mono = monomorphise_workspace(&ws).expect("mono");

    let eq_intbox = mono.modules.iter()
        .flat_map(|m| m.defs.iter())
        .find_map(|d| match d {
            Def::Fn(f) if f.name == "eq__IntBox" => Some(f),
            _ => None,
        })
        .expect("eq__IntBox not synthesised");

    // Pin the canonical hash. If this changes, either the user-instance
    // body emission shifted (legitimate refactor — re-record) or the
    // unification accidentally rewired IntBox eq to the wrong source
    // (regression — investigate).
    let h = type_hash(&Def::Fn(eq_intbox.clone()));
    assert_eq!(
        h, "PIN_AFTER_FIRST_GREEN_RUN",
        "eq__IntBox hash drifted — see mono_hash_stability.rs for the resolution pattern"
    );
}

The hash string "PIN_AFTER_FIRST_GREEN_RUN" is a deliberate placeholder; Step 4 records the actual hash after first green run (see Step 4 instruction).

  • Step 2: Run tests to verify they fail

Run: cargo test --workspace -p ail --test eq_ord_e2e eq_ord_user_adt

Expected: BOTH fail. eq_ord_user_adt_runs_end_to_end fails with "ail build failed" (fixture missing). eq_ord_user_adt_eq_intbox_hash_stable fails with "load" panic (same reason).

  • Step 3: Write minimal implementation

Create examples/eq_ord_user_adt.ail.json:

{
  "schema": "ailang/v0",
  "name": "eq_ord_user_adt",
  "imports": [],
  "defs": [
    {
      "kind": "type",
      "name": "IntBox",
      "vars": [],
      "doc": "A one-field box around an Int, used to demonstrate that the unified mono pass synthesises eq__IntBox from a user-written instance, not from the primitive == operator.",
      "ctors": [
        { "name": "MkIntBox", "fields": [{ "k": "con", "name": "Int" }] }
      ]
    },
    {
      "kind": "instance",
      "class": "Eq",
      "type": { "k": "con", "name": "IntBox" },
      "doc": "Eq IntBox by structural unwrap of the inner Int.",
      "methods": [
        {
          "name": "eq",
          "body": {
            "t": "lam",
            "params": ["a", "b"],
            "paramTypes": [
              { "k": "con", "name": "IntBox" },
              { "k": "con", "name": "IntBox" }
            ],
            "retType": { "k": "con", "name": "Bool" },
            "body": {
              "t": "match",
              "scrutinee": { "t": "var", "name": "a" },
              "arms": [{
                "pat": { "p": "ctor", "ctor": "MkIntBox", "fields": [{ "p": "var", "name": "ai" }] },
                "body": {
                  "t": "match",
                  "scrutinee": { "t": "var", "name": "b" },
                  "arms": [{
                    "pat": { "p": "ctor", "ctor": "MkIntBox", "fields": [{ "p": "var", "name": "bi" }] },
                    "body": {
                      "t": "app",
                      "fn": { "t": "var", "name": "==" },
                      "args": [
                        { "t": "var", "name": "ai" },
                        { "t": "var", "name": "bi" }
                      ]
                    }
                  }]
                }
              }]
            }
          }
        }
      ]
    },
    {
      "kind": "instance",
      "class": "Ord",
      "type": { "k": "con", "name": "IntBox" },
      "doc": "Ord IntBox by delegating to Int's compare on the inner field.",
      "methods": [
        {
          "name": "compare",
          "body": {
            "t": "lam",
            "params": ["a", "b"],
            "paramTypes": [
              { "k": "con", "name": "IntBox" },
              { "k": "con", "name": "IntBox" }
            ],
            "retType": { "k": "con", "name": "Ordering" },
            "body": {
              "t": "match",
              "scrutinee": { "t": "var", "name": "a" },
              "arms": [{
                "pat": { "p": "ctor", "ctor": "MkIntBox", "fields": [{ "p": "var", "name": "ai" }] },
                "body": {
                  "t": "match",
                  "scrutinee": { "t": "var", "name": "b" },
                  "arms": [{
                    "pat": { "p": "ctor", "ctor": "MkIntBox", "fields": [{ "p": "var", "name": "bi" }] },
                    "body": {
                      "t": "app",
                      "fn": { "t": "var", "name": "compare" },
                      "args": [
                        { "t": "var", "name": "ai" },
                        { "t": "var", "name": "bi" }
                      ]
                    }
                  }]
                }
              }]
            }
          }
        }
      ]
    },
    {
      "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": "if",
            "cond": {
              "t": "app",
              "fn": { "t": "var", "name": "eq" },
              "args": [
                {
                  "t": "ctor", "type": "eq_ord_user_adt.IntBox", "ctor": "MkIntBox",
                  "args": [{ "t": "lit", "lit": { "kind": "int", "value": 3 } }]
                },
                {
                  "t": "ctor", "type": "eq_ord_user_adt.IntBox", "ctor": "MkIntBox",
                  "args": [{ "t": "lit", "lit": { "kind": "int", "value": 3 } }]
                }
              ]
            },
            "then": { "t": "lit", "lit": { "kind": "int", "value": 1 } },
            "else": { "t": "lit", "lit": { "kind": "int", "value": 0 } }
          }]
        },
        "rhs": {
          "t": "do", "op": "io/print_int",
          "args": [{
            "t": "if",
            "cond": {
              "t": "app",
              "fn": { "t": "var", "name": "eq" },
              "args": [
                {
                  "t": "ctor", "type": "eq_ord_user_adt.IntBox", "ctor": "MkIntBox",
                  "args": [{ "t": "lit", "lit": { "kind": "int", "value": 3 } }]
                },
                {
                  "t": "ctor", "type": "eq_ord_user_adt.IntBox", "ctor": "MkIntBox",
                  "args": [{ "t": "lit", "lit": { "kind": "int", "value": 5 } }]
                }
              ]
            },
            "then": { "t": "lit", "lit": { "kind": "int", "value": 1 } },
            "else": { "t": "lit", "lit": { "kind": "int", "value": 0 } }
          }]
        }
      }
    }
  ]
}

NOTE on Term::Ctor shape (verified against ast.rs:436-446): { "t": "ctor", "type": "<TypeName>", "ctor": "<CtorName>", "args": [...] }. The "type" value may be qualified ("<module>.<TypeName>") or unqualified. Qualified form is used above; if the auto-loaded prelude or the user-ADT module is in scope, the bare form "type": "IntBox" also works.

  • Step 4: Run tests to verify they pass + record hash

Run: cargo test --workspace -p ail --test eq_ord_e2e eq_ord_user_adt_runs_end_to_end

Expected: PASS, stdout "10".

Then run: cargo test --workspace -p ail --test eq_ord_e2e eq_ord_user_adt_eq_intbox_hash_stable

Expected: FAIL with assertion message showing the actual hash (e.g. assertion failed: ... actual: "ab12cd34...").

Extract the actual hash from the failure message, edit crates/ail/tests/eq_ord_e2e.rs to replace "PIN_AFTER_FIRST_GREEN_RUN" with the extracted hash, re-run:

Run: cargo test --workspace -p ail --test eq_ord_e2e eq_ord_user_adt_eq_intbox_hash_stable

Expected: PASS.

(This is the same record-then-pin pattern used in crates/ail/tests/mono_hash_stability.rs from iter 23.4.)


Task 5: Negative Float-NoInstance E2E + diagnostic addendum

Add a Float-aware addendum to the NoInstance diagnostic message emitted at crates/ailang-check/src/lib.rs:1607-1613. When the unresolved constraint is Eq Float or Ord Float, append a cross-reference to DESIGN.md §"Float semantics" so the LLM author immediately learns the partial-Float story. Pin the addended message verbatim via a new fixture + test.

Open commitment (spec §336-338): The exact wording is drafted by the implementer in this Step. Constraint: the addendum MUST contain the literal substring Float (so the message-contains-Float assertion works regardless of phrasing) and SHOULD cross-reference DESIGN.md §"Float semantics".

Files:

  • Create: examples/eq_float_noinstance.ail.json

  • Create: crates/ail/tests/eq_float_noinstance.rs

  • Modify: crates/ailang-check/src/lib.rs:1607-1613 (NoInstance construction site)

  • Step 1: Write the failing test

Create crates/ail/tests/eq_float_noinstance.rs:

use ailang_core::workspace::load_workspace;
use ailang_check::check_workspace;
use std::path::PathBuf;

fn fixture(name: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../../examples")
        .join(name)
}

#[test]
fn eq_at_float_fires_float_aware_noinstance() {
    let ws = load_workspace(&fixture("eq_float_noinstance.ail.json")).expect("load");
    let diags = check_workspace(&ws);

    assert!(!diags.is_empty(), "expected NoInstance diagnostic, got none");

    let no_inst = diags.iter().find(|d| d.code == "no-instance")
        .expect(&format!("expected diagnostic with code 'no-instance', got: {diags:#?}"));

    assert!(
        no_inst.message.contains("Float"),
        "expected Float-aware NoInstance diagnostic, got message: {:?}",
        no_inst.message
    );

    // Cross-ref to DESIGN.md §Float semantics — the LLM-author should
    // be pointed at the canonical explanation of partial Float orderability.
    assert!(
        no_inst.message.contains("Float semantics") || no_inst.message.contains("DESIGN"),
        "expected NoInstance message to cross-reference DESIGN.md §Float semantics, got: {:?}",
        no_inst.message
    );
}

Create examples/eq_float_noinstance.ail.json:

{
  "schema": "ailang/v0",
  "name": "eq_float_noinstance",
  "imports": [],
  "defs": [
    {
      "kind": "fn",
      "name": "main",
      "type": {
        "k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" },
        "effects": ["IO"]
      },
      "params": [],
      "body": {
        "t": "do", "op": "io/print_int",
        "args": [{
          "t": "if",
          "cond": {
            "t": "app",
            "fn": { "t": "var", "name": "eq" },
            "args": [
              { "t": "lit", "lit": { "kind": "float", "bits": "3ff0000000000000" } },
              { "t": "lit", "lit": { "kind": "float", "bits": "4000000000000000" } }
            ]
          },
          "then": { "t": "lit", "lit": { "kind": "int", "value": 1 } },
          "else": { "t": "lit", "lit": { "kind": "int", "value": 0 } }
        }]
      }
    }
  ]
}

(Float literal shape { "kind": "float", "bits": "<hex-IEEE754>" } verified against examples/floats.ail.json:26-27. 3ff0000000000000 = 1.0; 4000000000000000 = 2.0. Use raw hex IEEE 754 bits, not a decimal value — the schema requires bit-stable hex form.)

  • Step 2: Run test to verify it fails

Run: cargo test --workspace -p ail --test eq_float_noinstance eq_at_float_fires_float_aware_noinstance

Expected: FAIL with "expected Float-aware NoInstance diagnostic, got message: 'fn main calls eq requiring instance Eq Float, but no such instance exists in the workspace'" — the current bare message has no "Float semantics" reference.

  • Step 3: Write minimal implementation

Modify crates/ailang-check/src/lib.rs:1607-1613 (the NoInstance construction site for CheckError::NoInstance).

The current construction emits the bare thiserror-templated message from lib.rs:563-566:

"fn `{def}` calls `{method}` requiring `instance {class} {at_type}`, but no such instance exists in the workspace"

Refactor the construction site to append a Float-aware addendum when the unresolved type is Float AND the class is Eq or Ord. Pseudocode (the implementer adapts to the actual local variable names at lib.rs:1607-1613):

let mut diag = Diagnostic::from(CheckError::NoInstance {
    def: def_name.clone(),
    method: method_name.clone(),
    class: class_name.clone(),
    at_type: at_type_surface.clone(),
});

// Float-aware addendum (iter 23.5): when the unresolved instance
// is on Float, point the LLM-author at the canonical Float
// semantics so they immediately learn the partial-Float story.
let is_float = matches!(&r_ty, Type::Con { name, .. } if name == "Float");
let is_eq_or_ord = class_name == "Eq" || class_name == "Ord";
if is_float && is_eq_or_ord {
    diag.message = format!(
        "{} — Float has no Eq/Ord instance by design; see DESIGN.md §\"Float semantics\".",
        diag.message
    );
}

diagnostics.push(diag);

Two structural choices the implementer must make:

  1. Mutating diag.message after construction. If the Diagnostic struct is constructed via Display-on-CheckError (and message is therefore not directly assignable), the implementer either: (a) makes message a public mutable field, or (b) introduces a new CheckError::NoInstanceFloat { ... } variant with its own #[error(...)] template and selects between NoInstance / NoInstanceFloat at construction time. Option (a) is the simpler diff; option (b) is the more typed/structural solution. Either is acceptable — pick based on what the codebase already does elsewhere for "diagnostic with situational addendum".

  2. Exact wording. Constraint: must contain the substring "Float" AND must contain either "Float semantics" or "DESIGN" (for the cross-reference assertion). Beyond that, the implementer drafts.

  • Step 4: Run test to verify it passes

Run: cargo test --workspace -p ail --test eq_float_noinstance eq_at_float_fires_float_aware_noinstance

Expected: PASS.

Also run the full workspace check to confirm no existing test relied on the bare NoInstance message:

Run: cargo test --workspace

Expected: all pre-existing tests still PASS. If any existing test asserted on a NoInstance message verbatim (none found in recon, but possible), update its expected substring to match the new wording.


Task 6: DESIGN.md amendment — §"Decision 11 / Prelude classes"

Amend docs/DESIGN.md:1690-1742 to record that Eq/Ord shipped in milestone 23. The "Milestone 22 ships no built-in Prelude classes" wording (line 1692) stays as historical accuracy; the amendment is inserted as a new paragraph between line 1702 (end of "see typeclasses spec amendments...") and line 1704 (start of "Primitive output goes through io/print_int..."), mirroring the tone of the iter-23.4 DESIGN.md amendment at DESIGN.md:1513-1517.

Files:

  • Modify: docs/DESIGN.md:1702 (insertion point)

  • Step 1: Write the failing test

Pure-prose change; no Rust test is meaningful here. Substitute a grep-based assertion: git grep -l "Milestone 23 amends" docs/DESIGN.md must return docs/DESIGN.md after the edit.

Run: git grep -l 'Milestone 23 amends' docs/DESIGN.md

Expected (pre-edit): no output (exit 1).

  • Step 2: Verify the grep currently fails

Same command as Step 1. Confirm empty output. (This is the "RED" state.)

  • Step 3: Write minimal implementation

Edit docs/DESIGN.md. After line 1702 (the line ending with "... see docs/specs/0002-22-typeclasses.md 'Amendments' for the substantive rationale."), insert two blank lines and the following paragraph block:

Milestone 23 amends the above: the prelude now ships the `Ordering`
ADT, the `Eq` and `Ord` classes, primitive `Eq Int/Bool/Str` and
`Ord Int/Bool/Str` instances, and the five polymorphic free-fn helpers
`ne` / `lt` / `le` / `gt` / `ge`. `Show`, operator routing through
`Eq`/`Ord`, and `print`-rewire remain out of scope per their original
substantive reasons (heap-`Str` ABI, bench rebaseline). Float has
neither `Eq` nor `Ord` instance per §"Float semantics"; a polymorphic
helper invoked at Float fires `NoInstance` at typecheck with a
Float-aware diagnostic cross-referencing this section.

Leave line 1704 onwards (Primitive output goes through io/print_int...) unchanged — it remains accurate after the amendment.

  • Step 4: Verify the grep now passes

Run: git grep -l 'Milestone 23 amends' docs/DESIGN.md

Expected: docs/DESIGN.md (exit 0).

Visual-review the inserted paragraph for prose quality; fix any typo or awkward phrasing inline.


Task 7: Roadmap update — split Post-22 Prelude entry

Edit docs/roadmap.md:44-64. Per spec acceptance §8 and recon finding (split is preferred over single-entry-checked): split the existing single [ ] Post-22 Prelude — ship Show/Eq/Ord ... entry into two adjacent entries — one [x] for the shipped Eq/Ord half, one [ ] for the pending Show + print rewire half.

Files:

  • Modify: docs/roadmap.md:44-64

  • Step 1: Write the failing test

Grep-based assertion: post-edit, docs/roadmap.md must contain both [x] **\[milestone\]** Post-22 Prelude — Eq/Ord AND [ ] **\[milestone\]** Post-22 Prelude — Show + print rewire.

Run: git grep -c 'Post-22 Prelude' docs/roadmap.md

Expected (pre-edit): 1 (the single existing entry).

  • Step 2: Verify the count is currently 1

Same command. Confirm output 1.

  • Step 3: Write minimal implementation

Edit docs/roadmap.md. Replace the entire block from line 44 through line 64 (the existing single Post-22 Prelude entry with all its sub-bullets) with the following two-entry block:

- [x] **\[milestone\]** Post-22 Prelude — Eq/Ord — shipped milestone
  23 (2026-05-12). `Ordering` ADT, `Eq`/`Ord` classes, primitive
  instances on Int / Bool / Str, polymorphic free fns
  `ne`/`lt`/`le`/`gt`/`ge`, runtime `ail_str_eq` + `ail_str_compare`,
  end-to-end coverage including user-ADT-instance integration. Float
  has neither instance by design; polymorphic helpers at Float fire
  Float-aware `NoInstance` per DESIGN.md §"Float semantics".

- [ ] **\[milestone\]** Post-22 Prelude — Show + print rewire — ship
  `Show` typeclass with `Show Int/Bool/Str/Float` instances; rewire
  `print` through `Show.show`. Gated on heap-`Str` ABI runtime work
  (`int_to_str` needs to allocate). Originally queued as 22b.4 in
  the typeclass milestone, kept on hold pending demand and the
  heap-`Str` prerequisite.
  - context: brainstorm 2026-05-12 (iter 23.5 wrap). Heap-`Str` ABI
    is the load-bearing prerequisite; until it ships, `Show Int`
    cannot allocate a `Str` to return.
  • Step 4: Verify the count is now 2

Run: git grep -c 'Post-22 Prelude' docs/roadmap.md

Expected: 2 (the two split entries).

Also confirm one is checked and one is not:

Run: git grep '\[x\] \*\*\\\[milestone\\\]\*\* Post-22 Prelude — Eq/Ord' docs/roadmap.md

Expected: exactly one match.

Run: git grep '\[ \] \*\*\\\[milestone\\\]\*\* Post-22 Prelude — Show' docs/roadmap.md

Expected: exactly one match.


Task 8: Bench-regression check

Final gate before the iter is handed back to the Boss. The three bench scripts must exit 0 (no regression) or be audit-ratified per spec §248-249. Same gate iter 23.4 cleared (a1692a4) — same scripts.

Files:

  • (no edits — verification only)

  • Step 1: Establish the gate

The "test" for this task is the exit code of the three bench scripts. No additional RED-state setup is needed; we simply run them.

  • Step 2: Run baseline check (pre-mono-extension)

Run: python3 bench/check.py

Expected: exit 0.

Run: python3 bench/compile_check.py

Expected: exit 0.

Run: python3 bench/cross_lang.py

Expected: exit 0.

If any of the three exits non-zero with regression diagnostics, inspect the regression: is it caused by the prelude growing by five Def::Fns (expected — the mono pass now has more work per workspace that uses the prelude)? If so, the regression is audit-ratifiable per spec §248-249. Record the regression magnitude in the journal Concerns section; the Boss decides whether to rebaseline at iter close.

  • Step 3: Verify cross-language ratio still holds

bench/cross_lang.py compares AILang against the C reference. If the ratio shifted measurably, note it in journal Concerns; if the shift is within audit tolerance, no action needed.

  • Step 4: Mark gate cleared

In the per-iter journal (written by the orchestrator in Phase 4), record per-script exit codes in the Stats section.


Self-review checklist

(Run by the planner before handoff; results recorded inline.)

  1. Spec coverage:

    • Acceptance §3 (prelude free fns) → Tasks 1 + 2 ✓
    • Acceptance §4 (DESIGN.md Decision 11 amendment) → Task 6 ✓
    • Acceptance §5 (workspace tests + E2E green) → Tasks 1, 2, 3, 4 ✓
    • Acceptance §6 (bench regression ratified) → Task 8 ✓
    • Acceptance §7 (polymorphic helper at three primitives + Float-NoInstance) → Tasks 3 + 5 ✓
    • Acceptance §8 (roadmap update) → Task 7 ✓
  2. Placeholder scan:

    • No "TBD" / "TODO" / "implement later" / "fill in later".
    • The one "PIN_AFTER_FIRST_GREEN_RUN" placeholder in Task 4 is deliberate (record-then-pin pattern from mono_hash_stability.rs) and Step 4 of Task 4 instructs the implementer to replace it with the actual hash. This is not a plan failure — it's the standard hash-pin procedure.
  3. Type consistency:

    • mono_symbol_names helper defined in Task 1, reused implicitly by Task 2's tests (same file). ✓
    • build_and_run / fixture_path helpers defined in Task 3, reused by Task 4's tests (same file). ✓
    • All fixture filenames match between fixture-creation and test-load. ✓
  4. Step granularity:

    • Every step is 2-5 minutes. Task 4 Step 3 (the user-ADT fixture JSON) is the longest; it is one logical action (write the fixture JSON) of ~5 minutes typing.
  5. No commit steps: confirmed — no task contains git commit.

Handoff

Plan file: docs/plans/0035-iter-23.5.md.

Hand off to skills/implement with: this plan path. Boss commits the plan with subject plan: iter 23.5 prelude free fns + E2E — 8 tasks before dispatching the implement orchestrator.