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:
@@ -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_<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.
|
||||
Reference in New Issue
Block a user