Skip to content

Record a NodeId-keyed lineage table as a byproduct of each rewrite - #107

Open
groundlar wants to merge 5 commits into
mainfrom
skylar/provenance/02-lineage-table
Open

Record a NodeId-keyed lineage table as a byproduct of each rewrite#107
groundlar wants to merge 5 commits into
mainfrom
skylar/provenance/02-lineage-table

Conversation

@groundlar

@groundlar groundlar commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

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 (Clone freshens) 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.

Copy rather than Transform is 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_ids now reaches refinement predicates — through type slots, user_annotation, and Cast targets — so the fold must explain them. That splits two questions which previously had one answer, and the design doc now leads with the split:

  • Explanation — which ids must the fold account for? The main tree and its predicates.
  • Uniqueness — may two live nodes share an id? The main tree only. assert_unique_node_ids stays 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] puts x > k in 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 the mono.specialize frame. Since Clone freshens (#98), the clone is what fires on_copy, so every pair fell on the floor: 28 unexplained nodes, and Mono absent from the recorded passes entirely. Two pinned expectations move as a result, both in the right direction — the transaction fixture gains Mono, 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 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 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 PredMemo carries the intent: PredMemo::replacing() keeps ids and uniquify is 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 by Rc pointer) 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_go takes &mut TypedExpr and edits the node already in the tree, so nothing new exists. apply_expr_inner returns 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 feeds apply_type, and as_expr clones it afresh at every occurrence it fills.

Preserving shrinks to what it can justify

Every clone_preserving_ids site was audited with the id savings measured, which moved five to recording:

site preserved verdict
fan_out_copy keep-first + chained-compare operands 302 ids, subtrees of 1–5 record
register-init stash 20 ids, subtrees of 1–3 record
apply_expr's early returns record: the result is a new node the caller owns while e stays live
try_lift_defer 2484 ids, subtrees to 19 keep

The lowering sites already had a copy_frame for their other arm, so freshening made both arms identical — this deletes the keep-first bookkeeping outright, including the used: &mut bool threaded recursively through float_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_root deleted

mut_elim's use was a defect rather than churn: Expr::let_in mints, and the re-root then overwrote that id — a phantom birth, the exact thing preserve exists to make unrepresentable. 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. Nothing is stranded.

With both converted, re_root had no users. Its doc also claimed "a clone mints nothing", false since Clone began 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_final terminal reads) rewrote most of transact_phase, so the
recording was re-adopted onto the new structure rather than merged textually.

Ported. build_letrecplan_store (now per store) and splice_letrec
splice_stores/walk_spine, carrying transact.commit_record,
transact.history, transact.key_rebind and transact.carrier with them.
key_init threads MutVarDecl through upstream's by-reference signatures.
StorePlan::carrier_slot names each store carrier's slot on the same argument the
single carrier used — the outermost key declaration among its keys. A store's
planned bindings are moved out of the borrowed plan with clone_preserving_ids
rather than freshened, so the finer commit_record / history attribution
survives placement instead of being re-parented wholesale onto the carrier.

