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)
194 lines
6.8 KiB
Python
194 lines
6.8 KiB
Python
"""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.fts_sidecar_build import build as build_fts_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.idx.db"
|
|
build_fts_sidecar(str(bucket_shard), str(sidecar_path), verbose=False)
|
|
|
|
# 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",
|
|
"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"))
|
|
|
|
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
|