JOURNAL: Iter 18c.3 — uniqueness inference + non-recursive RC inc/dec

Records the inference's max-over-paths semantics, the codegen
gates (consume_count == 0, body-tail-not-binder,
block-not-terminated, non-escape membership), the @-prefix elide
on closure-pair globals, the rc_box_drop fixture's choice of a
no-boxed-children ADT (shallow free sufficient), and the explicit
scope cut for per-type drop fn (18c.4). Closes with the dispatch
note for 18c.4 and the reminder that the new CLAUDE.md tidy-iter
rule fires after 18f when the 18-arc closes.
This commit is contained in:
2026-05-08 10:25:30 +02:00
parent 307a3ea848
commit b4cddd830e
+161
View File
@@ -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:12561278): 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:10581110): a
new `is_rc_heap_allocated` predicate (lib.rs:9841012) 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:421429) 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_<type>(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_<type>(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_<value-type>` 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.