Files
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

44 KiB

Iter 24.3 — fn print polymorphic free fn + E2E + DESIGN.md sync — Implementation Plan

Parent spec: docs/specs/0024-24-show-print.md

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

Goal: Ship fn print : forall a. Show a => (a borrow) -> () !IO in the prelude with body \x -> let s = show x in do io/print_str s, accompanied by three E2E fixtures (positive 4-prim smoke, user-ADT, negative NoInstance), a mono-symbol IR-shape pin asserting the let-binder discipline survives mono, a Show-aware NoInstance diagnostic, DESIGN.md amendments, and the roadmap update closing milestone 24.

Architecture: Iter 24.3 is the second and final half of milestone 24. It adds one new top-level def to examples/prelude.ail.json (fn print, the first prelude entry to use the intra-prelude bare class ref "Show" in a Constraint.class field — load-bearing for mq.1 canonical-form), three new E2E fixtures, three new test files (show_print_e2e.rs, show_no_instance_e2e.rs, print_mono_body_shape.rs), and one diagnostic-wording extension in ailang-check/src/lib.rs adjacent to the existing Float-aware Eq/Ord NoInstance arm. The mono pass synthesises print__Int|Bool|Str|Float per call-site; the IR-shape pin asserts the post-mono Def::Fn.body is structurally Term::Lam → Term::Let → Term::Do, protecting the explicit let-binder that the heap-Str RC discipline depends on (eob.1 Str carve-out). DESIGN.md §"Prelude (built-in) classes" amends the milestone-24 paragraph to list print as the polymorphic helper; §"Float semantics" gains a paragraph cross-referencing the Show-Float NaN-spelling path.

Tech Stack: ailang-check (mono synthesis, NoInstance diagnostic), ailang-core (workspace loader, AST), ailang-codegen (eob.1 Str carve-out, RC discipline — unchanged, just exercised), prelude JSON, three new Rust integration tests.


Pre-flight notes (Boss-ratified post-recon)

Spec Open Commitments resolved as Boss decisions:

  1. Implicit-let vs explicit-let for print's prelude source form — ship explicit-let. Recon scanned crates/ailang-core/src/desugar.rs and crates/ailang-check/src/uniqueness.rs: no automatic let-introduction pass for Term::App in effect-op-arg position. Explicit-let is the safe default that matches the spec's prescribed body shape (\x -> let s = show x in do io/print_str s) and makes the heap-Str RC discipline visible in the source form.

  2. Mono IR-shape pin: AST-level vs LLVM textAST-level via recursive Term-pattern match on post-mono Def::Fn.body. Precedent: crates/ail/tests/mono_hash_stability.rs inspects Def::Fn directly. The existing crates/ail/tests/ir_snapshot.rs opens emitted .ll text but uses ail emit-ir which does NOT run mono (per roadmap P3 line 227-236), so it's unsuitable for print__Int shape assertion.

  3. NoInstance Show diagnostic wording — extend the existing Float-aware Eq/Ord branch at crates/ailang-check/src/lib.rs:770-779 with a parallel class == "prelude.Show" arm. Cross-reference: literal section anchor "Prelude (built-in) classes" (the existing section title in DESIGN.md line 1874).

  4. Roadmap P2 insertion order — insert the new "Retire io/print_int|bool|float effect-ops + migrate example corpus" entry at the top of P2 (immediately after the P2 header, before the existing [feature] Operator routing through Eq/Ord entry). Rationale: corpus migration is near-term mechanical work directly downstream of milestone 24's close; operator routing is still a feature decision with no committed shape.

  5. show_no_instance.ail.json shape — use let f : Int -> Int = \x -> x in do print f. Concrete fn-type, no Show instance, fires NoInstance Show (Int -> Int). The spec's print id was schematic; id is not in the current prelude (verified — prelude.ail.json has not, eq, compare, ne, lt, le, gt, ge, plus the iter-24.2 Show defs, no id). The let-bound concrete-typed fn is the closest LLM-natural shape that triggers the failure.

Prelude insertion point. New fn print def appends at examples/prelude.ail.json:485 — immediately after the final fn ge def (whose closing brace is at line 485 per the post-24.2 file shape, with ge starting at line 484-485 and ending before the workspace-closing ]). The implementer reads the file tail to confirm the exact line number and trailing-comma placement before editing.

Iter 24.2 baseline. Closed at commit 3286117. 552 tests green. prelude.ail.json has 19 top-level defs (post-Show: was 14 pre-iter-24.2). class Show + 4 primitive instances live at lines 223-onwards. The post-mono prelude module synthesises show__Int|Bool|Str|Float.


Files this plan creates or modifies

Create:

  • examples/show_print_smoke.ail.json — positive E2E: main calls print 42, print true, print "hello", print 3.14.
  • examples/show_user_adt.ail.json — user-ADT E2E: data IntBox = MkIntBox Int + instance prelude.Show IntBox + main calls print (MkIntBox 7).
  • examples/show_no_instance.ail.json — negative E2E: main calls print f where f : Int -> Int.
  • crates/ail/tests/show_print_e2e.rs — positive + user-ADT E2E tests (build + run + stdout assertions).
  • crates/ail/tests/show_no_instance_e2e.rs — negative-diagnostic test (typecheck-only; asserts code == "no-instance", message mentions Show + cross-references DESIGN.md).
  • crates/ail/tests/print_mono_body_shape.rs — IR-shape pin: post-mono print__Int Def::Fn.body matches Term::Lam → Term::Let → Term::Do recursively.

