Files
AILang/docs/specs/0022-24-show-print.md
T
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

667 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 24 — Show + print rewire — Design Spec
**Date:** 2026-05-12
**Status:** Partial — iter 24.1 shipped 2026-05-12 @ `f38bad8`
(`bool_to_str` + `str_clone` runtime + codegen wiring); iters 24.2
and 24.3 **deferred** on 2026-05-13 pending the retirement of the
`MethodNameCollision` workaround. The deferral was triggered by a
spec-gap surfaced during 24.2 plan-recon: adding `class Show` to
the prelude collides with the user-class `Show` declared by 14
test fixtures under `examples/test_22b{1,2,3}_*.ail.json` (plus
hardcoded `"Show"` / `"show"` assertions in
`crates/ail/tests/typeclass_22b{2,3}.rs`) through the
workspace-global method-name-collision pre-pass at
`crates/ailang-core/src/workspace.rs:547`. Three resolution paths
were surfaced; the user picked "defer until
`MethodNameCollision` retires" per the P2 roadmap entry
"Module-qualified class names + type-driven method dispatch". A
fresh brainstorm re-derives 24.2 + 24.3 from scratch against the
post-retirement architecture; this document remains as historical
context for that re-brainstorm.
**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 -> <primitive_to_str> 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 <T>` 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 <fn-type>` 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 <T>`** — 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 <UserType>`** — re-uses the existing 22-era
diagnostic path; no new code shape.
- **`OrphanInstance Show <T>`** — 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 <UserType>` 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 <fn-type>`. 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__<UserType>` 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_<T> 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 <UserType>` 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 <T>`;
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` +4050
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 (~320 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 15121547) 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/0007-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`.