Skip to content

Commit 1a74073

Browse files
paddymulclaude
andcommitted
docs: explain the dependency-graph mechanism in reactive-recalc
Adds a "How the dependency graph is recorded and read" section answering the questions that recur about the reactive system: - the three data-sourcing functions (read_project_file / tracked_ / pinned_ expr_from_alias) and the edge each records, as a table - where the graph lives (manifest.parents / manifest.sources, now defined before first use) - roots/leaves direction (data-flow convention, not build-system) - cheap vs expensive parents = non-materialized vs materialized, and why a cheap parent's sources land in the child's manifest.sources - build-time side-channel capture, why it's tallyman not xorq, and why the #73/#74 expression flattening makes the edge unrecoverable from xorq's output - dependents.py as the single read seam Also fixes errors the rename's blanket from_catalog->tracked_expr_from_alias substitution introduced: the hash-pin paths now correctly say pinned_expr_from_alias (tracked_expr_from_alias rejects hashes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bb48747 commit 1a74073

1 file changed

Lines changed: 144 additions & 15 deletions

File tree

docs/reactive-recalc.md

Lines changed: 144 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -73,17 +73,31 @@ tool: it operates on the session's active project (set by `project_switch`); the
7373
optional `project=` kwarg on `read_project_file`/`tracked_expr_from_alias` defaults to that same
7474
active project, so recipes omit it.
7575

76+
A recipe pulls in data through one of three functions in `tallyman_xorq.io`, and
77+
which one you call is what records the dependency edge:
78+
79+
| Function | Reads | Records | Goes stale when |
80+
|---|---|---|---|
81+
| `read_project_file("orders.parquet")` | a raw file under `<project>/data/` | a **source** leaf (`rel_path → digest`) | the file's bytes change on disk |
82+
| `tracked_expr_from_alias("orders")` | a catalog entry, by **alias** | a **parent** edge, `follow=True` | the alias advances to a new head |
83+
| `pinned_expr_from_alias(...)` | a catalog entry, by alias **or** hash | a **parent** edge, `follow=False` | never (it pinned that exact revision) |
84+
7685
The follow relationship is the whole game:
7786

78-
- `tracked_expr_from_alias("orders")` — an alias *name* — records the edge as **follow=True**.
79-
The recipe means "whatever `orders` is now," and at build time it resolves to
80-
`orders`'s current head.
81-
- `tracked_expr_from_alias("<hash>")` — a literal hash — records the edge as
82-
**follow=False**: a pin to that exact revision. A pinned child never goes stale
83-
when its parent advances, and recalc deliberately leaves it alone.
87+
- `tracked_expr_from_alias("orders")` means "whatever `orders` is now." It accepts
88+
an alias only — pass it a hash and it raises, because a hash has no head to
89+
follow — resolves to the alias's current head at build time, and records
90+
**follow=True**. The child goes stale and recomputes when `orders` advances.
91+
This is normal chaining.
92+
- `pinned_expr_from_alias("orders")` or `pinned_expr_from_alias("<hash>")` is the
93+
deliberate opt-out. It accepts an alias or a hash, records **follow=False**, and
94+
the child stays on that exact revision: it never goes stale on the alias axis,
95+
and recalc finds it but leaves it alone.
96+
- `read_project_file` is the root of every chain: a raw file with no catalog
97+
identity, the leaf the graph bottoms out in.
8498

8599
So after these three calls: `orders → by_region → top_regions`, each aliased at
86-
version 1, each following its parent by name.
100+
version 1, each following its parent by name (`tracked_expr_from_alias`).
87101

88102
## Revising an alias and recomputing its dependents
89103

@@ -189,7 +203,7 @@ Three edges make that precise:
189203
intermediate node referenced only by a hash pin — produces a new hash and a
190204
`remap` entry but **zero** alias revisions. A head carrying two aliases advances
191205
both.
192-
- A **hash-pinned** child (`tracked_expr_from_alias("<hash>")`) re-resolves to the same
206+
- A **hash-pinned** child (`pinned_expr_from_alias("<hash>")`) re-resolves to the same
193207
parent and is a `noop`, so it neither rebuilds nor advances its alias.
194208

195209
This per-alias revision is distinct from the catalog-level checkpoint. The alias
@@ -198,6 +212,120 @@ git revision that commits the whole walk (see *One revision, undoable atomically
198212
below). One recalc can append many alias revisions and still take exactly one
199213
checkpoint.
200214