Modify:

  • examples/prelude.ail.json:485 — append the new top-level fn print def immediately after the fn ge entry, before the workspace-closing ].
  • crates/ailang-check/src/lib.rs:770-779 — extend the Float-aware Eq/Ord NoInstance arm with a parallel class == "prelude.Show" branch yielding a Show-aware diagnostic that cross-references DESIGN.md §"Prelude (built-in) classes".
  • docs/DESIGN.md:1899-1910 — amend §"Prelude (built-in) classes" milestone-24 paragraph to flip print-rewire from "deferred to iter 24.3" to "ships in iter 24.3".
  • docs/DESIGN.md:2441-2447 — extend §"Float semantics" paragraph to add a Show-Float NaN-spelling cross-reference paragraph.
  • docs/roadmap.md:64-86 — flip the P1 "Post-22 Prelude — Show + print rewire" entry checkbox to [x].
  • docs/roadmap.md:88-90 — insert new P2 entry "Retire io/print_int|bool|float effect-ops + migrate example corpus to print" at the top of P2 (before existing entries).

Task 1: Add fn print to prelude.ail.json with explicit-let body

Files:

  • Modify: examples/prelude.ail.json:485 — append fn print def.

  • Step 1: Read the prelude tail to confirm insertion point.

Run: tail -10 examples/prelude.ail.json

Expected: a closing } (end of fn ge body), then } (end of fn ge def — note trailing comma if ge is not the last def, or no comma if it IS the last def), then ] (closing defs array), then } (closing workspace object).

If ge ends with , (trailing comma after closing }), ge is not currently the last def — verify by reading further. If ge ends with no trailing comma, ge is the current last def and the new print def is appended with a leading comma after ge's closing }.

  • Step 2: Append the fn print def.

Use Edit tool to add the following entry after the fn ge def. The exact insertion depends on Step 1's finding (with or without leading comma):

    {
      "kind": "fn",
      "name": "print",
      "doc": "Polymorphic console-print helper. `print x` ≡ `do io/print_str (show x)` with an explicit let-binder around `show x` for heap-Str RC discipline per eob.1 Str carve-out. Ships in milestone 24 as the second half of the Show prelude.",
      "type": {
        "k": "forall",
        "vars": ["a"],
        "constraints": [
          { "class": "Show",
            "type": { "k": "var", "name": "a" } }
        ],
        "body": {
          "k": "fn",
          "params": [{ "k": "var", "name": "a" }],
          "param_modes": ["borrow"],
          "ret": { "k": "con", "name": "Unit" },
          "effects": ["IO"]
        }
      },
      "params": ["x"],
      "body": {
        "t": "lam",
        "params": ["x"],
        "paramTypes": [{ "k": "var", "name": "a" }],
        "retType": { "k": "con", "name": "Unit" },
        "body": {
          "t": "let",
          "name": "s",
          "value": {
            "t": "app",
            "fn": { "t": "var", "name": "show" },
            "args": [{ "t": "var", "name": "x" }]
          },
          "body": {
            "t": "do",
            "op": "io/print_str",
            "args": [{ "t": "var", "name": "s" }]
          }
        }
      }
    }

The "class": "Show" is bare per mq.1 same-module canonical form (the constraint lives inside the prelude module).

If the surrounding existing defs use a different lambda-body schema for free fns (e.g. nested t: "lam" vs flat params at the def level), inspect a milestone-23 reference like fn ne (lines 325-356 of the post-24.2 prelude) to match. The implementer may need to adjust the paramTypes/retType fields' presence based on what the existing fns carry.

  • Step 3: Verify JSON parses + workspace round-trips.

Run: python3 -c "import json; d = json.load(open('examples/prelude.ail.json')); print(f'{len(d[\"defs\"])} defs')"

Expected: 20 defs (was 19 post-24.2, +1 for print).

If JSON parse fails, the inserted block has a syntax issue; diagnose with python3 -c "import json; json.load(open('examples/prelude.ail.json'))" and read the resulting error message.

  • Step 4: Run the full workspace test suite to confirm print resolves.

Run: cargo test --workspace --no-fail-fast 2>&1 | grep -E '^test result' | awk '{s+=$4; f+=$6} END {print "passed:", s, "failed:", f}'

Expected: passed: 552 failed: 0 (no new tests yet; existing 552 stay green because print is a new symbol — adding it cannot break any existing test).

If any tests fail, the new def's schema is malformed in a way that the JSON parser accepts but the typechecker rejects (e.g. wrong paramTypes shape). Diagnose: cargo test --workspace --no-fail-fast 2>&1 | grep -A3 FAILED | head -20.


Task 2: Mono symbol IR-shape pin for print__Int

Files:

  • Create: crates/ail/tests/print_mono_body_shape.rs — AST-level pattern-match pin.

This task runs before the E2E tests because the IR-shape assertion is the load-bearing semantic gate: if print__Int's post-mono body is not Term::Lam → Term::Let → Term::Do, the heap-Str RC discipline is broken and the E2E tests may produce confusing failures.

  • Step 1: Read existing patterns for post-mono Def::Fn.body inspection.

Run: head -60 crates/ail/tests/mono_hash_stability.rs

Confirm: load workspace → check → mono → find Def::Fn by name → inspect f.body (which is a Term).

