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

33 KiB
Raw Permalink Blame History

24.1 — bool_to_str + str_clone Runtime + Codegen Wiring — Implementation Plan

Parent spec: docs/specs/0022-24-show-print.md (commit 953f2e1)

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

Goal: Wire two new heap-Str-producing primitives — bool_to_str and str_clone — through the runtime C code, type checker, codegen IR lowering, and IR-header preamble, mechanically mirror-imaging the existing hs.4 wiring for int_to_str / float_to_str.

Architecture: Pure-additive wiring across four crates / files. The two new C functions live in runtime/str.c next to ailang_int_to_str / ailang_float_to_str. Type signatures land lockstep in crates/ailang-check/src/builtins.rs::install and crates/ailang-codegen/src/synth.rs::builtin_value_type (both sides MUST agree on ParamMode::Own for ret_mode). Codegen gains two IR-header declares, two Emitter::lower_app arms, and two is_static_callee whitelist entries — all in crates/ailang-codegen/src/lib.rs. Five IR snapshots regenerate because the header preamble grows by two lines. Nine new tests land: 2 builtins-install unit tests (parallel to install_int_to_str_signature), 2 IR-shape unit pins (parallel to int_to_str_lowers_to_ailang_int_to_str_call), 2 RC-stats E2E, 2 stdout-smoke E2E (one per Bool branch), plus a cross-realisation E2E for str_clone. No checker-level architectural decisions — all design is committed in the parent spec.

Tech Stack: ailang-check::builtins, ailang-codegen::lib::Emitter::{lower_app, is_static_callee}, ailang-codegen::synth::builtin_value_type, runtime/str.c (C with <stdbool.h>), crates/ail/tests/e2e.rs (E2E harness with build_and_run_with_rc_stats from eob.1).


Files this plan creates or modifies:

  • Modify: runtime/str.c:143 — append two new C functions ailang_bool_to_str(bool) and ailang_str_clone(const char *).
  • Modify: crates/ailang-check/src/builtins.rs:212 — insert two new env.globals.insert blocks for bool_to_str and str_clone (parallel to the existing int_to_str / float_to_str blocks at :193-212).
  • Modify: crates/ailang-check/src/builtins.rs:307 — add row ("int_to_str", "(Int) -> Str") (pre-existing drift: hs.4 installed it in env.globals but missed the list() row) plus two new rows for bool_to_str and str_clone.
  • Modify: crates/ailang-check/src/builtins.rs:467 — append two new unit tests install_bool_to_str_signature and install_str_clone_signature (parallel to the existing install_int_to_str_signature at :461-467).
  • Modify: crates/ailang-codegen/src/synth.rs:180 — insert two new arms in builtin_value_type for bool_to_str and str_clone (parallel to the existing int_to_str arm at :174-180).
  • Modify: crates/ailang-codegen/src/lib.rs:538 — insert two new IR-header declares declare ptr @ailang_bool_to_str(i1) and declare ptr @ailang_str_clone(ptr) after the existing @ailang_int_to_str / @ailang_float_to_str declares at :537-538.
  • Modify: crates/ailang-codegen/src/lib.rs:1933 — insert two new lower_app arms for bool_to_str and str_clone after the existing float_to_str arm at :1915-1933.
  • Modify: crates/ailang-codegen/src/lib.rs:2133 — extend is_static_callee matches! whitelist (at :2126-2134) with "bool_to_str" and "str_clone".
  • Modify: crates/ailang-codegen/src/lib.rs:4204 — append two new IR-shape pin tests bool_to_str_emits_call_to_ailang_bool_to_str and str_clone_emits_call_to_ailang_str_clone (parallel to the existing int_to_str_lowers_to_ailang_int_to_str_call at :4131-4165).
  • Modify: crates/ail/tests/snapshots/hello.ll, list.ll, max3.ll, sum.ll, ws_main.ll — regenerate 5 IR snapshots via UPDATE_SNAPSHOTS=1 cargo test ir_snapshot_ after the IR header grows by two declare lines.
  • Create: examples/bool_to_str_drop_rc.ail.jsonlet s = bool_to_str true in do io/print_str s for the RC-stats test.
  • Create: examples/bool_to_str_smoke_false.ail.jsonlet s = bool_to_str false in do io/print_str s for the false-branch stdout-smoke test.
  • Create: examples/str_clone_drop_rc.ail.jsonlet s = str_clone "hello" in do io/print_str s for the RC-stats test (also doubles as the stdout-smoke test for str_clone).
  • Create: examples/str_clone_cross_realisation.ail.json — clones both int_to_str 42 (heap-Str input) and the static-Str literal "abc" and prints both, pinning the uniform consumer ABI claim.
  • Test: crates/ail/tests/e2e.rs:2657 — append five new tests (bool_to_str_drop_balances_rc_stats, bool_to_str_emits_true_branch, bool_to_str_emits_false_branch, str_clone_drop_balances_rc_stats, str_clone_cross_realisation_uniform_abi).

