|
| 1 | +# How reactive recalc works |
| 2 | + |
| 3 | +- **Status:** Built (2026-06-18) on `feat/89-reactive-recalc`. This documents the |
| 4 | + mechanism as implemented, not a proposal. It is the Stage 2/3 follow-on to |
| 5 | + `plans/reactive-staleness-recalc.md` (the staged plan) and |
| 6 | + `plans/adr-reactive-catalog-recalc.md` (the design questions). Stage 1 |
| 7 | + (staleness detection) shipped in PR #126; the #115 cold-read blocker in PR #127. |
| 8 | +- **Code:** `src/tallyman_xorq/recalc.py` (the engine), `tallyman_xorq/staleness.py` |
| 9 | + and `tallyman_xorq/dependents.py` (Stage 1, the inputs it reads), the MCP tools |
| 10 | + `catalog_scan_staleness` / `catalog_recalc` in `tallyman_mcp/server.py`, and the |
| 11 | + companion routes `GET /{project}/api/staleness` / `POST /{project}/api/recalc` in |
| 12 | + `tallyman_companion/app.py`. |
| 13 | + |
| 14 | +## The problem |
| 15 | + |
| 16 | +A tallyman project is a few raw sources and many derived entries. Two kinds of |
| 17 | +change leave derived entries silently wrong: |
| 18 | + |
| 19 | +1. **An upstream entry is revised.** `from_catalog("A")` binds the *value* alias A |
| 20 | + had when the child was built. Revise A and the child keeps reading A's old |
| 21 | + version. |
| 22 | +2. **A raw source is edited in place.** xorq keys its caches on the path, not the |
| 23 | + content, so an in-place edit is invisible to identity. |
| 24 | + |
| 25 | +Recalc closes the loop: edit a source or revise an upstream entry, then recompute |
| 26 | +the dependents on demand instead of leaving them stale. |
| 27 | + |
| 28 | +## Two halves: detect, then act |
| 29 | + |
| 30 | +The consumer is split so the read side can run with no risk of mutation. |
| 31 | + |
| 32 | +- **Detect** (`staleness.py`, Stage 1, read-only). `scan(project)` returns a |
| 33 | + `StaleVerdict` per entry. An entry is *stale* when an input it recorded at build |
| 34 | + no longer matches the world, on either of two axes: |
| 35 | + - **alias** — a `from_catalog("A")` parent (recorded `follow=True`) whose alias |
| 36 | + head now resolves to a different hash than the one recorded. |
| 37 | + - **source** — a recorded `manifest.sources` digest that no longer matches the |
| 38 | + file on disk. |
| 39 | + A *directly* stale entry has its own input moved; a *transitively* stale entry |
| 40 | + is a clean descendant of a stale ancestor in the dependency cone. |
| 41 | +- **Act** (`recalc.py`, Stage 2). `recalc(project, roots, *, dry_run)` walks the |
| 42 | + roots' descendant cone and recomputes it. |
| 43 | + |
| 44 | +## The dependency graph |
| 45 | + |
| 46 | +`manifest.parents` records each entry's resolved `from_catalog` edges |
| 47 | +(`{hash, ref, follow}`) at build time. `dependents.py` reads them: |
| 48 | + |
| 49 | +- `build_dag(project)` — forward edges `{child: [ParentRef]}`, enumerated over |
| 50 | + every on-disk `manifest.json` (so a freshly-built, not-yet-checkpointed entry is |
| 51 | + seen). |
| 52 | +- `descendant_cone(project, roots)` — the roots plus all their transitive |
| 53 | + dependents, **topologically ordered** (parents before children) via Kahn's |
| 54 | + algorithm. A cycle raises `DependencyCycleError` rather than looping. |
| 55 | + |
| 56 | +The topological order is the load-bearing part: it guarantees a parent is |
| 57 | +recomputed and its alias re-pointed *before* any child that reads it. |
| 58 | + |
| 59 | +## The recalc walk |
| 60 | + |
| 61 | +`recalc(project, roots, dry_run=False)` does this: |
| 62 | + |
| 63 | +1. Drop any root that names no on-disk entry, then `cone = descendant_cone(roots)`. |
| 64 | + A cycle returns a report with `status="cycle"` and builds nothing. |
| 65 | +2. `verdicts = scan(project)` once, for the report's per-entry staleness. |
| 66 | +3. **Dry run** (`dry_run=True`): return the ordered cone with each entry's verdict |
| 67 | + and a planned `action` (`rebuild` / `cascade` / `unchanged`). Nothing is built; |
| 68 | + no alias moves. This is the preview the surfaces call first. |
| 69 | +4. **Real run**: walk the cone in order. For each entry: |
| 70 | + - Read its persisted recipe (`expr.py`, with the `${TALLYMAN_PROJECT_ROOT}` |
| 71 | + placeholder expanded) and replay it through `build_and_persist` — the same |
| 72 | + primitive `catalog_revise` uses. |
| 73 | + - If the replay yields a **new** hash, record `old -> new` in the remap, |
| 74 | + re-point every alias whose head was the old hash via `set_alias`, carry the |
| 75 | + chart/display config forward (`carry_forward_entry_config`), and clear the |
| 76 | + in-process result-plan cache. |
| 77 | + - If the replay yields the **same** hash, it is a no-op: the alias is left |
| 78 | + untouched. |
| 79 | +5. Take **one** checkpoint for the whole walk, but only if something actually |
| 80 | + changed (a non-empty remap). The recalc is then a single git revision |
| 81 | + `reset_to` undoes atomically. |
| 82 | + |
| 83 | +### Why replaying the whole cone is correct and idempotent |
| 84 | + |
| 85 | +The walk replays every entry in the cone, including ones that turn out not to |
| 86 | +change. That is safe because of two facts: |
| 87 | + |
| 88 | +- **A followed parent re-resolves to the advanced head.** A recipe names its |
| 89 | + followed parent by alias (`from_catalog("A")`). `io.from_catalog` resolves that |
| 90 | + to the alias's *current* head at build time (`get_alias`). Because the walk |
| 91 | + re-points alias A before it reaches A's children, each child's replay reads the |
| 92 | + advanced A. The new upstream content propagates into the child's expression |
| 93 | + graph (under `cas`, the content digest rides in the source path), so the child's |
| 94 | + `content_hash` changes and it, in turn, advances. |
| 95 | +- **An unchanged input replays to the same hash.** `build_and_persist` |
| 96 | + early-returns when the target entry dir already exists. So an entry whose inputs |
| 97 | + did not move — and a hash-pinned child, whose literal-hash recipe re-resolves to |
| 98 | + the same parent — short-circuits to a no-op with no alias change. Recomputing |
| 99 | + the whole cone costs only the genuinely-changed entries plus a cheap re-tokenize |
| 100 | + for the rest. |
| 101 | + |
| 102 | +### Stop-and-report on failure |
| 103 | + |
| 104 | +If an entry's replay raises a `BuildError`, the walk stops there. The already- |
| 105 | +recomputed prefix stays committed (the one checkpoint still fires because the |
| 106 | +remap is non-empty), the failing entry is reported with `action="failed"` and the |
| 107 | +error text, and the entries after it are `skipped`. `recalc` never raises on a |
| 108 | +build error, so an MCP/companion caller gets a structured report, and the prefix |
| 109 | +is recoverable with `reset_to`. This is the plan's default among the three cascade |
| 110 | +policies (stop / skip-and-continue / all-or-nothing); the other two are not |
| 111 | +implemented. |
| 112 | + |
| 113 | +## The cheap-chain source leak (why a "follower" can be directly stale) |
| 114 | + |
| 115 | +A *cheap* child inlines its parent's recipe rather than reading a baked snapshot. |
| 116 | +Building the child therefore re-runs the parent's `from_project`, which records the |
| 117 | +parent's source in the **child's** own `manifest.sources`. So editing a raw source |
| 118 | +makes every cheap descendant *directly* stale on the source axis, not merely |
| 119 | +transitively. This is expected (it falls out of the #74 reconstruction model) and |
| 120 | +does not change recalc's behavior — the cone walk recomputes them either way. The |
| 121 | +pure transitive/cascade case (a clean descendant carried by a stale ancestor) |
| 122 | +shows up across an *expensive* (snapshot-baked) intermediate, whose source does |
| 123 | +not leak downstream. See `[[reactive-staleness-edge-semantics]]`. |
| 124 | + |
| 125 | +## Decision gates and the defaults chosen |
| 126 | + |
| 127 | +The ADR left four questions open. The build commits to these defaults; each is |
| 128 | +localized so a different choice is a small change. |
| 129 | + |
| 130 | +- **Trigger model** — scan-on-load (a passive flag) plus an explicit recalc. |
| 131 | + `catalog_scan_staleness` / `GET …/api/staleness` is the scan-on-load surface; |
| 132 | + `catalog_recalc` / `POST …/api/recalc` is the explicit action. No |
| 133 | + auto-cascade-on-revise. |
| 134 | +- **Cascade failure** — stop-and-report (above). |
| 135 | +- **off-mode source axis** — reported `unknown`, never silently `fresh` (Stage 1). |
| 136 | +- **Pinned child of an advanced alias** — stays on its pin. A `follow=False` |
| 137 | + recipe re-resolves to the same revision, so its replay is a no-op. |
| 138 | + |
| 139 | +Out of scope here (deletion-side work): archive/delete-cone semantics and |
| 140 | +multi-parent unbind (ADR Q3/Q4). |
| 141 | + |
| 142 | +## Surfaces and checkpoint semantics |
| 143 | + |
| 144 | +Both surfaces are thin; all logic is in `recalc.py` / `staleness.py`. |
| 145 | + |
| 146 | +- **MCP.** `catalog_scan_staleness()` returns `{stale, transitively_stale, |
| 147 | + entries}`. `catalog_recalc(roots=None, dry_run=True)` previews by default; |
| 148 | + `roots=None` defaults to the directly-stale set (a scan-driven recalc). |
| 149 | +- **Companion.** `GET /{project}/api/staleness` is the scan; `POST |
| 150 | + /{project}/api/recalc` takes `{roots?, dry_run}`. A committed run invalidates the |
| 151 | + companion caches, reloads buckaroo sessions, and publishes a `recalc` SSE event |
| 152 | + so open views refetch. |
| 153 | + |
| 154 | +Checkpointing is arg-aware, which the generic per-op checkpoint hooks (the MCP |
| 155 | +dispatch decorator, the companion middleware) cannot be — they key on tool name / |
| 156 | +HTTP method, not the `dry_run` flag. So `recalc` takes its own single checkpoint |
| 157 | +on a real run that changed something, and both surfaces exempt the recalc path |
| 158 | +from their generic checkpoint. A dry run takes none; a real run takes exactly one. |
| 159 | + |
| 160 | +## `result_digest` is not a recalc trigger |
| 161 | + |
| 162 | +An entry that recomputes to the same `content_hash` but a different |
| 163 | +`result_digest` is *nondeterministic* (`sample()`, `now()`, an unordered limit, an |
| 164 | +impure UDF), not stale — recompute cannot make it fresh. The report surfaces it as |
| 165 | +a `noop` with its verdict; the durable fix is the #83/#121 path, not this one. |
| 166 | + |
| 167 | +## Worked examples |
| 168 | + |
| 169 | +**Source edit, cheap chain.** `a = from_project("orders.parquet")`, |
| 170 | +`b = from_catalog("a")` (cheap). Edit `orders.parquet`. Scan: both a and b |
| 171 | +directly stale (source; b via the cheap-chain leak). `recalc([a])`: cone `[a, b]`; |
| 172 | +a replays against the new file to a2 and alias `a` re-points; b replays, reads a2, |
| 173 | +and advances to b2 with alias `b` re-pointed. One checkpoint. Both `rebuilt`. |
| 174 | + |
| 175 | +**Alias advance, expensive intermediate.** `a` (root), `b` (expensive, follows |
| 176 | +`a`), `c` (cheap, follows `b`). `catalog_revise("a", …)` advances alias `a`. Scan: |
| 177 | +b directly stale (alias axis), c only transitively stale. `recalc([b])`: cone |
| 178 | +`[b, c]`; b replays against the new a head to b2 (alias `b` re-pointed), then c |
| 179 | +replays against b2 to c2 (alias `c` re-pointed). |
| 180 | + |
| 181 | +**Pinned child.** `b = from_catalog("<a-v1-hash>")` (literal hash, `follow=False`). |
| 182 | +Advance a. `recalc([a])`: a rebuilds; b's replay re-resolves the literal hash to |
| 183 | +the same a-v1 entry, yields the same b hash, and is a `noop` — the pin holds. |
0 commit comments