Run: grep -n 'Term::Lam\|Term::Let\|Term::Do\|enum Term' crates/ailang-core/src/ast.rs | head -10

Confirm the variant names match what the body shape uses. If Term::Lam is spelled differently (e.g. Term::Lambda), adjust the pattern match below.

  • Step 2: Write the test stub at crates/ail/tests/print_mono_body_shape.rs.
//! IR-shape pin for milestone 24.3's `print` polymorphic free fn.
//!
//! Asserts that the post-mono `print__Int` `Def::Fn.body` is structurally
//! `Term::Lam → Term::Let → Term::Do`. The explicit let-binder around
//! `show__Int x` is load-bearing for the heap-Str RC discipline (eob.1
//! Str carve-out at `drop_symbol_for_binder` requires a let-binder to
//! attach the rc-dec to). If a future codegen / mono refactor inlines
//! the let-binder away, this pin fires and surfaces the regression
//! BEFORE the E2E runtime stats produce a confusing "memory leak"
//! diagnostic.

use ailang_core::ast::{Def, Term};
use ailang_core::workspace::load_workspace;
use std::path::PathBuf;

fn fixture_path() -> PathBuf {
    let mut d = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
    d.pop();
    d.pop();
    d.join("examples").join("show_print_smoke.ail.json")
}

#[test]
fn print_int_body_preserves_explicit_let_binder() {
    let ws = load_workspace(&fixture_path()).expect("workspace loads");
    let diags = ailang_check::check_workspace(&ws);
    assert!(diags.is_empty(), "typecheck diagnostics: {diags:?}");
    let post_mono = ailang_check::monomorphise_workspace(&ws).expect("mono green");

    let prelude_mod = post_mono
        .modules
        .get("prelude")
        .expect("prelude module present");

    let print_int = prelude_mod
        .defs
        .iter()
        .find_map(|d| match d {
            Def::Fn(f) if f.name == "print__Int" => Some(f),
            _ => None,
        })
        .expect("print__Int mono symbol not found in prelude module");

    // Body should be: Term::Lam { body: Term::Let { name: "s", value: Term::App(show__Int, [x]), body: Term::Do { op: io/print_str, args: [s] } } }
    let lam_body = match &print_int.body {
        Term::Lam { body, .. } => body.as_ref(),
        other => panic!("print__Int.body is not Term::Lam, got: {other:?}"),
    };

    let let_value_and_body = match lam_body {
        Term::Let { name, value, body } => {
            assert_eq!(name, "s", "let-binder name expected 's', got {name:?}");
            (value.as_ref(), body.as_ref())
        }
        other => panic!(
            "print__Int.body.body is not Term::Let — let-binder was optimised away or never inserted. Got: {other:?}"
        ),
    };

    // Inner App should reference show__Int (post-mono symbol).
    let (app_fn, _app_args) = match let_value_and_body.0 {
        Term::App { fn: f, args } => (f.as_ref(), args),
        other => panic!("print__Int let-value is not Term::App, got: {other:?}"),
    };
    let app_fn_name = match app_fn {
        Term::Var { name } => name,
        other => panic!("let-value's fn position is not Term::Var, got: {other:?}"),
    };
    assert_eq!(
        app_fn_name, "show__Int",
        "post-mono let-value should call show__Int, got: {app_fn_name}"
    );

    // Inner body should be a Do invoking io/print_str.
    match let_value_and_body.1 {
        Term::Do { op, args } => {
            assert_eq!(op, "io/print_str", "let-body Do op expected 'io/print_str', got {op:?}");
            assert_eq!(args.len(), 1, "io/print_str expects 1 arg, got {}", args.len());
        }
        other => panic!("print__Int let-body is not Term::Do, got: {other:?}"),
    }
}

If the actual Term AST variants are named differently (e.g. Term::Application instead of Term::App, or Term::Effect instead of Term::Do), adjust the pattern match throughout. The load-bearing assertions are: (a) outer is Lam; (b) inner is Let with name "s"; (c) Let value calls show__Int; (d) Let body is the io/print_str Do.

