|
| 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