Newly bracketed, because the rewrites are new: transact.await_final (the
terminal read a marker becomes) and transact.await_final_seed (a writer-free
key'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_loops called mut_elim::fold_induction_loop with no frame
    open
    , so the entire folded letrec __hist — its binding, trailing reads, feed
    hoists, 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, with
    wrap_cross_domain bracketing each folded loop's carrier on the statement it came
    from (transact.cross_domain_group, via a slots column index-parallel with
    bindings) and the shared body wrappers on the outermost
    (transact.cross_domain_body).
  • mut_elim::flatten_spine mints in two of its arms — the writer-body hoist and a
    terminalized value-position write — and runs at the top of mut_elim::run,
    before any frame. Now letrec.hoist_writer_body and letrec.terminalize_write.
    Its other arms already preserved ids deliberately and are untouched.

CAMBRA_LINEAGE_AUDIT=full now ends at the last instrumented pane
(post-inference..post-as-of-read) instead of join-planned. An audit measures
what 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-elim and then join-planned in
the commits that instrument those passes
; provenance.md now says so at the
window, and the glossary entry on windows says why.

Renames following upstream: the post-live-read boundary is
post-as-of-read; the transact.live_read rule is transact.as_of_read.

State, measured on the restacked stack

Full suite green; fmt clean; clippy clean in debug, release, serde and lib-only.

window Unexplained ParentUnknown
letrec (post-inference..post-letrec), predicate interiors live 0 / 581 0 / 581
full (post-inference..post-as-of-read), predicate interiors live 6 / 577 6 / 577

The full residue is all channelize — ending the window at post-desugar
instead 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) the letrec window
reports ParentUnknown on 166 of 581 windows, which is the predicate-interior
crossing provenance.md already documents: this commit's PredMemo derives and
records 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-ParentUnknown predicate-domain
residue quoted in provenance.md (measured at the old full span — the doc now
says so inline). The pane-boundary leak gate itself
(pane_boundaries_fold_with_no_structural_leaks, Unexplained == 0 and
ParentUnknown == 0 at both boundaries over its corpus) passes on the restacked
stack; it is the count that was not re-counted.


Rebased onto main (7e31f0f2), after #98 and #101 merged

Both landed as squashes, so the stack's own hygiene and subst commits are not
ancestors of main; the three subst commits were dropped as redundant (verified
first: free_among, discharge_env_in_place, both arrow tests, and the removal of
subst_var_with/subst_env are all present on main). #114 (refinements and
function-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_copy dropping its keep-first used flag, and the chained-comparison
operand(i) closure. provenance.md's ### The id domain is 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 them
rather than fixing them. They are fixed. Two rewrite arms in desugar_inner minted
outside any frame — the defer lift (lift_defer splices the outer body into the
inner scope's tail) and the defer-returning-let collapse (which copies the inner
scope out of the borrowed tree). Both replace the let they match, so both are
bracketed on it: channelize.defer_lift and channelize.defer_collapse.

The audit's default live set now matches the gate's

materialize_panes takes every pane's live set with collect_tree_ids, which is
predicate-inclusive; the audit defaulted to the narrow collect_main_tree_ids
and 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 ParentUnknown with nothing unrecorded — ~166 windows of
noise on a plain invocation, which is exactly where real defects hide.
CAMBRA_LINEAGE_PREDICATES is now on by default, =0 to narrow.

CAMBRA_LINEAGE_GATE=1 — the gate's corpus is the suite

The always-on gate (pane_boundaries_fold_with_no_structural_leaks) samples
eleven programs listed in corpus(). That sample is what let two recording
gaps live — transact_phase calling mut_elim::fold_induction_loop with no frame
open, and flatten_spine's value-position writer hoist — because neither shape is
among 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 audit
window's endpoint: the first boundary spans monomorphization and inference, and
inference's predicate producers do not recordspecialize_use clones a
definition 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 all
of it the crossing "Known prerequisites" already records. Gating it today would
pin a constant, not catch a regression; it joins gated_boundaries() in the commit
that 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 instrumented
audit window is zero on a plain invocation, no flags:

window span Unexplained ParentUnknown
letrec post-inference..post-letrec 0 / 584 0 / 584
mutelim post-transact..post-letrec 0 / 584 0 / 584
full post-inference..post-as-of-read 0 / 580 0 / 580

CAMBRA_LINEAGE_AUDIT=planning reports 39 / 48 over 580 — lambda_elim and
planning, which the upstack commits instrument and which the full endpoint
deliberately 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 OpenStep holding them was a "frame". Neither means anything
outside 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, FrameGuard and OpenStep keep their spellings and the
diff stays reviewable. The eight #[test] names spelling it the old way are
renamed 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.md said lambda_elim and planning bracket their rewrites.
    Neither does — lambda_elim opens no recording at all, planning only via
    planning/iterate. That is the same false claim this PR has been working
    around.
  • It said the predicate domain is unrecorded and PredMemo::rebuild fires no
    hook — directly contradicting the commit immediately below it, which records
    against predicate.rebuild. What actually remains unrecorded is planning
    raising a predicate back into the main tree.
  • Nature's doc claimed Expansion had no production producer; there are 16.
  • lineage.rs described a SourceKey → NodeId projection. No such type; both
    domains are NodeId.
  • Two RewriteLabel doc examples named labels appearing 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 deleted items (flush_bracket, freshen_node_id,
    freshen_clone_node_ids) — invisible to rustdoc, the items being pub(crate).
  • Drifted counts: simplify's wrapped rules (thirteen, cited two different wrong
    ways), the TODO(preserve) sites, the interned rule triples.

