832375f2ac
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.
538 lines
22 KiB
Markdown
538 lines
22 KiB
Markdown
# 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 `" |