If the body uses a tagged-union shape like t: "lam" rather than the Rust enum variant directly, the test uses a different inspection path (e.g. JSON-like field accessors); the implementer adapts based on the actual ast.rs structure read at Step 1.

  • Step 3: Run the test (depends on Task 4's fixture being in place).

Note: this task's test depends on examples/show_print_smoke.ail.json from Task 4. Either:

  • Sequence: run Task 4 Step 1 (create the fixture file) before this Step 3, then come back; OR
  • Create a minimal local fixture inline for this task (single print 42 call) and use that instead. Use the local-fixture approach if Task 4 is slower to prep.

Either way:

Run: cargo test --workspace --no-fail-fast -p ail --test print_mono_body_shape 2>&1 | tail -10

Expected: test result: ok. 1 passed.

If the test fails with "print__Int mono symbol not found in prelude module", the fixture's print 42 call site is not driving mono synthesis — verify the fixture has at least one concrete print : Int -> ... call.

If the test fails with "print__Int.body.body is not Term::Let", the implicit-let assumption was wrong — the desugarer DID auto-introduce a let-binder elsewhere, OR (more likely) the explicit-let in the prelude source got optimised away by a post-mono pass. Surface to Boss; this is a substantive semantic regression and merits investigation before continuing.


Task 3: Positive E2E fixture + test (4 primitives smoke)

Files:

  • Create: examples/show_print_smoke.ail.json — positive E2E workspace.

  • Step 1: Write the fixture examples/show_print_smoke.ail.json.

{
  "schema": "ailang/v0",
  "name": "show_print_smoke",
  "imports": [],
  "defs": [
    {
      "kind": "fn",
      "name": "main",
      "type": {
        "k": "fn",
        "params": [],
        "ret": { "k": "con", "name": "Unit" },
        "effects": ["IO"]
      },
      "params": [],
      "body": {
        "t": "do_seq",
        "stmts": [
          { "t": "do",
            "op": "io/print_int_unused",
            "_doc": "this stmt replaced below — sequencer-shape varies by AST"
          }
        ]
      }
    }
  ]
}

The above is a stub. The implementer needs to inspect existing milestone-23 E2E fixtures (e.g. examples/eq_ord_polymorphic_smoke.ail.json or similar from crates/ail/tests/eq_ord_e2e.rs::fixture_path references) to find the canonical multi-statement main body shape. Likely candidates:

  • A do_seq / do_block with multiple statements
  • A chain of let _ = ... in let _ = ... in ... flushing each value to stdout
  • A single composite expression

Run: cat examples/eq_ord_polymorphic_smoke.ail.json 2>/dev/null | head -50 to identify the precedent.

If no precedent exists for multi-do main bodies, the fallback is a single-call fixture (do print 42 only), then ship four separate fixtures show_print_int.ail.json, ..._bool.ail.json, ..._str.ail.json, ..._float.ail.json and four #[test] fns asserting each separately.

The load-bearing requirement is that the fixture exercises all four print__T synthesis paths (Int, Bool, Str, Float) somewhere in the workspace. Whether they share a main or live in separate fixtures is structural detail.

  • Step 2: Verify the fixture parses + round-trips bit-stable.

Run: cargo test --workspace --no-fail-fast 2>&1 | grep -E 'show_print_smoke.*ok|FAILED' | head -5

Expected: at least one green line referencing the fixture (typically a generic fixtures_roundtrip test that auto-covers every .ail.json in examples/).

If the fixture errors out at parse: re-inspect the multi-statement do shape against the precedent; adjust.

  • Step 3: Verify the workspace compiles + runs end-to-end.

Run: cargo run --bin ail -- build examples/show_print_smoke.ail.json -o /tmp/show_print_smoke.bin 2>&1 | tail -10

Expected: clean build with no errors.

Then: /tmp/show_print_smoke.bin 2>&1 | head -10

Expected: four lines on stdout — one for each print call. Exact ordering depends on the fixture's body shape (Step 1).

If print 3.14 produces a Float rendering that differs from "3.14", note the actual libc-%g output for use in Step 4's stdout assertion.


Task 4: Positive E2E + user-ADT integration tests

Files:

  • Create: examples/show_user_adt.ail.json — user-ADT E2E workspace.

  • Create: crates/ail/tests/show_print_e2e.rs — positive + user-ADT tests.

  • Step 1: Write examples/show_user_adt.ail.json.

{
  "schema": "ailang/v0",
  "name": "show_user_adt",
  "imports": [],
  "defs": [
    {
      "kind": "type",
      "name": "IntBox",
      "vars": [],
      "ctors": [
        { "name": "MkIntBox",
          "fields": [{ "k": "con", "name": "Int" }] }
      ]
    },
    {
      "kind": "instance",
      "class": "prelude.Show",
      "type": { "k": "con", "name": "IntBox" },
      "methods": [
        {
          "name": "show",
          "body": {
            "t": "lam",
            "params": ["x"],
            "paramTypes": [{ "k": "con", "name": "show_user_adt.IntBox" }],
            "retType": { "k": "con", "name": "Str" },
            "body": {
              "t": "match",
              "scrut": { "t": "var", "name": "x" },
              "arms": [
                {
                  "pat": { "p": "ctor",
                           "ctor": "MkIntBox",
                           "fields": [{ "p": "var", "name": "n" }] },
                  "body": {
                    "t": "app",
                    "fn": { "t": "var", "name": "int_to_str" },
                    "args": [{ "t": "var", "name": "n" }]
                  }
                }
              ]
            }
          }
        }
      ]
    },
    {
      "kind": "fn",
      "name": "main",
      "type": {
        "k": "fn",
        "params": [],
        "ret": { "k": "con", "name": "Unit" },
        "effects": ["IO"]
      },
      "params": [],
      "body": {
        "t": "do",
        "op": "io/print_str",
        "args": [
          { "t": "app",
            "fn": { "t": "var", "name": "show" },
            "args": [
              { "t": "app",
                "fn": { "t": "var", "name": "MkIntBox" },
                "args": [{ "t": "lit", "lit": { "kind": "int", "value": 7 } }] }
            ] }
        ]
      }
    }
  ]
}

Note 1: the instance prelude.Show IntBox uses the cross-module qualifier "prelude.Show" per mq.1 canonical-form (the class lives in prelude, the instance lives in the user module).

Note 2: the paramTypes for the instance lambda may need to use the qualified type-name "show_user_adt.IntBox" per the ct.1 canonical-type-names rule, or it may accept bare "IntBox" for same-module. Verify the existing milestone-23 ADT instance pattern (e.g. eq_ord_user_adt's instance Eq IntBox if one exists) to mirror exactly.

Note 3: the body invokes show (MkIntBox 7) directly rather than going through print first, because the spec's example user-ADT path goes print (MkIntBox 7)show__IntBoxint_to_str. The shorter show call is sufficient to exercise the user-instance mono synthesis. If the spec testing requirement names print specifically, change showprint and wrap appropriately.

Actually, the spec at line 391-397 says explicitly fn main = do print (MkIntBox 7). Revise the main body above to use print:

{
  "kind": "fn",
  "name": "main",
  "type": { "k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" }, "effects": ["IO"] },
  "params": [],
  "body": {
    "t": "do",
    "op": "_print_via_fn",
    "_args_inline": "print is a free fn not an effect-op; this shape needs the App-then-Do wrapper",
    "args": []
  }
}

— but print is a free fn, not an effect-op. The right shape is:

"body": {
  "t": "app",
  "fn": { "t": "var", "name": "print" },
  "args": [
    { "t": "app",
      "fn": { "t": "var", "name": "MkIntBox" },
      "args": [{ "t": "lit", "lit": { "kind": "int", "value": 7 } }] }
  ]
}

— a plain App(print, MkIntBox(7)) whose type is () !IO. The implementer verifies by inspecting how milestone-23's fixtures call ne/lt/etc. as the top-level expression of main.

  • Step 2: Verify the user-ADT fixture parses + round-trips + typechecks.

Run: cargo run --bin ail -- check examples/show_user_adt.ail.json 2>&1 | head -10

Expected: clean check (no no-instance or parse errors).

If no-instance fires: the instance prelude.Show IntBox def's class-ref qualifier is malformed; verify the spec's mq.1 canonical-form rule (cross-module bare-Class-with-prelude-prefix).

  • Step 3: Write crates/ail/tests/show_print_e2e.rs.
//! End-to-end tests for the `print` polymorphic helper shipped in
//! milestone 24.3.
//!
//! Each test compiles a `.ail.json` workspace via the `ail build`
//! subcommand, runs the resulting native binary, and asserts on stdout.

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 for {fixture}:\nstdout: {}\nstderr: {}",
        String::from_utf8_lossy(&build.stdout),
        String::from_utf8_lossy(&build.stderr),
    );

    let run = Command::new(&out).output().expect("run binary");
    String::from_utf8(run.stdout).expect("stdout is utf-8")
}

