arborist/tests/test_query.py
russell@unturf.com a99ac4388b
qa: lock phrase-route non-regression tests + open ticket #000002 (Module L)
Two follow-ups to the phrase-pattern retrieval fix (commit 1b8677d)
covering items 6 and 10-11 of fox's 2026-05-01 architectural review:

(1) Non-regression tests for the phrase route:
  - test_phrase_route_skipped_when_question_shorter_than_min_n
    pins the structural false-positive guard: the n=5/n=6 minimum
    means a 4-token literal-geography query lacks enough tokens to
    trigger the route at all.
  - test_phrase_route_does_not_hijack_literal_geography_query
    end-to-end: a 4-token "oceania east asia geography" query on
    a synthetic 2-doc corpus surfaces only the geography-stub doc;
    the orwell-stub doc (whose body has the diagnostic 5-gram) is
    correctly NOT pulled in by the phrase route on a literal query.

(2) docs/ticket-000002-reference-frame-polarity-contract.md
    Captures fox's Module L proposal verbatim as Appendix A and
    extracts the implementation sketch into the standard ticket
    body (problem statement, abstraction, CTI interpretation, three
    pieces of code to write, test list, scope boundaries).

    The phrase route closed the RETRIEVAL side of reference-frame
    failure. Module L addresses the ANSWER side: today's substrate
    answers Orwell queries as "the text does not directly state..."
    when it should produce multi-frame answers distinguishing
    Party propaganda from fictional-actual continuity. Forecast
    cost ~3-4 hours; risk medium (prompt augmentation interaction
    with claim_lattice prompt).

    Module M = ticket #000001 (route provenance binding); not
    duplicated. Module N (FP guards) partially landed via the
    tests above; remaining tests folded into ticket #000002's
    test list. Module H (relation warrant lite) lacks scope
    detail; deferred without a ticket.

(3) docs/TICKETS.md updated: index gains #000002 row, Next ID
    bumped to 000003.
2026-05-01 14:03:39 -04:00

