diff --git a/docs/specs/2026-05-14-retire-per-type-print-effects.md b/docs/specs/2026-05-14-retire-per-type-print-effects.md new file mode 100644 index 0000000..9642f08 --- /dev/null +++ b/docs/specs/2026-05-14-retire-per-type-print-effects.md @@ -0,0 +1,326 @@ +# 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.