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.
44 KiB
Iter 24.3 — fn print polymorphic free fn + E2E + DESIGN.md sync — Implementation Plan
Parent spec:
docs/specs/0024-24-show-print.mdFor agentic workers: REQUIRED SUB-SKILL: use
skills/implementto 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:
-
Implicit-let vs explicit-let for
print's prelude source form — ship explicit-let. Recon scannedcrates/ailang-core/src/desugar.rsandcrates/ailang-check/src/uniqueness.rs: no automatic let-introduction pass forTerm::Appin 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. -
Mono IR-shape pin: AST-level vs LLVM text — AST-level via recursive
Term-pattern match on post-monoDef::Fn.body. Precedent:crates/ail/tests/mono_hash_stability.rsinspectsDef::Fndirectly. The existingcrates/ail/tests/ir_snapshot.rsopens emitted.lltext but usesail emit-irwhich does NOT run mono (per roadmap P3 line 227-236), so it's unsuitable forprint__Intshape assertion. -
NoInstance Show diagnostic wording — extend the existing Float-aware Eq/Ord branch at
crates/ailang-check/src/lib.rs:770-779with a parallelclass == "prelude.Show"arm. Cross-reference: literal section anchor"Prelude (built-in) classes"(the existing section title in DESIGN.md line 1874). -
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/Ordentry). 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. -
show_no_instance.ail.jsonshape — uselet f : Int -> Int = \x -> x in do print f. Concrete fn-type, no Show instance, firesNoInstance Show (Int -> Int). The spec'sprint idwas schematic;idis not in the current prelude (verified —prelude.ail.jsonhasnot,eq,compare,ne,lt,le,gt,ge, plus the iter-24.2 Show defs, noid). 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:maincallsprint 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+maincallsprint (MkIntBox 7).examples/show_no_instance.ail.json— negative E2E:maincallsprint fwheref : 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; assertscode == "no-instance", message mentions Show + cross-references DESIGN.md).crates/ail/tests/print_mono_body_shape.rs— IR-shape pin: post-monoprint__IntDef::Fn.bodymatchesTerm::Lam → Term::Let → Term::Dorecursively.
Modify:
examples/prelude.ail.json:485— append the new top-levelfn printdef immediately after thefn geentry, before the workspace-closing].crates/ailang-check/src/lib.rs:770-779— extend the Float-aware Eq/Ord NoInstance arm with a parallelclass == "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 flipprint-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— appendfn printdef. -
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 printdef.
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
printresolves.
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.bodyinspection.
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 42call) 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_blockwith 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__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:
{
"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
Tis 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— flipprint-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__Intmono symbol mismatch — Task 2's IR-shape pin caught a regression in mono. -
show_print_e2ebuild failure — thefn printin prelude has a schema typo not caught by JSON parse. -
show_no_instance_e2emessage-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_stabilityEq/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 xinside 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.