215+
## How the dependency graph is recorded and read
216+
217+
Staleness, the cone, and the cascade all run on a dependency graph the reactive
218+
system never introspects live. The graph is **recorded into each entry's manifest
219+
at build time, then read back by scanning those manifests**. There is no separate
220+
graph database and no global index; the adjacency is rebuilt from the manifests on
221+
each query (cheap for a notebook-sized catalog — one small JSON read per entry).
222+
223+
### Where the edges live
224+
225+
Each entry has a `manifest.json` with two fields that carry the graph:
226+
227+
- **`manifest.parents`** — the resolved cross-entry edges, a list of
228+
`{hash, ref, follow}`. `hash` is the parent's build-time content hash, `ref` the
229+
original argument (alias or hash), `follow` its read-intent. Empty for a root (an
230+
entry that reads no other entry).
231+
- **`manifest.sources`** — the raw-file leaves, `{rel_path: digest}`: the project
232+
files the recipe read via `read_project_file`, each with the content digest it
233+
was built against. `None` when the entry was built under `off` identity mode (so
234+
the source axis can't be evaluated); `{}` when the recipe read no raw files.
235+
236+
`tracked_`/`pinned_expr_from_alias` write `parents`; `read_project_file` writes
237+
`sources`. Together they are the only record of the graph.
238+
239+
### Roots, leaves, and direction
240+
241+
This doc uses the **data-flow** convention (as in Airflow, dbt, Spark lineage):
242+
edges point downstream, the direction data moves.
243+
244+
- A **source / root** has no incoming edges — nothing it depends on: a
245+
`read_project_file` raw input, or an entry with empty `manifest.parents`.
246+
- A **leaf / sink** has no outgoing edges — nothing depends on it: a final
247+
aggregation nobody chains off.
248+
249+
A recalc starts at the changed roots and flows down to the leaves. (Build-system
250+
tools — Make, Bazel — point the arrows the other way and swap these names, which is
251+
the usual source of confusion.)
252+
253+
### Cheap vs expensive parents — and what lands in `manifest.sources`
254+
255+
When a recipe reads a parent with `tracked_expr_from_alias`, what comes back
256+
depends on whether the parent was classified **expensive** or **cheap** at build.
257+
This is the materialized-vs-not distinction:
258+
259+
- An **expensive** parent (an Aggregate, Join, Sort, or UDF) bakes a result
260+
snapshot when built (materialized). A child reading it gets a *read of that baked
261+
snapshot* — the parent's work is computed once and shared, and the child does not
262+
re-run it.
263+
- A **cheap** parent (row-wise: select, filter, mutate) bakes nothing
264+
(non-materialized). A child reading it gets the parent's *recipe re-run inline*
265+
pushdown makes that ~free — so the parent's expression, down to its own
266+
`read_project_file` reads, is composed into the child.
267+
268+
That second case is why a cheap parent's sources show up in the **child's**
269+
`manifest.sources`: the inlined recipe re-runs the parent's `read_project_file`
270+
calls during the child's build, and those reads get collected into the child's own
271+
source set. So editing that raw file makes the child **directly** stale on the
272+
source axis, not merely transitively stale. A child reading an *expensive* parent
273+
reads the snapshot instead, so the parent's raw sources never enter the child's
274+
sources — there the source edit makes the *parent* directly stale, and the child
275+
follows only when the parent's alias advances.
276+
277+
Which work is materialized is otherwise an implementation detail you don't see:
278+
`tracked_expr_from_alias` returns an expression either way, and the child never
279+
depends on the parent having a `result.parquet` on disk.
280+
281+
### Build-time capture, not live introspection
282+
283+
The edges are captured the moment a recipe runs, not by walking the built
284+
expression afterward. While `build_and_persist` imports the recipe, two collectors
285+
are armed, and each loader announces itself as it executes: `read_project_file`
286+
notes the source digest, `tracked_`/`pinned_expr_from_alias` notes the resolved
287+
parent hash. After the import those bags are written into the manifest. The capture
288+
happens once, at build; nothing re-derives it later.
289+
290+
This is a **tallyman** mechanism, not an xorq one. xorq's unit is a single
291+
expression and its content hash; it has no concept of a catalog, an alias, an
292+
entry, or an inter-entry edge — those are all tallyman's. xorq builds one
293+
expression and hashes it; tallyman wraps that build, harvests the edges its own
294+
loaders announced, and writes the manifest beside xorq's artifact.
295+
296+
The capture is a side-channel because the parent edge is **not recoverable from
297+
xorq's output**. Since the expression-composition change (#73/#74),
298+
`tracked_expr_from_alias` composes the parent's *expression* into the child (cheap:
299+
the inlined recipe; expensive: a read of the baked snapshot) rather than reading
300+
the parent's `result.parquet` by a path that named it. The result is one flattened
301+
graph with no node that says "this subtree was entry `<parent_hash>`." Before the
302+
change a child read `entries/<parent_hash>/result.parquet` — a leaf path naming the
303+
parent — and the edge was readable straight out of the expression. After it, the
304+
only place the edge survives is the manifest tallyman wrote.
305+
306+
### Reading it back: one seam
307+
308+
All graph reads go through `tallyman_xorq.dependents`, which scans the manifests
309+
and assembles the views the reactive system needs:
310+
311+
- `parents_of(hash)` / `sources_of(hash)` — one entry's recorded inputs.
312+
- `build_dag()` — forward edges for every live entry, `{child: [parents]}`.
313+
- `dependents_index()` — the reverse index, `{parent: {children}}`: how a recalc
314+
goes from a changed entry to everything downstream.
315+
- `descendant_cone(roots)` — the roots and all their transitive dependents,
316+
topologically ordered (parents before children) — the recalc order.
317+
318+
Both `staleness` and `recalc` go through this module rather than touching manifest
319+
fields directly, so it is the single seam over the recorded graph: swapping the
320+
implementation (a persistent index, say) is a change to `dependents` alone.
321+
322+
A consequence worth noting (and a candidate future check): because the parent edge
323+
is gone from xorq's flattened expression, you can't reconcile tallyman's recorded
324+
parents against xorq's graph. What you *can* reconcile is the source leaves — a
325+
child's inlined raw-file reads should equal the union of `sources` over its
326+
transitive cheap parents. That invariant is checkable; the parent edges are only as
327+
good as what the side-channel captured at build.
328+
201329
## The two staleness axes
202330

