Files
AILang/docs/specs/0019-heap-str-abi.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

538 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Heap-`Str` ABI — Design Spec
**Date:** 2026-05-12 (amended same day after hs.2 empirical finding)
**Status:** Amended — awaiting re-dispatched Step 7.5 grounding-check
**Authors:** Brummel (orchestrator) + Claude
## Goal
Add a second realisation of `Str` values — `malloc`-backed,
reference-counted heap slabs — alongside the existing static
`@.str_*` globals, *without* enlarging the language surface.
From the LLM-author's view, `Str` remains a single type. The
two realisations share the **consumer ABI** (pointer-to-len-field,
`+8` for bytes), so `@puts` / `@ail_str_eq` / `@ail_str_compare` /
`@strcmp` work uniformly on either. The producer side differs
(static via constexpr GEP into a `.rodata` packed-struct global;
heap via `ailang_int_to_str` / `ailang_float_to_str`), and the RC
discipline applies only to heap-Str — static-Str pointers never
flow into `ailang_rc_inc` / `ailang_rc_dec` along any shipping
execution path. Codegen invariant, not runtime guard.
The milestone exists to unblock two builtins that are already
visible at the type level but currently broken at codegen:
- `float_to_str : (Float) -> Str` is type-installed today but
raises `CodegenError::Internal` on call (`crates/ailang-codegen/src/lib.rs:1829`)
because there is no runtime-allocated `Str` path.
- `int_to_str : (Int) -> Str` is a natural counterpart that is
not even type-installed today; its absence forces the LLM-author
into awkward Bool-based formatting workarounds whenever an Int
needs to flow into print.
Both ship together; both exercise the new heap-allocation path
identically (produce a fresh refcounted slab containing
runtime-formatted bytes).
Out of scope (each its own follow-up milestone or todo):
- `++` (Str concatenation operator). Would exercise the
*consume-and-produce* heap-Str path; the operator does not
exist in the language yet and needs its own feature-shape
decision (surface operator vs. builtin fn vs. typeclass
method).
- `Show` typeclass and `print`-rewire. Depends on this milestone
per the roadmap (P1 entry "Post-22 Prelude — Show + print
rewire").
- Length-aware `==`/`<` semantics that handle embedded `\0` bytes
correctly. The status quo (`@strcmp`/`@ail_str_eq`/
`@ail_str_compare`) carries forward unchanged.
## Feature-acceptance check
Per DESIGN.md §"Feature-acceptance criterion":
1. **An LLM author naturally produces code that uses it.** Yes.
`int_to_str(42)` is the canonical way to convert an integer
into a printable representation; `float_to_str` is already
announced in DESIGN.md as a Float builtin. Both surface in
real LLM-authored programs whenever a numeric value needs to
reach `io/print_str`.
2. **It measurably improves correctness or removes redundancy.**
Yes. Today `float_to_str` typechecks but fails codegen with a
structured internal error; this milestone makes it a working
path. `int_to_str` removes the redundant Bool-formatting
workaround needed today.
Neither criterion rests on ergonomic appeal. The milestone ships
infrastructure that turns two announced-but-broken builtins into
working ones.
## Architecture
The milestone adds work in three coordinated layers without
enlarging the type-surface.
### Runtime layer (`runtime/str.c`, `runtime/rc.c`)
Two new extern functions in `runtime/str.c`:
- `char *ailang_int_to_str(int64_t n)`
- `char *ailang_float_to_str(double x)`
Both format their input into a stack buffer, allocate a heap
slab of matching size via a private `static char *str_alloc(uint64_t len)`
helper, copy the bytes (plus terminating `NUL`), and return the
payload pointer.
`str_alloc` itself calls `ailang_rc_alloc(8 + len + 1)`, writes
the length into the first 8 bytes of the payload, and returns
the payload pointer. The caller fills bytes at offset 8 and the
`NUL` at offset `8 + len`.
`runtime/rc.c` is **not modified** in this milestone. The
runtime's RC primitives only ever see heap-Str pointers (real
`rc_header` at `payload - 8`, real refcount). Static-Str pointers
do not flow into `ailang_rc_inc` / `ailang_rc_dec` along any
shipping execution path — current codegen elides those calls
through two orthogonal optimisations (`emit_inlined_partial_drop`
move-tracking from iter 18d.3 + iter 18b non-escape lowering).
This is a codegen-level invariant; no runtime guard backs it up.
### Codegen layer (`crates/ailang-codegen/src/lib.rs`)
Four change-points.
1. **Static-Str globals** (`intern_string`, ~`crates/ailang-codegen/src/lib.rs:2487`):
emitted not as `[N+1 x i8] c"…\00"` but as a packed struct
`<{i64, [N+1 x i8]}>` with values `<{i64 N, c"…\00"}>`. The
first field is the explicit byte length (excluding the
terminating `NUL`); the array tail carries the bytes and the
`NUL`. The callsite pointer is `getelementptr` to the first
field of this struct (= where the `len` slot starts). The
pointer that flows through the IR as a `Str` value points at
the *len-field*; for heap-Str, the analogous pointer points at
the `len`-field of the malloc-backed payload (with the real
`rc_header` at `payload - 8`). The two layouts share the
*consumer ABI* (len at offset 0, bytes at offset 8) but
physically differ in what sits at `payload - 8` — heap-Str has
the rc_header there, static-Str has whatever the linker placed
before the global (which codegen guarantees is never read).
2. **`float_to_str` lowering** (~`crates/ailang-codegen/src/lib.rs:1829`):
the `CodegenError::Internal` fork is replaced by
`call ptr @ailang_float_to_str(double %a)`.
3. **`int_to_str` lowering** (new): parallel to `float_to_str`,
emits `call ptr @ailang_int_to_str(i64 %a)`.
4. **`drop_<m>_<T>` walks** (per-type drop fns) — no code change.
`Str` fields are dispatched today via `field_drop_call` at
`crates/ailang-codegen/src/drop.rs:372`, which routes
`Type::Con { name: "Str", .. }` to bare `ailang_rc_dec`. The
comment at that site justifies the routing on the (now stale)
assumption that *"Str payloads are NUL-terminated bytes in
static memory; nothing to recurse into"* — true for static-Str,
correct-by-coincidence for heap-Str (heap-Str's rc_header is
exactly the `rc_header` shape `ailang_rc_dec` expects).
Empirically the dispatch site is **dead code along all
shipping execution paths** due to two orthogonal codegen
optimisations: `emit_inlined_partial_drop` (iter 18d.3) marks
match-arm-extracted `Str` slots as "already moved" and skips
their decs; non-escape lowering (iter 18b) stack-allocates
ADTs that never leave their fn frame, eliminating the
per-type drop call entirely. We rely on these elisions as the
*codegen-level* invariant that static-Str payload pointers
never reach `ailang_rc_dec`. If either elision were lost, the
call would read undefined bytes at `payload - 8` (the static
global has no `rc_header` slot at that offset; the linker
places arbitrary bytes there) and likely segfault — a loud,
locatable failure mode the testing
discipline would catch immediately. No runtime guard backs up
the invariant; the codegen-level guarantee is the protection.
The existing extern declarations `@strcmp`, `@ail_str_eq`,
`@ail_str_compare`, `@puts`, `@printf` stay. They still expect a
`NUL`-terminated bytes pointer, which is *not* the same as the
payload pointer that flows through the IR (the payload pointer
points at the `len` field; bytes start 8 bytes later). The three
codegen sites that today call these C functions on a `Str` value
(`compare__Str`, `eq__Str`, `io/print_str` — at
`crates/ailang-codegen/src/lib.rs:2152`, `2262`, `2313`) emit a
`getelementptr i8, ptr %s, i64 8` immediately before the call to
land on the bytes pointer.
### Checker layer (`crates/ailang-check/src/builtins.rs`)
`int_to_str : (Int) -> Str` is added to the builtin signature
table next to the existing `float_to_str` entry
(`crates/ailang-check/src/builtins.rs:194`). The unit test
`install_int_to_str_signature` mirrors the existing
`install_float_to_str_signature` (line 445).
No mode annotations, no effects — like `int_to_float`, this is a
pure conversion.
### What this milestone does *not* change
- The `Type::Str` variant, the `Literal::Str` JSON encoding, the
Form-A / Form-B mapping, the prose serialisation.
- The uniqueness inferencer. `Str` is immutable; no Own/Bor
modes apply.
- The `@strcmp`-based comparison semantics. Embedded `\0` bytes
in a `Str` value continue to cause comparison to stop early;
this is the status quo, and the milestone explicitly inherits
it.
## Components
File-by-file inventory of the change surface.
### `runtime/str.c`
Add:
- `static char *str_alloc(uint64_t len)` — private helper.
Calls `ailang_rc_alloc(8 + len + 1)`, writes `len` at payload
offset 0, returns payload pointer.
- `char *ailang_int_to_str(int64_t n)` — extern. `snprintf` into
a 64-byte stack buffer with format `"%lld"`; defensively
asserts no truncation (the longest i64 is 20 chars, the buffer
is 64); calls `str_alloc` with the resulting length; memcpy's
bytes + writes the `NUL`; returns the payload pointer.
- `char *ailang_float_to_str(double x)` — extern. Same shape
with format `"%g"`. `%g` matches `io/print_float`'s existing
format for IEEE consistency; NaN/±Inf render via libc default.
### `runtime/rc.c`
Unchanged. The runtime's RC primitives only see heap-Str
pointers along shipping execution paths; static-Str pointers
are gated out at codegen via the elisions described in
§Architecture / Codegen layer item 4.
### `crates/ailang-codegen/src/lib.rs`
Modify:
- `intern_string` (~`:2487`) — switch the LLVM-IR emission of
static-Str globals from `[N+1 x i8] c"…\00"` to
`<{i64, [N+1 x i8]}> <{i64 N, c"…\00"}>` (single `i64` length
prefix; bytes + `NUL` in the array tail). The global name
stays the same; the way callsites materialise a pointer to it
changes to a `getelementptr` landing on the `len`-field
(= field index 0 of the struct).
- IR-header preamble (~`:455``:498`): add
`declare ptr @ailang_int_to_str(i64)` and
`declare ptr @ailang_float_to_str(double)`.
- `float_to_str` arm (~`:1829`): drop the `Err(CodegenError::Internal(…))`,
emit the call instead.
- New arm parallel to it for `int_to_str`.
- `compare__Str`, `eq__Str`, `io/print_str` codegen sites
(~`:2152`, `:2262`, `:2313`): emit a +8 `getelementptr` to
land on the bytes pointer before calling `@ail_str_compare`,
`@ail_str_eq`, `@puts` respectively.
- **Per-type drop walks are unchanged.** `Str` is already routed
to `ailang_rc_dec` by `field_drop_call`
(`crates/ailang-codegen/src/drop.rs:372`). The stale comment
at that site (*"NUL-terminated bytes in static memory; nothing
to recurse into"*) becomes incorrect once heap-Str ships and
must be updated to reflect the new dual-realisation reality
— but the dispatched symbol (`ailang_rc_dec`) is already
correct.
### `crates/ailang-check/src/builtins.rs`
Add `int_to_str` to the builtin signatures table (~`:194`,
mirroring `float_to_str`'s entry), and add the type-installation
unit test parallel to `install_float_to_str_signature`.
### `crates/ailang-codegen/src/synth.rs`
Add an `int_to_str` arm parallel to the `float_to_str` arm at
`:167` so the codegen's local type-replay knows the signature.
### `docs/DESIGN.md`
- §"Float semantics", last paragraph: remove the "codegen lowering
deferred" caveat on `float_to_str`.
- §"What is not (yet) supported" and the builtin inventory under
"What **is** supported": add `int_to_str : (Int) -> Str` next
to `float_to_str`.
- New top-level subsection "Str ABI" (or under an appropriate
Decision-10 / runtime-contracts anchor): document the heap-Str
slab layout `{rc_header, len, bytes…, NUL}` and the static-Str
global layout `<{len, bytes…, NUL}>` (no rc_header, since the
codegen-level elision invariant guarantees the runtime's RC
primitives never see static-Str pointers). Both layouts share
the consumer ABI "pointer-at-len-field, +8 for bytes". The
inherited limitation that `==` / `<` use `strcmp` semantics
and do not support embedded `\0` bytes is noted as a future-
milestone enhancement, not a regression.
## Data flow
Three representative IR cases pin the after-state.
**Static string literal.** Today, `"hello"` is emitted as:
```llvm
@.str_M_hello_0 = private unnamed_addr constant [6 x i8] c"hello\00"
%1 = call i32 @puts(ptr @.str_M_hello_0)
```
After the milestone:
```llvm
@.str_M_hello_0 = private unnamed_addr constant <{i64, [6 x i8]}>
<{ i64 5, [6 x i8] c"hello\00" }>
%payload = getelementptr inbounds <{i64, [6 x i8]}>,
ptr @.str_M_hello_0, i32 0, i32 0
%bytes = getelementptr inbounds i8, ptr %payload, i64 8
%1 = call i32 @puts(ptr %bytes)
```
`%payload` is the value that flows through the IR as a `Str`. The
`%bytes` adjustment is local to the `@puts` callsite.
**Heap-allocated string from `int_to_str(42)`.**
```llvm
%s = call ptr @ailang_int_to_str(i64 42)
; …print:
%bytes = getelementptr inbounds i8, ptr %s, i64 8
%1 = call i32 @puts(ptr %bytes)
; …at scope close:
call void @ailang_rc_dec(ptr %s)
```
`%s` is a fresh heap slab with `rc_header == 1`. `ailang_rc_dec`
accesses the header at `%s - 8`, decrements to zero, frees.
**`eq Str` with mixed-origin operands.**
```llvm
define i1 @eq__Str(ptr %a, ptr %b) {
%a_bytes = getelementptr inbounds i8, ptr %a, i64 8
%b_bytes = getelementptr inbounds i8, ptr %b, i64 8
%r = call zeroext i1 @ail_str_eq(ptr %a_bytes, ptr %b_bytes)
ret i1 %r
}
```
Either operand may be a static-Str pointer (into a `.rodata`
packed-struct global) or a heap-Str pointer (into a malloc'd
slab); the body does not distinguish, because the consumer ABI
(len at offset 0, bytes at offset 8) is identical and
`@ail_str_eq` only reads the bytes via `strcmp`.
**ADT drop with a `Str` field.** For `type Boxed = | Box(Str)`,
the per-type `drop_M_Boxed(ptr %cell)` body in
`crates/ailang-codegen/src/drop.rs` dispatches the `Str` field
through `field_drop_call` to `ailang_rc_dec`. Empirically the
emitted call is **dead code along all shipping execution paths**
due to two orthogonal codegen optimisations:
`emit_inlined_partial_drop` (iter 18d.3) marks match-arm-extracted
`Str` slots as moved-out and skips their decs, and non-escape
lowering (iter 18b) stack-allocates ADTs that never leave the
fn frame, eliminating the per-type drop call entirely. The
codegen never produces an execution path that hands a static-Str
pointer to `ailang_rc_dec`. We rely on this as a codegen-level
invariant — no runtime guard backs it up. If a future codegen
change ever loses one of the two elisions, the resulting
`ailang_rc_dec(static_str_ptr)` call would read undefined memory
at `payload - 8` (the static-Str global has no `rc_header` slot)
and likely segfault — a loud failure that the test suite would
flag immediately, leading the implementer of that codegen change
to inspect the dispatch site.
## Error handling
Three failure modes; most are inherited or structurally impossible.
**Out-of-memory at heap-Str allocation.** `ailang_rc_alloc`
already `abort()`s on OOM, matching Boehm. `str_alloc`,
`ailang_int_to_str`, `ailang_float_to_str` propagate. No new
recovery path.
**`snprintf` truncation in the runtime formatters.** The 64-byte
stack buffer is strictly larger than the worst-case width of
either `%lld` (longest i64 = 20 chars) or `%g` on a `double`
(~25 chars worst case). The runtime body asserts no truncation
defensively (the `snprintf` return tells us how many bytes
*would* have been written; an `abort()` fires if that exceeds
the buffer). The defensive check survives format-string changes
that might widen the output without anyone noticing the buffer
is too small.
**Embedded `\0` bytes in static-Str literals from JSON.** Status
quo: a literal containing `""` ends up with a `NUL` in the
middle of the slab; `strcmp`-based comparison treats it as the
end of string. This is the *current* behaviour of static-Str and
heap-Str inherits it unchanged. Heap-Str values produced by
`int_to_str` and `float_to_str` themselves cannot contain
embedded `\0` (neither `%lld` nor `%g` emits one), so the
milestone scope is not affected. DESIGN.md §"Str ABI" notes the
limitation explicitly with a forward pointer to a future
length-aware-compare milestone.
`float_to_str`'s `CodegenError::Internal` path is *removed*
entirely after the milestone — neither as a caught nor as an
uncaught error. The symbol-installation in the builtin tier and
the codegen lowering are landed in the same iteration; there is
no intermediate state where one exists without the other.
## Testing strategy
Three layers.
### IR-shape tests in the codegen crate
Pin the structural guarantees of §"Data flow" with substring
matches against emitted IR:
- `static_str_global_uses_packed_struct_with_len` — pins the
new slab format (single `i64` length prefix, bytes + `NUL` in
the array tail).
- `static_str_callsite_pointer_is_payload_not_slab` — pins the
`getelementptr`-to-second-field convention.
- `eq_str_calls_ail_str_eq_with_bytes_pointer`,
`compare_str_calls_ail_str_compare_with_bytes_pointer`,
`print_str_calls_puts_with_bytes_pointer` — pin the +8 GEP at
each existing C-API callsite.
- `int_to_str_lowers_to_ailang_int_to_str_call` — pins the new
builtin's lowering.
- `float_to_str_no_longer_errors_internal` — converts the
existing "lowering deferred"-pinning test into a positive
green test.
These tests are fast but sensitive to SSA-naming changes. The
existing test style in `crates/ailang-codegen/src/lib.rs` already
accepts this risk for analogous pins (e.g.
`eq_str_mono_symbol_emits_ail_str_eq_call`); the new tests match.
### Stdout E2E tests in `crates/ail/tests/e2e.rs`
Observable program behaviour:
- `int_to_str_smoke``int_to_str(42)` prints `"42\n"`; plus
edge cases `0`, `-1`, `i64::MAX`, `i64::MIN`.
- `float_to_str_smoke``float_to_str(3.5)` prints something
starting with `3.5`; plus `0.0`, `NaN`, `+Inf`, `-Inf` (pinned
against libc `%g` rendering — implementations are permitted to
print NaN as `nan`, `-nan`, or other casing; the test pins the
exact bytes observed on the dev target with a comment
acknowledging the libc-defined freedom).
- `static_str_print_unchanged` — an existing example
(`examples/hello.ail` or similar) produces byte-identical
stdout before and after the milestone, sourced from
pre-milestone `expected/`.
### RC-discipline E2E tests via `AILANG_RC_STATS`
Catch leaks and double-frees:
- `int_to_str_drop_balances_rc_stats``int_to_str` is called
N times in a loop, each result dropped without aliasing;
`AILANG_RC_STATS=1` reports `allocs=K frees=K live=0` for some
K matched to the loop bound + setup. Pins that heap-Str
participates in RC normally.
- `str_field_in_adt_drops_heap_str_correctly` — a value of
`type Boxed = | Box(Str)` is instantiated with a heap-Str
(from `int_to_str`) and dropped; `live=0`.
No `str_field_in_adt_drops_static_str_noop` test ships. An
earlier draft of this spec proposed it as a load-bearing safety
gate for a runtime sentinel short-circuit; empirically the
codegen elides the static-Str-field-drop path entirely via
move-tracking and non-escape lowering, so the test could not be
made non-vacuous against any natural fixture shape. The
elision-based invariant is documented in §Architecture /
Codegen layer item 4 and stands or falls with the elision
itself, not with a runtime test.
### Bench gates
At audit close (per `skills/audit/SKILL.md`):
- `bench/check.py` — existing latency baselines green, with the
known `latency.explicit_at_rc.*` nondeterminism tolerance
documented in recent audits.
- `bench/compile_check.py` — entire `examples/` corpus compiles.
- `bench/cross_lang.py` — byte-identical outputs vs. the C
reference corpus in `bench/reference/`. This is the strongest
"we did not break anything" gate because it compares output
against a second-language implementation, not against AILang
itself.
### Out of testing scope
Embedded-`\0` behaviour. Concurrent RC (AILang is single-
threaded). Allocator stress beyond representative program sizes.
## Acceptance criteria
The milestone closes when all the following are observable.
**Language surface.**
- `int_to_str : (Int) -> Str` is installed in
`crates/ailang-check/src/builtins.rs` and resolved by the
codegen builtin lookup.
- `float_to_str` no longer lowers to `CodegenError::Internal`;
calling it produces a working binary.
- DESIGN.md §"Float semantics" carries no "codegen lowering
deferred" caveat on `float_to_str`; the builtin inventory
under "What **is** supported" lists `int_to_str` alongside
`float_to_str`.
**Runtime / ABI.**
- `runtime/str.c` exports `ailang_int_to_str(int64_t) -> char*`
and `ailang_float_to_str(double) -> char*`. The private
`str_alloc(uint64_t)` is *not* in the export symbol set.
- `runtime/rc.c` is unchanged from pre-milestone.
- Static-Str globals are emitted as `<{i64, [N+1 x i8]}>` with
values `{i64 N, c"…\00"}`.
- DESIGN.md has a "Str ABI" anchor (or equivalent) documenting
the heap-Str and static-Str layouts, the shared consumer ABI
(len at offset 0, bytes at offset 8), the codegen-level
elision invariant for static-Str-in-RC-paths, and the
inherited `strcmp`-based comparison semantics.
**Observable program behaviour.**
- A trivial `.ail` program `do io/print_str(int_to_str(42))`
builds, runs, and prints `42`.
- The analogous `float_to_str(3.5)` program builds, runs, and
prints `%g`-conformant output (exact bytes pinned by the E2E
test).
- All existing `examples/` programs produce byte-identical
stdout vs. pre-milestone (no regressions).
**RC discipline.**
- The three new `AILANG_RC_STATS`-based E2E tests pass.
- No existing drop-relevant test breaks.
**Bench gates.**
- `bench/check.py`, `bench/compile_check.py`, `bench/cross_lang.py`
all green at audit close.
**Documentation / journal.**
- One journal entry per iteration with per-task notes.
- `WhatsNew.md` gets a milestone-close entry (verbatim with the
done-state Notify), written for the user-as-reader without
iteration codes or crate names.
**Explicitly *not* part of acceptance.**
- `++` / Str concatenation operator (own milestone).
- `Show` typeclass and `print` rewire (own milestone, depends-on
this milestone).
- Length-aware comparison semantics / embedded-`\0` support.