Files
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

12 KiB

hs.3 — Heap-Str runtime additions — Implementation Plan

Parent spec: docs/specs/0019-heap-str-abi.md

For agentic workers: REQUIRED SUB-SKILL: use skills/implement to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Add three new symbols to runtime/str.c: a private str_alloc(uint64_t len) helper that allocates a heap-Str slab via the existing ailang_rc_alloc and writes the length prefix; plus ailang_int_to_str(int64_t) and ailang_float_to_str(double), the two extern formatters that compose str_alloc with snprintf. No IR-side caller is wired in this iter — that lands in hs.4 together with the IR-header declare lines, the Literal::Call lowering, the checker install, and the build-pipeline change to link rc.c unconditionally. Hs.3 is the runtime-infrastructure foundation; the acceptance gate is that the workspace continues to build and that all existing regression sweeps stay green with the new symbols present but unreferenced.

Architecture: Single-file modification of runtime/str.c. New #include lines pick up <stdint.h> (for int64_t / uint64_t), <stdio.h> (for snprintf), and <stdlib.h> (for abort and the size_t shape of ailang_rc_alloc). A four-line extern block declares ailang_rc_alloc from runtime/rc.c (which already exposes it with default external linkage). Two new public extern functions and one new private static helper are appended after ail_str_compare. The Linker drops the new symbols at the unused-fn elision pass under clang -O2 for every program that does not yet call them — which is every program until hs.4 wires the codegen lowering. The runtime/str.c translation unit is unconditionally compiled and linked across all three --alloc strategies (see the build-pipeline notes from hs.3 recon), so the new symbols are present in every binary regardless of allocator choice.

Tech Stack: C99 (runtime/str.c). No Rust changes. No build- pipeline changes. No new tests; the existing workspace + bench sweeps verify that the runtime continues to compile and link.


Files this plan creates or modifies

  • Modify: runtime/str.c — append three new functions (one private static helper + two extern formatters) and the supporting #include / extern lines. Existing functions ail_str_eq (lines 24-26) and ail_str_compare (lines 35-40) stay unchanged.
  • Untouched: runtime/rc.cailang_rc_alloc is already exposed at runtime/rc.c:113 with default external linkage.
  • Untouched: runtime/bump.c — orthogonal allocator stub.
  • Untouched: crates/ail/src/main.rs:2271-2293 — the existing unconditional runtime/str.c clang-link step already covers the new symbols.
  • Untouched: crates/ailang-codegen/src/lib.rs — IR-side declarations and lowering land in hs.4.
  • Untouched: crates/ailang-check/src/builtins.rs — checker install lands in hs.4.

Task 1 — Append the three new symbols to runtime/str.c

Files:

  • Modify: runtime/str.c

  • Step 1: Extend the include block

Locate the existing include block at runtime/str.c:16-17. After the existing #include <stdbool.h> and #include <string.h> lines, append three more:

#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>

<stdint.h> provides int64_t/uint64_t. <stdio.h> provides snprintf. <stdlib.h> provides abort and the size_t shape used by the ailang_rc_alloc extern declaration that follows in Step 2.

  • Step 2: Add the ailang_rc_alloc extern declaration

Immediately below the include block (still before the existing ail_str_eq definition), add:

/* Defined in runtime/rc.c — exposed with default external linkage.
 * Returns a zero-initialised payload whose `rc_header` sits at
 * `payload - 8` and is initialised to refcount 1. str.c's heap-Str
 * helpers call this regardless of the program's --alloc strategy,
 * because heap-Str values are always refcounted (the global
 * `--alloc` flag governs ADT allocation, not Str allocation).
 */
extern void *ailang_rc_alloc(size_t size);

The body of the file remains unchanged from line 19 (the ail_str_eq doc-comment) through line 40 (the closing brace of ail_str_compare).

  • Step 3: Append the str_alloc private helper after ail_str_compare

