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
+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