#[test]
fn print_primitives_smoke_runs_end_to_end() {
    let stdout = build_and_run("show_print_smoke.ail.json");
    // Exact expected stdout shape depends on the fixture's body
    // (single-stmt vs do_seq). Implementer fills in concrete assertion:
    assert!(stdout.contains("42"), "expected '42' in stdout, got: {stdout:?}");
    assert!(stdout.contains("true"), "expected 'true' in stdout, got: {stdout:?}");
    assert!(stdout.contains("hello"), "expected 'hello' in stdout, got: {stdout:?}");
    assert!(stdout.contains("3.14"), "expected '3.14' in stdout, got: {stdout:?}");
}

#[test]
fn print_user_adt_runs_end_to_end() {
    let stdout = build_and_run("show_user_adt.ail.json");
    assert!(
        stdout.trim() == "7",
        "expected stdout '7' (trimmed), got: {stdout:?}"
    );
}
  • Step 4: Run the new E2E tests.

Run: cargo test --workspace --no-fail-fast -p ail --test show_print_e2e 2>&1 | tail -15

Expected: 2 passed.

If print_primitives_smoke_runs_end_to_end fails on a substring assertion: the fixture's body shape produced different stdout than expected. Read the actual stdout from the failure message and either adjust the assertion or the fixture body.

If print_user_adt_runs_end_to_end fails with ail build failed: the cross-module class ref "prelude.Show" was not resolved correctly. Inspect the build's stderr; likely a canonical-form parse / mq.1 dispatch issue surfaced by the IntBox-Show instance.


Task 5: Negative E2E fixture + diagnostic-wording extension

Files:

  • Create: examples/show_no_instance.ail.json — negative E2E (typecheck-failure case).

  • Create: crates/ail/tests/show_no_instance_e2e.rs — diagnostic-assertion test.

  • Modify: crates/ailang-check/src/lib.rs:770-779 — extend Float-aware NoInstance arm with parallel Show-aware branch.

  • Step 1: Read the existing NoInstance diagnostic arm.

Run: sed -n '765,795p' crates/ailang-check/src/lib.rs

Identify: the existing arm matches on class == "Eq" or class == "Ord" plus type_ matches Float, and emits a Float-aware message cross-referencing DESIGN.md §"Float semantics".

If the arm has different structure or lives at different lines, grep: grep -n 'no-instance\|NoInstance.*Float' crates/ailang-check/src/lib.rs | head -10.

  • Step 2: Extend the arm with a Show-aware branch.

The diagnostic should fire when:

  • A Show <T> constraint cannot be discharged
  • The unsatisfied type T is not in the prelude's Show-instance set (Int/Bool/Str/Float) AND not a user-declared Show instance

The message format (Boss-decision wording):

no instance `Show <T>` — `print` and `show` require a Show instance
for the argument type. Built-in Show ships for Int, Bool, Str, Float
in the prelude; see DESIGN.md §"Prelude (built-in) classes". User
types declare their own `instance prelude.Show <T>` in the type's
defining module per Decision 11 coherence.

— compressed to a single line for the actual diagnostic.text field, with the cross-reference embedded.

