@@ -86,7 +86,7 @@ would break both the isolation and the reset semantics.
8686
8787Tallyman decides what to materialize at * build* time and bakes the decision
8888into the entry's recipe, so every later read takes the same path. Before a
89- build, ` rewrite_for_build ` (` source_cache.py:82 ` ) rewrites the submitted
89+ build, ` rewrite_for_build ` (` source_cache.py:89 ` ) rewrites the submitted
9090expression in three steps: reject in-memory reads, inject a source-read cache
9191after each non-parquet file read, and — for an expensive expression — wrap the
9292whole thing in a top-level result cache. Both kinds of cache node are
@@ -103,12 +103,19 @@ path), so there is no separate `result_cache/` directory; both land under
103103 exempt (` _EXEMPT_READS ` ): re-reading a columnar source is already a pushdown.
104104- ** Baked result snapshot** — an expression is * worthy* when it contains an
105105 Aggregate / Join / Sort / window / UDF (` _EXPENSIVE_OPS ` ,
106- ` result_cache.py:48 ` ). A worthy expression is wrapped in a result cache with
107- ` relative_path="result_cache" ` (` source_cache.py:153 ` ), so its snapshot lands
106+ ` result_cache.py:49 ` ). A worthy expression is wrapped in a result cache with
107+ ` relative_path="result_cache" ` (` source_cache.py:160 ` ), so its snapshot lands
108108 under ` compute_cache/result_cache/ ` ; executing the entry at build time
109- materializes it once (` build.py:487 ` ). A non-parquet read is * not* by itself
110- worthy (` classify_build ` , ` result_cache.py:61 ` ) — its parse is already
111- handled by the source-read cache.
109+ materializes it once (` build.py:491 ` ). A non-parquet read is * not* by itself
110+ worthy (` classify_build ` , ` result_cache.py:62 ` ) — its parse is already
111+ handled by the source-read cache. Worthiness is decided by two predicates that
112+ must agree: ` _is_worthy_expr ` gates the bake (` source_cache.py:62 ` ) and
113+ ` classify_build ` records ` cache_worthy ` in the manifest. ` _is_worthy_expr `
114+ matches a UDF by class ancestry (` type(node).__mro__ ` , ` source_cache.py:84 ` )
115+ and ` classify_build ` by the serialized op name containing ` UDF `
116+ (` result_cache.py:85 ` ; a scalar UDF serializes as ` op: ScalarUDF ` ); the two now
117+ agree for a scalar UDF, so a scalar-UDF-only entry is worthy under both and
118+ bakes its snapshot instead of recomputing the whole graph on every read (#81 ).
112119
113120A cheap entry — a source read plus projections / renames / row-wise scalar
114121math — bakes no result snapshot. Recompute costs about the same as reading a
@@ -118,13 +125,17 @@ written for any entry, cheap or expensive, at build time or on demand (#73 and
118125follow-up).
119126
120127Every consumer reads an entry's result through one function,
121- ` cached_result_expr ` (` result_cache.py:273 ` ):
128+ ` cached_result_expr ` (` result_cache.py:403 ` ):
122129
123130- Expensive entry → ` deferred_read_parquet ` of the baked snapshot, on the
124131 default backend. The snapshot was written when the entry built, so reading it
125132 skips re-running the graph. If it was evicted since, the cache node recomputes
126- it once and the read proceeds — a self-heal re-checked on every call
127- (` result_cache.py:321 ` ).
133+ it once and the read proceeds — a self-heal re-checked on every call,
134+ single-flighted per ` (project, content_hash) ` so concurrent cold readers don't
135+ both run the shared cache op; after a repopulate the snapshot's digest is
136+ checked against the recorded ` result_digest ` and a mismatch is logged as
137+ recompute drift (` result_cache.py:451-473 ` , ` _heal_lock ` ,
138+ ` _warn_if_self_heal_unfaithful ` ; #79 , #83 ).
128139- Cheap entry → the live expression, re-imported from the entry's ` expr.py `
129140 and recomputed on read.
130141
@@ -136,10 +147,20 @@ reads a pre-existing `result.parquet`, because none is written.
136147Snapshot strategy is the right one here because an entry is an immutable,
137148content-addressed artifact — its result must not invalidate just because an
138149upstream file's mtime drifts. No TTL: entries are permanent history, not
139- expiring scratch. The one exception is ` salt ` source-identity mode, where
140- path-only snapshot keys would collide across entries with identical paths but
141- different content; under salt, ` rewrite_for_build ` bakes no cache at all
142- (` source_cache.py:123 ` ) and every read recomputes.
150+ expiring scratch.
151+
152+ How a source file's content reaches the key is set by ` TALLYMAN_SOURCE_IDENTITY `
153+ (` source_identity.py:52 ` ). The default is ` cas ` : ` from_project ` reads through a
154+ content-addressed clone at ` <project>/data/.cas/<digest> ` (` io.py:58 ` ), so the
155+ path xorq tokenizes embeds the content digest and every xorq-level key — build
156+ hash and snapshot keys alike — is content-honest, and a rebuild over an edited
157+ source forks the hash instead of deduping to the stale entry. ` off ` is the
158+ historical path-only mode: no digest, no clone, an edit collides with the prior
159+ entry. ` salt ` is the remaining exception to baking: it folds source digests into
160+ the entry hash but leaves xorq's own keys path-only, so a baked snapshot's
161+ path-only key would collide across entries with identical paths but different
162+ content. Under salt, ` rewrite_for_build ` bakes no cache at all
163+ (` source_cache.py:130 ` ) and every read recomputes.
143164
144165### Compute cache (` compute_cache_dir ` in ` src/tallyman_core/paths.py ` )
145166
@@ -156,7 +177,11 @@ file captured at each checkpoint; `reset_to`
156177(` src/tallyman_core/catalog_state.py:327 ` ) prunes or restores the cache to
157178match the target step. Evicted files move to ` bullpen/ ` rather than being
158179deleted, so a forward reset restores them instead of recomputing, while a
159- re-added entry still computes honestly cold.
180+ re-added entry still computes honestly cold. ` reset_to ` also reclaims orphaned
181+ ` cas ` clones: ` <project>/data/.cas ` lives outside the catalog git repo, so
182+ ` git reset ` can't touch it, and ` _gc_cas_clones ` (` catalog_state.py:349 ` ,
183+ ` source_identity.gc_cas ` ) deletes any ` .cas ` file no surviving entry's
184+ ` manifest.sources ` still references (#86 ).
160185
161186### In-memory caches in the companion
162187
@@ -165,14 +190,18 @@ and staleness is impossible:
165190
166191- ` cached_result_expr ` — its reconstruction work is memoized on
167192 ` _resolve_result_plan ` , ` lru_cache(256) ` keyed ` (project, content_hash) `
168- (` result_cache.py:232 ` ); ` cached_result_expr ` re-exposes that memo's
193+ (` result_cache.py:345 ` ); ` cached_result_expr ` re-exposes that memo's
169194 ` cache_clear ` / ` cache_info ` . The memo saves re-importing the entry's
170195 ` expr.py ` and, for an expensive entry, re-deriving its baked-snapshot path.
171196 ` cached_result_expr ` itself is a thin wrapper that re-checks the snapshot's
172- on-disk presence on every call, so an evicted snapshot self-heals.
197+ on-disk presence on every call, so an evicted snapshot self-heals; the heal
198+ runs under a per-entry lock (` _heal_lock ` , ` result_cache.py:393 ` ) so only the
199+ first of several concurrent cold readers executes it, and a peer process that
200+ wins xorq's fixed-` .tmp ` rename is caught and retried once if the snapshot
201+ landed, else re-raised (#79 ).
173202- ` _build_compare_expr ` — ` lru_cache(128) ` keyed
174203 ` (project, a_hash, b_hash, keys) ` ; saves rebuilding diff outer-join
175- expressions (` src/tallyman_companion/app.py:192 ` ). Build dirs land under
204+ expressions (` src/tallyman_companion/app.py:189 ` ). Build dirs land under
176205 ` $TMPDIR/tallyman_diff_builds/ ` .
177206- Disk-usage payload — per-project, 3-second TTL
178207 (` _DISK_USAGE_TTL ` in ` src/tallyman_companion/app.py ` ). The only
@@ -231,12 +260,23 @@ catalog store, `catalog.py` / `catalog_state.py`, for the full tracked surface.)
231260 omits it rather than symlinking it.)
232261- ** Manifest / schema** — ` <entry>/manifest.json ` , ` <entry>/schema.json ` :
233262 row counts, timings (including #87 's cache-admission fields:
234- ` compile_seconds ` , ` cache_worthy ` , ` cache_bytes ` ), and the schema, all
263+ ` compile_seconds ` , ` cache_worthy ` , ` cache_bytes ` ), the #83 ` result_digest `
264+ (a content hash of the executed result bytes), and the schema, all
235265 derived from the expression at build so later reads don't re-walk it.
236266- ** Alias history** — ` <catalog>/aliases.jsonl ` , one line per alias holding
237267 its current head and the append-only version log (` aliases.py ` ). It is a
238268 git-tracked file in the catalog repo, not a per-entry artifact, so it rolls
239269 back with ` git reset ` on a ` reset_to ` .
270+ - ** Per-hash config** — ` <catalog>/chart_specs/<hash>.vl.json ` and
271+ ` <catalog>/display_configs/<hash>.json ` hold an entry's chart and display
272+ config, keyed by content hash. These are mutable (set or cleared from the UI),
273+ not build outputs. Because the key is the hash, a revise mints a new hash and
274+ would orphan them, so ` carry_forward_entry_config ` (` entry_config.py:21 ` )
275+ copies each from the prior version unless the new version already defines its
276+ own — called from ` catalog_revise ` (` server.py:572 ` ) and the companion's
277+ ` put_code ` . Post-processing and summary stats are not carried: they are
278+ project-global (keyed by name, not hash) and already apply to every version
279+ (#109 ).
240280
241281## Buckaroo
242282
@@ -296,11 +336,23 @@ all rely on "same key, same bytes, forever". Cleanup for those is a
296336space concern (manual delete, bullpen moves on reset), and a deleted file
297337self-heals by recomputing.
298338
299- The one documented hole in "never stale" is execution nondeterminism: an
300- entry whose recipe calls ` now() ` / ` random() ` / an unseeded ` sample() `
301- produces different bytes each run under one content hash, so a cold recompute
302- can disagree with what was built (#88 ). The build flags these as advisory lint
303- warnings (` _nondeterminism_warnings ` , ` build.py ` ); it does not block them.
339+ Two documented holes break "never stale". The first is execution
340+ nondeterminism: an entry whose recipe calls ` now() ` / ` random() ` / an unseeded
341+ ` sample() ` produces different bytes each run under one content hash, so a cold
342+ recompute can disagree with what was built (#88 ). The build flags these as
343+ advisory lint warnings (` _nondeterminism_warnings ` , ` build.py ` ); it does not
344+ block them. As of #83 the build also records a ` result_digest ` of the executed
345+ bytes in the manifest, and the self-heal compares the repopulated snapshot
346+ against it (` verify_result_faithful ` , ` result_cache.py:299 ` ;
347+ ` _warn_if_self_heal_unfaithful ` , ` result_cache.py:315 ` ), so this drift is now
348+ detected on recompute even though it is still not prevented. The second is
349+ cold-reconstruction faithfulness under the ` cas `
350+ default: ` cas ` makes * build* identity content-aware (a rebuild over an edited
351+ source forks the hash) but not * reconstruction* . ` cached_result_expr ` re-runs a
352+ cheap entry's ` expr.py ` on every read, and ` from_project ` re-digests the live
353+ source, so a cheap entry — or an expensive entry self-healing an evicted
354+ snapshot — serves the edited bytes under its original ` content_hash ` . Closing
355+ this needs digest-pinned reconstruction, tracked in #115 .
304356
305357Where staleness is actually possible, it is handled explicitly:
306358
@@ -313,9 +365,12 @@ Where staleness is actually possible, it is handled explicitly:
313365 ` purgeInfiniteCache ` on sort/ops change, the session map clearing on
314366 Buckaroo restart, and the stat-cache deletion on reload.
315367
316- The one rule to remember: snapshot-strategy caches do not notice upstream
317- data changes. Tallyman is safe because entries are immutable by
318- construction; anyone pointing xorq's snapshot caches at mutable source
319- files has to manage invalidation themselves. Tallyman's one opt-out is ` salt `
320- source-identity mode, which bakes no snapshot cache at all (path-only keys
321- would collide across salted entries) and recomputes on every read.
368+ The one rule to remember: snapshot-strategy caches do not notice upstream data
369+ changes. The ` cas ` default papers over this at * build* time — an edited source
370+ forks the entry hash rather than deduping to the stale one — but a cold read of
371+ a cheap or evicted entry still re-digests the live source and can serve edited
372+ bytes under the old hash (#74 , above). Anyone who needs reconstruction to be
373+ content-faithful today should treat a source as immutable once an entry is built.
374+ ` off ` reverts to xorq's path-only identity (an edit collides silently); ` salt `
375+ mixes digests into the entry hash but bakes no snapshot cache at all (path-only
376+ keys would collide across salted entries) and recomputes on every read.
0 commit comments