diff --git a/docs/specs/2026-05-12-24-show-print.md b/docs/specs/2026-05-12-24-show-print.md new file mode 100644 index 0000000..11a0f58 --- /dev/null +++ b/docs/specs/2026-05-12-24-show-print.md @@ -0,0 +1,650 @@ +# 24 — Show + print rewire — Design Spec + +**Date:** 2026-05-12 +**Status:** Draft — awaiting user spec review +**Authors:** Brummel (orchestrator) + Claude + +## Goal + +Close the prelude story opened by milestone 23 (Eq/Ord) by shipping: + +1. The `Show` typeclass with single method `show : (a borrow) -> Str`. +2. Four primitive instances: `Show Int`, `Show Bool`, `Show Str`, + `Show Float`. +3. A polymorphic free fn `print : forall a. Show a => (a borrow) -> () !IO` + that routes through `show` and `io/print_str`, replacing the + ad-hoc per-type print idiom for new code. +4. Two new runtime primitives that let `Show Bool` and `Show Str` + honour the uniform `(a borrow) -> Str Own` signature without + special-casing the codegen pipeline. + +The milestone is the second and final half of the original +"Post-22 Prelude" roadmap entry. The first half (Eq/Ord) shipped as +milestone 23. The two halves were split because `Show` had a hard +dependency on the heap-`Str` ABI (`int_to_str` / `float_to_str` had +to allocate heap-`Str` slabs at runtime), which itself was a +substantively separate milestone. That dependency closed in the +heap-`Str` ABI milestone (`hs.1`–`hs.4` plus `eob.1`, ratified at +`audit-eob` on 2026-05-12). `int_to_str` / `float_to_str` are now +fully wired and RC-disciplined, removing the only barrier. + +Existing per-type effect-ops `io/print_int` / `io/print_bool` / +`io/print_float` STAY in this milestone. The redundancy between +`print x` and `io/print_int x` for `x : Int` is **ratified as +transitional**, mirroring the same call milestone 23 made for `==` +vs. `eq`. Retiring the per-type effect-ops and migrating the +example corpus (86 fixtures) is queued as a separate P2 follow-up; +this milestone is the architecture-shipping milestone, not the +migration milestone. + +## Architecture + +Three artefacts, in dependency order. + +### 1. Two new runtime primitives + +`runtime/str.c` gains two functions paralleling the existing +`ailang_int_to_str` and `ailang_float_to_str`: + +- `ailang_bool_to_str(int1_t b) -> heap_str_ptr` — returns a freshly + allocated heap-Str slab containing either `"true"` or `"false"` + (5 or 6 bytes inclusive of trailing NUL). The slab is allocated + via `ailang_rc_alloc` with `rc_header` at `payload - 8`, exactly + like `int_to_str`. Always heap-allocates; never returns a + static-Str pointer. The allocation cost is paid every `show True` + / `show False` call; the alternative (schema-`if` with two + static-Str literals) is deferred as an open commitment because + phi-of-static-Str through the drop-elision pipeline is not + load-bearing-ratified today. +- `ailang_str_clone(const heap_str_ptr src) -> heap_str_ptr` — + reads `i64 len` from offset 0 of the source slab and allocates a + fresh heap-Str slab of the same length, memcpy's `len + 1` bytes + (payload + trailing NUL) from source to destination, and returns + the new slab pointer. Works uniformly on both static-Str and + heap-Str inputs because the consumer ABI is identical (see + DESIGN.md §"Str ABI"); only the `len` field at offset 0 plus the + bytes plus the NUL are consulted, never the (absent for + static-Str) `rc_header` of the source. + +Both new symbols install in `crates/ailang-check/src/builtins.rs` +alongside `int_to_str` / `float_to_str`: + +- `bool_to_str : (Bool) -> Str` with `ret_mode: Own`. +- `str_clone : (Str borrow) -> Str` with `ret_mode: Own`. + +Both lower in `crates/ailang-codegen/src/lib.rs::Emitter::lower_app` +via new arms paralleling the existing `int_to_str` and +`float_to_str` arms (extern declarations in the IR header preamble, +direct call emission, `Position::Consume` for the result let-binder +per eob.1's effect-op-Borrow / let-binder-Own discipline). + +The `is_static_callee` whitelist in `crates/ailang-codegen/src/synth.rs` +extends to include both new names. + +### 2. `Show` typeclass + four primitive instances + +`examples/prelude.ail.json` gains five new top-level defs: + +```jsonc +// class +{ "kind": "class", + "name": "Show", + "param": "a", + "methods": [ + { "name": "show", + "type": { + "k": "fn", + "params": [{ "k": "var", "name": "a" }], + "param_modes": ["borrow"], + "ret": { "k": "con", "name": "Str" }, + "effects": [] + } + } + ] +} +// instance Show Int: body invokes int_to_str +// instance Show Bool: body invokes bool_to_str +// instance Show Str: body invokes str_clone +// instance Show Float: body invokes float_to_str +``` + +Each instance body is a single-application lambda +`\x -> x`. No codegen intercept (mirror of +`try_emit_primitive_instance_body` from Eq/Ord) is required because +the four primitives are themselves first-class lowered functions — +unlike Eq/Bool/Str whose `==` bodies needed `try_emit_primitive_instance_body` +to redirect to the appropriate `icmp` or `@ail_str_eq` lowering. The +mono pass synthesises `show__Int`, `show__Bool`, `show__Str`, +`show__Float`; each body is a direct call to the corresponding +primitive. + +Float **is** included in the Show prelude — unlike Eq/Ord which +explicitly excluded Float because IEEE-754 makes structural equality +and total ordering semantically dubious. Producing a textual +representation of a Float is well-defined modulo the NaN spelling +caveat in DESIGN.md §"Float semantics" (`printf("%g", nan)` may emit +`nan` / `-nan` / `NaN`); this caveat is acceptable for a +human-readable Show. + +### 3. `print` polymorphic free fn + +`examples/prelude.ail.json` also gains a sixth new top-level def: + +```jsonc +{ "kind": "fn", + "name": "print", + "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": "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 let-binder for `show x` is **explicit**, not implicit. This is +load-bearing for the heap-Str RC discipline: the Own heap-Str +returned by `show x` (for Int/Bool/Str/Float, every instance allocates) +needs a let-binder so that +`drop_symbol_for_binder` fires the heap-Str drop at scope close — +the Str carve-out path introduced in `eob.1`. Without the let-binder, +the heap-Str slab from `show x` would not be RC-tracked at any +binder and the slab would leak (the same failure mode the eob.1 RED +tests pinned). + +`print` is a free fn, not a class method. Two reasons: + +- The body is identical for every `a : Show a` — no per-instance + variation. The per-instance dispatch happens **inside** `show x`, + not in `print`'s body. A class method with an identical body for + every instance would be redundant. +- It matches Haskell's Prelude convention (`print :: Show a => a -> IO ()` + is a free fn, not a class method) and the milestone 23 free-fn + pattern (`ne`/`lt`/`le`/`gt`/`ge`). + +The mono pass (`crates/ailang-check/src/mono.rs`, unified in 23.4) +handles `print` exactly as it handles `ne`: free-fn entry, rigid-var +substitution `a → Int` (or whichever concrete type), body walker +sees the nested `show` call at concrete type, schedules `show__Int` +in the same fixpoint round, the synthesised `print__Int` body +references `show__Int` directly. + +## Components (iterations) + +| Iter | Status | Scope | +|------|--------|-------| +| **24.1** | upcoming | **Runtime + codegen for two new primitives.** `runtime/str.c` gains `ailang_bool_to_str` and `ailang_str_clone`. `crates/ailang-check/src/builtins.rs` installs `bool_to_str : (Bool) -> Str` and `str_clone : (Str borrow) -> Str`, both with `ret_mode: Own`. `crates/ailang-codegen/src/lib.rs::Emitter::lower_app` gains two new arms emitting direct calls to `@ailang_bool_to_str` and `@ailang_str_clone`, plus extern declarations in the IR header preamble. `crates/ailang-codegen/src/synth.rs::is_static_callee` whitelist extends. RC-stats E2E tests pin `allocs == 1, frees == 1, live == 0` for both primitives. IR-shape pins for the two extern declarations and the two call emissions. | +| **24.2** | upcoming | **`class Show` + 4 primitive instances in prelude.** `examples/prelude.ail.json` gains `class Show a where show : (a borrow) -> Str` and four `instance Show ` defs (Int/Bool/Str/Float), each with a single-application lambda body invoking the corresponding `*_to_str` primitive. Round-trip test confirms prelude.ail.json parses + canonicalises bit-stable. Mono synthesis test confirms `show__Int`, `show__Bool`, `show__Str`, `show__Float` Def::Fn entries are produced. DESIGN.md §"Prelude (built-in) classes" amended to include Show with the four instances. | +| **24.3** | upcoming | **`print` polymorphic free fn + E2E + DESIGN.md sync.** `examples/prelude.ail.json` gains `fn print : forall a. Show a => (a borrow) -> () !IO` with body `\x -> let s = show x in do io/print_str s`. E2E fixtures: (a) positive — `do print 42`, `do print True`, `do print "hello"`, `do print 3.14` produce expected stdout; (b) user-ADT — `data IntBox = MkIntBox Int` + `instance Show IntBox where show x = match x with MkIntBox n -> int_to_str n` + `print (MkIntBox 7)` produces stdout `"7"`; (c) negative — `print f` where `f : Int -> Int` fires `NoInstance Show ` at typecheck with a Show-aware diagnostic. DESIGN.md §"Prelude (built-in) classes" amended to include `print`; §"Float semantics" gains a paragraph linking Show Float (via `float_to_str`) to the existing NaN-spelling caveat. Roadmap P1 "Post-22 Prelude" entry flips to `[x]`; new P2 entry added: "retire `io/print_int|bool|float` effect-ops + migrate example corpus to `print`". | + +The milestone closes with the standard `audit` pipeline (architect +drift review + three bench scripts). + +## Data flow + +Two trajectories show the routing at work. + +### `print 42` at type `Int` + +1. **Typecheck.** `print` resolves to the polymorphic free fn with + `forall a. Show a => (a borrow) -> () !IO`. Bare-name lookup + reaches the prelude via `env.module_globals` fall-through + (prep1 from 23.4-prep). Constraint `Show Int` discharged against + the registry entry for `instance Show Int`. +2. **Mono (free-fn entry).** Body taken directly from `Def::Fn.body`, + rigid-var subst `a → Int`. The body walker detects the nested + `show x` call at concrete `Int` and schedules `show__Int` in the + same fixpoint round. `show__Int` synthesises from the + `instance Show Int` body (class-method entry, + `Registry::entries[(Show, Int)]` lookup). The synthesised + `show__Int` body is `\x -> int_to_str x`. The synthesised + `print__Int` body is `\x -> let s = show__Int x in do io/print_str s`. +3. **Codegen.** `print__Int` lowers as any monomorphic fn. The + nested `show__Int x` is a direct call returning Own heap-Str; + the let-binder `s` is RC-tracked at scope close per the eob.1 + Str carve-out in `drop_symbol_for_binder`. `do io/print_str s` + walks `s` as `Position::Borrow` (eob.1's effect-op arg rule); + the let-binder's drop fires after the effect-op call returns, + emitting `@ailang_rc_dec(s_ptr)` which decrements the heap-Str's + rc_header to zero and frees the slab. Stdout: `"42\n"`. + +### `print 3.14` at type `Float` + +Identical to the above with `a → Float`, `Show Float`, `show__Float`, +`int_to_str` replaced by `float_to_str`. The NaN-spelling caveat +applies if the Float happens to be NaN: the rendered output follows +libc's `%g` formatting, identical to `io/print_float`. Stdout for +`3.14`: depends on libc but typically `"3.14\n"`; for `0.0 / 0.0`: +typically `"nan\n"` or `"-nan\n"`. + +### `print x` at user type `IntBox` with `instance Show IntBox` + +1. **Typecheck.** `Show IntBox` discharged against the registry + entry for `instance Show IntBox`. +2. **Mono.** `print__IntBox` synthesises with body + `\x -> let s = show__IntBox x in do io/print_str s`. `show__IntBox` + synthesises from the instance body (whatever the user wrote, e.g. + `\x -> match x with MkIntBox n -> int_to_str n`). +3. **Codegen.** Lowers normally; ADT-pattern-match plus inner + `int_to_str` call produce a heap-Str; outer let-binder drops at + scope close. + +## Error handling + +- **`NoInstance Show `** — fired at typecheck when `print` is + called on a type without a `Show` instance (most commonly a + function type, or a user type the author forgot to give an + instance for). Diagnostic must cross-reference DESIGN.md + §"Prelude (built-in) classes" so the LLM-author immediately learns + which types ship with built-in Show instances. + +- **`NoInstance Show `** — re-uses the existing 22-era + diagnostic path; no new code shape. + +- **`OrphanInstance Show `** — Decision 11 coherence rule + unchanged. The four primitive instances ship in the prelude + (`Show`'s defining module = `Int`/`Bool`/`Str`/`Float`'s defining + module = prelude module; non-orphan by construction). User-side + `instance Show ` MUST live in the user type's defining + module or in the prelude module (the latter is not possible for + user types since the prelude is read-only), which constrains user + instances to the user-type's defining module. + +- **`CheckError::Internal` from mono** — if `Show`-specialisation + fails internally (e.g. the body walker fails to schedule + `show__T` when synthesising `print__T`), this is a caller-contract + violation per the existing convention in `mono.rs::synthesise_mono_fn`. + Mirrors 23.4's free-fn-entry error handling. + +## Testing strategy + +### 24.1 — runtime + codegen for `bool_to_str` + `str_clone` + +- **Unit: each primitive lowers to expected IR.** Two new IR-shape + pins in `crates/ailang-codegen/src/lib.rs::tests`: + - `bool_to_str_emits_call_to_ailang_bool_to_str` — fixture + `let s = bool_to_str true in do io/print_str s`; assert IR body + contains `call ptr @ailang_bool_to_str(i1 1)` and + `declare ptr @ailang_bool_to_str(i1)`. + - `str_clone_emits_call_to_ailang_str_clone` — fixture + `let s = str_clone "hi" in do io/print_str s`; assert IR body + contains `call ptr @ailang_str_clone(ptr ...)` and + `declare ptr @ailang_str_clone(ptr)`. + +- **E2E RC-stats (two tests in `crates/ailang-codegen/tests/e2e.rs`):** + - `bool_to_str_drop_balances_rc_stats` — fixture + `let s = bool_to_str true in do io/print_str s`. Stdout + `"true\n"`. RC stats: `allocs == 1, frees == 1, live == 0`. + - `str_clone_drop_balances_rc_stats` — fixture + `let s = str_clone "hello" in do io/print_str s`. Stdout + `"hello\n"`. RC stats: `allocs == 1, frees == 1, live == 0`. + +- **E2E stdout-smoke (two tests):** + - `bool_to_str_emits_true_or_false` — both branches: + `let s = bool_to_str true in do io/print_str s` → `"true\n"`; + same with `false` → `"false\n"`. + - `str_clone_emits_input_bytes` — `str_clone "world"` → + stdout `"world\n"`. Confirms memcpy semantics, not just the + pointer. + +- **Cross-realisation test for `str_clone`:** clone a heap-Str + (output of `int_to_str 42`) AND a static-Str (literal `"abc"`). + Both produce heap-Str outputs with correct bytes. RC discipline + green in both cases. Pins the "uniform consumer ABI" claim from + DESIGN.md §"Str ABI". + +- **Linker / build hygiene:** `cargo test --workspace` green. The + `__attribute__((weak))` on `ailang_rc_alloc` extern (hs.3 pattern) + is unchanged — `rc.c` is unconditionally linked post-eob.1, so + the weak symbol is a documented no-op. + +### 24.2 — `class Show` + 4 instances + +- **Round-trip:** `examples/prelude.ail.json` parses + canonicalises + bit-stable after the four new defs land. The existing prelude + hash-stability test from milestone 23 stays green (the prelude's + pre-existing defs — Ordering, Eq, Ord, instances, ne/lt/le/gt/ge — + are unaffected). + +- **Mono synthesis (unit test in `crates/ailang-check/src/mono.rs::tests`):** + for a fixture that calls `show 1`, `show True`, `show "x"`, and + `show 1.5`, assert that the post-mono workspace contains + `show__Int`, `show__Bool`, `show__Str`, `show__Float` as `Def::Fn` + entries. Bodies are direct calls to the corresponding primitives. + +- **Hash stability of existing mono symbols:** `eq__Int`, + `compare__Str`, and the four other class-method mono symbols from + milestone 23 must produce identical bodies (and therefore + identical hashes) under the unified pass after the Show defs are + added. Pin test in the style of + `crates/ailang-core/src/hash.rs::ct4_migrated_fixtures_have_canonical_form_hashes`. + +- **Round-trip of new fixtures** — every fixture authored under 24.2 + passes Form-A parser + canonicaliser bit-stable. + +### 24.3 — `print` polymorphic free fn + E2E + +- **Positive E2E (`examples/show_print_smoke.ail.json`):** a single + fixture exercises all four primitives: + ``` + fn main = do + print 42 -- "42\n" + print true -- "true\n" + print "hello" -- "hello\n" + print 3.14 -- "3.14\n" (libc-dependent) + ``` + Test asserts compile, run, expected stdout (the Float line uses + a non-NaN, non-inf value where libc rendering is stable across + versions — `3.14`'s `%g` rendering is `"3.14"` on every libc this + project targets). + +- **User-ADT E2E (`examples/show_user_adt.ail.json`):** + ``` + data IntBox = MkIntBox Int + instance Show IntBox where + show = \x -> match x with MkIntBox n -> int_to_str n + fn main = do print (MkIntBox 7) + ``` + Stdout `"7\n"`. Confirms the mono pass handles user-defined Show + instances and that `print` composes with them. + +- **Negative E2E (`examples/show_no_instance.ail.json`):** + ``` + fn main = do print id -- id : forall a. a -> a, no Show instance + ``` + Typecheck rejects with `NoInstance Show `. The exact + diagnostic wording is named as an Open commitment below + ("Negative-test diagnostic wording"); the gold-standard test pins + the message verbatim after the implementer drafts it. + +- **Mono symbol IR-shape pin:** `crates/ailang-check/src/mono.rs::tests` + asserts `print__Int` body desugars to + `\x -> let s = show__Int x in do io/print_str s` post-mono. Confirms + the let-binder is preserved (not optimised away — load-bearing for + the heap-Str RC discipline). + +- **Round-trip of new fixtures.** + +- **Bench regression check** at milestone close: the three bench + scripts exit 0 or audit-ratified per audit-skill convention. + +## Acceptance criteria + +The milestone closes when: + +1. **24.1 has landed:** `runtime/str.c` defines `ailang_bool_to_str` + and `ailang_str_clone`. `crates/ailang-check/src/builtins.rs` + installs both signatures with `ret_mode: Own`. + `crates/ailang-codegen/src/lib.rs::Emitter::lower_app` emits direct + calls plus extern declarations. RC-stats E2E pins balance. + +2. **24.2 has landed:** `examples/prelude.ail.json` contains + `class Show a where show : (a borrow) -> Str` and four primitive + instances (Int/Bool/Str/Float). The mono pass synthesises + `show__Int`, `show__Bool`, `show__Str`, `show__Float`. DESIGN.md + §"Prelude (built-in) classes" amended to include Show with the + four instances. + +3. **24.3 has landed:** `examples/prelude.ail.json` contains + `fn print : forall a. Show a => (a borrow) -> () !IO` with the + explicit-let body shape. The mono pass synthesises `print__Int`, + `print__Bool`, `print__Str`, `print__Float` for each call site at + a primitive type, plus `print__` for each user-instance + call site. Positive / user-ADT / negative E2E green. DESIGN.md + §"Prelude (built-in) classes" lists `print` as the polymorphic + helper; §"Float semantics" gains the Show Float NaN-spelling + cross-reference paragraph. + +4. **Tests pass:** `cargo test --workspace` green; existing milestone + 23 prelude fixtures (`ne`/`lt`/`le`/`gt`/`ge` E2E) unchanged; + existing typeclass fixtures (`eq__Int`, `compare__Str`, etc.) + hash-stable. + +5. **Bench regression ratified:** `bench/check.py && bench/compile_check.py && bench/cross_lang.py` + exit 0 or audit-ratified with journal entry. + +6. **Roadmap update:** P1 "Post-22 Prelude — Show + print rewire" + entry flips to `[x]`. New P2 entry: "Retire `io/print_int` / + `io/print_bool` / `io/print_float` effect-ops + migrate example + corpus to `print`". A user runs the bulk text substitution + `do io/print_ x` → `do print x` across `examples/*.ail.json` + (86 files affected), and the per-type ops are deleted from + `builtins.rs` / `lib.rs` codegen arms / runtime C glue. + +## Out of scope (deferred, with substantive rationale) + +- **Retiring `io/print_int` / `io/print_bool` / `io/print_float`.** + Queued as P2 follow-up. The architecture-shipping milestone and + the corpus-migration milestone are kept separate so the migration + is one mechanical pass against a frozen architecture, not a + moving target. Same call milestone 23 made for the + `==` / `eq`, `<` / `lt` redundancy. + +- **`io/print_str` retirement.** Stays. Load-bearing inside `print`'s + body (`do io/print_str s`); cannot be retired without an alternate + primitive output path. Also stays as a directly-callable surface + for Str-typed values where the LLM-author wants to bypass Show + (e.g. when the Str is already in hand and `show__Str`'s + `str_clone`-allocation is wasteful). + +- **`bool_to_str` as schema-`if`.** The body `\x -> if x then "true" else "false"` + is schematically valid but requires phi-of-static-Str to flow + cleanly through the drop-elision pipeline (DESIGN.md §"Str ABI" + codegen-level static-Str elision). The phi case is not + load-bearing-ratified today; the safe always-correct + `bool_to_str` primitive avoids the question. Open commitment: + if a future codegen iter ratifies phi-of-static-Str drop-elision, + swap `show__Bool` body to the schema-`if` form and retire + `bool_to_str`. + +- **`str_clone` as rc_header bump.** For heap-Str inputs, `str_clone` + could rc_inc the existing slab instead of allocating a fresh + copy. This requires the implementation to distinguish heap-Str + from static-Str at runtime, which the codebase deliberately does + NOT support post-hs.2 (the UINT64_MAX sentinel was dropped in + favour of codegen-time elision). Open commitment for a future + iter: if runtime tagging is reintroduced for a different reason, + `str_clone` can become an rc_inc on heap-Str / no-op-with-fresh- + alloc on static-Str hybrid. For now: always-allocate, always-copy. + +- **`Show Ordering` instance.** The prelude's `Ordering` ADT has + three constructors (LT/EQ/GT). Shipping `Show Ordering` would + add a user-friendly pretty-print but no concrete demand exists + and the design-by-default in this milestone is "instances ship + for types LLM authors actually print". User can declare their + own `instance Show Ordering` if they want it. + +- **`Show` for ADTs via `deriving`.** No auto-derivation. The + `instance Show ` body is hand-written per instance. + Auto-derivation is a substantive feature in its own right + (canonical-form choices, recursive descent, list / tuple Show + bodies) and out of scope here. + +- **Higher-kinded `Show` (`Show [a]`, `Show (Maybe a)`).** Decision + 11 axis 5 explicitly excludes higher-kinded class params. The + LLM-natural pattern is per-type instances (`Show [Int]` as a + monomorphic instance, hand-written). Same constraint as Eq/Ord. + +- **`print` with newline-free variant (`putStr` / `print_no_newline`).** + `io/print_str` calls `@puts` which appends a newline. Every + `print x` therefore ends in `\n`. A newline-suppressed variant is + a separate feature; deferred until a concrete LLM-author pattern + demands it. + +- **Operator routing through `Show` (`show` invoked as `<<` or + similar).** No commitment. AILang does not have a Haskell-style + monadic operator surface and adding one to invoke `show` + implicitly would re-open the ergonomics question Decision 6 + closed in favour of explicit `do` blocks. + +## Open commitments (24.1 / 24.2 / 24.3 implementer) + +- **`bool_to_str` runtime body.** The 4-byte slab `"true"` and + 5-byte slab `"false"` can either be heap-allocated per call + (current spec) or cached as module-level static slabs the + function returns by-pointer. The cached variant is faster but + re-introduces the same "static-Str-without-sentinel" question + the project deliberately avoided. Implementer ships the + always-heap-allocate variant unless a clear path through the + drop-elision pipeline appears. + +- **`str_clone` byte count.** The naive memcpy copies `len + 1` + bytes (payload + trailing NUL). The implementation may choose to + recompute the NUL via `strncpy`-style code instead of trusting + the trailing NUL in the source; either is correct, both are + acceptable as long as the result slab is valid UTF-8-or-bytes + per the source's contract. + +- **Negative-test diagnostic wording.** Implementer drafts the + one-line Show-aware message for `NoInstance Show `; + orchestrator reviews in spec-compliance phase. + +- **Implicit-let vs. explicit-let in `print`'s body.** The spec + prescribes the explicit-let form + `\x -> let s = show x in do io/print_str s`. If the AILang + desugarer would automatically introduce a let-binder for any + Term::App in effect-op-arg position, the explicit-let could be + dropped from the source form. Implementer verifies the + desugarer's behaviour during 24.3 Task 1; if implicit-let is + the realised semantics, the prelude source form may be the + more compact `\x -> do io/print_str (show x)`. The + load-bearing property is "the heap-Str returned by `show x` has + a tracked let-binder when entering the effect-op call site"; + whether the let-binder is sourced from the prelude form or + desugar-introduced is a surface-level choice with identical + semantics post-checker. + +- **Show instance for the `Unit` type / Tuples / Lists.** Out of + scope per "instances for types LLM authors actually print" — but + if 24.3's user-ADT E2E surfaces a recurring shape (e.g. printing + a Tuple inside the `IntBox` test), the implementer may queue a + P3 idea entry without expanding the milestone scope. + +## Known costs + +- **24.1 runtime + codegen:** estimated +30 LOC in `runtime/str.c` + (two new functions, ~15 LOC each), +20 LOC in + `crates/ailang-check/src/builtins.rs` (two new signature + installations parallel to the existing `int_to_str` / + `float_to_str` blocks), +30 LOC in + `crates/ailang-codegen/src/lib.rs` (two new arms in `lower_app` + + two extern declarations in the IR header preamble), +2 LOC in + `crates/ailang-codegen/src/synth.rs::is_static_callee`. + +- **24.2 prelude additions:** `examples/prelude.ail.json` +40–50 + lines (one class + four instances, JSON shape parallel to Eq + Int/Bool/Str). DESIGN.md amendment ~10 lines. + +- **24.3 prelude additions + tests:** `examples/prelude.ail.json` + +30 lines (one polymorphic free fn with constraint signature + and let-bound body). Three new E2E `.ail.json` fixtures, ~40 + lines JSON each. Diagnostic test ~20 lines Rust. DESIGN.md + amendments ~20 lines (Prelude classes + Float semantics + cross-reference). + +- **Net code reduction:** none. This milestone is additive. + Reduction comes later in the P2 follow-up (retiring three + builtin effect-ops + three codegen arms + three runtime glue + functions, estimated ~−150 LOC). + +- **Compile-time shift:** the mono pass gains four new instance-method + synthesis targets (`show__Int|Bool|Str|Float`) plus one new free-fn + target (`print`) per call site. The shift is bounded — additive + per call site, no new fixpoint depth — and is expected to be + within bench tolerance. Quantified by `bench/compile_check.py` + post-24.3; audit-ratified or rebaselined. + +- **Runtime cost on `print x`:** + - For `x : Int` — one `int_to_str` allocation (~3–20 bytes per + Int depending on magnitude) plus one rc_dec at scope close. + - For `x : Bool` — one `bool_to_str` allocation (5 or 6 bytes) + plus one rc_dec. + - For `x : Str` — one `str_clone` allocation + memcpy plus one + rc_dec. **Strictly worse** than `do io/print_str x`. The + redundancy is acknowledged; LLM-author has the recourse to + bypass `print` for Str-typed values. + - For `x : Float` — one `float_to_str` allocation plus one rc_dec. + +## Load-bearing assumptions about current behaviour + +Surfaced explicitly so the Step 7.5 grounding-check agent has a +canonical list to ratify. Each assumption is something this spec +relies on as currently-true. + +1. `crates/ailang-check/src/mono.rs::monomorphise_workspace` is the + single specialiser for both class methods and polymorphic free + fns (per milestone 23.4 unification), and it handles transitive + specialisation across nested calls in one fixpoint loop. + +2. `int_to_str : (Int) -> Str` and `float_to_str : (Float) -> Str` + are installed in `builtins.rs` with `ret_mode: Own`, lowered in + `Emitter::lower_app` via direct extern calls to + `@ailang_int_to_str` / `@ailang_float_to_str`, and RC-disciplined + end-to-end (allocs == frees, live == 0) per eob.1. + +3. `io/print_str` lowers to `@puts(ptr)` and accepts any Str + realisation (static-Str at `@.str_*` LLVM globals OR heap-Str + allocated by `int_to_str` / `float_to_str` / the new primitives) + via a consumer ABI of `i64 len` at offset 0 followed by the + bytes plus a trailing NUL (DESIGN.md §"Str ABI"). + +4. Effect-op args walk in `Position::Borrow` per eob.1 + (`uniqueness.rs:289` + `linearity.rs`'s effect-op-arg arm); the + let-binder for the Own heap-Str returned by `show x` fires its + drop at scope close. + +5. `drop_symbol_for_binder` has a Str carve-out (eob.1) that emits + the correct rc-dec call for a Str let-binder regardless of + whether the underlying realisation is heap-Str or static-Str + (codegen-time elision ensures static-Str pointers do not reach + `ailang_rc_dec`). + +6. `Type::Forall` with `constraints` round-trips bit-stable through + the Form-A parser + canonicaliser + printer (22b.4a.4.5 work, + ratified by milestone 23's prelude fixtures `ne`/`lt`/`le`/etc. + which all carry `Eq` or `Ord` constraints). + +7. The Form-A `ClassDef` and `InstanceDef` schema shapes (DESIGN.md + line 1512–1547) round-trip; the existing `class Eq` / `class Ord` + defs in `examples/prelude.ail.json` are the load-bearing proof. + +8. `ailang_rc_alloc(size_t)` is the heap-Str slab allocator, + unconditionally linked post-eob.1 (the `__attribute__((weak))` + on str.c's extern declaration is a documented no-op). + +9. The mono pass's body walker (`mono.rs::collect_mono_targets`) + schedules targets for both class-method residuals AND + polymorphic free-fn calls with fully-concrete substitutions, in + one pass, transitively (the `cmp_max_smoke` proof from 23.4). + +10. `NoInstance` diagnostics already cross-reference the responsible + class (per 22 milestone error-handling) and accept additional + cross-references via the existing diagnostic-message + infrastructure; adding a Show-aware variant is a content edit, + not an infrastructure extension. + +11. The canonical-form layer (per + `docs/specs/2026-05-10-canonical-type-names.md`) treats mono + Defs as post-typecheck artefacts not in + `CheckedModule.symbols`, so adding `show__T` and `print__T` + symbols does not affect surface-level hash pins. The four + primitive instance type names (Int/Bool/Str/Float) are + canonical (Decision 11's mono-symbol naming uses surface name + for primitives). + +12. `examples/prelude.ail.json` is the auto-loaded prelude per + 23.1's `check_workspace` extension; new defs added to it are + visible to every workspace without an explicit `import`.