Task 1: Runtime C functions in runtime/str.c

Add two new C functions paralleling ailang_int_to_str / ailang_float_to_str. Both allocate fresh heap-Str slabs via the existing str_alloc helper (which calls ailang_rc_alloc) and return the payload pointer. <stdbool.h> is already included at :16 so bool is in scope.

Files:

  • Modify: runtime/str.c:143

  • Step 1: Append ailang_bool_to_str after ailang_float_to_str

Edit runtime/str.c — insert after the closing brace of ailang_float_to_str (which ends at line 143):


/* Convert a bool into a heap-allocated Str — "true" (4 bytes) or
 * "false" (5 bytes). Heap-allocates every call; the static-Str
 * literal alternative (used by a hypothetical `\x -> if x then "true"
 * else "false"` schema body) would require phi-of-static-Str
 * through the drop-elision pipeline which is not load-bearing-
 * ratified today (see parent spec §"Out of scope" / `bool_to_str` as
 * schema-if).
 *
 * Returns the payload pointer of a fresh heap-Str slab.
 */
char *ailang_bool_to_str(bool b) {
    const char *lit = b ? "true" : "false";
    uint64_t len = b ? 4 : 5;
    char *payload = str_alloc(len);
    memcpy(payload + 8, lit, len);
    payload[8 + len] = '\0';
    return payload;
}
  • Step 2: Append ailang_str_clone after ailang_bool_to_str

Edit runtime/str.c — insert after the closing brace of the just- added ailang_bool_to_str:


/* Clone a Str into a fresh heap-Str slab. Reads `len` from the
 * first 8 bytes of the source payload, allocates a new slab of the
 * same length via `str_alloc`, and memcpy's the payload bytes plus
 * the trailing NUL. Works uniformly on static-Str and heap-Str
 * inputs because the consumer ABI (i64 len at offset 0, bytes
 * starting at offset 8, NUL at offset 8 + len) is identical
 * between realisations (see DESIGN.md §"Str ABI"); the rc_header at
 * offset -8 — present only on heap-Str — is never consulted.
 *
 * Used by `show__Str` (parent spec milestone 24): converts a
 * borrowed Str input into an Owned heap-Str output, preserving
 * the uniform `show : forall a. Show a => (a borrow) -> Str Own`
 * signature.
 *
 * Returns the payload pointer of a fresh heap-Str slab.
 */
char *ailang_str_clone(const char *src) {
    uint64_t len = *(const uint64_t *)src;
    char *payload = str_alloc(len);
    memcpy(payload + 8, src + 8, len);
    payload[8 + len] = '\0';
    return payload;
}
  • Step 3: Verify str.c compiles standalone via cargo build --workspace

Run: cargo build --workspace 2>&1 | tail -20 Expected: clean build, no warnings, no errors. (The new C functions are unreachable from any IR call site until Task 3 lands the codegen wiring, but the .o builds fine in isolation.)


Task 2: Builtin install — checker + synth.rs lockstep + list() rows + unit tests

Install both signatures in crates/ailang-check/src/builtins.rs::install with ret_mode: ParamMode::Own, mirror in crates/ailang-codegen/src/synth.rs::builtin_value_type. Add the two new rows plus the pre-existing-missing int_to_str row in list(). Two new unit tests in builtins.rs's tests module.

Files:

  • Modify: crates/ailang-check/src/builtins.rs:212

  • Modify: crates/ailang-check/src/builtins.rs:307

  • Modify: crates/ailang-check/src/builtins.rs:467

  • Modify: crates/ailang-codegen/src/synth.rs:180

  • Step 1: Insert bool_to_str and str_clone env.globals.insert blocks

Edit crates/ailang-check/src/builtins.rs:212 — insert after the closing of the int_to_str block (which ends at line 212, the line with );) and before the env.globals.insert("is_nan".into(), ...) block at line 213:

    env.globals.insert(
        "bool_to_str".into(),
        Type::Fn {
            params: vec![Type::bool_()],
            ret: Box::new(Type::str_()),
            effects: vec![],
            param_modes: vec![],
            ret_mode: ailang_core::ast::ParamMode::Own,
        },
    );
    env.globals.insert(
        "str_clone".into(),
        Type::Fn {
            params: vec![Type::str_()],
            ret: Box::new(Type::str_()),
            effects: vec![],
            param_modes: vec![ailang_core::ast::ParamMode::Borrow],
            ret_mode: ailang_core::ast::ParamMode::Own,
        },
    );

Note: str_clone's param_modes carries an explicit Borrow for the single Str input, matching the parent spec's (Str borrow) -> Str signature. bool_to_str's param_modes stays empty (Bool is bit-copied; no borrow/own distinction).

  • Step 2: Add three rows to list() vec — int_to_str (drift fix), bool_to_str, str_clone

Edit crates/ailang-check/src/builtins.rs:307 — replace the line:

        ("float_to_str", "(Float) -> Str"),

with:

        ("float_to_str", "(Float) -> Str"),
        ("int_to_str", "(Int) -> Str"),
        ("bool_to_str", "(Bool) -> Str"),
        ("str_clone", "(Str) -> Str"),

(The int_to_str row is the pre-existing-drift fix flagged by plan-recon; hs.4 added the env.globals entry at :204 but missed the corresponding list() row.)

  • Step 3: Append install_bool_to_str_signature and install_str_clone_signature unit tests

Edit crates/ailang-check/src/builtins.rs:467 — insert after the closing brace of install_int_to_str_signature (which ends at line 467) and before the next test (install_is_nan_signature at line 473):


    /// Iter 24.1: `bool_to_str : (Bool) -> Str`. Codegen lowers via the
    /// runtime C glue `ailang_bool_to_str` from `runtime/str.c`.
    #[test]
    fn install_bool_to_str_signature() {
        let ty = synth_in_builtins_env(&app("bool_to_str", vec![lit_bool(true)]));
        assert_eq!(ty, Type::str_(), "(bool_to_str true) must type as Str");
    }

    /// Iter 24.1: `str_clone : (Str borrow) -> Str`. Codegen lowers via the
    /// runtime C glue `ailang_str_clone` from `runtime/str.c`.
    #[test]
    fn install_str_clone_signature() {
        let ty = synth_in_builtins_env(&app("str_clone", vec![lit_str("hi")]));
        assert_eq!(ty, Type::str_(), "(str_clone \"hi\") must type as Str");
    }

Note: if lit_bool / lit_str test helpers do not exist in the local test module, fall back to the explicit shape:

let ty = synth_in_builtins_env(&app(
    "bool_to_str",
    vec![Term::Lit { lit: Literal::Bool { value: true } }],
));

(check the existing install_is_nan_signature test at :474-479 for the in-use helper shape; if it uses lit_float, then lit_bool / lit_str likely exist as siblings.)

  • Step 4: Run builtins.rs tests to verify all three new tests pass

Run: cargo test --workspace -p ailang-check builtins::tests 2>&1 | tail -20 Expected: all builtins::tests green, including the two new install_bool_to_str_signature and install_str_clone_signature.

  • Step 5: Mirror lockstep arms in synth.rs::builtin_value_type

Edit crates/ailang-codegen/src/synth.rs:180 — insert after the closing brace of the int_to_str arm (which ends at line 180, },) and before the is_nan arm at line 181:

        "bool_to_str" => Type::Fn {
            params: vec![Type::bool_()],
            ret: Box::new(Type::str_()),
            effects: vec![],
            param_modes: vec![],
            ret_mode: ParamMode::Own,
        },
        "str_clone" => Type::Fn {
            params: vec![Type::str_()],
            ret: Box::new(Type::str_()),
            effects: vec![],
            param_modes: vec![ParamMode::Borrow],
            ret_mode: ParamMode::Own,
        },
  • Step 6: Verify lockstep — full workspace builds clean

