Files
AILang/docs/specs/0022-24-show-print.md
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

32 KiB
Raw Permalink Blame History

24 — Show + print rewire — Design Spec

Date: 2026-05-12 Status: Partial — iter 24.1 shipped 2026-05-12 @ f38bad8 (bool_to_str + str_clone runtime + codegen wiring); iters 24.2 and 24.3 deferred on 2026-05-13 pending the retirement of the MethodNameCollision workaround. The deferral was triggered by a spec-gap surfaced during 24.2 plan-recon: adding class Show to the prelude collides with the user-class Show declared by 14 test fixtures under examples/test_22b{1,2,3}_*.ail.json (plus hardcoded "Show" / "show" assertions in crates/ail/tests/typeclass_22b{2,3}.rs) through the workspace-global method-name-collision pre-pass at crates/ailang-core/src/workspace.rs:547. Three resolution paths were surfaced; the user picked "defer until MethodNameCollision retires" per the P2 roadmap entry "Module-qualified class names + type-driven method dispatch". A fresh brainstorm re-derives 24.2 + 24.3 from scratch against the post-retirement architecture; this document remains as historical context for that re-brainstorm. Authors: Brummel (orchestrator) + Claude

Goal

Close the prelude story opened by milestone 23 (Eq/Ord) by shipping:

  1. The Show typeclass with single method show : (a borrow) -> Str.
  2. Four primitive instances: Show Int, Show Bool, Show Str, Show Float.
  3. A polymorphic free fn print : forall a. Show a => (a borrow) -> () !IO that routes through show and io/print_str, replacing the ad-hoc per-type print idiom for new code.
  4. Two new runtime primitives that let Show Bool and Show Str honour the uniform (a borrow) -> Str Own signature without special-casing the codegen pipeline.

The milestone is the second and final half of the original "Post-22 Prelude" roadmap entry. The first half (Eq/Ord) shipped as milestone 23. The two halves were split because Show had a hard dependency on the heap-Str ABI (int_to_str / float_to_str had to allocate heap-Str slabs at runtime), which itself was a substantively separate milestone. That dependency closed in the heap-Str ABI milestone (hs.1hs.4 plus eob.1, ratified at audit-eob on 2026-05-12). int_to_str / float_to_str are now fully wired and RC-disciplined, removing the only barrier.

Existing per-type effect-ops io/print_int / io/print_bool / io/print_float STAY in this milestone. The redundancy between print x and io/print_int x for x : Int is ratified as transitional, mirroring the same call milestone 23 made for == vs. eq. Retiring the per-type effect-ops and migrating the example corpus (86 fixtures) is queued as a separate P2 follow-up; this milestone is the architecture-shipping milestone, not the migration milestone.

Architecture

Three artefacts, in dependency order.

1. Two new runtime primitives

runtime/str.c gains two functions paralleling the existing ailang_int_to_str and ailang_float_to_str:

  • ailang_bool_to_str(int1_t b) -> heap_str_ptr — returns a freshly allocated heap-Str slab containing either "true" or "false" (5 or 6 bytes inclusive of trailing NUL). The slab is allocated via ailang_rc_alloc with rc_header at payload - 8, exactly like int_to_str. Always heap-allocates; never returns a static-Str pointer. The allocation cost is paid every show True / show False call; the alternative (schema-if with two static-Str literals) is deferred as an open commitment because phi-of-static-Str through the drop-elision pipeline is not load-bearing-ratified today.
  • ailang_str_clone(const heap_str_ptr src) -> heap_str_ptr — reads i64 len from offset 0 of the source slab and allocates a fresh heap-Str slab of the same length, memcpy's len + 1 bytes (payload + trailing NUL) from source to destination, and returns the new slab pointer. Works uniformly on both static-Str and heap-Str inputs because the consumer ABI is identical (see DESIGN.md §"Str ABI"); only the len field at offset 0 plus the bytes plus the NUL are consulted, never the (absent for static-Str) rc_header of the source.

Both new symbols install in crates/ailang-check/src/builtins.rs alongside int_to_str / float_to_str:

  • bool_to_str : (Bool) -> Str with ret_mode: Own.
  • str_clone : (Str borrow) -> Str with ret_mode: Own.

