Files
AILang/design/models/0009-explicit-rc.md
T
Brummel 9848d4b2df docs(design): explore explicit RC as opt-in, not the floor (model 0009)
Records the runtime-axis dual of ownership totality (model 0008). 0008
totalises the *annotation* (own/borrow everywhere, no Implicit) but keeps
universal RC as the floor; 0009 asks the question totality makes askable
— must the runtime discipline stay universal, or can it follow the type?

Principle: RC is authored, never universal. Default `box` (affine,
refcount-free, deterministic scope-drop ≈ Box); opt-in `rc` (refcounted,
explicit `share` ≈ Rc). Motivated by the maintenance record: ~1/3 of
recent commits touch RC/ownership/drop, no bug was ever in the RC runtime
itself — all lived in the inference (uniqueness-elision, is_rc_heap_allocated,
mode-through-subst) that exists only to reconcile affine ownership with
universal RC and strip the RC it over-charges.

Soundness leans on existing law: the 0015 DAG constraints + linear-by-
default already draw the "needs-a-count" partition; `rc` only names it.
No cycle collector reappears (rc graphs stay acyclic). No `&mut` mode:
in-place mutation rides consume-and-return (move, not alias), so
constraint 3 (immutable-once-constructed) holds — `mut` is out by
principle. §5 enumerates what leaves the compiler (uniqueness-elision,
is_rc_heap_allocated term-shape predicate, over-strict lint, reuse-as,
escape-clawback) and what stays (the affine/linearity check, already
present).

Status-stamped NOT current state, per the honesty rule and the 0008
precedent; INDEX.md left untouched (exploration whitepapers are not on the
canonical spine until adopted). Composes with 0008 regime A, which already
makes transitive box-drop sound. §8 isolates the open questions (closure
capture move/share, clone-vs-share split, coupling to 0008's escape pass,
keyword spelling, sharing-heavy workloads).
2026-06-01 20:18:47 +02:00

20 KiB
Raw Blame History

Explicit RC — refcounting as an opt-in, not the floor

STATUS. Design exploration — NOT current state, NOT an approved milestone. Nothing here is shipped. The canonical memory model today is universal reference counting with static uniqueness inference (../contracts/0008-memory-model.md, 0004-rc-uniqueness.md): every ADT and closure is RC-heap-allocated, and a uniqueness pass elides inc/dec where it can prove a reference unique. This whitepaper records a possible direction — make RC an authored opt-in rather than the universal floor — and isolates the genuinely-open questions in §8. The honesty rule (../contracts/0007-honesty-rule.md) governs the contract ledger, not this exploratory model; this file exists precisely to fix a future shape before committing it. If adopted, it revises the floor recorded in 0004-rc-uniqueness.md and the codegen contract in ../contracts/0008-memory-model.md, and lands as one or more specs under ../../docs/specs/.

This is the runtime-axis dual of ownership totality (0008-ownership-totality.md). That whitepaper makes the annotation total — every position carries own/borrow, no Implicit. This one asks the question totality makes askable: once ownership is authored on every position, does the runtime discipline still have to be universal RC, or can it follow the annotation — affine values drop deterministically with no refcount, and RC narrows to the positions that actually declare sharing? The two compose: 0008 makes ownership total in the type; 0009 makes the runtime mechanism track the type.


1. The pain that motivates it