Code sketch (adapt to the actual lib.rs:770-779 shape):

// Existing pattern:
} else if class == "prelude.Show" || class == "Show" {
    Diagnostic::error(
        "no-instance",
        format!(
            "no instance `Show {type_str}` — `print` / `show` need a Show \
             instance for `{type_str}`. Built-in Show ships for Int, Bool, \
             Str, Float; see DESIGN.md §\"Prelude (built-in) classes\". \
             User types declare their own `instance prelude.Show {type_str}` \
             in the type's defining module."
        ),
    )
    // ... existing diagnostic ctx fields (span, candidate_classes, ...)
}

The implementer adapts to the actual existing arm's style (whether it uses format!, a builder pattern, or another structure).

The key load-bearing observable: the diagnostic's .code must be "no-instance" (matches the existing convention); .message must contain the substring "Show" AND the substring "Prelude (built-in) classes" (so the test in Step 4 can assert both).

  • Step 3: Write examples/show_no_instance.ail.json.
{
  "schema": "ailang/v0",
  "name": "show_no_instance",
  "imports": [],
  "defs": [
    {
      "kind": "fn",
      "name": "main",
      "type": {
        "k": "fn",
        "params": [],
        "ret": { "k": "con", "name": "Unit" },
        "effects": ["IO"]
      },
      "params": [],
      "body": {
        "t": "let",
        "name": "f",
        "valueType": {
          "k": "fn",
          "params": [{ "k": "con", "name": "Int" }],
          "ret": { "k": "con", "name": "Int" },
          "effects": []
        },
        "value": {
          "t": "lam",
          "params": ["x"],
          "paramTypes": [{ "k": "con", "name": "Int" }],
          "retType": { "k": "con", "name": "Int" },
          "body": { "t": "var", "name": "x" }
        },
        "body": {
          "t": "app",
          "fn": { "t": "var", "name": "print" },
          "args": [{ "t": "var", "name": "f" }]
        }
      }
    }
  ]
}

If the let-binder schema's valueType annotation has a different field name in the current AST (e.g. typeAnnotation, tyHint), the implementer adapts. The load-bearing requirement: at the print f call site, f's type is concretely Int -> Int (a function type with no Show instance).

If concrete-fn-type annotation is non-trivial in the let-binder schema, fallback shape: define a top-level fn dummy : (Int) -> Int = \x -> x and call print dummy from main. This sidesteps the let-binder type-annotation question.

  • Step 4: Write crates/ail/tests/show_no_instance_e2e.rs.
//! Pins the Show-aware `no-instance` diagnostic shipped in milestone 24.3.
//!
//! Property protected: calling `print` on a function type fires the
//! `no-instance` diagnostic with a Show-aware message that
//! cross-references DESIGN.md §"Prelude (built-in) classes" so the
//! LLM author immediately learns which types ship with Show and
//! how to declare their own instance.

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

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

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

    assert!(
        !diags.is_empty(),
        "expected NoInstance Show diagnostic, got no diagnostics"
    );

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

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

    // Must cross-reference DESIGN.md §Prelude (built-in) classes — the
    // canonical section listing which types ship with Show.
    assert!(
        no_inst.message.contains("Prelude (built-in) classes")
            || no_inst.message.contains("Prelude"),
        "expected DESIGN.md §Prelude cross-reference, got message: {:?}",
        no_inst.message
    );

    // The class name in the diagnostic should be "Show" (bare or with
    // prelude qualifier — either is acceptable from the LLM-author's POV).
    let class_mention_ok = no_inst.message.contains("Show ")
        || no_inst.message.contains("prelude.Show");
    assert!(
        class_mention_ok,
        "expected class name 'Show' or 'prelude.Show' in diagnostic, got: {:?}",
        no_inst.message
    );
}
  • Step 5: Run the negative-diagnostic test.

Run: cargo test --workspace --no-fail-fast -p ail --test show_no_instance_e2e 2>&1 | tail -15

Expected: 1 passed.

If the test fails with "expected NoInstance Show diagnostic, got no diagnostics": the fixture's print f call didn't reach the constraint-discharge phase; verify the let-binder type annotation is producing a concrete function type at the call site (not a polymorphic forall a. a -> a).

If the test fails on the message-substring assertion: the diagnostic wording in Task 5 Step 2 is missing one of the load-bearing substrings (Show, Prelude, etc.). Adjust the wording in lib.rs:770-779 accordingly.


Task 6: DESIGN.md §"Prelude (built-in) classes" + §"Float semantics" amendments

Files:

  • Modify: docs/DESIGN.md:1899-1910 — flip print-rewire from deferred to shipped.

  • Modify: docs/DESIGN.md:2441-2447 — extend §"Float semantics" with Show-Float NaN cross-reference paragraph.

  • Step 1: Read the current milestone-24 paragraph in §"Prelude (built-in) classes".

Run: sed -n '1898,1915p' docs/DESIGN.md

Identify: the iter-24.2 amendment from iter-24.2's Task 7 mentions "The polymorphic helper print : forall a. Show a => a -> () !IO ships in the same milestone as the second iteration (24.3) and routes through show and io/print_str." — this needs flipping from future-tense to past-tense.

  • Step 2: Amend the milestone-24 paragraph.

Edit the sentence ending in "...ships in the same milestone as the second iteration (24.3) and routes through showandio/print_str." to past tense:

