iter eob.1: effect-op args walk as Borrow; heap-Str RC discipline closes

Language rule: Term::Do.args[*] are walked in Position::Borrow by
the uniqueness + linearity passes, symmetric to Term::Ctor.args[*]
being walked in Position::Consume. The kind itself carries the
ownership default — no per-op field on EffectOpSig. Three
analyser sites flip in lockstep: uniqueness.rs:289, linearity.rs:506,
and the shared doc-comment at linearity.rs:42.

Heap-Str-specific consequences (closing hs.4's leak):
- int_to_str / float_to_str ret_mode: Implicit → Own at four lockstep
  sites (builtins.rs:200,210 + synth.rs:172,179). The codegen
  trackability gate (drop.rs:464-475, iter 18g.2) now fires on these
  callees, so the let-binder receives a scope-close drop.
- drop_symbol_for_binder's App arm gets a Type::Con{name:"Str"} →
  "ailang_rc_dec" carve-out, symmetric to field_drop_call's existing
  Str arm. Without it, the new Own-trackable App binder would emit
  drop_<m>_Str (undefined).

Tests: the two pre-existing RED tests at e2e.rs (commit 592d87b)
flip to GREEN unchanged with predicted numbers (allocs=1,frees=1,
live=0 and allocs=2,frees=2,live=0). Two new positive tests pin
the rule's coverage: primitive-Int arg through io/print_int produces
zero RC traffic; heap-Str through 2× io/print_str in sequence
typechecks (no use-after-consume) and balances allocs=1,frees=1,
live=0.

DESIGN.md §Decision 10 gets the arg-position-policy table for both
AST node kinds (Ctor=Consume, Do=Borrow) plus a linking paragraph
explaining how Do=Borrow cooperates with ret_mode==Own.

heap-str-abi milestone closes here: WhatsNew entry shipped (Strings
produced at runtime now release cleanly), roadmap P1 entry checked
off, Post-22-Prelude depends-on line removed.

Workspace cargo test + cross_lang.py + compile_check.py all green.
check.py noisy latency cluster + bench_list_sum.bump_s ±12% — same
precedent as the previous two audits, not coupled to this milestone.
This commit is contained in:
2026-05-12 21:29:05 +02:00
parent b5b0c2d7dc
commit 78e8338a8d
14 changed files with 299 additions and 10 deletions
@@ -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
}
+33
View File
@@ -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}");
}
+2 -2
View File
@@ -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(
+7 -2
View File
@@ -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, .. } => {
+1 -1
View File
@@ -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, .. } => {
+8
View File
@@ -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");
+2 -2
View File
@@ -169,14 +169,14 @@ pub(crate) fn builtin_ail_type(name: &str) -> Option<Type> {
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()],
+25
View File
@@ -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` /
+6
View File
@@ -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 (6180% 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.
+123
View File
@@ -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
+1
View File
@@ -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 `<stdint.h>`/`<stdio.h>`/`<stdlib.h>` 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
+1 -3
View File
@@ -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
@@ -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" }]
}
}
}
}
]
}
+29
View File
@@ -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" }]
}
}
}
]
}