Memory management is the project's dominant maintenance cost. Across the last ~200 commits, roughly a third touch the RC / ownership / drop machinery. The notable fact about every leak and refcount bug in that history is where it was not: not one was a defect in the RC runtime itself (ailang_rc_alloc, inc, dec, free-on-zero — the header-at-`ptr

  • 8layout inruntime/rc.c` is small and correct). Every bug lived in the static inference that decides where to place inc/dec/drop:

  • #43 A2a — cross-module ownership modes lost because the uniqueness pass ran per-module and missed a type-scoped signature (RawBuf.get), defaulting a borrow argument to Consume and suppressing a scope-close drop.

  • #43 A2b — the uniqueness side table is keyed by (def_name, binder_name); a shadow-rebind (let buf … let buf …) collapsed the keys, so codegen read a sibling binding's consume_count. The repair was a whole desugar alpha-rename pass (docs/specs/0056, plan 0111).

  • #47 / #49 — loop-carried owned values (a RawBuf accumulator, a Str threaded through recur) leaked because the codegen predicate is_rc_heap_allocated had no arm for that term shape.

  • 449df13Subst::apply silently rebuilt every Type::Fn with param_modes: vec![], dropping the modes; the typed-MIR drop path then could not see ownership and leaked.

The codegen-side complexity map quantifies it: roughly 22 independent conditions — spread across uniqueness.rs, linearity.rs (2149 lines), escape.rs, drop.rs, match_lower.rs, lower_to_mir.rs and the substitution/monomorphisation passes — must all agree for a single drop to be placed correctly.

The diagnosis is not "RC is slow" or "RC is wrong." It is that three memory disciplines run in parallel and the bugs live at their seams:

  1. Affine ownership via own/borrow modes (the Rust-shaped part);
  2. Universal reference counting underneath everything;
  3. A uniqueness inference that exists only to reconcile (1) with (2) — to prove references unique so it can elide the RC traffic that (2) would otherwise emit.

Discipline (3) is pure overhead in the literal sense: it is machinery whose entire job is to remove the runtime mechanism of (2) wherever it can. The corpus is overwhelmingly single-owner functional code — lists, trees, RawBuf fills — so the inference is mostly succeeding at proving uniqueness and stripping the RC. We pay enormous static-analysis complexity to delete a mechanism we mostly never needed, and the bugs are in the deletion logic.

2. The principle: RC is authored, never universal

0008's principle is "ownership is authored, never defaulted." The dual on the runtime axis is: the refcount is authored, never universal.

A heap value is one of two things, and the author says which:

  • owned / box — a unique heap allocation. Exactly one live owner at any program point. No refcount. Freed deterministically when the owner's lexical scope ends or the owner is moved-and-consumed. This is Rust's Box<T> — affine, single-owner, drop-at-scope.
  • shared / rc — a refcounted heap allocation. Multiple owners may coexist; each is created by an explicit, source-visible share, each contributes one to the count, and the count is decremented at each owner's scope-end with free-on-zero. This is Rust's Rc<T>.

The default is box. rc appears only where an author declares genuine sharing. Refcounting stops being the floor and becomes a named, visible, rare construct — the dual of how 0008 makes Implicit unrepresentable.

This admits no half-application, exactly as totality does not (0008 §2): a value's heap discipline must be readable from its type, or the trust break local reasoning forbids has merely moved from "which mode?" to "counted or not?".

3. Why affine drop is sound here without a cycle collector

The move is only legal because AILang has already legislated the preconditions. ../contracts/0015-language-constraints.md binds four constraints — strict evaluation, no recursive value bindings, no shared mutable refs, ADTs acyclic by construction — whose stated purpose is to make the reference graph a DAG. Today those constraints justify "RC without a cycle collector." They justify something stronger that the universal-RC framing leaves on the table:

A DAG of single-owner values needs no refcount at all. Deterministic drop at end-of-owner-scope frees every node exactly once, in reverse-construction order, with no count to maintain.

Refcounting earns its keep on exactly one thing: a node with more than one owner (so that no single scope-end may free it). The DAG constraint plus AILang's linear-by-default with explicit clone (0004-rc-uniqueness.md §2) already forces every act of sharing to be written. So the set of values that genuinely need a count is precisely the set the author already had to mark. Making that set the rc set is not a new burden — it is naming a partition the language already draws.

The cycle question does not reappear: an rc value is still acyclic by construction (constraints 14 are unchanged), so no rc graph can leak via a cycle. rc buys sharing, not cycles — the cycle-collector backstop stays unnecessary for the same reason it is unnecessary today.

4. Worked surface

The author-facing surface barely changes; the change is what the compiler must do underneath. (Form-A keywords box / rc / share below are illustrative; the real spelling is an open question, §8 Q4, and should align with 0008 §3.4's adjectival argument — likely owned heap vs shared heap.)

4.1 The linear majority — no RC at all

(data List
  (ctor Nil)
  (ctor Cons (con Int) (box List)))     ; unique tail — affine, refcount-free

(fn map_inc
  (type (fn-type (params (own (box List))) (ret (box List))))
  (params xs)
  (body
    (match xs
      (case (pat-ctor Nil) (term-ctor List Nil))
      (case (pat-ctor Cons h t)
        (term-ctor List Cons (app + h 1) (app map_inc t))))))

Count the occurrences of rc: zero. For the entire list / tree / RawBuf corpus the author writes box, and box carries no count. The inc/dec inference, the reuse-as hint, the over-strict-mode lint, and the escape-analysis-to-reclaim-stack all have nothing to do here, because "this allocation is not shared" is a type fact, not an inference result.

4.2 Genuine sharing — the one place rc and share appear

(fn two_views
  (type (fn-type (params (own (rc List))) (ret (con Pair))))
  (params shared_tail)
  (body
    (let a (term-ctor List Cons 1 (share shared_tail))   ; +1 owner, visible
      (let b (term-ctor List Cons 2 (share shared_tail)) ; +1 owner, visible
        (term-ctor Pair MkPair a b)))))                  ; a, b, shared_tail
                                                         ; drop at scope-end → dec

Forget a share and use shared_tail twice and it is a use-after-move error on that line — the affine/linearity check (linearity.rs, which already exists) catches it locally — not a silent leak surfacing three passes later in codegen. The failure class moves from "the compiler placed a drop wrong" to "the author did not declare sharing," and the latter is local and checkable.

4.3 In-place mutation: unique-by-type replaces unique-by-inference

RawBuf is the language's only mutation, and the case where explicit ownership pays the most. Its set is own RawBuf → own RawBuf and its doc states the current semantics exactly:

Under uniqueness in-place; under shared copy-on-write (Issue #22).

i.e. whether a write reuses the slab or copies is decided by the uniqueness inference — the fragile part (A2a, A2b, the loop arm). With box:

(fn set                                                  ; HYPOTHETICAL
  (type (forall (vars a)
    (fn-type (params (box (con RawBuf a)) (own (con Int)) (own a))
             (ret (box (con RawBuf a))))))
  (params b i v) (intrinsic))

A box is unique by type, so the write is in-place unconditionally — no proof, no copy-on-write branch, no refcount check at the write site. There is no shared case for a box; an author who wanted sharing wrote rc, and set on an rc is a type error ("cannot mutate a shared buffer") rather than a silent COW branch.

This is consume-and-return, not mutable aliasing. set consumes its buffer (a move); the old binding is dead afterward and never observable, so semantically a new value is produced and "immutable once constructed" (constraint 3) holds for every individual value — the in-place slab reuse is an optimisation under the hood, never a language-visible write through a live alias. A &mut-style mutable-borrow mode is therefore out of scope by principle, not preference: a mutable borrow is a mutable alias, which constraint 3 forbids, and it is the one genuinely hard part of Rust's borrow checker (aliased-mutation policing) that AILang's identity removes the pressure for. In-place update rides uniqueness/linearity (now: type), exactly as 0008 §8 records, not exclusive borrows.

The shadow-rebind idiom (let buf … let buf …) may stay verbatim, and A2b still cannot recur — not because the rebind is gone, but because there is no uniqueness side table to collapse. Codegen reads no per-name consume_count to decide in-place vs. drop; drop is placed at the lexical scope of the move-checked binding. The alpha-rename pass C (spec 0056) was load-bearing only for disambiguating side-table keys; absent the side table it is at most optional scoping hygiene.

5. What this removes from the compiler, and what stays

Removed — from author code and from the compiler:

  • The uniqueness-elision inference (uniqueness.rs's consume_count dataflow) — its entire job was to strip universal RC; with no universal RC there is nothing to strip. A box is non-shared by type; an rc is counted by type.
  • The is_rc_heap_allocated term-shape predicate with its Ctor/Lam/Own-App/Loop arms — the thing that kept missing cases (#47). "Does this need a drop?" becomes a type question (box/rc), total and arm-free, not a term-shape guess.
  • The over-strict-mode lint and its suppress mechanism — there is no over-strict own to warn about once box carries no count.
  • reuse-as as a distinct hint — a unique box is reusable in place because it is the sole owner; reuse is a property of the owner, not an author annotation the inference must verify.
  • The escape-analysis-to-reclaim-stack pass (escape.rs) — an owned box already is "not shared," so stack-promotion of non-escaping allocations is a plain liveness question, not a clawback from a universal-RC default.

Kept (and made more prominent):

  • The affine / linearity check (linearity.rs) — use-after-move, consume-while-borrowed. This is the load-bearing safety pass and it already exists; it becomes the mechanism rather than a diagnostic layered over an inference.
  • own / borrow — unchanged, call-scoped, no lifetime variables (0008 §8).
  • clone / share — but now type-distinguished: share on an rc is a cheap inc; "clone" of a box is a genuine copy. The two meanings the single Term::Clone conflates today are split by the type.

The net is fewer moving parts than today, not more: the borrow check stays (we pay it already), and the universal-RC + elision-inference + escape-clawback triad collapses into one type-directed rule.

6. Relation to ownership totality (0008) and to Rust

0008 and this whitepaper are two cuts of the same object and are most coherent adopted together:

Axis 0008 (totality) 0009 (explicit RC)
What it totalises the annotation (own/borrow everywhere) the runtime discipline (counted vs. affine)
Removes Implicit default universal-RC default
Default own (trivially true on value types) box (affine, no count)
The opt-in borrow (a lent view) rc (a shared, counted value)
Soundness pass return-provenance / escape (0008 §5.2) the same affine check, now load-bearing

0008 §5.1 already takes regime A — borrows may not escape into an escaping heap structure; a field of a returned box is necessarily owned. That decision is exactly what makes box drop transitively sound here: under regime A, dropping an owned box recursively frees owned children with no borrowed member to mis-free. The two designs share one substrate.

This is not a rebuild of Rust, for the reasons 0008 §8 already lays out and this proposal sharpens: no lifetime variables (point-free is cut, §4.4 there), no &mut / shared-XOR-mutable (§4.3 here, constraint 3), no elision (totality is the opposite of Rust's lifetime elision). What this adds to 0008 is the one further Rust correspondence 0008 left implicit: boxBox, rcRc, and the default is Box, not Rc — which is the entire point. Today AILang's default is effectively Rc everywhere with a pass trying to downgrade it to Box; the proposal flips the default to Box and makes Rc the marked case, which is both what Rust does and what the corpus's actual sharing profile wants.

7. Feature-acceptance check

../contracts/0004-feature-acceptance.md: a feature ships only if an LLM author reaches for it unprompted and it measurably improves correctness or removes redundancy.

  • Reached for unprompted. The Box/Rc distinction is core Rust vocabulary; an LLM author trained on Rust produces Box for unique ownership and Rc only for sharing without prompting. The construct is LLM-native in the strongest sense — it is what the model already expects, and the current implicit-universal-RC model is the surprise.
  • Correctness. It deletes the inference layer that produced ~⅓ of recent commits and the three named leak clusters; "is this shared?" becomes a checked type fact rather than a dataflow result codegen re-reads out of band.
  • Redundancy. It removes universal refcount traffic (inc/dec the uniqueness pass mostly elides anyway) and the reuse-as / over-strict / escape-clawback machinery that exists to claw back what universal RC over-charges.

Both clauses are met. The cost (below) is real but is implementation effort and one irreversible hash reset, not a design objection — and per the design-rationale rule effort is at most a tiebreaker.

8. Costs, consequences, open questions

Named honestly:

  • Memory-model swap + hash reset. This revises the canonical floor (0004-rc-uniqueness.md) and the codegen contract (../contracts/0008-memory-model.md). As in 0008 §6, removing a serialisation default (here: the RC-everywhere assumption baked into layout and drop emission) shifts module hashes once across the corpus and resets every hash-pin test. This is the one genuinely irreversible step and the one to sign off on deliberately. The hash-pin blast radius spans every crates/*/tests/ hash-pin file, not only the AST-home crate.

  • Closures are the hard case. A closure captures an environment; capture-by-move (box) vs. capture-by-share (rc) is a real authored decision, and the capture set is where single-ownership is least obvious. Today captures are conservatively Consume (uniqueness.rs, "Lam captures are conservatively Consume" — unsound-but-safe). The explicit model must make move/share on captures authored and checked. This is the closure-chain benchmark that already measures widest against the RC target (0004-rc-uniqueness.md). Open (Q1): what does the capture surface look like, and does the affine check cover capture-by-move without new machinery?

  • clone semantics split. Term::Clone today is "RC inc." Under the split, share on an rc is the inc; cloning a box is a genuine (structural) copy. Open (Q2): is a structural deep-copy of a box a primitive, or is it only ever "promote to rc then share"? The latter keeps one sharing mechanism; the former allows owned duplication without ever introducing a count.

  • Coupling to 0008's return-side pass. 0008 §5.2 shows that for a compound box return, the flat ownership claim is sound only if a reachability pass proves nothing borrowed is reachable. That pass is the same one this proposal relies on for transitive box drop. Open (Q3): does box-drop soundness require 0008's escape analysis to land first, making 0009 strictly downstream of 0008's verification axis? (Working hypothesis: yes for compound returns; scalar box drop is independent.)

  • Keyword spelling. Open (Q4): box/rc/share vs. the adjectival owned/shared that 0008 §3.4 argues for on position-neutrality grounds. The choice should land before the hash reset, since renaming a keyword post-cutover is a second irreversible step (0008 §7 Q3 makes the identical argument for own/borrow).

  • Sharing-heavy workloads. The proposal bets the corpus is overwhelmingly single-owner. Open (Q5): is there a real workload class (memoised DAGs, hash-consed terms, shared dictionaries) where rc would be pervasive rather than rare? If so, the ergonomics of pervasive share need a second look — though even then the correctness argument (sharing is checked, not inferred) stands.

Resolved by the shape above (not open): the default is box, not rc (§2); affine drop is sound under the existing DAG constraints (§3); no &mut / mutable-borrow mode, in-place rides consume-and-return (§4.3); the uniqueness-elision inference, is_rc_heap_allocated, over-strict, reuse-as, and escape-clawback are removed, the affine check stays (§5).


Cross-references: ownership totality (the annotation-axis dual) 0008-ownership-totality.md; current memory model ../contracts/0008-memory-model.md; RC + uniqueness floor this revises 0004-rc-uniqueness.md; the language constraints that make affine drop sound ../contracts/0015-language-constraints.md; feature-acceptance gate ../contracts/0004-feature-acceptance.md.