spec: harden the ownership analysis for universal activation (0063)

Precondition for #55 (eliminate ParamMode::Implicit, spec 0062). The
strict linearity check (use-after-consume / consume-while-borrowed)
runs today only on the ~45 of 258 all-explicit-mode fns; deleting
Implicit turns it on universally and surfaces ~21% false positives in
two classes — value-type params (Int/Bool/Float/Unit, never consumed)
and applied function params in HOFs (application is a borrow).

Two design questions settled against the live tool, not the model:
- Str is heap, NOT a value type for consume-tracking. drop.rs:490-492
  lowers only Int/Bool/Float/Unit to non-ptr; Str is a ptr, RC-dec'd,
  so a multi-consume of Str without clone is a real use-after-free. The
  value-type set is the unboxed {Int,Bool,Float,Unit}, narrower than
  is_primitive_name (which includes Str).
- Applying a function value is a borrow; passing it as an arg follows
  callee param_modes (unchanged). The recursion-passing HOF pattern is
  then consistent under a borrow f-param.

All three false-positive classes reproduced today against explicit-mode
fns (testable without #55) and recorded as RED fixtures; a heap
double-consume fixture stays RED to prove the exemption is type-gated.
grounding-check PASS. refs #56
This commit is contained in:
2026-06-01 15:29:12 +02:00
parent 6c351af169
commit 8cdac7ecef
@@ -0,0 +1,382 @@
# Harden the ownership analysis for universal activation — Design Spec
**Date:** 2026-06-01
**Status:** Draft — awaiting user spec review
**Authors:** orchestrator + Claude
## Goal
Make the strict linearity analysis (`use-after-consume` /
`consume-while-borrowed`, `crates/ailang-check/src/linearity.rs`)
correct when it runs on **every** fn, not just the ~45 of 258
fn-bearing modules that today carry an all-explicit signature. The
analysis is the core ownership-safety feature, yet its activation gate
(`linearity.rs:339`) skips any fn with a bare/`Implicit` param — so it
almost never runs. Deleting `ParamMode::Implicit` (#55, spec 0062)
turns it on universally, and two blind spots then surface as ~21%
false positives (38 of ~182 functional fixtures, measured 2026-06-01):
1. **Value-type params** — an `Int`/`Bool`/`Float`/`Unit` param read
multiple times trips `use-after-consume`. Value types have no
refcount and are never consumed; multi-read is always legal.
2. **Function params applied in HOFs** — applying a function param
(`apply_thrice`, `map_int`, `fold_left`) trips `use-after-consume`
(own f-param) or `consume-while-borrowed` (borrow f-param).
Applying a function value reads it; it is a borrow, not a consume.
This is the **precondition for #55**: spec 0062 §8 derives each
migrated parameter's mode from the consume analysis and asserts "the
corpus contains none [of the rejected shapes]". That assertion only
holds once these two blind spots are closed — otherwise the cutover
turns ~21% of the corpus red. Closing them is also independently
valuable: it makes the core ownership analysis sharp across the whole
codebase for the first time.
The rationale anchors are `design/models/0008-ownership-totality.md`
§3.2 (value types are trivial-`own`; a value type "has nothing to
lend") and §4 (HOF mode slots — each canonical HOF applies its
function param, which is a read), plus the RC/uniqueness contract
`design/contracts/0008-memory-model.md`. The model is `status: Design
exploration`; its claims are validated against the live tool below
(Testing strategy), not assumed.
### Scope decisions ratified here
1. **`Str` is heap, not a value type, for consume-tracking.** The
model §1 calls `Str` "an RC-heap value"; codegen confirms it:
`drop.rs:490492` lowers only `Int`/`Bool`/`Float`/`Unit` to
non-`ptr`, and `Str` lowers to `ptr` and is RC-`dec`'d. A `Str`
binder **can** be consumed (its slab freed) and a multi-consume
without `clone` is a genuine use-after-free. So the value-type
exemption covers exactly the **unboxed** set `{Int, Bool, Float,
Unit}` — narrower than `primitives::is_primitive_name`, which
includes `Str`. This divergence is deliberate and named: "primitive
zero-arity type constructor" (`is_primitive_name`, includes `Str`)
is a different predicate from "unboxed value type, no RC"
(`is_value_type`, excludes `Str`).
2. **Applying a function value is a borrow.** A function value
(closure or fn-ref) applied at a call site is read, not moved; it
stays live for further applications and is dropped at scope close
like any other binder. Passing a function value **as an argument**
is unchanged — it follows the callee's `param_modes` (borrow-param
→ borrow, own-param → consume), which `callee_arg_modes` already
resolves. The recursion-passing pattern (`map_int f t`) is then
consistent: `f` is borrowed for the application *and* borrowed for
the recursive hand-off when `map_int`'s f-param is `borrow`.
### Out of scope
- **#55 itself** — deleting `ParamMode::Implicit`, the schema/hash
reset, the parser/return changes. This spec only hardens the
analysis; it changes no schema, resets no hash, and is testable
today against explicit-mode fns (Testing strategy).
- **Value-typed `let`-binders.** The exemption is driven from the
binder's type where it is locally available: parameter signatures,
ctor field types (pattern binders), and `lam` typed-params. A
`let`-binder's type is inferred, not annotated, and the linearity
walk is a post-typecheck pass that does not re-run inference, so a
value-typed `let`-binder stays conservatively tracked as heap. This
is soundness-safe (it can only keep the analysis *over-strict* — a
potential false positive — never miss a real consume) and is not a
measured corpus shape. Lifting it would mean threading a full
binder→type table out of the type-checker; deferred until a corpus
shape demands it.
- **The `over-strict-mode` lint's `Str` treatment.** That lint uses
`is_heap_type` (which routes through `is_primitive_name`, so it
treats `Str` as non-heap). Whether that is correct for the lint is a
separate question; this spec does not touch `is_heap_type` or the
lint.
## Architecture
Two orthogonal, additive changes inside `linearity.rs`, plus one new
shared predicate. No schema change, no new `Type` variant, no hash
shift, no codegen change. The analysis stays a pure diagnostic pass.
**New shared predicate (`crates/ailang-core/src/primitives.rs`):**
`is_value_type(name) -> bool` returns `true` for exactly `Int` /
`Bool` / `Float` / `Unit`. It is the "unboxed, no-RC" predicate, as
distinct from `is_primitive_name` (which also returns `true` for
`Str`). Both live in the same module so the divergence is visible in
one place, and a unit test pins that `is_value_type`
`is_primitive_name`, the sole difference being `Str`.
**Fix 1 — value-type exemption (`linearity.rs`):**
A binder of a value type is never consumed. `BinderState` gains an
`is_value: bool` flag; `use_var` skips all consume bookkeeping (no
`consumed = true`, no `consume-while-borrowed`) when it is set. The
flag is set from the binder's locally-available type at three
introduction sites:
- **parameters** — `check_fn` already has `param_tys`; a param whose
type is a value type starts with `is_value = true`;
- **pattern binders** — `walk_arm` looks the binder's type up in the
ctor field types (the `ctors` map, now threaded into the `Checker`)
and sets `is_value` accordingly;
- **`lam` typed-params** — `Term::Lam` carries each param's declared
type; a value-typed lam param starts with `is_value = true`.
Because the flag rides on `BinderState`, it follows the existing
save/restore scoping (`with_binder`) automatically and survives the
branch/match `merge_states` (a binder's value-ness is invariant across
branches, so the merge keeps it).
**Fix 2 — application is a borrow (`linearity.rs`):**
`Term::App` walks its callee in `Position::Borrow` instead of
`Position::Consume` (`linearity.rs:433`). For a global fn-ref callee
this is a no-op (globals are not tracked binders, so `use_var` returns
early either way); for a tracked function-typed binder (a HOF param,
or a `let`/`lam`-bound function value) it stops the application from
consuming the binder. The callee's `consumed` flag is still checked,
so applying an already-consumed function value still fires
`use-after-consume`.
## Concrete code shapes
### RED fixtures (check-error today, clean post-fix)
These are the feature-acceptance evidence: the post-#55 authoring
surface forces every value-type and function-type slot to carry a mode
(`(own …)` / `(borrow …)`), and an LLM author writing the natural form
below trips a false positive **today** — exactly the friction this
cycle removes. All three are testable now against explicit-mode fns,
without #55, because an all-explicit signature already activates the
analysis. Live `ail check` traces (`target/debug/ail`, run
2026-06-01) are in the Testing section.
**Class 1 — value-type param read multiple times** (`(borrow (con
Int))` is an error per model §3.2, so a multi-read `Int` param *must*
be `own`):
```ail
(module fp_value
(fn sum_explicit
(doc "value-type param read multiple times")
(type (fn-type (params (own (con Int))) (ret (own (con Int)))))
(params n)
(body (if (app eq n 0) 0 (app + n (app sum_explicit (app - n 1))))))
(fn main
(type (fn-type (params) (ret (own (con Unit))) (effects IO)))
(params)
(body (app print (app sum_explicit 10)))))
```
**Class 2a — own function param applied multiple times:**
```ail
(module fp_hof
(fn apply_thrice
(doc "apply a function param three times")
(type (fn-type
(params (own (fn-type (params (own (con Int))) (ret (own (con Int))))) (own (con Int)))
(ret (own (con Int)))))
(params f x)
(body (app f (app f (app f x)))))
(fn main
(type (fn-type (params) (ret (own (con Unit))) (effects IO)))
(params)
(body (app print (app apply_thrice (lam (params (typed y (con Int))) (ret (con Int)) (body (app + y 1))) 0)))))
```
**Class 2b — recursive HOF, borrow function param applied + passed:**
```ail
(module fp_map
(data IntList
(doc "boxed list")
(ctor Nil)
(ctor Cons (con Int) (con IntList)))
(fn map_int
(doc "recursive HOF: f applied AND passed to the recursive call")
(type (fn-type
(params (borrow (fn-type (params (own (con Int))) (ret (own (con Int))))) (own (con IntList)))
(ret (own (con IntList)))))
(params f xs)
(body (match xs
(case (pat-ctor Nil) (term-ctor IntList Nil))
(case (pat-ctor Cons h t) (term-ctor IntList Cons (app f h) (app map_int f t)))))))
```
### Must-stay-RED fixture (a genuine consume, not exempted)
The exemption must not silence a real heap multi-consume. This
boxed-ADT param is consumed twice with no `clone`; it is a real
`use-after-consume` and **stays** an error after the fix (proves the
exemption is type-gated to value types, not blanket):
```ail
(module real_consume
(data Box
(doc "heap cell")
(ctor Box (con Int)))
(data Pair
(doc "two boxes")
(ctor Pair (con Box) (con Box)))
(fn dup
(doc "MUST STAY an error: a heap param consumed twice without clone")
(type (fn-type (params (own (con Box))) (ret (own (con Pair)))))
(params b)
(body (term-ctor Pair Pair b b))))
```
### Secondary: implementation shapes (before → after)
*Supporting detail, not the headline.*
**`crates/ailang-core/src/primitives.rs` — new predicate:**
```text
after (added alongside is_primitive_name):
/// Unboxed value type — no RC, no heap slab. Narrower than
/// `is_primitive_name`: `Str` is a primitive zero-arity ctor but is
/// heap-allocated (`ptr`, RC'd), so it is NOT a value type.
pub fn is_value_type(name: &str) -> bool {
matches!(name, "Int" | "Bool" | "Float" | "Unit")
}
```
**`crates/ailang-check/src/linearity.rs``BinderState`:**
```text
before: struct BinderState { consumed: bool, borrow_count: u32 }
after: struct BinderState { consumed: bool, borrow_count: u32, is_value: bool }
// is_value: a value-typed binder is never consumed.
```
**`linearity.rs``use_var` consume arm:**
```text
before (Position::Consume):
if state.borrow_count > 0 { ...consume-while-borrowed...; return; }
state.consumed = true;
after:
if state.is_value { return; } // value types are never consumed
if state.borrow_count > 0 { ...consume-while-borrowed...; return; }
state.consumed = true;
```
**`linearity.rs``Term::App` callee walk (`:433`):**
```text
before: self.walk(callee, Position::Consume); // "the function value is consumed"
after: self.walk(callee, Position::Borrow); // applying a function value reads it
// global fn-refs are untracked → no-op; a tracked function-typed
// binder (HOF param) is no longer consumed by application.
```
**`linearity.rs` — param/pattern/lam `is_value` seeding:** `check_fn`
sets `is_value` from `param_tys[i]` (a `Type::Con` whose name
satisfies `is_value_type`); `walk_arm` threads the `ctors` map to type
each pattern binder; `Term::Lam` reads its typed-param types. All three
default `is_value = false` (heap, conservative) when the type is not a
value `Type::Con`.
## Components
- **`ailang-core::primitives`** — gains `is_value_type`; a unit test
pins it as the `Str`-excluding subset of `is_primitive_name`.
- **`ailang-check::linearity`** — `BinderState.is_value`; the `use_var`
consume short-circuit; the `App` callee borrow walk; `is_value`
seeding at param / pattern / lam introduction; `Checker` gains an
immutable `ctors` reference for pattern-binder typing.
- **`.ail` fixtures** — the three RED fixtures plus the must-stay-RED
heap-consume fixture, under `examples/`.
No change to: the schema (`ParamMode`, `Type::Fn`), the parser, the
printer, codegen, the runtime, the `over-strict-mode` lint, or
`is_heap_type`.
## Data flow
**Value exemption.** The linearity walk does not infer types; it reads
them where the source already pins them. A parameter's type is on the
signature (`param_tys`); a pattern binder's type is the corresponding
ctor field type (`ctors[ctor].fields[i]`); a `lam` param's type is its
`(typed x T)` annotation. Each of these is a `Type::Con { name }` for a
value type, and `is_value_type(name)` decides the flag. The flag is
immutable per binder and rides `BinderState`, so lexical scoping
(`with_binder`) and branch merging (`merge_states`) carry it without
special handling. A binder whose type is *not* a locally-pinned value
`Type::Con` (a heap ADT, `Str`, a type variable, a `let`-binder)
defaults to `is_value = false` and is tracked exactly as today.
**Application borrow.** `Term::App` already distinguishes callee from
args: args go through `callee_arg_modes` to pick up per-position
modes, while the callee was walked as `Consume`. Switching the callee
to `Borrow` aligns it with the semantics — a function value is read by
application — and leaves the arg path untouched, so passing a function
value to an `own` param still consumes it and to a `borrow` param
still borrows it. The `consumed`-check in `use_var` still runs in
`Borrow` position, so applying an already-consumed function value is
still caught.
## Error handling
No new diagnostic codes. The two existing codes
(`use-after-consume`, `consume-while-borrowed`) fire on a strictly
*smaller, more correct* set: they no longer fire on value-type
multi-reads or on function-param applications, and they continue to
fire on genuine heap multi-consume (the must-stay-RED fixture) and on
applying an already-consumed function value. The change is a precision
improvement to existing diagnostics, not a new check surface.
## Testing strategy
Live `ail check` traces (run 2026-06-01, `target/debug/ail`) — the
state **today**, before the fix:
```text
fp_value.ail : exit 1 error: [use-after-consume] sum_explicit: `n` ...
fp_hof.ail : exit 1 error: [use-after-consume] apply_thrice: `f` ... (x2)
fp_map.ail : exit 1 error: [consume-while-borrowed] map_int: `f` ...
real_consume.ail : exit 1 error: [use-after-consume] dup: `b` ...
```
- **RED→GREEN fixtures.** `fp_value`, `fp_hof`, `fp_map` land under
`examples/`; each asserts the named diagnostic fires **today** and
is **clean** after the fix (exit 0). These are the RED side of the
two blind-spot fixes.
- **Stays-RED fixture.** `real_consume` asserts the heap
`use-after-consume` still fires after the fix — the exemption is
type-gated, not blanket.
- **Unit test on the predicate.** `is_value_type` agrees with
`is_primitive_name` on every name except `Str`, where
`is_value_type` is `false` and `is_primitive_name` is `true`.
- **Linearity unit tests.** The in-source `#[cfg(test)] mod tests` in
`linearity.rs` gains cases: a value-typed param multi-read produces
no diagnostic; an own/borrow function-param application produces no
diagnostic; a heap-typed param multi-consume still produces
`use-after-consume`.
- **Full corpus regression.** `cargo test --workspace` stays green,
and the existing `examples/` typecheck/codegen suite is the net for
"the exemption silenced nothing it should not have". Per the
typed-MIR re-synth strictness memory, run the whole workspace suite,
not just e2e.
- **Regression scripts.** `bench/check.py` and
`bench/compile_check.py` stay green (the analysis is diagnostic-only;
no compile-baseline shift is expected).
## Acceptance criteria
1. `is_value_type` exists in `ailang-core::primitives`, returns `true`
for exactly `{Int, Bool, Float, Unit}`, and a unit test pins it as
the `Str`-excluding subset of `is_primitive_name`.
2. `fp_value`, `fp_hof`, `fp_map` check **clean** (exit 0) after the
fix; each shipped as an `examples/` fixture asserting its today→post
transition.
3. `real_consume` still fails with `use-after-consume` after the fix.
4. `apply_thrice`-style own-function-param application and
`map_int`-style borrow-function-param application+recursion both
check clean.
5. The value exemption is type-gated: an in-source unit test shows a
heap param multi-consume still produces `use-after-consume` while a
value param multi-read produces none.
6. `cargo test --workspace` green; `bench/check.py` and
`bench/compile_check.py` green.
7. No change to `ParamMode`, `Type::Fn`, the parser/printer, codegen,
the runtime, `is_heap_type`, or the `over-strict-mode` lint (grep /
diff clean).