... `class Show a where show : (a borrow) -> Str` and primitive
`Show Int`, `Show Bool`, `Show Str`, `Show Float` instances shipped
in iter 24.2; the polymorphic helper `print : forall a. Show a => a ->
() !IO` shipped in iter 24.3 with body `\x -> let s = show x in do
io/print_str s` (explicit let-binder for heap-Str RC discipline per
eob.1 Str carve-out). The let-binder is structurally pinned by
`crates/ail/tests/print_mono_body_shape.rs`. Routing through `print`
replaces the ad-hoc `io/print_int|bool|float` idiom for new code;
retiring the per-type effect-ops is queued as a P2 follow-up.

If the existing paragraph's wording was different (verify by reading at Step 1), adapt the tense flip and content preserving the existing structure.

  • Step 3: Read the current §"Float semantics" paragraph.

Run: sed -n '2436,2452p' docs/DESIGN.md

Identify: the existing paragraph mentions io/print_float's libc-%g rendering and the NaN-spelling caveat.

  • Step 4: Append the Show-Float cross-reference paragraph.

Insert at the end of §"Float semantics" (immediately before the next subsection header):

The same libc-`%g` rendering applies to `show 1.5` / `show nan` /
`show inf` via `instance Show Float` (which calls `float_to_str`
internally — see §"Prelude (built-in) classes" for the Show ship).
The NaN-spelling caveat (`printf("%g", nan)` may emit `nan` /
`-nan` / `NaN`) is observable via both `do io/print_float x` and
`do print x` at the same x; the rendering is libc-version-dependent
and target-libc-specific. AILang does NOT canonicalise Float
textual representation; the LLM-author who needs deterministic
Float rendering for cross-platform test fixtures should bypass
`show` / `print` and emit a custom formatter.
  • Step 5: Verify DESIGN.md still parses as markdown.

Run: wc -l docs/DESIGN.md

Expected: line count grew by ~15-20 lines compared to post-24.2 baseline (was at 2700ish).

Run: head -2455 docs/DESIGN.md | tail -25

Expected: §"Float semantics" section ends with the new Show-Float paragraph; the next section header (whatever follows §"Float semantics") is intact.


Task 7: Roadmap update — P1 close + new P2 entry

Files:

  • Modify: docs/roadmap.md:64-86 — flip P1 checkbox.

  • Modify: docs/roadmap.md:88-90 — insert new P2 entry at top.

  • Step 1: Read the current P1 "Post-22 Prelude — Show + print rewire" entry.

Run: grep -n 'Post-22 Prelude\|Show + print' docs/roadmap.md | head -5

Identify the exact line of the entry's checkbox - [ ] or - [~] or - [x] (depending on prior state).

  • Step 2: Flip the P1 checkbox to [x].

Use Edit tool to replace the existing checkbox state with [x]. The exact old_string depends on the current state read at Step 1.

If the entry is structured as a multi-line block with sub-items, only the top-level checkbox flips; the sub-items stay structured for archival.

  • Step 3: Read the P2 section header to find insertion point.

Run: grep -n '^## P2\|^## P3' docs/roadmap.md

Identify: the line number of the ## P2 header. The new entry goes immediately after the header, before any existing P2 entries.

  • Step 4: Insert the new P2 entry.

Insert immediately after the ## P2 header line:

- [ ] **[milestone]** Retire `io/print_int` / `io/print_bool` /
  `io/print_float` effect-ops + migrate example corpus to `print`.
  Bulk text substitution `do io/print_<T> x``do print x` across
  `examples/*.ail.json` (~86 fixtures affected). Per-type effect-ops
  are deleted from `crates/ailang-check/src/builtins.rs`,
  `crates/ailang-codegen/src/lib.rs::lower_app` arms, and the
  corresponding runtime C glue.
  - context: post-milestone-24 mechanical follow-up; the architecture-
    shipping milestone (24) and corpus-migration milestone (this) are
    separate so migration runs against a frozen architecture. Same
    call milestone 23 made for `==` / `eq`.
  • Step 5: Verify roadmap structure intact.

Run: grep -c '^- \[ \]\|^- \[x\]\|^- \[~\]' docs/roadmap.md

Expected: pre-iter count + 1 (one new P2 entry added). If P1 was at [~] and is now [x], the total count is unchanged because checkbox state changes don't affect entry count.


Task 8: Integration verification + bench

Files: none modified; verification-only.

  • Step 1: Full workspace test.

Run: cargo test --workspace --no-fail-fast 2>&1 | grep -E '^test result' | awk '{s+=$4; f+=$6} END {print "passed:", s, "failed:", f}'

Expected: at least passed: 556 failed: 0 — 552 from iter 24.2 + 4 new (1 print_mono_body_shape + 2 show_print_e2e + 1 show_no_instance_e2e).

If failed is non-zero, diagnose via cargo test --workspace --no-fail-fast 2>&1 | grep -B2 FAILED | head -30. Common failure modes:

  • print__Int mono symbol mismatch — Task 2's IR-shape pin caught a regression in mono.

  • show_print_e2e build failure — the fn print in prelude has a schema typo not caught by JSON parse.

  • show_no_instance_e2e message-substring failure — Task 5's diagnostic wording is missing the load-bearing cross-reference.

  • Step 2: Verify existing mq3 + 22b + Eq/Ord tests stay green.

Run: cargo test --workspace --no-fail-fast 2>&1 | grep -E '(mq3|22b|eq_ord).*FAILED' | head -10