203331
The workflow above drives the **alias axis**. There is a second axis for source
@@ -209,19 +337,20 @@ An entry is judged on two independent axes, each tied to a kind of recorded inpu
209337
- **alias** — a `follow=True` parent (recorded when the recipe referenced a parent
210338
by alias name, `tracked_expr_from_alias("orders")`) is stale when that alias now resolves to
211339
a different hash than the one recorded at build. This is what fires when you
212-
revise an upstream alias. A `follow=False` parent (a literal-hash pin) is never
213-
stale on this axis: the recipe asked for *that* revision and still gets it.
340+
revise an upstream alias. A `follow=False` parent (a `pinned_expr_from_alias`
341+
reference, by alias or hash) is never stale on this axis: the recipe asked for
342+
*that* revision and still gets it.
214343
- **source** — a recorded `(rel_path, digest)` is stale when the file on disk no
215344
longer digests to the recorded value. The check forces a faithful re-read so an
216345
in-place content swap can't be masked by a cached digest. In a closed catalog
217346
you don't expect this to fire; it covers the case where a raw parquet under
218347
`data/` is replaced.
219348

220-
A note on cheap chains: a *cheap* child inlines its parent's recipe rather than
221-
referencing it through the catalog, so the parent's source files are collected
222-
into the child's own `manifest.sources`. Editing that source makes the child
223-
*directly* stale on the source axis, not merely transitively stale. This is
224-
expected; it falls out of how cheap entries record their inputs.
349+
A note on cheap chains: when a child reads a *cheap* (non-materialized) parent,
350+
that parent's recipe is inlined into the child, so the parent's source files land
351+
in the child's own `manifest.sources` (see *Cheap vs expensive parents* above).
352+
Editing such a source makes the child **directly** stale on the source axis, not
353+
merely transitively stale. This is expected.
225354

226355
`result_digest` is deliberately not a staleness input. An entry that recomputes to
227356
the same `content_hash` but a different result is *nondeterministic*, not stale,

0 commit comments

Comments
 (0)