Record a NodeId-keyed lineage table as a byproduct of each rewrite - #107
Record a NodeId-keyed lineage table as a byproduct of each rewrite#107groundlar wants to merge 5 commits into
Conversation
74e408b to
db039eb
Compare
4fa6517 to
bad8e98
Compare
db039eb to
9590586
Compare
bad8e98 to
5009100
Compare
5b6e02a to
c0bc0cc
Compare
5009100 to
6956a16
Compare
9bcc01f to
4b7068b
Compare
… surfaces (#98) `Clone` was derived on `Expr`, so it copied `node_id`: a subtree reaching the output at two positions left both nodes sharing one identity, which makes the pane projection ambiguous and collapses the pair into a single entry in every `NodeId`-keyed walk. Only one pipeline boundary — `post-lowering` — checked for that. This adds the other seven (`post-inline`, `post-transact`, `post-letrec-run`, `post-desugar`, `post-live-read`, `post-lambda-elim`, `post-planning`), fixes the two violations they surface, and then **removes the class of defect entirely by making `Clone` freshen**. ## The two violations the new boundaries surfaced **`simplify`'s zip-distribute** placed the left operand on both legs with bare clones, so the legs shared an identity. It survived because the follow-on product-beta consumes one copy per leg whenever the arms read different slots (`⟨.0, .1⟩`); only same-slot arms (`⟨.0, .0⟩`) leave both live. The first leg is now the survivor, the second a freshened sibling. **`transact_phase::subst_var_with`** freshened the whole replacement, satisfying uniqueness by *deleting* the occurrence's id — and the read site is user-written, so its span went too. It now root-carries, like the two `subst_env`s below it. `planning::groupby`'s key lift was flagged by the same audit and is already safe: it leaves the predicate domain through `lambda_elim::run`, which re-mints every node. `groupby_recognition_lifts_the_key_without_aliasing` pins that, so an elim that started preserving ids fails there rather than at a pane boundary. ## `Clone` freshens, and the opt-out is named A clone is a **sibling**, not the same node. The hand-written `Clone` mints a new `NodeId` for every node it copies and reports each `(origin, fresh)` pair through `on_copy`. The recursion is the derive's — `node.clone()` clones the children, each reaching the same impl — so the freshen is deep by construction and **fused** into the copy rather than being the second walk `fresh_copy` made. `fresh_copy`, `freshen_node_ids_deep`, `freshen_interior_node_ids`, `freshen_node_id` and monomorphization's `freshen_clone_node_ids` are all deleted; 34 call sites become a plain `clone()`. **The decision inverts rather than disappearing.** The safe default is now automatic, and a copy that is *not a new node* says so through `clone_preserving_ids`. Two shapes qualify — a snapshot taken for rollback or comparison, and a test comparing trees across a pass — plus the moves-out-of-a-borrow, where Rust forces a clone, the source is dropped, and the copy takes its position. Those last were *already* id-preserving here; only the spelling flips, since `clone()` used to preserve and `fresh_copy()` opted into freshening. What it is **not** for is silencing a leak. An `Unexplained` or `ParentUnknown` means a copy was made with no frame open, or against an origin the table never recorded — a *recording* gap, whose honest fix is a bracket. ### Measured, because the objection was about cost Release, min-of-5, against the unchanged parent, on the instrumented top of the stack (so the pane gates were live): | program | compile time | peak RSS | ids | |---|---|---|---| | `stress_10_10_3_10` | 1.067 → **1.030 s** (0.97×) | +2.2% | 2.09× | | `stress_10_10_5_12` | 3.702 → **3.472 s** (0.94×) | +1.3% | 1.74× | | `stress_10_10_8_12` | 21.631 → **20.569 s** (0.95×) | +0.5% | 1.36× | Compile time *improves*, consistently and with a mechanism: one fused walk replaces copy-then-walk. Isolating capture with `CAMBRA_LINEAGE=0/1`, the lineage table goes 5.2 MB → 10.3 MB on a 1 GB compile; with capture off the two arms agree to 0.05%, so the freshening itself costs no memory. Even the unmitigated arm — freshening everywhere, no opt-outs — was faster than baseline at 2–3× the ids. ## Lowering records its predicates `collect_tree_ids` now reaches refinement predicates, so the lowering fold has to explain them. It could not: lowering builds a predicate from already-lowered sub-expressions plus the nodes minted and copied to join them up, seals it into a `Refinement` via `refined_data_fun`, and those assembly nodes live in a type slot outside the `walk_children` domain. 1331 of them across 80 pipeline tests. **Nearly half are copies that exist only because `Clone` freshens** — they used to alias already-tagged main-tree ids — which is why this rides in the same commit. `LoweringContext::tag_predicate` sweeps the finished predicate at the three sites that build one. A copy-frame cannot do this job: `flush_into_lowering` deliberately asserts a lowering frame captures no mints (a frame-flushed step carries no span, so a mint routed through one would be silently unresolvable), and a predicate is a mixed mint/copy region. **The sweep skips nodes that are already recorded, and that is the load-bearing part.** The fold is last-write-wins, so a blanket sweep leaves every node perfectly *explained* while silently replacing its real span and label with the sweep's coarse ones — 318 nodes on the corpus, with every gate green. `the_predicate_sweep_skips_already_recorded_nodes` pins it, because no leak class can. `assert_unique_node_ids` stays narrow: it answers uniqueness, not explanation, and predicates legitimately alias main-tree ids at inline's blind spot. ## What this PR cannot prove on its own This commit gates on the lowering fold and uniquify's id-stability tripwire, and that is all — the `NodeId`-keyed table and the pane boundaries arrive in #107. The pane snapshots, `check()`'s scratch copy, the register-init stash and the type-domain discharges carry no local test; they are verified on the instrumented stack and land here on that evidence. Two markers record what the next commit must do: - **`TODO(mono-frame)`** in `specialize_use`: when the recorder arrives, its frame must open **before** the clone, since the clone is what fires `on_copy` — a frame entered after it leaves the whole specialization `Unexplained`. - **`TODO(predicate-domain)`** on `lineage::preserving_ids`: one legitimate caller (`PredMemo`'s rebuild), slated for removal once predicate mints are recorded. Inference's predicate producers are deliberately out of scope here — there is no pass recorder over inference yet, so a bracket would be a no-op with no test that could fail. Full reading, including the eight distinct intents that were hiding under one `clone` and the exhaustive compiler-oracle sweep of all 118 production clone sites: `freshening-clone-report` in the vault. --- ## Rebased onto `main` (`7a9d1201`) Restacked onto current `main`, which brought #91 (commit stores split by connected components of transaction blocks) and #92 (`await_final` terminal reads). **One new commit rides here:** `ccl(transact): a dropped as-of read moves its body rather than freshening it`. `rewrite_as_of_reads`' dead-binding sweep (`drop_dead_as_of_reads`) does `*e = (**body).clone()` to drop a `let` whose as-of read nothing reads. That was a move when `clone()` preserved ids; with the inversion above it re-mints the whole continuation instead, stranding every id below the dropped binding — and on a reply chain that subtree is the rest of the program. It now `mem::take`s the body, which copies nothing at all. The site arrived upstream *after* the clone audit in this PR ran, so it was never one of the 118 sites that sweep classified. Read that claim as "118 as of the pre-rebase tree"; this is the 119th, and it is the one case the sweep's own argument predicts — a `clone()` that was a move, at a call site nobody re-read when the default flipped. **The second placement of a register init is gone.** The trailing `final_or_default(reg_x, init)` read that placed a key's seed a second time is now an `as_of_read` with no seed operand (#92), so the freshening this PR added there had nothing left to freshen and was dropped rather than ported. **Boundary rename:** `assert_unique_node_ids(&expr, "post-live-read")` is now `"post-as-of-read"`, following upstream's rename of `rewrite_live_reads` → `rewrite_as_of_reads`. **Stale numbers.** The compile-time / peak-RSS / id-ratio table above was measured before the restack and has **not** been re-run. The mechanism it reports — one fused walk replacing copy-then-walk — is unchanged by the rebase.
e8cd06b to
a0d6cd3
Compare
…ach rewrite
Every IR node keeps a link to the source the user wrote, through a pipeline
that otherwise loses it. Recording is a **byproduct of performing a
rewrite**, never a post-pass diff of before and after.
A rewriting site names the node it is about to rewrite and declares nothing
else:
let _g = lineage::enter(slot_id, "inline.beta", Nature::Expansion);
Every id minted while that guard is innermost records `slot_id` as a parent.
The pairing is (id before, minted during), not (value in, value out), so one
recording fits an `fn(Expr) -> Expr` rewrite and an `&mut Expr` one alike,
and a recording that mints nothing is a preserve that records nothing.
**Nothing declares a fate.** Deaths are `input_ids ∖ output_ids` at a
boundary, so no pass has to predict whether a value dies before it can
record — a prediction `mut_elim` was making by re-running `collect_writes`
and `body_has_feed` to guess what a transform 140 lines away would decide.
It is deleted, not repaired.
Provenance is stored as one row per node, keyed by `NodeId`: `parents` (the
ids consumed to produce it), `blame` (ids it is related to but was not made
from), and an interned `rule`. The two relations stay separate columns
because they assert different things — descent versus relatedness — and a
consumer must be able to render or prune each. Attribution reads both,
unioned, parentage first.
No span column: a node's spans are derived by walking `parents` back to the
lowering projection, measured at five hops or fewer and flat as programs
grow. Lowering keeps its own sink, because that is where spans enter and
where the walk terminates.
Three write-time assertions carry what the shape guarantees: one row per id,
no row with neither parent nor blame, and no node as its own parent. The
boundary audit reports `Unexplained` (an output node nothing accounted for),
`ParentUnknown`, and `Died` — the last a report, not a defect.
Adoption here covers lowering, monomorphization, inference's singleton
predicate, `inline`, `mut_elim`, `transact_phase`, `channelize`, `subst`,
`simplify` and `planning/iterate`. The last two sit below the final pane, so
their rows reach a table only under an audit window. Both pane boundaries
hold at `Unexplained == 0` with no structural leaks over an eleven-program
corpus.
…r's reach A refinement predicate is an `Rc` shared across many type slots, so a rewrite cannot mutate through it: every rebuild builds a new `Rc` and repoints the refinement it was handed. That is a **replacement** only if the walk reaches every occurrence. Otherwise the original survives on some type the walk never visits, and preserving ids then puts one id-set on two simultaneously-live terms -- caught by nothing, because uniqueness is not asserted for predicates. Nothing about predicates is special here. It is the same replace-versus-derive question the main tree has; sharing is what makes the answer depend on the caller rather than on the operation. So `PredMemo` carries the intent. `PredMemo::replacing()` keeps the ids; `uniquify` is the only caller entitled to it, because it walks the whole tree. Every other caller rebuilds within a single type and now freshens, bracketed on the source predicate's own root. Both `rebuild` and `rebuild_always` run the copy and the rewrite together under that mode, since the callback mints *into* the term. `uniquify`'s entitlement is asserted rather than assumed: a new tripwire beside the existing id-multiset one censuses **distinct** predicate terms (deduped by `Rc` pointer) before and after the walk, and requires N in, N out, same ids. Measured: predicate id collisions 33 -> 0. `distinct_predicate_terms_never_share_a_node_id` now asserts zero rather than pinning a residual. # Preserving shrinks to what it can justify An audit of every `clone_preserving_ids` site, with the id savings measured rather than asserted, moved five of them to recording: - `apply_expr`'s two early returns build a term the caller owns while `e` stays live, so the result is a new node even when the substitution changed nothing. They freshen inside a bracket on `e`. - `fan_out_copy`'s keep-first arm and the chained-comparison operands preserved **302 ids over the whole pipeline suite, subtrees of 1 to 5 nodes**. Both sites already had a `copy_frame` for their other arm, so freshening made the arms identical -- this **deletes** the keep-first bookkeeping outright, including the `used: &mut bool` threaded recursively through `float_comp_source_case`. - the register-init stash preserved 20 ids. It is now bracketed on the `MutDecl` it is taken from, which the stash already carries as `decl`. The justification retired with them was *"freshen it and the later placements row on an id nothing ever recorded"*, which conflates freshening with not recording. A freshened copy made inside a frame is recorded like any other; the requirement is only that a copy is not an **unrecorded** mint. `try_lift_defer` keeps its preserve on measurement: 2484 ids, subtrees to 19. # `re_root` is deleted `mut_elim`'s use was a defect, not churn: `Expr::let_in` *mints*, and the re-root then overwrote that id -- a phantom birth, the exact thing `preserve` exists to make unrepresentable. It is now `Expr::preserve`. `subst`'s use was necessary but wastefully spelled. It carries the *occurrence's* id onto the replacement root so a user-written read site keeps its span, but `clone().re_root(id)` minted a root, recorded a `Copy` for it and discarded both -- one stranded id and row per substituted occurrence. `clone_at` builds the root at the carried id while `node.clone()` still freshens the children. With both converted `re_root` had no users. Its doc also claimed "a clone mints nothing", false since `Clone` began freshening. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8LCJ9ZtrfqA5bbUmNUWeF
6956a16 to
14878ab
Compare
…at match the code The lineage docs had two bespoke words for one idea. A rewrite "bracketed" its products, and the `OpenStep` holding them was a "frame" — neither term meaning anything outside this subsystem, and "frame" already spoken for four other ways in the tree (stack frames, solver specialization frames, scope frames, type-morphism frames). Both are gone. A rewrite **records**; what it opens is a **recording**; the RAII value is a **guard**. Item names are untouched — this is prose, doc comments and assertion strings — so `copy_frame`, `FrameGuard` and `OpenStep` keep their spellings and the diff stays reviewable. The eight `#[test]` names that spelled it the old way are renamed, since they had no callers and were otherwise the only "bracket" left. The pass was also the occasion to make the docs true, because several claims had rotted into saying the opposite of the code: - `provenance.md` said `lambda_elim` and `planning` bracket their rewrites. Neither does: `lambda_elim` opens no recording at all, and `planning` only through `planning/iterate`. - It said the predicate domain is unrecorded and `PredMemo::rebuild` fires no hook. The commit below this one made it record, against `predicate.rebuild`. What remains unrecorded is planning raising a predicate back into the main tree. - `Nature`'s doc said `Expansion` had no production producer. There are sixteen. - `lineage.rs` described a `SourceKey -> NodeId` projection. No such type exists; both domains are `NodeId`. - Two `RewriteLabel` examples named labels that appear nowhere in the tree. - The lowering projection was documented as excluding refinement-predicate interiors; it uses `collect_tree_ids`, which includes them. - Three doc links pointed at items that no longer exist — `flush_bracket`, `freshen_node_id`, `freshen_clone_node_ids` — none of which rustdoc catches, the items being `pub(crate)`. - Counts that had drifted: `simplify`'s wrapped rules (thirteen), the `TODO(preserve)` sites (thirteen across five files), the interned rule triples. `NodeId::as_u64` is deleted. It had no caller anywhere — the `serde` wire impl reads the field directly — and it is `pub` on a `pub` type, so nothing warned. The comment above the wire impl now says why there is no accessor rather than pointing at one; it can come back when a caller needs it. `Pass::Uniquify` and `Pass::LambdaElim` are documented as not yet constructed rather than removed: they are constructed upstack.
Three words named things the codebase already had names for. Each is dropped in favour of the existing name, and `provenance.md` gains a summary of the mechanism so the glossary stops carrying the explanatory load. **`descent`/`relatedness` -> `ancestry`/`blame`.** `EdgeLabels` carried two coined words for the two relations a row records, while the columns producing them are spelled `parents` and `blame`. `relatedness` is gone: it was a synonym for the column it closes, so `EdgeLabels::RELATED` is `BLAME` and `relates()` is `has_blame()`. `descent` becomes `ancestry` rather than `parent`, because the two are not interchangeable at the endpoints: the closure is reflexive, and a `PARENT`-labelled dense self-edge would read as a node parenting itself, which the row-level rule forbids in the same doc. `context.rs` already said "reads ancestry off the label it finds there". **`pane boundary` -> `pane relation`.** What the fold produces is a relation between two panes, and the tree already spelled that three ways -- `pane relation`, `pane-to-pane relation`, `inter-pane relation`. `boundaries()` is `pane_relations()`, `gated_boundaries()` is `gated_pane_relations()`. Bare "boundary" keeps its unrelated senses (the lowering handoff, an inference level, a pass boundary `assert_unique_node_ids` asserts at) -- one word was covering four things, and dropping the pane sense is what makes the other three readable. **`window` -> the passes, or the audit's span.** A window was never a window: it is the set of passes a fold reads (`MONO_PASSES` is a single pass), and the word was already taken by `ProposalWindow` and the interpreter's streaming windows. `collapse` takes `passes`, and `LineageAudit` has a `span` with two chosen endpoints. No flag changes; `CAMBRA_LINEAGE_AUDIT` already named spans. ## Docs the renames were hiding `Mechanism at a glance` states the whole design in five paragraphs -- identity, what a recording claims, the table, what folds it, where spans enter -- so the glossary can be five definitions instead of an explanation. `pane` no longer names the three snapshot fields, which are state rather than definition. **No column records a fate** moves ahead of the column table. It was hanging off `parents` as if it were a parents-specific caveat and repeated under `blame` in different words; the axis is orthogonal to both columns, and deaths come only from the live-set difference. **Four claims still described the pre-predicate model** and contradicted the glossary in the same file: that a predicate interior is the example of an addressed-but-unrecorded id, that admitting predicate interiors as rows is planned, that `collect_tree_ids` excludes them, and that a pane enumerates no predicate interior. Lowering's projection covers every id `collect_tree_ids` enumerates, predicates included, and `PredMemo::rebuild` records. What remains unrecorded is planning raising a predicate into the main tree. ## Comments at the recording sites Eight sites carried 11 to 18 lines of comment, most of it the API contract restated: that nothing declares a death, that blame is not consumption, that the hooks need an open recording to write to, that a recording must not wrap a recursion. Those are properties of `lineage::enter`, so they are stated once in its doc and cut from the sites, which keep only the judgment that is theirs -- which node is the slot, and why. Sites at >=11 lines go from eight to four, and the largest from 18 to 12. `ci.sh` green in all four clippy configurations plus doc and doc_refs. Audit spans unchanged at zero (`letrec`, `mutelim`, `full`), and the suite passes under `DEEP_TYPECHECK=1 CAMBRA_LINEAGE_GATE=1`.
The pass has been `channelize` since it stopped being defer desugaring and started assembling channels, but the older name stayed on its own identifiers. One concept had two names, and `provenance.md` inherited the second because it was tracking the code. **The pass and its artifacts.** `Pass::Desugar` is `Pass::Channelize`, `CompiledProgram::post_desugar_ir` is `post_channelize_ir`, the `"post-desugar"` boundary label is `"post-channelize"`, and `DESUGAR_PASSES` is `CHANNELIZE_PASSES`. Inside the module the recursive worker `desugar` is `channelize_expr` (the entry point was already `run`), with `desugar_inner`, `desugar_rename`, `desugar_substitute` and `DesugarCtx` following. `check_pre_desugar`, `PreDesugar`, `has_pre_desugar_artifacts`, `CompileError::DesugarDefers`, `desugar_leaks` and `desugar_map` rename the same way. Prose naming the pass, its boundary, or its artifacts follows: "pre-desugar" is "pre-channelize", "defer desugaring" is "channelization". **The word survives where it is not this pass.** Lowering desugars `x op= e` and a chained comparison, lambda elimination desugars `BinOp` to applied-combinator form, and `docs`' `let x = e1 in e2 ≡ (λx. e2)(e1)` is a desugaring identity. Those are ordinary uses of the word for a rewrite that removes surface syntax, and renaming them would say the channelize pass does them. That the same word covered both is what made the pass name ambiguous in the first place. Mechanical: no behavior change, no signature change beyond the names. `ci.sh` green in all four clippy configurations plus doc and doc_refs, and the suite passes under `DEEP_TYPECHECK=1 CAMBRA_LINEAGE_GATE=1`.
| |---|---| | ||
| | **pane** | A retained AST snapshot the inspector displays, materialized after a set of passes. Each one costs a retained full-tree clone. | | ||
| | **pane relation** | What folding the passes between two adjacent panes produces: an id-to-id relation with labelled edges. The **durable, gated** artifact — the leak classes are asserted here. | | ||
| | **recording** | The scope `lineage::enter` opens over one rewrite, held as a `FrameGuard`. Every node minted while it is the innermost open one takes the node it names as a parent. Prose here says "a recording" for the scope, "the recording site" for the code location, and "records against X"; the guard is the RAII value that closes it. | |
There was a problem hiding this comment.
Sounds like FrameGuard should be RecordingGuard
| | **pane** | A retained AST snapshot the inspector displays, materialized after a set of passes. Each one costs a retained full-tree clone. | | ||
| | **pane relation** | What folding the passes between two adjacent panes produces: an id-to-id relation with labelled edges. The **durable, gated** artifact — the leak classes are asserted here. | | ||
| | **recording** | The scope `lineage::enter` opens over one rewrite, held as a `FrameGuard`. Every node minted while it is the innermost open one takes the node it names as a parent. Prose here says "a recording" for the scope, "the recording site" for the code location, and "records against X"; the guard is the RAII value that closes it. | | ||
| | **slot** | The node a recording names — `lineage::enter(slot_id, …)` — read off the tree *before* the rewrite runs. Normally a main-tree node. A predicate interior *may* be one, but work **on** a predicate is usually recorded against the predicate's own root, and work that *produces* one against the main-tree node whose type will carry it. | |
There was a problem hiding this comment.
How is "slot" different from "parent"?
| | **pane relation** | What folding the passes between two adjacent panes produces: an id-to-id relation with labelled edges. The **durable, gated** artifact — the leak classes are asserted here. | | ||
| | **recording** | The scope `lineage::enter` opens over one rewrite, held as a `FrameGuard`. Every node minted while it is the innermost open one takes the node it names as a parent. Prose here says "a recording" for the scope, "the recording site" for the code location, and "records against X"; the guard is the RAII value that closes it. | | ||
| | **slot** | The node a recording names — `lineage::enter(slot_id, …)` — read off the tree *before* the rewrite runs. Normally a main-tree node. A predicate interior *may* be one, but work **on** a predicate is usually recorded against the predicate's own root, and work that *produces* one against the main-tree node whose type will carry it. | | ||
| | **predicate interior** | A `NodeId` on a `TypedExpr` inside a `Type::Refinement`'s predicate. Ordinary ids from the same counter, and inside the id domain a fold must explain: `collect_tree_ids` enumerates them. They are the one place explanation and uniqueness come apart — `assert_unique_node_ids` walks the main tree only, because a predicate interior may legitimately carry a main-tree id. See "Walking the ids". | |
There was a problem hiding this comment.
Defining "predicate interior" to mean a type of node ID is a confusing definition. Does this really need a term? Just writing "NodeIds inside refinements" when those need to be referenced feels just as good and doesn't involve new terminology
| stops a sweep replacing precise attribution with a coarse label. For the same reason a duplication | ||
| path may share a predicate `Rc` with its source rather than rebuild one (see | ||
| `design/type-inference.md`, "Sharing is an invariant, not an optimization detail"). | ||
| **Two questions, two domains.** Keep them apart, because they used to have the |
There was a problem hiding this comment.
Looks like this is reverting a bunch of edits you made downstack
| | **slot** | The node a recording names — `lineage::enter(slot_id, …)` — read off the tree *before* the rewrite runs. Normally a main-tree node. A predicate interior *may* be one, but work **on** a predicate is usually recorded against the predicate's own root, and work that *produces* one against the main-tree node whose type will carry it. | | ||
| | **predicate interior** | A `NodeId` on a `TypedExpr` inside a `Type::Refinement`'s predicate. Ordinary ids from the same counter, and inside the id domain a fold must explain: `collect_tree_ids` enumerates them. They are the one place explanation and uniqueness come apart — `assert_unique_node_ids` walks the main tree only, because a predicate interior may legitimately carry a main-tree id. See "Walking the ids". | | ||
|
|
||
| An audit's endpoint is **chosen**, because a span running past the last |
There was a problem hiding this comment.
All four of these paragraphs are misplaced. They aren't defining terms, and this is the terms section. Looks like this is stuff about what automated verification we have; it probably belongs in a section at the end, or maybe alongside whichever section introduces the invariant that is being checked.
Also, it looks like the content might be stale. It references exceptions for refinements
| @@ -1 +1 @@ | |||
| # Provenance & lineage — node identity and source attribution | |||
There was a problem hiding this comment.
Why are provenance and lineage two separate things? The contents of provenance.rs aren't even "provenance" in the colloquial sense; it's just node identity. Can we drop one of these two terms entirely?
| declare what it destroyed, because a death is the difference between the ids live | ||
| before and after. | ||
|
|
||
| Those rows accumulate in one `NodeId`-keyed table per compile, one row per node: |
There was a problem hiding this comment.
What happens if you create multiple recording guards against the same nodeID? For example, maybe we create something blamed to a node, then later transform that same node into new nodes? Do we maintain the proper edges?
| /// inspector reads, never something a gate asserts against. The class that | ||
| /// *is* a defect on this side is [`Unexplained`](Leak::Unexplained) — an | ||
| /// output node no capture explains. | ||
| Died { input: NodeId }, |
There was a problem hiding this comment.
If this isn't a leak, why is it in the Leak enum?
| /// would mean recording the *shape* of the rewrite, which the `parents` | ||
| /// column does not and should not carry: its cardinality already expresses | ||
| /// 1:1, 1:many and many:1, and nothing else about the shape was ever read. | ||
| ParentUnknown { parent: NodeId }, |
There was a problem hiding this comment.
How is ParentUnknown different from Unexplained? Seems like you would hit them the same way: when you see a row in the LineageTable where the parent doesn't exist. Is the difference whether we discovered the node by looking at the LineageTable or by walking the AST and comparing against the LineageMap? That doesn't seem like a particularly important distinction, but if it is, these need better names since that is very non-obvious from either the names or the comments.
If we don't care about the distinction, then this entire Leak enum can be deleted
| /// One in-flight recording, accumulating the ids born and copied while its guard | ||
| /// is the innermost one open. Finalized when the [`FrameGuard`] drops. The | ||
| /// produced side is captured from the construction hooks rather than declared, | ||
| /// which is what makes a row a byproduct of the rewrite. | ||
| struct OpenStep { |
There was a problem hiding this comment.
If this is an inflight recording, should it be called InflightRecording?
Provenance so far is a log of rewrite steps folded at a boundary. This replaces it with a
NodeId-keyed table: one row per node, holding its parents, its blame, and an interned rule tag. Identity is the key; parentage and attribution are columns. Then it extends the recorded domain to refinement predicates, which is where the interesting problems were.Stacked on #98 (
Clonefreshens) and #101 (one substitution engine).The table
A driver brackets each rewrite by node identity —
lineage::enter(slot_id, …)returning a#[must_use]guard — and the nodes minted under that bracket row on the slot. Nothing is declared: a pass never names what it destroys, so there is no region to over-claim and no fate to predict. Deaths are a set difference at the boundary,recorded ∖ live, taken over rows and never over the key space.Copyrather thanTransformis what makes adopt-a-live-subtree expressible: a rewrite that keeps a node's id while wrapping one of its children must not declare that node dead.Predicates enter the recorded domain
collect_tree_idsnow reaches refinement predicates — through type slots,user_annotation, andCasttargets — so the fold must explain them. That splits two questions which previously had one answer, and the design doc now leads with the split:assert_unique_node_idsstays narrow, because a predicate interior legitimately aliases a main-tree id at inline's blind spot.A refinement predicate is program text the user wrote —
[x for x in xs if x > k]putsx > kin one — so it deserves the same attribution as any other node. The "cost, accepted" the design doc used to record, a guard error reporting without a caret because its id was not in the projection, is exactly what this buys back.Three crossings have to record. Entry and transformation are done here. Raising — planning materializing a predicate back into the main tree — is not, and is deliberately left: those nodes are minted below the last pane, so no boundary gates them. It lands with the planning commit.
What that surfaced
Monomorphization was recording nothing at all.
frame.def.clone()sat 22 lines above themono.specializeframe. SinceClonefreshens (#98), the clone is what fireson_copy, so every pair fell on the floor: 28 unexplained nodes, andMonoabsent from the recorded passes entirely. Two pinned expectations move as a result, both in the right direction — the transaction fixture gainsMono, and the boundaries deriving edges go 7 → 16, a strict superset.A predicate rebuild is a replacement or a derivation depending on the caller's reach. A predicate is an
Rcshared across many type slots, so a rewrite cannot mutate through it — every rebuild builds a newRcand repoints the refinement it was handed. That is a replacement only if the walk reaches every occurrence; otherwise the original survives on a type the walk never visits, and preserving ids puts one id-set on two simultaneously-live terms.Nothing about predicates is special here — it is the same replace-versus-derive question the main tree has. Sharing is what makes the answer depend on the caller rather than on the operation. So
PredMemocarries the intent:PredMemo::replacing()keeps ids anduniquifyis the only caller entitled to it, because it walks the whole tree. Everyone else freshens, bracketed on the source predicate's root.uniquify's entitlement is asserted, not assumed — a tripwire beside the existing id-multiset one censuses distinct predicate terms (deduped byRcpointer) before and after the walk and requires N in, N out, same ids.Predicate id collisions: 33 → 0, now asserted rather than pinned.
Nine substitution sites, each decided by one question — does this code create a new node?
rewrite_expr_gotakes&mut TypedExprand edits the node already in the tree, so nothing new exists.apply_expr_innerreturns a new one, so it is bracketed on the node it derives from. Discharge payloads keep their ids because a payload is a template: it only feedsapply_type, andas_exprclones it afresh at every occurrence it fills.Preserving shrinks to what it can justify
Every
clone_preserving_idssite was audited with the id savings measured, which moved five to recording:fan_out_copykeep-first + chained-compare operandsapply_expr's early returnsestays livetry_lift_deferThe lowering sites already had a
copy_framefor their other arm, so freshening made both arms identical — this deletes the keep-first bookkeeping outright, including theused: &mut boolthreaded recursively throughfloat_comp_source_case.One justification was retired as simply wrong: "freshen it and the later placements row on an id nothing ever recorded" conflates freshening with not recording. A freshened copy made inside a frame is recorded like any other; the requirement is only that a copy is not an unrecorded mint.
re_rootdeletedmut_elim's use was a defect rather than churn:Expr::let_inmints, and the re-root then overwrote that id — a phantom birth, the exact thingpreserveexists to make unrepresentable. NowExpr::preserve.subst's use was necessary but wastefully spelled. It carries the occurrence's id onto the replacement root so a user-written read site keeps its span, butclone().re_root(id)minted a root, recorded aCopyfor it, and discarded both — one stranded id and row per substituted occurrence.clone_atbuilds the root at the carried id whilenode.clone()still freshens the children. Nothing is stranded.With both converted,
re_roothad no users. Its doc also claimed "a clone mints nothing", false sinceClonebegan freshening.State
Every gated leak class is zero on all 22 pane boundaries. Full suite green, clippy clean.
Known and deliberate: planning's predicate work is untouched (it is the largest remaining body — 775 rewrites over 8825 nodes — and belongs with the planning commit), and predicate uniqueness is asserted only by the new test rather than by a boundary check.
Rebased onto
main(7a9d1201)Upstream's #91 (commit stores split by connected components of transaction blocks)
and #92 (
await_finalterminal reads) rewrote most oftransact_phase, so therecording was re-adopted onto the new structure rather than merged textually.
Ported.
build_letrec→plan_store(now per store) andsplice_letrec→splice_stores/walk_spine, carryingtransact.commit_record,transact.history,transact.key_rebindandtransact.carrierwith them.key_initthreadsMutVarDeclthrough upstream's by-reference signatures.StorePlan::carrier_slotnames each store carrier's slot on the same argument thesingle carrier used — the outermost key declaration among its keys. A store's
planned bindings are moved out of the borrowed plan with
clone_preserving_idsrather than freshened, so the finer
commit_record/historyattributionsurvives placement instead of being re-parented wholesale onto the carrier.
Newly bracketed, because the rewrites are new:
transact.await_final(theterminal read a marker becomes) and
transact.await_final_seed(a writer-freekey's seed, inlined for its await).
Two recording gaps the audit surfaced, both closed. Neither is new to the
rebase; both were invisible before the window change below.
fold_cross_domain_loopscalledmut_elim::fold_induction_loopwith no frameopen, so the entire folded
letrec __hist— its binding, trailing reads, feedhoists, and the per-accumulator views a commit decision reads
acc(r)through —was an unrecorded mint. That was ~46 unexplained nodes on every program with a
cross-domain induction loop. Now
transact.cross_domain_fold, withwrap_cross_domainbracketing each folded loop's carrier on the statement it camefrom (
transact.cross_domain_group, via aslotscolumn index-parallel withbindings) and the shared body wrappers on the outermost(
transact.cross_domain_body).mut_elim::flatten_spinemints in two of its arms — the writer-body hoist and aterminalized value-position write — and runs at the top of
mut_elim::run,before any frame. Now
letrec.hoist_writer_bodyandletrec.terminalize_write.Its other arms already preserved ids deliberately and are untouched.
CAMBRA_LINEAGE_AUDIT=fullnow ends at the last instrumented pane(
post-inference..post-as-of-read) instead ofjoin-planned. An audit measureswhat the brackets explain, so a window running past the last instrumented pass
counts everything the uninstrumented tail mints as a defect — a number that cannot
reach zero however correct the recording is. That made the audit read as a broken
gate rather than a measurement, and it buried both gaps above in planning's noise.
The endpoint is meant to move to
post-lambda-elimand thenjoin-plannedinthe commits that instrument those passes;
provenance.mdnow says so at thewindow, and the glossary entry on windows says why.
Renames following upstream: the
post-live-readboundary ispost-as-of-read; thetransact.live_readrule istransact.as_of_read.State, measured on the restacked stack
Full suite green;
fmtclean; clippy clean in debug, release,serdeand lib-only.UnexplainedParentUnknownletrec(post-inference..post-letrec), predicate interiors livefull(post-inference..post-as-of-read), predicate interiors liveThe
fullresidue is allchannelize— ending the window atpost-desugarinstead accounts for every one of the 6. Channelize's adoption here is scoped to
the defer cluster, and closing the rest is not done in this PR.
With the narrow live set (no
CAMBRA_LINEAGE_PREDICATES=1) theletrecwindowreports
ParentUnknownon 166 of 581 windows, which is the predicate-interiorcrossing
provenance.mdalready documents: this commit'sPredMemoderives andrecords rather than preserving, so a rebuilt predicate's parents are
predicate-interior ids the narrow set excludes. Admitting them is what closes it,
as that section predicts.
Numbers carried over and not re-taken: the "33 → 0" predicate-collision count,
the "all 22 pane boundaries" count, and the 1184-
ParentUnknownpredicate-domainresidue quoted in
provenance.md(measured at the oldfullspan — the doc nowsays so inline). The pane-boundary leak gate itself
(
pane_boundaries_fold_with_no_structural_leaks,Unexplained == 0andParentUnknown == 0at both boundaries over its corpus) passes on the restackedstack; it is the count that was not re-counted.
Rebased onto
main(7e31f0f2), after #98 and #101 mergedBoth landed as squashes, so the stack's own hygiene and subst commits are not
ancestors of
main; the threesubstcommits were dropped as redundant (verifiedfirst:
free_among,discharge_env_in_place, both arrow tests, and the removal ofsubst_var_with/subst_envare all present onmain). #114 (refinements andfunction-def output types) also landed underneath.
Where upstream's merged version and this stack had both rewritten the same thing,
upstream's won on two counts where it reached the same conclusion more cleanly:
fan_out_copydropping its keep-firstusedflag, and the chained-comparisonoperand(i)closure.provenance.md's### The id domainis now### Walking the ids, following upstream's rename, with the citations retargeted.Channelize is instrumented
The previous round left 6 windows leaking, all in
channelize, and reported themrather than fixing them. They are fixed. Two rewrite arms in
desugar_innermintedoutside any frame — the defer lift (
lift_defersplices the outer body into theinner scope's tail) and the defer-returning-
letcollapse (which copies the innerscope out of the borrowed tree). Both replace the
letthey match, so both arebracketed on it:
channelize.defer_liftandchannelize.defer_collapse.The audit's default live set now matches the gate's
materialize_panestakes every pane's live set withcollect_tree_ids, which ispredicate-inclusive; the audit defaulted to the narrow
collect_main_tree_idsand so reported edges the gate does not. A recorded predicate rebuild's parents are
input-tree predicate interiors, which the narrow set omits from the input side, so
those edges dangled as
ParentUnknownwith nothing unrecorded — ~166 windows ofnoise on a plain invocation, which is exactly where real defects hide.
CAMBRA_LINEAGE_PREDICATESis now on by default,=0to narrow.CAMBRA_LINEAGE_GATE=1— the gate's corpus is the suiteThe always-on gate (
pane_boundaries_fold_with_no_structural_leaks) sampleseleven programs listed in
corpus(). That sample is what let two recordinggaps live —
transact_phasecallingmut_elim::fold_induction_loopwith no frameopen, and
flatten_spine's value-position writer hoist — because neither shape isamong the eleven. With this flag every compile folds its own pane boundaries and
gates them, so the corpus is whatever the caller compiles. CI turns it on.
It gates
gated_boundaries()rather than both, on the same argument as an auditwindow's endpoint: the first boundary spans monomorphization and inference, and
inference's predicate producers do not record —
specialize_useclones adefinition per instantiation and the predicate copies inside it row against
interior ids no pass produced. That is 5 programs over
tests/compilation_pipeline, all UDF-with-filter or poly-wrapper shapes, and allof it the crossing "Known prerequisites" already records. Gating it today would
pin a constant, not catch a regression; it joins
gated_boundaries()in the committhat makes inference record.
Cost, debug, alternating runs on a warm build: the pipeline binary goes
0.97 s → 1.07 s (min-of-6; +~0.10 s, ~+9%), and the whole suite 3.21 s → 3.36 s
(min-of-3; +~0.15 s, ~+4%).
State
Full CI green with
DEEP_TYPECHECK=1 CAMBRA_LINEAGE_GATE=1. Every instrumentedaudit window is zero on a plain invocation, no flags:
UnexplainedParentUnknownletrecpost-inference..post-letrecmutelimpost-transact..post-letrecfullpost-inference..post-as-of-readCAMBRA_LINEAGE_AUDIT=planningreports 39 / 48 over 580 —lambda_elimandplanning, which the upstack commits instrument and which thefullendpointdeliberately stops short of.
Third commit: the recording vocabulary is plain English now
The docs carried two bespoke words for one idea — a rewrite "bracketed" its
products, and the
OpenStepholding them was a "frame". Neither means anythingoutside this subsystem, and "frame" was already spoken for four other ways in the
tree (stack frames, solver specialization frames, scope frames, type-morphism
frames). A rewrite now records, what it opens is a recording, and the RAII
value is a guard. Prose, doc comments and assertion strings only — no item is
renamed, so
copy_frame,FrameGuardandOpenStepkeep their spellings and thediff stays reviewable. The eight
#[test]names spelling it the old way arerenamed too, having no callers and being otherwise the last "bracket" standing.
Mining the abandoned doc pass turned up claims that had rotted into saying the
opposite of the code, which is the part worth reviewing:
provenance.mdsaidlambda_elimandplanningbracket their rewrites.Neither does —
lambda_elimopens no recording at all,planningonly viaplanning/iterate. That is the same false claim this PR has been workingaround.
PredMemo::rebuildfires nohook — directly contradicting the commit immediately below it, which records
against
predicate.rebuild. What actually remains unrecorded is planningraising a predicate back into the main tree.
Nature's doc claimedExpansionhad no production producer; there are 16.lineage.rsdescribed aSourceKey → NodeIdprojection. No such type; bothdomains are
NodeId.RewriteLabeldoc examples named labels appearing nowhere in the tree.interiors. It uses
collect_tree_ids, which includes them.flush_bracket,freshen_node_id,freshen_clone_node_ids) — invisible to rustdoc, the items beingpub(crate).simplify's wrapped rules (thirteen, cited two different wrongways), the
TODO(preserve)sites, the interned rule triples.NodeId::as_u64is deleted — no caller anywhere, theserdewire impl readsthe field directly, and being
pubon apubtype nothing warned. The commentabove the wire impl now says why there is no accessor instead of pointing at one.
Pass::UniquifyandPass::LambdaElimare left in place, documented as not yetconstructed, since they are constructed upstack.
Gates
Every
ci.shtarget green, includingclippy_release,clippy_serdeandclippy_lib(theserdeone matters here, sinceas_u64sat next to the wireimpl), and
DEEP_TYPECHECK=1 CAMBRA_LINEAGE_GATE=1 ./ci.sh test. The audit windowsare unchanged at zero:
letrecandmutelim0/584,full0/580.