Skip to content

Commit c212a92

Browse files
paddymulclaude
andcommitted
feat(#89): reactive recalc — cone walk, replay, alias re-point + surfaces
Stage 2/3 of the reactive consumer. Stage 1 (staleness detection, PR #126) flags an entry as stale relative to its recorded inputs but never acts; this is the half that acts. recalc.py: recalc(project, roots, *, dry_run) walks the roots' topologically ordered descendant cone and replays each entry's recipe through build_and_persist. A followed parent re-resolves to the advanced alias head (get_alias), so re-pointing a parent's alias before its children replay propagates the change down the cone; an unchanged input replays to the same content_hash and build_and_persist early-returns, so the walk is idempotent and a hash-pinned child is a no-op. Cascade-failure policy is stop-and-report: a build error halts the walk, leaves the rebuilt prefix committed, and is flagged in the report rather than raised. One checkpoint per walk (only when something changed), so a recalc is a single revision reset_to undoes atomically. Surfaces (thin; logic stays in recalc.py / staleness.py): - MCP: catalog_scan_staleness (read-only) and catalog_recalc (dry_run defaults true). Both in _NO_CHECKPOINT — recalc self-checkpoints, arg-aware. - Companion: GET /{project}/api/staleness (scan-on-load) and POST /{project}/api/recalc (preview + commit), the recalc path exempt from the checkpoint middleware so the op is exactly one revision. plans/recalc-mechanism.md documents the mechanism, the idempotence argument, the cheap-chain source-leak nuance, and the decision-gate defaults (stop-and-report; scan-on-load + explicit recalc; off-mode=unknown; pin stays). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7e76073 commit c212a92

4 files changed

Lines changed: 539 additions & 1 deletion

File tree

plans/recalc-mechanism.md

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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.

src/tallyman_companion/app.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import os
88
import re
99
import time
10+
from dataclasses import asdict
1011
from pathlib import Path
1112

1213
import markdown as md_lib
@@ -369,6 +370,8 @@ def _checkpoint_exempt(method: str, path: str) -> bool:
369370
return True # cache/log clears, not authored state
370371
if path.endswith("/api/reset"):
371372
return True # reset moves `current`; it is not a new step
373+
if path.endswith("/api/recalc"):
374+
return True # recalc self-checkpoints (one tx for the whole walk; dry-run takes none)
372375
return False
373376

374377
@app.middleware("http")
@@ -1165,6 +1168,61 @@ async def api_reset(project: str, payload: dict):
11651168
await publish({"kind": "project_reset", "step": step})
11661169
return {"ok": True, "step": step, "reloaded": reloaded}
11671170

1171+
@app.get("/{project}/api/staleness")
1172+
def api_staleness(project: str):
1173+
"""Staleness scan for every entry — the scan-on-load reactive surface.
1174+
1175+
Read-only (no checkpoint). Drives the per-entry staleness badge: ``stale``
1176+
lists directly-stale hashes (their own input moved), ``transitively_stale``
1177+
the clean descendants a recalc would carry, ``entries`` the full verdict
1178+
map. The SPA fires this on project load / switch and after a recalc.
1179+
"""
1180+
project = _validate_project(project)
1181+
from tallyman_xorq import staleness # noqa: PLC0415
1182+
1183+
verdicts = staleness.scan(project)
1184+
return {
1185+
"project": project,
1186+
"stale": sorted(h for h, v in verdicts.items() if v.stale),
1187+
"transitively_stale": sorted(h for h, v in verdicts.items() if v.transitively_stale),
1188+
"entries": {h: asdict(v) for h, v in verdicts.items()},
1189+
}
1190+
1191+
@app.post("/{project}/api/recalc")
1192+
async def api_recalc(project: str, payload: dict | None = None):
1193+
"""Recompute stale entries and their dependents in dependency order.
1194+
1195+
Exempt from the checkpoint middleware — ``recalc`` takes its own single
1196+
checkpoint for the whole walk (only on a real run that changed something),
1197+
so the operation is one revision ``reset_to`` undoes atomically.
1198+
``dry_run`` (default True) previews the cone without building. A committed
1199+
run invalidates the companion caches, reloads buckaroo sessions, and
1200+
publishes a ``recalc`` event so open views refetch.
1201+
"""
1202+
project = _validate_project(project)
1203+
if read_only:
1204+
raise HTTPException(403, "serve mode")
1205+
payload = payload or {}
1206+
dry_run = bool(payload.get("dry_run", True))
1207+
roots = payload.get("roots")
1208+
from starlette.concurrency import run_in_threadpool # noqa: PLC0415
1209+
1210+
from tallyman_xorq import staleness # noqa: PLC0415
1211+
from tallyman_xorq.recalc import recalc # noqa: PLC0415
1212+
1213+
if roots is None:
1214+
verdicts = await run_in_threadpool(staleness.scan, project)
1215+
roots = sorted(h for h, v in verdicts.items() if v.stale)
1216+
if not roots:
1217+
return {"status": "ok", "roots": [], "cone": [], "entries": [], "dry_run": dry_run, "remap": {}}
1218+
report = await run_in_threadpool(recalc, project, roots, dry_run=dry_run)
1219+
if not dry_run and report.remap:
1220+
_invalidate_reset_caches()
1221+
if buckaroo:
1222+
buckaroo.reload_project_sessions(project)
1223+
await publish({"kind": "recalc", "remap": report.remap, "step": report.checkpoint_step})
1224+
return asdict(report)
1225+
11681226
# ------------------------------------------------------------------
11691227
# Project-agnostic routes (no project prefix)
11701228
# ------------------------------------------------------------------

src/tallyman_mcp/server.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
import os
77
import sys
8+
from dataclasses import asdict
89

910
import httpx
1011
from fastmcp import FastMCP
@@ -44,7 +45,8 @@
4445
write_post_processing,
4546
write_stat,
4647
)
47-
from tallyman_xorq import BuildError, build_and_persist, full_diff, list_entries
48+
from tallyman_xorq import BuildError, build_and_persist, full_diff, list_entries, staleness
49+
from tallyman_xorq.recalc import recalc
4850

4951
log = logging.getLogger("tallyman_mcp")
5052
COMPANION_URL = os.environ.get("TALLYMAN_COMPANION_URL", "http://127.0.0.1:7860")
@@ -124,6 +126,8 @@ def wrapped(*args, **kwargs):
124126
{
125127
"catalog_list",
126128
"catalog_diff",
129+
"catalog_scan_staleness", # read-only staleness scan
130+
"catalog_recalc", # self-checkpoints (arg-aware: dry-run takes none, a real run one)
127131
"catalog_list_summary_stats",
128132
"catalog_list_post_processings",
129133
"catalog_run_post_processing", # preview, persists nothing
@@ -871,6 +875,76 @@ def _resolve(v):
871875
}
872876

873877

878+
@mcp.tool()
879+
@_tag_project
880+
def catalog_scan_staleness() -> dict:
881+
"""Scan every catalog entry for staleness against its recorded inputs.
882+
883+
Read-only. An entry is *stale* when an input it was built against moved:
884+
a followed alias (built with ``from_catalog("name")``) advanced past the
885+
recorded head, or a recorded source file's content drifted on disk. A
886+
*directly* stale entry has its own input moved; a *transitively* stale entry
887+
is a clean descendant of a stale ancestor. Use ``catalog_recalc`` to act.
888+
889+
Returns:
890+
stale: hashes that are directly stale (the natural recalc roots).
891+
transitively_stale: clean descendants carried by a stale ancestor.
892+
entries: ``{hash: verdict}`` for every live entry, where a verdict is
893+
``{stale, reasons:[{axis, ref, was, now}], unknown_axes,
894+
transitively_stale}``.
895+
"""
896+
project = _resolve_active_project()
897+
verdicts = staleness.scan(project)
898+
return {
899+
"stale": sorted(h for h, v in verdicts.items() if v.stale),
900+
"transitively_stale": sorted(h for h, v in verdicts.items() if v.transitively_stale),
901+
"entries": {h: asdict(v) for h, v in verdicts.items()},
902+
}
903+
904+
905+
@mcp.tool()
906+
@_tag_project
907+
def catalog_recalc(roots: list[str] | None = None, dry_run: bool = True) -> dict:
908+
"""Recompute stale entries and their dependents, in dependency order.
909+
910+
``dry_run=True`` (the default) previews the cone — what would rebuild, in
911+
order — without building anything. Run it first; call again with
912+
``dry_run=False`` to commit. A real run replays each entry's recipe (so it
913+
re-resolves a followed parent to the advanced alias head), re-points the
914+
followed aliases, and takes ONE checkpoint for the whole walk, so a recalc
915+
is a single revision ``reset_to`` can undo atomically. A hash-pinned child
916+
re-pins the same parent and is left unchanged. On a build failure the walk
917+
stops, the already-rebuilt prefix stays committed, and the report flags it.
918+
919+
Args:
920+
roots: content hashes to recompute together with their dependents. When
921+
omitted, defaults to every directly-stale entry (a scan-driven recalc).
922+
dry_run: preview only (default True). False commits the recompute.
923+
924+
Returns the report: the ordered ``cone``, each entry's ``action``
925+
(dry-run: rebuild/cascade/unchanged; real: rebuilt/noop/failed/skipped),
926+
the old→new ``remap``, the ``status`` (ok/failed/cycle), and
927+
``checkpoint_step`` for a real run that changed something.
928+
"""
929+
project = _resolve_active_project()
930+
if roots is None:
931+
roots = sorted(h for h, v in staleness.scan(project).items() if v.stale)
932+
if not roots:
933+
return {
934+
"status": "ok",
935+
"roots": [],
936+
"cone": [],
937+
"entries": [],
938+
"dry_run": dry_run,
939+
"remap": {},
940+
"note": "nothing stale to recompute",
941+
}
942+
report = recalc(project, roots, dry_run=dry_run)
943+
if not dry_run and report.remap:
944+
_notify("recalc", extra={"remap": report.remap})
945+
return asdict(report)
946+
947+
874948
@mcp.tool()
875949
@_tag_project
876950
def catalog_add_summary_stat(name: str, source: str) -> dict:

0 commit comments

Comments
 (0)