Both lower in crates/ailang-codegen/src/lib.rs::Emitter::lower_app via new arms paralleling the existing int_to_str and float_to_str arms (extern declarations in the IR header preamble, direct call emission, Position::Consume for the result let-binder per eob.1's effect-op-Borrow / let-binder-Own discipline).

The is_static_callee whitelist in crates/ailang-codegen/src/synth.rs extends to include both new names.

2. Show typeclass + four primitive instances

examples/prelude.ail.json gains five new top-level defs:

// class
{ "kind": "class",
  "name": "Show",
  "param": "a",
  "methods": [
    { "name": "show",
      "type": {
        "k": "fn",
        "params": [{ "k": "var", "name": "a" }],
        "param_modes": ["borrow"],
        "ret": { "k": "con", "name": "Str" },
        "effects": []
      }
    }
  ]
}
// instance Show Int: body invokes int_to_str
// instance Show Bool: body invokes bool_to_str
// instance Show Str: body invokes str_clone
// instance Show Float: body invokes float_to_str

Each instance body is a single-application lambda \x -> <primitive_to_str> x. No codegen intercept (mirror of try_emit_primitive_instance_body from Eq/Ord) is required because the four primitives are themselves first-class lowered functions — unlike Eq/Bool/Str whose == bodies needed try_emit_primitive_instance_body to redirect to the appropriate icmp or @ail_str_eq lowering. The mono pass synthesises show__Int, show__Bool, show__Str, show__Float; each body is a direct call to the corresponding primitive.

Float is included in the Show prelude — unlike Eq/Ord which explicitly excluded Float because IEEE-754 makes structural equality and total ordering semantically dubious. Producing a textual representation of a Float is well-defined modulo the NaN spelling caveat in DESIGN.md §"Float semantics" (printf("%g", nan) may emit nan / -nan / NaN); this caveat is acceptable for a human-readable Show.

3. print polymorphic free fn

examples/prelude.ail.json also gains a sixth new top-level def:

{ "kind": "fn",
  "name": "print",
  "type": {
    "k": "forall",
    "vars": ["a"],
    "constraints": [{ "class": "Show", "type": { "k": "var", "name": "a" } }],
    "body": {
      "k": "fn",
      "params": [{ "k": "var", "name": "a" }],
      "param_modes": ["borrow"],
      "ret": { "k": "con", "name": "Unit" },
      "effects": ["IO"]
    }
  },
  "params": ["x"],
  "body": {
    "t": "let",
    "name": "s",
    "value": { "t": "app", "fn": { "t": "var", "name": "show" }, "args": [{ "t": "var", "name": "x" }] },
    "body": { "t": "do", "op": "io/print_str", "args": [{ "t": "var", "name": "s" }] }
  }
}

The let-binder for show x is explicit, not implicit. This is load-bearing for the heap-Str RC discipline: the Own heap-Str returned by show x (for Int/Bool/Str/Float, every instance allocates) needs a let-binder so that drop_symbol_for_binder fires the heap-Str drop at scope close — the Str carve-out path introduced in eob.1. Without the let-binder, the heap-Str slab from show x would not be RC-tracked at any binder and the slab would leak (the same failure mode the eob.1 RED tests pinned).

print is a free fn, not a class method. Two reasons:

  • The body is identical for every a : Show a — no per-instance variation. The per-instance dispatch happens inside show x, not in print's body. A class method with an identical body for every instance would be redundant.
  • It matches Haskell's Prelude convention (print :: Show a => a -> IO () is a free fn, not a class method) and the milestone 23 free-fn pattern (ne/lt/le/gt/ge).

The mono pass (crates/ailang-check/src/mono.rs, unified in 23.4) handles print exactly as it handles ne: free-fn entry, rigid-var substitution a → Int (or whichever concrete type), body walker sees the nested show call at concrete type, schedules show__Int in the same fixpoint round, the synthesised print__Int body references show__Int directly.

Components (iterations)

Iter Status Scope
24.1 upcoming Runtime + codegen for two new primitives. runtime/str.c gains ailang_bool_to_str and ailang_str_clone. crates/ailang-check/src/builtins.rs installs bool_to_str : (Bool) -> Str and str_clone : (Str borrow) -> Str, both with ret_mode: Own. crates/ailang-codegen/src/lib.rs::Emitter::lower_app gains two new arms emitting direct calls to @ailang_bool_to_str and @ailang_str_clone, plus extern declarations in the IR header preamble. crates/ailang-codegen/src/synth.rs::is_static_callee whitelist extends. RC-stats E2E tests pin allocs == 1, frees == 1, live == 0 for both primitives. IR-shape pins for the two extern declarations and the two call emissions.
24.2 upcoming class Show + 4 primitive instances in prelude. examples/prelude.ail.json gains class Show a where show : (a borrow) -> Str and four instance Show <T> defs (Int/Bool/Str/Float), each with a single-application lambda body invoking the corresponding *_to_str primitive. Round-trip test confirms prelude.ail.json parses + canonicalises bit-stable. Mono synthesis test confirms show__Int, show__Bool, show__Str, show__Float Def::Fn entries are produced. DESIGN.md §"Prelude (built-in) classes" amended to include Show with the four instances.
24.3 upcoming print polymorphic free fn + E2E + DESIGN.md sync. examples/prelude.ail.json gains fn print : forall a. Show a => (a borrow) -> () !IO with body \x -> let s = show x in do io/print_str s. E2E fixtures: (a) positive — do print 42, do print True, do print "hello", do print 3.14 produce expected stdout; (b) user-ADT — data IntBox = MkIntBox Int + instance Show IntBox where show x = match x with MkIntBox n -> int_to_str n + print (MkIntBox 7) produces stdout "7"; (c) negative — print f where f : Int -> Int fires NoInstance Show <fn-type> at typecheck with a Show-aware diagnostic. DESIGN.md §"Prelude (built-in) classes" amended to include print; §"Float semantics" gains a paragraph linking Show Float (via float_to_str) to the existing NaN-spelling caveat. Roadmap P1 "Post-22 Prelude" entry flips to [x]; new P2 entry added: "retire `io/print_int

The milestone closes with the standard audit pipeline (architect drift review + three bench scripts).

Data flow

Two trajectories show the routing at work.

print 42 at type Int

  1. Typecheck. print resolves to the polymorphic free fn with forall a. Show a => (a borrow) -> () !IO. Bare-name lookup reaches the prelude via env.module_globals fall-through (prep1 from 23.4-prep). Constraint Show Int discharged against the registry entry for instance Show Int.
  2. Mono (free-fn entry). Body taken directly from Def::Fn.body, rigid-var subst a → Int. The body walker detects the nested show x call at concrete Int and schedules show__Int in the same fixpoint round. show__Int synthesises from the instance Show Int body (class-method entry, Registry::entries[(Show, Int)] lookup). The synthesised show__Int body is \x -> int_to_str x. The synthesised print__Int body is \x -> let s = show__Int x in do io/print_str s.
  3. Codegen. print__Int lowers as any monomorphic fn. The nested show__Int x is a direct call returning Own heap-Str; the let-binder s is RC-tracked at scope close per the eob.1 Str carve-out in drop_symbol_for_binder. do io/print_str s walks s as Position::Borrow (eob.1's effect-op arg rule); the let-binder's drop fires after the effect-op call returns, emitting @ailang_rc_dec(s_ptr) which decrements the heap-Str's rc_header to zero and frees the slab. Stdout: "42\n".

print 3.14 at type Float

Identical to the above with a → Float, Show Float, show__Float, int_to_str replaced by float_to_str. The NaN-spelling caveat applies if the Float happens to be NaN: the rendered output follows libc's %g formatting, identical to io/print_float. Stdout for 3.14: depends on libc but typically "3.14\n"; for 0.0 / 0.0: typically "nan\n" or "-nan\n".

print x at user type IntBox with instance Show IntBox

  1. Typecheck. Show IntBox discharged against the registry entry for instance Show IntBox.
  2. Mono. print__IntBox synthesises with body \x -> let s = show__IntBox x in do io/print_str s. show__IntBox synthesises from the instance body (whatever the user wrote, e.g. \x -> match x with MkIntBox n -> int_to_str n).
  3. Codegen. Lowers normally; ADT-pattern-match plus inner int_to_str call produce a heap-Str; outer let-binder drops at scope close.

Error handling

  • NoInstance Show <T> — fired at typecheck when print is called on a type without a Show instance (most commonly a function type, or a user type the author forgot to give an instance for). Diagnostic must cross-reference DESIGN.md §"Prelude (built-in) classes" so the LLM-author immediately learns which types ship with built-in Show instances.

  • NoInstance Show <UserType> — re-uses the existing 22-era diagnostic path; no new code shape.

  • OrphanInstance Show <T> — Decision 11 coherence rule unchanged. The four primitive instances ship in the prelude (Show's defining module = Int/Bool/Str/Float's defining module = prelude module; non-orphan by construction). User-side instance Show <UserType> MUST live in the user type's defining module or in the prelude module (the latter is not possible for user types since the prelude is read-only), which constrains user instances to the user-type's defining module.

  • CheckError::Internal from mono — if Show-specialisation fails internally (e.g. the body walker fails to schedule show__T when synthesising print__T), this is a caller-contract violation per the existing convention in mono.rs::synthesise_mono_fn. Mirrors 23.4's free-fn-entry error handling.

Testing strategy

24.1 — runtime + codegen for bool_to_str + str_clone

  • Unit: each primitive lowers to expected IR. Two new IR-shape pins in crates/ailang-codegen/src/lib.rs::tests:

    • bool_to_str_emits_call_to_ailang_bool_to_str — fixture let s = bool_to_str true in do io/print_str s; assert IR body contains call ptr @ailang_bool_to_str(i1 1) and declare ptr @ailang_bool_to_str(i1).
    • str_clone_emits_call_to_ailang_str_clone — fixture let s = str_clone "hi" in do io/print_str s; assert IR body contains call ptr @ailang_str_clone(ptr ...) and declare ptr @ailang_str_clone(ptr).
  • E2E RC-stats (two tests in crates/ailang-codegen/tests/e2e.rs):

    • bool_to_str_drop_balances_rc_stats — fixture let s = bool_to_str true in do io/print_str s. Stdout "true\n". RC stats: allocs == 1, frees == 1, live == 0.
    • str_clone_drop_balances_rc_stats — fixture let s = str_clone "hello" in do io/print_str s. Stdout "hello\n". RC stats: allocs == 1, frees == 1, live == 0.
  • E2E stdout-smoke (two tests):

    • bool_to_str_emits_true_or_false — both branches: let s = bool_to_str true in do io/print_str s"true\n"; same with false"false\n".
    • str_clone_emits_input_bytesstr_clone "world" → stdout "world\n". Confirms memcpy semantics, not just the pointer.
  • Cross-realisation test for str_clone: clone a heap-Str (output of int_to_str 42) AND a static-Str (literal "abc"). Both produce heap-Str outputs with correct bytes. RC discipline green in both cases. Pins the "uniform consumer ABI" claim from DESIGN.md §"Str ABI".

  • Linker / build hygiene: cargo test --workspace green. The __attribute__((weak)) on ailang_rc_alloc extern (hs.3 pattern) is unchanged — rc.c is unconditionally linked post-eob.1, so the weak symbol is a documented no-op.

24.2 — class Show + 4 instances

  • Round-trip: examples/prelude.ail.json parses + canonicalises bit-stable after the four new defs land. The existing prelude hash-stability test from milestone 23 stays green (the prelude's pre-existing defs — Ordering, Eq, Ord, instances, ne/lt/le/gt/ge — are unaffected).

  • Mono synthesis (unit test in crates/ailang-check/src/mono.rs::tests): for a fixture that calls show 1, show True, show "x", and show 1.5, assert that the post-mono workspace contains show__Int, show__Bool, show__Str, show__Float as Def::Fn entries. Bodies are direct calls to the corresponding primitives.

  • Hash stability of existing mono symbols: eq__Int, compare__Str, and the four other class-method mono symbols from milestone 23 must produce identical bodies (and therefore identical hashes) under the unified pass after the Show defs are added. Pin test in the style of crates/ailang-core/src/hash.rs::ct4_migrated_fixtures_have_canonical_form_hashes.

  • Round-trip of new fixtures — every fixture authored under 24.2 passes Form-A parser + canonicaliser bit-stable.

24.3 — print polymorphic free fn + E2E

  • Positive E2E (examples/show_print_smoke.ail.json): a single fixture exercises all four primitives:

    fn main = do
      print 42       -- "42\n"
      print true     -- "true\n"
      print "hello"  -- "hello\n"
      print 3.14     -- "3.14\n" (libc-dependent)
    

    Test asserts compile, run, expected stdout (the Float line uses a non-NaN, non-inf value where libc rendering is stable across versions — 3.14's %g rendering is "3.14" on every libc this project targets).

  • User-ADT E2E (examples/show_user_adt.ail.json):

    data IntBox = MkIntBox Int
    instance Show IntBox where
      show = \x -> match x with MkIntBox n -> int_to_str n
    fn main = do print (MkIntBox 7)
    

    Stdout "7\n". Confirms the mono pass handles user-defined Show instances and that print composes with them.

  • Negative E2E (examples/show_no_instance.ail.json):

    fn main = do print id  -- id : forall a. a -> a, no Show instance
    

    Typecheck rejects with NoInstance Show <fn-type>. The exact diagnostic wording is named as an Open commitment below ("Negative-test diagnostic wording"); the gold-standard test pins the message verbatim after the implementer drafts it.

  • Mono symbol IR-shape pin: crates/ailang-check/src/mono.rs::tests asserts print__Int body desugars to \x -> let s = show__Int x in do io/print_str s post-mono. Confirms the let-binder is preserved (not optimised away — load-bearing for the heap-Str RC discipline).

  • Round-trip of new fixtures.

  • Bench regression check at milestone close: the three bench scripts exit 0 or audit-ratified per audit-skill convention.

Acceptance criteria

The milestone closes when:

  1. 24.1 has landed: runtime/str.c defines ailang_bool_to_str and ailang_str_clone. crates/ailang-check/src/builtins.rs installs both signatures with ret_mode: Own. crates/ailang-codegen/src/lib.rs::Emitter::lower_app emits direct calls plus extern declarations. RC-stats E2E pins balance.

  2. 24.2 has landed: examples/prelude.ail.json contains class Show a where show : (a borrow) -> Str and four primitive instances (Int/Bool/Str/Float). The mono pass synthesises show__Int, show__Bool, show__Str, show__Float. DESIGN.md §"Prelude (built-in) classes" amended to include Show with the four instances.

  3. 24.3 has landed: examples/prelude.ail.json contains fn print : forall a. Show a => (a borrow) -> () !IO with the explicit-let body shape. The mono pass synthesises print__Int, print__Bool, print__Str, print__Float for each call site at a primitive type, plus print__<UserType> for each user-instance call site. Positive / user-ADT / negative E2E green. DESIGN.md §"Prelude (built-in) classes" lists print as the polymorphic helper; §"Float semantics" gains the Show Float NaN-spelling cross-reference paragraph.

  4. Tests pass: cargo test --workspace green; existing milestone 23 prelude fixtures (ne/lt/le/gt/ge E2E) unchanged; existing typeclass fixtures (eq__Int, compare__Str, etc.) hash-stable.

  5. Bench regression ratified: bench/check.py && bench/compile_check.py && bench/cross_lang.py exit 0 or audit-ratified with journal entry.

  6. Roadmap update: P1 "Post-22 Prelude — Show + print rewire" entry flips to [x]. New P2 entry: "Retire io/print_int / io/print_bool / io/print_float effect-ops + migrate example corpus to print". A user runs the bulk text substitution do io/print_<T> xdo print x across examples/*.ail.json (86 files affected), and the per-type ops are deleted from builtins.rs / lib.rs codegen arms / runtime C glue.

Out of scope (deferred, with substantive rationale)

  • Retiring io/print_int / io/print_bool / io/print_float. Queued as P2 follow-up. The architecture-shipping milestone and the corpus-migration milestone are kept separate so the migration is one mechanical pass against a frozen architecture, not a moving target. Same call milestone 23 made for the == / eq, < / lt redundancy.

  • io/print_str retirement. Stays. Load-bearing inside print's body (do io/print_str s); cannot be retired without an alternate primitive output path. Also stays as a directly-callable surface for Str-typed values where the LLM-author wants to bypass Show (e.g. when the Str is already in hand and show__Str's str_clone-allocation is wasteful).

  • bool_to_str as schema-if. The body \x -> if x then "true" else "false" is schematically valid but requires phi-of-static-Str to flow cleanly through the drop-elision pipeline (DESIGN.md §"Str ABI" codegen-level static-Str elision). The phi case is not load-bearing-ratified today; the safe always-correct bool_to_str primitive avoids the question. Open commitment: if a future codegen iter ratifies phi-of-static-Str drop-elision, swap show__Bool body to the schema-if form and retire bool_to_str.

  • str_clone as rc_header bump. For heap-Str inputs, str_clone could rc_inc the existing slab instead of allocating a fresh copy. This requires the implementation to distinguish heap-Str from static-Str at runtime, which the codebase deliberately does NOT support post-hs.2 (the UINT64_MAX sentinel was dropped in favour of codegen-time elision). Open commitment for a future iter: if runtime tagging is reintroduced for a different reason, str_clone can become an rc_inc on heap-Str / no-op-with-fresh- alloc on static-Str hybrid. For now: always-allocate, always-copy.

  • Show Ordering instance. The prelude's Ordering ADT has three constructors (LT/EQ/GT). Shipping Show Ordering would add a user-friendly pretty-print but no concrete demand exists and the design-by-default in this milestone is "instances ship for types LLM authors actually print". User can declare their own instance Show Ordering if they want it.

  • Show for ADTs via deriving. No auto-derivation. The instance Show <UserType> body is hand-written per instance. Auto-derivation is a substantive feature in its own right (canonical-form choices, recursive descent, list / tuple Show bodies) and out of scope here.

  • Higher-kinded Show (Show [a], Show (Maybe a)). Decision 11 axis 5 explicitly excludes higher-kinded class params. The LLM-natural pattern is per-type instances (Show [Int] as a monomorphic instance, hand-written). Same constraint as Eq/Ord.

  • print with newline-free variant (putStr / print_no_newline). io/print_str calls @puts which appends a newline. Every print x therefore ends in \n. A newline-suppressed variant is a separate feature; deferred until a concrete LLM-author pattern demands it.

  • Operator routing through Show (show invoked as << or similar). No commitment. AILang does not have a Haskell-style monadic operator surface and adding one to invoke show implicitly would re-open the ergonomics question Decision 6 closed in favour of explicit do blocks.

Open commitments (24.1 / 24.2 / 24.3 implementer)

  • bool_to_str runtime body. The 4-byte slab "true" and 5-byte slab "false" can either be heap-allocated per call (current spec) or cached as module-level static slabs the function returns by-pointer. The cached variant is faster but re-introduces the same "static-Str-without-sentinel" question the project deliberately avoided. Implementer ships the always-heap-allocate variant unless a clear path through the drop-elision pipeline appears.

  • str_clone byte count. The naive memcpy copies len + 1 bytes (payload + trailing NUL). The implementation may choose to recompute the NUL via strncpy-style code instead of trusting the trailing NUL in the source; either is correct, both are acceptable as long as the result slab is valid UTF-8-or-bytes per the source's contract.

  • Negative-test diagnostic wording. Implementer drafts the one-line Show-aware message for NoInstance Show <T>; orchestrator reviews in spec-compliance phase.

  • Implicit-let vs. explicit-let in print's body. The spec prescribes the explicit-let form \x -> let s = show x in do io/print_str s. If the AILang desugarer would automatically introduce a let-binder for any Term::App in effect-op-arg position, the explicit-let could be dropped from the source form. Implementer verifies the desugarer's behaviour during 24.3 Task 1; if implicit-let is the realised semantics, the prelude source form may be the more compact \x -> do io/print_str (show x). The load-bearing property is "the heap-Str returned by show x has a tracked let-binder when entering the effect-op call site"; whether the let-binder is sourced from the prelude form or desugar-introduced is a surface-level choice with identical semantics post-checker.

  • Show instance for the Unit type / Tuples / Lists. Out of scope per "instances for types LLM authors actually print" — but if 24.3's user-ADT E2E surfaces a recurring shape (e.g. printing a Tuple inside the IntBox test), the implementer may queue a P3 idea entry without expanding the milestone scope.

Known costs

  • 24.1 runtime + codegen: estimated +30 LOC in runtime/str.c (two new functions, ~15 LOC each), +20 LOC in crates/ailang-check/src/builtins.rs (two new signature installations parallel to the existing int_to_str / float_to_str blocks), +30 LOC in crates/ailang-codegen/src/lib.rs (two new arms in lower_app

    • two extern declarations in the IR header preamble), +2 LOC in crates/ailang-codegen/src/synth.rs::is_static_callee.
  • 24.2 prelude additions: examples/prelude.ail.json +4050 lines (one class + four instances, JSON shape parallel to Eq Int/Bool/Str). DESIGN.md amendment ~10 lines.

  • 24.3 prelude additions + tests: examples/prelude.ail.json +30 lines (one polymorphic free fn with constraint signature and let-bound body). Three new E2E .ail.json fixtures, ~40 lines JSON each. Diagnostic test ~20 lines Rust. DESIGN.md amendments ~20 lines (Prelude classes + Float semantics cross-reference).

  • Net code reduction: none. This milestone is additive. Reduction comes later in the P2 follow-up (retiring three builtin effect-ops + three codegen arms + three runtime glue functions, estimated ~150 LOC).

  • Compile-time shift: the mono pass gains four new instance-method synthesis targets (show__Int|Bool|Str|Float) plus one new free-fn target (print) per call site. The shift is bounded — additive per call site, no new fixpoint depth — and is expected to be within bench tolerance. Quantified by bench/compile_check.py post-24.3; audit-ratified or rebaselined.

  • Runtime cost on print x:

    • For x : Int — one int_to_str allocation (~320 bytes per Int depending on magnitude) plus one rc_dec at scope close.
    • For x : Bool — one bool_to_str allocation (5 or 6 bytes) plus one rc_dec.
    • For x : Str — one str_clone allocation + memcpy plus one rc_dec. Strictly worse than do io/print_str x. The redundancy is acknowledged; LLM-author has the recourse to bypass print for Str-typed values.
    • For x : Float — one float_to_str allocation plus one rc_dec.

Load-bearing assumptions about current behaviour

Surfaced explicitly so the Step 7.5 grounding-check agent has a canonical list to ratify. Each assumption is something this spec relies on as currently-true.

  1. crates/ailang-check/src/mono.rs::monomorphise_workspace is the single specialiser for both class methods and polymorphic free fns (per milestone 23.4 unification), and it handles transitive specialisation across nested calls in one fixpoint loop.

  2. int_to_str : (Int) -> Str and float_to_str : (Float) -> Str are installed in builtins.rs with ret_mode: Own, lowered in Emitter::lower_app via direct extern calls to @ailang_int_to_str / @ailang_float_to_str, and RC-disciplined end-to-end (allocs == frees, live == 0) per eob.1.

  3. io/print_str lowers to @puts(ptr) and accepts any Str realisation (static-Str at @.str_* LLVM globals OR heap-Str allocated by int_to_str / float_to_str / the new primitives) via a consumer ABI of i64 len at offset 0 followed by the bytes plus a trailing NUL (DESIGN.md §"Str ABI").

  4. Effect-op args walk in Position::Borrow per eob.1 (uniqueness.rs:289 + linearity.rs's effect-op-arg arm); the let-binder for the Own heap-Str returned by show x fires its drop at scope close.

  5. drop_symbol_for_binder has a Str carve-out (eob.1) that emits the correct rc-dec call for a Str let-binder regardless of whether the underlying realisation is heap-Str or static-Str (codegen-time elision ensures static-Str pointers do not reach ailang_rc_dec).

  6. Type::Forall with constraints round-trips bit-stable through the Form-A parser + canonicaliser + printer (22b.4a.4.5 work, ratified by milestone 23's prelude fixtures ne/lt/le/etc. which all carry Eq or Ord constraints).

  7. The Form-A ClassDef and InstanceDef schema shapes (DESIGN.md line 15121547) round-trip; the existing class Eq / class Ord defs in examples/prelude.ail.json are the load-bearing proof.

  8. ailang_rc_alloc(size_t) is the heap-Str slab allocator, unconditionally linked post-eob.1 (the __attribute__((weak)) on str.c's extern declaration is a documented no-op).

  9. The mono pass's body walker (mono.rs::collect_mono_targets) schedules targets for both class-method residuals AND polymorphic free-fn calls with fully-concrete substitutions, in one pass, transitively (the cmp_max_smoke proof from 23.4).

  10. NoInstance diagnostics already cross-reference the responsible class (per 22 milestone error-handling) and accept additional cross-references via the existing diagnostic-message infrastructure; adding a Show-aware variant is a content edit, not an infrastructure extension.

  11. The canonical-form layer (per docs/specs/0007-canonical-type-names.md) treats mono Defs as post-typecheck artefacts not in CheckedModule.symbols, so adding show__T and print__T symbols does not affect surface-level hash pins. The four primitive instance type names (Int/Bool/Str/Float) are canonical (Decision 11's mono-symbol naming uses surface name for primitives).

  12. examples/prelude.ail.json is the auto-loaded prelude per 23.1's check_workspace extension; new defs added to it are visible to every workspace without an explicit import.