qa/corpus: Corpus Protocol + SqliteShardCorpus + SidecarBucketCorpus

First step toward DRYing the local-vs-cloud retrieval diff. Defines a
minimal Corpus Protocol (Hit, ChunkRow, fts_body, fts_title, fts_phrase,
chunks_for_doc, snapshot_root) and ships two adapters:

  SqliteShardCorpus    wraps a connect_query() connection. Implements
                       fts_body (via _to_fts5 sanitizer + chunks_fts
                       MATCH) and chunks_for_doc + snapshot_root.
                       fts_title / fts_phrase raise NotSupportedError
                       (those routes will move out of query.py inline
                       SQL in the next refactor pass).

  SidecarBucketCorpus  wraps a MultiShardSidecarCorpus. fts_body
                       delegates to the sidecar BM25 + title-boost +
                       extras-penalty + title-relevance pipeline.
                       chunks_for_doc walks the shard's apsw conn via
                       the bucket VFS, reusing warm page cache.
                       Title / phrase routes raise NotSupportedError
                       until the sidecar format ships those indexes.

Together: query() future-refactor takes a Corpus parameter, runs the
routes the adapter supports, skips NotSupportedError, falls through.
Every quality fix (today: possessive stem, accent fold, numeral fold,
extras-penalty title-boost, title-relevance filter) lands once and
both backends consume it through the protocol.

Tests (20 passing, three layers):

  tests/test_qa_corpus.py             (unit) — Hit/ChunkRow invariants,
    Protocol conformance (runtime_checkable isinstance), NotSupportedError
    on unimplemented routes, fts_body sanitization (no FTS5 syntax leak),
    stopword-only query → [], chunks_for_doc idx-ascending shape.

  tests/test_qa_corpus_integration.py (integration) — real corpus →
    both adapters → assert recall (target doc in top-K of both) and
    byte-identical chunks_for_doc decoding. Catches tokenizer/stem/fold
    drift between FTS5's unicode61 and the sidecar's NFKD+ASCII path.

  tests/test_qa_corpus_functional.py  (functional) — full retrieval +
    StubClient LLM + verify_claim_lattice pipeline routed via the
    protocol for both adapters; asserts shared audit_mode + primary
    source agreement. Prototype of what the post-refactor query() does.

Side fix: MultiShardSidecarCorpus title-relevance filter now falls
open when the title tokenizes to zero content tokens (single-char or
all-stopword titles like "A" or "I" shouldn't be dropped just because
the filter side has nothing to match on).
This commit is contained in:
russell@unturf.com 2026-05-30 22:28:46 -04:00
parent ae842555d8
commit 1f8952ab19
No known key found for this signature in database
5 changed files with 1026 additions and 0 deletions

339
arborist/qa/corpus.py Normal file
View file

