All 176 files in the four accumulating directories now use a zero-padded 4-digit counter prefix that reflects creation order (`NNNN-slug.md`). The counter is assigned per directory in strict git-log creation order; ties broken alphabetically by original name. The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is dropped — the date is recoverable from git log and the counter carries the ordering. A file's counter is stable for the life of the file: never reassigned, never reused, never compacted. Deleted files retire their counter; subsequent files do not fill the gap. This is the property that lets cross-references stay literal — refs use the full filename including the counter (`design/contracts/0007-honesty-rule.md`) so they grep cleanly and resolve directly without a glob step. 313 cross-references updated across .md/.rs/.toml/.c/.json files (test pins, include_str! paths, design-INDEX entries, baseline notes, runtime C comments, inter-contract markdown links incl. bare basename and `../models/foo.md` forms). CLAUDE.md gets a new "File-naming convention" section spelling out the rule and rationale. skills/brainstorm/SKILL.md and skills/planner/SKILL.md updated so new spec/plan creation produces counter-prefixed names from the start. The full test suite (cargo test --workspace) passes.
14 KiB
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 byprintand by examples that already hold aStrin hand.print : forall a. Show a => (borrow a) -> Unit !IO— the canonical user-facing print, polymorphic over theShowclass.
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:
- Roadmap entry P2.
docs/roadmap.mdlines 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. - DESIGN.md §"Polymorphic print" already names the deprecation.
Lines 1990–1992 ("Routing through
printreplaces the ad-hocio/print_int|bool|floatidiom for new code; retiring the per-type effect-ops is queued as a P2 follow-up.") This milestone closes that note out. - Feature-acceptance criterion. An LLM-author with access to
printwill reach for it overio/print_intunprompted (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 indocs/DESIGN.md§"Feature-acceptance criterion" is satisfied for the removal: the ops carry no unique payload — every call site has a cleanprintrewrite.
§C — Architecture
§C1 Migration shape
For every fixture examples/*.ail with (do io/print_<T> x) in
its body:
- (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), threedescribeentries (lines ~349, ~350, ~352), and their threeinstall_io_print_<T>_signaturetests (lines ~593–633).crates/ailang-codegen/src/lib.rs::lower_app— three arms (lines 2302, 2324, 2373) and thelowers_io_print_floattest (lines 3580–3610).runtime/— no symbols to delete. Codegen lowers per-type print ops inline toprintfIR; no runtime C glue exists for them. (runtime/str.c:118is a comment referencingio/print_float's%gsemantics for documentation purposes; the comment is rewritten to point atfloat_to_strinstead.)
§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 |
| 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 |
§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 theio/print_intbuiltin 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_strsurvives unchanged. It is used internally byprint(the let-binder(let s (app show x) (do io/print_str s))in the prelude body) and directly by fixtures that already hold aStrin hand (28 sites). Renaming is out of scope — it remains the canonical Str byte-channel.printitself is unchanged. This milestone consumes the preludeprintthat 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 forio/print_<T>use; none have any (they exist to test parse-rejection, not runtime behaviour). No interaction.
§D — Components
§D1 Fixture migration
- 98
examples/*.ailfiles referenceio/print_int|bool|float(verified viagrep -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
.ailstill parses and round-trips throughail 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_intat check time. This is the explicit signal that the migration is complete. The iter's finalcargo test --workspaceruns 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 staleio/print_intreference 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.rsreturns 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_apphas no arm for any of the three op names.cargo test --workspacegreen (regression count: 0).cargo clippy --workspace --all-targetsgreen (0 warnings — milestone clippy-sweep baseline holds).cargo doc --workspace --no-depsgreen (0 warnings).bench/check.pyexit 0.bench/compile_check.pyexit 0.bench/cross_lang.pyeither exit 0 OR ratified (--update-baselineran 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:
- RED test — author
crates/ailang-check/tests/no_per_type_print_ops.rsasserting the three ops are absent from the builtin registry. Verify it fails RED. - Corpus migration — bulk substitute
(do io/print_<T> x)→(app print x)across all 98examples/*.ailfiles.cargo test --workspacegreen after this step (the builtins still register; the new fixtures just stop using them). - Builtin deletion — delete the three registration entries,
three describe entries, and three
install_io_print_<T>_signaturetests fromcrates/ailang-check/src/builtins.rs. RED test from step 1 goes GREEN. - Codegen deletion — delete three
lower_apparms and thelowers_io_print_floattest fromcrates/ailang-codegen/src/lib.rs.cargo test --workspacegreen. - DESIGN.md sweep — seven edits per §C3.
- Bench run —
bench/check.py && bench/compile_check.py && bench/cross_lang.py. Ratify if needed. - 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.