NodeId::as_u64 is deleted — no caller anywhere, the serde wire impl reads
the field directly, and being pub on a pub type nothing warned. The comment
above the wire impl now says why there is no accessor instead of pointing at one.

Pass::Uniquify and Pass::LambdaElim are left in place, documented as not yet
constructed, since they are constructed upstack.

Gates

Every ci.sh target green, including clippy_release, clippy_serde and
clippy_lib (the serde one matters here, since as_u64 sat next to the wire
impl), and DEEP_TYPECHECK=1 CAMBRA_LINEAGE_GATE=1 ./ci.sh test. The audit windows
are unchanged at zero: letrec and mutelim 0/584, full 0/580.

@groundlar
groundlar requested a review from a team as a code owner August 18, 2026 21:05
Comment thread src/ccl/design/provenance.md Outdated
Comment thread src/ccl/design/provenance.md Outdated
Comment thread src/ccl/design/provenance.md Outdated
Comment thread src/ccl/design/provenance.md Outdated
Comment thread src/ccl/design/provenance.md Outdated
Comment thread src/ccl/design/provenance.md
Comment thread src/ccl/channelize.rs
Comment thread src/ccl/design/provenance.md Outdated
Comment thread src/ccl/lineage.rs Outdated
Comment thread src/ccl/design/provenance.md Outdated
@groundlar
groundlar force-pushed the skylar/provenance/subst-collapse branch from 74e408b to db039eb Compare August 19, 2026 16:32
@groundlar
groundlar force-pushed the skylar/provenance/02-lineage-table branch from 4fa6517 to bad8e98 Compare August 19, 2026 17:36
@groundlar
groundlar force-pushed the skylar/provenance/subst-collapse branch from db039eb to 9590586 Compare August 19, 2026 23:20
@groundlar
groundlar force-pushed the skylar/provenance/02-lineage-table branch from bad8e98 to 5009100 Compare August 20, 2026 14:51
@groundlar groundlar changed the title Record driver-derived provenance into a NodeId-keyed lineage table Record a NodeId-keyed lineage table as a byproduct of each rewrite Aug 20, 2026
@groundlar
groundlar force-pushed the skylar/provenance/subst-collapse branch 2 times, most recently from 5b6e02a to c0bc0cc Compare August 20, 2026 18:20
@groundlar
groundlar force-pushed the skylar/provenance/02-lineage-table branch from 5009100 to 6956a16 Compare August 20, 2026 18:20
@groundlar
groundlar force-pushed the skylar/provenance/subst-collapse branch 2 times, most recently from 9bcc01f to 4b7068b Compare August 21, 2026 01:23
groundlar added a commit that referenced this pull request Aug 21, 2026
… 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.
@groundlar
groundlar force-pushed the skylar/provenance/subst-collapse branch 2 times, most recently from e8cd06b to a0d6cd3 Compare August 21, 2026 12:59
Base automatically changed from skylar/provenance/subst-collapse to main August 21, 2026 13:03
groundlar and others added 2 commits August 21, 2026 07:42
…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
@groundlar
groundlar force-pushed the skylar/provenance/02-lineage-table branch from 6956a16 to 14878ab Compare August 21, 2026 13:43
…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. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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". |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread src/ccl/lineage.rs
/// 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 },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this isn't a leak, why is it in the Leak enum?

Comment thread src/ccl/lineage.rs
/// 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 },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/ccl/lineage.rs
/// 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is an inflight recording, should it be called InflightRecording?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants