spec: unique binder names per fn — A2b leg of RawBuf drop-leak (refs #43)

The last open leg of the #43 owned-heap drop-leak cluster is the
UniquenessTable shadow-name collapse: the side-table keys by
(def_name, binder_name), so a fn that shadows a binder name collapses
every shadow onto one key. The outermost binding (records last, on pop)
overwrites the inner ones; codegen's scope-close drop gates then read
the collapsed consume_count for the innermost binding and suppress its
drop, leaking the owned slab.

Spec 0056 resolves this as a compiler-internal naming collision — NOT
the deeper, separate problem of persistent AST provenance back to the
authored form (certain to be needed eventually, deliberately deferred;
conflating the two is a category error). Fix: alpha-rename shadowing
binders during desugar (reusing the existing fresh-name machinery that
already mints $mp_N / $lr_N), making the (def, name) key injective
again. No consumer of the uniqueness table changes — they all key by
name, and the name becomes a per-fn injective identity. IR-neutral:
codegen names heap by fresh SSA, never by source binder name. The
pre-desugar hashed form is untouched, so module identity and hash-pin
tests are unaffected.

RED fixtures securing the binder-kind class (the user's gating
condition before proceeding to the uniform-across-kinds fix):
- Let-shadow: the three raw_buf_{int,float,bool}_shadow_rebind tests
  (already in tree, committed f7f4c3b) — assert live == 0.
- Flat-pattern-shadow: a differential test plus its two fixtures
  (flat_pat_shadow_leak.ail shadows the outer binder; _control.ail
  alpha-renames it to a distinct name). The shadow must reach the
  control's live count. Differential by design, to isolate the
  shadow-collapse drop from unrelated baseline drop gaps out of
  #43's scope.

Grounding-check PASS; ail-check parse-gate green on every fenced block.
This commit is contained in:
2026-05-30 12:21:27 +02:00
parent 0f6108e428
commit 0015f3dad1
4 changed files with 361 additions and 0 deletions
+36
View File
@@ -2330,6 +2330,42 @@ fn raw_buf_bool_shadow_rebind_drop_balances_rc_stats() {
);
}
/// A2b completeness (refs #43): the pattern-binder sibling of the
/// Let-shadow leak. A flat-match pattern-binder that shadows an outer
/// same-named heap binder must not leak more than its alpha-renamed
/// equivalent. The uniqueness side-table keys by `(def, binder_name)`
/// (`crates/ailang-check/src/uniqueness.rs`); the shadowing pattern
/// binder `x` collides with the outer `let x`, and the arm-close drop
/// gate (`crates/ailang-codegen/src/match_lower.rs:795`) reads the
/// collapsed (outer) `consume_count` and suppresses the inner
/// payload's drop. `flat_pat_shadow_leak.ail` (inner binder `x`,
/// shadows) and `flat_pat_shadow_control.ail` (inner binder `y`,
/// alpha-renamed) are byte-identical apart from that one name; the
/// shadow must reach the control's live count. RED until binder names
/// are made unique per fn at desugar (the same fix that closes the
/// three `raw_buf_*_shadow_rebind` Let-shadow tests). Differential
/// rather than `live == 0` on purpose: it isolates the shadow-collapse
/// drop from unrelated baseline drop gaps (Box outer cell,
/// implicit-consume of the outer binder) that are out of #43's scope.
#[test]
fn flat_pat_shadow_binder_does_not_leak_more_than_alpha_renamed() {
let (s_out, s_allocs, s_frees, s_live) =
build_and_run_with_rc_stats("flat_pat_shadow_leak.ail");
let (c_out, _c_allocs, _c_frees, c_live) =
build_and_run_with_rc_stats("flat_pat_shadow_control.ail");
assert_eq!(
s_out.trim(),
c_out.trim(),
"shadow and alpha-renamed control must print identically"
);
assert_eq!(
s_live, c_live,
"flat-pattern shadow leaks live={s_live} (allocs={s_allocs} frees={s_frees}) \
vs alpha-renamed control live={c_live}; the shadowing pattern-binder's \
arm-close drop is suppressed by the (def,name) uniqueness-table collision"
);
}
/// raw-buf.4 Float variant: a 2-slot Float RawBuf (1.5 + 2.5),
/// exercising the `double` load/store emits. Prints `4.0` (the `%g`
/// whole-double `.0` fallback).
+263
View File
@@ -0,0 +1,263 @@
# Unique Binder Names per Fn — Design Spec
**Date:** 2026-05-30
**Status:** Draft — awaiting user spec review
**Authors:** orchestrator + Claude
## Goal
Close the last open leg of the owned-heap drop-leak cluster #43: the
`UniquenessTable` shadow-name collapse. The uniqueness side-table is
keyed by `(def_name, binder_name)`
(`crates/ailang-check/src/uniqueness.rs:99`). When a fn shadows a
binder name — `(let buf (new …) (let buf (set buf …) … (get buf)))`
every shadow records under the same key; the last `record_binder` to
fire (the **outermost** binding, which pops last) overwrites the
inner ones. Codegen's scope-close drop gates then read the collapsed
`consume_count` for the *innermost* binding, misjudge whether it is
still owned, and suppress its drop. The owned heap slab leaks.
The fix gives every binder a name that is unique within its fn, so the
`(def, name)` key is injective again. This is done by alpha-renaming
shadowing binders during desugaring — the existing lowering pass that
already mints fresh unique names for its own synthetic binders
(`$mp_N`, `$lr_N`). No consumer of the uniqueness table changes: they
all key by name, and the name is now an injective per-fn identity.
### Scope: what this is and is not
This resolves a **compiler-internal naming collision**. It is *not*
the deeper, separate problem of giving AST nodes a persistent identity
with provenance back to the pre-desugar (authored) form — that
back-reference is certain to be needed eventually but is **not** a
requirement here, and conflating the two is a category error. The
authored `.ail` (the canonical, content-addressed, hashed form) is
untouched: renaming happens only on the post-desugar internal tree,
which is already a lowered form whose binder names desugar already
treats as tags.
## Architecture
The post-desugar AST is an internal lowering, not the authored
artifact. Desugar already (a) threads a lexical `scope` through every
term (`desugar_term(t, scope)`), and (b) owns a collision-free
fresh-name generator (`Desugarer::fresh`, used for `$mp_N` match
temporaries and `$lr_N` lifted letrecs). The fix extends (a) to detect
a binder whose name is already bound in the enclosing `scope` and, on
such a shadow, allocate a fresh unique name for it, recording the
original→fresh mapping in the scope handed to that binder's body. The
one structural addition is that `Term::Var` — today a verbatim
`t.clone()` — must rewrite its name through the active rename map so
references resolve to the renamed binder.
The rename is **on shadow only** and **uniform across binder kinds**.
Non-shadowing binders keep their authored name, so every existing
fixture's desugared output is byte-identical; only an actual shadow is
renamed. The scope check is identical at every binder-introduction
site, so a single mechanism covers `Term::Let`, flat-`Match`
pattern-binders, `Term::Lam` params, and `Term::Loop` binders. Of
those, only `Term::Let` and flat-`Match` pattern-binders currently
reach a uniqueness-gated drop site (see Components); covering the
others is free under the same scope check and forecloses the collision
class for any future gate.
Emitted LLVM IR is unaffected: codegen names heap values by fresh SSA
(`%vN`), never by the source binder name (the binder name is only an
internal lookup key into `self.locals` / `moved_slots` / the
uniqueness table). Renaming an internal binder therefore changes no IR
bytes — only which uniqueness entry the lookup resolves to, which is
the entire point.
## Concrete code shapes
### North-star — the LLM-natural programs that must stop leaking
These are shipped (or completeness-securing) fixtures. They are the
empirical evidence: an LLM author *did* write the shadow-rebind idiom
(it is the milestone fixture), and the fix makes it correct.
**Let-shadow (the shipped #43/#42 symptom, `examples/raw_buf_int.ail`):**
```ail
(module raw_buf_int
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(app print
(let buf (new RawBuf (con Int) 3)
(let buf (app RawBuf.set buf 0 10)
(let buf (app RawBuf.set buf 1 20)
(let buf (app RawBuf.set buf 2 30)
(app + (app RawBuf.get buf 0)
(app + (app RawBuf.get buf 1)
(app RawBuf.get buf 2)))))))))))
```
Prints `60`; under `AILANG_RC_STATS` must reach `live == 0` (today
`live == 1`).
**Flat-pattern-shadow (completeness, `examples/flat_pat_shadow_leak.ail`):**
a flat-match pattern-binder `x` shadowing an outer `let x`; the inner
`x` (a heap `Box` payload) is borrow-only and must drop at arm close,
but the collision suppresses it. Paired with
`examples/flat_pat_shadow_control.ail` (inner binder alpha-renamed to
`y`); the shadow must reach the control's live count.
### How desugar rewrites the north-star (worked, Let case)
Input is the four-`buf` body above. Output binds each shadow to a
fresh name and rewrites references in its body; values are desugared
in the *outer* scope (before the binder is installed), so each
`RawBuf.set` still refers to the previous binding:
```text
(let buf (new RawBuf (con Int) 3)
(let buf$1 (app RawBuf.set buf 0 10)
(let buf$2 (app RawBuf.set buf$1 1 20)
(let buf$3 (app RawBuf.set buf$2 2 30)
(app + (app RawBuf.get buf$3 0)
(app + (app RawBuf.get buf$3 1)
(app RawBuf.get buf$3 2)))))))
```
Keys become `(main,buf)`, `(main,buf$1)`, `(main,buf$2)`,
`(main,buf$3)` — all distinct. The three outer bindings are each
consumed once by the next `set`'s `own` param (`consume_count == 1`,
correctly not dropped: `set` is in-place, same slab); the innermost
`buf$3` is only borrowed by the three `get`s (`consume_count == 0`)
and is dropped once at scope close. Exactly one drop fires, balancing
the one slab alloc → `live == 0`.
### Implementation shape (secondary)
**`Term::Var` — rewrite through the active rename map.** Before:
```rust
Term::Lit { .. } | Term::Var { .. } => t.clone(),
```
After (Var split out; rename map carried alongside `scope`):
```rust
Term::Lit { .. } => t.clone(),
Term::Var { name } => Term::Var {
name: scope.resolved_name(name), // renamed binding, or `name` verbatim
},
```
**Each binder-introduction site — rename on shadow.** Before
(`Term::Let`):
```rust
Term::Let { name, value, body } => {
let v = self.desugar_term(value, scope);
let mut inner = scope.clone();
inner.insert(name.clone(), ScopeEntry::LetBound);
let b = self.desugar_term(body, &inner);
Term::Let { name: name.clone(), value: Box::new(v), body: Box::new(b) }
}
```
After:
```rust
Term::Let { name, value, body } => {
let v = self.desugar_term(value, scope); // outer scope: refs to prior binding
let mut inner = scope.clone();
let bound = inner.bind_renaming_on_shadow(name, &mut self.fresh_binder);
let b = self.desugar_term(body, &inner);
Term::Let { name: bound, value: Box::new(v), body: Box::new(b) }
}
```
`bind_renaming_on_shadow` returns `name` verbatim when it is free in
`scope`, or a fresh unique name (recorded as `name → fresh` in `inner`
for `resolved_name`) when it shadows. The same call wraps the
`Match`-arm pattern-binder insertion (`desugar_term`'s `Term::Match`
arm loop), the `Term::Lam` param loop, and the `Term::Loop` binder
loop. Flat-match arms (returned verbatim by `desugar_match` today)
must carry the renamed pattern so the renamed name reaches codegen's
arm-close gate.
The fresh-name generator must avoid all in-scope names and the
existing `$mp_N` / `$lr_N` spaces; a per-fn `<name>$<n>` scheme
checked against the scope satisfies this and is visually traceable to
its origin binder.
## Components
| Component | File | Change |
|---|---|---|
| Rename-on-shadow at binder sites | `crates/ailang-core/src/desugar.rs` | detect shadow against `scope`, mint fresh name, record mapping; applied at `Let`, flat-`Match` arm, `Lam` params, `Loop` binders |
| `Term::Var` resolution | `crates/ailang-core/src/desugar.rs` | rewrite name through the active rename map |
| Fresh-binder name scheme | `crates/ailang-core/src/desugar.rs` | per-fn `<name>$<n>`, collision-checked against scope + `$mp`/`$lr` |
**No change** to the uniqueness pass, codegen drop gates, or
`linearity`. They key by `(def, name)`; the fix makes `name`
injective, so their existing lookups become correct without edits.
The three uniqueness-gated drop sites and their exposure:
| Drop gate | Site | Keyed by | Shadows? |
|---|---|---|---|
| fn `own`-param dec at return | `codegen/src/lib.rs:1447` | `(def, pname)` | never (params unique per signature) |
| `Term::Let` scope-close drop | `codegen/src/lib.rs:1792` | `(def, name)` | **yes — fixed here** |
| `Match` arm-close pattern dec | `codegen/src/match_lower.rs:795` | `(def, bname)` | **yes — fixed here** |
## Data flow
`parse (.ail → JSON-AST, hashed)`**`desugar_module` (renames
shadows here)** → `lift_letrecs``monomorphise``check`
(uniqueness inference reads renamed names) / `codegen` (drop gates
read renamed names). The rename lives entirely between parse and the
two consumers; both consumers see the same renamed tree, so they
cannot disagree about which binding a key denotes. The pre-desugar
hashed form never carries a rename, so module identity and all
hash-pin tests are unaffected.
## Error handling
No new diagnostics. Renaming is a total, infallible rewrite: every
binder either keeps its name or receives a guaranteed-fresh one.
The fresh-name loop is bounded by the in-scope set (finite per fn) and
cannot fail to terminate (it increments until free, exactly as
`Desugarer::fresh` already does). Programs that were ill-typed before
desugar are rejected upstream as today; the rename does not run on
un-parsed input.
## Testing strategy
RED-first; all RED fixtures already exist in the working tree:
- **Let-shadow (3, shipped):** `raw_buf_{int,float,bool}_shadow_rebind_drop_balances_rc_stats` — assert `live == 0`. RED today (`live == 1`).
- **Flat-pattern-shadow (1, differential):** `flat_pat_shadow_binder_does_not_leak_more_than_alpha_renamed` — assert the shadow fixture's `live` equals its alpha-renamed control's. RED today (`5` vs `3`). Differential by design, to isolate the shadow-collapse drop from unrelated baseline drop gaps (Box outer cell, implicit-consume of the outer binder) that are out of #43's scope.
Regression guards (must stay green):
- **No-op on non-shadowing fixtures:** the full existing suite must
stay byte-identical — every fixture without a shadow desugars
unchanged. This is the evidence that the rename is on-shadow-only.
- **Reference rewrite correctness:** a shadow whose body reads the
renamed binder (the `get buf$3` path) must still compute the right
value — covered by the stdout assertions on the four fixtures
(`60`, and the pattern fixtures' printed sums).
- **Desugar unit tests:** any existing `desugar_module` test on a
shadowing input updates to the renamed output; non-shadowing inputs
are untouched.
## Acceptance criteria
1. All four RED tests above are GREEN.
2. The full workspace test suite is otherwise unchanged (no
regression; no non-shadowing fixture's output drifts).
3. Emitted IR for the shipped fixtures is unchanged except for the
added drop call(s) — SSA names do not depend on binder names.
4. `examples/raw_buf_int.ail` reaches `live == 0` under
`AILANG_RC_STATS`, closing #43.
5. No change to `uniqueness.rs`, the codegen drop gates, or
`linearity.rs`; the `UniquenessTable` doc-comment
(`uniqueness.rs:90-98`), currently wrong about pop-order
("last = innermost") and about every fixture having unique binder
names, is corrected to state that desugar guarantees per-fn name
uniqueness.
```
+31
View File
@@ -0,0 +1,31 @@
(module flat_pat_shadow_control
(data IntList
(ctor Nil)
(ctor Cons (con Int) (con IntList)))
(data Box (vars a)
(ctor MkBox a))
(fn blen
(doc "borrow-only length over a borrowed IntList")
(type (fn-type (params (borrow (con IntList))) (ret (con Int))))
(params xs)
(body (match xs
(case (pat-ctor Nil) 0)
(case (pat-ctor Cons h t) (app + 1 (app blen t))))))
(fn csum
(doc "consuming sum over an IntList")
(type (fn-type (params (con IntList)) (ret (con Int))))
(params xs)
(body (match xs
(case (pat-ctor Nil) 0)
(case (pat-ctor Cons h t) (app + h (app csum t))))))
(fn main
(doc "Alpha-renamed control for flat_pat_shadow_leak.ail (refs #43): structurally identical, but the inner flat-match pattern-binder is `y` (no shadow of the outer `x`). The inner payload's arm-close drop fires because (def,\"y\") does not collide with (def,\"x\"). This is the live-count baseline the shadow fixture must reach once binder names are unique per fn at desugar (C').")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(let x (term-ctor IntList Cons 1 (term-ctor IntList Nil))
(seq
(match (term-ctor Box MkBox (term-ctor IntList Cons 2 (term-ctor IntList Nil)))
(case (pat-ctor MkBox y)
(app print (app blen y))))
(app print (app csum x)))))))
+31
View File
@@ -0,0 +1,31 @@
(module flat_pat_shadow_leak
(data IntList
(ctor Nil)
(ctor Cons (con Int) (con IntList)))
(data Box (vars a)
(ctor MkBox a))
(fn blen
(doc "borrow-only length over a borrowed IntList")
(type (fn-type (params (borrow (con IntList))) (ret (con Int))))
(params xs)
(body (match xs
(case (pat-ctor Nil) 0)
(case (pat-ctor Cons h t) (app + 1 (app blen t))))))
(fn csum
(doc "consuming sum over an IntList")
(type (fn-type (params (con IntList)) (ret (con Int))))
(params xs)
(body (match xs
(case (pat-ctor Nil) 0)
(case (pat-ctor Cons h t) (app + h (app csum t))))))
(fn main
(doc "A2b completeness fixture (refs #43): a flat-match pattern-binder `x` shadows the outer let `x`. The inner `x` (Box payload, a heap IntList) is borrow-only (consume_count 0) and must be dropped at arm close; the outer `x` is consumed once by csum (count 1). Under the (def,name) uniqueness-table collision the arm-close drop gate (match_lower.rs) reads the collapsed outer count 1 and suppresses the inner payload's drop, leaking 2 cells. Pairs with flat_pat_shadow_control.ail (same program, inner binder alpha-renamed to `y`); the live-count delta is exactly the suppressed drop. GREEN once binder names are made unique per fn at desugar.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(let x (term-ctor IntList Cons 1 (term-ctor IntList Nil))
(seq
(match (term-ctor Box MkBox (term-ctor IntList Cons 2 (term-ctor IntList Nil)))
(case (pat-ctor MkBox x)
(app print (app blen x))))
(app print (app csum x)))))))