Skip to content

Commit 52eb313

Browse files
paddymulclaude
andcommitted
docs: why result_digest can't reuse xorq's content-aware cache staleness
Research note comparing xorq's cache staleness to tallyman's result_digest. - xorq staleness is input-addressed: the key is a tokenized graph + source-leaf identity, a cache miss IS the staleness signal (storage.exists), and it never hashes the executed output bytes — it assumes determinism. - tallyman already reuses that whole input-identity stack: build_expr tokenizer -> content_hash, ParquetSnapshotCache, source_identity as the content-hash upgrade of xorq's mtime fold, and staleness.scan as the catalog-level exists() analog. - result_digest is output-addressed (faithfulness of the executed bytes) — a question xorq's cache is structurally blind to, frozen-upstream to fix, so it cannot be replaced by xorq's mechanism. - Frames result_digest as a temporary crutch for LLM-authored recipes with an explicit retraction path: the #88 lint covers the decidable cases (those entries can skip the digest); impure UDFs are the undecidable hard kernel, so full removal needs UDFs constrained to a pure subset, not just a smarter model. xorq needs no equivalent because it assumes a trusted author. Holds the findings from this research session for the eventual docs pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d42e4da commit 52eb313

1 file changed

Lines changed: 160 additions & 0 deletions

File tree

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# result_digest vs xorq's content-aware cache staleness
2+
3+
- **Status:** Research note (2026-06-26)
4+
- **Question:** xorq already ships a content-aware cache that knows when a cached
5+
result is stale. Why does tallyman maintain a separate `result_digest` /
6+
`verify_result_faithful`? Can we reuse xorq's staleness mechanism instead — or a
7+
portion of it?
8+
- **Verdict:** The input-identity portion of xorq's mechanism is *already reused
9+
wholesale*. The output-faithfulness portion (`result_digest`) cannot be supplied
10+
by xorq at all — it answers a different question xorq's cache is structurally
11+
blind to — and is deliberately a thin, temporary crutch for LLM-authored recipes.
12+
- **Related:** `plans/adr-result-digest-canonical-ordering.md`,
13+
`plans/89-determinism-prereqs-execution.md` (#83 digest / #88 lint / #89 epic),
14+
`plans/adr-source-identity-content-hash.md` (`content_hash` + source identity).
15+
- **Method:** traced the installed `xorq` 0.3.26 / `xorq_datafusion` 0.2.7 source and
16+
tallyman `src/`; each load-bearing claim below was independently re-checked against
17+
the code.
18+
19+
## How xorq's content-aware staleness works
20+
21+
It is purely **input-addressed**, with no explicit invalidation step:
22+
23+
- The cache key is re-derived from the expression on every access (`Cache.calc_key`
24+
`strategy.calc_key(expr)`, `xorq/caching/__init__.py:62-67`). The only "is it
25+
stale?" check is `storage.exists(key)` — a bare filename-presence test
26+
(`ParquetStorage.exists` = `get_path(key).exists()`, `xorq/caching/storage.py:120-121`)
27+
or a backend-table-presence test (`SourceStorage.exists` = `key in source.tables`,
28+
`storage.py:187-188`). **A cache miss *is* the staleness signal:** a changed input
29+
yields a different key, the new key isn't present, so it recomputes; the old
30+
artifact orphans under its old key.
31+
- All "content-awareness" lives in **key derivation** (the strategy).
32+
`ModificationTimeStrategy.calc_key` returns `key_prefix + expr.ls.tokenized`
33+
(`strategy.py:88-90`); the default data-sensitive HASHER normalizes each source leaf
34+
via `normalize_read_path_stat`, folding `(st_mtime, st_size, st_ino)` into the leaf
35+
token (`xorq/common/utils/defer_utils.py:86-97`, reached through the Read-path
36+
normalization at `_paths.py:77` — the coupling is indirect, not a call inside
37+
`strategy.py`). There is also an opt-in content variant `normalize_read_path_md5sum`
38+
(`defer_utils.py:82-83`). `SnapshotStrategy` keys on path + structure only — "path
39+
identity only, not file modification stats" (`strategy.py:49-69`).
40+
- **No strategy ever hashes or compares the executed OUTPUT bytes.** `put` writes the
41+
result parquet (`storage.py:131-138`); `get` reads it back (`123-129`); neither
42+
validates stored bytes against a recompute. The cache *assumes determinism*: same
43+
key ⇒ the stored bytes are by assumption the correct output.
44+
- (`ParquetTTLStorage.exists`, `storage.py:160-168`, adds age-based eviction on the
45+
cache file's own mtime vs wall-clock — a TTL, not a source- or output-content check.
46+
So "the only check is `exists()`" is true, but `exists()` carries a TTL dimension in
47+
that one subclass.)
48+
49+
## Two non-overlapping questions
50+
51+
| | xorq cache staleness | tallyman `result_digest` |
52+
| --- | --- | --- |
53+
| Asks | "Have the **inputs** moved?" | "Does the **output** still reproduce?" |
54+
| Addressed on | input identity (tokenized graph + source-leaf identity) | output bytes (`sha256` of the baked snapshot) |
55+
| Signal | key changed ⇒ cache miss ⇒ recompute | recorded digest ≠ re-baked digest ⇒ unfaithful |
56+
| Blind to | the same key yielding different bytes | anything about inputs |
57+
58+
Input-identity is necessary but not sufficient. A fixed graph over fixed declared
59+
inputs can still execute to different bytes: `sample()`, `now()`, an unordered
60+
`limit`, an impure UDF, or source drift under `off` identity. The plans say it
61+
directly: the structural hash "is a `SnapshotStrategy` tokenization of the graph; it
62+
cannot see this" (`89-determinism-prereqs-execution.md:40-42`); these failures "leave
63+
the hash fixed and only move the *bytes*" (`:53-61`). That gap is exactly what
64+
`result_digest` exists to police.
65+
66+
## What tallyman already reuses from xorq
67+
68+
tallyman takes xorq's entire input-identity stack and layers its own output audit on
69+
top:
70+
71+
| xorq mechanism | tallyman analog | evidence |
72+
| --- | --- | --- |
73+
| graph tokenizer (`build_expr``xxh128` over the normalized graph) | `content_hash` is literally the build-dir basename | `build.py:402,407` (verbatim in default `cas` mode; `salt` mode post-wraps it, `build.py:408-411`) |
74+
| `ParquetSnapshotCache` (SnapshotStrategy, path-identity keys) | used directly as the `.cache()` argument | `source_cache.py:118,160-161` (it is a Cache storage+strategy class; the graph node it yields is a `CachedNode`) |
75+
| `ModificationTimeStrategy` mtime fold (source-leaf identity in the key) | `source_identity` content digest — the content-hash *upgrade* of the mtime fold | `cas` reroutes reads to a digest-named clone so the tokenized path embeds the digest (`io.py:71-74`); md5-over-bytes memoized on `(mtime_ns,size,inode)` (`source_identity.py:70-101`) |
76+
| `storage.exists(key)` (per-expression "did the key change?") | `staleness.scan` / `entry_staleness` (catalog-level, two input axes) | followed-alias head vs recorded hash + source digest re-digest (`staleness.py:74-114,84-107`) |
77+
78+
So the "did an input move?" question is answered the xorq way, only stronger: a real
79+
byte digest instead of `(st_mtime,st_size,st_ino)`, lifted onto git-versioned manifest
80+
entries, with a forced rehash (`_force_source_rehash`) that catches same-stat in-place
81+
edits xorq's mtime strategy would miss.
82+
83+
## Why xorq's cache cannot replace result_digest
84+
85+
- **Structurally blind to byte drift.** xorq never hashes or compares output bytes;
86+
`exists(key)` short-circuits on the input-derived key and assumes the stored bytes
87+
are correct. There is no xorq primitive that could detect output drift.
88+
- **Frozen upstream.** Modifying xorq's hash to fold this in is explicitly rejected /
89+
out-of-scope (`adr-source-identity-content-hash.md:111`).
90+
- **Abstraction mismatch.** tallyman reads results across a deliberate parquet boundary
91+
`cached_result_expr` returns `deferred_read_parquet(snapshot)` rather than
92+
re-chaining the recipe (`result_cache.py`), to stay single-backend and skip
93+
re-running the DAG. Identity lives in a git-versioned manifest of catalog entries
94+
(`manifest.py:51-57`), not bound to one live in-process expression the way xorq's
95+
per-access key is.
96+
97+
tallyman's own code already draws this exact line: `result_digest` is "deliberately
98+
*not* a staleness input: a recompute-differs entry is *nondeterministic*, not stale,
99+
and recompute cannot make it fresh" (`staleness.py:20-22`; `recalc.py:33-36`).
100+
101+
## The reusable portion
102+
103+
Everything on the **input-identity** axis — and it is already taken. The tokenizer
104+
(`build_expr``content_hash`) stays borrowed. The source-staleness idea is reused in
105+
the right (stronger) form via `source_identity` + `staleness.scan`. The one
106+
transferable *idea* still worth naming is xorq's `SnapshotStrategy` path-identity
107+
discipline — pin identity to a stable path/structure token, decouple it from volatile
108+
stat — which `cas` mode already operationalizes by making the path itself
109+
content-addressed. Nothing on the **output-faithfulness** axis is reusable, because no
110+
xorq strategy touches output bytes.
111+
112+
## result_digest is a crutch
113+
114+
`result_digest` exists because tallyman's recipes are **LLM-authored**, and an LLM can
115+
inject nondeterminism (`now()`, `sample()`, unordered `limit`, an impure UDF) that a
116+
human expert would not. xorq's cache has no equivalent precisely because it assumes a
117+
**trusted author** — same key ⇒ blind reuse. So `result_digest` is the *tax of letting
118+
an LLM write the cached computation*, and it should retract as the generator becomes
119+
trustworthy.
120+
121+
The retraction path has a decidable part and a hard kernel:
122+
123+
- **Decidable nondeterminism**`now()`, `sample()`, unordered `limit`, a baked random
124+
literal — is visible in the graph; the #88 structural lint (and
125+
`recipe_is_structurally_nondeterministic`) can reject or flag it at author time. For
126+
entries the lint proves clean, the digest can be skipped entirely (ADR open Q3). This
127+
shrinks the crutch as lint coverage grows, independent of model quality.
128+
- **Undecidable nondeterminism** — an impure UDF (arbitrary Python) — cannot be proven
129+
pure statically. A *smarter* model doesn't close this: even a perfect author can
130+
write a UDF whose impurity isn't visible in source. So "reliable deterministic code
131+
gen" has to mean **the UDF surface is constrained or verified to a pure subset**
132+
(sandbox, allowlist, purity checker), not just a better LLM. Until that exists, a
133+
runtime byte-level audit is the only backstop for the UDF residual.
134+
135+
This argues for keeping `result_digest` exactly as the canonical-ordering branch
136+
shapes it: thin, ≈free (hash the snapshot bytes already being baked), nothing recorded
137+
for cheap entries, and pointedly *not* a staleness/recompute trigger. A crutch for an
138+
untrusted generator is most valuable as a **steering signal back to the generator**
139+
surfacing "this recipe isn't reproducible" so the recipe gets rewritten — so the digest
140+
should be loud and legible in the self-heal / diff / warning surface, with
141+
cache-soundness as the side benefit.
142+
143+
The branch's contribution, in this light, is that it fixed a *broken* crutch: the old
144+
order-sensitive digest flagged datafusion's benign parallel-scan reordering as if it
145+
were LLM-introduced nondeterminism (false drift). The multiset / canonical-order
146+
definition re-scopes the audit to fire only on *genuine* nondeterminism — the kind a
147+
better generator (or completed lint) would actually prevent.
148+
149+
## Recommendation
150+
151+
1. Drive staleness/recompute entirely from input-addressing — `content_hash` +
152+
`source_identity` + `staleness.scan` — which already *is* xorq's mechanism. Keep
153+
`result_digest` out of that path (it already is).
154+
2. Keep `result_digest` as a thin output-faithfulness / eviction-soundness audit,
155+
consumed by `verify_result_faithful` and self-heal, in its post-branch form
156+
(`sha256` of the canonical-order snapshot; nothing for cheap entries).
157+
3. Do not try to merge the two or "use xorq's cache instead" — xorq cannot see the
158+
bytes. Treat `result_digest` as a removable crutch with the retraction path above;
159+
invest in #88 lint coverage and a constrained UDF surface, not in elaborating the
160+
digest.

0 commit comments

Comments
 (0)