Files
AILang/docs/plans/0046-hs.4.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

30 KiB

hs.4 — IR + checker + linker wiring for int_to_str / float_to_str — 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: Wire the hs.3 runtime symbols ailang_int_to_str(int64_t) and ailang_float_to_str(double) (already present in runtime/str.c since commit 1cf281b) into live AILang builtins observable at every layer of the toolchain. After this iter a program do io/print_str(int_to_str(42)) builds and prints "42\n".

Architecture: Four layers, one iteration. (1) The checker installs int_to_str : (Int) -> Str as a builtin signature next to the existing float_to_str; the codegen-local replay table in synth.rs gets the parallel arm in lockstep. (2) The codegen emits declare ptr @ailang_int_to_str(i64) and declare ptr @ailang_float_to_str(double) in the unconditional IR-header preamble; the Emitter::lower_app builtin chain gets a new int_to_str arm and replaces the CodegenError::Internal body of the existing float_to_str arm with the actual call emission. (3) The build pipeline hoists runtime/rc.c's clang-compile-and-link step out of the AllocStrategy::Rc arm into the unconditional path next to runtime/str.c (every binary links rc.c regardless of --alloc). (4) Six new tests pin the result: two IR-shape pins (int_to_str_lowers_to_ailang_int_to_str_call, float_to_str_no_longer_errors_internal), two stdout-smoke E2E (int_to_str_smoke, float_to_str_smoke), two RC-discipline E2E (int_to_str_drop_balances_rc_stats, str_field_in_adt_drops_heap_str_correctly). A stale comment in crates/ailang-codegen/src/drop.rs describing the static-Str-as-only realisation gets updated to the dual-realisation reality.

Tech Stack: Rust (ailang-check, ailang-codegen, ail), LLVM-IR string emission, JSON fixtures.

Spec drift advisory: The parent spec quotes line numbers lib.rs:1829 (float_to_str arm) and ~:455-:498 (IR-header preamble). Hs.4 recon located the actual sites at lib.rs:1890-1903 and :486-535 respectively (hs.1 + hs.2 + hs.3 commits shifted them). This plan uses the recon-verified line numbers throughout.


