Files
AILang/docs/specs/0027-retire-per-type-print-effects.md
T
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
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.
2026-05-28 13:31:31 +02:00

14 KiB
Raw Blame History

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:

- (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
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 : Floatshow xfloat_to_str xio/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 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 runbench/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.