@ -0,0 +1,339 @@
"""Corpus protocol — single retrieval interface for local + cloud.
Today's `arborist.qa.query.query()` is tightly coupled to a SQLite
connection (`connect_query(shards_dir=)`), and the cloud path
(`arborist cloud query`) re-implements retrieval against a sidecar
bucket. Every quality fix has to ship twice. This module defines a
minimal Corpus protocol so retrieval can be swapped (local SQLite,
HTTP sidecar, future edge-proxy) while the orchestrator stays single-
source.
Three pieces:
- ``Hit`` what a retrieval route returns
- ``ChunkRow`` what an evidence-builder reads
- ``Corpus`` Protocol with the routes query() needs
Two adapters live in this module:
- ``SqliteShardCorpus`` wraps a connect_query() connection
- ``SidecarBucketCorpus`` wraps MultiShardSidecarCorpus
Future query() refactor will take a ``Corpus`` parameter; until then,
adapters can be used standalone to test parity between backends or to
back the cloud-query path without re-implementing prompt/verify/render
logic.
Routes an adapter doesn't natively support raise ``NotSupportedError``.
The orchestrator catches and skips graceful degradation, no silent
result skew. Sidecar today only supports ``fts_body``; phrase/title-
LIKE/core-keyword routes will land as the sidecar format learns to
ship them.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Iterable, Iterator, Optional, Protocol, runtime_checkable
class NotSupportedError(NotImplementedError):
"""Raised by an adapter when a route is not implemented for that
backend. The orchestrator should catch and skip never propagate."""
@dataclass(frozen=True)
class Hit:
"""One retrieval result, backend-agnostic.
``score`` semantics differ per backend (FTS5 BM25 ranges negative
[-20, 0]; sidecar BM25 ranges positive [+25, +60]). Callers that
merge across backends must use rank or a normalization layer
raw score is NOT comparable across adapters. Within a single
backend, lower-is-better is the FTS5 convention; higher-is-better
is the sidecar convention. The adapter sets ``higher_is_better``
on construction so callers can sort uniformly.
"""
document_root: str
document_uri: str
title: str
score: float
shard_id: Optional[str] = None # which shard surfaced this hit (debug)
extras: dict = field(default_factory=dict) # adapter-specific metadata
@dataclass(frozen=True)
class ChunkRow:
"""One chunk's evidence-builder shape.
``content`` is decompressed UTF-8 prose (callers don't need to know
about the storage encoding). ``leaf_hash`` is the Merkle leaf
required for wallet-side verification, so adapters that can't
surface it (synthesized chunks?) should explicitly return None.
"""
document_root: str
idx: int
leaf_hash: Optional[str]
content: str
@runtime_checkable
class Corpus(Protocol):
"""The retrieval surface query() depends on.
Implementations:
- SqliteShardCorpus local, full feature set
- SidecarBucketCorpus cloud (HTTP), fts_body + chunks_for_doc
Future routes (phrase_5gram, core_keyword, title_like) will land
here as methods that adapters either implement or raise
NotSupportedError.
"""
# Backend identity (for debug + score-comparison decisions).
@property
def name(self) -> str: ...
# `True` if scores from this adapter sort higher-is-better.
@property
def higher_is_better(self) -> bool: ...
# --- retrieval routes ---
def fts_body(self, query: str, *, limit: int = 8) -> list[Hit]:
"""Full-text body search. Tokenizer-agnostic from the caller's
POV: pass a natural-language string; the adapter sanitizes."""
...
def fts_title(self, query: str, *, limit: int = 8) -> list[Hit]:
"""Title-only search. SidecarBucketCorpus today falls back to
fts_body + title-boost re-rank inside its search() call, so
raises NotSupportedError to make the orchestrator's intent
explicit when title-LIKE is desired as a separate route."""
...
def fts_phrase(
self, ngrams: Iterable[str], *, limit: int = 8
) -> list[Hit]:
"""Verbatim n-gram phrase match. Closes the allusion gap (e.g.
"always been at war" 1984). SidecarBucketCorpus raises
NotSupportedError until the sidecar format ships a phrase
index."""
...
# --- evidence reads ---
def chunks_for_doc(
self, document_root: str, *, limit: int | None = None
) -> list[ChunkRow]:
"""Per-doc chunk rows in idx-ascending order."""
...
# --- corpus identity ---
def snapshot_root(self) -> str:
"""Hex of the corpus Merkle commit. Required for wallet-side
provenance binding."""
...
# ===========================================================================
# Adapter: SqliteShardCorpus — local SQLite shards via existing connect().
# ===========================================================================
class SqliteShardCorpus:
"""Adapter over a local SQLite connection (single-shard or multi-
shard via connect_query()). Default for `arborist query`.
Today this implements only fts_body + chunks_for_doc + snapshot_root.
Other routes raise NotSupportedError; the future query.py refactor
will lift the existing in-line SQL for title-LIKE / phrase / core-
keyword into this adapter so they land here once and both backends
consume them through the protocol.
"""
name = "sqlite-shard"
higher_is_better = False # FTS5 BM25 convention (smaller score = stronger)
def __init__(self, conn):
"""``conn`` is a sqlite3.Connection (or connect_query() result)
with ``chunks``, ``documents``, ``chunks_fts`` accessible. Set
``row_factory = sqlite3.Row`` so SELECT * yields keyed rows."""
import sqlite3 as _sqlite
if conn.row_factory is None:
conn.row_factory = _sqlite.Row
self._conn = conn
def fts_body(self, query: str, *, limit: int = 8) -> list[Hit]:
sql = (
"SELECT d.document_root, d.document_uri, d.title, "
" bm25(chunks_fts) AS score "
"FROM chunks_fts JOIN chunks c ON c.rowid = chunks_fts.rowid "
"JOIN documents d ON d.document_root = c.document_root "
"WHERE chunks_fts MATCH ? "
"ORDER BY score LIMIT ?"
)
# The caller passes natural-language; sanitize on this side so
# FTS5 doesn't choke on punctuation. Mirrors the bucket-direct
# sanitizer (arborist.wallet.bucket._to_fts5).
from arborist.wallet.bucket import _to_fts5
match_expr = _to_fts5(query)
if not match_expr.strip():
return []
rows = list(self._conn.execute(sql, (match_expr, limit)))
return [
Hit(
document_root=r["document_root"],
document_uri=r["document_uri"] or "",
title=r["title"] or "",
score=r["score"],
shard_id=None,
)
for r in rows
]
def fts_title(self, query: str, *, limit: int = 8) -> list[Hit]:
raise NotSupportedError(
"fts_title not yet routed through SqliteShardCorpus — "
"query.py owns title-LIKE today; will move in the next refactor."
)
def fts_phrase(self, ngrams, *, limit: int = 8) -> list[Hit]:
raise NotSupportedError(
"fts_phrase not yet routed through SqliteShardCorpus — "
"query.py owns phrase-pattern today; will move in the next refactor."
)
def chunks_for_doc(
self, document_root: str, *, limit: int | None = None
) -> list[ChunkRow]:
from arborist.compress import unpack_chunk
sql = (
"SELECT idx, leaf_hash, content FROM chunks "
"WHERE document_root = ? AND content IS NOT NULL "
"ORDER BY idx ASC"
)
if limit is not None:
sql += " LIMIT ?"
params = (document_root, limit)
else:
params = (document_root,)
rows = list(self._conn.execute(sql, params))
out: list[ChunkRow] = []
for r in rows:
text = unpack_chunk(r["content"]) or ""
if not text:
continue
out.append(ChunkRow(
document_root=document_root,
idx=r["idx"],
leaf_hash=r["leaf_hash"],
content=text,
))
return out
def snapshot_root(self) -> str:
from arborist.snapshot import compute_snapshot_root
root, _ = compute_snapshot_root(self._conn)
return root
# ===========================================================================
# Adapter: SidecarBucketCorpus — multi-shard sidecar over HTTP.
# ===========================================================================
class SidecarBucketCorpus:
"""Adapter over MultiShardSidecarCorpus (sidecar BM25 + bucket-direct
chunk reads). Default for `arborist cloud query`.
fts_body delegates to the sidecar's BM25 + title boost + extras
penalty. fts_title / fts_phrase raise NotSupportedError until the
sidecar format ships those indexes.
"""
name = "sidecar-bucket"
higher_is_better = True # sidecar BM25 + boost is positive, larger = stronger
def __init__(self, multi_shard):
"""``multi_shard`` is a MultiShardSidecarCorpus (or any object
with fts_search(query, limit) [{document_root, document_uri,
title, score, _shard_url}] and conn_for_shard(shard_url) for
chunk reads)."""
self._mscorpus = multi_shard
def fts_body(self, query: str, *, limit: int = 8) -> list[Hit]:
hits = self._mscorpus.fts_search(query, limit=limit)
return [
Hit(
document_root=h["document_root"],
document_uri=h.get("document_uri", ""),
title=h.get("title", ""),
score=h.get("score", 0.0),
shard_id=h.get("_shard_url"),
extras={"merge_score": h.get("merge_score")} if "merge_score" in h else {},
)
for h in hits
]
def fts_title(self, query: str, *, limit: int = 8) -> list[Hit]:
raise NotSupportedError(
"fts_title not implemented for SidecarBucketCorpus — "
"sidecar today fuses title-boost into fts_body. A separate "
"title-only route would need a sidecar v3 with a title index."
)
def fts_phrase(self, ngrams, *, limit: int = 8) -> list[Hit]:
raise NotSupportedError(
"fts_phrase not implemented for SidecarBucketCorpus — "
"phrase index is sidecar v3 work."
)
def chunks_for_doc(
self, document_root: str, *, limit: int | None = None
) -> list[ChunkRow]:
# We don't know which shard owns the doc without a lookup. The
# caller of fts_body already has Hit.shard_id; the contract here
# accepts a bare document_root and scans every shard's bucket
# connection until one returns rows. Cheap because BucketClient
# caches the .db pages it touched for the FTS search.
from arborist.compress import unpack_chunk
# Try each shard's conn.
for sh in getattr(self._mscorpus, "manifest", None).shards:
try:
conn = self._mscorpus.conn_for_shard(sh["url"])
except (KeyError, AttributeError):
continue
sql = (
"SELECT idx, leaf_hash, content FROM chunks "
"WHERE document_root = ? AND content IS NOT NULL "
"ORDER BY idx ASC"
)
if limit is not None:
sql += f" LIMIT {int(limit)}"
try:
rows = list(conn.execute(sql, (document_root,)))
except Exception:
continue
if not rows:
continue
out: list[ChunkRow] = []
for r in rows:
idx, leaf_hash, content_blob = r
text = unpack_chunk(content_blob) or ""
if not text:
continue
out.append(ChunkRow(
document_root=document_root,
idx=idx, leaf_hash=leaf_hash, content=text,
))
if out:
return out
return []
def snapshot_root(self) -> str:
# Manifest may carry a pre-computed snapshot_root; fall back to
# empty hex when unknown (cloud manifests today leave it null).
sr = getattr(self._mscorpus.manifest, "snapshot_root", None)
return sr or ("00" * 32)

View file

@ -906,6 +906,13 @@ class MultiShardSidecarCorpus:
if not title:
return False
title_tokens = set(tokenize_text(title))
# Fall-open when the title has no content tokens after
# stopword/length filtering — single-char or all-stopword
# titles ("A", "I", "Of Mice and Men") shouldn't be
# filtered out just because the FILTER side has nothing
# to match on. Keep the hit; let BM25 + title-boost rank.
if not title_tokens:
return True
return bool(query_tokens & title_tokens)
per_shard = [
(sh_url, [h for h in hits if _title_relevant(h)])

221
tests/test_qa_corpus.py Normal file
View file

@ -0,0 +1,221 @@
"""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_frozen_and_equals_by_value():
h1 = Hit("root1", "uri1", "title1", 1.5)
h2 = Hit("root1", "uri1", "title1", 1.5)
with pytest.raises(Exception):
h1.score = 2.0 # type: ignore[misc]
assert h1 == h2 # value equality
# 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"
assert corpus.higher_is_better is True
# ---------------------------------------------------------------------------
# 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)
# ---------------------------------------------------------------------------
# Routes that should NotSupportedError
# ---------------------------------------------------------------------------
def test_sqlite_fts_title_not_supported(shard_conn):
corpus = SqliteShardCorpus(shard_conn)
with pytest.raises(NotSupportedError):
corpus.fts_title("anarchism")
def test_sqlite_fts_phrase_not_supported(shard_conn):
corpus = SqliteShardCorpus(shard_conn)
with pytest.raises(NotSupportedError):
corpus.fts_phrase(["anarchism is a"])
def test_sidecar_fts_title_not_supported():
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())
with pytest.raises(NotSupportedError):
corpus.fts_title("anarchism")
def test_sidecar_fts_phrase_not_supported():
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())
with pytest.raises(NotSupportedError):
corpus.fts_phrase(["a b c"])
# ---------------------------------------------------------------------------
# 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

