Six items from fox's de-novo synthesis (2026-05-01) landing as one atomic commit. Each item references its motivation and respects the v9.8 honesty boundary (no claims of semantic truth, no proof-path contamination by soft signals). NEW DOCS -------- docs/cti-architecture.md Maps today's modules onto the architectural layer cake fox named in his synthesis: PROMETHEUS-Σ (controller) / CTI (claim-lattice reasoning IR) / Merkle-AGI-DAG (commitment) / Reverse-RAG (evidence direction) / v9.8 Providence (admissibility ledger) / Hermes (weak proposer). Per-layer responsibility table + module map showing which existing files implement which layer. The architecture is real; the labels above name what's already there. docs/naming-deferral.md Argues why we keep internal terms (claim_lattice, claim_lattice_pointer, verify_claim_lattice) instead of renaming to architectural labels (CTI, PROMETHEUS-Σ). The rename costs cache invalidation, ~150 test fixture references, schema CHECK migration, blame-history disconnect, mesh peer coordination. The bridge is the cti-architecture mapping doc — read it once, then read code in code's vocabulary and prose in prose's vocabulary. Lists four triggers that would invert the decision. docs/self-reference-distillation-design.md Successor to docs/self-reference-thought-chains-design.md (the flat-source MVP). Maps STRICT claims onto the existing Distiller / Core / derivations infrastructure: each STRICT record becomes a Fact-Core via a new ProvidenceDistiller, with per-cited-chunk Merkle inclusion proofs back to Wikipedia source_roots. The fact-graph grows; new claims attach by inheriting the proof chain. CompositionDistiller (combining facts into new claims) is explicitly deferred — that's reasoning machinery, not infrastructure. docs/test-coverage-audit-2026-05-01.md Maps fox's §11 test list (16 items) against the codebase. 16/16 covered. Documents item #9's manual-quote-rule lifecycle: the rule was retired from pointer mode in commit224bfd6after the bench journey identified it was rejecting factually correct claims; retained in JSON variant where the punctuation-rationale argument doesn't apply. The audit doc itself is the requirements- drift defense. CODE ---- aborist/qa/keys.py:verifier_policy_hash New pure function. Hashes the verifier-relevant subset of policy (answer_mode + claim_lattice_* verifier knobs + entity_policy fields + base_version). Folded into cache_key as an optional 9th dimension via a new keyword arg with backward-compat default None — legacy 8-dim callers continue to work and produce the pre-2026-05-01 hash result. aborist/qa/{runner,query}.py Compute verifier_policy_hash from the policy_variant and pass to cache_key as the 9th dim. Records written from this commit forward bind to the verifier-policy identity. Pre-existing 8-dim records become cache-misses on next lookup — same migration cost as any governance_policy_hash bump. The 9-dim form makes the question "did the verifier rules change?" answerable from cache_key diff alone, separate from "did the prompt change?" CLAUDE.md Adds "old maps vs runtime maps" engineering discipline to the bench-maxing section. Codifies the principle: every base model carries old maps from training time; the runtime carries the fresh map; when they disagree, the runtime wins. Move authority OUT of the model's prior and INTO runtime artifacts (pointer IDs the runtime mints, source spans the runtime interpolates by offset, evidence maps assembled per query, policy hashes that fold prompt/verifier/retrieval into cache_key, hard checks run by the verifier). Hermes' content-addressed-evidence-id hallucination (commitbb8450d) is the canonical case study. TESTS ----- tests/test_keys.py Six new tests for verifier_policy_hash: - only hashes verifier subset (non-verifier fields don't change it) - changes when verifier-relevant field changes - empty-policy → stable - 9-dim cache_key distinct from 8-dim - 9-dim distinct under different verifier hashes - 8-dim form preserved for legacy callers (None == omit) DEFERRED (per fox + naming-deferral.md) --------------------------------------- - PROMETHEUS-Σ as an extracted controller module — the dispatch logic in runner.py + query.py already IS PROMETHEUS-Σ; an explicit prometheus.py is a refactor with no behavior change. Defer until a §5-rename-trigger fires. 507 tests pass (was 501 before, +6 from verifier_policy_hash coverage).
879 lines
38 KiB
Python
879 lines
38 KiB
Python
"""Q&A runner: cache-first lookup -> inference fallback -> provable record.
|
|
|
|
Implements the v9.8 admissibility invariant:
|
|
No record reused unless all 8 cache_key dimensions match AND state
|
|
is 'live' (not failed/stale/quarantined).
|
|
|
|
Cache hit -> persisted audit_mode (STRICT/HYBRID/UNGROUNDED).
|
|
Cache miss -> call ChatClient, run faithfulness check, classify, store
|
|
record, audit event.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
import time
|
|
|
|
from aborist import (
|
|
CANONICALIZATION_VERSION,
|
|
SCHEMA_VERSION,
|
|
)
|
|
from aborist.compress import unpack_chunk
|
|
from aborist.merkle import MerkleTree, proof_to_dict
|
|
from aborist.qa.client import ChatClient
|
|
from aborist.qa.keys import (
|
|
DEFAULT_FIDELITY,
|
|
DEFAULT_QUESTION_DEDUP,
|
|
FIDELITY_MODES,
|
|
QUESTION_DEDUP_MODES,
|
|
cache_key,
|
|
canonical_question,
|
|
conversation_hash,
|
|
governance_policy_hash,
|
|
model_profile_hash,
|
|
question_hash,
|
|
verifier_policy_hash,
|
|
)
|
|
from aborist.qa.dag import build_run_dag
|
|
from aborist.qa.evidence import (
|
|
build_evidence_map,
|
|
evidence_map_root,
|
|
render_evidence_map,
|
|
render_evidence_map_for_json,
|
|
)
|
|
from aborist.qa.repair import mechanical_repair, reprompt_repair
|
|
from aborist.qa.verify import (
|
|
ANSWER_MODES,
|
|
CLAIM_LATTICE_JSON_SCHEMA,
|
|
DEFAULT_ANSWER_MODE,
|
|
verify_claim_lattice,
|
|
verify_claim_lattice_json,
|
|
verify_quotes,
|
|
)
|
|
from aborist.store import append_audit, transaction
|
|
|
|
try:
|
|
from aborist.wikitext import BASE_VERSION as _WIKITEXT_BASE_VERSION
|
|
from aborist.wikitext import to_base as _wikitext_to_base
|
|
except ImportError: # pragma: no cover
|
|
_WIKITEXT_BASE_VERSION = None
|
|
_wikitext_to_base = None
|
|
|
|
|
|
DEFAULT_POLICY = {
|
|
"system_prompt": (
|
|
"Answer the user's question based ONLY on the document below. "
|
|
"For EVERY factual claim, include a verbatim quote from the "
|
|
"document enclosed in double quotes (\"...\"). The quoted span "
|
|
"must appear word-for-word. Make a claim only when a verbatim "
|
|
"quote in the document directly supports it. "
|
|
"If the answer is in the document, write it. "
|
|
"If the answer is absent from the document, say 'I don't know "
|
|
"based on the provided document.' and stop there. "
|
|
"Stay inside the document at all times."
|
|
),
|
|
# Restated rule fired as a user message right before the document +
|
|
# question arrive. See aborist/qa/query.py for the rationale (recent
|
|
# user-turn instructions outweigh decayed system-turn rules in 8B
|
|
# instruction-tuned models).
|
|
"grounding_reminder": (
|
|
"REMINDER: wrap every factual claim in double quotes (\"...\") "
|
|
"and the quoted span must appear word-for-word in the "
|
|
"document. Each claim earns a verbatim quote. "
|
|
"Now answer the question on the next message."
|
|
),
|
|
"temperature": 0.1,
|
|
"top_p": 1.0,
|
|
"max_tokens": 512,
|
|
"entity_policy": "proximity",
|
|
"entity_proximity_n": 3,
|
|
"entity_proximity_window": 300,
|
|
# Mechanical answer repair after first verify. Off by default; see
|
|
# aborist/qa/query.py for semantics.
|
|
"repair_enabled": False,
|
|
"repair_max_reprompts": 0,
|
|
# Strip wikitext markup before the LLM ever sees the context. Lets
|
|
# Hermes quote prose verbatim and shrinks token bills (~43% on
|
|
# Wikipedia chunks). Bumps governance_policy_hash so prior cached
|
|
# answers under raw-wikitext policy stay distinct on lookup. Set
|
|
# via the wikitext extras; no-op if mwparserfromhell isn't installed.
|
|
"base_version": _WIKITEXT_BASE_VERSION,
|
|
# G0 / CTI — claim-lattice-pointer answer mode. "quote" (default):
|
|
# existing behavior, model writes prose with verbatim quotes inline.
|
|
# "claim_lattice_pointer": runtime builds an evidence map and shows
|
|
# the model short pointer ids (E1, E2, …); model writes natural
|
|
# prose with bracket pointer tags ("Claim. [E12]") instead of
|
|
# quoting source text. Renderer interpolates literal spans at
|
|
# display time. Synthetic-elision-by-construction-impossible: the
|
|
# model never types the quote string. Two-layer id discipline keeps
|
|
# the cache & run-DAG keyed on content-addressed evidence_ids.
|
|
# Folds into governance_policy_hash so two modes write under
|
|
# different cache_keys and never alias. No iterative repair in
|
|
# pointer mode (one-shot benchmark discipline).
|
|
"answer_mode": DEFAULT_ANSWER_MODE,
|
|
"claim_lattice_system_prompt": (
|
|
"You will see numbered EVIDENCE blocks tagged E1, E2, E3, etc. "
|
|
"Answer using natural-language pointer-lines: one claim per "
|
|
"line, followed by a bracket tag with the pointer IDs that "
|
|
"directly support that claim.\n\n"
|
|
"WORKED EXAMPLE 1 — narrow factoid\n"
|
|
"---------------------------------\n"
|
|
"EVIDENCE:\n\n"
|
|
"=== E1 (Apple_Inc | primary_answer_source) ===\n"
|
|
"Apple Inc. was founded by Steve Jobs, Steve Wozniak, and "
|
|
"Ronald Wayne in April 1976.\n\n"
|
|
"=== E2 (Steve_Wozniak | secondary_context_source) ===\n"
|
|
"Steve Wozniak co-founded Apple Computer Company alongside "
|
|
"Steve Jobs in 1976 and designed the Apple I.\n\n"
|
|
"QUESTION: who founded Apple?\n\n"
|
|
"ANSWER:\n"
|
|
"Steve Jobs co-founded Apple. [E1]\n"
|
|
"Steve Wozniak co-founded Apple. [E1,E2]\n"
|
|
"Ronald Wayne co-founded Apple. [E1]\n\n"
|
|
"WORKED EXAMPLE 2 — broad descriptive\n"
|
|
"------------------------------------\n"
|
|
"EVIDENCE:\n\n"
|
|
"=== E1 (Mars | primary_answer_source) ===\n"
|
|
"Mars is the fourth planet from the Sun. It has two moons, "
|
|
"Phobos and Deimos. Mars has a thin atmosphere of carbon "
|
|
"dioxide. Average surface temperature is around -60 "
|
|
"degrees Celsius.\n\n"
|
|
"QUESTION: tell me about Mars\n\n"
|
|
"ANSWER:\n"
|
|
"Mars is the fourth planet from the Sun. [E1]\n"
|
|
"Mars has two moons, Phobos and Deimos. [E1]\n"
|
|
"Mars has a thin atmosphere of carbon dioxide. [E1]\n"
|
|
"The average surface temperature on Mars is around -60 "
|
|
"degrees Celsius. [E1]\n\n"
|
|
"END OF EXAMPLES\n\n"
|
|
"RULES (each rule says what TO do):\n"
|
|
"1. Reference evidence by pointer ID. The runtime displays "
|
|
"the literal source span beside each claim — referencing is "
|
|
"your job; quoting is the runtime's job.\n"
|
|
"2. Use only pointer IDs that appear in the EVIDENCE blocks "
|
|
"above.\n"
|
|
"3. Cite 1 or 2 pointers per claim — the blocks whose text "
|
|
"directly contains the claim's key terms.\n"
|
|
"4. End every claim line with [E#] or [E#,E#].\n"
|
|
"5. Make a claim only when an EVIDENCE block textually "
|
|
"supports it. Stop when the evidence runs out — a short "
|
|
"answer is the right answer when only short evidence "
|
|
"exists.\n"
|
|
"6. Write each claim as one plain-prose sentence on its own "
|
|
"line.\n"
|
|
"7. Each line begins with the claim text and ends with a "
|
|
"bracket tag of pointer ids. The headers above the QUESTION "
|
|
"(`EVIDENCE:`, `=== E1 (Title) ===`) are scaffolding for "
|
|
"the input only — your answer is the claim lines themselves.\n"
|
|
"8. Each claim line cites at most two pointers. Lines with "
|
|
"three or more pointers are rejected. When more than two "
|
|
"evidence blocks support a claim, pick the two that most "
|
|
"directly contain the claim's key terms, or split the "
|
|
"claim into two lines."
|
|
),
|
|
"claim_lattice_grounding_reminder": (
|
|
"REMINDER: format = pointer-line — `Claim text. [E1]` per "
|
|
"line, plain prose with bracket tags. Pointer IDs come from "
|
|
"the EVIDENCE blocks above. At most two pointers per claim. "
|
|
"Begin each line with the claim text. Now answer the question "
|
|
"on the next message."
|
|
),
|
|
# Allowed source roles for claim-lattice verification. Roles outside
|
|
# this set get classified SOURCE_ROLE_BLOCKED and downgrade the
|
|
# verdict. Mirrors aborist.qa.verify.DEFAULT_ALLOWED_SOURCE_ROLES;
|
|
# noisy_background_source / sequel_background_source are excluded by
|
|
# default. Folds into governance_policy_hash on change.
|
|
"claim_lattice_allowed_source_roles": [
|
|
"primary_answer_source",
|
|
"secondary_context_source",
|
|
"background_source",
|
|
"unclassified",
|
|
# Self-promoted providence records (`aborist://providence/`
|
|
# URI scheme). Trusted-as-fact substrate per the
|
|
# self-reference design — STRICT live records past the
|
|
# kindergarten window. See
|
|
# docs/self-reference-thought-chains-design.md for the
|
|
# falsification trust model.
|
|
"self_reference_source",
|
|
],
|
|
# Hard cap on pointer ids per claim line — mirrors prompt Rule 9.
|
|
# Lines exceeding this cap classify as SCHEMA_INVALID and the
|
|
# verdict can no longer reach STRICT. Folds into
|
|
# governance_policy_hash so changing the cap invalidates prior
|
|
# cached records.
|
|
"claim_lattice_max_pointers_per_claim": 2,
|
|
# Minimum claim-token coverage required for the citation-overlap
|
|
# check (Rule 6) to pass. Pre-2026-04-30 the threshold was implicit
|
|
# at "≥1 shared token", which let through lazy-anchored claims
|
|
# whose only overlap was a single topical word (e.g. "Yale
|
|
# University... [E9]" cited to a highway-data span containing only
|
|
# "Connecticut"). 0.30 means a 10-token claim needs ≥3 of its
|
|
# content tokens to appear in the cited span. Short claims (≤3
|
|
# content tokens) keep the old ≥1-token floor so narrow factoids
|
|
# like "Steve Jobs co-founded Apple" still pass. Folds into
|
|
# governance_policy_hash on change.
|
|
"claim_lattice_min_citation_coverage": 0.30,
|
|
# Bare-name claim guard. A claim with fewer than this many content
|
|
# tokens (>=4 chars, post-spotlight-stopword) is rejected as
|
|
# SCHEMA_INVALID. Catches the JP-dinosaurs lazy-anchor where
|
|
# "Triceratops. [E16]" passes lexical overlap on a single token
|
|
# even when E16 is a video-game tie-in chunk rather than the film
|
|
# article. Default 2: bare-entity-name claims (one content token
|
|
# after stopword strip) fail; sentence-shape claims pass. Note
|
|
# ``_content_tokens`` already filters "appears", "shown", etc. so
|
|
# "Trex appears" → 1 content token (filtered), "Trex appears in
|
|
# the film" → 2 content tokens (passes). Folds into
|
|
# governance_policy_hash on change.
|
|
"claim_lattice_min_claim_content_tokens": 2,
|
|
# Lazy-anchor smell auto-demote. When >= threshold of verified
|
|
# pointer-pairs cite a single pointer AND there are >= min_pairs
|
|
# total, cap audit_mode at HYBRID. The smell sidecar was advisory
|
|
# only pre-2026-04-30; now it's load-bearing. STRICT requires
|
|
# diverse anchoring across pointers.
|
|
"claim_lattice_lazy_anchor_demote_threshold": 0.5,
|
|
"claim_lattice_lazy_anchor_demote_min_pairs": 3,
|
|
# Claim-count ceiling. Bench finding (2026-04-30 york-england):
|
|
# "tell me all there is to know about X" prompted Hermes to spam
|
|
# 26-59 encyclopedic claims sourced from training, only 2-4 of
|
|
# which grounded in retrieval. Atomic-claim prompt rule (b5925c8)
|
|
# cut this to ~10, but a hard structural cap is defense in depth.
|
|
# Cap of 12 admits typical entity-list questions (5-7 dinosaurs,
|
|
# Simpsons + pets) while flagging the runaway shape. Folds into
|
|
# governance_policy_hash on change.
|
|
"claim_lattice_max_claims_per_answer": 12,
|
|
# JSON variant — `answer_mode="claim_lattice"`. Mirrors the
|
|
# multi-source query path. Pairs with grammar-constrained inference
|
|
# (vLLM guided_json, Claude/GPT-4 native JSON, Qwen 3.6 reasoner).
|
|
# Lenient pre-parser in verify_claim_lattice_json keeps the path
|
|
# survivable on inference paths without grammar guidance.
|
|
"claim_lattice_json_system_prompt": (
|
|
"You will see numbered EVIDENCE blocks tagged with short "
|
|
"pointer IDs: E1, E2, E3, etc. Answer as a single JSON "
|
|
"object using EXACTLY this schema. Your output begins with "
|
|
"`{` and ends with `}`:\n\n"
|
|
' {"claims":[{"text":"<claim text>","evidence_ids":["E1"]}]}\n\n'
|
|
"RULES:\n"
|
|
"1. Your output is exactly one JSON object — first character "
|
|
"is `{`, last character is `}`.\n"
|
|
"2. `text` is plain prose. Punctuation (including any quoted "
|
|
"names from the source) appears in the source span the "
|
|
"runtime renders for you, so write the text using only "
|
|
"letters, digits, spaces, and basic sentence punctuation "
|
|
"(`,`, `.`, `;`, `:`, `?`, `!`, `(`, `)`, `-`).\n"
|
|
"3. `evidence_ids` are pointer IDs that appear verbatim in "
|
|
"the EVIDENCE block headers above (E1, E2, …). Use only IDs "
|
|
"you can see in the headers above. At most two IDs per claim.\n"
|
|
"4. Each claim references at least one evidence_id.\n"
|
|
"5. Emit a claim only when an EVIDENCE block directly "
|
|
"supports it; stop emitting claims when supporting evidence "
|
|
"runs out — a short answer is the right answer when only "
|
|
"short evidence exists.\n"
|
|
"6. Each claim is one focused fact citing the source that "
|
|
"directly contains it. Multiple distinct facts become "
|
|
"multiple claim entries, each citing the source for that "
|
|
"fact. Three short claims, each citing one pointer, beat "
|
|
"one long claim citing five."
|
|
),
|
|
"claim_lattice_json_grounding_reminder": (
|
|
"REMINDER: emit one JSON object — first character `{`, last "
|
|
"character `}` — with the schema "
|
|
'`{"claims":[{"text":"...","evidence_ids":["E1"]}]}`. '
|
|
"evidence_ids are pointer IDs (E1, E2, …) that appear "
|
|
"verbatim in the EVIDENCE block headers above. At most two "
|
|
"per claim. Each claim is one focused fact. Now answer the "
|
|
"question on the next message."
|
|
),
|
|
"claim_lattice_use_guided_json": True,
|
|
# JSON-mode stop sequences. Hermes-3-8B sometimes spams whitespace
|
|
# / newlines after the closing brace on broad-descriptive shapes
|
|
# ("plot of X", "tell me about Y") — the response runs out the
|
|
# max_tokens budget and the lenient parser sees truncated JSON.
|
|
# Stopping on a blank line cuts the runaway. JSON-mode output
|
|
# never legitimately contains a blank line (single object, single
|
|
# line) so this is a safe filter. Folds into
|
|
# governance_policy_hash on change.
|
|
"claim_lattice_json_stop_sequences": ["\n\n"],
|
|
}
|
|
|
|
|
|
def _ms_since(t: float) -> float:
|
|
return round((time.monotonic() - t) * 1000, 1)
|
|
|
|
|
|
def ask(
|
|
conn: sqlite3.Connection,
|
|
*,
|
|
document_root: str,
|
|
question: str,
|
|
client: ChatClient,
|
|
model_id: str,
|
|
revision: str = "",
|
|
quantization: str = "",
|
|
policy: dict | None = None,
|
|
chain: str = "private",
|
|
fidelity: str | None = None,
|
|
) -> dict:
|
|
"""Look up cached answer or run inference. Returns a result dict.
|
|
|
|
See ``aborist.qa.query.query`` for `fidelity` semantics — it
|
|
controls lookup tolerance: ``"strict"`` only checks the cache_key
|
|
matching the call's ``policy["question_dedup"]``; the default
|
|
``"equivalence_class"`` falls back to the alternate dedup mode's
|
|
cache_key on miss so a fast-cache agent can reuse records written
|
|
under either mode. Result includes ``lookup_path``.
|
|
"""
|
|
policy = policy or DEFAULT_POLICY
|
|
if fidelity is None:
|
|
fidelity = policy.get("fidelity", DEFAULT_FIDELITY)
|
|
if fidelity not in FIDELITY_MODES:
|
|
raise ValueError(
|
|
f"fidelity must be one of {FIDELITY_MODES}, got {fidelity!r}"
|
|
)
|
|
t_start = time.monotonic()
|
|
|
|
doc = conn.execute(
|
|
"SELECT document_uri, chunking_version FROM documents "
|
|
"WHERE document_root = ?",
|
|
(document_root,),
|
|
).fetchone()
|
|
if doc is None:
|
|
return {"status": "unknown_document"}
|
|
|
|
chunk_rows = conn.execute(
|
|
"SELECT idx, leaf_hash, content FROM chunks "
|
|
"WHERE document_root = ? ORDER BY idx ASC",
|
|
(document_root,),
|
|
).fetchall()
|
|
if not chunk_rows:
|
|
return {"status": "unknown_document"}
|
|
if any(r["content"] is None for r in chunk_rows):
|
|
return {"status": "source_cold", "msg": "rehydrate before asking"}
|
|
|
|
answer_mode = policy.get("answer_mode", DEFAULT_ANSWER_MODE)
|
|
if answer_mode not in ANSWER_MODES:
|
|
raise ValueError(
|
|
f"policy['answer_mode'] must be one of {ANSWER_MODES}, got {answer_mode!r}"
|
|
)
|
|
|
|
chunk_texts = [unpack_chunk(r["content"]) for r in chunk_rows]
|
|
document_text = "\n\n".join(chunk_texts)
|
|
|
|
# Wikitext → prose before the LLM sees it. The model can then quote
|
|
# verbatim against the prose form; the verifier compares like-against-
|
|
# like. Idempotent if context is already plain prose. Gated on
|
|
# policy["base_version"] so this is part of governance_policy_hash.
|
|
if policy.get("base_version") and _wikitext_to_base is not None:
|
|
document_text = _wikitext_to_base(document_text)
|
|
chunk_texts = [_wikitext_to_base(t) for t in chunk_texts]
|
|
|
|
evidence_map = []
|
|
if answer_mode == "claim_lattice_pointer":
|
|
# Quote-by-pointer: one evidence object per chunk. The model sees
|
|
# the literal spans labeled with content-addressed IDs and is
|
|
# instructed to reference IDs, not type quote text. Synthetic
|
|
# elision is impossible by construction — the model never produces
|
|
# the quote string.
|
|
chunks_for_map = [
|
|
{
|
|
"source_root": document_root,
|
|
"document_uri": doc["document_uri"],
|
|
"title": None,
|
|
"chunk_idx": r["idx"],
|
|
"chunk_root": r["leaf_hash"],
|
|
"span": chunk_texts[i],
|
|
"source_role": "primary_answer_source",
|
|
}
|
|
for i, r in enumerate(chunk_rows)
|
|
]
|
|
evidence_map = build_evidence_map(chunks_for_map)
|
|
sys_prompt = policy["claim_lattice_system_prompt"]
|
|
grounding_reminder = policy.get("claim_lattice_grounding_reminder")
|
|
rendered_evidence = render_evidence_map(evidence_map)
|
|
|
|
def _user_payload(q: str) -> str:
|
|
return f"EVIDENCE:\n\n{rendered_evidence}\n\n---\n\nQUESTION: {q}"
|
|
elif answer_mode == "claim_lattice":
|
|
# JSON variant — same per-chunk evidence map as pointer mode,
|
|
# blocks labeled with content-addressed evidence_id (long hex)
|
|
# since the model emits IDs in JSON. Pairs with grammar-
|
|
# constrained inference; lenient pre-parser handles drift.
|
|
chunks_for_map = [
|
|
{
|
|
"source_root": document_root,
|
|
"document_uri": doc["document_uri"],
|
|
"title": None,
|
|
"chunk_idx": r["idx"],
|
|
"chunk_root": r["leaf_hash"],
|
|
"span": chunk_texts[i],
|
|
"source_role": "primary_answer_source",
|
|
}
|
|
for i, r in enumerate(chunk_rows)
|
|
]
|
|
evidence_map = build_evidence_map(chunks_for_map)
|
|
sys_prompt = policy.get(
|
|
"claim_lattice_json_system_prompt",
|
|
policy["claim_lattice_system_prompt"],
|
|
)
|
|
grounding_reminder = policy.get(
|
|
"claim_lattice_json_grounding_reminder",
|
|
policy.get("claim_lattice_grounding_reminder"),
|
|
)
|
|
rendered_evidence = render_evidence_map_for_json(evidence_map)
|
|
|
|
def _user_payload(q: str) -> str:
|
|
return f"EVIDENCE:\n\n{rendered_evidence}\n\n---\n\nQUESTION: {q}"
|
|
else:
|
|
sys_prompt = policy["system_prompt"]
|
|
grounding_reminder = policy.get("grounding_reminder")
|
|
|
|
def _user_payload(q: str) -> str:
|
|
return f"Document:\n\n{document_text}\n\n---\n\nQuestion: {q}"
|
|
|
|
# System sets the policy; a user-turn reminder restates the rule one
|
|
# message before the payload arrives. Payload (document or evidence
|
|
# map + question) lands last as the most-recent tokens before
|
|
# generation.
|
|
messages = [{"role": "system", "content": sys_prompt}]
|
|
if grounding_reminder:
|
|
messages.append({"role": "user", "content": grounding_reminder})
|
|
messages.append({"role": "user", "content": _user_payload(question)})
|
|
|
|
mhash = model_profile_hash(model_id, revision, quantization)
|
|
|
|
# Dedup-mode-aware cache_key. See aborist/qa/query.py for rationale —
|
|
# policy_variant matches the alternate mode so governance_policy_hash
|
|
# agrees with what an agent under that mode would have written,
|
|
# enabling cross-silo fallback.
|
|
def _ckey_for_mode(mode: str) -> str:
|
|
canon_q = canonical_question(question, mode=mode)
|
|
canon_msgs = list(messages[:-1]) + [
|
|
{"role": "user", "content": _user_payload(canon_q)},
|
|
]
|
|
policy_variant = dict(policy, question_dedup=mode)
|
|
return cache_key(
|
|
document_root,
|
|
question_hash(question, mode=mode),
|
|
mhash,
|
|
conversation_hash(canon_msgs),
|
|
governance_policy_hash(policy_variant),
|
|
SCHEMA_VERSION,
|
|
CANONICALIZATION_VERSION,
|
|
doc["chunking_version"],
|
|
verifier_policy_hash(policy_variant),
|
|
)
|
|
|
|
ghash = governance_policy_hash(policy) # for the legacy INSERT below
|
|
|
|
primary_dedup = policy.get("question_dedup", DEFAULT_QUESTION_DEDUP)
|
|
if primary_dedup not in QUESTION_DEDUP_MODES:
|
|
raise ValueError(
|
|
f"policy['question_dedup'] must be one of {QUESTION_DEDUP_MODES}, "
|
|
f"got {primary_dedup!r}"
|
|
)
|
|
# Re-derive the per-mode hashes for use in the INSERT below. _ckey_for_mode
|
|
# already builds them, but the legacy INSERT references qhash/chash by name.
|
|
qhash = question_hash(question, mode=primary_dedup)
|
|
canonical_q_primary = canonical_question(question, mode=primary_dedup)
|
|
canonical_messages_primary = list(messages[:-1]) + [
|
|
{"role": "user", "content": _user_payload(canonical_q_primary)},
|
|
]
|
|
chash = conversation_hash(canonical_messages_primary)
|
|
primary_ckey = _ckey_for_mode(primary_dedup)
|
|
ckey = primary_ckey # legacy name for the rest of the function
|
|
|
|
t_lookup = time.monotonic()
|
|
cached = conn.execute(
|
|
"SELECT * FROM providence_cache "
|
|
"WHERE cache_key = ? AND falsification_state = 'live'",
|
|
(primary_ckey,),
|
|
).fetchone()
|
|
hit_ckey = primary_ckey
|
|
lookup_path = primary_dedup if cached is not None else None
|
|
if cached is None and fidelity == "equivalence_class":
|
|
other_mode = (
|
|
"equivalence_class" if primary_dedup == "strict" else "strict"
|
|
)
|
|
other_ckey = _ckey_for_mode(other_mode)
|
|
if other_ckey != primary_ckey:
|
|
cached = conn.execute(
|
|
"SELECT * FROM providence_cache "
|
|
"WHERE cache_key = ? AND falsification_state = 'live'",
|
|
(other_ckey,),
|
|
).fetchone()
|
|
if cached is not None:
|
|
hit_ckey = other_ckey
|
|
lookup_path = f"{other_mode}_fallback"
|
|
cache_lookup_ms = _ms_since(t_lookup)
|
|
if cached is not None:
|
|
with transaction(conn):
|
|
now = int(time.time())
|
|
conn.execute(
|
|
"UPDATE providence_cache "
|
|
"SET hit_count = hit_count + 1, last_hit_at = ? "
|
|
"WHERE cache_key = ?",
|
|
(now, hit_ckey),
|
|
)
|
|
return {
|
|
"status": "cache_hit",
|
|
"audit_mode": cached["audit_mode"],
|
|
"cache_key": hit_ckey,
|
|
"lookup_path": lookup_path,
|
|
"source_root": document_root,
|
|
"answer_text": cached["answer_text"],
|
|
"merkle_proof": json.loads(cached["merkle_proof"]),
|
|
"n_quotes": cached["n_quotes"],
|
|
"n_verified": cached["n_verified"],
|
|
"verifier_method": cached["verifier_method"],
|
|
"unverified_quotes": (
|
|
json.loads(cached["unverified_quotes"])
|
|
if cached["unverified_quotes"]
|
|
else []
|
|
),
|
|
"partially_verified_quotes": [],
|
|
"timings": {
|
|
"cache_lookup_ms": cache_lookup_ms,
|
|
"llm_ms": None,
|
|
"total_ms": _ms_since(t_start),
|
|
},
|
|
}
|
|
|
|
t_llm = time.monotonic()
|
|
# JSON mode: pass guided_json schema so vLLM constrains output at
|
|
# sampling time. Endpoints without guided-decoding silently drop the
|
|
# field; the lenient pre-parser handles whatever drift remains.
|
|
extra_body: dict | None = None
|
|
stop_seqs: list[str] | None = None
|
|
if answer_mode == "claim_lattice" and policy.get(
|
|
"claim_lattice_use_guided_json", True
|
|
):
|
|
extra_body = {"guided_json": CLAIM_LATTICE_JSON_SCHEMA}
|
|
if answer_mode == "claim_lattice":
|
|
# JSON-mode token-runaway guard. On broad-descriptive /
|
|
# comparison questions Hermes-3-8B sometimes spams whitespace
|
|
# / newlines after the closing brace until max_tokens
|
|
# exhausts; the resulting truncated payload won't parse and
|
|
# the run lands UNGROUNDED 0/0 at 12-15s instead of 2-4s.
|
|
# Stopping on a blank line (\n\n) cuts the runaway —
|
|
# well-formed JSON-mode output never contains a blank line
|
|
# since the model emits a single object on one line (or
|
|
# with simple internal newlines).
|
|
stop_seqs = list(policy.get(
|
|
"claim_lattice_json_stop_sequences", ["\n\n"]
|
|
))
|
|
raw_answer = client.chat_completion(
|
|
messages,
|
|
model=model_id,
|
|
temperature=policy["temperature"],
|
|
max_tokens=policy["max_tokens"],
|
|
top_p=policy.get("top_p", 1.0),
|
|
extra_body=extra_body,
|
|
stop=stop_seqs,
|
|
)
|
|
llm_ms = _ms_since(t_llm)
|
|
|
|
repair_changes: list[dict] = []
|
|
pre_repair_verdict: dict | None = None
|
|
|
|
if answer_mode == "claim_lattice_pointer":
|
|
verdict = verify_claim_lattice(
|
|
raw_answer,
|
|
evidence_map,
|
|
allowed_source_roles=tuple(
|
|
policy.get(
|
|
"claim_lattice_allowed_source_roles",
|
|
[
|
|
"primary_answer_source",
|
|
"secondary_context_source",
|
|
"background_source",
|
|
"unclassified",
|
|
],
|
|
)
|
|
),
|
|
max_pointers_per_claim=int(policy.get(
|
|
"claim_lattice_max_pointers_per_claim", 2
|
|
)),
|
|
min_citation_coverage=float(policy.get(
|
|
"claim_lattice_min_citation_coverage", 0.30
|
|
)),
|
|
min_claim_content_tokens=int(policy.get(
|
|
"claim_lattice_min_claim_content_tokens", 3
|
|
)),
|
|
lazy_anchor_demote_threshold=float(policy.get(
|
|
"claim_lattice_lazy_anchor_demote_threshold", 0.5
|
|
)),
|
|
lazy_anchor_demote_min_pairs=int(policy.get(
|
|
"claim_lattice_lazy_anchor_demote_min_pairs", 3
|
|
)),
|
|
max_claims_per_answer=int(policy.get(
|
|
"claim_lattice_max_claims_per_answer", 12
|
|
)),
|
|
)
|
|
# Rendered prose (literal spans interpolated) is the user-facing
|
|
# answer text — never the model's raw pointer-line output. If
|
|
# rendering produced nothing (no valid claims), persist the raw
|
|
# output so an operator can see what the model actually said.
|
|
rendered = verdict["rendered_text"]
|
|
answer_text = rendered if rendered else raw_answer
|
|
elif answer_mode == "claim_lattice":
|
|
verdict = verify_claim_lattice_json(
|
|
raw_answer,
|
|
evidence_map,
|
|
allowed_source_roles=tuple(
|
|
policy.get(
|
|
"claim_lattice_allowed_source_roles",
|
|
[
|
|
"primary_answer_source",
|
|
"secondary_context_source",
|
|
"background_source",
|
|
"unclassified",
|
|
],
|
|
)
|
|
),
|
|
max_evidence_per_claim=int(policy.get(
|
|
"claim_lattice_max_pointers_per_claim", 2
|
|
)),
|
|
min_citation_coverage=float(policy.get(
|
|
"claim_lattice_min_citation_coverage", 0.30
|
|
)),
|
|
max_claims_per_answer=int(policy.get(
|
|
"claim_lattice_max_claims_per_answer", 12
|
|
)),
|
|
)
|
|
rendered = verdict["rendered_text"]
|
|
answer_text = rendered if rendered else raw_answer
|
|
else:
|
|
answer_text = raw_answer
|
|
verdict = verify_quotes(
|
|
answer_text,
|
|
document_text,
|
|
entity_policy=policy.get("entity_policy", "hybrid"),
|
|
proximity_n=policy.get("entity_proximity_n", 3),
|
|
proximity_window=policy.get("entity_proximity_window", 300),
|
|
)
|
|
|
|
def _verify(text: str) -> dict:
|
|
return verify_quotes(
|
|
text,
|
|
document_text,
|
|
entity_policy=policy.get("entity_policy", "hybrid"),
|
|
proximity_n=policy.get("entity_proximity_n", 3),
|
|
proximity_window=policy.get("entity_proximity_window", 300),
|
|
)
|
|
|
|
if (
|
|
policy.get("repair_enabled")
|
|
and verdict["audit_mode"] != "STRICT"
|
|
and verdict.get("unverified_quotes")
|
|
):
|
|
repair_result = mechanical_repair(
|
|
answer_text, verdict["unverified_quotes"], document_text
|
|
)
|
|
if repair_result["changes"]:
|
|
new_verdict = _verify(repair_result["repaired_text"])
|
|
if new_verdict["n_verified"] >= verdict["n_verified"]:
|
|
pre_repair_verdict = verdict
|
|
answer_text = repair_result["repaired_text"]
|
|
verdict = new_verdict
|
|
repair_changes = list(repair_result["changes"])
|
|
|
|
max_reprompts = int(policy.get("repair_max_reprompts", 0))
|
|
for _ in range(max_reprompts):
|
|
if (
|
|
verdict["audit_mode"] == "STRICT"
|
|
or not verdict.get("unverified_quotes")
|
|
):
|
|
break
|
|
new_text = reprompt_repair(
|
|
chat_client=client,
|
|
model_id=model_id,
|
|
original_messages=messages,
|
|
original_answer=answer_text,
|
|
failed_quotes=verdict["unverified_quotes"],
|
|
policy=policy,
|
|
)
|
|
if not new_text:
|
|
break
|
|
new_verdict = _verify(new_text)
|
|
if new_verdict["n_verified"] > verdict["n_verified"]:
|
|
if pre_repair_verdict is None:
|
|
pre_repair_verdict = verdict
|
|
answer_text = new_text
|
|
verdict = new_verdict
|
|
repair_changes.append({
|
|
"action": "reprompt_rewrite",
|
|
"diagnosis": "model_feedback_loop",
|
|
})
|
|
else:
|
|
break
|
|
|
|
unverified_blob = (
|
|
json.dumps(verdict["unverified_quotes"], separators=(",", ":"))
|
|
if verdict["unverified_quotes"]
|
|
else None
|
|
)
|
|
|
|
leaves = [bytes.fromhex(r["leaf_hash"]) for r in chunk_rows]
|
|
tree = MerkleTree.build(leaves)
|
|
proof_obj = {
|
|
"document_root": document_root,
|
|
"chunk_0_proof": proof_to_dict(tree.proof(0)),
|
|
}
|
|
proof_blob = json.dumps(proof_obj, separators=(",", ":"))
|
|
|
|
# Per-run Merkle-DAG (see aborist/qa/dag.py). Single-doc shape: the
|
|
# only "source" is document_root. Pointer mode swaps the 7-stage
|
|
# quote shape for the 9-stage CTI shape — context drops out and
|
|
# answer splits into raw_answer / parsed_claim_lattice / render.
|
|
ev_root = evidence_map_root(evidence_map) if evidence_map else None
|
|
parsed_lattice = None
|
|
is_lattice_mode = answer_mode in ("claim_lattice_pointer", "claim_lattice")
|
|
if is_lattice_mode:
|
|
# Per-claim list of {claim_text, content-addressed evidence_ids}
|
|
# for the parsed_claim_lattice node hash. Pointer ids are
|
|
# run-dependent; evidence_ids are content-addressed → the run-
|
|
# DAG hashes the run-stable form. Same shape for JSON and
|
|
# pointer; verifier already returns evidence_id_pairs.
|
|
evidence_id_pairs = verdict.get("evidence_id_pairs") or []
|
|
parsed_lattice = [
|
|
{
|
|
"claim_text": cs.get("text", ""),
|
|
"evidence_ids": evidence_id_pairs[i] if i < len(evidence_id_pairs) else [],
|
|
}
|
|
for i, cs in enumerate(verdict.get("claim_statuses") or [])
|
|
]
|
|
run_dag = build_run_dag(
|
|
question_hash=qhash,
|
|
sources=[{
|
|
"document_root": document_root,
|
|
"source_role": "primary_answer_source",
|
|
"score": None,
|
|
"chunk_idx": None,
|
|
}],
|
|
context_root=document_root,
|
|
conversation_hash=chash,
|
|
answer_text=answer_text,
|
|
audit_mode=verdict["audit_mode"],
|
|
verifier_method=verdict["verifier_method"],
|
|
n_quotes=verdict["n_quotes"],
|
|
n_verified=verdict["n_verified"],
|
|
claim_statuses=verdict.get("claim_statuses", []),
|
|
lookup_path="miss",
|
|
evidence_map_root=ev_root,
|
|
answer_mode=answer_mode if answer_mode != "quote" else None,
|
|
violations=verdict.get("violations"),
|
|
raw_answer_text=raw_answer if is_lattice_mode else None,
|
|
parsed_lattice=parsed_lattice,
|
|
rendered_text=answer_text if is_lattice_mode else None,
|
|
)
|
|
run_dag_blob = json.dumps(run_dag, separators=(",", ":"))
|
|
|
|
now = int(time.time())
|
|
with transaction(conn):
|
|
if repair_changes and pre_repair_verdict is not None:
|
|
append_audit(
|
|
conn,
|
|
event_type="providence_repair",
|
|
subject_root=ckey,
|
|
body={
|
|
"kind": "mechanical",
|
|
"n_changes": len(repair_changes),
|
|
"changes": repair_changes,
|
|
"pre_audit_mode": pre_repair_verdict["audit_mode"],
|
|
"post_audit_mode": verdict["audit_mode"],
|
|
"pre_n_verified": pre_repair_verdict["n_verified"],
|
|
"post_n_verified": verdict["n_verified"],
|
|
},
|
|
ts=now,
|
|
)
|
|
event_hash = append_audit(
|
|
conn,
|
|
event_type="providence_write",
|
|
subject_root=ckey,
|
|
body={
|
|
"source_root": document_root,
|
|
"model_id": model_id,
|
|
"revision": revision,
|
|
"quantization": quantization,
|
|
"chunks_in_context": len(chunk_rows),
|
|
"answer_chars": len(answer_text),
|
|
"audit_mode": verdict["audit_mode"],
|
|
"n_quotes": verdict["n_quotes"],
|
|
"n_verified": verdict["n_verified"],
|
|
"verifier_method": verdict["verifier_method"],
|
|
},
|
|
ts=now,
|
|
)
|
|
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, "
|
|
" ?, ?, ?, ?, ?, ?, ?)",
|
|
(
|
|
ckey,
|
|
document_root,
|
|
doc["document_uri"],
|
|
qhash,
|
|
question,
|
|
answer_text,
|
|
proof_blob,
|
|
mhash,
|
|
chash,
|
|
ghash,
|
|
SCHEMA_VERSION,
|
|
CANONICALIZATION_VERSION,
|
|
doc["chunking_version"],
|
|
chain,
|
|
event_hash,
|
|
now,
|
|
verdict["audit_mode"],
|
|
verdict["n_quotes"],
|
|
verdict["n_verified"],
|
|
unverified_blob,
|
|
verdict["verifier_method"],
|
|
run_dag["root"],
|
|
run_dag_blob,
|
|
),
|
|
)
|
|
|
|
from aborist.qa.dag import localize_failure as _localize
|
|
failure_stage = _localize(
|
|
audit_mode=verdict["audit_mode"],
|
|
n_sources=1, # ask() runs against one document
|
|
n_quotes=verdict["n_quotes"],
|
|
n_verified=verdict["n_verified"],
|
|
)
|
|
return {
|
|
"status": "cache_miss_then_written",
|
|
"audit_mode": verdict["audit_mode"],
|
|
"cache_key": ckey,
|
|
"run_dag_root": run_dag["root"],
|
|
"lookup_path": "miss",
|
|
"failure_stage": failure_stage,
|
|
"repair_changes": repair_changes,
|
|
"pre_repair_audit_mode": (
|
|
pre_repair_verdict["audit_mode"] if pre_repair_verdict else None
|
|
),
|
|
"source_root": document_root,
|
|
"answer_text": answer_text,
|
|
"merkle_proof": proof_obj,
|
|
"n_quotes": verdict["n_quotes"],
|
|
"n_verified": verdict["n_verified"],
|
|
"verifier_method": verdict["verifier_method"],
|
|
"unverified_quotes": verdict["unverified_quotes"],
|
|
"partially_verified_quotes": verdict.get("partially_verified_quotes") or [],
|
|
# Sidecar smell signals (claim_lattice mode only) — render-
|
|
# layer; never persisted, never in run_dag_root.
|
|
"pointer_id_distribution": verdict.get("pointer_id_distribution"),
|
|
"lazy_anchor_ratio": verdict.get("lazy_anchor_ratio"),
|
|
"timings": {
|
|
"cache_lookup_ms": cache_lookup_ms,
|
|
"llm_ms": llm_ms,
|
|
"total_ms": _ms_since(t_start),
|
|
},
|
|
}
|