Expected: empty (no FAILED entries for these test groups).

If any fail: the iter touched a load-bearing invariant of the prior milestones; surface to Boss.

  • Step 3: Confirm prelude_free_fns.rs (milestone 23) still passes.

Run: cargo test --workspace --no-fail-fast -p ail --test prelude_free_fns 2>&1 | tail -5

Expected: test result: ok. <N> passed; 0 failed (5 tests per LBA-8).

  • Step 4: Confirm mono_hash_stability Eq/Ord + Show pins all green.

Run: cargo test --workspace --no-fail-fast -p ail --test mono_hash_stability 2>&1 | tail -10

Expected: 2 passed (primitive_eq_ord_mono_symbol_hashes_stay_bit_identical + primitive_show_mono_symbol_hashes_stay_bit_identical).

If Eq/Ord hashes drift unexpectedly: the new fn print def's insertion perturbed mono ordering or canonicaliser output. Surface to Boss for hash-rebaseline decision.

  • Step 5: Bench scripts.

Run: bench/compile_check.py 2>&1 | tail -10

Expected: exit 0; 24/24 stable.

Run: bench/cross_lang.py 2>&1 | tail -10

Expected: exit 0; 25/25 stable.

Run: bench/check.py 2>&1 | tail -15

Expected: exit 0 or audit-ratifiable noise per the conservative-call convention. The latency.implicit_at_rc.* max-tail metric noise envelope (7 consecutive observations per iter-24.2 journal) likely produces 1-3 noise-class metrics; baseline pristine unless the envelope shifts character substantially.

If any bench script exits non-zero on a metric NOT in the documented noise envelope, surface to Boss for audit-ratification.

  • Step 6: Write the per-iter journal.

Path: docs/journals/2026-05-13-iter-24.3.md

Structure (matching iter-24.2 journal pattern):

  • # iter 24.3 — fn print polymorphic free fn + E2E + DESIGN.md sync
  • **Date:** 2026-05-13
  • **Started from:** <commit after iter 24.2 INDEX line>
  • **Status:** DONE
  • **Tasks completed:** 8 of 8
  • Summary paragraph
  • Per-task notes
  • Concerns section (the implicit-vs-explicit-let Boss decision rationale, IR-shape pin pattern choice, NoInstance wording draft + Boss review, P2 roadmap insertion ordering)
  • Known debt (milestone 24 closes structurally; only audit-mq-style milestone close remains before next milestone starts)
  • Files touched (list)
  • Stats: bench/orchestrator-stats/2026-05-13-iter-24.3.json

Append the index line to docs/journals/INDEX.md: - 2026-05-13 — iter 24.3: fn print + E2E (4-prim + user-ADT + negative) + DESIGN.md/roadmap close → 2026-05-13-iter-24.3.md


Self-review checklist (planner Step 5)

Spec coverage:

  • Architecture §2 (fn print in prelude) → Task 1 ✓
  • Architecture §3 (dispatch routing) → exercised by Task 4 (positive E2E proves Step-4 constraint-driven filter at show x inside print's body works) ✓
  • Testing §24.3 positive E2E → Tasks 3 + 4 ✓
  • Testing §24.3 user-ADT E2E → Task 4 ✓
  • Testing §24.3 negative E2E → Task 5 ✓
  • Testing §24.3 mono symbol IR-shape pin → Task 2 ✓
  • Testing §24.3 round-trip → Task 3 Step 2 ✓
  • Testing §24.3 bench regression → Task 8 Step 5 ✓
  • Acceptance §2 (24.3 has landed) → Tasks 1, 2, 4, 5, 6, 7 ✓
  • Acceptance §3 (tests pass) → Task 8 Steps 1-4 ✓
  • Acceptance §4 (bench ratified) → Task 8 Step 5 ✓
  • Acceptance §5 (roadmap) → Task 7 ✓
  • DESIGN.md §Prelude (built-in) classes amendment → Task 6 Step 2 ✓
  • DESIGN.md §Float semantics amendment → Task 6 Step 4 ✓

Placeholder scan: the "_args_inline" and "_doc" annotations inside the stub fixture JSON in Task 3 Step 1 are documented stubs the implementer replaces with the actual multi-statement shape after inspecting the milestone-23 precedent. Not genuine TBDs; the Boss-decision is "use the existing milestone-23 multi-statement pattern, whichever shape that is". Single placeholder remaining is the "implementer fills in concrete assertion" comment in Task 4 Step 3's test — this is the spec's print 3.14 Float-rendering open commitment (libc-dependent output). The implementer captures actual output during Task 3 Step 3 and fills the assertion accordingly. Not a TBD; it's the Boss-acknowledged Float-rendering capture pattern.

Type consistency: print, print__Int|Bool|Str|Float, show, show__Int|Bool|Str|Float, prelude.Show, Show IntBox, MkIntBox, IntBox, int_to_str, io/print_str, Term::Lam, Term::Let, Term::App, Term::Do, class_methods, no-instance, Prelude (built-in) classes (DESIGN section anchor) — all consistent across tasks.

Step granularity: every step is a single discrete action (Edit invocation, single command run, single file read, single test execution). The longest step is Task 3 Step 1 (write the multi-statement fixture body) at ~5 minutes if the implementer has to inspect the precedent first; otherwise faster.

No commit steps: none of the 8 tasks include git commit. The Boss commits at iter close per CLAUDE.md.