arborist/tests/test_query.py
russell@unturf.com e3cbe3bb37
qa: question equivalence class dedups conversation_hash too, not just question_hash
Fox caught on 2026-04-29: 'who is batman?' and 'who is the batman?' produced
different cache_keys despite question_hash collapsing both variants. The 8-dim
cache_key has TWO seams that touch question text — question_hash AND
conversation_hash (which hashes the messages list with the literal question
in the user turn). Only the former canonicalized; the latter took bytes as
written, so each variant got its own chash and missed cache.

Fix: decouple the two forms.

- aborist/qa/keys.py: extract canonical_question(text) → str; question_hash
  composes _sha256(canonical_question(t)) so the equivalence class is
  defined in one place.

- aborist/qa/query.py + aborist/qa/runner.py: build TWO message lists per
  call. `messages` carries the verbatim question (Hermes sees the user's
  natural phrasing — no grammar drift). `canonical_messages` substitutes
  the canonical form, and conversation_hash hashes that. The LLM still
  gets 'Who Is THE Batman?'; the cache_key collapses to 'who is batman'.

- tests/test_query.py: regression — three variants (with/without `?`,
  with/without `the`) hit the same cache_key. First call populates,
  later variants cache_hit. Captures stub messages to confirm the LLM
  saw the verbatim question, not the canonical form.

437 tests pass.
2026-04-29 15:48:57 -04:00

308 lines
9.8 KiB
Python

