diff --git a/bench/orchestrator-stats/2026-05-12-iter-eob.1.json b/bench/orchestrator-stats/2026-05-12-iter-eob.1.json new file mode 100644 index 0000000..a4cc381 --- /dev/null +++ b/bench/orchestrator-stats/2026-05-12-iter-eob.1.json @@ -0,0 +1,20 @@ +{ + "iter_id": "eob.1", + "date": "2026-05-12", + "mode": "standard", + "outcome": "DONE", + "tasks_total": 7, + "tasks_completed": 7, + "reloops_per_task": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 0, + "6": 0, + "7": 0 + }, + "review_loops_spec": 0, + "review_loops_quality": 0, + "blocked_reason": null +} diff --git a/crates/ail/tests/e2e.rs b/crates/ail/tests/e2e.rs index 7abeb8f..93c728a 100644 --- a/crates/ail/tests/e2e.rs +++ b/crates/ail/tests/e2e.rs @@ -2622,3 +2622,36 @@ fn str_field_in_adt_drops_heap_str_correctly() { 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}"); } + +/// Iter eob.1: primitive-Int arg passed to an effect-op. Under the +/// new "Term::Do args = Borrow" rule, the let-binder `n` is not +/// consumed by `io/print_int`; but Int is unboxed, so there is no +/// pointer to RC-track. Pin: zero allocs / zero frees / zero live — +/// the rule introduces no spurious bookkeeping on primitives. +#[test] +fn int_arg_to_effect_op_does_not_rc_track() { + let (stdout, allocs, frees, live) = + build_and_run_with_rc_stats("int_to_print_int_borrow.ail.json"); + assert_eq!(stdout, "7\n"); + assert_eq!(allocs, 0, "Int arg should not trigger any RC alloc; got allocs={allocs}"); + assert_eq!(frees, 0, "Int arg should not trigger any RC free; got frees={frees}"); + assert_eq!(live, 0, "no leaked RC slabs allowed; live={live}"); +} + +/// Iter eob.1: heap-Str let-binder consumed by two `io/print_str` +/// calls in sequence. Pins the linearity-side consequence of the new +/// rule: under the old Position::Consume walk for Term::Do args, the +/// second print would have triggered `use-after-consume`. Under the +/// new Position::Borrow walk, both calls are borrows and the program +/// typechecks. Under --alloc=rc the slab is allocated once by +/// int_to_str, lives through both prints, and is freed exactly once +/// at scope close — allocs == 1, frees == 1, live == 0. +#[test] +fn heap_str_repeated_print_balances_rc_stats() { + let (stdout, allocs, frees, live) = + build_and_run_with_rc_stats("heap_str_repeated_print_borrow.ail.json"); + assert_eq!(stdout, "42\n42\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}"); +} diff --git a/crates/ailang-check/src/builtins.rs b/crates/ailang-check/src/builtins.rs index d37afdb..228be62 100644 --- a/crates/ailang-check/src/builtins.rs +++ b/crates/ailang-check/src/builtins.rs @@ -197,7 +197,7 @@ pub fn install(env: &mut crate::Env) { ret: Box::new(Type::str_()), effects: vec![], param_modes: vec![], - ret_mode: ailang_core::ast::ParamMode::Implicit, + ret_mode: ailang_core::ast::ParamMode::Own, }, ); env.globals.insert( @@ -207,7 +207,7 @@ pub fn install(env: &mut crate::Env) { ret: Box::new(Type::str_()), effects: vec![], param_modes: vec![], - ret_mode: ailang_core::ast::ParamMode::Implicit, + ret_mode: ailang_core::ast::ParamMode::Own, }, ); env.globals.insert( diff --git a/crates/ailang-check/src/linearity.rs b/crates/ailang-check/src/linearity.rs index 5575160..bb10ea6 100644 --- a/crates/ailang-check/src/linearity.rs +++ b/crates/ailang-check/src/linearity.rs @@ -39,7 +39,9 @@ //! false-negative for "param declared own but never moved" — out of //! scope for 18c.2 per the assignment). //! - `Term::Let.value`, `Term::Seq.lhs`, `Term::If.cond` — Consume. -//! - `Term::Ctor.args[*]`, `Term::Do.args[*]` — Consume. +//! - `Term::Ctor.args[*]` — Consume (the new value owns them). +//! - `Term::Do.args[*]` — Borrow (effect-op observes; ownership +//! stays with the caller). //! - `Term::Lam` — captures are conservatively Consume. //! //! ## Within-call borrow lifetime (consume-while-borrowed) @@ -504,8 +506,11 @@ impl<'a> Checker<'a> { } } Term::Do { args, .. } => { + // Effect-op args are borrowed: the op observes the value + // but ownership stays with the caller. Symmetric to the + // doc-comment bullet at :42-43. for a in args { - self.walk(a, Position::Consume); + self.walk(a, Position::Borrow); } } Term::Lam { params, body, .. } => { diff --git a/crates/ailang-check/src/uniqueness.rs b/crates/ailang-check/src/uniqueness.rs index 22863cc..d1971c9 100644 --- a/crates/ailang-check/src/uniqueness.rs +++ b/crates/ailang-check/src/uniqueness.rs @@ -288,7 +288,7 @@ impl<'a> Walker<'a> { } Term::Do { args, .. } => { for a in args { - self.walk(a, Position::Consume); + self.walk(a, Position::Borrow); } } Term::Lam { params, body, .. } => { diff --git a/crates/ailang-codegen/src/drop.rs b/crates/ailang-codegen/src/drop.rs index eb7f4bc..57c5568 100644 --- a/crates/ailang-codegen/src/drop.rs +++ b/crates/ailang-codegen/src/drop.rs @@ -534,6 +534,14 @@ impl<'a> Emitter<'a> { Term::App { .. } => { if let Ok(ret_ty) = self.synth_arg_type(value) { if let Type::Con { name, .. } = ret_ty { + // Symmetric to `field_drop_call`'s Str arm: Str is a + // built-in pointer type with no per-type drop fn. Both + // heap-Str (rc_header at payload-8) and static-Str + // (codegen-elision keeps static pointers out of this + // path) consume via `ailang_rc_dec`. + if name == "Str" { + return "ailang_rc_dec".to_string(); + } if name.matches('.').count() == 1 { let (prefix, suffix) = name.split_once('.').expect("checked"); diff --git a/crates/ailang-codegen/src/synth.rs b/crates/ailang-codegen/src/synth.rs index a07662c..a652fc8 100644 --- a/crates/ailang-codegen/src/synth.rs +++ b/crates/ailang-codegen/src/synth.rs @@ -169,14 +169,14 @@ pub(crate) fn builtin_ail_type(name: &str) -> Option { ret: Box::new(Type::str_()), effects: vec![], param_modes: vec![], - ret_mode: ParamMode::Implicit, + ret_mode: ParamMode::Own, }, "int_to_str" => Type::Fn { params: vec![Type::int()], ret: Box::new(Type::str_()), effects: vec![], param_modes: vec![], - ret_mode: ParamMode::Implicit, + ret_mode: ParamMode::Own, }, "is_nan" => Type::Fn { params: vec![Type::float()], diff --git a/docs/DESIGN.md b/docs/DESIGN.md index cfee449..03eb52d 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1396,6 +1396,31 @@ returns that are not `Type::Con` (e.g. unresolved type vars on a polymorphic call's pre-monomorphisation site; the monomorphised copies resolve to concrete drop fns). +#### Arg-position policy for compound AST nodes + +The uniqueness and linearity passes walk arguments of compound +nodes with a fixed `Position` policy. For ownership-bearing nodes: + +| Node | Arg position | Reason | +|----------------------|--------------|---------------------------------------------------------------------------------------| +| `Term::Ctor.args[*]` | Consume | constructor packs values into the cell; the cell owns them afterwards | +| `Term::Do.args[*]` | Borrow | effect-op observes its arguments; the caller still owns whatever pointer it passed in | + +The two policies are language rules, not per-op annotations. They +do not appear as fields on `EffectOpSig` or `Ctor`; the AST node +kind itself carries the default. The walkers that read this policy +live at `crates/ailang-check/src/uniqueness.rs` and +`crates/ailang-check/src/linearity.rs` (matched arms in both). + +The Do = Borrow rule pairs with the `ret_mode == Own` letbinder- +trackability rule above: when a built-in such as `int_to_str` is +declared `ret_mode: Own` and its result is fed into an effect-op +(`io/print_str s`), the let-binder is RC-tracked for scope-close +drop *and* the effect-op does not consume it — the slab is freed +exactly once at scope close, never zero-times (RC leak under the +old Consume rule, which silenced the scope-close drop) and never +twice (double-free under a hypothetical Consume + scope-close). + **What this widening does NOT do.** - Does not change the canonical hash. `param_modes` / diff --git a/docs/WhatsNew.md b/docs/WhatsNew.md index 03fdfe9..dc596dc 100644 --- a/docs/WhatsNew.md +++ b/docs/WhatsNew.md @@ -80,3 +80,9 @@ Cross-model authoring test landed. First run (Qwen3-Coder-Next, 4 tasks, two bli ## 2026-05-12 — Cross-model authoring test: second subject added Cross-model authoring test now has a second subject. Ran CodeLlama alongside Qwen on the same four tasks, fresh runs both. Both models point the same way: textual form is cheaper than structured form on tokens (61–80% of structured form's cost across the two models) and reaches working code at least as often (Qwen ties on this run; CodeLlama strictly dominates 2/4 vs 0/4). Three of four first-attempt successes across the two models are textual. A feedback-asymmetry bug surfaced during planning — the structured form was silently losing its underlying parse error on the way back to the model — and got fixed in the same milestone, so both subjects ran with the fix in place. Not a universal verdict yet (would need three or more models with repetitions), but the second data point points the same way as the first. Milestone closed; queue back to user-direction. + +## 2026-05-12 — Strings produced at runtime now release cleanly + +`int_to_str`, `float_to_str`, and similar built-in primitives that produce a freshly allocated string at runtime now participate in reference counting like every other heap value. A string passed into a print call is now treated as borrowed, not consumed, so it is freed exactly once when it leaves scope. Earlier programs that converted a number to a string and printed it leaked the string buffer at program exit; that no longer happens. + +A side effect: a function parameter declared `(own T)` whose only in-body use was passing it into a print call will now correctly be flagged as over-strict — that annotation should be relaxed to `(borrow T)` (or removed). No current example programs are affected. diff --git a/docs/journals/2026-05-12-iter-eob.1.md b/docs/journals/2026-05-12-iter-eob.1.md new file mode 100644 index 0000000..44aa0a1 --- /dev/null +++ b/docs/journals/2026-05-12-iter-eob.1.md @@ -0,0 +1,123 @@ +# iter eob.1 — Effect-op args walked as Borrow; heap-Str RC discipline closes + +**Date:** 2026-05-12 +**Started from:** b5b0c2d7dcd8bec1819801257de7731edc542e0f +**Status:** DONE +**Tasks completed:** 7 of 7 + +## Summary + +Land the language rule that `Term::Do.args[*]` are walked in +`Position::Borrow` by the uniqueness and linearity passes, plus the +two heap-Str-specific shape fixes (`ret_mode: Own` for `int_to_str` +/ `float_to_str`; Str carve-out in the App arm of +`drop_symbol_for_binder`) that make the existing RED tests at +`crates/ail/tests/e2e.rs` flip to GREEN under `--alloc=rc`. Both +arg-position rules (Ctor = Consume, Do = Borrow) are anchored in +DESIGN.md §Decision 10. The heap-str-abi milestone closes here: +WhatsNew entry shipped, roadmap P1 entry checked off, downstream +Post-22-Prelude `depends on:` line removed. + +The five plan layers landed first-shot in lockstep — no review +re-loops fired on any of the 7 tasks. The pre-existing RED tests +(`int_to_str_drop_balances_rc_stats`, +`str_field_in_adt_drops_heap_str_correctly`) flipped to GREEN with +the predicted concrete numbers (`allocs == 1, frees == 1, live == 0` +and `allocs == 2, frees == 2, live == 0`). Two new positive tests +pin the rule's coverage at the primitive boundary (Int through +`io/print_int`: zero RC traffic) and at the linearity boundary +(heap-Str through two sequential `io/print_str` calls: allocs == 1, +frees == 1, live == 0). Workspace test --workspace + cross_lang.py ++ compile_check.py all green. check.py shows the same noisy +latency-max + bump_s cluster the audit-cma and audit-ms journals +both left pristine ("not coupled to milestone, baseline left +untouched for second consecutive audit") — same precedent applies +here; baseline left as-is. + +## Per-task notes + +- iter eob.1.1: Term::Do arg-walks flip Consume → Borrow in both + uniqueness.rs:289 and linearity.rs:506; linearity.rs:42 doc + comment split into two bullets matching the new code shape. + Inline clarifying comment added on the linearity-side flip + mirroring the Ctor-arm convention. cargo build + workspace + check-crate tests green. +- iter eob.1.2: builtins.rs `float_to_str` (:200) + `int_to_str` + (:210) and synth.rs `float_to_str` (:172) + `int_to_str` (:179) + all flip `ret_mode: Implicit` → `ret_mode: Own` in lockstep. + Workspace build clean. Target test now reports the predicted + link-error shape "undefined value @drop_int_to_str_drop_rc_Str" + — exactly the gap Task 3 closes. +- iter eob.1.3: drop.rs App arm of `drop_symbol_for_binder` gets + the Str carve-out `if name == "Str" return ailang_rc_dec`, + symmetric to `field_drop_call`'s existing Str arm. The + comment block mirrors `field_drop_call`'s wording. cargo build + clean. +- iter eob.1.4 (observational): both RED tests flip to GREEN with + the predicted concrete numbers (allocs == 1, frees == 1 / allocs + == 2, frees == 2; live == 0 on both). Full e2e suite (79 tests) + all green — no other fixture's RC trace shifted unexpectedly. +- iter eob.1.5: two new fixtures + (`examples/int_to_print_int_borrow.ail.json`, + `examples/heap_str_repeated_print_borrow.ail.json`) + two new + e2e tests (`int_arg_to_effect_op_does_not_rc_track`, + `heap_str_repeated_print_balances_rc_stats`) appended near + e2e.rs:2625. Both new tests PASS first-shot with exact predicted + RC stats. +- iter eob.1.6: DESIGN.md §Decision 10 gets a new subsection + `#### Arg-position policy for compound AST nodes` after the + `ret_mode` block. The two arg-position rules (Ctor = Consume, + Do = Borrow) are anchored together as language rules, not + per-op annotations. One added linking paragraph explains how + the Do = Borrow rule cooperates with the `ret_mode == Own` + letbinder-trackability rule above to balance heap-Str RC + discipline — within the plan's "Boss judgement on placement" + carve-out. +- iter eob.1.7: WhatsNew.md entry appended at bottom (user-facing + wording, no internals). roadmap.md heap-Str ABI entry flipped + `[ ]` → `[x]`. Post-22 Prelude `depends on:` line removed (the + only dep was Heap-Str, which has shipped). cargo test workspace + + cross_lang.py + compile_check.py all green. check.py noisy + latency cluster left pristine per audit-cma/audit-ms precedent. + +## Concerns + +(none — all 7 tasks DONE without review re-loops) + +## Known debt + +- check.py shows recurring noisy `latency.implicit_at_rc.max_us` + / `latency.explicit_at_rc.max_us` tail-jitter, plus + `throughput.bench_list_sum.bump_s` swinging +11-15% across + back-to-back runs (with reciprocal `gc_over_bump` improvement + on the same fixture). Neither family is reachable from this + iter's changes (bump_s on bench_list_sum has no effect-ops; the + list-sum hot path has no int_to_str / float_to_str calls). The + audit-cma and audit-ms journals already document the same + noise cluster as "not coupled to milestone, baseline left + pristine for two consecutive audits" — same precedent applies; + baseline untouched. + +## Files touched + +Code: +- crates/ailang-check/src/uniqueness.rs (Term::Do Consume → Borrow) +- crates/ailang-check/src/linearity.rs (Term::Do Consume → Borrow + doc-block split) +- crates/ailang-check/src/builtins.rs (int_to_str / float_to_str ret_mode → Own) +- crates/ailang-codegen/src/synth.rs (int_to_str / float_to_str ret_mode → Own) +- crates/ailang-codegen/src/drop.rs (App-arm Str carve-out) + +Tests + fixtures: +- crates/ail/tests/e2e.rs (2 new positive tests appended) +- examples/int_to_print_int_borrow.ail.json (new) +- examples/heap_str_repeated_print_borrow.ail.json (new) + +Docs: +- docs/DESIGN.md (Decision 10: arg-position policy subsection) +- docs/WhatsNew.md (heap-str-abi milestone close entry) +- docs/roadmap.md (Heap-Str ABI P1 closed; Post-22 Prelude + depends-on line removed) + +## Stats + +bench/orchestrator-stats/2026-05-12-iter-eob.1.json diff --git a/docs/journals/INDEX.md b/docs/journals/INDEX.md index efa09d3..890875c 100644 --- a/docs/journals/INDEX.md +++ b/docs/journals/INDEX.md @@ -35,3 +35,4 @@ - 2026-05-12 — iter hs.2: static-Str layout retrofit — drop the `UINT64_MAX` sentinel slot from packed-struct globals (`<{ i64, i64, [N x i8] }>` → `<{ i64, [N x i8] }>`); IR-`Str` pointer now lands on the `len`-field at struct-index 0 (was 1) via constexpr-GEP `i32 0, i32 0`; `+8` consumer-side GEPs at the four C-API callsites invariant across the switch; renamed `..._sentinel_and_len` test to `..._with_len`; updated 5 sites in `crates/ailang-codegen/src/lib.rs` (3 format strings + 2 doc-comments); regenerated `hello.ll` snapshot; full `cargo test --workspace`, cross_lang.py, compile_check.py, check.py all green; static-Str globals are now 8 bytes smaller per literal → 2026-05-12-iter-hs.2.md - 2026-05-12 — iter hs.3: heap-Str runtime additions — three new symbols appended to `runtime/str.c` (`static str_alloc(uint64_t)` slab helper, `ailang_int_to_str(int64_t)`, `ailang_float_to_str(double)`); supporting ``/``/`` includes + `extern void *ailang_rc_alloc(size_t)` declaration; the extern was promoted from plain-external to `__attribute__((weak))` after the first regression sweep surfaced 11 e2e link failures under `--alloc=gc`/`bump` (public `T` symbols pull in their transitive callees regardless of upstream usage; rc.c is not linked under gc/bump in hs.3 — repair makes str.c link-safe in isolation, no-op once hs.4 lands the unconditional rc.c link); cargo test --workspace + cross_lang.py + compile_check.py + check.py all green on re-sweep; no IR-side caller wired yet (hs.4) → 2026-05-12-iter-hs.3.md - 2026-05-12 — iter hs.4: IR + checker + linker wiring — `int_to_str : (Int) -> Str` installed in checker + synth.rs lockstep partner; IR-header preamble unconditionally declares both `@ailang_int_to_str` / `@ailang_float_to_str`; `Emitter::lower_app` gets new `int_to_str` arm and replaces `float_to_str`'s `CodegenError::Internal` with the actual call emission; `is_static_callee` whitelist extends to `int_to_str`; `runtime/rc.c` hoisted out of `AllocStrategy::Rc` arm to the unconditional link path (the `__attribute__((weak))` on str.c's `ailang_rc_alloc` extern becomes the documented no-op); 2 new IR-shape pins + 4 new E2E (2 stdout-smoke + 2 RC-stats) + 4 `.ail.json` fixtures; `drop.rs` Str-arm comment refreshed to dual-realisation framing; 5 IR snapshots regen for the 2 new declare lines. Status: DONE_WITH_CONCERNS — heap-Str RC-discipline incomplete (slabs leak at program end) because plan's literal `ret_mode: Implicit` interacts with uniqueness analyser walking `Term::Do` args as `Position::Consume`. Test asserts weakened from `allocs == frees && live == 0` to `allocs >= 1`. Substantive fix requires spec-level effect-op arg-mode decision (deferred, bounce-back to user) → 2026-05-12-iter-hs.4.md +- 2026-05-12 — iter eob.1: Effect-op args walked as Borrow (uniqueness.rs + linearity.rs + linearity.rs doc-comment); heap-Str RC discipline closes (int_to_str / float_to_str `ret_mode: Implicit` → `Own` at 4 lockstep sites across builtins.rs + synth.rs; `drop_symbol_for_binder` App-arm gets `Str` carve-out symmetric to `field_drop_call`'s existing one); 2 pre-existing RED tests at e2e.rs (commit 592d87b) flipped to GREEN unchanged with predicted concrete numbers (`allocs==1,frees==1,live==0` and `allocs==2,frees==2,live==0`); 2 new positive tests + fixtures (primitive-Int through `io/print_int`: zero RC traffic; heap-Str repeated borrow through 2× `io/print_str`: `allocs==1,frees==1,live==0`); DESIGN.md §Decision 10 anchors both arg-position rules together (Ctor=Consume, Do=Borrow, plus a paragraph linking Do=Borrow to ret_mode==Own letbinder-trackability); WhatsNew entry shipped, roadmap P1 `[milestone] Heap-Str ABI` checked off, Post-22-Prelude `depends on:` line removed; 7/7 tasks first-shot, zero review re-loops; lint side-effect surface (over-strict-mode on (own T) params used solely via effect-ops) was empty for the current corpus → 2026-05-12-iter-eob.1.md diff --git a/docs/roadmap.md b/docs/roadmap.md index 1411d27..6aee916 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -41,7 +41,7 @@ context. Pick the next milestone from P1.)_ ## P1 — Next -- [ ] **\[milestone\]** Heap-`Str` ABI — runtime infrastructure for +- [x] **\[milestone\]** Heap-`Str` ABI — runtime infrastructure for malloc-backed, refcounted `Str` values alongside the existing static `@.str_*` globals. Today the `Str` path is static-only (DESIGN.md §"Float semantics" notes this explicitly), so any @@ -66,8 +66,6 @@ context. Pick the next milestone from P1.)_ `print` through `Show.show`. Originally queued as 22b.4 in the typeclass milestone, kept on hold pending demand and the heap-`Str` prerequisite. - - depends on: Heap-`Str` ABI (above). Without it, `Show Int` - cannot allocate a `Str` to return. - context: brainstorm 2026-05-12 (iter 23.5 wrap). ## P2 — Medium-term diff --git a/examples/heap_str_repeated_print_borrow.ail.json b/examples/heap_str_repeated_print_borrow.ail.json new file mode 100644 index 0000000..161574b --- /dev/null +++ b/examples/heap_str_repeated_print_borrow.ail.json @@ -0,0 +1,41 @@ +{ + "schema": "ailang/v0", + "name": "heap_str_repeated_print_borrow", + "imports": [], + "defs": [ + { + "kind": "fn", + "name": "main", + "type": { + "k": "fn", + "params": [], + "ret": { "k": "con", "name": "Unit" }, + "effects": ["IO"] + }, + "params": [], + "doc": "Iter eob.1: heap-Str passed to io/print_str twice in sequence. Under the new rule (Term::Do args = Borrow), the second call no longer triggers use-after-consume; the linearity check accepts the program. Under --alloc=rc + AILANG_RC_STATS=1 the slab allocates once and is freed once at scope close (allocs == 1, frees == 1, live == 0).", + "body": { + "t": "let", + "name": "s", + "value": { + "t": "app", + "fn": { "t": "var", "name": "int_to_str" }, + "args": [{ "t": "lit", "lit": { "kind": "int", "value": 42 } }] + }, + "body": { + "t": "seq", + "lhs": { + "t": "do", + "op": "io/print_str", + "args": [{ "t": "var", "name": "s" }] + }, + "rhs": { + "t": "do", + "op": "io/print_str", + "args": [{ "t": "var", "name": "s" }] + } + } + } + } + ] +} diff --git a/examples/int_to_print_int_borrow.ail.json b/examples/int_to_print_int_borrow.ail.json new file mode 100644 index 0000000..ad1b5b7 --- /dev/null +++ b/examples/int_to_print_int_borrow.ail.json @@ -0,0 +1,29 @@ +{ + "schema": "ailang/v0", + "name": "int_to_print_int_borrow", + "imports": [], + "defs": [ + { + "kind": "fn", + "name": "main", + "type": { + "k": "fn", + "params": [], + "ret": { "k": "con", "name": "Unit" }, + "effects": ["IO"] + }, + "params": [], + "doc": "Iter eob.1: primitive Int passed to io/print_int. The rule that Term::Do args are Borrow has no observable RC effect here because Int is unboxed; the test pins that no spurious bookkeeping appears (allocs == 0, frees == 0, live == 0).", + "body": { + "t": "let", + "name": "n", + "value": { "t": "lit", "lit": { "kind": "int", "value": 7 } }, + "body": { + "t": "do", + "op": "io/print_int", + "args": [{ "t": "var", "name": "n" }] + } + } + } + ] +}