1173 lines
41 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_strict_dedup_distinguishes_question_variants(tmp_path):
"""policy['question_dedup']='strict' makes every variant get its
own cache_key. 'Who is X?' and 'who is the X?' write separate
records under strict policy."""
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()
strict_policy = dict(query.__globals__["DEFAULT_QUERY_POLICY"])
strict_policy["question_dedup"] = "strict"
r1 = query(
question="What is anarchism?",
qa_db=qa_db,
chat_client=StubClient(answer="a"),
model_id="m",
single_db=main_db,
policy=strict_policy,
fidelity="strict",
)
r2 = query(
question="what is anarchism",
qa_db=qa_db,
chat_client=StubClient(answer="b"),
model_id="m",
single_db=main_db,
policy=strict_policy,
fidelity="strict",
)
# Different cache_keys; both populated independently.
assert r1["cache_key"] != r2["cache_key"]
assert r1["status"] == r2["status"] == "cache_miss_then_written"
assert r1["lookup_path"] == "miss"
def test_query_equivalence_class_fidelity_falls_back_across_dedup_modes(tmp_path):
"""A record written under equivalence_class policy gets reused by a
later strict-policy lookup that asks for fidelity='equivalence_class'.
Verifies the cross-silo fallback: strict ckey misses, alternate
equivalence_class ckey hits, lookup_path reports the fallback."""
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()
DEFAULT = query.__globals__["DEFAULT_QUERY_POLICY"]
eq_policy = dict(DEFAULT)
eq_policy["question_dedup"] = "equivalence_class"
strict_policy = dict(DEFAULT)
strict_policy["question_dedup"] = "strict"
# Agent A writes equivalence-class record for 'what is anarchism?'.
captured: list = []
def _capture(messages, **kw):
captured.append(messages)
return "stub"
r1 = query(
question="What is anarchism?",
qa_db=qa_db,
chat_client=StubClient(answer=_capture),
model_id="m",
single_db=main_db,
policy=eq_policy,
)
assert r1["status"] == "cache_miss_then_written"
# Agent B (strict policy, equivalence_class fidelity) asks the same
# question. Strict ckey misses; the eq_class fallback hits A's record.
r2 = query(
question="What is anarchism?",
qa_db=qa_db,
chat_client=StubClient(answer=_capture),
model_id="m",
single_db=main_db,
policy=strict_policy,
fidelity="equivalence_class",
)
assert r2["status"] == "cache_hit"
assert r2["lookup_path"] == "equivalence_class_fallback"
assert len(captured) == 1 # only A's call reached the LLM
def test_query_strict_fidelity_does_not_fall_back(tmp_path):
"""Audit-grade lookup: strict-fidelity refuses to read records from
the other dedup mode's silo. Even if equivalence_class has a hit,
strict fidelity reports cache miss."""
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()
DEFAULT = query.__globals__["DEFAULT_QUERY_POLICY"]
eq_policy = dict(DEFAULT)
eq_policy["question_dedup"] = "equivalence_class"
strict_policy = dict(DEFAULT)
strict_policy["question_dedup"] = "strict"
# Agent A writes under equivalence_class.
query(
question="What is anarchism?",
qa_db=qa_db,
chat_client=StubClient(answer="a"),
model_id="m",
single_db=main_db,
policy=eq_policy,
)
# Agent B (strict policy + strict fidelity) asks same question.
# Should NOT find A's record; runs LLM fresh.
captured: list = []
def _capture(messages, **kw):
captured.append(messages)
return "stub"
r2 = query(
question="What is anarchism?",
qa_db=qa_db,
chat_client=StubClient(answer=_capture),
model_id="m",
single_db=main_db,
policy=strict_policy,
fidelity="strict",
)
assert r2["status"] == "cache_miss_then_written"
assert r2["lookup_path"] == "miss"
assert len(captured) == 1
def test_classify_source_role_separates_primary_from_noisy():
"""Direct unit test on the role classifier. JP film-score should be
noisy_background; JP (film) should be primary; JP franchise should
be secondary; The Lost World should be sequel; off-topic background.
Catches the case where peripheral pages with strong title overlap
used to share the primary slot."""
from aborist.qa.query import _classify_source_role
qstems = {"dinosaur", "jurassic", "park", "film"}
assert _classify_source_role("Jurassic Park (film)", qstems) == "primary_answer_source"
assert _classify_source_role("Jurassic Park (film score)", qstems) == "noisy_background_source"
assert _classify_source_role("Jurassic Park video games", qstems) == "noisy_background_source"
assert _classify_source_role("Jurassic Park (franchise)", qstems) == "secondary_context_source"
assert _classify_source_role("List of Jurassic Park characters", qstems) == "secondary_context_source"
assert _classify_source_role("The Lost World: Jurassic Park", qstems) == "sequel_background_source"
# Off-topic title (no shared stems): falls through to background.
assert _classify_source_role("Anarchism", qstems) == "background_source"
def test_query_role_weighted_budget_persists_role_on_sources(tmp_path):
"""Each source in the providence record's merkle_proof.sources gains
a `source_role` field — verifies the role made it into the audit
trail so an inspector can see which slot a source occupied."""
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()
result = query(
question="What is anarcho-capitalism?",
qa_db=qa_db,
chat_client=StubClient(answer="x"),
model_id="m",
single_db=main_db,
top_k=3,
)
assert all("source_role" in s for s in result["sources"])
def test_query_persists_run_dag_root_on_record_and_result(tmp_path):
"""Per-run Merkle-DAG fingerprint surfaces on both the result dict
& the persisted providence_cache row. Recomputing the root from
the persisted blob matches what was stored."""
from aborist.qa.dag import verify_run_dag
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()
result = query(
question="What is anarchism?",
qa_db=qa_db,
chat_client=StubClient(answer='The source: "a political philosophy that opposes the state"'),
model_id="m",
single_db=main_db,
)
assert "run_dag_root" in result
assert isinstance(result["run_dag_root"], str)
assert len(result["run_dag_root"]) == 64 # sha256 hex
qa_conn = connect(qa_db)
try:
row = qa_conn.execute(
"SELECT run_dag_root, run_dag_blob FROM providence_cache "
"WHERE cache_key = ?",
(result["cache_key"],),
).fetchone()
finally:
qa_conn.close()
assert row["run_dag_root"] == result["run_dag_root"]
assert verify_run_dag(row["run_dag_blob"]) is True
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_filter_requires_breadth_for_multi_token_queries(tmp_path):
"""Fox 2026-04-29 catch: 'supermans girlfriend' returned 7-of-8
unrelated `Girlfriends`-titled articles because title-overlap
accepted ANY single-token match. The fix tightens both title and
body filters to require ALL query tokens (≤2-token queries) so a
doc whose title only matches ONE of the two qtokens doesn't pass.
Synthetic corpus pins the new behavior:
- "Lois Lane" — neither qtoken in title; body has both → keep
- "Girlfriends" — only "girlfriend" in title/body; no "superman" → drop
- "Superman album""superman" in title; body lacks "girlfriend" → drop
"""
main_db = tmp_path / "corpus.db"
qa_db = tmp_path / "qa.db"
docs = [
_doc(
"test://lois-lane",
"Lois Lane is a fictional character who works for the Daily Planet. "
"She is Superman's girlfriend and frequently appears in his stories. "
* 5,
),
_doc(
"test://girlfriends-tv",
"Girlfriends is a sitcom about four women in Los Angeles. " * 10,
),
_doc(
"test://superman-music",
"Superman is an album of rock music recorded in Tokyo. " * 10,
),
]
conn = connect(main_db)
try:
ingest_source(conn, FakeSource(docs))
finally:
conn.close()
result = query(
question="who is supermans girlfriend?",
qa_db=qa_db,
chat_client=StubClient(answer="x"),
model_id="m",
single_db=main_db,
top_k=8,
)
src_uris = [s["document_uri"] for s in result["sources"]]
# Lois Lane MUST be in the result set — it's the only doc with both
# query tokens in its body.
assert any("lois-lane" in u for u in src_uris), (
f"breadth filter regressed: lois-lane not in {src_uris}"
)
def test_query_filter_one_token_query_still_synonym_expands(tmp_path):
"""1-token queries keep the loose synonym-expanded any-match — pin
that we didn't over-tighten the single-token case."""
main_db = tmp_path / "corpus.db"
qa_db = tmp_path / "qa.db"
docs = [
_doc("test://anarchism", "Anarchism is a political philosophy. " * 30),
]
conn = connect(main_db)
try:
ingest_source(conn, FakeSource(docs))
finally:
conn.close()
result = query(
question="anarchism?",
qa_db=qa_db,
chat_client=StubClient(answer="x"),
model_id="m",
single_db=main_db,
top_k=3,
)
assert len(result["sources"]) == 1
def test_query_burn_existing_forces_fresh_inference(tmp_path):
"""Fox 2026-04-29: `make query Q=... BURN=1` busts any matching live
cache record before lookup so a fresh inference runs.
Sequence:
1. First query → cache_miss_then_written, populates cache
2. Second query, NO burn → cache_hit (no new LLM call)
3. Third query, BURN=True → cache_miss_then_written (cache busted),
result reports burned_existing=1
"""
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="anarchism is a thing")
r1 = query(
question="What is anarchism?",
qa_db=qa_db,
chat_client=client,
model_id="m",
single_db=main_db,
)
assert r1["status"] == "cache_miss_then_written"
assert r1["burned_existing"] == 0
n_calls_after_first = len(client.calls)
r2 = query(
question="What is anarchism?",
qa_db=qa_db,
chat_client=client,
model_id="m",
single_db=main_db,
)
assert r2["status"] == "cache_hit"
assert r2["burned_existing"] == 0
assert len(client.calls) == n_calls_after_first # no new LLM call
r3 = query(
question="What is anarchism?",
qa_db=qa_db,
chat_client=client,
model_id="m",
single_db=main_db,
burn_existing=True,
)
assert r3["status"] == "cache_miss_then_written"
assert r3["burned_existing"] == 1
assert len(client.calls) == n_calls_after_first + 1 # new LLM call after burn
def test_query_burn_existing_writes_audit_event(tmp_path):
"""Each --burn writes a providence_burn audit event so the chain
records the bust. Verifies one event lands per burn."""
from aborist.store import connect as _connect
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="x")
query(
question="What is anarchism?",
qa_db=qa_db, chat_client=client, model_id="m", single_db=main_db,
)
qc = _connect(qa_db)
try:
burns_before = qc.execute(
"SELECT COUNT(*) FROM audit_events WHERE event_type='providence_burn'"
).fetchone()[0]
finally:
qc.close()
query(
question="What is anarchism?",
qa_db=qa_db, chat_client=client, model_id="m", single_db=main_db,
burn_existing=True,
)
qc = _connect(qa_db)
try:
burns_after = qc.execute(
"SELECT COUNT(*) FROM audit_events WHERE event_type='providence_burn'"
).fetchone()[0]
finally:
qc.close()
assert burns_after == burns_before + 1
def test_query_burn_existing_with_no_prior_record_is_noop(tmp_path):
"""First-time query with --burn: nothing to burn → burned_existing=0,
proceeds to fresh inference normally."""
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()
r = query(
question="What is anarchism?",
qa_db=qa_db, chat_client=StubClient(answer="x"),
model_id="m", single_db=main_db,
burn_existing=True,
)
assert r["status"] == "cache_miss_then_written"
assert r["burned_existing"] == 0
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
# ---------------------------------------------------------------------------
# capacity metrics — prompt_chars + answer_chars surface
# ---------------------------------------------------------------------------
def _setup_corpus(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()
return main_db, qa_db
def test_query_returns_prompt_chars_breakdown(tmp_path):
"""prompt_chars must contain the five expected keys and
messages_total must equal the sum of system + reminder + user
message lengths actually sent to the chat client."""
main_db, qa_db = _setup_corpus(tmp_path)
client = StubClient(answer="A short answer.")
result = query(
question="What is anarcho-capitalism?",
qa_db=qa_db,
chat_client=client,
model_id="m",
single_db=main_db,
top_k=3,
)
pc = result["prompt_chars"]
assert set(pc.keys()) == {
"system_prompt",
"grounding_reminder",
"user_question",
"evidence_or_context",
"messages_total",
}
# System prompt is from DEFAULT_QUERY_POLICY — non-empty.
assert pc["system_prompt"] > 0
# Question is exactly the chars passed in.
assert pc["user_question"] == len("What is anarcho-capitalism?")
# Evidence/context is the chunk text the model saw — non-trivial.
assert pc["evidence_or_context"] > 0
# messages_total equals the sum of message contents the stub saw.
sent = client.calls[0]["messages"]
actual_total = sum(len(m["content"]) for m in sent)
assert pc["messages_total"] == actual_total
def test_query_answer_chars_matches_answer_text(tmp_path):
main_db, qa_db = _setup_corpus(tmp_path)
client = StubClient(answer="Exactly twenty-six chars!!")
result = query(
question="What is anarchism?",
qa_db=qa_db,
chat_client=client,
model_id="m",
single_db=main_db,
)
assert result["answer_chars"] == len(result["answer_text"])
def test_query_cache_hit_also_returns_capacity_metrics(tmp_path):
"""Cache-hit path must populate prompt_chars too — operators
inspecting cached records still want the capacity breakdown."""
main_db, qa_db = _setup_corpus(tmp_path)
client = StubClient(answer="Same answer twice.")
first = query(
question="What is anarcho-capitalism?",
qa_db=qa_db,
chat_client=client,
model_id="m",
single_db=main_db,
top_k=3,
)
assert first["status"] == "cache_miss_then_written"
second = query(
question="What is anarcho-capitalism?",
qa_db=qa_db,
chat_client=client,
model_id="m",
single_db=main_db,
top_k=3,
)
assert second["status"] == "cache_hit"
assert "prompt_chars" in second
assert second["prompt_chars"]["messages_total"] > 0
assert second["answer_chars"] > 0
def test_query_evidence_chars_grows_with_topk(tmp_path):
"""Adding more sources to the context budget should increase
`evidence_or_context`. Sanity check that the metric tracks
actual context build, not a stale constant."""
main_db, qa_db = _setup_corpus(tmp_path)
qa_db_small = tmp_path / "qa_small.db"
qa_db_big = tmp_path / "qa_big.db"
small = query(
question="What is anarchism?",
qa_db=qa_db_small,
chat_client=StubClient(answer="X"),
model_id="m",
single_db=main_db,
top_k=1,
max_context_chars=2000,
)
big = query(
question="What is anarchism?",
qa_db=qa_db_big,
chat_client=StubClient(answer="X"),
model_id="m",
single_db=main_db,
top_k=3,
max_context_chars=20000,
)
assert big["prompt_chars"]["evidence_or_context"] >= small["prompt_chars"]["evidence_or_context"]
# ---------------------------------------------------------------------------
# --retrieval-keywords flag (operator-supplied retrieval augmentation)
# ---------------------------------------------------------------------------
def test_retrieval_keywords_does_not_alter_question_to_llm(tmp_path):
"""The LLM must see the original question string, not the
augmented retrieval query. Cache_key must match the same call
without retrieval_keywords."""
main_db, qa_db = _setup_corpus(tmp_path)
captured = {}
def _record_answer(messages, **kwargs):
captured["messages"] = messages
return "Some answer."
client = StubClient(answer=_record_answer)
bare = query(
question="What is anarchism?",
qa_db=qa_db,
chat_client=client,
model_id="m",
single_db=main_db,
top_k=3,
)
# Same question, with retrieval keywords. New qa_db so cache lookup
# under fresh state — both runs are cache-miss.
qa_db_2 = tmp_path / "qa_with_keywords.db"
client2 = StubClient(answer=_record_answer)
augmented = query(
question="What is anarchism?",
qa_db=qa_db_2,
chat_client=client2,
model_id="m",
single_db=main_db,
top_k=3,
retrieval_keywords="mutual aid theory",
)
# User question text is byte-identical between the two runs.
bare_user_msg = [m["content"] for m in client.calls[0]["messages"]
if m["role"] == "user"][-1]
aug_user_msg = [m["content"] for m in client2.calls[0]["messages"]
if m["role"] == "user"][-1]
# Both end with "Question: What is anarchism?" — the keywords
# never appear inside the LLM-facing question segment (they
# only flow into FTS5 retrieval + title-filter token sets).
assert "What is anarchism?" in bare_user_msg
assert "What is anarchism?" in aug_user_msg
assert "mutual aid theory" not in aug_user_msg.split("Question:")[-1]
# System prompt unchanged across runs.
bare_sys = [m["content"] for m in client.calls[0]["messages"]
if m["role"] == "system"][0]
aug_sys = [m["content"] for m in client2.calls[0]["messages"]
if m["role"] == "system"][0]
assert bare_sys == aug_sys
def test_retrieval_keywords_changes_retrieved_sources(tmp_path):
"""Different keywords surface different docs, even with the
same question text. Same-keywords across runs is reproducible."""
main_db, qa_db = _setup_corpus(tmp_path)
qa_db_a = tmp_path / "a.db"
qa_db_b = tmp_path / "b.db"
a = query(
question="define the system",
qa_db=qa_db_a,
chat_client=StubClient(answer="X"),
model_id="m",
single_db=main_db,
top_k=3,
retrieval_keywords="anarcho-capitalism rothbard",
)
b = query(
question="define the system",
qa_db=qa_db_b,
chat_client=StubClient(answer="X"),
model_id="m",
single_db=main_db,
top_k=3,
retrieval_keywords="capitalism markets",
)
a_uris = sorted(s["document_uri"] for s in a["sources"])
b_uris = sorted(s["document_uri"] for s in b["sources"])
# The two retrieval-keyword sets pull different topical sources.
# (Sanity: same question without keywords is the no-keyword
# baseline; the assertion is that adding distinct keywords does
# something to the result.)
assert a_uris != b_uris or set(a_uris) != set(b_uris), (
f"keywords should affect retrieval; got identical sources {a_uris}"
)
# ---------------------------------------------------------------------------
# phrase-pattern retrieval route (allusion / verbatim sequence boost)
# ---------------------------------------------------------------------------
def test_question_phrases_returns_sliding_n_grams_no_stopword_strip():
"""`_question_phrases` extracts verbatim n-token windows. Function
words are kept — diagnostic value of an allusion is the EXACT
sequence ('always been at war' >> 'always war')."""
from aborist.qa.query import _question_phrases
out = _question_phrases("has oceania always been at war with east asia", n=4)
# 9-token query, 4-gram window → 6 phrases, all preserved verbatim
# (lowercase) and deduped.
assert "has oceania always been" in out
assert "always been at war" in out
assert "war with east asia" in out
# Stopwords ARE present — that's the design, not a bug.
assert any("at" in p.split() for p in out)
def test_question_phrases_n_5_yields_five_token_phrases():
"""5-grams trade recall for precision; 'oceania always been at war'
is a much stronger Orwell signal than 'always been at war' alone."""
from aborist.qa.query import _question_phrases
out = _question_phrases("has oceania always been at war with east asia", n=5)
assert "oceania always been at war" in out
assert "always been at war with" in out
# Too short for 6-grams of just "war with east asia" alone.
assert all(len(p.split()) == 5 for p in out)
def test_question_phrases_skips_when_question_shorter_than_n():
"""`who is X?` is too short to yield 4-grams. Empty output is the
expected behavior (the body BM25 + title routes still cover it)."""
from aborist.qa.query import _question_phrases
assert _question_phrases("who is X?", n=4) == []
assert _question_phrases("", n=4) == []
def test_question_phrases_drops_all_short_token_phrases():
"""A window of all 1-3 char tokens is boilerplate ('to be or not')
— drops to avoid over-matching. The skip rule fires only when ALL
tokens in the window are <4 chars."""
from aborist.qa.query import _question_phrases
# All ≤3-char tokens — drop.
assert _question_phrases("to be or not", n=4) == []
# Mixed: at least one ≥4-char token → keep.
out = _question_phrases("to be or maybe", n=4)
assert out == ["to be or maybe"]
def test_question_phrases_lowercases_and_dedupes():
"""Output is lowercase, deduped on string equality. Same
sequence in different cases collapses to one phrase."""
from aborist.qa.query import _question_phrases
out = _question_phrases("Always been at war Always been at war", n=4)
# Repeated sequence appears only once in the output.
assert out.count("always been at war") == 1
def test_search_phrases_returns_empty_on_no_phrases(tmp_path):
"""Defensive: empty phrase list yields no rows, no exceptions."""
from aborist.qa.query import _search_phrases
main_db = tmp_path / "corpus.db"
conn = connect(main_db)
try:
ingest_source(conn, FakeSource(DOCS))
rows = _search_phrases(conn, [], 10)
finally:
conn.close()
assert rows == []
def test_search_phrases_skips_phrases_with_double_quotes(tmp_path):
"""Adversarial input safety: phrases containing `"` would break
the FTS5 quoted-phrase syntax. The function silently drops them."""
from aborist.qa.query import _search_phrases
main_db = tmp_path / "corpus.db"
conn = connect(main_db)
try:
ingest_source(conn, FakeSource(DOCS))
# All phrases contain quotes — function returns empty.
rows = _search_phrases(conn, ['has "embedded" quote', 'also "bad"'], 10)
finally:
conn.close()
assert rows == []
def test_phrase_match_surfaces_topical_doc(tmp_path):
"""End-to-end: a query whose phrase verbatim-matches one doc's
body should surface that doc even when title tokens don't
overlap. Closes the 2026-05-01 Orwell case where the 1984
article had zero token overlap with the question's title-tokens
but matched the verbatim phrase 'always been at war'."""
main_db = tmp_path / "corpus.db"
qa_db = tmp_path / "qa.db"
conn = connect(main_db)
try:
# Two docs: one explicitly contains the diagnostic phrase
# but its title doesn't overlap question tokens; the other
# is a generic geography article.
ingest_source(conn, FakeSource([
_doc(
"test://orwell-stub",
# Title-irrelevant to the question; body contains
# the diagnostic 5-gram.
"The novel narrates that Oceania always been at war with "
"Eastasia though the alliances had previously rotated. " * 6
),
_doc(
"test://geography-stub",
"Geographic descriptions of regions called Oceania and East "
"Asia. " * 12,
),
]))
finally:
conn.close()
result = query(
question="has oceania always been at war with east asia",
qa_db=qa_db,
chat_client=StubClient(answer="An answer."),
model_id="m",
single_db=main_db,
top_k=5,
)
uris = [s["document_uri"] for s in result["sources"]]
# Both docs surface; the phrase-route ensures the orwell-stub
# doc isn't filtered out by the title-relevance gate.
assert "test://orwell-stub" in uris
def test_phrase_route_skipped_when_question_shorter_than_min_n():
"""False-positive guard: a 4-token geography question lacks enough
tokens to trigger the n=5/n=6 phrase route. Short conventional
queries route through body-BM25 + title-LIKE only — phrase routing
is structurally biased toward longer allusion-shape questions."""
from aborist.qa.query import _question_phrases
# 4 tokens after extraction → empty 5-gram and 6-gram outputs.
assert _question_phrases("oceania east asia geography", n=5) == []
assert _question_phrases("oceania east asia geography", n=6) == []
# 5 tokens → exactly one 5-gram, zero 6-grams.
out_5 = _question_phrases("oceania population east asia trade", n=5)
assert len(out_5) == 1
assert out_5[0] == "oceania population east asia trade"
out_6 = _question_phrases("oceania population east asia trade", n=6)
assert out_6 == []
def test_phrase_route_does_not_hijack_literal_geography_query(tmp_path):
"""Critical false-positive guard. A literal geography query about
Oceania + East Asia must NOT pull in an Orwell-flavored stub doc
just because both contain geographic tokens. The phrase route
only fires for verbatim 5+ token sequences from the question;
a different geography question shouldn't accidentally invoke it."""
main_db = tmp_path / "corpus.db"
qa_db = tmp_path / "qa.db"
conn = connect(main_db)
try:
ingest_source(conn, FakeSource([
_doc(
"test://orwell-stub",
# Body has the diagnostic Orwell 5-gram, but the title
# is title-irrelevant to a geography query.
"The novel narrates that Oceania always been at war with "
"Eastasia though the alliances had previously rotated. " * 6
),
_doc(
"test://geography-stub",
"Geographic descriptions of regions called Oceania and East "
"Asia. Topics: trade, population, climate, demographics. " * 12,
),
]))
finally:
conn.close()
# Literal geography query — short, no Orwell phrase.
result = query(
question="oceania east asia geography",
qa_db=qa_db,
chat_client=StubClient(answer="A geography answer."),
model_id="m",
single_db=main_db,
top_k=5,
)
uris = [s["document_uri"] for s in result["sources"]]
# Geography stub should be present (literal query, literal source).
assert "test://geography-stub" in uris
# Orwell stub should NOT have been surfaced via phrase route on
# a literal-geography query — the phrase route only activates on
# verbatim 5+ token sequences from the question, and "oceania
# east asia geography" is too short to produce any.
def test_filter_keeps_phrase_match_root_with_no_title_overlap():
"""Direct unit test for accept-path 4: a hit whose title shares
zero content tokens with the question, but whose document_root is
in `phrase_match_roots`, must pass the filter."""
from aborist.qa.query import _Hit, _filter_by_title_relevance
hits = [
# Title shares NO content tokens with the question. Without
# accept-path 4 (phrase_match_roots), it would be dropped.
_Hit(
document_root="bbb",
document_uri="t://nineteen-eighty-four",
title="Nineteen Eighty-Four",
score=70.0,
shard_path="x",
chunk_idx=0,
),
]
# Without phrase_match_roots, the hit is dropped (title-relevance
# filter has no accept path that fires).
kept_without = _filter_by_title_relevance(
hits,
"has oceania always been at war with east asia",
)
# Filter falls back to top-N when nothing accepts; accept the
# fallback as 'kept' here too — what we care about is whether
# accept-path 4 is the path firing when phrase_match_roots is set.
kept_with = _filter_by_title_relevance(
hits,
"has oceania always been at war with east asia",
phrase_match_roots={"bbb"},
)
kept_roots = {h.document_root for h in kept_with}
assert "bbb" in kept_roots, (
"phrase_match_roots accept-path 4 should keep titles with no "
"token overlap when their body verbatim-matched a question phrase"
)
# And the fallback path doesn't suddenly fail when phrase_match_roots
# is present — the keep is via accept-path 4, not via the fallback.
_ = kept_without # documents that fallback may also keep, but via different path