Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions tslang/docs/superpowers/plans/2026-10-02-own-phase-6.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# `-mm=own` Phase 6 Implementation Plan: the corpus report and diagnostics polish

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development
> (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use
> checkbox (`- [ ]`) syntax for tracking.

**Goal:** Spec §7 item 6, "refine the shapes the histogram says matter". After 7b, 364 of the 591
corpus files compile under `-mm=own`. Of the 227 that fail, 137 stop at the catch-all
`'x' takes a second reference; -mm=own cannot prove a move or a borrow here yet`, which names no
rule. This phase measures what the catch-all hides, accepts the sound shapes, and names the rest.

**Spec:** §5 (diagnostics), §6.5 (the corpus report). Results go into §20.

## Global Constraints

As in phase 7b (plan `2026-10-02-own-phase-7b.md`). Also:

- Branch `own-phase-6` from `origin/main` (ad8ea908, with #441 merged).
- **The report tooling stays out of the tree.** These live in the scratchpad and are cited in §20:
- `corpus/run.sh`: the first error per file;
- `p6/shapedump.patch`: a temporary stderr dump at the four `reportSecondReference` sites, under
`OWN_SHAPES`: the retained value's definition, its root, its users, its type, whether it owns
a block, the source location, and for a `Load` the place it reads;
- `corpus/show.py`: buckets with source lines.

Re-apply the patch with `git apply` for each histogram, and never commit it.
- **Re-histogram after each task.** Every fix moves files on to their next blocker.
- **Exit criterion:** every bucket that is the only blocker of 6 or more files is either accepted
or given a named message. The catch-all count is reported before and after.

## The report on ad8ea908 (first error per file, 227 failures)

| first error | files |
|---|---|
| takes a second reference (catch-all) | 137 |
| used after its value was moved | 22 |
| borrows a field and cannot be stored, returned or captured | 21 |
| borrows an element and cannot be ... | 9 |
| ownership inference left a retain behind (lowering) | 8 |
| other named rules, unsupported, frontend | 30 |

The 137, split by the dump (447 sites). The last column counts files where the bucket is the only
one:

| bucket | sites | files | only blocker |
|---|---|---|---|
| a slot read (`Load` of a `ts.Variable`): mixed | 97 | 39 | 17 |
| a store of a call's result: script-mode globals | 59 | 28 | 14 |
| tuple literals (`[[1, "a"], ...]`) | 52 | 23 | 13 |
| the retained value owns no block (`number \| null`) | 35 | 23 | 9 |
| a block argument (`c ? a : b`) | 18 | 11 | 7 |
| a function literal (`Cast` of a `SymbolRef`) | 24 | 13 | 6 |
| a read of a global (`AddressOf`) | 22 | 8 | 4 |
| a number/bool/bigint converted to a string | 42 | 11 | 0 |
| a string constant | 5 | 1 | 0 |
| other | 55 | 34 | 18 |

## Findings that set the order

- **Script-mode globals are an rc fault.** In top-level code, `const t = new C()` stores into
the global with no retain, and §9.30 then releases the value at the end of the block. 5ai fixed
this for an assignment (`t = new C()`, via `isOwnedGlobalSlot`) but not for a declaration
(`MLIRGenImpl.h`, the "not global scope" store). Own reads the store as a move and the release
as a second reference, which is correct on wrong IR.
- Under rc it is not observable today: nothing runs after `main`'s top-level code. A module
with top-level code, imported as source, does not compile ("Global is referenced by
parentless instruction! @puts"); with `-shared`, its exported `const` cannot be resolved.
Those are two pre-existing bugs, every model, outside this phase.
- **"Left a retain behind" (8 files) is a global initializer region.** `const b: I = {...}` at
module level builds a fresh value in the `ts.Global` region and retains it for
`ts.GlobalResult`. No own pass runs there, so the lowering's guard fails the compile. It is
loud, not a hole.

## Rulings

- **Globals.** A global is a root, and a fresh value moves into it. The retain for a
`ts.GlobalResult` of a fresh value, or of a view of one, is erased. Anything else retained in
a global region gets a named error, not the lowering's.
- **No block.** A `ts.Retain` whose root owns no block (a union or optional made from a number
or null) is erased: an SSA value never changes.
- A `ts.RetainSlot` is decided on its initializer's root, so it is erased only when nothing
else is ever stored into the slot. `let y: number | string = 1; y = "s"; let z = y;` must
stay an error, or move as it does today. Negative test and tooth.
- **Function literals.** A `ts.Cast` of a `ts.SymbolRef` to a function type holds no capture
box, so it owns no block. The same slot guard applies: a slot that a capturing closure is
later stored into is not covered.
- **Conversions.** A `ts.Cast` from a type that owns no block to `string` makes a fresh string.
It already carries `__owned_result`; `isFresh` should read that.
- **String constants.** A `ts.Constant` string is immortal: retaining it takes nothing.
- **Tuple literals.** A `ts.CreateTuple` whose owning fields are constants or moved fresh values
holds nothing of anyone else's, so rc's retain of it is its reference for the moves. This
extends `holdsConstantsOrMoves` to a `CreateTuple` that feeds a `ts.CreateArray`, a store or a
union.
- **Named messages.** A shape the dump recognises but this phase does not accept gets its own
message in §5's language, not the catch-all, for example: `'x' is a conditional expression's
result; -mm=own cannot prove which branch owns it yet`.

## Review Focus

1. Every erased retain is of a value that owns no block, is immortal, or moves into a root.
2. The slot guard: a `RetainSlot` decision on an initializer holds only if the slot is never
stored again.
3. The rc change adds a retain under every counting model: the rc suite must stay unchanged. It
does not branch on the model (§4.3), since gc erases retains.

---

### Task 1: globals (rc, every counting model, a PR of its own)

- [x] A declaration of a global in top-level code goes through the owning-slot path: it takes
over an owned reference or retains, and releases the slot's old value, as an assignment
does.
- [x] Tests (PR #444): `own_global_script` (positive: `const`, `let` and `var` in top-level code, a class
and a string, read from a function); and the rc corpus unchanged.

### Task 2: global initializer regions (own)

- [x] Erase the retain of a fresh value, or of a view of one, given to `ts.GlobalResult`.
Anything else retained in a global region: a named error.
- [x] Tests: `own_global_init` (positive: an interface-typed object literal, a nested array
literal, an object with an accessor, at module level).

### Task 3: no block, function literals, conversions, string constants

- [x] As the rulings say, with the slot guard (`isConstantData`). Conversions to a string stayed
out: no file has them as its only blocker.
- [x] Tests (as built: `own_constant_data`, and the negative `own_err_literal_two_records` for the
`ts.New` exclusion; the slot guard is conservative, and no case was found where it changes
the verdict):
- positive `own_no_block`: `number | null`, a function literal in a `let`, a number converted
to a string, string constants through a `?:`;
- negative `own_err_union_slot_reassigned` (the guard);
- teeth: drop the guard, and the negative compiles.

### Task 4: tuple literals

- [x] As built: through `isConstantData` (a tuple of constants), and the slot guard reading a
field ref that only reads. Also: a record literal moved into the owning local it is made
for (`recordRetainOf`, `own_record_local`).
- [x] Tests: in `own_constant_data`; `own_tuple_literals`: `[number, string][]` literals, `push`/`unshift`/`splice` of a
literal tuple, a tuple in a union.

### Task 5: named messages and the specific-rule buckets (the next PR)

- [ ] Named messages for each recognised, unaccepted bucket left after tasks 1-4.
- [ ] Sample the use-after-move (22) and borrows-a-field (21) files with `--di`. A true positive
stays; a false positive becomes a shape.
- [ ] Re-run the report. Spec §20.
148 changes: 146 additions & 2 deletions tslang/docs/superpowers/specs/2026-09-24-own-memory-model-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ amendments folded in. Phase 0 merged as #399 (results §11); phase 1
(results §13); phase 3 (containers and unions) merged as #403 (results §14); phase 4 (function
signatures and `any`) merged as #406-#410 (results §15); phase 5a (closures that do not escape)
merged as #436 (results §16); phase 5b (closures that escape) merged as #437 (results §17); phase
7a (generators, async) merged as #439 (results §18); phase 7b (generators that borrow) on branch
`own-phase-7b` (results §19). Plans in `docs/superpowers/plans/`.
7a (generators, async) merged as #439 (results §18); phase 7b (generators that borrow) merged as
#441 (results §19); phase 6 (the corpus report) on branch `own-phase-6`, after #444 (results
§20). Plans in `docs/superpowers/plans/`.

## 1. Purpose

Expand Down Expand Up @@ -1432,3 +1433,146 @@ Found, not fixed (every model): `a.map(f).map(g)` fails to compile ("the conditi
- **A copy a borrowing field takes of a sub-field** (`const v = c.v` in the generator) is not
borrowing, so it is the existing "cannot be stored" error.
- **Without `--di`**, the bounded errors name "this value".

## 20. Phase 6 results, 2026-10-02 (first PR)

Plan: `docs/superpowers/plans/2026-10-02-own-phase-6.md`. Changed: `MLIRGenImpl.h` (#444, every
counting model), `OwnershipSignaturePass.cpp`, `OwnershipInferencePass.cpp`, `OwnershipFacts.h`.

### 20.1 The report (spec §6.5)

The tooling stays out of the tree:
- a script that compiles every corpus file under own and keeps the first error;
- a temporary dump, under `OWN_SHAPES`, at the four places that report the catch-all. It prints the
retained value's definition, its root, its users, its type, whether it owns a block, the source
location, and for a read of a slot, the slot's kind and what takes the value.

On ad8ea908 (7b merged), 364 of 591 compile. The first error of the 227 that do not:

| first error | files |
|---|---|
| `takes a second reference` (the catch-all, which names no rule) | 137 |
| used after its value was moved | 22 |
| borrows a field and cannot be stored, returned or captured | 21 |
| borrows an element and cannot be stored, returned or captured | 9 |
| `ownership inference left a retain behind` (the lowering's guard) | 8 |
| the rest: other named rules, unsupported, frontend | 30 |

The 137, by the dump: 447 sites. The last column counts the files where the bucket is the only
blocker:

| shape | sites | files | only blocker |
|---|---|---|---|
| a read of a slot (mixed) | 97 | 39 | 17 |
| a call's result stored: script-mode globals | 59 | 28 | 14 |
| tuple literals | 52 | 23 | 13 |
| the value owns no block (`number \| null`) | 35 | 23 | 9 |
| a block argument (`c ? a : b`) | 18 | 11 | 7 |
| a function literal | 24 | 13 | 6 |
| a read of a global | 22 | 8 | 4 |
| a number, bool or bigint converted to a string | 42 | 11 | 0 |
| other | 55 | 34 | 18 |

### 20.2 What this PR accepts

- **A global declared in top-level code (#444, rc).** `const t = new C()` there stored into the
global with no reference, and the end-of-block release gave the value's back: 5ai's bug, in
the declaration's store, which 5ai's fix (the assignment's) did not reach.
- The fix: the declaration's store takes the owning-slot path, as an assignment does.
- It is not observable under rc today. Nothing runs after a program's top-level code, and a
module with top-level code cannot be imported (§20.5).
- Under own, the store read as a move and the release as a second reference.
- **A global's initializer** runs in the global's region, which no function's inference sees,
so rc's retain for its `ts.GlobalResult` reached the lowering's guard. The signature pass now
erases such a retain when:
- the value is the only reference to its block: retained once, released nowhere in the region;
- it is fresh, constant data, or a record of constants;
- every use of it ends in the `ts.GlobalResult`: directly, through views, as an element of an
array literal, or copied into an object literal's box.

Any other retain there is reported by the global's name.
- **Data nothing owns** (`isConstantData`, `OwnershipFacts.h`): what holds no block (a number or
null in a union), a `ts.Constant` or a field of one, a function named by a `ts.SymbolRef` and
its cast to a function value, and a tuple of such.
- A retain of it is erased.
- A `ts.RetainSlot` is decided on its slot's initializer, so only while nothing is stored into
the slot or a field of it, and no closure captures it.
- Such data has no use after a move: under `--opt`, CSE merges equal literals (the left-out
optional field of two tuple literals), and one value goes into several places.
- Two exclusions, from a type-only reading. The header's `ownsHeap` reads the type, and the
inference pass overrides it for a `ts.New` seen as its object; the two disagree by design.
- A `ts.New` is excluded: its `value_ref` owns nothing by type, but it is the object. Without
this, `00object` compiled again: one object literal stored into two records, which 7a
rightly rejects.
- A value a `ts.NewInterface` view boxes is excluded: each view is a block of its own.
- **A record literal made for an owning local**, `let a = { item: new C() }`, and its assignment
form. MLIRGen builds it in a local a `ts.Constant` starts, stores the fresh value into the
field, and reads the record once into `a`. That is 7a's record built to be returned, with an
owning local in place of the result slot: the fresh value moves into the record, the record
into `a`, and `a`'s `ts.RetainSlot` is the reference for what moved in.

### 20.3 Measured and teeth

AOT, `measure.ps1`, a loop that builds and drops `let a = { item: new Leaf(i) }`, in MB:

| iterations | gc | rc | none | own |
|---|---|---|---|---|
| 20000 | 5.9 | 4.2 | 7.7 | 4.2 |
| 200000 | 5.9 | 4.2 | 35.2 | 4.2 |

Teeth, each a temporary switch, with the JIT cache cleared:
- **The global declaration's retain reverted** (#444): `own_global_script` fails under own (AOT,
JIT, no-counting). rc passes either way, as §20.2 says.
- **The global initializer's erase skipped:** `own_global_init` fails.
- **The constant-data erase skipped:** `own_constant_data` fails.
- **Its no-use-after-move skipped:** `own_constant_data` fails under the runner's `--opt`.
- **The `ts.New` exclusion dropped:** `own_err_literal_two_records` compiles.
- **The boxing exclusion dropped:** `own_err_interface_two_views` still fails. It compiles only with
both exclusions dropped, so for that test each exclusion is a second guard for the other.
- **The record rule's two acceptances dropped:** `own_record_local` fails.

### 20.4 Tests and the corpus

- **Positive:** `own_global_script` (also under rc, none and gc), `own_global_init`,
`own_constant_data`, `own_record_local`.
- **Negative:** `own_err_literal_two_records`.

Corpus: 364 → 402 of 591, none lost. It is 402 with the runner's `--opt --opt_level=3` as well;
the plain count can overstate (`01optional` compiled plain and failed under `--opt` before the
no-use-after-move rule). Each of the 38 passes test-runner `-mm=own`, AOT and JIT, with gc's
output, re-gated on the final build. The first errors after:

| first error | files |
|---|---|
| `takes a second reference` | 101 |
| used after its value was moved | 24 |
| borrows a field and cannot be stored, returned or captured | 21 |
| borrows an element and cannot be stored, returned or captured | 9 |
| `left a retain behind` | 0 |

Remaining shapes, by the dump, as the files where each is the only blocker:
- slot reads, 25. They split into:
- a record a `ts.Constant` starts, stored or taken;
- a parameter's value stored, pushed or cast;
- other, 24;
- a conditional's result (`return a > b ? a : b`), 7;
- tuples, 7;
- nested record literals (`{ inner: { item: new C() } }`) and arrays of record literals.

These are the next PR's input: named messages for what the dump recognises, and the sampling
of the use-after-move and borrows-a-field files for false positives.

### 20.5 Found, not fixed (every model)

- **A global initialized from another global reads garbage** (#446). `let a: number[] = [1, 2]; let b = a;`
at module level gives `b.length` = 140711081380496 under gc. With a class, the program faults.
- **A module with a top-level statement, imported as source,** (#447) fails with "Global is referenced
by parentless instruction! @puts" (invalid LLVM IR).
- **With `-shared`, an exported `const` from such a module** (#448) cannot be resolved by the importer.

### 20.6 Known limits

- **A borrow of data nothing owns past its owner's overwrite** is still an error:
`let m = n; n = null; use(m)`, with `n: number | null`.
- **Nested record literals, and arrays of record literals,** are the catch-all.
- **A conditional's result** is the catch-all.
72 changes: 72 additions & 0 deletions tslang/lib/TypeScript/OwnershipFacts.h
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,78 @@ inline bool holdsNoBlock(mlir::Value value)
return !ownsHeap(value) || (def && isLiteral(def));
}

// Some view of `value` is a `ts.NewInterface`, which boxes what it shows into a block.
inline bool boxedByView(mlir::Value value)
{
llvm::SmallVector<mlir::Value> values{value};
while (!values.empty())
{
for (auto &use : values.pop_back_val().getUses())
{
auto *user = use.getOwner();
if (isView(user) && use.getOperandNumber() == 0)
{
if (mlir::isa<mlir_ts::NewInterfaceOp>(user))
{
return true;
}

values.push_back(user->getResult(0));
}
}
}

return false;
}

// Data nothing owns: what holds no block, a `ts.Constant` (its strings are immortal) or a field of
// one, and a tuple built of such (`[1, "a"]`, an object literal's fields read out of its constant).
// Retaining it takes nothing anyone gives back.
inline bool isConstantData(mlir::Value value)
{
// a view that boxes it makes a block of its own: a folded object literal given as an interface
// is a new block each time, which its own release destroys. And a `ts.New` is a block, though
// its `value_ref` type owns nothing: it is the object its view shows.
if (value.getDefiningOp<mlir_ts::NewOp>() || boxedByView(value))
{
return false;
}

if (holdsNoBlock(value))
{
return true;
}

auto *def = value.getDefiningOp();
if (!def)
{
return false;
}

if (mlir::isa<mlir_ts::ConstantOp, mlir_ts::SymbolRefOp>(def))
{
return true;
}

// a function, which a `ts.SymbolRef` names, seen as a function value: it has no capture box
if (auto castOp = mlir::dyn_cast<mlir_ts::CastOp>(def); castOp && castOp.getIn().getDefiningOp<mlir_ts::SymbolRefOp>())
{
return true;
}

if (auto extractOp = mlir::dyn_cast<mlir_ts::ExtractPropertyOp>(def))
{
return extractOp.getObject().getDefiningOp<mlir_ts::ConstantOp>() != nullptr;
}

if (mlir::isa<mlir_ts::CreateTupleOp>(def))
{
return llvm::all_of(def->getOperands(), [](mlir::Value operand) { return isConstantData(operand); });
}

return false;
}

// A captured variable's cell, as a closure body reaches it: read out of a field of its capture box
// (or of any other block - a RefType loaded from somewhere is a cell). Its variable lives in
// whatever function declared it, so an assignment through it may destroy a value that function, and
Expand Down
Loading
Loading