DESIGN/JOURNAL: Decision 10 reframed — RC + LLM-author annotations

Replaces the earlier "RC + inference, no annotations" position
with a two-layer architecture: inference (intra-fn) + mandatory
LLM-author mode annotations on fn signatures (inter-fn). Adds
structured rationale for rejecting region inference (regions
assume stack-shaped lifetimes; real programs need
non-stack-shaped lifetimes for caches / memo tables) and linear
types as primary mechanism.

Schema additions enumerated for the 18-series:
  Type::Borrow / Type::Own (Iter 18a)
  Term::Clone (Iter 18c)
  Term::ReuseAs (Iter 18d)
  TypeDef.drop_iterative (Iter 18e)

Migration plan extended from 18a-d (4 iters) to 18a-f (6 iters).

JOURNAL records the regions excursion, the scope-shape reduction,
and the LLM-author lever as the structural advantage Decision 10
cashes in on.
This commit is contained in:
2026-05-08 01:52:40 +02:00
parent 65e280bb70
commit e3ecf4f0cd
2 changed files with 631 additions and 1 deletions
+343 -1
View File
@@ -543,7 +543,15 @@ constructor-blocked recursions in `map`, `sort`, `insert`
remain unmarked — they cannot benefit from `musttail`
without a source-level rewrite.
## Decision 9: memory management — Boehm conservative GC
## Decision 9: transitional allocator — Boehm conservative GC
**Status: superseded by Decision 10 (RC + Uniqueness, 2026-05-08).**
This section documents the active runtime as of Iter 17a / the
GC bench, but it is no longer the canonical memory model. Boehm
remains linked behind `--alloc=gc` (the current default) until
the RC pipeline (Iters 18a18d) is validated and the default
flips. The `--alloc=bump` mode introduced for the bench is a
measurement tool, not a production target.
Through Iter 14e, every ADT box, lambda env, and closure pair was
allocated with bare `malloc` and never freed. That worked for the
@@ -660,6 +668,340 @@ The closure-pair and its env share an escape verdict — they have
parallel lifetimes. If the closure pair is non-escaping, the env
is too.
## Decision 10: memory model — RC + Uniqueness with LLM-author annotations
**Committed 2026-05-08, after the GC bench (`bench/run.sh`) showed
Boehm contributing ~60% of runtime on allocation-heavy workloads
that hold the heap fully live. Sharpened later the same day: the
mainstream "RC + inference" position was extended with mandatory
LLM-author mode annotations (`borrow` / `own`), explicit `clone`,
first-class `reuse-as`, and `drop-iterative` data attrs.**
The cost of GC is structurally in the allocate path — Boehm's
`GC_malloc` is ~2.8× slower than a bump pointer, and the bench
workloads exercised allocate cost without collection cost.
Tracing GC's irreducible variability cannot be tuned away; RC's
costs are bounded and analysable per program point. A corpus
committed to one memory model is expensive to switch — pre-
stdlib is the cheapest moment to commit.
**Choice.** AILang's canonical memory model is reference counting
with static uniqueness inference **and explicit LLM-author
annotations on fn signatures**, in the lineage of Lean 4 / Roc /
Koka. Boehm becomes a transitional allocator (Decision 9) and is
retired when the RC pipeline matches the bump-allocator floor
within an acceptable margin (target 1.3× on `bench/run.sh`).
The architecture has two layers:
1. **Inference.** A post-typecheck pass produces a per-node
uniqueness side table. Codegen uses it to elide inc/dec
wherever provably redundant.
2. **LLM-author annotations.** Fn signatures carry mandatory
`(borrow T)` / `(own T)` mode markers. Authors mark
sharing-vs-consumption explicitly. The compiler verifies
rather than guesses.
The combination plays to what LLMs are good at (writing slightly
more annotation per definition) and avoids what compilers are
bad at (proving sharing absent in the face of recursion +
closures + match).
### Why not other memory models
**Tracing GC.** Open-ended. Boehm's bench number (~60% allocate-
path overhead) is structural; tuning Boehm cannot move that
needle. A precise tracing GC would need read/write barriers,
root maps, generational machinery — months of work, with
irreducible pause-time variability at the end. The user's
framing was decisive: "Am GC kannst du ewig rumschrauben (und
bekommst trotzdem auch in 100 Jahren keine berechnbare
Performance)."
**Region inference (Tofte/Talpin / MLton-style).** Considered
seriously; rejected. Regions tie lifetimes to dynamic scope —
every value lives in some region, regions stack on entry/exit,
deallocation is bulk-by-region. The reduction: a region is an
explicit allocator with sugar plus a static check that nothing
escapes its lifetime. The deeper problem: regions assume
**stack-shaped lifetimes**. Real programs have non-stack-shaped
lifetimes — caches, memo tables, registries, lookup structures
whose lifetimes are not nested. Regions would either force these
into a `letregion` at the top of `main` (everyone's allocator
becomes the root region — useless), or require a region per
cache variant (combinatorial). RC has no lifetime-shape
assumption; it works for any DAG.
**Linear / ownership types as primary mechanism (Rust-style).**
Considered as a general-purpose alternative; rejected as primary.
Rust's borrow-checker is a great mechanism but requires the
author to thread lifetimes through every signature and accept
that some programs cannot be expressed without `unsafe`. For an
LLM-targeted language with recursion + closures everywhere, that
cost is too high — most of the source surface would be lifetime
annotations rather than logic. AILang uses a *subset* of these
ideas (mode annotations, linear consumption discipline)
selectively, layered on top of RC, where the annotations buy
concrete optimisations and never have to thread lifetimes
through callees.
### The LLM-aware sharpening
A mainstream RC implementation (think Lean 4 in default mode)
infers everything from naked AST plus a few optional hints. The
inference is conservative; whatever it can't prove unique becomes
shared and pays runtime inc/dec. AILang exploits its target
audience to push that conservative ceiling higher.
Five mechanisms.
**(1) Mandatory `(borrow T)` / `(own T)` on fn signatures.**
```
(fn list_length
(type (fn-type (params (borrow (List Int))) (ret (con Int))))
...)
(fn sum_list_consume
(type (fn-type (params (own (List Int))) (ret (con Int))))
...)
```
`(borrow T)` declares the parameter is read-only and lives at
most until the call returns; the caller still owns it; the
callee performs no inc/dec on it. `(own T)` declares ownership
transfer; the callee consumes the value and is responsible for
its end-of-life. The declaration is structural (visible in JSON)
and binding (the typechecker rejects bodies that contradict it).
For a Lean 4 / Roc author this is all *optional* and inferred
when omitted. AILang makes it mandatory because the LLM author
can carry the cognitive cost trivially, and the compiler gains a
precise contract at every call site instead of a probabilistic
guess.
**(2) Linear-by-default consumption with explicit `(clone X)`.**
In bodies, every binder is consumed by exactly one `own`-mode
use. If the LLM writes:
```
(let p (expensive_fn x)
(let r1 (consume_a p) ; consume_a takes (own); consumes p
(let r2 (consume_b p) ; ERROR: p already consumed
...)))
```
the compiler emits a structured non-linear-use diagnostic with
concrete `suggested_rewrites`:
- "make consume_a borrow": refactor consume_a's signature, no
body change at the call site;
- "explicit clone": insert `(clone p)` at the first use;
- "fuse traversal": replace the two separate calls with a fused fn.
The LLM picks one. There is no implicit clone — sharing always
costs visible source.
**(3) Reuse hints as first-class.**
```
(fn map_inc
(type (fn-type (params (own (List Int))) (ret (own (List Int)))))
(params xs)
(body
(match xs
(case Nil Nil)
(case (Cons h t)
(reuse-as xs (term-ctor List Cons (app + h 1) (app map_inc t)))))))
```
`(reuse-as SRC NEW-CTOR)` asks the codegen to allocate `NEW-CTOR`
in `SRC`'s memory slot. Compiler verifies: `SRC` is owned, this
is its last use, sizes match. On a hit: no malloc, no free — the
box is overwritten in place. On a miss: structured diagnostic
explains which precondition failed; the LLM either adjusts the
surrounding code or removes the hint.
This matches Lean 4 / Roc reuse analysis but lifts it from
"compiler-inferred when possible" to "author-asserted, compiler-
verified". The LLM applies it everywhere it expects to fire and
lets the compiler bounce the request when it can't.
**(4) `(drop-iterative)` annotation on data declarations.**
```
(data Tree (vars a)
(ctor Leaf)
(ctor Node a (Tree a) (Tree a))
(drop-iterative))
```
When the refcount of a `Tree` value reaches zero, the synthesised
dec-on-zero traversal is iterative (worklist + heap-allocated
stack) instead of recursive. Avoids stack overflow on deep
structures. The LLM adds the annotation where appropriate; the
compiler refuses to emit recursive dec-cascade on annotated types.
**(5) Structured compiler diagnostics with `suggested_rewrites`.**
Every RC-mode error (use-after-consume, mode-mismatch,
reuse-as-fail, drop-cascade-too-deep) emits a JSON object
containing the failure kind, the source span, and a list of
concrete rewrite suggestions in form-A AILang. The LLM consumes
these without prose-parsing. This is the missing half of the
LLM-as-author story: the language spec defines not only what
compiles, but what the compiler tells the author when it doesn't.
### Language-design constraints (binding)
The four constraints below are necessary preconditions for RC to
be sound and complete *without* a cycle-collector backstop. They
are not new — AILang already satisfies all four — but Decision 10
makes them load-bearing rather than incidental:
1. **Strict evaluation.** Every `Term::App` argument is fully
evaluated before the call. No laziness, no thunks. (Already
true.)
2. **No recursive value bindings.** `(let x EXPR ...)` evaluates
`EXPR` in a scope where `x` is *not* bound. Recursion is
exclusively via `Term::LetRec` (which binds a fn, not a value)
and module-level fn defs. (Already true.)
3. **No shared mutable refs.** Values are immutable once
constructed. There is no `ref`, `IORef`, `Mutex`, or any
primitive that allows a value to be mutated from a position
outside its allocation. (Already true.)
4. **ADTs are acyclic by construction.** Strict evaluation +
no-recursive-value-bindings + no-shared-mutable-refs together
guarantee that any value graph reachable from a binding is a
DAG. The reference graph has no cycles. (Follows from 13.)
A future iter that proposes any of laziness, recursive value
bindings, shared mutable state, or any feature that creates
cycles must either prove the cycle is collectible by an
extension (e.g. linear ownership) or be rejected at design time.
### Schema additions
**Iter 18a — mode wrappers on `Type`.**
```
Type::Borrow(Box<Type>) ; `(borrow T)` in form-A
Type::Own(Box<Type>) ; `(own T)` in form-A
```
Both are transparent for unification (they erase to their inner
`Type` for HM purposes) but carry binding mode metadata for the
RC pipeline. Skipped during JSON serialisation when absent so
canonical hashes of every pre-18a fixture stay bit-identical.
The legacy `(con T)` form is treated as `(own T)` semantically
throughout the 18-series; a later iter (deferred) makes the
explicit annotation mandatory and rejects bare `(con T)` for
boxed parameter types.
**Iter 18c/18d — new `Term` variants.**
```
Term::Clone { value: Box<Term> } ; `(clone X)` — explicit RC inc
Term::ReuseAs { source: Box<Term>, body: Box<Term> } ; `(reuse-as SRC NEW-CTOR)`
```
**Iter 18e — `TypeDef` attribute.**
```
TypeDef.drop_iterative: bool ; `(drop-iterative)`
```
All four are skipped during serialisation when absent / false /
None so canonical-JSON hashes of every fixture remain stable
until the fixture intentionally adopts the feature.
### Inference algorithm (Iter 18c)
Post-typecheck, post-`lift_letrecs`, pre-codegen pass over the
elaborated module. For each `Term` node that produces or binds a
boxed value, the pass computes a uniqueness flag:
- **Unique:** at this program point, the reference is the only
outstanding reference to its referent.
- **Shared:** there may be multiple outstanding references.
A reference is *unique* if every path from its allocation to the
current program point passes through exactly one binding. The
inference is a forward dataflow over the AST. The annotations
from 18a (`borrow` / `own`) provide the inter-fn contract; the
inference fills in intra-fn detail. (Implementer-level detail in
the Iter 18c brief.)
### Codegen contract (Iter 18b, 18c)
Memory layout (Iter 18b):
- Every heap allocation has an 8-byte refcount header, followed
by the payload. `ailang_rc_alloc(size)` returns a pointer to
the *payload*; the header is at `ptr - 8`.
- `ailang_rc_inc(ptr)`: load `ptr - 8`, +1, store. Non-atomic
(single-threaded).
- `ailang_rc_dec(ptr)`: load, -1, store; if zero, recurse-dec
child references and `free(ptr - 8)`. For `(drop-iterative)`
types, the recursion is replaced by a worklist loop (Iter 18e).
Codegen for `Term::Ctor` / `Term::Lam` env / closure pair under
`--memory=rc` calls `ailang_rc_alloc(SIZE)`. Iter 18b stops there
— inc/dec instrumentation is added in Iter 18c, once the
inference is wired up. Until then, `--memory=rc` deliberately
leaks like the pre-Boehm era; this is purely about plumbing.
### Migration plan
1. **Iter 18a:** `(borrow T)` / `(own T)` annotations as a
language feature. Schema, parser, JSON, typechecker. No
codegen change. `(con T)` ≡ `(own T)`. Existing fixtures
unchanged.
2. **Iter 18b:** RC runtime. `runtime/rc.c` with header layout +
alloc/inc/dec. Codegen `--memory=rc` routes allocation through
`rc_alloc`; **no inc/dec yet**. `--memory=gc` remains default.
3. **Iter 18c:** uniqueness inference + codegen inc/dec
instrumentation. Combines 18a's annotations with intra-fn
dataflow. Linear-by-default enforcement turns on. `(clone X)`
in the `Term` schema.
4. **Iter 18d:** reuse hints + reuse analysis. `(reuse-as ...)`
form. Codegen rewrites dec+malloc into in-place overwrite when
the precondition holds.
5. **Iter 18e:** `(drop-iterative)` data attr. Worklist-based
free for annotated types.
6. **Iter 18f:** RC validation bench. If RC within 1.3× of bump
on `bench/run.sh`, retire Boehm: flip default to RC, drop
`-lgc`, mark Decision 9 historical.
### Adjacent extensions for mutability (out of Decision 10's scope)
If future workloads need mutable arrays, hash tables, or other
inherently mutable primitives, the answer is **not** a tracing GC
backstop. The answer is a separate ownership/linear extension
that gates mutability behind static single-owner discipline. RC
+ uniqueness is the universal floor; ownership extends it for
specific high-performance primitives without rebreaking the
acyclicity invariant. Concrete design deferred until the need
materialises with a real workload.
### What this Decision deliberately does not do
- **Does not infer everything.** An earlier draft of this
Decision said "uniqueness is fully inferred" — that was the
mainstream RC position. The 2026-05-08 follow-up discussion
replaced it: AILang demands annotations *because* the LLM
author can produce them effortlessly. The compiler does
inference *plus* verification of contracts.
- **Does not require existing fixtures to migrate immediately.**
Iter 18a treats `(con T)` as `(own T)`. Existing JSON hashes
stay bit-identical until the fixture is intentionally updated.
- **Does not commit to atomic refcounts.** AILang is currently
single-threaded. If/when concurrency arrives, atomic-vs-non-
atomic will be a separate decision per allocation kind.
- **Does not introduce regions.** Regions were considered and
rejected; see "Why not other memory models" above.
## Mangling scheme (Iter 5c)
All AILang functions are mangled to `@ail_<module>_<def>` — even in the