spec: rpe.1 — retire per-type print effect-ops

Single-iter milestone closing the post-milestone-24 follow-up named
in docs/roadmap.md lines 80–90 and DESIGN.md §"Polymorphic print"
lines 1990–1992. Migrates the 98 examples/*.ail fixtures from
(do io/print_int|bool|float x) to (app print x), deletes the three
per-type effect-op builtins + codegen arms + tests, and sweeps the
seven DESIGN.md sites that reference the retired ops.

§C4 decides the bench-latency-fixture carve-out question (option a:
migrate everything, ratify any latency-baseline drift as part of the
milestone; option b's carve-out would defeat the milestone's
premise).

Grounding-check PASS — all seven load-bearing assumptions
ratified by named workspace tests.
This commit is contained in:
2026-05-14 01:03:08 +02:00
parent e515093d9a
commit 68bab007c8
@@ -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 8090 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 19901992 ("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_<T> 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_<T>`
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_<T>_signature`
tests (lines ~593633).
- `crates/ailang-codegen/src/lib.rs::lower_app` — three arms
(lines 2302, 2324, 2373) and the `lowers_io_print_float` test
(lines 35803610).
- `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 <T>` 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_<T>` 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
`<decimal>\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_<T>`
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_<T> 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_<T>_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 25 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.