arborist/tests/test_qa_corpus.py
russell@unturf.com d0999957e6
qa/retrieval_routes: lift 5 reranks + unfreeze Hit (Path A first attempt — NOT WIRED)
#000072 Path A first attempt: ported the 5 downstream reranks from
legacy query() into arborist.qa.retrieval_routes:
  - body_density_passes / filter_by_body_density (Corpus.doc_body)
  - rerank_by_source_role (SOURCE_ROLE_RANK_WEIGHTS)
  - rerank_by_title_purity ((1+overlap)*(1+purity), shards_dir-gated
    synonym_expand_strict)
  - rerank_by_ordered_token_match (LCS over title tokens)
  - rerank_by_body_coverage (sqrt body coverage, Corpus.doc_body)

Also unfreezes ``arborist.qa.corpus.Hit`` so the reranks can mutate
.score in place (matches legacy _Hit convention). ChunkRow stays
frozen (it's content-addressable evidence). test_hit_is_frozen test
renamed and inverted.

NOT WIRED INTO run_query: smoke probe with all 5 wired in legacy
order (filter → body_density → body_coverage → source_role →
title_purity → ordered_token → apply_title_boost) made the
multi_route regression WORSE:

  pre-reranks:   3/5 correct (Soviet Union ✓, Mt Kilimanjaro ✓,
                              Mona Lisa ✓, Mercury Seven ✗,
                              dinosaurs ✗)
  post-reranks:  1/5 correct (Soviet Union ✗ → "national bandy team",
                              Mt Kilimanjaro ✓,
                              Mona Lisa ✗ → "Painting Mona Lisa",
                              Mercury Seven ✗ → "305th Air Mobility
                              Wing", dinosaurs ✗)

Root cause: legacy's reranks were tuned against legacy's
candidate-set shape (multi-shard parallel _search_corpus with
body-density baked in EARLIER, over_fetch larger than the per_route
limit I'm using, and a different rivalry-exclusion order). Applying
the same multipliers to my multi_route fan-out's candidate set
lands the cascade in a different basin — short noisy titles with
high stem-overlap get amplified into rank-1 territory.

The helpers stay in tree as importable building blocks for a future
Path A v2 attempt. Possible v2 directions: (a) match legacy's
oversample factor (32+ vs my 4×top_k); (b) apply body-density
filter BEFORE rerank cascade (legacy does this earlier in
_search_corpus); (c) rerun against per-shard route output instead
of post-merge candidates so per-shard discrimination survives.

policy=None / experimental multi_route=True paths unchanged in
behavior — multi_route is still strictly worse than body-only
(documented in #000072) but no longer worse than itself with
reranks; reranks aren't auto-applied.

264 tests pass.
2026-05-31 13:18:33 -04:00

327 lines
12 KiB
Python

"""Unit tests for arborist.qa.corpus — adapter contract conformance.
What this asserts:
* Both adapters implement the Corpus Protocol (runtime_checkable)
* Hit + ChunkRow are frozen and hashable
* NotSupportedError raised by routes adapters don't implement
* SqliteShardCorpus.fts_body sanitizes natural-language input
(no FTS5 syntax leaks through punctuation)
* Empty / stopword-only queries return [] rather than raising
What this does NOT assert (covered in integration / functional):
* Cross-adapter result agreement
* Per-chunk content decompression equivalence
* End-to-end LLM + verifier behaviour
"""
from __future__ import annotations
import sqlite3
from typing import Iterator
import pytest
from arborist.document import Document
from arborist.ingest import ingest_source
from arborist.qa.corpus import (
ChunkRow,
Corpus,
Hit,
NotSupportedError,
SidecarBucketCorpus,
SqliteShardCorpus,
)
from arborist.source import Source
from arborist.store import connect
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
class _FakeSource(Source):
source_type = "test"
def __init__(self, ds): self.ds = ds
def iter_documents(self) -> Iterator[Document]:
yield from self.ds
def _doc(uri: str, content: str, title: str | None = None) -> Document:
return Document(
uri=uri, content=content, source_type="test",
title=title or uri.split("/")[-1],
)
CORPUS_DOCS = [
_doc("test://anarchism", (
"Anarchism is a political philosophy that promotes a stateless society. "
* 6
+ "It seeks to abolish authority. " * 6
), title="Anarchism"),
_doc("test://capital", (
"The eight forms of capital include living, social, intellectual. " * 6
), title="Capital"),
]
@pytest.fixture
def shard_conn(tmp_path):
db = tmp_path / "corpus.db"
c = connect(db)
ingest_source(c, _FakeSource(CORPUS_DOCS))
yield c
c.close()
# ---------------------------------------------------------------------------
# Data-class invariants
# ---------------------------------------------------------------------------
def test_hit_is_mutable_and_equals_by_value():
"""Hit is mutable on purpose so the rerank pipeline in
arborist.qa.retrieval_routes can mutate .score in place (matches
legacy _Hit convention). Equality stays value-based."""
h1 = Hit("root1", "uri1", "title1", 1.5)
h2 = Hit("root1", "uri1", "title1", 1.5)
assert h1 == h2 # value equality
# Mutation works — score is the lever rerank stages use.
h1.score = 2.0
assert h1.score == 2.0
assert h1 != h2 # mutation breaks equality (as expected)
# extras carries a dict so Hit isn't hashable by design — callers
# dedup by (document_root, ...) tuples, not by put-in-a-set.
def test_chunk_row_is_frozen():
c = ChunkRow("root", 0, "hex", "content")
with pytest.raises(Exception):
c.idx = 1 # type: ignore[misc]
def test_hit_extras_defaults_empty():
h = Hit("a", "b", "c", 0.0)
assert h.extras == {}
# ---------------------------------------------------------------------------
# Protocol conformance
# ---------------------------------------------------------------------------
def test_sqlite_shard_corpus_satisfies_protocol(shard_conn):
corpus = SqliteShardCorpus(shard_conn)
assert isinstance(corpus, Corpus)
assert corpus.name == "sqlite-shard"
assert corpus.higher_is_better is False
def test_sidecar_bucket_corpus_satisfies_protocol():
# Doesn't need a live bucket — runtime_checkable Protocol only
# checks method presence, not call signatures.
class _StubMulti:
manifest = type("M", (), {"shards": [], "snapshot_root": None})()
def fts_search(self, q, *, limit=8): return []
def conn_for_shard(self, url): raise KeyError(url)
corpus = SidecarBucketCorpus(_StubMulti())
assert isinstance(corpus, Corpus)
assert corpus.name == "sidecar-bucket"
# FTS5 bm25 is negative; lower (more negative) = better.
assert corpus.higher_is_better is False
# ---------------------------------------------------------------------------
# fts_body sanitization (no FTS5-syntax leaks)
# ---------------------------------------------------------------------------
def test_sqlite_fts_body_handles_natural_language(shard_conn):
"""Punctuation like '?' and '-' would crash raw FTS5. The adapter
must sanitize via _to_fts5 (mirrors cloud sanitizer)."""
corpus = SqliteShardCorpus(shard_conn)
# Question mark + apostrophe must not crash.
hits = corpus.fts_body("what is anarchism?", limit=5)
assert any("Anarchism" in h.title for h in hits)
def test_sqlite_fts_body_stopword_only_query_returns_empty(shard_conn):
"""All-stopword queries reduce to empty FTS5 expressions; the
adapter must short-circuit to [] rather than passing '' to MATCH."""
corpus = SqliteShardCorpus(shard_conn)
hits = corpus.fts_body("the a an of with", limit=5)
assert hits == []
def test_sqlite_fts_body_returns_hit_shape(shard_conn):
corpus = SqliteShardCorpus(shard_conn)
hits = corpus.fts_body("anarchism", limit=2)
assert len(hits) >= 1
for h in hits:
assert isinstance(h, Hit)
assert h.document_root and h.document_uri and h.title
assert isinstance(h.score, float)
# ---------------------------------------------------------------------------
# fts_title + fts_phrase routes (documents_fts + FTS5 phrase MATCH)
# ---------------------------------------------------------------------------
def test_sqlite_fts_title_via_documents_fts(shard_conn):
"""fts_title returns title-bm25 ranked hits from documents_fts."""
corpus = SqliteShardCorpus(shard_conn)
hits = corpus.fts_title("anarchism", limit=5)
# Should rank the Anarchism doc highly via title MATCH.
assert any("Anarchism" in h.title for h in hits)
def test_sqlite_fts_title_stopword_only_returns_empty(shard_conn):
"""All-stopword queries reduce to empty FTS5 — short-circuit."""
corpus = SqliteShardCorpus(shard_conn)
assert corpus.fts_title("the of and") == []
def test_sqlite_fts_phrase_matches_verbatim(shard_conn):
"""FTS5 phrase MATCH '"phrase"' returns chunks containing the
verbatim phrase. Use a phrase known to appear in the fixture doc."""
corpus = SqliteShardCorpus(shard_conn)
# The Anarchism fixture has "political philosophy" verbatim.
hits = corpus.fts_phrase(["political philosophy"], limit=5)
assert hits, "expected at least one hit for verbatim phrase"
assert any("Anarchism" in h.title for h in hits)
# Phrase tokens that DON'T appear together should return nothing.
misses = corpus.fts_phrase(["stateless capital"], limit=5)
assert misses == []
def test_sqlite_fts_phrase_empty_input_returns_empty(shard_conn):
corpus = SqliteShardCorpus(shard_conn)
assert corpus.fts_phrase([]) == []
assert corpus.fts_phrase(["", " "]) == []
def test_sidecar_fts_title_delegates_to_multishard():
"""SidecarBucketCorpus.fts_title delegates to the multi-shard
fts_title_search method. Stub the multi-shard and confirm shape."""
class _StubMulti:
manifest = type("M", (), {"shards": [], "snapshot_root": None})()
def fts_search(self, q, *, limit=8): return []
def fts_title_search(self, q, *, limit=8): return [
{"document_root": "abc", "document_uri": "test://x",
"title": "X", "score": -12.0, "_shard_url": "s0"},
]
def fts_phrase_search(self, ngs, *, limit=8): return []
def conn_for_shard(self, url): raise KeyError(url)
corpus = SidecarBucketCorpus(_StubMulti())
hits = corpus.fts_title("anything")
assert len(hits) == 1
assert hits[0].title == "X"
assert hits[0].shard_id == "s0"
def test_sidecar_fts_phrase_delegates_to_multishard():
class _StubMulti:
manifest = type("M", (), {"shards": [], "snapshot_root": None})()
def fts_search(self, q, *, limit=8): return []
def fts_title_search(self, q, *, limit=8): return []
def fts_phrase_search(self, ngs, *, limit=8): return [
{"document_root": "abc", "document_uri": "test://x",
"title": "X", "score": -23.5},
]
def conn_for_shard(self, url): raise KeyError(url)
corpus = SidecarBucketCorpus(_StubMulti())
hits = corpus.fts_phrase(["any phrase"])
assert len(hits) == 1
assert hits[0].score == -23.5
# ---------------------------------------------------------------------------
# chunks_for_doc shape + ordering
# ---------------------------------------------------------------------------
def test_sqlite_chunks_for_doc_returns_idx_ascending(shard_conn):
corpus = SqliteShardCorpus(shard_conn)
hits = corpus.fts_body("anarchism", limit=1)
assert hits
chunks = corpus.chunks_for_doc(hits[0].document_root)
assert len(chunks) >= 1
indices = [c.idx for c in chunks]
assert indices == sorted(indices)
for c in chunks:
assert isinstance(c, ChunkRow)
assert c.content # decompressed, non-empty
assert c.leaf_hash # ingest sets this
def test_sqlite_snapshot_root_hex(shard_conn):
corpus = SqliteShardCorpus(shard_conn)
root = corpus.snapshot_root()
assert isinstance(root, str)
assert len(root) == 64
int(root, 16) # well-formed hex
# ---------------------------------------------------------------------------
# MultiShardSqliteCorpus — per-shard FTS + merge
# ---------------------------------------------------------------------------
def test_multishard_sqlite_aggregates_hits_across_shards(tmp_path):
"""Two shards each holding a distinct anchor doc. fts_body must
surface BOTH topic-matches when their respective terms are queried,
proving the per-shard merge works rather than the old single-conn
rowid-namespace failure."""
from arborist.qa.corpus import MultiShardSqliteCorpus
# Shard A: anarchism content only.
db_a = tmp_path / "a.db"
c = connect(db_a)
ingest_source(c, _FakeSource([CORPUS_DOCS[0]])) # Anarchism
c.close()
# Shard B: capital content only.
db_b = tmp_path / "b.db"
c = connect(db_b)
ingest_source(c, _FakeSource([CORPUS_DOCS[1]])) # Capital
c.close()
corpus = MultiShardSqliteCorpus([db_a, db_b])
try:
# 'anarchism' hits only shard A.
a_hits = corpus.fts_body("anarchism", limit=3)
assert any("Anarchism" in h.title for h in a_hits)
# 'capital' hits only shard B.
b_hits = corpus.fts_body("capital", limit=3)
assert any("Capital" in h.title for h in b_hits)
# snapshot_root spans BOTH shards' document_roots.
root = corpus.snapshot_root()
assert len(root) == 64
# Hit.shard_id annotated → chunks_for_doc can route.
assert all(h.shard_id is not None for h in a_hits + b_hits)
finally:
corpus.close()
def test_multishard_sqlite_chunks_for_doc_finds_right_shard(tmp_path):
"""chunks_for_doc walks shards until one returns rows. Doc in
shard B should still resolve when shard A is asked first."""
from arborist.qa.corpus import MultiShardSqliteCorpus
db_a = tmp_path / "a.db"
c = connect(db_a); ingest_source(c, _FakeSource([CORPUS_DOCS[0]])); c.close()
db_b = tmp_path / "b.db"
c = connect(db_b); ingest_source(c, _FakeSource([CORPUS_DOCS[1]])); c.close()
corpus = MultiShardSqliteCorpus([db_a, db_b])
try:
b_hits = corpus.fts_body("capital", limit=1)
assert b_hits
chunks = corpus.chunks_for_doc(b_hits[0].document_root)
assert chunks, "chunks_for_doc returned nothing for shard-B-owned doc"
assert any("capital" in c.content.lower() for c in chunks)
finally:
corpus.close()