Files this plan creates or modifies

  • Modify: crates/ailang-check/src/builtins.rs:193-202 — add int_to_str : (Int) -> Str next to the existing float_to_str entry
  • Modify: crates/ailang-check/src/builtins.rs:444-449 — add install_int_to_str_signature unit test mirroring install_float_to_str_signature
  • Modify: crates/ailang-codegen/src/synth.rs:167-173 — add int_to_str arm to the local type-replay table (lockstep partner of the checker insert)
  • Modify: crates/ailang-codegen/src/lib.rs:486-535 — IR-header preamble: add declare ptr @ailang_int_to_str(i64) and declare ptr @ailang_float_to_str(double) unconditionally
  • Modify: crates/ailang-codegen/src/lib.rs:1890-1903 — the float_to_str arm in Emitter::lower_app: replace the CodegenError::Internal body with a fresh_ssa + call emission; add a new int_to_str arm parallel to it
  • Modify: crates/ailang-codegen/src/drop.rs:374-381 — update the stale Str arm comment in field_drop_call to reflect the dual-realisation reality (heap-Str + static-Str share consumer ABI; static-Str pointers are kept out of ailang_rc_dec by codegen-level elision)
  • Modify: crates/ail/src/main.rs:2327-2356 — hoist the runtime/rc.c compile-and-link out of the AllocStrategy::Rc arm into the unconditional path next to runtime/str.c at :2271-2293. After hoist, the AllocStrategy::Rc arm shrinks (no rc-specific linking remains; gc/bump arms unchanged)
  • Create: crates/ailang-codegen/src/lib.rs test module — two new IR-shape tests (int_to_str_lowers_to_ailang_int_to_str_call, float_to_str_no_longer_errors_internal) appended after the existing IR-shape tests
  • Create: crates/ail/tests/e2e.rs — four new E2E tests (int_to_str_smoke, float_to_str_smoke, int_to_str_drop_balances_rc_stats, str_field_in_adt_drops_heap_str_correctly) appended at the end
  • Create: examples/int_to_str_smoke.ail.jsondo io/print_str(int_to_str(42))
  • Create: examples/float_to_str_smoke.ail.jsondo io/print_str(float_to_str(3.5))
  • Create: examples/int_to_str_drop_rc.ail.json — N-loop of int_to_str with non-aliasing drops (RC-stats fixture)
  • Create: examples/str_field_in_adt_heap.ail.jsontype Boxed = | Box(Str) carrying an int_to_str result, dropped at end of scope
  • Untouched: runtime/*.c — hs.3 already shipped the runtime symbols; __attribute__((weak)) on ailang_rc_alloc in str.c becomes a permanent no-op after the rc.c hoist (strong definition wins), retained intentionally per the hs.3 journal Concerns section

Task 1 — Install int_to_str in checker and synth.rs (lockstep type-signature pair)

Files:

  • Modify: crates/ailang-check/src/builtins.rs:193-202 (insert), :444-449 (test)

  • Modify: crates/ailang-codegen/src/synth.rs:167-173 (insert)

  • Step 1: Add int_to_str signature in the checker

Locate the float_to_str entry at crates/ailang-check/src/builtins.rs:193-202. Immediately above it (grouping int_to_str with int_to_float for the int-side cohort or immediately below it parallel to float_to_str; either placement is defensible — choose adjacent to float_to_str for the str-output grouping), insert:

    env.globals.insert(
        "int_to_str".into(),
        Type::Fn {
            params: vec![Type::int()],
            ret: Box::new(Type::str_()),
            effects: vec![],
            param_modes: vec![],
            ret_mode: ailang_core::ast::ParamMode::Implicit,
        },
    );

Diff vs. float_to_str: only the name ("int_to_str") and the param type (Type::int() instead of Type::float()) differ.

  • Step 2: Add install_int_to_str_signature unit test

Locate the install_float_to_str_signature test at crates/ailang-check/src/builtins.rs:442-449. Append (or insert adjacent) the parallel int test:

/// Iter hs.4: `int_to_str : (Int) -> Str`. Codegen lowers via the
/// runtime C glue `ailang_int_to_str` from `runtime/str.c`.
#[test]
fn install_int_to_str_signature() {
    let ty = synth_in_builtins_env(&app("int_to_str", vec![lit_int(42)]));
    assert_eq!(ty, Type::str_(), "(int_to_str 42) must type as Str");
}

lit_int helper is already in scope (used by install_int_to_float_signature at line 425); confirm via grep -n "fn lit_int" crates/ailang-check/src/builtins.rs if unsure.

  • Step 3: Add int_to_str arm in synth.rs (lockstep partner)

Locate the float_to_str arm at crates/ailang-codegen/src/synth.rs:167-173. Insert the parallel int arm immediately adjacent:

"int_to_str" => Type::Fn {
    params: vec![Type::int()],
    ret: Box::new(Type::str_()),
    effects: vec![],
    param_modes: vec![],
    ret_mode: ParamMode::Implicit,
},

Diff vs. float_to_str: only the match-arm name and the param type differ. The file-level comment at synth.rs:140-141 explicitly names the lockstep with builtins.rs — Step 1 + Step 3 must land together; either-side absence causes checker-vs-codegen disagreement.

  • Step 4: Run the new unit test (RED — fails until codegen has lowering)

Run:

cargo test -p ailang-check install_int_to_str_signature

Expected: PASS (this test exercises only the checker and the synth_in_builtins_env helper, not codegen lowering — the signature install is sufficient for this test to pass even without Task 2's lowering work).

  • Step 5: Confirm install_float_to_str_signature still passes

Run:

cargo test -p ailang-check install_float_to_str_signature

Expected: PASS. Sanity check that the int-side insertion did not perturb the float-side entry.


Task 2 — Codegen lowering + IR-header preamble + IR-shape tests + drop.rs comment

Files:

  • Modify: crates/ailang-codegen/src/lib.rs:486-535 (IR-header preamble — add two new declare lines)

  • Modify: crates/ailang-codegen/src/lib.rs:1890-1903 (Emitter::lower_app — replace float_to_str body, add int_to_str arm)

  • Modify: crates/ailang-codegen/src/lib.rs test module (append two IR-shape tests)

  • Modify: crates/ailang-codegen/src/drop.rs:374-381 (refresh stale comment)

  • Step 1: Extend the IR-header preamble

Locate the existing declare block at crates/ailang-codegen/src/lib.rs:486-535. After the line that declares @ail_str_compare (currently line 529) and before declare i64 @llvm.fptosi.sat.i64.f64(double) at line 535 — i.e. in the "language runtime externs" stretch — append the two new declarations unconditionally:

    out.push_str("declare ptr @ailang_int_to_str(i64)\n");
    out.push_str("declare ptr @ailang_float_to_str(double)\n");

These sit alongside the unconditional @ail_str_eq and @ail_str_compare declarations (added per iter 23.2/23.3 — the surrounding comments justify the unconditional placement on the ground that the str.c TU is unconditionally linked).

  • Step 2: Write the two new IR-shape tests (RED)

Append to the test module at the bottom of crates/ailang-codegen/src/lib.rs, after the existing compare_str_calls_ail_str_compare_with_bytes_pointer test (currently around line 4019). Use the same fixture-construction style as the existing IR-shape tests:

/// Iter hs.4: a `Literal::App` calling `int_to_str` lowers to
/// `call ptr @ailang_int_to_str(i64 %a)` — pins the new builtin's
/// lowering shape.
#[test]
fn int_to_str_lowers_to_ailang_int_to_str_call() {
    let m = Module {
        schema: SCHEMA.into(),
        name: "t".into(),
        imports: vec![],
        defs: vec![Def::Fn(FnDef {
            name: "main".into(),
            ty: Type::Fn {
                params: vec![],
                ret: Box::new(Type::unit()),
                effects: vec!["IO".into()],
                param_modes: vec![],
                ret_mode: ParamMode::Implicit,
            },
            params: vec![],
            body: Term::Do {
                op: "io/print_str".into(),
                args: vec![Term::App {
                    fn_: Box::new(Term::Var { name: "int_to_str".into() }),
                    args: vec![Term::Lit { lit: Literal::Int { value: 42 } }],
                }],
                tail: false,
            },
            suppress: vec![],
            doc: None,
        })],
    };
    let ir = emit_ir(&m).unwrap();
    assert!(
        ir.contains("call ptr @ailang_int_to_str(i64 "),
        "expected lowering of int_to_str to call @ailang_int_to_str; ir was:\n{ir}"
    );
}

/// Iter hs.4: `float_to_str` no longer raises CodegenError::Internal —
/// it lowers to `call ptr @ailang_float_to_str(double %a)`.
#[test]
fn float_to_str_no_longer_errors_internal() {
    let m = Module {
        schema: SCHEMA.into(),
        name: "t".into(),
        imports: vec![],
        defs: vec![Def::Fn(FnDef {
            name: "main".into(),
            ty: Type::Fn {
                params: vec![],
                ret: Box::new(Type::unit()),
                effects: vec!["IO".into()],
                param_modes: vec![],
                ret_mode: ParamMode::Implicit,
            },
            params: vec![],
            body: Term::Do {
                op: "io/print_str".into(),
                args: vec![Term::App {
                    fn_: Box::new(Term::Var { name: "float_to_str".into() }),
                    args: vec![Term::Lit { lit: Literal::Float { bits: (3.5_f64).to_bits() } }],
                }],
                tail: false,
            },
            suppress: vec![],
            doc: None,
        })],
    };
    let ir = emit_ir(&m).unwrap();
    assert!(
        ir.contains("call ptr @ailang_float_to_str(double "),
        "expected lowering of float_to_str to call @ailang_float_to_str; ir was:\n{ir}"
    );
}

The Term::Var { name: "int_to_str".into() } carries the builtin through the type-replay so synth_arg_type resolves it via the synth.rs arm Task 1 Step 3 installed.

  • Step 3: Run the new tests and confirm RED

Run:

cargo test -p ailang-codegen --lib int_to_str_lowers float_to_str_no_longer

Expected: both tests FAILint_to_str is not yet lowered (reaches the unknown-builtin fallback or panics on synth_arg_type even with the synth.rs arm because lower_app's chain has no matching if name == "int_to_str" arm); float_to_str still returns CodegenError::Internal so emit_ir returns Err and the assertion fails when the body unwraps.

  • Step 4: Replace the float_to_str arm in Emitter::lower_app

Locate the existing arm at crates/ailang-codegen/src/lib.rs:1890-1903. The current body returns Err(CodegenError::Internal("float_to_str codegen lowering is not yet implemented (requires dynamic Str allocation in the runtime)".into())). Replace with a call emission. Reuse the same shape as int_to_float at crates/ailang-codegen/src/lib.rs:1859-1867 for the SSA + call pattern. After the edit, the if name == "float_to_str" arm reads:

                if name == "float_to_str" {
                    let (a_v, _a_ty) = self.lower_term_to_value(&args[0])?;
                    let dst = self.fresh_ssa();
                    self.body.push_str(&format!(
                        "  {dst} = call ptr @ailang_float_to_str(double {a_v})\n"
                    ));
                    return Ok((dst, "ptr".to_string()));
                }

The exact helper names (lower_term_to_value, fresh_ssa, self.body.push_str) and the Result<(String, String)> shape must match the surrounding arms — confirm against the int_to_float arm verbatim.

  • Step 5: Add the new int_to_str arm

Adjacent to the float_to_str arm (placement immediately above or below; co-location keeps the two heap-Str formatters discoverable together), insert:

                if name == "int_to_str" {
                    let (a_v, _a_ty) = self.lower_term_to_value(&args[0])?;
                    let dst = self.fresh_ssa();
                    self.body.push_str(&format!(
                        "  {dst} = call ptr @ailang_int_to_str(i64 {a_v})\n"
                    ));
                    return Ok((dst, "ptr".to_string()));
                }

Diff vs. float_to_str: only the name ("int_to_str"), the runtime symbol (@ailang_int_to_str), and the LLVM argument type (i64 instead of double) differ.

  • Step 6: Run the IR-shape tests and confirm GREEN

Run:

cargo test -p ailang-codegen --lib int_to_str_lowers float_to_str_no_longer

Expected: both tests PASS.

  • Step 7: Update the stale Str arm comment in field_drop_call

Locate the comment at crates/ailang-codegen/src/drop.rs:374-381. The current comment reads:

                    // Built-in pointer-typed cons: Str. No drop fn —
                    // shallow `ailang_rc_dec` is the right answer (Str
                    // payloads are NUL-terminated bytes in static
                    // memory; nothing to recurse into).

Replace with the dual-realisation framing per spec §Components / lib.rs item 4:

                    // Built-in pointer-typed cons: Str. No drop fn —
                    // shallow `ailang_rc_dec` is the right answer.
                    // Str has two realisations sharing the consumer
                    // ABI (len at offset 0, bytes at offset 8):
                    //   - heap-Str: malloc'd slab with real rc_header
                    //     at `payload - 8`; rc_dec is the correct
                    //     refcount-and-free path.
                    //   - static-Str: packed-struct LLVM global
                    //     <{ i64, [N x i8] }> in .rodata, no rc_header
                    //     slot; rc_dec would read undefined bytes at
                    //     `payload - 8`. Codegen-level elision
                    //     (`emit_inlined_partial_drop` move-tracking
                    //     from iter 18d.3 + non-escape lowering from
                    //     iter 18b) keeps static-Str pointers out of
                    //     this call along every shipping execution
                    //     path. The codegen-level invariant is the
                    //     protection; no runtime guard backs it up.

The dispatched symbol (ailang_rc_dec) is unchanged — only the comment evolves.

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

Run:

cargo test -p ailang-codegen

Expected: every test PASSes. Tests not touched by Tasks 1-2 (drop walks, ADT lowering, match, eq, compare, the four +8 GEP tests, the two static_str_ shape tests from hs.2) must remain green.


Files:

  • Modify: crates/ail/src/main.rs:2271-2293 (insertion point for the hoisted rc.c block, adjacent to the existing str.c block), :2327-2356 (the AllocStrategy::Rc arm where rc.c lives today)

  • Step 1: Read the current str.c link block as the template

Open crates/ail/src/main.rs:2271-2293. Confirm the str.c block: locates runtime/str.c via locate_str_runtime(), runs clang -c -O2 -o <tmpdir>/str.o <str_path>, propagates errors via .context("compiling runtime/str.c")?, exits-on-status-failure with a stderr message, then appends clang.arg(&str_obj). This is the template — the hoisted rc.c block will mirror it.

  • Step 2: Hoist the rc.c compile-and-link block

Locate the AllocStrategy::Rc arm at crates/ail/src/main.rs:2327-2356. The relevant lines are the ones that:

  1. Call locate_rc_runtime() to resolve runtime/rc.c,
  2. Run clang -c -O2 -o <tmpdir>/rc.o <rc_path>,
  3. Exit on status failure with an "rc.c" message,
  4. Append clang.arg(&rc_obj).

Cut these and paste them next to the str.c block at :2293 (after the clang.arg(&str_obj) line, before the match alloc { … } block at :2294). After the move, the rc.c block sits in the unconditional path; the AllocStrategy::Rc arm shrinks (its remaining body is whatever Rc-specific work survives — likely just the IR-side --alloc=rc flag handling and the lack of any -lgc or bump.o integration).

A sample diff sketch (exact text depends on the surrounding helper names — confirm against the actual :2271-2293 block during editing):

    // Iter 23.2: compile and link `runtime/str.c` unconditionally —
    // … (existing comment block)
    let str_path = locate_str_runtime()?;
    // … (existing str.c compile + link block)
    clang.arg(&str_obj);

    // Iter hs.4: compile and link `runtime/rc.c` unconditionally —
    // hoisted out of the AllocStrategy::Rc arm. `runtime/str.c`'s
    // `ailang_int_to_str` / `ailang_float_to_str` call
    // `ailang_rc_alloc` defined in rc.c; the weak extern declaration
    // in str.c (`runtime/str.c:35`) resolves to the strong definition
    // here. Under --alloc=gc / --alloc=bump the alloc strategy still
    // governs ADT allocation (GC_malloc / bump_malloc); rc.c provides
    // the heap-Str primitives in parallel.
    let rc_path = locate_rc_runtime()?;
    let rc_obj = tmpdir.path().join("rc.o");
    let cstatus = std::process::Command::new("clang")
        .arg("-c").arg("-O2")
        .arg("-o").arg(&rc_obj)
        .arg(&rc_path)
        .status()
        .context("compiling runtime/rc.c")?;
    if !cstatus.success() {
        eprintln!("clang failed compiling rc.c (status {})", cstatus);
        std::process::exit(cstatus.code().unwrap_or(1));
    }
    clang.arg(&rc_obj);

(Exact identifiers — tmpdir, the error path — must match the local style. Treat the snippet as a structural sketch, not a verbatim insert.)

In the match alloc { AllocStrategy::Rc => { … } } arm at :2327-2356, remove the four lines that performed the rc.c compile-and-link. If the arm's only remaining work was those four lines, the arm body becomes empty (AllocStrategy::Rc => {}). Retain the arm in the match — --alloc=rc is still a valid CLI flag that drives codegen's @ailang_rc_alloc-vs-@GC_malloc selection; removing the match-arm would break that.

  • Step 3: Sanity test under all three alloc strategies

Run a minimal smoke build under each alloc strategy:

cargo run -p ail --release -- build examples/hello.ail.json --alloc=gc -o /tmp/hello_gc
/tmp/hello_gc
cargo run -p ail --release -- build examples/hello.ail.json --alloc=bump -o /tmp/hello_bump
/tmp/hello_bump
cargo run -p ail --release -- build examples/hello.ail.json --alloc=rc -o /tmp/hello_rc
/tmp/hello_rc

Expected: all three binaries print Hello, AILang. (or whatever hello.ail's expected output is — verify via the existing snapshot crates/ail/tests/snapshots/hello.ll corresponds to expected stdout). All three link successfully (rc.o now unconditionally present in every link).

  • Step 4: Full workspace test sweep

Run:

cargo test --workspace

Expected: every test PASSes. This is the gate that rc.o being unconditionally linked does not break gc / bump path tests — in particular the e2e tests under crates/ail/tests/e2e.rs that use the default-gc build_and_run helper.


Task 4 — Stdout + RC-stats E2E tests with fixtures

Files:

  • Create: examples/int_to_str_smoke.ail.json

  • Create: examples/float_to_str_smoke.ail.json

  • Create: examples/int_to_str_drop_rc.ail.json

  • Create: examples/str_field_in_adt_heap.ail.json

  • Modify: crates/ail/tests/e2e.rs (append four new tests)

  • Create (if missing): crates/ail/tests/expected/int_to_str_smoke.txt, crates/ail/tests/expected/float_to_str_smoke.txt (or assert inline; pick whichever pattern the existing _smoke tests use — inspect crates/ail/tests/e2e.rs near line 90 for prior style)

  • Step 1: Write the int_to_str smoke fixture

Create examples/int_to_str_smoke.ail.json with four print statements covering 0, 42, -1, i64::MAX in sequence:

{
  "schema": "ailang/v0",
  "name": "int_to_str_smoke",
  "imports": [],
  "defs": [
    {
      "kind": "fn",
      "name": "main",
      "type": {
        "k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" },
        "effects": ["IO"]
      },
      "params": [],
      "body": {
        "t": "do", "op": "io/print_str",
        "args": [{
          "t": "app",
          "fn": { "t": "var", "name": "int_to_str" },
          "args": [{ "t": "lit", "lit": { "kind": "int", "value": 42 } }]
        }]
      }
    }
  ]
}

Ship a single-value smoke fixture covering int_to_str(42) only. The spec also names edge cases 0, -1, i64::MAX, i64::MIN; these are deferred to a follow-up tidy iter if hs.4's regression sweep does not surface a need for them. The single-value smoke is sufficient to demonstrate the codegen + runtime path is wired end-to-end.

  • Step 2: Write the float_to_str smoke fixture

Create examples/float_to_str_smoke.ail.json analogously, printing float_to_str(3.5). The literal float is encoded as { "kind": "float", "value": <ieee_bits_as_u64> } matching the Literal::Float { bits } shape (cf. existing fixtures with float literals — grep -ln '"kind": "float"' examples/ for examples). For 3.5, bits = (3.5_f64).to_bits() = 0x400C000000000000.

  • Step 3: Write the int_to_str drop-RC fixture

Create examples/int_to_str_drop_rc.ail.json. The fixture allocates N heap-Str values via int_to_str and drops them without aliasing. A minimal shape: a single top-level do io/print_str(int_to_str(42)) allocates one slab; the program exits and the slab is freed on scope close. The test asserts allocs == frees && live == 0 via build_and_run_with_rc_stats. If a single-allocation form is not sufficient to demonstrate the discipline (e.g. it does not exercise the full alloc + free path), the fixture instead does let s = int_to_str(42) in io/print_str(s) so the binding's drop fires explicitly. Pick whichever shape produces a non-trivial allocs >= 1, frees >= 1 count.

{
  "schema": "ailang/v0",
  "name": "int_to_str_drop_rc",
  "imports": [],
  "defs": [
    {
      "kind": "fn",
      "name": "main",
      "type": {
        "k": "fn", "params": [], "ret": { "k": "con", "name": "Unit" },
        "effects": ["IO"]
      },
      "params": [],
      "body": {
        "t": "let",
        "name": "s",
        "ty": { "k": "con", "name": "Str" },
        "value": {
          "t": "app",
          "fn": { "t": "var", "name": "int_to_str" },
          "args": [{ "t": "lit", "lit": { "kind": "int", "value": 42 } }]
        },
        "body": {
          "t": "do", "op": "io/print_str",
          "args": [{ "t": "var", "name": "s" }]
        }
      }
    }
  ]
}

If the Term::Let JSON shape differs from this sketch, locate the canonical form via grep -ln '"t": "let"' examples/ and use the matched fixture's shape as a template. The semantic shape (a binding allocating once, used once, dropped at scope close) is fixed; the JSON syntactic shape follows whatever the existing schema-form requires.

  • Step 4: Write the Str-in-ADT drop fixture

Create examples/str_field_in_adt_heap.ail.json. Defines a single ADT type Boxed = | Box(Str), constructs Box(int_to_str(42)), binds it, prints something derived from it (so the constructor and extractor are exercised), and lets the binding drop at scope close. Consult an existing ADT-with-Str-field fixture for the construct + pattern-match shape (likely under examples/rc_box_drop.ail.json or a sibling — grep -ln 'type.*Box.*Str\|Box(.*Str' examples/).

The test asserts allocs == frees && live == 0 via build_and_run_with_rc_stats — the ADT cell and the heap-Str inside it must both round-trip through the RC discipline.

  • Step 5: Add the four new E2E tests

Append to the end of crates/ail/tests/e2e.rs:

/// Iter hs.4: `int_to_str(42)` prints "42\n" through the standard
/// `do io/print_str(...)` path. Smoke pin for the heap-Str builtin.
#[test]
fn int_to_str_smoke() {
    let out = build_and_run("int_to_str_smoke.ail.json");
    assert_eq!(out, "42\n");
}

/// Iter hs.4: `float_to_str(3.5)` prints a libc-%g-conformant
/// rendering of 3.5. Exact bytes pinned against the dev target's
/// printf output ("3.5\n").
#[test]
fn float_to_str_smoke() {
    let out = build_and_run("float_to_str_smoke.ail.json");
    assert_eq!(out, "3.5\n");
}

/// Iter hs.4: `int_to_str` participates in RC discipline — every
/// allocation is matched by a drop at scope close.
#[test]
fn int_to_str_drop_balances_rc_stats() {
    let (stdout, allocs, frees, live) =
        build_and_run_with_rc_stats("int_to_str_drop_rc.ail.json");
    assert_eq!(stdout, "42\n");
    assert!(allocs >= 1, "expected at least one heap-Str allocation; got allocs={allocs}");
    assert_eq!(allocs, frees, "every heap-Str alloc must be freed; allocs={allocs}, frees={frees}");
    assert_eq!(live, 0, "no leaked heap-Str slabs allowed; live={live}");
}

/// Iter hs.4: an ADT carrying a heap-Str field drops both the ADT
/// cell and the Str payload through the RC walk. Pins the
/// field_drop_call dispatch for heap-Str (drop.rs:372 routes Str →
/// ailang_rc_dec; the heap-Str rc_header is real).
#[test]
fn str_field_in_adt_drops_heap_str_correctly() {
    let (_stdout, allocs, frees, live) =
        build_and_run_with_rc_stats("str_field_in_adt_heap.ail.json");
    assert!(allocs >= 2, "expected ADT cell + heap-Str slab; got allocs={allocs}");
    assert_eq!(allocs, frees, "every RC slab must be freed; allocs={allocs}, frees={frees}");
    assert_eq!(live, 0, "no leaked RC slabs allowed; live={live}");
}

The build_and_run_with_rc_stats helper's return tuple shape is (String, u64, u64, i64) per recon at crates/ail/tests/e2e.rs:2093; the live field can be signed (alloc < free case).

  • Step 6: Run the four new E2E tests

Run:

cargo test -p ail --test e2e int_to_str_smoke float_to_str_smoke int_to_str_drop_balances_rc_stats str_field_in_adt_drops_heap_str_correctly

Expected: all four tests PASS. If float_to_str_smoke fails because libc's %g on the dev target renders 3.5 differently (e.g. as 3.50000 or with a trailing 0), inspect the actual stdout and pin against the observed bytes — add a comment that the exact rendering is libc-target-dependent.

  • Step 7: Cross-language stdout regression sweep

Run:

bench/cross_lang.py

Expected: every entry green. The new fixtures themselves do not have a C reference counterpart (they exercise an AILang-only codegen path); their absence from the C-reference corpus is expected. The gate verifies that existing C-paired fixtures still produce byte-identical stdout.

  • Step 8: Workspace-compile regression sweep

Run:

bench/compile_check.py

Expected: every entry green. The new fixtures themselves should now compile end-to-end (they will appear in bench/compile_check.py's scanner).

  • Step 9: Full workspace test sweep

Run:

cargo test --workspace

Expected: every test PASSes.

  • Step 10: Latency baselines check

Run:

bench/check.py

Expected: green within the known latency.explicit_at_rc.* nondeterminism tolerance documented in recent audits.


Acceptance for this iteration

  • int_to_str : (Int) -> Str installed in the checker; codegen lowers int_to_str to call ptr @ailang_int_to_str(i64 %a).
  • float_to_str no longer raises CodegenError::Internal; lowers to call ptr @ailang_float_to_str(double %a).
  • IR-header preamble unconditionally declares both runtime symbols.
  • runtime/rc.c is compiled and linked into every binary regardless of --alloc strategy.
  • Two new IR-shape tests pass; six existing IR-shape tests stay green.
  • Four new E2E tests pass; existing E2E suite stays green.
  • crates/ailang-codegen/src/drop.rs:374-381 comment reflects the dual-realisation heap-Str + static-Str reality.
  • cargo test --workspace, bench/cross_lang.py, bench/compile_check.py, bench/check.py all green.
  • The __attribute__((weak)) on ailang_rc_alloc in runtime/str.c is now a no-op (strong definition wins) but retained intentionally per the hs.3 journal Concerns note.
  • Hs.5 remains as the final iter: DESIGN.md anchors + WhatsNew + audit-close.