Replaces the hand-rolled BM25 sidecar (arborist/wallet/sidecar.py, ~690 LOC) with a slim SQLite file that just COPIES the source shard's FTS5 shadow tables verbatim + minimal doc/chunk metadata. Cloud retrieval then runs SQLite FTS5 bm25() on the same bytes the local shard uses — bit-for-bit parity by construction. 5/5 source + audit_mode agreement on the smoke fixture between local corpus-query and cloud-query against the new manifest-fts.json. Why --- Custom binary sidecar was per-document BM25; main encyclopedia articles got length-normalized so hard that on "why did the dinosaurs go extinct?" "Edwina, the Dinosaur Who Didn't Know She Was Extinct" beat "Dinosaur" (measured cloud-vs-local divergence). Local FTS5 indexes per-chunk so each chunk is a moderate-length doc and the main article wins multiply. Different granularity, not a tuning knob — fix is to use the same indexer cloud-side. What ships ---------- - arborist/wallet/fts_sidecar_build.py — builder. ATTACH source shard, copy documents (root/uri/title only), copy chunks (id/root/idx/leaf only, NO content), CREATE VIRTUAL TABLE chunks_fts/documents_fts with same DDL as source, bulk-copy the four shadow tables verbatim, VACUUM. 8.78 GB shard → 2.15 GB sidecar (24.5%) in ~45 s; full 4-shard wiki corpus 37.4 GB → 8.1 GB (21.7%) in ~3 min. - arborist/wallet/bucket.py: FtsSidecarShardClient — downloads slim sidecar once into ~/.arborist/sidecar-fts-cache/<hash>.idx.db, opens read-only sqlite3 (check_same_thread=False for parallel shard fan- out), runs FTS5 MATCH locally. Chunk content fetches via blobs/<hash> with HTTP-range big-shard fallback when blobs aren't published. MultiShardSidecarCorpus simplified to fts_sidecar_url ∨ bucket-direct (both are FTS5 backends; merge by raw bm25 MIN ascending). - arborist/qa/corpus.py: SidecarBucketCorpus.higher_is_better=False (FTS5 bm25 is negative, lower=better). chunks_for_doc dispatches on fetch_chunk_body attr for the slim-FTS5 client. apply_title_boost imports tokenizer helpers from new arborist/qa/_text_norm.py. - arborist/qa/_text_norm.py — fold_accents, numeral_expand, tokenize_text, STOPWORDS — extracted from the deleted sidecar.py so apply_title_boost keeps its lexical shape. - arborist/cli.py: `arborist sidecar build-fts` subcommand; old `sidecar build`/`sidecar search` removed. cloud_query recognizes fts_sidecar_url + sidecar_url alike. - Makefile: `sidecar-build-fts` + `sidecar-build-fts-all` targets; `sidecar-build` + `sidecar-search` removed. - scripts/upload_fts_sidecars.py — boto3 producer: uploads slim sidecars to clones/sidecars-fts/<n>.idx.db, publishes clones/manifest-fts.json (4 wikipedia shards inherit existing shard_url for content fallback; ACL public-read; idempotent on size match). Existing manifest-sidecar.json untouched. - tests/test_qa_corpus_functional.py + test_qa_corpus_integration.py converted from build_sidecar → build_fts_sidecar; 6 fixtures pass. - bench/slim_fts_parity_bench.py — local 3-way bench (legacy/corpus/slim_fts) over the smoke fixture. Validation ---------- - Per-shard FTS5 parity: slim sidecar returns IDENTICAL rowids + bm25 scores to the source shard for top-10 of "dinosaurs extinct". - 3-way bench (legacy local / corpus local / slim-FTS5 over real bucket fallback): 5/5 source agreement AND 5/5 audit_mode agreement between corpus and slim_fts. Q5 legacy disagreement (Edwina vs Dinosaur) is the pre-existing 2000-line query() retrieval quirk, unrelated. - End-to-end cloud query against published manifest-fts.json (cold- start, ~149 s sidecar download once): STRICT · Dinosaur, every quote verified (2/2). - Full pytest suite: 2737 passed, 28 skipped, 1 xfailed. One pre- existing failure (tests/test_doc_counts.py — claim_pack docs row- count drift) and one pre-existing cold_object failure, both reproduce on main HEAD. Bucket state ------------ - s3://arborist/clones/sidecars-fts/00[0-3].idx.db (8.1 GB) — new - s3://arborist/clones/manifest-fts.json — new - s3://arborist/clones/manifest-sidecar.json — kept live (deprecated but still readable; downstream callers should switch to manifest-fts.json)
265 lines
9.3 KiB
Python
265 lines
9.3 KiB
Python
"""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.fts_sidecar_build import build as build_fts_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 + slim FTS5 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.idx.db"
|
|
build_fts_sidecar(str(bucket_shard), str(sidecar_path), verbose=False)
|
|
|
|
# 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",
|
|
"fts_sidecar_url": f"{base_url}/clones/snap-1/000.idx.db",
|
|
}],
|
|
blob_base=f"{base_url}/blobs",
|
|
snapshot_root=None,
|
|
snapshot_ts=None,
|
|
)
|
|
multi = MultiShardSidecarCorpus(
|
|
manifest, fts_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
|