diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 1b7a38b..d9a0c38 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -6304,3 +6304,164 @@ surface; 18c.3's inference is internal codegen-side bookkeeping. The two layers communicate only through the explicit `Term::Clone` markers in the source — the linearity check demands them, the codegen pass honours them. + +## Iter 18c.3 — uniqueness inference + non-recursive RC `inc`/`dec` + +End of the leak-everything era under `--alloc=rc`. Codegen now +emits `ailang_rc_inc` at every `Term::Clone` and `ailang_rc_dec` +at end-of-scope of trackable RC-allocated let-binders. The +default `--alloc=boehm` path is untouched. + +### Two parts, two files + +**Part A — `crates/ailang-check/src/uniqueness.rs`** (new module, +`pub mod uniqueness`). Post-typecheck dataflow producing +`UniquenessTable: BTreeMap<(def, binder), UniquenessInfo>` with +`Uniqueness::{Unique, Shared}` and a `consume_count: u32` +side-output. The walk reuses the position model from 18c.2's +linearity check (Consume vs Borrow), but runs unconditionally on +every fn (not gated on all-explicit modes) and produces no +diagnostics — the table is internal codegen input. + +`consume_count` is **max-over-paths**: at `Term::If` and +`Term::Match` the walker saves state, walks each arm against the +saved snapshot, and `merge_states` takes the max of the +per-binder counters. So `(let x e1 (if c x x))` gets +`consume_count = 1` (one arm consumes once, the other arm +consumes once, max = 1), correctly preventing codegen from +double-dec'ing on either branch. + +`Term::Clone { value }` is treated as a Borrow on the inner term +— the explicit clone is the user's signal that a fresh ref is +being produced via inc, not a fresh consume of the original +binder. + +5 unit tests: let unused / consumed once / consumed twice / +pattern bindings / `(clone)` is borrow. + +**Part B — codegen emission in +`crates/ailang-codegen/src/lib.rs`.** Two seams: + +- **`Term::Clone { value }`** (lib.rs:1256–1278): lower the inner + value, then under `--alloc=rc` and `val_ty == "ptr"` and + `!val_ssa.starts_with('@')` emit + `call void @ailang_rc_inc(ptr %v)`. The `@`-prefix gate elides + inc on top-level fn closure-pair globals — those live in the + LLVM data segment, not in `runtime/rc.c`'s 8-byte-header heap. + +- **`Term::Let { name, value, body }`** (lib.rs:1058–1110): a + new `is_rc_heap_allocated` predicate (lib.rs:984–1012) decides + whether `value` lowers through `ailang_rc_alloc` — true iff + `value` is `Term::Ctor` or `Term::Lam` AND the term is in the + current fn's escape-set (i.e. it heap-allocates rather than + `alloca`-allocates). When true AND `val_ty == "ptr"` AND the + uniqueness table reports `consume_count == 0` AND the body's + tail SSA is not the binder itself AND the current block is not + terminated, codegen emits + `call void @ailang_rc_dec(ptr %v)` after the body lowers, + before the let exits. The four extra gates each catch a + failure mode: + - `consume_count == 0`: a non-zero count means a callee/outer + site already took ownership; double-dec is undefined + behaviour against `runtime/rc.c`'s underflow guard. + - body-tail-is-binder: a let whose body returns the binder + transfers ownership to the caller — caller dec's, not us. + - block-not-terminated: a tail call or `unreachable` already + closed the block; emitting after is malformed LLVM IR. + - `non_escape` membership: stack `alloca`s have no header to + dec. + +Header declarations of `@ailang_rc_inc` / `@ailang_rc_dec` are +gated on `--alloc=rc` (lib.rs:421–429) so the Boehm and Bump +modules' IR shape is byte-identical to before this iter. + +### Fixture and E2E + +`examples/rc_box_drop.ail.json`: a single-cell `Box(Int)` ADT, +`main` does `(let b (MkBox 42) (match b (MkBox x) (print x)))`. +`b` has `consume_count == 0` (only borrow-position match +scrutinee), so codegen emits `dec` after the match join. The +`MkBox` cell has no boxed children, so the shallow `free()` in +`runtime/rc.c::ailang_rc_dec` is sufficient — this fixture +deliberately avoids the recursive-dec story (deferred to 18c.4). + +E2E test `alloc_rc_emits_dec_for_unique_let_bound_box` at +`crates/ail/tests/e2e.rs:1349` builds the fixture under both +`--alloc=gc` and `--alloc=rc`, asserts both emit `42`, and +asserts byte-identical stdout. Properties guarded: +1. dec does not free the box BEFORE the match reads its payload; +2. dec does not double-free or trip the underflow guard; +3. RC vs GC paths produce identical observable behaviour. + +### Test deltas + +- E2E: 52 → 53. +- ailang-check unit: 38 → 43 (5 new uniqueness tests). +- All other buckets unchanged. +- `cargo test --workspace` green. + +### Scope cuts (deliberate) + +- **Recursive `dec` cascades / per-type drop fns — Iter 18c.4.** + `runtime/rc.c::ailang_rc_dec` still frees the box without + recursing into boxed children (its top-of-file comment already + documents this). 18c.3 ships shallow free; recursive ADTs + (List, Tree) under `--alloc=rc` still leak everything except + the outermost cell. The fix is a per-type drop function the + codegen emits for each ADT — one `void @drop_(ptr)` + symbol that walks the boxed children, dec's each, then frees + the outer cell. + +- **Binders with `consume_count > 0`.** No `dec` is emitted — + the value moved to a callee or returned, and the receiving + site is responsible. This is correct under the current ABI + for callee-takes-ownership, but means a fixture that returns + a heap-allocated value still leaks the outer cell (the + caller's let-binding is a separate scope and a separate + question). 18c.4 / 18d will tighten this. + +- **Fn parameters.** The uniqueness table records them but + codegen does not emit `dec` on params — there's no static + signal yet for "this parameter is RC-allocated and the caller + has handed off ownership". `(own T)` parameters under explicit + modes carry exactly that semantic; wiring it through is part + of the wider mode-aware codegen story (likely 18d alongside + reuse hints). + +- **`runtime/rc.c::ailang_rc_inc` UB on static globals.** + Codegen now elides `inc` on `@`-prefixed SSAs, so the UB path + in `runtime/rc.c` is unreachable in practice. The runtime + itself still has no guard; a defensive check (e.g. flag bit + in the header word) is a tidy-iter concern, not load-bearing. + +### Why a separate inference pass from `linearity` + +Different scope, different output, different consumption point. +`linearity` is opt-in by signature (every param mode explicit) +and emits user diagnostics; uniqueness inference runs +unconditionally on every fn body and produces an internal side +table. They share the position model but nothing else, and +collapsing them would either force user-facing diagnostics on +fns that don't opt in, or hide the codegen input behind a gate +the codegen doesn't want. The two-pass design preserves +Decision 10's separation: linearity is the user-visible +*language* surface; uniqueness is the *implementation*. + +### Next + +Iter 18c.4 (queued, was implicit in 18c original splitting): +per-type drop fn / recursive `dec` cascade. Codegen emits a +`void @drop_(ptr)` for each `Type::Type` ADT that walks +boxed children, calls `ailang_rc_dec` on each, then frees the +outer cell. The `Term::Let` dec-emission seam from 18c.3 then +calls `@drop_` instead of `@ailang_rc_dec` directly +when the binder's type has boxed children. After 18c.4, RC +ADTs (List, Tree) under `--alloc=rc` should be allocation-leak- +free under `valgrind --leak-check=full`. + +After 18c.4 closes, the 18-arc continues with 18d (reuse hints ++ reuse analysis), 18e (drop-iterative + worklist free), 18f +(RC validation bench + Boehm retirement). After 18f the entire +18-arc closes — at which point the new tidy-iter rule from +CLAUDE.md kicks in: the next iter is `ailang-architect`-driven +drift cleanup before 19 starts.