# Retire per-type print effect-ops — Design Spec **Date:** 2026-05-14 **Status:** Draft — authored under boss-mode autonomy **Authors:** Brummel (orchestrator) + Claude ## §A — Goal Remove `io/print_int`, `io/print_bool`, `io/print_float` from the compiler and the example corpus, and migrate every fixture call site to the polymorphic `print` shipped by milestone 24 (iter 24.3). After this milestone the only surviving IO output channels are: - `io/print_str : (Str) -> Unit !IO` — the byte-channel primitive, used internally by `print` and by examples that already hold a `Str` in hand. - `print : forall a. Show a => (borrow a) -> Unit !IO` — the canonical user-facing print, polymorphic over the `Show` class. This is the same call milestone 23 made for `eq` over `==` / per-type comparators: once the polymorphic helper exists, the specialised primitives are redundant authoring surface and must be removed so the LLM-author has exactly one idiom to reach for. ## §B — Why now Three forces converge: 1. **Roadmap entry P2.** `docs/roadmap.md` lines 80–90 names this milestone explicitly as the post-milestone-24 follow-up. The architecture-shipping milestone (24) and the corpus-migration milestone (this one) are sequenced so the migration runs against a frozen architecture. 2. **DESIGN.md §"Polymorphic print" already names the deprecation.** Lines 1990–1992 ("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.") This milestone closes that note out. 3. **Feature-acceptance criterion.** An LLM-author with access to `print` will reach for it over `io/print_int` unprompted (cleaner, polymorphic, one name to remember). Keeping the per-type ops around hides that preference behind a tie-break the LLM does not need to make. The criterion in `docs/DESIGN.md` §"Feature-acceptance criterion" is satisfied for the *removal*: the ops carry no unique payload — every call site has a clean `print` rewrite. ## §C — Architecture ### §C1 Migration shape For every fixture `examples/*.ail` with `(do io/print_ x)` in its body: ```diff - (do io/print_int x) + (app print x) ``` The transformation is local: `print` is a function with effect `IO`, invoked with `(app print x)` (function-application form, effect propagates through the inferred signature), not with `do` (which is for direct effect-op invocation). The 4 prelude Show instances (`Show Int`, `Show Bool`, `Show Str`, `Show Float`) cover every type that currently appears as a `io/print_` argument, so dispatch always resolves. ### §C2 Compiler-side deletion Three call sites delete in lockstep: - `crates/ailang-check/src/builtins.rs` — three builtin entries (lines ~270, ~278, ~296), three `describe` entries (lines ~349, ~350, ~352), and their three `install_io_print__signature` tests (lines ~593–633). - `crates/ailang-codegen/src/lib.rs::lower_app` — three arms (lines 2302, 2324, 2373) and the `lowers_io_print_float` test (lines 3580–3610). - `runtime/` — no symbols to delete. Codegen lowers per-type print ops inline to `printf` IR; no runtime C glue exists for them. (`runtime/str.c:118` is a *comment* referencing `io/print_float`'s `%g` semantics for documentation purposes; the comment is rewritten to point at `float_to_str` instead.) ### §C3 DESIGN.md updates Five sites in DESIGN.md reference the per-type print ops by name: | Line | Section | Edit shape | |------|---------|------------| | 331 | Decision 11 (canonical form) | Example uses `io/print_int` as a representative effect-op name → swap to `io/print_str` (the surviving member). | | 1990-1992 | Polymorphic print | "retiring the per-type effect-ops is queued as a P2 follow-up" → rewrite past-tense: "retired 2026-05-14 in iter rpe.1". | | 2024-2025 | Heap-Str primitives | "Primitive output goes through `io/print_int` / `io/print_bool` / `io/print_str` directly." → rewrite: "Primitive output for `Str` goes through `io/print_str`; values of other types route through the polymorphic `print` helper (§"Polymorphic print"). The per-type effect-ops `io/print_int|bool|float` are retired (iter rpe.1, 2026-05-14)." | | 2331 | Effect-op invocation example | Comment example uses `"io/print_int"` → swap to `"io/print_str"`. | | 2565-2577 | Float semantics (NaN textual rendering) | "the textual rendering of NaN by `io/print_float`" needs updating — the same `%g` channel is now reached via `print x : Float` → `show x` → `float_to_str x` → `io/print_str s`. The `%g` contract is preserved (both `float_to_str` and the retired `io/print_float` used `%g`). Rewrite to anchor on `float_to_str`. | | 2679 | What is supported (effect ops) | "(`io/print_int`, `io/print_bool`, `io/print_str`)" → "(`io/print_str`)". | | 2699 | What is supported (IO effect ops) | "the IO effect ops (`io/print_int|bool|str|float`)" → "the IO effect op `io/print_str`". | ### §C4 Carve-outs **Bench-latency fixtures.** `examples/bench_latency_explicit.ail` and `examples/bench_latency_implicit.ail` use `io/print_int` in their inner hot loop (`do io/print_int (app one_op chunk_len t)` per iteration of a 20000-iter loop). Migrating these to `print` would introduce a per-iteration heap-Str alloc (`int_to_str` slab + RC drop) into the latency-measurement hot path, which is not the workload these benchmarks were calibrated against. Two routings considered: - **(a) Migrate everything; ratify the latency baseline drift.** Honest. After the migration the latency bench measures the realistic post-milestone-24 output path. The RC-vs-GC comparison the latency benches were designed to measure is preserved (the extra heap-Str alloc per iteration is identical for both allocator strategies, so the *delta* between RC and GC is unchanged). Cost: one ratify cycle, JOURNAL entry, and the new baseline becomes the post-milestone-24 ground-truth. - **(b) Carve out the bench-latency fixtures.** Keep the two bench fixtures on `io/print_int`, which would require keeping the `io/print_int` builtin alive in the compiler — defeating the milestone's premise. **Decision: (a).** Option (b) is internally contradictory: a milestone that "retires per-type print effect-ops" cannot keep one of them alive for two fixtures. Option (a) is the only self-consistent choice; the latency-baseline ratify is a cost that this milestone pays once, in exchange for a fully post-milestone-24 corpus. The bench-latency fixtures' READY (8888) and DONE (9999) marker prints, which fire once per run, are not load-bearing for latency measurement and migrate without comment. ### §C5 Scope edges - `io/print_str` survives unchanged. It is used internally by `print` (the let-binder `(let s (app show x) (do io/print_str s))` in the prelude body) and directly by fixtures that already hold a `Str` in hand (28 sites). Renaming is out of scope — it remains the canonical Str byte-channel. - `print` itself is unchanged. This milestone consumes the prelude `print` that shipped in iter 24.3; no prelude edits. - No new prelude additions. Every migrated fixture relies on one of the four `Show ` instances that already ship in iter 24.2. - Carve-out fixtures from form-a-default-authoring (§C4 (a) of that spec — the seven canonical-form-rejection fixtures that stay `.ail.json`-only) are inspected for `io/print_` use; none have any (they exist to test parse-rejection, not runtime behaviour). No interaction. ## §D — Components ### §D1 Fixture migration - 98 `examples/*.ail` files reference `io/print_int|bool|float` (verified via `grep -rl io/print_int\|io/print_bool\|io/print_float examples/`). - Each is opened, the per-type print ops are textually replaced with the corresponding `(app print x)` shape, and the file is saved. The transformation is fully mechanical — no judgement call per fixture. - After bulk substitution, the round-trip-CI invariant runs to verify that each migrated `.ail` still parses and round-trips through `ail render`. ### §D2 Builtin + codegen deletion The three deletion bundles run in lockstep with the fixture migration: builtins delete, codegen arms delete, tests delete. The order inside the iter is plan-level (fixtures first, then deletion — so the in-flight compiler still accepts the pre-migration fixtures while the fixtures are being migrated; once all fixtures are migrated, the builtins are deleted and the workspace stays green). ### §D3 DESIGN.md sweep Seven DESIGN.md edits per §C3 above. Inline within the iter so the spec stays consistent with the code. ### §D4 Bench baseline ratification After the migration, `bench/check.py`, `bench/compile_check.py`, and `bench/cross_lang.py` re-run. Compile-time benches are expected to be neutral (compiler did not get faster or slower — deleting three arms is symmetric noise). Cross-lang and latency benches may drift; per §C4 (a) the new baselines are recorded via `--update-baseline` with a JOURNAL entry naming the milestone as the legitimate cause. ## §E — Data flow Pre-milestone fixture: ``` (do io/print_int x) ↓ check: x : Int, op := "io/print_int", effect IO ↓ codegen: lower_app arm 2302 → printf("%lld\n", x) ↓ runtime: libc printf ``` Post-milestone fixture: ``` (app print x) ↓ check: print : forall a. Show a => (borrow a) -> Unit !IO ↓ unify a := Int; resolve Show Int → instance#show_Int ↓ body: let s = (app show x) in (do io/print_str s) ↓ codegen: lower (app show x) → call ptr @ailang_int_to_str(i64 %x) ↓ lower (do io/print_str s) → printf("%s\n", %s_payload) ↓ runtime: ailang_int_to_str (heap-Str alloc) + libc printf ``` Output bytes are identical (both paths produce `\n`). Runtime cost differs by one heap-Str alloc per print. ## §F — Error handling The migration is mechanical; no new diagnostics. Two sanity-checks land as part of the iter: - **Builtin deletion sanity:** after the three `io/print_` builtins are deleted, any surviving fixture referencing them produces `[unbound-effect-op] unknown effect-op: io/print_int` at check time. This is the explicit signal that the migration is complete. The iter's final `cargo test --workspace` runs this gate by exhaustive corpus traversal — if any fixture was missed, a test fails. - **DESIGN.md doc-test pin:** `crates/ailang-check/tests/design_schema_drift.rs` (and the broader doc-drift tests) read DESIGN.md sections; if a stale `io/print_int` reference remains, the drift test catches it. ## §G — Testing strategy ### §G1 Existing tests The 98 migrated fixtures already have E2E test wrappers (in `crates/ail/tests/e2e.rs` and sibling test files) that assert on their stdout. After migration, the test suite is the correctness gate: if the output bytes change for any fixture, a stdout-comparison test fails. ### §G2 RED test for the iter A single RED test is added at iter-start: `crates/ailang-check/tests/no_per_type_print_ops.rs` asserts that none of the three per-type effect-op names (`io/print_int`, `io/print_bool`, `io/print_float`) appear in the builtin registry after `install_builtins(&mut env)`. This is the "milestone has shipped" gate; it fails RED until the builtin deletion lands. ### §G3 Bench ratification `bench/check.py` and `bench/compile_check.py` are expected to return exit 0 against existing baselines (compile-time-only metrics; deletion is symmetric in cost). If they don't, the deletion changed something unintended and is treated as a regression. `bench/cross_lang.py` and the latency harness (`bench/run.sh`) may drift because of the heap-Str alloc per print. Ratification is part of the iter's close: `--update-baseline` plus a JOURNAL entry naming the milestone as the cause. ## §H — Acceptance criteria - [ ] `grep -rl "io/print_int\|io/print_bool\|io/print_float" examples/` returns zero results (the only carve-out is none — every fixture migrates). - [ ] `grep -n "io/print_int\|io/print_bool\|io/print_float" crates/ailang-check/src/builtins.rs` returns zero registration lines (test sections may keep one historical reference in a doc-comment naming what was retired — acceptable if comments only). - [ ] `crates/ailang-codegen/src/lib.rs::lower_app` has no arm for any of the three op names. - [ ] `cargo test --workspace` green (regression count: 0). - [ ] `cargo clippy --workspace --all-targets` green (0 warnings — milestone clippy-sweep baseline holds). - [ ] `cargo doc --workspace --no-deps` green (0 warnings). - [ ] `bench/check.py` exit 0. - [ ] `bench/compile_check.py` exit 0. - [ ] `bench/cross_lang.py` either exit 0 OR ratified (`--update-baseline` ran with JOURNAL entry naming this milestone). - [ ] All seven DESIGN.md sites updated per §C3. - [ ] Roadmap entry struck through with `- [x]` and date. ## §I — Iteration plan Single iter, `rpe.1`, with this task ordering: 1. **RED test** — author `crates/ailang-check/tests/no_per_type_print_ops.rs` asserting the three ops are absent from the builtin registry. Verify it fails RED. 2. **Corpus migration** — bulk substitute `(do io/print_ x)` → `(app print x)` across all 98 `examples/*.ail` files. `cargo test --workspace` green after this step (the builtins still register; the new fixtures just stop using them). 3. **Builtin deletion** — delete the three registration entries, three describe entries, and three `install_io_print__signature` tests from `crates/ailang-check/src/builtins.rs`. RED test from step 1 goes GREEN. 4. **Codegen deletion** — delete three `lower_app` arms and the `lowers_io_print_float` test from `crates/ailang-codegen/src/lib.rs`. `cargo test --workspace` green. 5. **DESIGN.md sweep** — seven edits per §C3. 6. **Bench run** — `bench/check.py && bench/compile_check.py && bench/cross_lang.py`. Ratify if needed. 7. **Roadmap + WhatsNew** — strike-through the entry; append user-facing WhatsNew entry. Each task fits the 2–5 minute step granularity that `skills/planner` requires. ## §J — Carry-on The form-a milestone left two `.ail.json` carve-outs that mention `io/print_int` in their *test-rejection* content: neither is touched (per §C5 the carve-out fixtures test canonical-form rejection, not runtime behaviour). The post-iter inventory still shows seven §C4 (a) JSON-only fixtures + one §C4 (b) compile-time-embed (prelude.ail.json); this milestone does not move that count.