Run: cargo build --workspace 2>&1 | tail -20 Expected: clean build, no warnings. Both builtins.rs::install and synth.rs::builtin_value_type now agree on the two new symbols.


Task 3: Codegen IR wiring — header preamble declares + lower_app arms + is_static_callee whitelist

Three sub-edits all in crates/ailang-codegen/src/lib.rs. Adds two IR-header declare lines, two lower_app call-emission arms, and two whitelist entries in is_static_callee.

Files:

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

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

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

  • Step 1: Add two IR-header declares

Edit crates/ailang-codegen/src/lib.rs:538 — replace the line:

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

with:

    out.push_str("declare ptr @ailang_float_to_str(double)\n");
    // Iter 24.1: heap-Str primitive externs for the `show` instances
    // — `ailang_bool_to_str` (Show Bool) and `ailang_str_clone`
    // (Show Str). Same dual-realisation ABI as int_to_str /
    // float_to_str: rc_header at offset -8 on the heap-Str output;
    // consumer ABI shared with static-Str (len at offset 0, bytes
    // at offset 8). Declared unconditionally on the same rationale
    // — runtime/str.c is unconditionally linked and clang -O2 dead-
    // strips when no caller exists.
    out.push_str("declare ptr @ailang_bool_to_str(i1)\n");
    out.push_str("declare ptr @ailang_str_clone(ptr)\n");
  • Step 2: Add two lower_app arms

Edit crates/ailang-codegen/src/lib.rs:1933 — insert after the closing brace of the float_to_str arm (which ends at line 1933, the line with }) and before line 1934 (the blank line preceding the cross-module-call comment block):

        if name == "bool_to_str" {
            // Iter 24.1: lowers to the runtime C glue
            // `ailang_bool_to_str(i1) -> ptr` defined in
            // `runtime/str.c`. Returned pointer is a heap-Str
            // (rc_header at offset -8; consumer ABI shared with
            // static-Str). Used by `show__Bool` in milestone 24.
            if args.len() != 1 {
                return Err(CodegenError::Internal("bool_to_str arity".into()));
            }
            let (a, _) = self.lower_term(&args[0])?;
            let dst = self.fresh_ssa();
            self.body.push_str(&format!(
                "  {dst} = call ptr @ailang_bool_to_str(i1 {a})\n"
            ));
            return Ok((dst, "ptr".to_string()));
        }
        if name == "str_clone" {
            // Iter 24.1: lowers to the runtime C glue
            // `ailang_str_clone(ptr) -> ptr` defined in
            // `runtime/str.c`. Reads `len` from offset 0 of the
            // source Str payload and allocates a fresh heap-Str
            // slab; works uniformly on static-Str and heap-Str
            // inputs because the consumer ABI is identical. Used by
            // `show__Str` in milestone 24.
            if args.len() != 1 {
                return Err(CodegenError::Internal("str_clone arity".into()));
            }
            let (a, _) = self.lower_term(&args[0])?;
            let dst = self.fresh_ssa();
            self.body.push_str(&format!(
                "  {dst} = call ptr @ailang_str_clone(ptr {a})\n"
            ));
            return Ok((dst, "ptr".to_string()));
        }
  • Step 3: Extend is_static_callee whitelist

Edit crates/ailang-codegen/src/lib.rs:2133 — replace the line:

                | "int_to_str"

with:

                | "int_to_str"
                | "bool_to_str"
                | "str_clone"
  • Step 4: Build workspace to verify codegen compiles

Run: cargo build --workspace 2>&1 | tail -20 Expected: clean build, no warnings, no errors. The new arms are unreachable from any test yet, but the file compiles.


Task 4: IR-shape unit pin tests in lib.rs::tests

Two new tests in crates/ailang-codegen/src/lib.rs's mod tests block, mechanical copies of the existing int_to_str_lowers_to_ailang_int_to_str_call template.

