"""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 arborist.document import Document from arborist.ingest import ingest_source from arborist.qa import query from arborist.qa.client import StubClient from arborist.source import Source from arborist.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 arborist.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 arborist.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 arborist.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 arborist.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 arborist.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 arborist.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 arborist.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 arborist.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 arborist.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 arborist.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 arborist.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 arborist.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 # --------------------------------------------------------------------------- # _search_titles long-query regression (2026-05-02) # --------------------------------------------------------------------------- def _build_titled_corpus(tmp_path) -> tuple: """Test corpus shaped to exercise _search_titles. Returns (single_db_path, qa_db_path).""" main_db = tmp_path / "corpus.db" qa_db = tmp_path / "qa.db" conn = connect(main_db) try: ingest_source(conn, FakeSource([ _doc( "test://neurotech", # Long body so retrieval has something to grade. "Neurotechnology covers brain-computer interfaces. " * 30 + "Transcranial magnetic stimulation reads brain " "activity. " * 30 ), _doc( "test://decoy", "Generic technology overview text. " * 60, ), ])) finally: conn.close() return main_db, qa_db def test_unit_search_titles_handles_long_question_without_crash(tmp_path): """Regression: 2026-05-02 fox-surfaced SQLite error on a 31-content-token query. The earlier SQL chained N CASE WHEN expressions joined by `+`, blowing past SQLite's default expression-tree depth limit (1000). The simplified SQL — OR-chain + ORDER BY LENGTH(title) — handles arbitrary token counts. Cap at MAX_TITLE_LIKE_TOKENS bounds the OR-chain growth defensively.""" from arborist.qa.query import _search_titles main_db, _qa_db = _build_titled_corpus(tmp_path) conn = connect(main_db) try: # 50-token query — well past anything that would have hit # the 1000-depth cap, but the bounded SQL handles it. long_tokens = [ "what", "technology", "are", "currently", "or", "soon", "available", "which", "may", "enable", "one", "person", "to", "reconstruct", "and", "understand", "some", "portion", "of", "another", "persons", "thoughts", "or", "ideas", "without", "speaking", "or", "sign", "language", "and", "make", "a", "summary", "we", "can", "use", "to", "improve", "communication", "between", "humans", "across", "the", "globe", "even", "if", "they", "speak", "different", "tongues", ] # Should not raise sqlite3.OperationalError; should return # SOME rows (the test corpus has 2 docs, one will match). rows = _search_titles(conn, long_tokens, 32) assert isinstance(rows, list) finally: conn.close() def test_unit_search_titles_handles_zero_tokens(): """Defensive: zero tokens → empty list, no SQL executed.""" from arborist.qa.query import _search_titles rows = _search_titles(None, [], 32) assert rows == [] def test_unit_search_titles_caps_or_chain_at_max_tokens(tmp_path): """The OR-chain is bounded at MAX_TITLE_LIKE_TOKENS so extreme-length queries (100+ tokens) don't cascade SQL expression-tree growth. Beyond the cap, additional tokens are dropped at the SQL layer; the post-filter in the caller sees them all and does the actual filtering.""" from arborist.qa.query import _search_titles main_db, _qa_db = _build_titled_corpus(tmp_path) conn = connect(main_db) try: # 200-token pathological query — should not crash. many_tokens = [f"tok{i}" for i in range(200)] rows = _search_titles(conn, many_tokens, 32) assert isinstance(rows, list) finally: conn.close() def test_integration_query_completes_on_long_question(tmp_path): """End-to-end: a long discursive question with many content tokens runs through query() to completion (StubClient — no LLM) without the SQLite expression-tree overflow that the 2026-05-02 regression triggered.""" main_db, qa_db = _build_titled_corpus(tmp_path) long_q = ( "what technology are currently or soon available which may " "enable one person to reconstruct and understand some or a " "portion of another persons thoughts or ideas without " "speaking or sign language" ) result = query( question=long_q, qa_db=qa_db, chat_client=StubClient(answer="A short stub answer."), model_id="m", single_db=main_db, top_k=3, ) # Pre-fix this raised sqlite3.OperationalError before reaching # the LLM call. Post-fix: query() returns a normal result dict. assert result["status"] in ( "cache_miss_then_written", "cache_hit", "no_sources", ) # Status field present means the pipeline completed. assert "audit_mode" in result or result["status"] == "no_sources" def test_functional_long_question_returns_sources(tmp_path): """Functional check: the long-question retrieval path actually surfaces relevant sources (not just empty). The corpus has a 'neurotech' doc whose title and body share tokens with the long question; it should appear in the top-K despite the question's length.""" main_db, qa_db = _build_titled_corpus(tmp_path) long_q = ( "what technology may enable one person to reconstruct and " "understand another persons thoughts or ideas without " "speaking or sign language using neurotechnology and " "transcranial brain reading" ) result = query( question=long_q, qa_db=qa_db, chat_client=StubClient(answer="answer"), model_id="m", single_db=main_db, top_k=3, ) # Pipeline completed; if any sources were found at all, the # neurotech doc (richest body match) should be among them. if result["status"] != "no_sources": uris = [s["document_uri"] for s in result.get("sources") or []] # Neurotech doc has matching tokens; should appear. assert any("neurotech" in u for u in uris), ( f"neurotech doc missing from sources {uris}" ) # --------------------------------------------------------------------- # Ticket #000007 — query-layer hyphen folding # --------------------------------------------------------------------- def test_unit_hyphen_fold_variants_emits_joined_form(): """`bi-polar` → {"bipolar"}; multiple hyphen runs emit one joined variant each; non-hyphenated input emits nothing.""" from arborist.qa.query import _hyphen_fold_variants assert _hyphen_fold_variants("bi-polar is rare?") == {"bipolar"} assert _hyphen_fold_variants("high-school co-op") == { "highschool", "coop", } assert _hyphen_fold_variants("plain query no hyphens") == set() # Single-letter pieces are still folded — `X-ray` → `xray`. assert "xray" in _hyphen_fold_variants("X-ray imaging") def test_unit_title_query_tokens_includes_hyphen_fold_additively(): """Hyphen-fold is additive — `bi-polar is rare?` produces both the split forms (bi, polar) AND the joined form (bipolar).""" from arborist.qa.query import _title_query_tokens toks = _title_query_tokens("bi-polar is rare?") assert "bi" in toks assert "polar" in toks assert "bipolar" in toks assert "rare" in toks # `is` is a stopword; should not appear. assert "is" not in toks def test_unit_title_query_tokens_no_hyphen_unchanged(): """Non-hyphen input behaves exactly as before — pin that the fold doesn't add spurious tokens for plain queries.""" from arborist.qa.query import _title_query_tokens assert _title_query_tokens("anarchism political philosophy") == { "anarchism", "political", "philosophy", } def test_integration_hyphenated_query_retrieves_joined_title(tmp_path): """Ticket #000007 reproduction: a query with a hyphenated form must reach a corpus document whose title uses the joined form. Corpus contains TWO docs: - `Bipolar disorder` (joined-form title — what we want) - `Bi-Polar Blues` (hyphenated-form title — irrelevant album) Pre-fix: query `bi-polar is rare?` only retrieved `Bi-Polar Blues` because FTS5 split both query and album-title on the hyphen while leaving `Bipolar disorder` indexed as a single token. Post-fix: the joined-form variant `bipolar` enters the title-FTS OR-pool; accept-path 5 in `_filter_by_title_relevance` rescues `Bipolar disorder` from the breadth gate. Both docs surface. """ main_db = tmp_path / "corpus.db" qa_db = tmp_path / "qa.db" docs = [ _doc( "test://bipolar-disorder", "Bipolar disorder is a mental health condition. " * 20 + "Bipolar disorder is rare in the elderly population. " * 5, ), _doc( "test://bi-polar-blues", "Bi-Polar Blues is a 1995 jazz album. " * 20, ), ] # Override default title (last URI segment) so titles match the # FTS5 hyphen-asymmetry shape we care about. docs[0].title = "Bipolar disorder" docs[1].title = "Bi-Polar Blues" conn = connect(main_db) try: ingest_source(conn, FakeSource(docs)) finally: conn.close() result = query( question="bi-polar is rare?", 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.get("sources") or []] # `Bipolar disorder` MUST surface — the whole point of the ticket. assert any("bipolar-disorder" in u for u in src_uris), ( f"Ticket #000007 regression: bipolar-disorder not in {src_uris}" ) def test_query_extra_shards_pulls_in_separate_db(tmp_path): """`extra_shards` searches a separate db alongside the main corpus — the seam the CLI uses to auto-include the local crawl db without folding it into the peer-shared main shards.""" main_db = tmp_path / "corpus.db" crawl_db = tmp_path / "web.db" conn = connect(main_db) try: ingest_source(conn, FakeSource(DOCS)) finally: conn.close() crawl_doc = _doc( "test://virtback", "Virtback is a python libvirt backup utility for kvm xen virtualbox. " * 12 + "Virtback restores domains from backups. " * 8, ) conn = connect(crawl_db) try: ingest_source(conn, FakeSource([crawl_doc])) finally: conn.close() client = StubClient( answer='Per the source: "Virtback is a python libvirt backup utility for kvm xen virtualbox"' ) # Without extra_shards: the crawl-only topic is absent from the main corpus. r_without = query( question="What is virtback?", qa_db=tmp_path / "qa1.db", chat_client=client, model_id="m", single_db=main_db, top_k=3, ) assert all( "virtback" not in s["document_uri"] for s in r_without.get("sources") or [] ) # With extra_shards: the separate crawl db is searched alongside it. r_with = query( question="What is virtback?", qa_db=tmp_path / "qa2.db", chat_client=client, model_id="m", single_db=main_db, extra_shards=[crawl_db], top_k=3, ) assert any( "virtback" in s["document_uri"] for s in r_with.get("sources") or [] ), f"extra_shards crawl db not searched: {[s['document_uri'] for s in r_with.get('sources') or []]}"