"""Multi-source corpus Q&A: search → context → cache → Hermes.
Stub client only — no network. Validates:
- FTS5 finds the right docs across a small corpus
- context_root is deterministic (sorted source roots, then Merkle)
- cache hit on identical question returns same record without calling client
- different question -> different cache_key
"""
from __future__ import annotations
import json
from typing import Iterator
from aborist.document import Document
from aborist.ingest import ingest_source
from aborist.qa import query
from aborist.qa.client import StubClient
from aborist.source import Source
from aborist.store import connect
class FakeSource(Source):
source_type = "test"
def __init__(self, docs: list[Document]):
self.docs = docs
def iter_documents(self) -> Iterator[Document]:
yield from self.docs
def _doc(uri: str, content: str) -> Document:
return Document(uri=uri, content=content, source_type="test", title=uri.rsplit("/", 1)[-1])
# Three docs with distinguishable content so FTS5 can pick winners.
DOCS = [
_doc(
"test://anarchism",
"Anarchism is a political philosophy that opposes the state. " * 12
+ "Mutual aid is central to anarchist theory. " * 8,
),
_doc(
"test://capitalism",
"Capitalism is an economic system based on private ownership. " * 12
+ "Market exchange and capital accumulation drive growth. " * 8,
),
_doc(
"test://anarcho-capitalism",
"Anarcho-capitalism combines anarchism's opposition to the state with capitalism's private property. " * 12
+ "Murray Rothbard developed many of its core ideas. " * 8,
),
]
def test_query_picks_relevant_docs_and_writes_record(tmp_path):
main_db = tmp_path / "corpus.db"
qa_db = tmp_path / "qa.db"
conn = connect(main_db)
try:
ingest_source(conn, FakeSource(DOCS))
finally:
conn.close()
# Verbatim quote from DOCS[2] → audit_mode=STRICT.
client = StubClient(
answer=(
'Per the source: '
'"Anarcho-capitalism combines anarchism\'s opposition to the state '
'with capitalism\'s private property"'
)
)
result = query(
question="What is anarcho-capitalism?",
qa_db=qa_db,
chat_client=client,
model_id="test-model",
single_db=main_db,
top_k=3,
)
assert result["status"] == "cache_miss_then_written"
assert result["audit_mode"] == "STRICT"
assert "Anarcho-capitalism" in result["answer_text"]
assert len(result["sources"]) >= 1
assert any("anarcho-capitalism" in s["document_uri"] for s in result["sources"])
# context_root is deterministic.
sorted_roots = sorted(s["document_root"] for s in result["sources"])
if len(sorted_roots) == 1:
assert result["context_root"] == sorted_roots[0]
# Repeat call with same question → cache hit, no LLM call.
n_calls_before = len(client.calls)
result2 = query(
question="What is anarcho-capitalism?",
qa_db=qa_db,
chat_client=client,
model_id="test-model",
single_db=main_db,
top_k=3,
)
assert result2["status"] == "cache_hit"
assert result2["cache_key"] == result["cache_key"]
assert len(client.calls) == n_calls_before # no new call
def test_query_different_question_different_cache(tmp_path):
main_db = tmp_path / "corpus.db"
qa_db = tmp_path / "qa.db"
conn = connect(main_db)
try:
ingest_source(conn, FakeSource(DOCS))
finally:
conn.close()
client = StubClient(answer="answer text")
r1 = query(
question="What is anarchism?",
qa_db=qa_db,
chat_client=client,
model_id="m",
single_db=main_db,
)
r2 = query(
question="What is capitalism?",
qa_db=qa_db,
chat_client=client,
model_id="m",
single_db=main_db,
)
assert r1["cache_key"] != r2["cache_key"]
assert len(client.calls) == 2 # both missed cache, both called client
def test_query_question_equivalence_class_dedups_cache(tmp_path):
"""Fox 2026-04-29 catch: 'who is batman?', 'who is batman', and
'who is the batman?' should all hit the same cache. Question_hash
canonicalizes correctly, but conversation_hash used to hash the
LITERAL question text in the user message — so each variant got
its own chash and missed cache. Fix: canonical_question form is
substituted into the messages list used for conversation_hash,
while the LLM still receives the verbatim question."""
main_db = tmp_path / "corpus.db"
qa_db = tmp_path / "qa.db"
conn = connect(main_db)
try:
ingest_source(conn, FakeSource(DOCS))
finally:
conn.close()
captured: list = []
def _capture(messages, **kw):
captured.append(messages)
return "stub answer"
# First variant — populates cache.
r1 = query(
question="What is anarchism?",
qa_db=qa_db,
chat_client=StubClient(answer=_capture),
model_id="m",
single_db=main_db,
)
# Second & third variants — must hit the same cache_key.
r2 = query(
question="What is anarchism", # no trailing ?
qa_db=qa_db,
chat_client=StubClient(answer=_capture),
model_id="m",
single_db=main_db,
)
r3 = query(
question="what is the anarchism?", # leading article
qa_db=qa_db,
chat_client=StubClient(answer=_capture),
model_id="m",
single_db=main_db,
)
assert r1["cache_key"] == r2["cache_key"] == r3["cache_key"]
assert r1["status"] == "cache_miss_then_written"
assert r2["status"] == "cache_hit"
assert r3["status"] == "cache_hit"
# Only the first call reached the LLM.
assert len(captured) == 1
# And it received the verbatim question, not the canonical form.
user_text = "\n".join(
m["content"] for m in captured[0] if m["role"] == "user"
)
assert "What is anarchism?" in user_text
def test_query_no_sources_when_empty_corpus(tmp_path):
main_db = tmp_path / "empty.db"
qa_db = tmp_path / "qa.db"
connect(main_db).close() # creates schema, no docs
result = query(
question="What is anything?",
qa_db=qa_db,
chat_client=StubClient(),
model_id="m",
single_db=main_db,
)
assert result["status"] == "no_sources"
def test_query_per_source_cap_prevents_huge_doc_monopoly(tmp_path):
"""Fox 2026-04-29 catch: a top-ranked huge document (e.g.
List_of_Batman_comics, 80 KB+ bibliography) used to consume the
entire 60 KB budget at hit #1, dropping every subsequent doc with
char_budget <= 0. Now each of the top_k hits gets at most
`max_context_chars / top_k` chars; multiple sources land in
context even when hit #1 is huge."""
main_db = tmp_path / "corpus.db"
qa_db = tmp_path / "qa.db"
# Hit #1 is intentionally huge: every query token AND the most copies
# so it FTS5-ranks first. Hits #2 and #3 are smaller but still
# contain the query token.
bulk_token = "anarchism " * 5000 # ~50 KB after canonicalize
docs = [
_doc("test://huge-bibliography", bulk_token),
_doc("test://anarchism-bio", "Anarchism is a political philosophy. " * 30),
_doc("test://anarchism-history", "Anarchism's history begins with Proudhon. " * 30),
]
conn = connect(main_db)
try:
ingest_source(conn, FakeSource(docs))
finally:
conn.close()
captured = {"messages": None}
def _capture(messages, **kw):
captured["messages"] = messages
return "stub"
result = query(
question="What is anarchism?",
qa_db=qa_db,
chat_client=StubClient(answer=_capture),
model_id="m",
single_db=main_db,
top_k=3,
max_context_chars=60000,
)
# All three sources should be in the result, not just the huge one.
assert len(result["sources"]) >= 2, (
f"per-source cap broke: only {len(result['sources'])} sources reached "
f"context (huge doc monopolized again)"
)
# Verify the user-turn context contains content from the smaller docs
# too — not just a 60K slab of the bulk doc.
user_text = "\n".join(m["content"] for m in captured["messages"] if m["role"] == "user")
assert "anarchism-bio" in user_text or "anarchism-history" in user_text
def test_query_per_source_cap_respects_top_k(tmp_path):
"""top_k=1 → cap = max_context_chars (legacy behavior preserved when
operator wants a single large source)."""
main_db = tmp_path / "corpus.db"
qa_db = tmp_path / "qa.db"
big = _doc("test://big", "Anarchism is a political philosophy. " * 2000)
conn = connect(main_db)
try:
ingest_source(conn, FakeSource([big]))
finally:
conn.close()
result = query(
question="What is anarchism?",
qa_db=qa_db,
chat_client=StubClient(answer="x"),
model_id="m",
single_db=main_db,
top_k=1,
max_context_chars=60000,
)
# Single source allowed up to full budget.
assert len(result["sources"]) == 1
def test_query_persists_audit_event(tmp_path):
main_db = tmp_path / "corpus.db"
qa_db = tmp_path / "qa.db"
conn = connect(main_db)
try:
ingest_source(conn, FakeSource(DOCS))
finally:
conn.close()
query(
question="What is anarchism?",
qa_db=qa_db,
chat_client=StubClient(answer="X"),
model_id="m",
single_db=main_db,
)
# qa.db should now have one providence_query event in its audit chain.
qc = connect(qa_db)
try:
events = qc.execute(
"SELECT event_type FROM audit_events ORDER BY seq"
).fetchall()
finally:
qc.close()
types = [e["event_type"] for e in events]
assert "providence_query" in types