|
| 1 | +# Plan: three-stage path to reactive catalog recalc |
| 2 | + |
| 3 | +Status: **proposed (2026-06-14).** Synthesises the decisions from the planning |
| 4 | +conversation around the reactive-catalog ADR (`plans/adr-reactive-catalog-recalc.md`). |
| 5 | +Splits the work into three sequenced stages. No implementation here. |
| 6 | + |
| 7 | +The throughline: don't build the reactive layer on machinery that doesn't yet |
| 8 | +hold its own invariants, and don't redesign the caching until it's measured. So |
| 9 | +stage 1 makes the existing catalog machinery correct and tested, stage 2 maps and |
| 10 | +measures the caching, and stage 3 builds reactivity on top — using stage 2's |
| 11 | +numbers to settle the ADR's open design questions. |
| 12 | + |
| 13 | +Operating constraints (stated 2026-06-14): single user, one active parking |
| 14 | +catalog, everything `--no-sync`. All perf work runs on this Mac alone; no |
| 15 | +distributed or CI perf runs. These narrow the scope in places noted below. |
| 16 | + |
| 17 | +--- |
| 18 | + |
| 19 | +## Stage 1 — catalog ↔ xorq coexistence, correct and tested |
| 20 | + |
| 21 | +The existing machinery has a cluster of bugs where tallyman and the xorq catalog |
| 22 | +disagree about what is durable. Stage 1 closes that cluster and proves it closed, |
| 23 | +then produces a clean parking catalog for stage 2 to measure. |
| 24 | + |
| 25 | +### The bug cluster |
| 26 | + |
| 27 | +- **#48** — tallyman commits `aliases.json` / `alias_history.json` into the xorq |
| 28 | + catalog repo → `assert_consistency` fails → every `xorq catalog add` after the |
| 29 | + first silently no-ops. 17/18 recipes never reached git. |
| 30 | +- **#52** — `reset_to` reconciles the untracked tallyman artifacts (entry dirs, |
| 31 | + result/compute cache) against the pointer lists via the bullpen, but the |
| 32 | + git-tracked xorq `.zip` recipes are reconciled separately by `git reset --hard`, |
| 33 | + with no cross-view consistency check. A back→forward reset restores an entry the |
| 34 | + durable catalog cannot reproduce and reports success. Compounded by #48 today, |
| 35 | + but independent: any failed/interrupted `add` (which `checkpoint_catalog` already |
| 36 | + anticipates, `catalog_state.py:388-390`) triggers it after #48 is fixed. |
| 37 | +- **#49** — authored state (charts, post_processing, …) stored as extra |
| 38 | + `catalog.yaml` keys is dropped when xorq rebuilds `catalog.yaml` from |
| 39 | + entries+aliases on a merge. **Deprioritised out of stage 1's critical path:** |
| 40 | + latent only under multi-user `pull`/sync, which does not happen single-user / |
| 41 | + `--no-sync`. Stays filed; not fixed this stage. |
| 42 | + |
| 43 | +### Work |
| 44 | + |
| 45 | +1. **#48 fix.** Move `aliases.json` / `alias_history.json` (and any other non-xorq |
| 46 | + file the checkpoint commits — audit the full set written under |
| 47 | + `catalog_dir`, not just the two named) out of the xorq catalog repo. |
| 48 | + - **Open decision — where they go.** *Recommendation: a sibling |
| 49 | + `<project>/artifacts/tallyman_state/` git repo, committed in lockstep with |
| 50 | + the xorq repo by the same `checkpoint_catalog` (matching step tags), and |
| 51 | + reset by the same `reset_to`.* This keeps the xorq repo holding only what |
| 52 | + `assert_consistency` expects, while preserving the free `git reset --hard` |
| 53 | + rollback of alias state — so `test_reset_rolls_back_alias_state` stays green |
| 54 | + via git rather than bespoke reconciliation. The alternative (files outside |
| 55 | + any repo + explicit pointer reconciliation in `reset_to`) is simpler to |
| 56 | + write but reintroduces the manual-reconciliation surface #52 is about. Needs |
| 57 | + ratification before coding. |
| 58 | + - The deferred `test_reset_rolls_back_alias_state` guard (PR #50) ships *with* |
| 59 | + this fix — moving the files changes reset's rollback behaviour, so the guard |
| 60 | + must travel with the change per the TDD rule. |
| 61 | + |
| 62 | +2. **#52 fix.** Add an explicit cross-view reconciliation + assertion at the end of |
| 63 | + `reset_to`: compare the tracked `entries/*.zip` set against `entry_hashes`, |
| 64 | + re-register (or fail loudly on) any pointer whose recipe is missing, and assert |
| 65 | + `Catalog.from_kwargs(init=False)` opens. Turns the silent divergence observable. |
| 66 | + |
| 67 | +3. **#48 secondary — surface the silent swallow.** `add_entry` is best-effort and |
| 68 | + discards its result (`xorq_catalog.py:63-68`, `build.py`); expose a |
| 69 | + `BuildResult.catalog_registered`-style signal, log failures at ERROR, and emit a |
| 70 | + one-time companion warning. PR #50's Group D pins this. |
| 71 | + |
| 72 | +4. **Reproducible parking-catalog rebuild script.** No migration/repair of the |
| 73 | + existing broken catalog — rebuild from scratch through the *fixed* machinery. |
| 74 | + One script drives the parking sources through `catalog_create`/`catalog_revise` |
| 75 | + to produce the canonical catalog, and emits both corpus variants for stage 2 |
| 76 | + (full ≈10M+ rows, truncated ≈1M). This replaces "repair" on the task list and |
| 77 | + doubles as the stage-2 corpus generator. Depends on #48 + #52 being fixed first |
| 78 | + (otherwise the rebuild is born broken again). |
| 79 | + |
| 80 | +### Tests / acceptance |
| 81 | + |
| 82 | +- PR #50's failing integration tests (Groups A/B/D) go green. |
| 83 | +- One end-to-end demo-walk invariant test: genesis → multiple creates → revise → |
| 84 | + reset back → forward → back, asserting at every step that the catalog opens, |
| 85 | + every recipe is durable as a tracked `.zip`, alias state is correct, and (where |
| 86 | + in scope) authored state survives. |
| 87 | +- TDD split per house rule: failing tests (PR #50) seen red on CI first, then the |
| 88 | + fixes; the alias-rollback guard ships with the fix commit. |
| 89 | + |
| 90 | +--- |
| 91 | + |
| 92 | +## Stage 2 — model the expression lifecycle, then measure it |
| 93 | + |
| 94 | +Premise (corrected 2026-06-14): app performance is dictated by **how many |
| 95 | +expressions have to be built, loaded, and re-executed, and when** — not by the |
| 96 | +cache *key* strategy (Snapshot vs ModificationTime), which experimentation so far |
| 97 | +shows is second-order. So stage 2 is reframed around the expression lifecycle, not |
| 98 | +the cache design. |
| 99 | + |
| 100 | +The xorq question is pragmatic, not a correctness audit. Ignore "are you using it |
| 101 | +right" — the only question that matters is the ADR's guiding principle: where xorq |
| 102 | +can do what we need *and is fast enough*, lean on it and write no code; where it |
| 103 | +can't, or is too slow for interactive use, build our own. Each capability gets a |
| 104 | +native-or-build verdict backed by a number, not an opinion. |
| 105 | + |
| 106 | +### Deliverable 0 — consolidate perf instrumentation, make noise configurable |
| 107 | + |
| 108 | +Across tallyman and buckaroo, perf/cache-stat logging has been added and then |
| 109 | +turned back off in churn (buckaroo `19abae89` "log cache stats", `72bcf2ad` |
| 110 | +bk-flash cross-stream tracing + "console cleanup", and similar on/off cycles). |
| 111 | +Stop the churn: **leave the instrumentation in permanently and gate its noisiness |
| 112 | +the normal way** — log levels and namespaces, not commenting lines in and out. |
| 113 | + |
| 114 | +The plumbing already exists and is partly used correctly: `TALLYMAN_LOG_LEVEL` |
| 115 | +(`src/tallyman_mcp/server.py:1383`), namespaced loggers (`tallyman.companion`, |
| 116 | +`tallyman.buckaroo`, `tallyman_mcp`), and instrumentation already level-gated — |
| 117 | +the warmup timing in `app.py:411-444` (`log.debug` per entry, `log.info` |
| 118 | +summary), `execute_seconds` in `build.py:335-341`. The work: |
| 119 | + |
| 120 | +- Recover the toggled-off perf/cache-stat logs and reinstate them permanently at |
| 121 | + the right level (per-event detail at DEBUG, per-action summaries at INFO). |
| 122 | +- Route perf logging through a dedicated child namespace (e.g. `tallyman.perf`, |
| 123 | + and the buckaroo equivalent) so it is independently dialable without raising |
| 124 | + the level of everything else. |
| 125 | +- For the buckaroo JS side, gate the bk-flash / cross-stream tracing behind a |
| 126 | + debug flag rather than deleting it in a "console cleanup." |
| 127 | + |
| 128 | +This is a prerequisite for the rest of stage 2: the lifecycle counts in |
| 129 | +deliverable 1 and the harness in deliverable 2 read these logs as the in-app |
| 130 | +source of build/load/execute events. (buckaroo changes are in scope — it's the |
| 131 | +companion's own viewer — and follow buckaroo's build/test conventions.) |
| 132 | + |
| 133 | +### Deliverable 1 — the expression-lifecycle cost model (write before any perf test) |
| 134 | + |
| 135 | +For each user-facing action — open the app, view an entry, revise a source, open a |
| 136 | +diff, reset — answer: how many expressions get **built** (graph constructed / |
| 137 | +tokenized), how many get **loaded** (from a build dir, the `/load_expr` path), and |
| 138 | +how many get **re-executed** (`.execute()`), and which of those were avoidable. |
| 139 | +Build/load/execute counts are the explanatory variable; wall-clock is the symptom. |
| 140 | + |
| 141 | +- **Build cost** dominates with graph depth and count — long xorq graphs are |
| 142 | + expensive to construct independent of execution, which is the whole reason |
| 143 | + `from_catalog` truncates at a parquet read (ADR). So "how many entries, how deep |
| 144 | + each graph" is a first-order lever. |
| 145 | +- **Load cost** is distinct from build — #34 (the 10s `/load_expr` timeout that |
| 146 | + abandons cold loads) is a load-path problem, not a cache-key problem. |
| 147 | +- **Re-execution timing** is where caching actually shows up, but the lever is |
| 148 | + *when* re-execution is triggered, not how the key is computed — #21 (caches warm |
| 149 | + lazily on view, never at build) means re-execution lands at view time and the |
| 150 | + user waits. |
| 151 | + |
| 152 | +The cache layers matter only insofar as they change those counts (a hit skips a |
| 153 | +re-execution; a shallow graph skips a build). The catalogue of layers already |
| 154 | +exists — the `docs/caching-architecture` branch ("map every cache in the |
| 155 | +tallyman/xorq/buckaroo stack", `xorq` `SnapshotStrategy`, tallyman `result_cache`, |
| 156 | +the `.cas` store + `content_hash`, buckaroo's stat/session caches). Reuse it as |
| 157 | +the levers section; the deliverable here is the lifecycle model on top, not a |
| 158 | +re-derived cache-key taxonomy. |
| 159 | + |
| 160 | +Existing backlog items are findings against this model: #21 (lazy warm), #22 |
| 161 | +(checkpoint capture cost per mutating request), #30 (measured cost rubric vs |
| 162 | +structural `cache_worthy`), #35 (leftover result dirs). The hypotheses to settle |
| 163 | +with numbers: does the parquet-truncation boundary actually cut build cost at |
| 164 | +depth (ADR open question, untimed); how many expressions are needlessly rebuilt or |
| 165 | +reloaded per app open; does revising a source re-execute dependents at all today |
| 166 | +(ADR says no — fast but stale). |
| 167 | + |
| 168 | +### Deliverable 2 — performance integration tests |
| 169 | + |
| 170 | +Three measurements, each on both corpus variants (full / truncated) and across a |
| 171 | +cold/warm cache axis. Local-only on this Mac, marker-gated off CI (same convention |
| 172 | +as `cache_lab`). |
| 173 | + |
| 174 | +Every measurement records the **build / load / execute counts** alongside |
| 175 | +wall-clock — per deliverable 1, the counts are the explanatory variable and the |
| 176 | +time is the symptom. A regression in wall-clock should be attributable to a |
| 177 | +changed count (one more expression rebuilt, one more cold load), not left as an |
| 178 | +unexplained slowdown. |
| 179 | + |
| 180 | +- **Execution time** — `time expr.execute()`. Trivial; cold and warm. |
| 181 | +- **Page load including buckaroo — two tiers.** |
| 182 | + - *Tier A (Playwright, ground truth).* Spin up the catalog + buckaroo server, |
| 183 | + drive `/load` (`mode: 'buckaroo'`), measure to **data-grid-visible** |
| 184 | + (`.df-viewer .ag-cell` first-visible) — that is "loaded." Follow buckaroo's |
| 185 | + server-mode examples: `packages/buckaroo-js-core/pw-tests/server-buckaroo-summary.spec.ts`, |
| 186 | + `playwright.config.server.ts`, `scripts/test_playwright_server.sh`. Produces |
| 187 | + the baseline number. |
| 188 | + - *Tier B (python-only proxy).* Call the same backend the buckaroo `/load` |
| 189 | + (`mode=buckaroo`) handler dispatches to — load the xorq expr from the entry's |
| 190 | + `xorq_build/` dir + compute summary stats — no server, no browser. Fast and |
| 191 | + deterministic; this is the test that runs routinely. |
| 192 | + - *Calibration (one-time, required).* Run both tiers on the same entry, record |
| 193 | + the backend/total ratio, and document what Tier B cannot see (browser render, |
| 194 | + network, first-paint JS). For the big parking datasets the backend dominates, |
| 195 | + so the proxy is faithful — but the ratio must be written down so the proxy |
| 196 | + can't silently drift from reality. |
| 197 | +- **Diff performance** — exercise the `catalog_diff` stat path (#45 measured 27s |
| 198 | + synchronous on first open for 18.4M vs 1.0M). Define the revision pairs diffed; |
| 199 | + cold and warm. |
| 200 | + |
| 201 | +**Truncation design.** Truncate at the raw-source read (`head(1M)` on the source |
| 202 | +parquet) so the DAG shape and build/tokenize cost are identical to the full |
| 203 | +corpus and only execution cost drops. Both variants emitted by the stage-1 |
| 204 | +rebuild script; regenerable, not checked in. |
| 205 | + |
| 206 | +**Gating.** Report-only first (no baselines yet), matching the existing perf-report |
| 207 | +suite. Promote a few to regression gates once baselined — #45's diff time is the |
| 208 | +obvious first gate. |
| 209 | + |
| 210 | +**Why this is the point.** Once the harness exists, competing approaches stop |
| 211 | +being argued and start being measured. Every native-or-build verdict in this |
| 212 | +stage, and every design choice in stage 3, is decided by running both options |
| 213 | +through the same harness and reading the build/load/execute counts and wall-clock |
| 214 | +off the parking corpus. The harness is the comparison instrument, not just a |
| 215 | +regression guard. |
| 216 | + |
| 217 | +--- |
| 218 | + |
| 219 | +## Stage 3 — reactive recalc & staleness |
| 220 | + |
| 221 | +Design captured in `plans/adr-reactive-catalog-recalc.md`. Two of its open |
| 222 | +questions are answered by stage 2's measurements, so stage 3 design is gated on |
| 223 | +stage 2: |
| 224 | + |
| 225 | +- deep-chain build cost (ADR open question, explicitly untimed) → stage 2's |
| 226 | + build-cost-at-depth measurement. |
| 227 | +- is `cas` affordable as the default for recalc-enabled projects (disk cost) → |
| 228 | + stage 2's disk/cold-warm numbers. |
| 229 | + |
| 230 | +**Landable early (low-risk, ADR-verified, net-neutral).** These can go in during |
| 231 | +the stage-1/2 window: |
| 232 | + |
| 233 | +- The tagged-read spine: replace the anonymous `deferred_read_parquet` in |
| 234 | + `io.py` with `deferred_read_parquet(<entry>/result.parquet).hashing_tag( |
| 235 | + CatalogTag.SOURCE, entry_name=…)` (ADR: built in 0.019s, stayed shallow). |
| 236 | +- Drop the regex `result.parquet`-path matching in `lineage.py` for |
| 237 | + `walk_nodes(HashingTag)`. |
| 238 | + |
| 239 | +**Still open (settle after stage 2):** read-intent recording (alias-follow vs |
| 240 | +hash-pin at build time), trigger model (button vs scan-on-load vs |
| 241 | +auto-cascade — leaning button + scan), cascade/transaction semantics, archive |
| 242 | +overlay (derived vs stored). |
| 243 | + |
| 244 | +--- |
| 245 | + |
| 246 | +## Cross-stage dependencies |
| 247 | + |
| 248 | +``` |
| 249 | +#48 fix ─┐ |
| 250 | +#52 fix ─┴─► rebuild script ─► clean parking catalog ─► stage 2 corpus |
| 251 | + │ |
| 252 | +cache-architecture map ───────────────────────────────► perf tests ─► numbers |
| 253 | + │ |
| 254 | + ┌──────────────┘ |
| 255 | +tagged-read spine (early) ──────────────────────────────►│ |
| 256 | + ▼ |
| 257 | + stage 3 reactive design |
| 258 | +``` |
0 commit comments