After the closing brace of ail_str_compare (currently line 40), append:

/* Heap-Str slab allocator. Lays out:
 *
 *     [ rc_header (8B) | len (8B) | bytes... | NUL ]
 *     ^                ^
 *     payload-8        payload (returned)
 *
 * Calls `ailang_rc_alloc(8 + len + 1)`. The +8 is the len field;
 * the +1 is the trailing NUL byte that keeps `@strcmp` and `@puts`
 * happy under the shared consumer ABI. Writes `len` into the first
 * 8 bytes of the payload and returns the payload pointer; the
 * caller is responsible for filling bytes [8 .. 8 + len) and the
 * `NUL` byte at offset `8 + len`.
 *
 * Private (static linkage) — callers within this TU only.
 */
static char *str_alloc(uint64_t len) {
    char *payload = (char *)ailang_rc_alloc(8 + len + 1);
    *(uint64_t *)payload = len;
    return payload;
}

The (uint64_t *)payload cast aliases the first eight bytes of the payload as a single uint64_t length slot, matching the IR-side convention (<{ i64, [N+1 x i8] }> for static-Str; here heap-Str's analog of the same layout, with the rc-header sitting above the payload at payload - 8 instead of being baked into the global as the static-Str path does).

  • Step 4: Append ailang_int_to_str

Append immediately after str_alloc:

/* Convert an i64 into a heap-allocated Str. Format `"%lld"`. The
 * worst-case width is 20 chars (i64::MIN = "-9223372036854775808");
 * a 64-byte stack buffer is comfortably oversized. `snprintf`'s
 * return tells us how many bytes *would* have been written; if that
 * exceeds the buffer we abort() defensively — this should be
 * unreachable for any valid i64 input but survives format-string
 * widening if a future change ever swaps `%lld` for something more
 * verbose without re-sizing the buffer.
 *
 * Returns the payload pointer of a fresh heap-Str slab. Caller owns
 * the refcount (init to 1 by `ailang_rc_alloc`); standard RC
 * discipline (`ailang_rc_inc` / `ailang_rc_dec`) applies.
 */
char *ailang_int_to_str(int64_t n) {
    char buf[64];
    int written = snprintf(buf, sizeof(buf), "%lld", (long long)n);
    if (written < 0 || (size_t)written >= sizeof(buf)) {
        fprintf(stderr,
                "ailang_int_to_str: snprintf truncation/error "
                "(written=%d, buffer=%zu) — should be unreachable for any valid i64; aborting\n",
                written, sizeof(buf));
        abort();
    }
    uint64_t len = (uint64_t)written;
    char *payload = str_alloc(len);
    memcpy(payload + 8, buf, len);
    payload[8 + len] = '\0';
    return payload;
}

The (long long)n cast keeps the format specifier portable across platforms where int64_t may not be exactly long long. The + 8 offset on the memcpy lands at the bytes region (past the len-field). The terminating NUL at payload[8 + len] is what keeps @strcmp and @puts working through the shared consumer ABI.

  • Step 5: Append ailang_float_to_str

Append immediately after ailang_int_to_str:

/* Convert a double into a heap-allocated Str. Format `"%g"` —
 * matches `io/print_float`'s libc rendering for IEEE consistency.
 * NaN renders as libc-default (`nan`/`-nan`/etc.); ±Inf as `inf`
 * /`-inf`. Worst-case width for `%g` on a `double` is bounded by
 * the libc default precision (`%.6g` ⇒ at most ~13 chars including
 * sign and exponent); the 64-byte stack buffer is comfortably
 * oversized. Defensive `abort()` on truncation, same shape as
 * `ailang_int_to_str`.
 *
 * Returns the payload pointer of a fresh heap-Str slab.
 */
char *ailang_float_to_str(double x) {
    char buf[64];
    int written = snprintf(buf, sizeof(buf), "%g", x);
    if (written < 0 || (size_t)written >= sizeof(buf)) {
        fprintf(stderr,
                "ailang_float_to_str: snprintf truncation/error "
                "(written=%d, buffer=%zu) — should be unreachable for any finite double; aborting\n",
                written, sizeof(buf));
        abort();
    }
    uint64_t len = (uint64_t)written;
    char *payload = str_alloc(len);
    memcpy(payload + 8, buf, len);
    payload[8 + len] = '\0';
    return payload;
}

The body is structurally identical to ailang_int_to_str; only the format specifier and the input type differ. Keeping the two implementations parallel rather than collapsing into a generic template trades a small redundancy for very explicit per-builtin behaviour the future LLM-author can read top-to-bottom without indirection.

  • Step 6: Compile-check str.c standalone

Run:

clang -c -O2 -Wall -Werror -o /tmp/str_hs3.o runtime/str.c

Expected: exit code 0, no warnings. This verifies the new symbols parse and pass -Wall -Werror cleanly (signed/unsigned mismatches, undeclared functions, missing includes, format-string mismatches all caught here).

  • Step 7: Inspect the resulting symbol table

Run:

nm /tmp/str_hs3.o | grep -E "ail_str|str_alloc|ailang_int_to_str|ailang_float_to_str"

Expected output (order may vary):

0000000000000000 T ail_str_compare
0000000000000000 T ail_str_eq
0000000000000000 T ailang_float_to_str
0000000000000000 T ailang_int_to_str
                 U ailang_rc_alloc
0000000000000000 t str_alloc

Confirms: two existing public symbols (ail_str_eq, ail_str_compare) plus two new public symbols (ailang_int_to_str, ailang_float_to_str, both uppercase T), one new private symbol (str_alloc, lowercase t for static linkage), and one undefined reference (ailang_rc_alloc, U) that the link step will resolve against runtime/rc.c.


Task 2 — Regression sweep with the new symbols present but unreferenced

Files:

  • (none)

  • Step 1: Full workspace test sweep

Run:

cargo test --workspace

Expected: every test PASSes. The workspace tests link str.c unconditionally; clang -O2 will dead-strip the three new unreferenced symbols at link time, so no observable behaviour changes. Failure here means either: (a) str.c failed to compile for a reason Task 1 Step 6 missed (likely an interaction with the -Werror flag the ail build pipeline uses), or (b) the new symbols forced an unexpected link-time error (likely missing <stdint.h> / <stdio.h> / <stdlib.h> propagation, or unresolved ailang_rc_alloc reference if a test happens to link str.c without rc.c — examine the failing test's AllocStrategy setting).

  • Step 2: Cross-language stdout regression sweep

Run:

bench/cross_lang.py

Expected: every entry green (byte-identical stdout vs. the C reference corpus in bench/reference/). This is the strongest gate that no observable program behaviour changed.

  • Step 3: Workspace-compile regression sweep

Run:

bench/compile_check.py

Expected: every entry green (every program in examples/ still compiles end-to-end through ail check + codegen + clang link).

  • Step 4: Latency baselines check

Run:

bench/check.py

Expected: green within the known latency.explicit_at_rc.* nondeterminism tolerance documented in recent audits. The new symbols are dead-stripped from every binary; no path-length or allocation change is possible from hs.3 alone.


Acceptance for this iteration

  • runtime/str.c exports ailang_int_to_str(int64_t) -> char * and ailang_float_to_str(double) -> char * (verified via nm).
  • str_alloc(uint64_t) is static (not in the export symbol set; lowercase-t nm line).
  • The new functions call ailang_rc_alloc correctly (the U reference in nm).
  • cargo test --workspace green.
  • bench/cross_lang.py, bench/compile_check.py, bench/check.py all green.
  • No Rust, codegen, checker, or build-pipeline changes.
  • The runtime functions are present but unreferenced from any IR caller; hs.4 wires the codegen + checker + linker work that turns them into live builtins.