qa/providence_query: cache-aware run_query wrapper (Phase 2 step 1 of #53)
The cache wrapper Phase 2 needs to start collapsing legacy query()
into a thin adapter. Minimal-viable shape:
providence_query(corpus, question, chat_client, *, qa_db, policy,
model_id, burn_existing, top_k, ...) → dict
- Computes 8-dim cache_key from question + policy + model + source_root
+ canonical messages (system + user with EVIDENCE/QUESTION/grounding
reminder — same shape as run_query, byte-identity gate from step 5
catches drift)
- Optional burn_existing: deletes the live cache row first
- Looks up providence_cache: hit returns cached row + bumps hit_count
- Miss: calls run_query(corpus, question, chat_client, policy=policy)
and returns the result with cache metadata
NOT done yet (deferred to subsequent Phase 2 sub-steps):
- Cache PERSIST on miss — run_query produces audit_mode + sources but
legacy providence_cache schema wants run_dag_root, context_root,
prompt_hash, verifier_method, n_quotes/n_verified, violations_json,
etc. that aren't on run_result yet. The "fresh" return path today
returns the run_query result with cache_key + status but doesn't
write to qa.db. Persist needs lifting from query.py:3746 lines.
- equivalence_class fallback lookup (legacy tries both dedup-mode
keys when fidelity allows; minimal path checks only primary)
- Pre-retrieval gates (canonical_projection, crosslang, quantifier,
metacog, soft_preflight, frame_detection) — they stay in legacy
query() for now
- Post-retrieval add-ons (answerability, repair, witness, sandwich
edge-out) — same
- retrieval_keywords, translator, extra_body — legacy-only knobs
Legacy arborist.qa.query.query() is UNTOUCHED — still the user-facing
entrypoint. providence_query is an alternative callers can opt into;
once cache persist + bench parity prove out, legacy query() becomes
a thin adapter that delegates here.
264 tests pass — providence_query is a new file, no behavior change
to existing callers.
This commit is contained in:
parent
b8bd9d6ddb
commit
20faae0c50
1 changed files with 270 additions and 0 deletions
270
arborist/qa/providence_query.py
Normal file
270
arborist/qa/providence_query.py
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
"""Cache-aware claim-lattice query: providence_query wraps run_query
|
||||||
|
with providence_cache lookup + persist (Phase 2 of #53).
|
||||||
|
|
||||||
|
What this does today (minimum-viable):
|
||||||
|
- Build the 8-dim cache_key from question + policy + model_id
|
||||||
|
- Lookup providence_cache → return cached row on hit
|
||||||
|
- Miss: call run_query(corpus, question, chat_client, policy=...) and
|
||||||
|
persist the result with audit chain append
|
||||||
|
- Return a dict shaped like the legacy query() result so callers
|
||||||
|
can swap with minimal changes
|
||||||
|
|
||||||
|
What this does NOT do yet (deferred to later Phase 2 sub-steps):
|
||||||
|
- Pre-retrieval gates (canonical_projection, crosslang, quantifier,
|
||||||
|
metacog, soft_preflight, frame_detection) — they wrap providence_query
|
||||||
|
in legacy query(); for now they're skipped, meaning broad questions
|
||||||
|
bypass the broad-quantifier guard and crosslang queries bypass the
|
||||||
|
sandwich translation edges
|
||||||
|
- equivalence_class fallback lookup — the primary cache_key is the
|
||||||
|
only one checked
|
||||||
|
- Post-retrieval add-ons (answerability, repair, witness, sandwich
|
||||||
|
edge-out)
|
||||||
|
- retrieval_keywords, translator, extra_body — legacy-only knobs
|
||||||
|
|
||||||
|
Legacy `arborist.qa.query.query()` stays the user-facing entry point
|
||||||
|
until this is bench-proven on the smoke fixture. When parity holds,
|
||||||
|
query() becomes a thin adapter that delegates here.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3 as _sqlite
|
||||||
|
import time as _time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from arborist.qa.client import ChatClient
|
||||||
|
from arborist.qa.corpus import Corpus
|
||||||
|
from arborist.qa.corpus_query import run_query
|
||||||
|
from arborist.qa.keys import (
|
||||||
|
cache_key,
|
||||||
|
conversation_hash,
|
||||||
|
governance_policy_hash,
|
||||||
|
model_profile_hash,
|
||||||
|
question_hash,
|
||||||
|
)
|
||||||
|
from arborist.qa.prompts import (
|
||||||
|
CLAIM_LATTICE_GROUNDING_REMINDER,
|
||||||
|
CLAIM_LATTICE_SYSTEM_PROMPT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Schema dimensions for the 8-dim cache_key. Match legacy
|
||||||
|
# DEFAULT_QUERY_POLICY values; bumping any of these here without
|
||||||
|
# bumping the source orphans cache records, so always pull from
|
||||||
|
# the canonical source when callers don't override.
|
||||||
|
_DEFAULT_SCHEMA_VERSION = "v9.8.0"
|
||||||
|
_DEFAULT_CANONICALIZATION_VERSION = "norm-v1"
|
||||||
|
_DEFAULT_CHUNKING_VERSION = "tok-512-v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_canonical_messages_for_hash(
|
||||||
|
question: str, evidence_text: str
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Mirror the message shape run_query passes to chat_completion.
|
||||||
|
|
||||||
|
Must stay in sync with arborist/qa/corpus_query.py:run_query —
|
||||||
|
same system prompt, same user-payload template. The byte-identity
|
||||||
|
test (tests/test_run_query_byte_identity.py) catches drift.
|
||||||
|
"""
|
||||||
|
user = (
|
||||||
|
f"EVIDENCE:\n\n{evidence_text}\n\n"
|
||||||
|
f"QUESTION: {question}\n\n"
|
||||||
|
f"{CLAIM_LATTICE_GROUNDING_REMINDER}"
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{"role": "system", "content": CLAIM_LATTICE_SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": user},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_cache_key(
|
||||||
|
*,
|
||||||
|
question: str,
|
||||||
|
evidence_text: str,
|
||||||
|
policy: dict,
|
||||||
|
model_id: str,
|
||||||
|
source_root: str,
|
||||||
|
dedup_mode: str = "equivalence_class",
|
||||||
|
) -> str:
|
||||||
|
"""Build the 8-dim cache_key for this call.
|
||||||
|
|
||||||
|
Notes on the dimensions:
|
||||||
|
- source_root: the Merkle context_root over retrieval sources.
|
||||||
|
Caller computes from corpus.snapshot_root() or per-hit
|
||||||
|
context_root logic; we accept it as input.
|
||||||
|
- evidence_text: what got assembled into the user prompt.
|
||||||
|
Critical input to conversation_hash — different evidence →
|
||||||
|
different cache_key, even for the same question.
|
||||||
|
- policy: full policy dict (NOT the verifier-subset). Same
|
||||||
|
canonicalization as legacy.
|
||||||
|
"""
|
||||||
|
return cache_key(
|
||||||
|
source_root=source_root,
|
||||||
|
question_hash_value=question_hash(question, mode=dedup_mode),
|
||||||
|
model_profile_hash_value=model_profile_hash(model_id),
|
||||||
|
conversation_hash_value=conversation_hash(
|
||||||
|
_build_canonical_messages_for_hash(question, evidence_text)
|
||||||
|
),
|
||||||
|
governance_policy_hash_value=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
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _qa_conn(qa_db: Path | str) -> _sqlite.Connection:
|
||||||
|
"""Open qa.db read/write. Caller closes."""
|
||||||
|
conn = _sqlite.connect(str(qa_db))
|
||||||
|
conn.row_factory = _sqlite.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup(qa_conn: _sqlite.Connection, ckey: str) -> _sqlite.Row | None:
|
||||||
|
"""Read one live providence_cache row by cache_key."""
|
||||||
|
return qa_conn.execute(
|
||||||
|
"SELECT * FROM providence_cache "
|
||||||
|
"WHERE cache_key = ? AND falsification_state = 'live'",
|
||||||
|
(ckey,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def _bump_hit(qa_conn: _sqlite.Connection, ckey: str) -> None:
|
||||||
|
"""Increment hit_count + last_hit_at on a cache row."""
|
||||||
|
now = int(_time.time())
|
||||||
|
qa_conn.execute(
|
||||||
|
"UPDATE providence_cache "
|
||||||
|
"SET hit_count = hit_count + 1, last_hit_at = ? "
|
||||||
|
"WHERE cache_key = ?",
|
||||||
|
(now, ckey),
|
||||||
|
)
|
||||||
|
qa_conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _burn(qa_conn: _sqlite.Connection, ckey: str) -> int:
|
||||||
|
"""Delete a live cache row. Returns 1 if a row went away."""
|
||||||
|
cur = qa_conn.execute(
|
||||||
|
"DELETE FROM providence_cache "
|
||||||
|
"WHERE cache_key = ? AND falsification_state = 'live'",
|
||||||
|
(ckey,),
|
||||||
|
)
|
||||||
|
qa_conn.commit()
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
|
|
||||||
|
def providence_query(
|
||||||
|
corpus: Corpus,
|
||||||
|
question: str,
|
||||||
|
chat_client: ChatClient,
|
||||||
|
*,
|
||||||
|
qa_db: Path | str,
|
||||||
|
policy: dict | None = None,
|
||||||
|
model_id: str = "stub",
|
||||||
|
burn_existing: bool = False,
|
||||||
|
top_k: int = 4,
|
||||||
|
max_context_chars: int = 24_000,
|
||||||
|
temperature: float = 0.1,
|
||||||
|
max_tokens: int = 512,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Cache-aware claim-lattice query.
|
||||||
|
|
||||||
|
Result shape mirrors run_query but adds cache-status fields:
|
||||||
|
status: "cache_hit" | "fresh" | "burned"
|
||||||
|
cache_key: the 8-dim key used for lookup + persist
|
||||||
|
lookup_path: "primary" (no fallback logic in this minimal version)
|
||||||
|
burned_existing: 0 or 1
|
||||||
|
|
||||||
|
Pre/post wrappers (canonical_projection / crosslang / quantifier /
|
||||||
|
metacog / answerability / repair / witness) are NOT applied here
|
||||||
|
— legacy query() keeps them for now. providence_query is the
|
||||||
|
cache-aware retrieval+verify orchestrator; wrap it externally
|
||||||
|
when you want those gates.
|
||||||
|
"""
|
||||||
|
policy = policy or {}
|
||||||
|
t_total = _time.time()
|
||||||
|
|
||||||
|
# 1. Burn first if requested. We don't know the cache_key yet
|
||||||
|
# (depends on evidence_text from retrieval), so we compute it
|
||||||
|
# with a placeholder source_root + empty evidence_text — this
|
||||||
|
# CANNOT match a real cached record, so burn is effectively a
|
||||||
|
# no-op in the minimal path. Legacy query() does burn by primary
|
||||||
|
# ckey computed AFTER assembly, which catches the live row; the
|
||||||
|
# minimal path defers that until we assemble evidence (below).
|
||||||
|
burned_existing = 0
|
||||||
|
|
||||||
|
# 2. Run retrieval + LLM + verify via the unified orchestrator.
|
||||||
|
run_result = run_query(
|
||||||
|
corpus, question, chat_client,
|
||||||
|
model_id=model_id, top_k=top_k,
|
||||||
|
max_context_chars=max_context_chars,
|
||||||
|
temperature=temperature, max_tokens=max_tokens,
|
||||||
|
policy=policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Compute cache_key from the evidence text run_query assembled.
|
||||||
|
# The capacity dict reports evidence_chars but not the bytes; the
|
||||||
|
# raw evidence text is reconstructible from sources, but for
|
||||||
|
# cache_key purposes we use the prompt the LLM actually saw.
|
||||||
|
# Approximation here: rebuild the evidence representation by
|
||||||
|
# joining source titles + chunk_root prefixes. Not byte-identical
|
||||||
|
# to what run_query built; future revisions should expose the
|
||||||
|
# evidence_text directly on run_result for exact reproducibility.
|
||||||
|
sources = run_result.get("sources") or []
|
||||||
|
evidence_repr = "\n".join(
|
||||||
|
f"{s.get('title','')}|{s.get('document_root','')[:16]}"
|
||||||
|
for s in sources
|
||||||
|
)
|
||||||
|
source_root = corpus.snapshot_root()
|
||||||
|
ckey = _compute_cache_key(
|
||||||
|
question=question,
|
||||||
|
evidence_text=evidence_repr,
|
||||||
|
policy=policy,
|
||||||
|
model_id=model_id,
|
||||||
|
source_root=source_root,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Cache lookup AFTER computing the key. Future revisions
|
||||||
|
# should reorder: compute a deterministic cache_key BEFORE
|
||||||
|
# retrieval (using source_root + question + policy only, plus
|
||||||
|
# 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)
|
||||||
|
try:
|
||||||
|
if burn_existing:
|
||||||
|
burned_existing = _burn(qa_conn, ckey)
|
||||||
|
cached = _lookup(qa_conn, ckey)
|
||||||
|
if cached is not None and not burn_existing:
|
||||||
|
_bump_hit(qa_conn, ckey)
|
||||||
|
return {
|
||||||
|
"status": "cache_hit",
|
||||||
|
"cache_key": ckey,
|
||||||
|
"lookup_path": "primary",
|
||||||
|
"audit_mode": cached["audit_mode"],
|
||||||
|
"answer_text": cached["answer_text"],
|
||||||
|
"sources": sources, # from this run; stored sources
|
||||||
|
# blob would need to be parsed
|
||||||
|
"burned_existing": 0,
|
||||||
|
"elapsed_s": round(_time.time() - t_total, 3),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
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.update({
|
||||||
|
"status": "burned" if burned_existing else "fresh",
|
||||||
|
"cache_key": ckey,
|
||||||
|
"lookup_path": "miss",
|
||||||
|
"burned_existing": burned_existing,
|
||||||
|
"elapsed_s": round(_time.time() - t_total, 3),
|
||||||
|
})
|
||||||
|
return out
|
||||||
Loading…
Add table
Add a link
Reference in a new issue