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

18 KiB

hs.2 — Static-Str sentinel-slot retrofit — 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: Retrofit hs.1's emitted static-Str LLVM globals from the sentinel-rc-header layout <{i64, i64, [N x i8]}> <{i64 -1, i64 N, c"…\0"}> to the amended-spec layout <{i64, [N x i8]}> <{i64 N, c"…\0"}>. The sentinel rc-header slot is removed; the explicit len field becomes the first (and only non-byte) field of the packed struct, on which the IR-Str pointer lands. The +8 consumer-side GEPs at the four C-API callsites do not change.

Architecture: Three format-string sites in crates/ailang-codegen/src/lib.rs (one global emission, two Literal::Str callsite GEPs) flip from the 3-field struct shape to the 2-field shape and from GEP-index (0,1) to (0,0). Two IR-shape tests in the same crate's test module update their asserts (one also renames). The crates/ail/tests/snapshots/hello.ll snapshot regenerates via UPDATE_SNAPSHOTS=1. No runtime changes, no checker changes, no new fixtures.

Tech Stack: crates/ailang-codegen (Rust, LLVM-IR string emission), crates/ail (snapshot test harness). Pure Rust; no C runtime touched.

Recon note: Recon agent bypassed per CLAUDE.md "When NOT to delegate" — the change surface is five string-literal sites in one file plus one snapshot file, and all five sites were located and quoted verbatim from disk during plan-writing.