View file

@ -0,0 +1,265 @@
"""Functional tests — full retrieval → context → LLM → verify, both adapters.
Builds the smallest plausible "ask-shaped" pipeline that the future
Corpus-aware query() refactor will collapse into:
hits = corpus.fts_body(question, limit=k)
chunks = []
for h in hits:
chunks.extend(corpus.chunks_for_doc(h.document_root, limit=1))
evidence_map = build_evidence_map([...chunks-as-evidence-dicts...])
prompt = system + EVIDENCE + question + reminder
answer = chat.chat_completion(...)
verdict = verify_claim_lattice(answer, evidence_map)
This module drives that pipeline through SqliteShardCorpus AND
SidecarBucketCorpus with a StubClient (deterministic answer) and
asserts both reach the same audit_mode + cite the same primary
source_root.
It's the SMALLEST canary for the "DRY the cloud + local diff" work:
when the refactor moves query() onto the protocol, this test gates
both adapters in one go.
"""
from __future__ import annotations
import os
import shutil
import sqlite3
import threading
from http.server import ThreadingHTTPServer
from arborist.wallet._range_http_server import RangeHandler as _RangeHandler
from pathlib import Path
from typing import Iterator
import pytest
apsw = pytest.importorskip("apsw")
from arborist.document import Document
from arborist.ingest import ingest_source
from arborist.qa.client import StubClient
from arborist.qa.corpus import SidecarBucketCorpus, SqliteShardCorpus
from arborist.qa.evidence import (
build_evidence_map,
render_evidence_map,
)
from arborist.qa.prompts import (
CLAIM_LATTICE_GROUNDING_REMINDER,
CLAIM_LATTICE_SYSTEM_PROMPT,
)
from arborist.qa.verify import verify_claim_lattice
from arborist.source import Source
from arborist.store import connect
from arborist.wallet.bucket import BucketManifest, MultiShardSidecarCorpus
from arborist.wallet.sidecar import build_sidecar
# ---------------------------------------------------------------------------
# Fixture: same corpus exposed via both adapters.
# ---------------------------------------------------------------------------
CORPUS_DOCS = [
Document(
uri="test://anarchism", source_type="test", title="Anarchism",
content=(
"Anarchism is a political philosophy that promotes a stateless "
"society. " * 6
+ 'The phrase "abolition of authority" describes the core principle '
'of anarchism. ' * 4
),
),
Document(
uri="test://capital", source_type="test", title="Capital",
content=(
"The eight forms of capital include living, social, intellectual, "
"material, financial, experiential, cultural, and spiritual. " * 6
),
),
Document(
uri="test://noise-1", source_type="test", title="Random Noise",
content=("Lorem ipsum dolor sit amet. " * 12),
),
]
class _FakeSource(Source):
source_type = "test"
def __init__(self, ds): self.ds = ds
def iter_documents(self) -> Iterator[Document]:
yield from self.ds
@pytest.fixture
def both_adapters(tmp_path: Path):
"""Yield (sqlite_corpus, sidecar_corpus) over the same content."""
db = tmp_path / "shard.db"
c0 = connect(db)
ingest_source(c0, _FakeSource(CORPUS_DOCS))
c0.execute("PRAGMA wal_checkpoint(FULL)")
c0.close()
# bucket layout + sidecar
bucket = tmp_path / "bucket"
clones = bucket / "clones" / "snap-1"
clones.mkdir(parents=True)
bucket_shard = clones / "000.db"
shutil.copy(db, bucket_shard)
sidecar_path = clones / "000.sidecar.bin"
build_sidecar(bucket_shard, sidecar_path)
# in-process Range-aware server
cwd_before = os.getcwd()
os.chdir(bucket)
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _RangeHandler)
port = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()
base_url = f"http://127.0.0.1:{port}"
manifest = BucketManifest(
shards=[{
"url": f"{base_url}/clones/snap-1/000.db",
"shard_idx": 0,
"label": "test-shard",
"sidecar_url": f"{base_url}/clones/snap-1/000.sidecar.bin",
}],
blob_base=f"{base_url}/blobs",
snapshot_root=None,
snapshot_ts=None,
)
multi = MultiShardSidecarCorpus(
manifest, sidecar_cache_dir=str(tmp_path / "cache"),
)
conn = connect(db)
try:
yield SqliteShardCorpus(conn), SidecarBucketCorpus(multi)
finally:
multi.close()
conn.close()
httpd.shutdown()
os.chdir(cwd_before)
# ---------------------------------------------------------------------------
# The shared end-to-end harness — the prototype of what query() will do
# once it takes a Corpus.
# ---------------------------------------------------------------------------
def _retrieve_and_verify(
corpus, question: str, *, stub_answer: str, top_k: int = 3,
):
"""Mini retrieval pipeline through the Corpus protocol. Returns a
dict with audit_mode + cited document_roots + verdict shape."""
hits = corpus.fts_body(question, limit=top_k)
if not hits:
return {"audit_mode": "UNGROUNDED", "n_quotes": 0, "n_verified": 0,
"sources": [], "cited_roots": set()}
chunks_payload: list[dict] = []
for rank, h in enumerate(hits, 1):
rows = corpus.chunks_for_doc(h.document_root, limit=1)
if not rows:
continue
r = rows[0]
chunks_payload.append({
"source_root": h.document_root,
"document_uri": h.document_uri,
"title": h.title,
"chunk_idx": r.idx,
"chunk_root": r.leaf_hash or "0" * 64,
"span": r.content[:1200],
"source_role": (
"primary_answer_source" if rank == 1 else "background_source"
),
})
evidence_map = build_evidence_map(chunks_payload)
evidence_text = render_evidence_map(evidence_map)
messages = [
{"role": "system", "content": CLAIM_LATTICE_SYSTEM_PROMPT},
{"role": "user", "content": (
f"EVIDENCE:\n\n{evidence_text}\n\n"
f"QUESTION: {question}\n\n"
f"{CLAIM_LATTICE_GROUNDING_REMINDER}"
)},
]
answer = StubClient(answer=stub_answer).chat_completion(
messages, model="stub", temperature=0.0,
)
verdict = verify_claim_lattice(
answer, evidence_map, question=question, warrant_check_enabled=False,
)
cited_roots = {h.document_root for h in hits}
return {
"audit_mode": verdict["audit_mode"],
"n_quotes": verdict["n_quotes"],
"n_verified": verdict["n_verified"],
"cited_roots": cited_roots,
"primary_root": hits[0].document_root if hits else "",
"primary_title": hits[0].title if hits else "",
}
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_pipeline_grounds_anarchism_through_both_adapters(both_adapters):
"""Verbatim-quote answer should land at the same audit_mode on both
adapters and primary-cite the Anarchism doc on both."""
sq, sc = both_adapters
question = "what is the core principle of anarchism?"
# Verbatim from CORPUS_DOCS[0]: 'abolition of authority'
stub = (
'The core principle of anarchism is the "abolition of authority". [E1]'
)
sq_result = _retrieve_and_verify(sq, question, stub_answer=stub)
sc_result = _retrieve_and_verify(sc, question, stub_answer=stub)
assert sq_result["audit_mode"] == sc_result["audit_mode"], (
f"audit_mode differs: sqlite={sq_result['audit_mode']} "
f"sidecar={sc_result['audit_mode']}"
)
# Both must surface the Anarchism doc as primary.
assert "Anarchism" in sq_result["primary_title"]
assert "Anarchism" in sc_result["primary_title"]
def test_pipeline_handles_noise_question_consistently(both_adapters):
"""An off-topic question (no matching doc) must produce UNGROUNDED
/ empty-evidence on both adapters."""
sq, sc = both_adapters
question = "what is the population of mars?"
stub = "Mars has no population because it is uninhabited. [E1]"
sq_result = _retrieve_and_verify(sq, question, stub_answer=stub)
sc_result = _retrieve_and_verify(sc, question, stub_answer=stub)
# Off-topic should land at UNGROUNDED or HYBRID on BOTH paths —
# neither corpus has Mars content. STRICT would be a false positive.
for r in (sq_result, sc_result):
assert r["audit_mode"] in ("UNGROUNDED", "HYBRID"), (
f"unexpected STRICT on off-topic: {r}"
)
def test_pipeline_chunks_for_primary_are_same_bytes(both_adapters):
"""Both adapters must surface byte-identical chunk content for the
same document_root wallet-side Merkle verification depends on it."""
sq, sc = both_adapters
question = "anarchism"
sq_hits = sq.fts_body(question, limit=1)
assert sq_hits
droot = sq_hits[0].document_root
sq_chunks = sq.chunks_for_doc(droot)
sc_chunks = sc.chunks_for_doc(droot)
assert sq_chunks and sc_chunks
for a, b in zip(sq_chunks, sc_chunks):
assert a.content == b.content, (
f"chunk[{a.idx}] content differs by adapter"
)
assert a.leaf_hash == b.leaf_hash

