diff --git a/arborist/qa/providence_query.py b/arborist/qa/providence_query.py new file mode 100644 index 0000000..8f72bdd --- /dev/null +++ b/arborist/qa/providence_query.py @@ -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