diff --git a/docs/plans/hs.4.md b/docs/plans/hs.4.md new file mode 100644 index 0000000..7343373 --- /dev/null +++ b/docs/plans/hs.4.md @@ -0,0 +1,813 @@ +# hs.4 — IR + checker + linker wiring for int_to_str / float_to_str — Implementation Plan + +> **Parent spec:** `docs/specs/2026-05-12-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.json` — `do io/print_str(int_to_str(42))` +- Create: `examples/float_to_str_smoke.ail.json` — `do 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.json` — `type 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: + +```rust + 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: + +```rust +/// 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: + +```rust +"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: + +```rust + 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: + +```rust +/// 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 **FAIL** — `int_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: + +```rust + 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: + +```rust + 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: + +```rust + // 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: + +```rust + // 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. + +--- + +## Task 3 — Hoist runtime/rc.c to unconditional link + +**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 /str.o `, 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 /rc.o `, +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): + +```rust + // 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: + +```json +{ + "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": }` 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. + +```json +{ + "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`: + +```rust +/// 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.