View file

@ -0,0 +1,194 @@
"""Integration tests for Corpus adapters — real corpus → both backends.
Builds a small SQLite shard, runs the same fixtures through:
- SqliteShardCorpus directly
- SidecarBucketCorpus over a sidecar built from the same shard,
served via an in-process HTTP-Range server, opened via apsw VFS
Asserts the two adapters agree on the WINNING document_root for each
fixture query. Score scales differ (FTS5 BM25 is negative, sidecar
BM25 + boost is positive), so we compare TOP-K SETS, not raw scores
the contract is "both backends point at the same primary source for
the same query."
What this catches that unit tests don't:
* Token/stem/fold mismatches between FTS5's unicode61 tokenizer and
the sidecar's NFKD-fold + ASCII-word path
* Sidecar build-time term loss vs FTS5's full-text body coverage
* apsw HttpRangeVFS read-path correctness on real SQLite files
Uses MockBucketBackend-style in-process serving (same trick as
tests/test_wallet_bucket.py) so the suite stays offline.
"""
from __future__ import annotations
import os
import sqlite3
import threading
from http.server import ThreadingHTTPServer
from pathlib import Path
from typing import Iterator
import pytest
apsw = pytest.importorskip("apsw")
from arborist.document import Document
from arborist.ingest import ingest_source
from arborist.qa.corpus import (
SidecarBucketCorpus,
SqliteShardCorpus,
)
from arborist.source import Source
from arborist.store import connect
from arborist.wallet.bucket import (
BucketManifest,
MultiShardSidecarCorpus,
)
from arborist.wallet.sidecar import build_sidecar
# ---------------------------------------------------------------------------
# Range-aware HTTP server (mirrors tests/test_wallet_bucket.py)
# ---------------------------------------------------------------------------
from arborist.wallet._range_http_server import RangeHandler as _RangeHandler
@pytest.fixture
def served_corpus(tmp_path: Path):
"""Build a 4-doc corpus, sidecar it, expose via HTTP. Yields:
(sqlite_conn, multi_shard_corpus, base_url)
"""
class _S(Source):
source_type = "test"
def __init__(self, ds): self.ds = ds
def iter_documents(self) -> Iterator[Document]:
yield from self.ds
db = tmp_path / "shard.db"
conn0 = connect(db)
ingest_source(conn0, _S([
Document(uri=f"test://doc-{i}", source_type="test",
title=f"Doc {i}",
content=(
f"Topic alpha-{i} is described in many sentences here. " * 6
+ f"The unique phrase for entry {i} is 'lemma-{i}'. " * 6
))
for i in range(4)
]))
# Checkpoint WAL + close so the on-disk file is self-contained
# before we copy it into the bucket layout.
conn0.execute("PRAGMA wal_checkpoint(FULL)")
conn0.close()
# Build sidecar from a copy of the shard placed in the bucket layout.
bucket = tmp_path / "bucket"
clones = bucket / "clones" / "snap-1"
clones.mkdir(parents=True)
import shutil
bucket_shard = clones / "000.db"
shutil.copy(db, bucket_shard)
sidecar_path = clones / "000.sidecar.bin"
build_sidecar(bucket_shard, sidecar_path)
# Re-open the original for SqliteShardCorpus tests.
conn = connect(db)
# Serve bucket dir.
cwd_before = os.getcwd()
os.chdir(bucket)
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _RangeHandler)
port = httpd.server_address[1]
t = threading.Thread(target=httpd.serve_forever, daemon=True)
t.start()
base_url = f"http://127.0.0.1:{port}"
manifest = BucketManifest(
shards=[{
"url": f"{base_url}/clones/snap-1/000.db",
"shard_idx": 0,
"label": "test-shard",
"sidecar_url": f"{base_url}/clones/snap-1/000.sidecar.bin",
}],
blob_base=f"{base_url}/blobs",
snapshot_root=None,
snapshot_ts=None,
)
multi = MultiShardSidecarCorpus(manifest, sidecar_cache_dir=str(tmp_path / "cache"))
try:
yield conn, multi, base_url
finally:
multi.close()
conn.close()
httpd.shutdown()
os.chdir(cwd_before)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def _top_doc_root(hits) -> str:
return hits[0].document_root if hits else ""
def test_adapters_both_surface_target_doc_in_top_k(served_corpus):
"""A token unique to one doc must appear in BOTH adapters' top-K.
Note: which doc ranks #1 can differ — sidecar applies a title-boost
that SqliteShardCorpus.fts_body doesn't (its title-LIKE route lands
in the next protocol refactor). The contract here is recall, not
rank: both backends must SEE the right doc in the top results.
"""
conn, multi, _ = served_corpus
sqlite_corpus = SqliteShardCorpus(conn)
sidecar_corpus = SidecarBucketCorpus(multi)
# 'lemma 3' has 'lemma' in every doc but '3' is rarer (Doc 3 title +
# body 'lemma-3'); a sane retrieval surfaces Doc 3 in the top-K of
# both backends.
sq_hits = sqlite_corpus.fts_body("lemma 3", limit=4)
sc_hits = sidecar_corpus.fts_body("lemma 3", limit=4)
assert sq_hits, "sqlite adapter returned no hits"
assert sc_hits, "sidecar adapter returned no hits"
sq_titles = {h.title for h in sq_hits}
sc_titles = {h.title for h in sc_hits}
assert "Doc 3" in sq_titles, f"sqlite top-K missing Doc 3: {sq_titles}"
assert "Doc 3" in sc_titles, f"sidecar top-K missing Doc 3: {sc_titles}"
def test_adapters_chunks_for_doc_decode_equivalently(served_corpus):
"""Same document_root → both adapters return decoded chunk text that
matches byte-for-byte (modulo any unpack_chunk normalization)."""
conn, multi, _ = served_corpus
sq = SqliteShardCorpus(conn)
sc = SidecarBucketCorpus(multi)
sq_hits = sq.fts_body("lemma 3", limit=1)
assert sq_hits
droot = sq_hits[0].document_root
sq_chunks = sq.chunks_for_doc(droot)
sc_chunks = sc.chunks_for_doc(droot)
assert len(sq_chunks) == len(sc_chunks), (
f"chunk count mismatch: sqlite={len(sq_chunks)} sidecar={len(sc_chunks)}"
)
for a, b in zip(sq_chunks, sc_chunks):
assert a.idx == b.idx
assert a.leaf_hash == b.leaf_hash
assert a.content == b.content
def test_sqlite_snapshot_root_matches_compute_snapshot_root(served_corpus):
"""Adapter-reported snapshot_root must match compute_snapshot_root()
on the same connection the wallet relies on this for binding."""
from arborist.snapshot import compute_snapshot_root
conn, _, _ = served_corpus
sq = SqliteShardCorpus(conn)
expected, _ = compute_snapshot_root(conn)
assert sq.snapshot_root() == expected