Files this plan creates or modifies

  • Modify: crates/ailang-codegen/src/lib.rs:462-475 — global-emission loop (the all_str_literals aggregation that writes @<name> = private unnamed_addr constant <{ i64, i64, [...] }> ...)
  • Modify: crates/ailang-codegen/src/lib.rs:924-937 — first Literal::Str arm in emit_const_def, constexpr-GEP format string
  • Modify: crates/ailang-codegen/src/lib.rs:1246-1259 — second Literal::Str arm in lower_term, constexpr-GEP format string
  • Modify: crates/ailang-codegen/src/lib.rs:561-565 — doc-comment on the str_literals field describing the layout (mentions UINT64_MAX sentinel rc-header; that detail must go)
  • Modify: crates/ailang-codegen/src/lib.rs:2596-2600 — doc-comment on intern_str_literal describing the layout (same correction)
  • Modify: crates/ailang-codegen/src/lib.rs:3808-3849 — IR-shape test static_str_global_uses_packed_struct_with_sentinel_and_len: rename
    • doc-comment update + assertion-substring update
  • Modify: crates/ailang-codegen/src/lib.rs:3851-3885 — IR-shape test static_str_callsite_pointer_is_payload_via_constexpr_gep: doc-comment update + assertion-substring update
  • Modify: crates/ail/tests/snapshots/hello.ll — regenerate via UPDATE_SNAPSHOTS=1; expected bytes flip from sentinel-prefixed to len-prefixed shape
  • Untouched: the four +8 GEP pinning tests (print_str_calls_puts_with_bytes_pointer, eq_str_calls_ail_str_eq_with_bytes_pointer, compare_str_calls_ail_str_compare_with_bytes_pointer, lower_eq_str_calls_strcmp_with_bytes_pointer) — consumer ABI is invariant across the layout switch
  • Untouched: any runtime/*.c — no runtime change
  • Untouched: any test fixture under examples/ — observable program output is invariant

Task 1 — Update IR-shape tests to the amended-spec layout (RED)

Files:

  • Modify: crates/ailang-codegen/src/lib.rs:3808-3885

  • Step 1: Rename and rewrite assert in static_str_global_uses_packed_struct_with_sentinel_and_len

Locate the test at crates/ailang-codegen/src/lib.rs:3816. Replace the test function name, its doc-comment immediately above (lines 3810-3814), and the inner assert!-substring. After the edit the test reads:

/// Iter hs.2: language `Str` literals emit as packed-struct globals
/// carrying an explicit `len` field followed by the bytes + trailing
/// NUL. (The hs.1-era sentinel rc-header slot was removed per the
/// amended spec; static-Str pointers are kept out of `ailang_rc_dec`
/// at the codegen level via move-tracking and non-escape lowering,
/// so no header slot is needed.) This test pins the layout shape
/// against a tiny single-`Literal::Str` fixture.
#[test]
fn static_str_global_uses_packed_struct_with_len() {
    let m = Module {
        schema: SCHEMA.into(),
        name: "t".into(),
        imports: vec![],
        defs: vec![
            Def::Const(ConstDef {
                name: "greeting".into(),
                ty: Type::str_(),
                value: Term::Lit { lit: Literal::Str { value: "hello".into() } },
                doc: None,
            }),
            Def::Fn(FnDef {
                name: "main".into(),
                ty: Type::Fn {
                    params: vec![],
                    ret: Box::new(Type::unit()),
                    effects: vec![],
                    param_modes: vec![],
                    ret_mode: ParamMode::Implicit,
                },
                params: vec![],
                body: Term::Lit { lit: Literal::Unit },
                suppress: vec![],
                doc: None,
            }),
        ],
    };
    let ir = emit_ir(&m).unwrap();
    assert!(
        ir.contains(r#"<{ i64, [6 x i8] }> <{ i64 5, [6 x i8] c"hello\00" }>"#),
        "expected packed-struct global with len + bytes + NUL; ir was:\n{ir}"
    );
}

The two changes versus hs.1: (a) function name drops the _with_sentinel_and_len qualifier and gains _with_len; (b) the assert-substring drops the leading i64, from the struct type and the leading i64 -1, from the initialiser.

  • Step 2: Update assert in static_str_callsite_pointer_is_payload_via_constexpr_gep

Locate the test at crates/ailang-codegen/src/lib.rs:3856. The function name stays — "payload" remains correct since the IR-Str pointer still lands on the len-field slot, which is the payload start. Replace its doc-comment (lines 3851-3854) and the inner assert!-substring:

/// Iter hs.2: a `Literal::Str` at a callsite materialises a constexpr
/// `getelementptr` landing on the `len`-field of the packed-struct
/// global (now the *first* field, since the hs.1-era sentinel
/// rc-header slot was removed per the amended spec). This pins the
/// IR-Str-pointer convention used uniformly by all consumers.
#[test]
fn static_str_callsite_pointer_is_payload_via_constexpr_gep() {
    // ... body unchanged ...
    assert!(
        ir.contains(r#"getelementptr inbounds (<{ i64, [3 x i8] }>, ptr @.str_t_str_0, i32 0, i32 0)"#),
        "expected constexpr-GEP-to-len-field at callsite; ir was:\n{ir}"
    );
}

The two changes versus hs.1: (a) struct type drops the leading i64, ; (b) GEP indices change from i32 0, i32 1 to i32 0, i32 0.

  • Step 3: Run both updated tests and confirm they fail RED

Run:

cargo test -p ailang-codegen --lib static_str_

Expected: both tests FAIL with assertion-mismatch messages — the emitted IR still carries the hs.1 sentinel-prefixed shape; the new asserts demand the hs.2 len-only shape. The failure messages will print the actual IR so the substring mismatch is visible.

  • Step 4: Confirm the four +8 GEP tests still pass under hs.1 emission

Run:

cargo test -p ailang-codegen --lib _calls_

Expected: print_str_calls_puts_with_bytes_pointer, eq_str_calls_ail_str_eq_with_bytes_pointer, compare_str_calls_ail_str_compare_with_bytes_pointer, lower_eq_str_calls_strcmp_with_bytes_pointer all PASS. These tests assert only on the i64 8 byte offset and the consumed-symbol name; they do not depend on the struct shape and must remain green.


Task 2 — Switch emission and callsite GEPs to the amended-spec layout (GREEN)

Files:

  • Modify: crates/ailang-codegen/src/lib.rs:462-475

  • Modify: crates/ailang-codegen/src/lib.rs:924-937

  • Modify: crates/ailang-codegen/src/lib.rs:1246-1259

  • Modify: crates/ailang-codegen/src/lib.rs:561-565

  • Modify: crates/ailang-codegen/src/lib.rs:2596-2600

  • Step 1: Switch the global-emission loop (single source of the i64 -1 sentinel literal)

Locate the loop at crates/ailang-codegen/src/lib.rs:466-475. Replace the comment (lines 462-465) and the format string (line 472):

    // Iter hs.2: packed-struct globals for language `Str` literals.
    // First `i64` is the byte length (excluding the trailing NUL); the
    // `[N+1 x i8]` carries the bytes followed by the terminating NUL.
    // The IR-`Str` pointer that flows through the rest of codegen lands
    // on the `len`-field via constexpr-GEP at the `Literal::Str` arms
    // (see emit_const_def / lower_term). Static-Str pointers are kept
    // out of `ailang_rc_dec` by codegen-level elision (move-tracking
    // from iter 18d.3 + non-escape lowering from iter 18b), so no
    // sentinel rc-header slot is needed at the global.
    for entries in all_str_literals.values() {
        for (name, content) in entries {
            let escaped = ll_string_literal(content);
            let total = c_byte_len(content); // bytes + NUL
            let bytes_len = total - 1; // bytes only
            out.push_str(&format!(
                "@{name} = private unnamed_addr constant <{{ i64, [{total} x i8] }}> <{{ i64 {bytes_len}, [{total} x i8] c\"{escaped}\" }}>, align 8\n",
            ));
            emitted_global = true;
        }
    }

Diff versus hs.1: comment loses the i64 -1/sentinel paragraph and gains the codegen-elision justification; format string loses the leading i64, from the struct type and the leading i64 -1, from the initialiser.

  • Step 2: Switch the first Literal::Str callsite GEP (in emit_const_def)

Locate the arm at crates/ailang-codegen/src/lib.rs:924-937. Replace the comment and the format string:

            Literal::Str { value } => {
                // Iter hs.2: emit a packed-struct global and return a
                // constexpr-GEP pointer landing on the `len`-field (now
                // the first field of the packed struct, since the
                // hs.1-era sentinel rc-header slot was removed). Every
                // IR-Str pointer in this codegen pipeline has the
                // shape len at offset 0, bytes at offset 8.
                let g = self.intern_str_literal("str", value);
                let total = c_byte_len(value); // bytes + NUL
                (
                    "ptr".to_string(),
                    format!(
                        "getelementptr inbounds (<{{ i64, [{total} x i8] }}>, ptr @{g}, i32 0, i32 0)",
                    ),
                )
            }

Diff versus hs.1: comment drops the "header at -8" phrase; struct type in the GEP loses the leading i64, ; GEP indices change from i32 0, i32 1 to i32 0, i32 0.

  • Step 3: Switch the second Literal::Str callsite GEP (in lower_term)

Locate the arm at crates/ailang-codegen/src/lib.rs:1246-1259. Replace the comment and the format string (the two arms are intentionally parallel; the edits are symmetric):

                Literal::Str { value } => {
                    // Iter hs.2: language `Str` literals materialise as
                    // a constexpr-GEP into the packed-struct global,
                    // landing on the `len`-field (now the first field,
                    // since the hs.1-era sentinel rc-header slot was
                    // removed). IR-Str pointer carries len at 0, bytes
                    // at +8.
                    let g = self.intern_str_literal("str", value);
                    let total = c_byte_len(value); // bytes + NUL
                    (
                        format!(
                            "getelementptr inbounds (<{{ i64, [{total} x i8] }}>, ptr @{g}, i32 0, i32 0)",
                        ),
                        "ptr".into(),
                    )
                }

Same diff shape as Step 2.

  • Step 4: Update the two doc-comments that describe the layout

Two doc-comments still describe the hs.1 layout. Update both:

crates/ailang-codegen/src/lib.rs:561-565 (on the str_literals field):

    /// Iter hs.1 (amended hs.2): language `Str` literals interned as
    /// packed-struct globals (`<{ i64, [N+1 x i8] }>`) carrying an
    /// explicit `len` field and the bytes + trailing NUL. Parallel to
    /// `strings` (which still serves runtime-internal format strings

crates/ailang-codegen/src/lib.rs:2596-2600 (on intern_str_literal):

    /// Iter hs.1 (amended hs.2): parallel to `intern_string`, but for
    /// language `Str` literals emitted as packed-struct globals
    /// (len + bytes + NUL). Shares the same monotonic `str_counter`
    /// so the produced global names remain alphabetically orderable
    /// alongside format-string globals.

Both edits remove only the "sentinel + " phrase and (in the second case) the "UINT64_MAX sentinel rc-header" phrase; the structural description is otherwise unchanged.

  • Step 5: Run both updated tests and confirm they pass GREEN

Run:

cargo test -p ailang-codegen --lib static_str_

Expected: both static_str_global_uses_packed_struct_with_len and static_str_callsite_pointer_is_payload_via_constexpr_gep PASS.

  • Step 6: Re-run the four +8 GEP tests and confirm they still pass

Run:

cargo test -p ailang-codegen --lib _calls_

Expected: all four _calls_*_with_bytes_pointer tests PASS — the +8 offset is invariant across the layout switch (the bytes still sit 8 bytes past the len-field; the only thing the layout-switch moved is where the IR-Str pointer was previously landing, and the IR-Str pointer still lands on the len-field — just at struct-index 0 instead of 1).

  • Step 7: Run the full ailang-codegen test suite for any collateral

Run:

cargo test -p ailang-codegen

Expected: all tests PASS. Tests not touched by Tasks 1-2 (drop walks, ADT lowering, match, eq, compare) do not reference the static- Str global shape and must remain green. If any test fails, it likely contains a literal substring assert that quotes the hs.1 struct shape; patch the assert in place using the same shape transformation (i64, i64, [...]i64, [...]; i64 -1, i64 Ni64 N; i32 0, i32 1i32 0, i32 0).


Task 3 — Regenerate hello.ll snapshot + workspace regression sweep

Files:

  • Modify: crates/ail/tests/snapshots/hello.ll

  • Step 1: Inspect the current snapshot

Run:

grep -n -E "i64 -1|i32 0, i32 1|<\{ i64, i64," crates/ail/tests/snapshots/hello.ll

Expected output (line numbers may vary slightly):

5:@.str_hello_str_0 = private unnamed_addr constant <{ i64, i64, [15 x i8] }> <{ i64 -1, i64 14, [15 x i8] c"Hello, AILang.\00" }>, align 8
18:  %v1 = getelementptr inbounds i8, ptr getelementptr inbounds (<{ i64, i64, [15 x i8] }>, ptr @.str_hello_str_0, i32 0, i32 1), i64 8

Confirms two lines need to flip.

  • Step 2: Regenerate the snapshot under UPDATE_SNAPSHOTS=1

Run:

UPDATE_SNAPSHOTS=1 cargo test -p ail --test e2e hello

(If the test harness name differs, locate it with: grep -rn "UPDATE_SNAPSHOTS" crates/ail/tests/ and run the matched test.)

Expected: the snapshot file is rewritten in place. The two flipped lines now read:

@.str_hello_str_0 = private unnamed_addr constant <{ i64, [15 x i8] }> <{ i64 14, [15 x i8] c"Hello, AILang.\00" }>, align 8
...
  %v1 = getelementptr inbounds i8, ptr getelementptr inbounds (<{ i64, [15 x i8] }>, ptr @.str_hello_str_0, i32 0, i32 0), i64 8

(The i64 -1 literal is gone; the struct type loses one i64,; the GEP-index pair changes from i32 0, i32 1 to i32 0, i32 0. The i64 8 byte-offset in the outer getelementptr i8 is unchanged.)

  • Step 3: Re-run the snapshot test without UPDATE_SNAPSHOTS=1 and confirm it passes

Run:

cargo test -p ail --test e2e hello

Expected: PASS. The regenerated snapshot now matches the live emission.

  • Step 4: Full workspace test sweep

Run:

cargo test --workspace

Expected: every test PASSes. Special attention to any test that greps for i64 -1 or for the 3-field struct type in emitted IR (unlikely outside the two tests Task 1 already covers; the ailang-prose / ailang-check / ailang-surface crates do not look at codegen output). If any test fails, decide whether to: (a) patch the test in place using the same shape transformation if it pinned hs.1 emission, or (b) treat it as a real regression and stop.

  • Step 5: 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: it verifies that the layout switch did not change observable program output for any program in examples/. Any divergence is a real regression — re-inspect the four +8 GEP callsites first, since those are the consumer-side ABI and most likely to misread the new layout.

  • Step 6: Workspace-compile regression sweep

Run:

bench/compile_check.py

Expected: every entry green (compile succeeds for the full examples/ corpus). This catches cases where a program compiled under hs.1 but fails under hs.2 codegen for reasons orthogonal to stdout output.

  • Step 7: Latency baselines check

Run:

bench/check.py

Expected: green within the known latency.explicit_at_rc.* nondeterminism tolerance documented in recent audits. The hs.2 retrofit is a struct-shape change, not a code-path-count change; no latency delta is expected.


Acceptance for this iteration

  • Both updated IR-shape tests pass.
  • All four +8 GEP tests pass unchanged.
  • crates/ail/tests/snapshots/hello.ll reflects the new layout.
  • cargo test --workspace green.
  • bench/cross_lang.py, bench/compile_check.py, bench/check.py all green.
  • The hs.1 sentinel-rc-header story is gone from both the runtime emission and the test asserts; static-Str globals are now 8 bytes smaller per literal.
  • No runtime-side change, no checker-side change, no new fixtures or test files.