Files:

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

  • Step 1: Append bool_to_str_emits_call_to_ailang_bool_to_str

Edit crates/ailang-codegen/src/lib.rs:4204 — insert after the closing brace of float_to_str_no_longer_errors_internal (which ends at line 4204, }) and before the } of mod tests at line 4205:


    /// Iter 24.1: a `Term::App` calling `bool_to_str` lowers to
    /// `call ptr @ailang_bool_to_str(i1 %a)`. Pins the new builtin's
    /// lowering shape against the runtime C glue introduced in this
    /// iter (mirror of hs.4's `int_to_str_lowers_to_ailang_int_to_str_call`).
    #[test]
    fn bool_to_str_emits_call_to_ailang_bool_to_str() {
        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 {
                        callee: Box::new(Term::Var { name: "bool_to_str".into() }),
                        args: vec![Term::Lit { lit: Literal::Bool { value: true } }],
                        tail: false,
                    }],
                    tail: false,
                },
                suppress: vec![],
                doc: None,
            })],
        };
        let ir = emit_ir(&m).unwrap();
        assert!(
            ir.contains("call ptr @ailang_bool_to_str(i1 "),
            "expected lowering of bool_to_str to call @ailang_bool_to_str; ir was:\n{ir}"
        );
    }
  • Step 2: Append str_clone_emits_call_to_ailang_str_clone

Edit crates/ailang-codegen/src/lib.rs — insert immediately after the closing brace of bool_to_str_emits_call_to_ailang_bool_to_str just added in Step 1:


    /// Iter 24.1: a `Term::App` calling `str_clone` lowers to
    /// `call ptr @ailang_str_clone(ptr %a)`. Pins the new builtin's
    /// lowering shape against the runtime C glue introduced in this
    /// iter (mirror of hs.4's `int_to_str_lowers_to_ailang_int_to_str_call`).
    #[test]
    fn str_clone_emits_call_to_ailang_str_clone() {
        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 {
                        callee: Box::new(Term::Var { name: "str_clone".into() }),
                        args: vec![Term::Lit { lit: Literal::Str { value: "hi".into() } }],
                        tail: false,
                    }],
                    tail: false,
                },
                suppress: vec![],
                doc: None,
            })],
        };
        let ir = emit_ir(&m).unwrap();
        assert!(
            ir.contains("call ptr @ailang_str_clone(ptr "),
            "expected lowering of str_clone to call @ailang_str_clone; ir was:\n{ir}"
        );
    }
  • Step 3: Run the two new tests

Run: cargo test --workspace -p ailang-codegen bool_to_str_emits_call_to_ailang_bool_to_str str_clone_emits_call_to_ailang_str_clone 2>&1 | tail -20 Expected: both PASS.


Task 5: Regenerate the five IR snapshots

The IR header preamble grew by two declare lines (Task 3 Step 1), which shifts the byte content of every IR snapshot. Five existing snapshots in crates/ail/tests/snapshots/ need regeneration. The snapshot harness lives at crates/ail/tests/ir_snapshot.rs:76 and honours UPDATE_SNAPSHOTS=1 to overwrite the goldens.

Files:

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

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

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

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

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

  • Step 1: Verify the IR-snapshot tests FAIL pre-regen

Run: cargo test --workspace -p ail ir_snapshot_ 2>&1 | tail -30 Expected: all five ir_snapshot_* tests FAIL with diff output showing the new declare ptr @ailang_bool_to_str(i1) and declare ptr @ailang_str_clone(ptr) lines appearing in the generated IR but not in the golden.

  • Step 2: Regenerate snapshots

Run: UPDATE_SNAPSHOTS=1 cargo test --workspace -p ail ir_snapshot_ 2>&1 | tail -10 Expected: all five ir_snapshot_* tests PASS (because UPDATE_SNAPSHOTS=1 overwrites the goldens rather than asserting against them).

  • Step 3: Verify the regenerated snapshots have exactly the two new declares

