diff --git a/docs/plans/24.3.md b/docs/plans/24.3.md new file mode 100644 index 0000000..d6c1ae8 --- /dev/null +++ b/docs/plans/24.3.md @@ -0,0 +1,934 @@ +# Iter 24.3 — `fn print` polymorphic free fn + E2E + DESIGN.md sync — Implementation Plan + +> **Parent spec:** `docs/specs/2026-05-13-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 text** — **AST-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): + +```jsonc + { + "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`.** + +```rust +//! 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`.** + +```jsonc +{ + "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`.** + +```jsonc +{ + "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__IntBox` → `int_to_str`. The shorter `show` call is sufficient to exercise the user-instance mono synthesis. If the spec testing requirement names `print` specifically, change `show` → `print` 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`: + +```jsonc +{ + "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: + +```jsonc +"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`.** + +```rust +//! 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 ` 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 ` — `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 ` 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): + +```rust +// 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`.** + +```jsonc +{ + "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`.** + +```rust +//! 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 `show` and `io/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: + +```markdown +- [ ] **[milestone]** Retire `io/print_int` / `io/print_bool` / + `io/print_float` effect-ops + migrate example corpus to `print`. + Bulk text substitution `do io/print_ 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. 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:** ` +- `**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.