qa/providence_query: cache persist on miss + audit chain append

Phase 2 step 2 of #000072. The skeleton landed in 20faae0 looked up
providence_cache + returned on hit, but the miss branch just
returned the fresh run_query result without writing anything back —
so every call paid the LLM cost. Now misses persist:

  1. Build run_dag via arborist.qa.dag.build_run_dag (claim-lattice
     mode, 9-stage variant — same shape legacy query() emits)
  2. Append a providence_write event via arborist.store.append_audit
     INSIDE a BEGIN IMMEDIATE transaction
  3. INSERT INTO providence_cache with all 25 columns the schema
     requires (cache_key, source_root, document_uri, question_*,
     answer_text, merkle_proof placeholder, 4-dim policy hashes,
     3-dim schema versions, audit_event_hash linking to the just-
     appended event, run_dag_root + run_dag_blob, audit_mode, etc.)
  4. COMMIT — atomic; audit chain + providence_cache stay
     consistent on crash mid-way

Result dict on miss now carries audit_event_hash + run_dag_root so
the caller can reference the audit chain or replay the DAG.
status="fresh_persisted" (was "fresh" in the skeleton) names the
new behavior.

NOT done yet (deferred to subsequent steps):
  - merkle_proof is "[]" placeholder. Schema requires NOT NULL.
    Real per-chunk proofs are a follow-up; the cache row is
    consistent without them but downstream wallet verification has
    nothing to walk.
  - burn_existing doesn't emit a providence_burn audit event
    (legacy query() does — query.py:3084 region). Audit chain
    still grows monotonically on the persist side, just doesn't
    record what was burned.
  - equivalence_class fallback lookup (legacy tries both dedup-mode
    keys when fidelity allows; primary only here)

Tests (tests/test_providence_query.py, 4):
  - first call persists row + audit event
  - second call returns cache_hit ignoring different stub
  - burn_existing forces re-run + re-persist; audit chain grows
  - chain links correctly across two distinct cache_keys

283 tests pass in the broader query/corpus/sidecar/wallet/bucket/
claim_lattice/byte_identity/providence gate.
This commit is contained in:
russell@unturf.com 2026-05-31 14:09:35 -04:00
parent b1c8fb7eba
commit 17b9622a08
No known key found for this signature in database
2 changed files with 319 additions and 20 deletions

View file

