# 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_<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 ~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 <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 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.