Run: grep -c "declare ptr @ailang_bool_to_str\|declare ptr @ailang_str_clone" crates/ail/tests/snapshots/*.ll Expected: each of the 5 files reports 2 (one match for each new declare line).

  • Step 4: Verify the IR-snapshot tests now PASS without UPDATE_SNAPSHOTS

Run: cargo test --workspace -p ail ir_snapshot_ 2>&1 | tail -10 Expected: all five ir_snapshot_* tests PASS.


Task 6: E2E fixtures + tests + acceptance gate

Four new .ail.json fixtures in examples/, five new E2E tests in crates/ail/tests/e2e.rs, then the full acceptance run.

Files:

  • Create: examples/bool_to_str_drop_rc.ail.json

  • Create: examples/bool_to_str_smoke_false.ail.json

  • Create: examples/str_clone_drop_rc.ail.json

  • Create: examples/str_clone_cross_realisation.ail.json

  • Modify: crates/ail/tests/e2e.rs:2657

  • Step 1: Create examples/bool_to_str_drop_rc.ail.json

Write a new file examples/bool_to_str_drop_rc.ail.json with this exact content (template: examples/int_to_str_drop_rc.ail.json adapted for bool_to_str with literal true):

{
  "schema": "ailang/v0",
  "name": "bool_to_str_drop_rc",
  "imports": [],
  "defs": [
    {
      "kind": "fn",
      "name": "main",
      "type": {
        "k": "fn",
        "params": [],
        "ret": { "k": "con", "name": "Unit" },
        "effects": ["IO"]
      },
      "params": [],
      "doc": "Iter 24.1: pin that `bool_to_str` participates in RC discipline. Bind the heap-Str result to `s`, consume `s` once in `io/print_str`, let the binder drop at scope close. Under --alloc=rc with AILANG_RC_STATS=1 the atexit summary must report allocs == 1 && frees == 1 && live == 0 — the heap-Str slab allocated by str_alloc inside ailang_bool_to_str rides the same rc_header + ailang_rc_dec path as int_to_str.",
      "body": {
        "t": "let",
        "name": "s",
        "value": {
          "t": "app",
          "fn": { "t": "var", "name": "bool_to_str" },
          "args": [{ "t": "lit", "lit": { "kind": "bool", "value": true } }]
        },
        "body": {
          "t": "do",
          "op": "io/print_str",
          "args": [{ "t": "var", "name": "s" }]
        }
      }
    }
  ]
}
  • Step 2: Create examples/bool_to_str_smoke_false.ail.json

Write a new file examples/bool_to_str_smoke_false.ail.json with this exact content (identical to Step 1's fixture but with literal false):

{
  "schema": "ailang/v0",
  "name": "bool_to_str_smoke_false",
  "imports": [],
  "defs": [
    {
      "kind": "fn",
      "name": "main",
      "type": {
        "k": "fn",
        "params": [],
        "ret": { "k": "con", "name": "Unit" },
        "effects": ["IO"]
      },
      "params": [],
      "doc": "Iter 24.1: stdout-smoke for the false branch of bool_to_str. Stdout must be `false\\n` (puts adds the newline). Companion of bool_to_str_drop_rc.ail.json (which covers the true branch and the RC-stats invariant).",
      "body": {
        "t": "let",
        "name": "s",
        "value": {
          "t": "app",
          "fn": { "t": "var", "name": "bool_to_str" },
          "args": [{ "t": "lit", "lit": { "kind": "bool", "value": false } }]
        },
        "body": {
          "t": "do",
          "op": "io/print_str",
          "args": [{ "t": "var", "name": "s" }]
        }
      }
    }
  ]
}
  • Step 3: Create examples/str_clone_drop_rc.ail.json

Write a new file examples/str_clone_drop_rc.ail.json with this exact content (clones the static-Str literal "hello"):

{
  "schema": "ailang/v0",
  "name": "str_clone_drop_rc",
  "imports": [],
  "defs": [
    {
      "kind": "fn",
      "name": "main",
      "type": {
        "k": "fn",
        "params": [],
        "ret": { "k": "con", "name": "Unit" },
        "effects": ["IO"]
      },
      "params": [],
      "doc": "Iter 24.1: pin that `str_clone` participates in RC discipline AND emits correct bytes. Input is a static-Str literal; the clone produces a fresh heap-Str slab whose payload is byte-equal to the input. RC stats under --alloc=rc with AILANG_RC_STATS=1: allocs == 1 (the clone), frees == 1 (let-binder drop at scope close), live == 0.",
      "body": {
        "t": "let",
        "name": "s",
        "value": {
          "t": "app",
          "fn": { "t": "var", "name": "str_clone" },
          "args": [{ "t": "lit", "lit": { "kind": "str", "value": "hello" } }]
        },
        "body": {
          "t": "do",
          "op": "io/print_str",
          "args": [{ "t": "var", "name": "s" }]
        }
      }
    }
  ]
}
  • Step 4: Create examples/str_clone_cross_realisation.ail.json

Write a new file examples/str_clone_cross_realisation.ail.json that clones one heap-Str (output of int_to_str 42) and one static-Str (literal "abc") in sequence, printing both:

{
  "schema": "ailang/v0",
  "name": "str_clone_cross_realisation",
  "imports": [],
  "defs": [
    {
      "kind": "fn",
      "name": "main",
      "type": {
        "k": "fn",
        "params": [],
        "ret": { "k": "con", "name": "Unit" },
        "effects": ["IO"]
      },
      "params": [],
      "doc": "Iter 24.1: cross-realisation invariant for str_clone. Clones a heap-Str (produced by int_to_str 42) AND a static-Str (literal `abc`); both clones produce fresh heap-Str slabs that print their input bytes. Pins the uniform-consumer-ABI claim — str_clone only reads the len-field at offset 0 plus the bytes plus the NUL; it never consults the (absent for static-Str) rc_header. RC stats under --alloc=rc: allocs == 3 (int_to_str output + two str_clone outputs), frees == 3, live == 0.",
      "body": {
        "t": "let",
        "name": "src_heap",
        "value": {
          "t": "app",
          "fn": { "t": "var", "name": "int_to_str" },
          "args": [{ "t": "lit", "lit": { "kind": "int", "value": 42 } }]
        },
        "body": {
          "t": "let",
          "name": "c1",
          "value": {
            "t": "app",
            "fn": { "t": "var", "name": "str_clone" },
            "args": [{ "t": "var", "name": "src_heap" }]
          },
          "body": {
            "t": "let",
            "name": "c2",
            "value": {
              "t": "app",
              "fn": { "t": "var", "name": "str_clone" },
              "args": [{ "t": "lit", "lit": { "kind": "str", "value": "abc" } }]
            },
            "body": {
              "t": "let",
              "name": "_a",
              "value": {
                "t": "do",
                "op": "io/print_str",
                "args": [{ "t": "var", "name": "c1" }]
              },
              "body": {
                "t": "do",
                "op": "io/print_str",
                "args": [{ "t": "var", "name": "c2" }]
              }
            }
          }
        }
      }
    }
  ]
}
  • Step 5: Append five new tests to crates/ail/tests/e2e.rs

Edit crates/ail/tests/e2e.rs:2657 — insert immediately after the closing brace of heap_str_repeated_print_balances_rc_stats (which ends at line 2657, the last } before the EOF):


/// Iter 24.1: `bool_to_str(true)` bound to a let, consumed by
/// `io/print_str`. The heap-Str slab from `ailang_bool_to_str`
/// rides the same rc_header + ailang_rc_dec path as int_to_str's
/// output — `allocs == 1, frees == 1, live == 0` and stdout is
/// `true\n`. Companion of `bool_to_str_emits_false_branch` which
/// covers the false-branch stdout.
#[test]
fn bool_to_str_drop_balances_rc_stats() {
    let (stdout, allocs, frees, live) =
        build_and_run_with_rc_stats("bool_to_str_drop_rc.ail.json");
    assert_eq!(stdout, "true\n");
    assert_eq!(allocs, 1, "expected exactly one heap-Str slab; got allocs={allocs}");
    assert_eq!(frees, 1, "expected exactly one free; got frees={frees}");
    assert_eq!(live, 0, "no leaked heap-Str slabs allowed; live={live}");
}

/// Iter 24.1: stdout-smoke for the true branch — same fixture as
/// `bool_to_str_drop_balances_rc_stats` but checked here without
/// the RC-stats overhead. Pins the byte content of `ailang_bool_to_str`'s
/// "true" slab.
#[test]
fn bool_to_str_emits_true_branch() {
    let out = build_and_run("bool_to_str_drop_rc.ail.json");
    assert_eq!(out, "true\n");
}

/// Iter 24.1: stdout-smoke for the false branch. Pins the byte
/// content of `ailang_bool_to_str`'s "false" slab.
#[test]
fn bool_to_str_emits_false_branch() {
    let out = build_and_run("bool_to_str_smoke_false.ail.json");
    assert_eq!(out, "false\n");
}

/// Iter 24.1: `str_clone("hello")` bound to a let, consumed by
/// `io/print_str`. The heap-Str clone allocates a fresh slab (via
/// str_alloc) and the let-binder drops it at scope close.
/// `allocs == 1, frees == 1, live == 0`; stdout is `hello\n` (puts
/// adds the newline) — confirming the memcpy preserved every byte.
#[test]
fn str_clone_drop_balances_rc_stats() {
    let (stdout, allocs, frees, live) =
        build_and_run_with_rc_stats("str_clone_drop_rc.ail.json");
    assert_eq!(stdout, "hello\n");
    assert_eq!(allocs, 1, "expected exactly one heap-Str clone slab; got allocs={allocs}");
    assert_eq!(frees, 1, "expected exactly one free; got frees={frees}");
    assert_eq!(live, 0, "no leaked heap-Str slabs allowed; live={live}");
}

/// Iter 24.1: cross-realisation invariant — `str_clone` works
/// uniformly on heap-Str input (`int_to_str 42`'s output) AND on
/// static-Str input (literal `"abc"`). Both clones print their
/// input bytes; RC stats account for three heap-Str slabs (one
/// from int_to_str, two from str_clone) and three matching frees.
/// Pins the "uniform consumer ABI" claim from DESIGN.md §"Str
/// ABI" — str_clone only reads len + bytes + NUL, never the
/// rc_header.
#[test]
fn str_clone_cross_realisation_uniform_abi() {
    let (stdout, allocs, frees, live) =
        build_and_run_with_rc_stats("str_clone_cross_realisation.ail.json");
    assert_eq!(stdout, "42\nabc\n");
    assert_eq!(allocs, 3, "expected one int_to_str slab + two str_clone slabs; got allocs={allocs}");
    assert_eq!(frees, 3, "expected three matching frees; got frees={frees}");
    assert_eq!(live, 0, "no leaked heap-Str slabs allowed; live={live}");
}
  • Step 6: Run the five new e2e tests

Run: cargo test --workspace -p ail bool_to_str_drop_balances_rc_stats bool_to_str_emits_true_branch bool_to_str_emits_false_branch str_clone_drop_balances_rc_stats str_clone_cross_realisation_uniform_abi 2>&1 | tail -30 Expected: all five PASS.

  • Step 7: Full workspace test run — acceptance gate

Run: cargo test --workspace 2>&1 | tail -30 Expected: all tests green, including the six new ones (two unit IR-pins from Task 4, four E2E tests from Task 6 Step 5 — well, five counting the smoke-false). No previously-passing tests regress.

  • Step 8: Bench scripts — acceptance gate

Run: bench/compile_check.py 2>&1 | tail -10 Expected: exit 0, all metrics within tolerance. (Codegen additions shouldn't move compile-time meaningfully; only two new declares plus two arms.)

Run: bench/cross_lang.py 2>&1 | tail -10 Expected: exit 0, all metrics within tolerance. (Runtime additions are unreached by any benchmark — no callers yet.)

  • Step 9: Grep verification — every new symbol is wired through every layer

Run: grep -rn "bool_to_str\|str_clone" runtime/str.c crates/ailang-check/src/builtins.rs crates/ailang-codegen/src/synth.rs crates/ailang-codegen/src/lib.rs | grep -v test | wc -l Expected: ≥ 10 hits (the eight code edits from Tasks 1-3 plus the two whitelist entries in is_static_callee).

Run: grep -n "ailang_bool_to_str\|ailang_str_clone" crates/ail/tests/snapshots/*.ll | wc -l Expected: 10 hits (2 declares × 5 snapshot files).

Run: cargo build --workspace 2>&1 | grep -E "warning|error" | head Expected: empty (no warnings, no errors).