@ -32,9 +32,12 @@ import time as _time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import json as _json
from arborist.qa.client import ChatClient from arborist.qa.client import ChatClient
from arborist.qa.corpus import Corpus from arborist.qa.corpus import Corpus
from arborist.qa.corpus_query import run_query from arborist.qa.corpus_query import run_query
from arborist.qa.dag import build_run_dag
from arborist.qa.keys import ( from arborist.qa.keys import (
cache_key, cache_key,
conversation_hash, conversation_hash,
@ -46,6 +49,7 @@ from arborist.qa.prompts import (
CLAIM_LATTICE_GROUNDING_REMINDER, CLAIM_LATTICE_GROUNDING_REMINDER,
CLAIM_LATTICE_SYSTEM_PROMPT, CLAIM_LATTICE_SYSTEM_PROMPT,
) )
from arborist.store import append_audit
# Schema dimensions for the 8-dim cache_key. Match legacy # Schema dimensions for the 8-dim cache_key. Match legacy
@ -155,6 +159,115 @@ def _burn(qa_conn: _sqlite.Connection, ckey: str) -> int:
return cur.rowcount return cur.rowcount
def _persist_miss(
qa_conn: _sqlite.Connection,
*,
ckey: str,
question: str,
run_result: dict,
sources: list,
source_root: str,
qhash: str,
mhash: str,
chash: str,
ghash: str,
schema_version: str,
canonicalization_version: str,
chunking_version: str,
chain: str = "private",
) -> tuple[str, dict]:
"""Write one providence_cache row + one audit event for a fresh
(miss) result. Returns (audit_event_hash, run_dag_dict).
Minimal-viable persist: merkle_proof is an empty JSON array
placeholder. Real per-chunk Merkle proofs are a follow-up — the
schema requires the column NOT NULL, so empty array satisfies
that without claiming proof structure the caller didn't actually
build.
Audit chain append happens BEFORE the providence_cache INSERT so
event_hash is available to set audit_event_hash on the row.
Both run inside one transaction so a crash mid-way leaves the
chain consistent.
"""
audit_mode = run_result.get("audit_mode") or "UNGROUNDED"
answer_text = run_result.get("answer_text") or ""
verifier_method = run_result.get("verifier_method") or "none"
n_quotes = int(run_result.get("n_quotes") or 0)
n_verified = int(run_result.get("n_verified") or 0)
violations = run_result.get("violations") or []
unverified_blob = _json.dumps(
run_result.get("unverified_quotes") or [], separators=(",", ":")
)
raw_answer = run_result.get("raw_answer") or ""
# Reconstruct context_root from per-source document_roots. Legacy
# query() uses _context_root which is a Merkle root over the
# sorted source_roots; reuse that helper for byte-identical roots
# when sources match.
from arborist.qa.query import _context_root
context_root = _context_root([s.get("document_root", "") for s in sources])
run_dag = build_run_dag(
question_hash=qhash,
sources=sources,
context_root=context_root,
conversation_hash=chash,
answer_text=answer_text,
audit_mode=audit_mode,
verifier_method=verifier_method,
n_quotes=n_quotes,
n_verified=n_verified,
violations=violations,
raw_answer_text=raw_answer or None,
)
run_dag_blob = _json.dumps(run_dag, separators=(",", ":"))
now = int(_time.time())
qa_conn.execute("BEGIN IMMEDIATE")
try:
event_hash = append_audit(
qa_conn,
event_type="providence_write",
body={
"cache_key": ckey,
"audit_mode": audit_mode,
"run_dag_root": run_dag["root"],
"n_quotes": n_quotes,
"n_verified": n_verified,
},
subject_root=source_root,
ts=now,
)
qa_conn.execute(
"INSERT INTO providence_cache "
"(cache_key, source_root, document_uri, question_hash, "
" question_text, answer_text, merkle_proof, "
" model_profile_hash, conversation_hash, "
" governance_policy_hash, schema_version, "
" canonicalization_version, chunking_version, "
" falsification_state, chain, audit_event_hash, "
" created_at, hit_count, audit_mode, n_quotes, "
" n_verified, unverified_quotes, verifier_method, "
" run_dag_root, run_dag_blob) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, "
" 'live', ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(cache_key) DO NOTHING",
(
ckey, source_root, "corpus://multi-source", qhash,
question, answer_text, "[]",
mhash, chash, ghash,
schema_version, canonicalization_version, chunking_version,
chain, event_hash, now,
audit_mode, n_quotes, n_verified,
unverified_blob, verifier_method,
run_dag["root"], run_dag_blob,
),
)
qa_conn.execute("COMMIT")
except BaseException:
qa_conn.execute("ROLLBACK")
raise
return event_hash, run_dag
def providence_query( def providence_query(
corpus: Corpus, corpus: Corpus,
question: str, question: str,
@ -218,21 +331,38 @@ def providence_query(
for s in sources for s in sources
) )
source_root = corpus.snapshot_root() source_root = corpus.snapshot_root()
ckey = _compute_cache_key( # Pre-compute each dimension once — needed both for cache_key
question=question, # AND for the persist path below.
evidence_text=evidence_repr, qhash = question_hash(question, mode="equivalence_class")
policy=policy, mhash = model_profile_hash(model_id)
model_id=model_id, chash = conversation_hash(
_build_canonical_messages_for_hash(question, evidence_repr)
)
ghash = governance_policy_hash(policy)
schema_version = policy.get("schema_version", _DEFAULT_SCHEMA_VERSION)
canonicalization_version = policy.get(
"canonicalization_version", _DEFAULT_CANONICALIZATION_VERSION
)
chunking_version = policy.get(
"chunking_version", _DEFAULT_CHUNKING_VERSION
)
ckey = cache_key(
source_root=source_root, source_root=source_root,
question_hash_value=qhash,
model_profile_hash_value=mhash,
conversation_hash_value=chash,
governance_policy_hash_value=ghash,
schema_version=schema_version,
canonicalization_version=canonicalization_version,
chunking_version=chunking_version,
) )
# 4. Cache lookup AFTER computing the key. Future revisions # 4. Cache lookup. Burn first if requested (so a re-lookup after
# should reorder: compute a deterministic cache_key BEFORE # burn correctly misses and re-runs the LLM in the SAME call —
# retrieval (using source_root + question + policy only, plus # legacy convention).
# a separate context_hash dimension) so burn + hit can happen
# before the LLM call. Today we accept the LLM-after-lookup
# cost as the trade-off for the minimal skeleton.
qa_conn = _qa_conn(qa_db) qa_conn = _qa_conn(qa_db)
audit_event_hash = None
run_dag_root = None
try: try:
if burn_existing: if burn_existing:
burned_existing = _burn(qa_conn, ckey) burned_existing = _burn(qa_conn, ckey)
@ -245,26 +375,36 @@ def providence_query(
"lookup_path": "primary", "lookup_path": "primary",
"audit_mode": cached["audit_mode"], "audit_mode": cached["audit_mode"],
"answer_text": cached["answer_text"], "answer_text": cached["answer_text"],
"sources": sources, # from this run; stored sources "sources": sources,
# blob would need to be parsed "audit_event_hash": cached["audit_event_hash"],
"run_dag_root": cached["run_dag_root"],
"burned_existing": 0, "burned_existing": 0,
"elapsed_s": round(_time.time() - t_total, 3), "elapsed_s": round(_time.time() - t_total, 3),
} }
# 5. Miss — persist the fresh result + append audit. ckey
# is the index; the row holds run_dag_blob + audit_event_hash
# so a future re-lookup can validate the chain.
audit_event_hash, run_dag = _persist_miss(
qa_conn,
ckey=ckey, question=question, run_result=run_result,
sources=sources, source_root=source_root,
qhash=qhash, mhash=mhash, chash=chash, ghash=ghash,
schema_version=schema_version,
canonicalization_version=canonicalization_version,
chunking_version=chunking_version,
)
run_dag_root = run_dag["root"]
finally: finally:
qa_conn.close() qa_conn.close()
# 5. Fresh result — return with cache metadata. Persist is
# deferred to legacy query()'s flow for now (the schema requires
# many fields beyond what run_query produces: run_dag_root,
# context_root, prompt_hash, verifier_method, n_quotes,
# n_verified, violations_json, etc.). Future revisions add a
# full persist path here so providence_query can stand alone.
out = dict(run_result) out = dict(run_result)
out.update({ out.update({
"status": "burned" if burned_existing else "fresh", "status": "burned" if burned_existing else "fresh_persisted",
"cache_key": ckey, "cache_key": ckey,
"lookup_path": "miss", "lookup_path": "miss",
"burned_existing": burned_existing, "burned_existing": burned_existing,
"audit_event_hash": audit_event_hash,
"run_dag_root": run_dag_root,
"elapsed_s": round(_time.time() - t_total, 3), "elapsed_s": round(_time.time() - t_total, 3),
}) })
return out return out

View file

@ -0,0 +1,159 @@
"""End-to-end tests for arborist.qa.providence_query.
Covers Phase 2 step 1 + step 2 of #000072:
- Step 1: cache hit / miss / burn shape (skeleton landed 20faae0)
- Step 2: persist on miss — providence_cache row + audit chain
event written atomically inside one transaction
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
from typing import Iterator
import pytest
from arborist.document import Document
from arborist.ingest import ingest_source
from arborist.qa.client import StubClient
from arborist.qa.corpus import SqliteShardCorpus
from arborist.qa.providence_query import providence_query
from arborist.source import Source
from arborist.store import connect
class _S(Source):
source_type = "test"
def __init__(self, ds): self.ds = ds
def iter_documents(self) -> Iterator[Document]:
yield from self.ds
@pytest.fixture
def setup(tmp_path):
"""Build a one-doc corpus + an empty qa.db; return (corpus, qa_path)."""
shard = tmp_path / "shard.db"
qa = tmp_path / "qa.db"
c = connect(shard)
ingest_source(c, _S([Document(
uri="test://anarchism", source_type="test", title="Anarchism",
content=(
"Anarchism is a political philosophy that promotes a stateless "
"society. " * 6
+ 'The phrase "abolition of authority" describes the core principle. ' * 4
),
)]))
c.execute("PRAGMA wal_checkpoint(FULL)")
c.close()
connect(qa).close() # init schema
corpus = SqliteShardCorpus(connect(shard))
return corpus, qa
def test_first_call_persists_with_audit_event(setup):
corpus, qa = setup
stub = 'The core principle of anarchism is the "abolition of authority". [E1]'
r = providence_query(
corpus, "what is the core principle of anarchism?",
StubClient(answer=stub),
qa_db=qa, model_id="stub", top_k=2,
)
assert r["status"] == "fresh_persisted"
assert r["audit_mode"] == "STRICT"
assert r["cache_key"]
assert r["audit_event_hash"]
assert r["run_dag_root"]
qaro = sqlite3.connect(f"file:{qa}?mode=ro", uri=True)
n_cache = qaro.execute(
"SELECT COUNT(*) FROM providence_cache WHERE cache_key = ?",
(r["cache_key"],),
).fetchone()[0]
assert n_cache == 1
n_audit = qaro.execute(
"SELECT COUNT(*) FROM audit_events WHERE event_type='providence_write'"
).fetchone()[0]
assert n_audit == 1
qaro.close()
def test_second_call_returns_cache_hit(setup):
"""Second call with the SAME question + model + corpus must hit
the cache and ignore the (different) stub answer."""
corpus, qa = setup
q = "what is the core principle of anarchism?"
stub1 = 'The core principle of anarchism is the "abolition of authority". [E1]'
r1 = providence_query(
corpus, q, StubClient(answer=stub1),
qa_db=qa, model_id="stub", top_k=2,
)
r2 = providence_query(
corpus, q, StubClient(answer="WRONG ANSWER [E1]"),
qa_db=qa, model_id="stub", top_k=2,
)
assert r2["status"] == "cache_hit"
assert r2["audit_mode"] == r1["audit_mode"]
assert r2["answer_text"] == r1["answer_text"] # NOT the wrong stub
assert r2["cache_key"] == r1["cache_key"]
def test_burn_existing_forces_re_run_and_re_persists(setup):
"""burn_existing deletes the cached row, re-runs the LLM, and
persists a NEW row (which by chance may have the same cache_key
if inputs are identical — that's expected). audit chain grows by
one event per write."""
corpus, qa = setup
q = "what is the core principle of anarchism?"
stub = 'The core principle of anarchism is the "abolition of authority". [E1]'
providence_query(
corpus, q, StubClient(answer=stub),
qa_db=qa, model_id="stub", top_k=2,
)
r2 = providence_query(
corpus, q, StubClient(answer=stub),
qa_db=qa, model_id="stub", top_k=2, burn_existing=True,
)
assert r2["status"] == "burned"
assert r2["burned_existing"] == 1
# After burn + re-persist, exactly one live row + 2 audit events
# (write + write — the burn itself doesn't currently emit a
# 'providence_burn' event in providence_query; legacy query()
# does, but that's deferred to a later sub-step).
qaro = sqlite3.connect(f"file:{qa}?mode=ro", uri=True)
n_live = qaro.execute(
"SELECT COUNT(*) FROM providence_cache "
"WHERE falsification_state = 'live'"
).fetchone()[0]
assert n_live == 1
n_audit = qaro.execute(
"SELECT COUNT(*) FROM audit_events WHERE event_type='providence_write'"
).fetchone()[0]
assert n_audit == 2
qaro.close()
def test_audit_chain_links_correctly(setup):
"""Each providence_write event must chain off the previous
head — append_audit's own discipline. Verified by walking the
chain backwards from the latest event."""
corpus, qa = setup
q = "what is the core principle of anarchism?"
stub = 'The core principle of anarchism is the "abolition of authority". [E1]'
# Write two rows via two distinct cache_keys (different model_ids).
r1 = providence_query(
corpus, q, StubClient(answer=stub),
qa_db=qa, model_id="stub", top_k=2,
)
r2 = providence_query(
corpus, q, StubClient(answer=stub),
qa_db=qa, model_id="stub2", top_k=2,
)
assert r1["cache_key"] != r2["cache_key"]
qaro = sqlite3.connect(f"file:{qa}?mode=ro", uri=True)
rows = qaro.execute(
"SELECT event_hash, prev_event_hash, event_type FROM audit_events "
"WHERE event_type='providence_write' ORDER BY ts ASC"
).fetchall()
qaro.close()
assert len(rows) == 2
# Second event's prev_event_hash must be the first event's hash.
assert rows[1][1] == rows[0][0]