Skip to content

perf(cache): source-cache + baked result-cache, drop blanket result.parquet (#73) - #74

Merged
paddymul merged 22 commits into
mainfrom
perf/73-source-cache-drop-result-parquet
Jun 16, 2026
Merged

paddymul merged 22 commits into
mainfrom
perf/73-source-cache-drop-result-parquet

Conversation

@paddymul

@paddymul paddymul commented Jun 15, 2026 •

Copy link
Copy Markdown
Contributor

Addresses #73.

What

Replaces the blanket per-entry result.parquet with a rewrite-then-build caching model. Before building, tallyman rewrites the submitted expression (source_cache.rewrite_for_build):

  1. Source-read caching — a snapshot .cache() is injected after each non-parquet file read (read_csv/read_json), so the parse is materialised once and shared across every entry that reads that source (path-only snapshot key). read_parquet/read_delta are exempt.
  2. Baked result cache — when the expression is expensive (Aggregate/Join/Sort/window/UDF), a top-level .cache() is baked into the build. The cache node is part of the durable recipe, so every loader — the Buckaroo viewer, diffs, from_catalog chaining — reads the cached result instead of re-running the whole DAG (the read-time-only path perf: viewer /load_expr sends the bare recipe for cache-worthy entries — count + every page re-runs the whole DAG instead of reading result.parquet #71 worked around, fixed at the source).
  3. In-memory reads (read_in_memory / ibis.memtable) are a hard BuildError, steering authors back to deferred_read_csv.

result.parquet is no longer written at build. A cheap entry materialises nothing (schema from the expression, row count from a pushdown count()); an expensive entry's rows live in its baked cache. ensure_result regenerates result.parquet on demand for the few callers that need a parquet path (downloads, promote-diff, post-processing), so it's now a regenerable cache, not an immutable artifact.

The content hash is computed from the rewritten expression; xorq_build/ carries the cache nodes while expr.py keeps the author's literal source.

Notable decisions

  • from_catalog composes expressions (via cached_result_expr) rather than reading result.parquet, so chaining off a cheap entry works. Consequence: the inter-entry catalog DAG (catalog_parents) goes dark — there's no result.parquet path string to match. Deferred to a future semantic-dependency mechanism (depend on the concept a parent computes, not its materialised hash), per discussion on the issue.
  • Co-located caches / result_cache/ retired. xorq's loader (load_expr(cache_dir=…)) rewrites every baked cache node's base path uniformly, so source parses and baked results co-locate in the per-project compute cache (content-addressed, already reset-reconciled). The separate result_cache/ dir is retired (kept as a now-empty vestige so reset/disk-reporting are untouched).
  • Internal-lineage view contracts out the injected CachedNode/RemoteTable so it still shows the author's logical pipeline.
  • Independent branch off main (does not stack on perf: viewer /load_expr sends the bare recipe for cache-worthy entries — count + every page re-runs the whole DAG instead of reading result.parquet #71).

Migration

build.migrate_drop_result_parquet deletes old build-time result.parquet files once per project (marker-guarded), run at companion create_app. Cheap entries recompute trivially on demand; expensive entries repopulate their cache lazily on first view.

Tests

Full suite: 454 passed. New test_source_cache.py covers injection, parquet exemption, expensive double-bake, and in-memory rejection. Updated result_cache / build / lineage / diff / cache-inspector / paths tests for the new behaviour.

Follow-ups (noted, not in scope)

  • Disk-usage reporting still points at the now-empty result_cache/ and doesn't count the compute cache where baked results live.
  • The cache-inspector reflects on-demand result.parquet rather than the baked compute-cache results.

Review follow-ups (#74 review)

Filed from the review of this PR; the first is to be reviewed in a separate PR, the rest are deferred:

🤖 Generated with Claude Code

paddymul and others added 2 commits June 15, 2026 19:03
…result.parquet (#73)

Rewrite-then-build: before building, rewrite the submitted expression to
(1) inject a snapshot .cache() after each non-parquet source read so the
CSV/JSON parse is materialised once and shared, and (2) bake a top-level
result cache when the expression is expensive (Aggregate/Join/Sort/window/
UDF). The baked cache becomes part of the durable recipe, so every loader —
the Buckaroo viewer, diffs, from_catalog chaining — reads the cached result
instead of re-running the whole DAG (the read-time-only path #71 worked
around). In-memory reads (read_in_memory / memtable) are a hard BuildError.

Drop the blanket per-entry result.parquet. A cheap entry materialises
nothing at build (schema from the expression, row count from a pushdown
count); an expensive entry's rows are materialised once via its baked cache.
result.parquet becomes a regenerable on-demand cache (ensure_result) for the
few callers that need a parquet path (downloads, promote-diff,
post-processing), not an immutable build artifact.

- source_cache.rewrite_for_build: in-memory rejection, source-read caching,
  expensive-result baking; memo breaks xorq's CachedNode re-descent recursion.
- classify_build worthiness is expensive-ops-only (dropped non-parquet read).
- cached_result_expr collapses to load_entry (the build carries the cache);
  ensure_result regenerates result.parquet in place on demand.
- from_catalog composes the parent's expression (cached_result_expr) instead
  of reading result.parquet; chaining off a cheap entry now works. The
  inter-entry catalog DAG goes dark (no result.parquet path to match) —
  deferred to a future semantic-dependency mechanism.
- All baked caches co-locate in the per-project compute cache (xorq's loader
  rewrites every cache node's base path uniformly); the separate result_cache/
  dir is retired.
- paths: result.parquet moves from ENTRY_ARTIFACT_NAMES to ENTRY_CACHE_NAMES.
- Consumers updated: companion /api/data + total_rows reads, post_processing.

Tests and the result.parquet bulk-delete migration are still to come.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…p; on-demand diff

- test_source_cache: source-read caching, parquet exemption, expensive bakes
  both caches, in-memory read rejected.
- result_cache/build/paths/lineage_view tests updated for: no build-time
  result.parquet, baked cache (CachedNode at load), catalog DAG dark, and
  result.parquet now a cache (not an artifact).
- diff: full_diff materialises a missing result.parquet on demand
  (_ensure_entry_parquet); path-taking diff tests use ensure_result.
- migration: build.migrate_drop_result_parquet (once per project, marker-guarded)
  run from create_app to drop old build-time result.parquet on upgrade.
- lineage: contract baked CachedNode/RemoteTable out of the internal-lineage
  view so it shows logical ops only.
- doc/comment fixes in the MCP diff + post-processing tools.

Full suite: 454 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@paddymul
paddymul marked this pull request as ready for review June 15, 2026 23:22
paddymul and others added 3 commits June 15, 2026 20:23
The wired diff path (MCP catalog_diff, companion api_diff_data) passes both
entries' cache-resolving expressions, which compose at the expression level.
full_diff must not eagerly materialise a result.parquet for either entry on
that path. Asserts neither entry dir gains one; fails on the current eager
materialisation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review fixes for the #73 source-cache / drop-result-parquet work.

full_diff no longer eagerly materialises both entries' result.parquet at the
top. The xorq backend materialises only in the no-expr fallback; polars/pandas
materialise inside their own branches. The wired diff path (MCP catalog_diff,
companion api_diff_data) passes cache-resolving expressions and now writes
nothing — fixes the regression the preceding test caught, and drops a dead
b_pq reassignment that clobbered its own prior line.

_ensure_entry_parquet logs a failed ensure_result at debug (exc_info) instead
of swallowing it silently, so a genuine materialisation failure is diagnosable.

Retire the result_cache_dir / result_cache vestige entirely: baked results now
live under compute_cache/result_cache, already covered by the compute_cache
pointer, so the separate catalog/result_cache dir and its catalog.yaml key were
fully redundant. Removed result_cache_dir(), the result_cache state key (read,
capture, prune_result_cache, reset_to, restore_from_bullpen), and the
result_cache field from the disk_usage payload (the SPA only reads
formatted.total). Tests assert the dir is never re-created.

classify_build docstring now cross-references source_cache._is_worthy_expr as
the live-expression twin of the same predicate (shared _EXPENSIVE_OPS).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Tier-A now measures the companion's real ensure_session -> /load_expr path
(production 10s cap) instead of a long-timeout bypass, so the report shows
whether a cold open returns a live session under the cap (cold ok / CAPPED),
the uncapped server-side cost from the #926 span, and same-lifetime
warm-cached vs server-restart warm-reload timings. Tier-B discovery and
row-counts key on manifest.json instead of result.parquet, which #73 no
longer writes at build time -- without this the harness skips a #73-built
corpus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@paddymul

Copy link
Copy Markdown
Contributor Author

Initial performance findings (ensure_session harness)

Updated the Tier-A perf harness to drive the real ensure_session → /load_expr (production 10s cap) instead of the long-timeout bypass, and re-keyed Tier-B discovery/row-counts on manifest.json since this branch no longer writes result.parquet at build (02361bc). Then compared this branch against main.

Caveat on scope

The comparison runs a reduced, single-parent 1m corpus, not the canonical parking corpus. This branch can't build any entry that combines ≥2 from_catalog parents (union/join) — build_expr raises Multiple backends found — so scripts/rebuild_parking_catalog.py aborts at step 7/15 (all_violations). Filed separately as #75. The numbers below are therefore the entries that build on both sides: the raw from_project reads and single-parent aggregates.

ensure_session (/load_expr) — the production viewer path

entry rows main branch
camera_violations_by_plate (Aggregate) 726,203 0.56s cold / 0.06s warm-reload 0.58s / 0.08s
camera_select_columns (raw read) 1,000,000 0.39s / 0.05s 0.38s / 0.05s

Neutral — within ~20ms. Expected: /load_expr already pushes down and never read result.parquet (#71), so dropping it / baking a result cache doesn't move this path at 1m scale. Neither side trips the 10s cap.

Execute path (load_entry → to_parquet) — what the result.parquet drop touches

entry rows main cold/warm branch cold/warm
camera_violations_by_plate (Aggregate) 726,203 0.13 / 0.11 0.27 / 0.14
camera_select_columns (raw) 1,000,000 0.17 / 0.17 0.17 / 0.17
camera_violations (raw, 20 col) 1,000,000 0.63 / 0.62 0.62 / 0.63

Raw reads identical; the aggregate is ~0.14s slower cold on the branch — main reads a materialised result.parquet (parquet→parquet copy) while the branch materialises through the baked-cache expr. Small absolute cost at this scale.

Bottom line

At the 1m single-parent scale this change is performance-neutral on the production ensure_session path and slightly negative on the cold execute of an aggregate. It can neither confirm nor refute the intended win, which targets large multi-parent aggregates over 100M+ row sources — exactly the entries that (a) can't be rebuilt here (#75) and (b) are where main's /load_expr re-runs the whole DAG (#71). Worth re-running on the full multi-parent corpus once #75 is fixed.

Harness: tests/test_perf_tier_a.py (ensure_session, cap-aware), tests/test_perf_integration.py (manifest-keyed). Run: uv run pytest -m perf_tier_a tests/test_perf_tier_a.py -s.

paddymul and others added 14 commits June 15, 2026 20:49
Two from_catalog parents combined in one expression (union/join) must
resolve to a single backend and build end-to-end. #73's expression-level
from_catalog loaded each parent into its own backend, so this raised
"Multiple backends found for this expression". Failing on this branch;
fix follows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-read

Adds the cases #73's load_entry-based from_catalog couldn't compose on one
backend, and updates the expensive-entry assertion to the single-backend
contract:

- from_project + from_catalog mix (cheap parent): one backend; builds (400 rows).
- from_project + from_catalog mix (expensive parent): one backend — exercises
  the deferred read of the baked snapshot, which a CachedNode would not.
- test_expensive_entry_bakes_result_cache now asserts cached_result_expr returns
  a single-backend Read (the baked snapshot), not the CachedNode itself.

These fail on the current source (load_entry roots each parent on its own
backend, so the mix raises "Multiple backends found"; the expensive read is a
CachedNode). The fix follows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…baked-snapshot read)

#73's from_catalog loaded each parent's build into its own xorq backend, so
combining two parents — or a from_catalog with a from_project — in a union/join
raised "Multiple backends found". cached_result_expr now reconstitutes every
entry on the single in-process default backend:

- Cheap entry: re-import its recipe (expr.py via _recipe_expr) — from_project /
  deferred readers bind to the default backend; recompute on read is
  pushdown-cheap.
- Expensive entry: read its baked result_cache snapshot directly as one
  deferred_read_parquet on the default backend. The path is derived from the
  same rewrite_for_build the build used (storage.get_path / calc_key), so it
  hits the file baked at build time — no recompute, and no CachedNode dragging
  its own storage backend into the caller's expression. Self-heals an evicted
  snapshot via the cache node once, then reads.

salt mode is unchanged (still routes through ensure_result). Child content
hashes stay stable across processes; the diff path's two sides now share one
backend, so into_backend bridging becomes a no-op.

The experimental shared_backend get_con memo is dropped — recipe re-execution
means from_catalog never composes loaded entries, so it was unnecessary.

Follow-up #76 tracks the expensive-parent child's dependency on the untracked
compute_cache snapshot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#73 stopped writing a build-time result.parquet, so child_build's result_mb stat raised FileNotFoundError and every build scenario became an ERROR row (cascading to the exec/buckaroo/companion scenarios that need a build). CI's perf-report job hid it — the report always exits 0. Report None when the file is absent; the comparable signal is wall/exec. Mirrors the #73-safe discovery already applied to test_perf_integration and test_perf_tier_a.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…construct

A revise-in-place recipe that chains off from_catalog of its own alias (the
pattern catalog_revise's docstring recommends) resolves to the previous
revision at build time but to itself afterwards — the alias is repointed only
after the build. Post-#75 cached_result_expr re-imports the raw recipe and
re-resolves from_catalog against the live head, so reconstructing such an entry
(diff, further chaining) recurses forever: a ~50GB RSS spike that locked a 48GB
machine while running the Tier-B perf suite.

The test pins the recursion limit just above the current stack depth so a
failing run raises RecursionError in tens of MB rather than OOMing CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cached_result_expr reconstructs an entry by re-importing its recipe, which
re-runs from_catalog against the *live* alias head. A revise-in-place entry
whose recipe reads from_catalog of its own alias therefore resolves to itself
and recurses without bound — the ~50GB RSS spike that locked the machine.

Track the entries under reconstruction on a contextvar stack; when from_catalog
resolves to one already in flight, walk back through alias history to the
build-time parent (what the alias pointed at when the entry was built — the
alias is repointed only after the build). A _MAX_RECON_DEPTH backstop converts
any cycle the resolver misses into a fast BuildError rather than an OOM.

Factor the alias-history step-back into tallyman_core.aliases.previous_version,
shared with primary_key._parent_hash.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cached_result_expr / from_catalog reconstruct an entry by re-importing its
recipe (expr.py), so the perf overlay — which symlinks each entry's artifacts
into a scratch TALLYMAN_HOME — now needs expr.py too. Pre-#73 the read path
resolved via xorq_build/ + result.parquet alone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
macOS ulimit -v caps address space, not RSS, so it can't stop a process ballooning resident memory. Polls process-tree RSS via psutil and SIGKILLs the tree at a GB cap, streaming child output through. Guards the Tier-B perf harness and the parking-corpus rebuild; backstop for unknown spikes (the known from_catalog cycle also has result_cache._MAX_RECON_DEPTH).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Truncating to --rows N needs only the first N rows, but _stage_sources read the whole parquet (pq.read_table) then sliced. On parking_plt_only4 (107M rows) that spikes RSS to tens of GB — the watchdog killed the rebuild at 16GB mid-staging. Test stages under a monkeypatched pq.read_table that raises, asserting no full materialisation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pull row-group batches until N rows collected, then stop, rather than reading the whole source and slicing. Bounds staging memory to ~N rows regardless of source size; rebuild peaks ~2GB at --rows 1000000 where staging previously blew past 16GB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t self-heal

On a cold compute cache (fresh clone / write-isolated overlay / not-yet-warmed
entry) an entry that from_catalogs an expensive parent fails: its build embeds a
*bare* deferred read of the parent's baked snapshot (#75 strips the parent
CachedNode to keep one backend), so the read resolves to zero files.

  - ensure_result (download / promote-diff / post-processing / paginated grid)
    raises "At least one path is required" — single-parent chain and the
    multi-parent tickets_and_ghosts join.
  - ensure_session (buckaroo /load_expr build replay) serves an empty grid.

Each test clears the reconstruction lru after evicting the snapshot to model a
fresh process; all three fail until ensure_result / ensure_session self-heal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
run_under_watchdog.py shipped with three >120-char f-string log writes that
fail `ruff check` — and ruff gates the test jobs, so the suite was being
skipped on CI. Wrap the heartbeat/kill lines and hoist peak_gb/elapsed locals
for the summary line; no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s on cold cache

An entry that from_catalogs an expensive parent has a build that embeds a *bare*
deferred read of the parent's baked snapshot (#75 strips the parent CachedNode to
keep the composed expression on one backend), with no recipe or source to recompute
from. So replaying that build on a cold compute cache — fresh clone, write-isolated
overlay, or an entry the startup warmup didn't reach — fails: the read resolves to
zero files.

  - ensure_result reconstructs via cached_result_expr (re-runs the recipe,
    re-resolves from_catalog, recomputes the parent) instead of load_entry, so an
    evicted snapshot anywhere in the chain self-heals rather than raising
    "At least one path is required". salt mode keeps load_entry to avoid recursing
    through cached_result_expr's salt branch.
  - ensure_session repopulates the snapshot via cached_result_expr before POSTing
    the build to buckaroo /load_expr, so the grid isn't empty on a cold cache.
    Healed before the session lock: a cold recompute mustn't block other sessions,
    a warm one is a cheap cache hit.

Also point the perf harness's execute measurement at cached_result_expr (the #73
reconstruct-on-read path) rather than load_entry, which couldn't materialise these
chains at all — the three ERRORs that surfaced this.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first cut only checked that *some* result_cache snapshot existed after
ensure_session — too weak, since the buckaroo /load_expr build replay reads a
specific snapshot keyed on the build path, not any snapshot. Replay the build
the same way (load_entry) and assert it serves real rows. Holds when build and
read share a project root (the same-machine cold cache the heal targets); a
cross-machine clone keys the snapshot differently and still needs the
reconstruct path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
paddymul and others added 3 commits June 16, 2026 10:25
cached_result_expr -> _recipe_expr -> _import_script registers each recipe under a unique sys.modules name and never removes it. Since #73 reconstruction runs per-read, repeated reconstruction of one entry accumulates module objects that pin their expression graphs. Failing test asserting it does not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_recipe_expr pops the recipe module out of sys.modules once the expression is extracted. The returned expr still holds the module object (and any UDF __globals__) while in use; only the unbounded name->module accumulation goes. _import_script (build path) is unchanged — its caller still needs the registration for serialization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…son parity

Adds the two review-identified coverage gaps: the cache_worthy/CachedNode disagreement fallback in cached_result_expr, and read_csv/read_json parity. The latter extracts _should_cache_read (membership over _EXEMPT_READS) so the parity is unit-testable — this xorq has no deferred_read_json, so the read_json branch has no end-to-end producer to build a fixture from.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@paddymul

Copy link
Copy Markdown
Contributor Author

Review pass (multi-agent, adversarially verified)

Reviewed across 6 dimensions (source-cache rewrite, recursion/cycles, self-heal + snapshot resolution, concurrency/lifecycle, API/state/lineage, tests/scripts) with each finding re-checked against the code by an independent verifier. 20 findings raised, 17 confirmed, 3 dismissed. Disposition below.

Fixed in this PR

  • _import_script leaked a sys.modules entry per reconstruction. Since caching: build_and_persist materialises result.parquet for every entry — replace with at-the-read source caching + cache-worthy-only result materialisation #73, cached_result_expr → _recipe_expr → _import_script runs per-read and registered each recipe under a unique sys.modules name without removing it, pinning the expression graph — unbounded under churn past the LRU's 256. Failing test 01d7fec (seen red on CI: AssertionError: leaked 5 recipe modules), fix d364157 (_recipe_expr pops the module after extracting the expr; _import_script/build path unchanged). Green on c50299a.
  • Test gaps (c50299a): the cache_worthy/CachedNode disagreement fallback in cached_result_expr, and read_csv/read_json parity (extracted _should_cache_read so the parity is unit-testable — this xorq exposes no deferred_read_json, so the read_json branch has no end-to-end producer).

Filed as follow-up issues

Noted on the PR body for separate review

  • previous_version cross-alias ambiguity (aliases.py) — not a regression (identical to the prior inline _parent_hash), but the new cycle resolver now depends on it. Recorded under "Review follow-ups" in the description.

Low — no action

Dismissed (3)

Also independently checked and cleared (no finding): dropping disk_usage.result_cache doesn't break the SPA (Header.tsx reads only formatted.total), and api_notebook_full's total_rows is reset per loop iteration.

CI green on c50299a (ruff · fast · integration).

paddymul added a commit that referenced this pull request Jun 16, 2026
Persist both cache verdicts per entry so #30's structural→measured flip is
data-driven instead of decided on first principles.

build.py: record the structural cache_worthy verdict + why (classify_build
already computed it and discarded it), compile_seconds (the author DAG's
to_sql / expr→backend-plan step, the dominant per-view cost per #30, timed
separate from execute), and cache_bytes (the baked snapshot size, the
value-per-byte denominator; None for a cheap entry). All land in manifest.json
and BuildResult, and the decision is logged once on the tallyman.perf
namespace with both verdicts side by side.

result_cache.py: tag each cold cached_result_expr read on tallyman.perf
(DEBUG) with the #74 path it took — cheap-recompute / baked-read /
evicted-self-heal (plus salt and the worthiness-disagreement fallback) — and
its reconstruct wall-clock (#82's lever). New baked_snapshot_path() names the
snapshot a cold read returns, extracted from the inline derivation now shared
with the read path.

app.py: /api/disk_usage now counts the per-project compute_cache/ (where #74's
baked results and source-read caches live), which it omitted — supplying the
byte denominator and closing the #74 disk-usage follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
paddymul added a commit that referenced this pull request Jun 17, 2026
…, in-process reuse

Stress-testing the rebuild against the real corpus (a disposable copy of the
15-entry parking_ticket_analysis_1m) surfaced three gaps, all fixed:

- Most real projects are catalog.yaml-era, not aliases.json-era: aliases live in
  alias_map/alias_history and charts/post_processing/stats/notebook are embedded
  in catalog.yaml with no decomposed files. read_old_catalog now reads each
  section from its decomposed file if present, else from catalog.yaml, and
  reconstructs the notebook (alias-keyed cells, no remap).
- A self-chaining revise (from_catalog of one's own alias, #74) made the topo
  sort see a false self-cycle. parse_deps now resolves a self-alias-ref to the
  PRIOR revision in history (the build-time parent), and the rebuild re-points an
  alias at each revision as it is rebuilt, oldest-first.
- An in-process rebuild left the process-global cached_result_expr memo populated
  with pre-wipe plans, so a from_catalog child re-execed against a stale parent.
  Clear the memo after wiping the catalog (a fresh-process CLI run is unaffected).

Documents the remaining build-time-alias-revision limitation: a recipe chains a
parent by alias with no version pin, so an alias revised after a child was built
(with a schema change) re-points to its latest revision and the child can miss a
column; pinning cross-alias refs to manifest.parents (#84) is the next step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
paddymul added a commit that referenced this pull request Jun 18, 2026
Flip mode() default off -> cas, add a cp --reflink=auto arm to _clone() for Linux (CoW where available, plain copy on ext4) keeping macOS cp -c and the copy fallback, and add gc_cas() wired into reset_to to reclaim .cas clones no live entry's manifest.sources references (.cas lives outside the catalog git repo, so git reset never reclaims it).

test_append_invalidates flips to passing; the fast-suite pins guard it in CI. The flip makes build identity content-aware; it does NOT make cold reconstruction content-faithful (#74 hazard) — that needs digest-pinned reconstruction, tracked in #115. ADR moved to Accepted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
paddymul added a commit that referenced this pull request Jun 18, 2026
…/result LRUs

The two existing reset tests only assert `currsize == 0` — they pin the clear
mechanism, not #80/#96's symptom (a memoized build executing over a deleted
snapshot). These add the cross-process leg and a load-bearing proof:

- test_notify_project_reset_evicts_compare_build_over_pruned_snapshot: warms
  `_build_compare_expr` (freezing b_h's baked snapshot path into a serialized
  build with no per-call exists() recheck), runs `reset_to` to prune b_h's
  snapshot (standing in for the out-of-process CLI reset, which does NOT reach
  the companion LRU), then proves the stale build executes over the deleted
  parquet (`ValueError: At least one path is required`, the #73/#74 symptom)
  and that only the `/internal/notify {project_reset}` signal evicts it.
- test_notify_project_reset_clears_result_plan_memo: parity hygiene for the
  result-plan memo on the same cross-process path.

Both fail today: the notify handler reloads buckaroo sessions but never clears
the companion's process-global LRUs, so a CLI `reset-to` leaves a long-lived
companion serving a diff build over a pruned snapshot (#80's "Communication
gap").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
paddymul added a commit that referenced this pull request Jun 18, 2026
…equisites #84-#89

Resolutions from the 2026-06-16 design review reconciling the reactive-recalc plan with merged PR #74: the materialization boundary is policy (structural cache_worthy stays live, measured value-per-byte runs in shadow, flip on data — #30 softened from remove-now); the cross-entry DAG is restorable now via a manifest parents field (only the follow-the-concept layer stays deferred); cas is a prerequisite, not an open question (recompute-on-cold-read); read-intent rides the same parents field {hash, ref, follow} with previous_version disambiguation; determinism is detect-and-warn (structural via a hash predicate, execution via a lint + #83).

Touches all four ADRs in plans/. Prerequisites filed #84-#88, epic #89; measurement #82, soundness #83.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
paddymul added a commit that referenced this pull request Jun 18, 2026
…e entry was built from (#115)

Cold reconstruction re-runs the recipe's from_project, which under the cas
default re-digested the live source and cloned it under a new digest — so a
cheap entry's cold read, or an expensive entry self-healing an evicted
snapshot, served bytes edited in place under the entry's original content_hash
(the #74 hazard #86's flip left open).

Thread the reconstructing entry's recorded sources into the reconstruction
context and resolve from_project to the frozen clone:

- result_cache._RECON_SOURCES: a contextvar set per-entry in _recipe_expr to
  (project, manifest.sources), paired with _RECONSTRUCTING around the
  _import_script window and reset in the same finally. Nested reconstruction
  overwrites then restores it, so each entry's from_project sees its own sources.
- io.from_project: in cas mode, when reconstructing, resolve to the recorded
  .cas/<digest> clone instead of re-digesting the live file, and note that
  frozen digest (keeps a child's manifest.sources honest for gc_cas liveness).
  Build path, off, salt, and unrecorded sources are unchanged.
- source_identity.recon_cas_path: returns the recorded clone; re-materialises it
  from the live source only if the live *content* (not the stat-keyed memo) still
  hashes to the recorded digest; otherwise serves live bytes best-effort and
  warns on tallyman.perf — the one branch that is not content-faithful.

Closes the cas cold-read/self-heal faithfulness hazard on the same machine with
.cas present. salt reconstruction, the digest_for stat-collision hole, off mode,
and cross-machine reads where .cas is absent AND the source drifted remain open.

Also pins the expensive test's aggregate with order_by('x') so its #83
result_digest is stable build-to-self-heal, and adds an off->cas
graceful-degradation guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
paddymul added a commit that referenced this pull request Jun 23, 2026
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>
paddymul added a commit that referenced this pull request Jun 25, 2026
…notated links

Each recorded catalog ref (e.g. "parking_2015") becomes a link to the exact entry it resolved to at build time, with a caption listing what each ref loaded (hash @ alias-head/pinned). Addresses the cognitive burden of an opaque self-referential alias. The inline-the-source-from-read_csv alternative is deliberately left out — it defeats the #73/#74 shallow-graph design and needs grilling first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
paddymul added a commit that referenced this pull request Jun 25, 2026
…ge via direct build

catalog_revise now rejects self-referential recipes (#135), so the two tests that documented self-ref revise as the recommended pattern (anti-recursion #73, staleness classification #74/#85) built their self-ref head via catalog_revise — now rejected. Build it directly (build_and_persist + set_alias) instead, so the #73 anti-recursion engine and the 'self-referential not UNEXPLAINED' classification stay covered for historical heads / indirect cycles, which the guard (a tool-boundary policy) deliberately doesn't block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant