arborist/bench/three_way_bench.py
russell@unturf.com 2b8777c947
qa/corpus: shared title-boost (extras penalty) lifted into run_query
The Hank-Scorpio-vs-Mr.-Burns problem: body BM25 alone outranks the
canonical primary-source article ("Homer Simpson") below sibling
articles that incidentally mention the same tokens. The sidecar
pipeline already had title-boost-with-extras-penalty; lift it into
a shared utility that the orchestrator applies to ANY adapter's
fts_body output.

  arborist/qa/corpus.py:apply_title_boost(hits, query, *,
                                          higher_is_better)
    Stems both sides (possessive + plural collapse), numeral-expands
    (7↔VII), accent-folds (é→e). Effective bonus per hit:
      max(0, overlap - extras/2) * boost
    where extras = title tokens NOT in query. Score direction honors
    each adapter's convention (BM25 negative → subtract; sidecar BM25
    positive → add).

  arborist/qa/corpus_query.py:run_query
    Now oversamples fts_body by 4× and reranks via apply_title_boost
    before slicing to top_k. Without oversampling the right primary
    can sit at rank 7-15 in the body BM25 output and get cut before
    the rerank sees it.

bench/three_way_bench.py: NEW — drives same fixture through
  - `arborist query` (legacy 2000-line pipeline)
  - `arborist corpus-query` (new, local shards via Corpus + run_query)
  - `arborist cloud query` (new, sidecar via Corpus + run_query)
  prints 3-column table + flags primary-source disagreements.

Smoke fixture (5 questions, all paths):
  4/5 all-paths agree on primary source (was 2/5 pre-fix)
  1/5 disagrees: dinosaur-extinction
    legacy:  UNGROUNDED · Edwina (children's book)   ← wrong
    corpus:  STRICT     · Dinosaur (main article)    ← right
    cloud:   HYBRID     · Edwina (children's book)   ← wrong
    The new title-boost lifted corpus-query above the legacy here;
    cloud still picks Edwina because SidecarReader.search applies
    title-boost INTERNALLY (it predates the shared util), so the
    extras-penalty stacks weirdly when run_query applies it again.
    Next fix: disable internal boost in SidecarReader once shared
    util is the single source of truth.

26 corpus/wallet/sidecar tests still green.
2026-05-31 08:43:24 -04:00

202 lines
7.2 KiB
Python

"""3-way bench: legacy local vs corpus-query vs cloud-query.
Goal: every fixture question produces the SAME primary-source citation
across all three paths. Diverging primary-source picks are real
regressions even when audit_mode happens to agree.
legacy arborist --shards-dir … query --json --burn --answer-mode claim_lattice ...
corpus-query arborist --shards-dir … corpus-query --json ... (Corpus → run_query)
cloud-query arborist cloud query --bucket-url <manifest> --json ... (Sidecar → run_query)
JSONL output: one row per question with all three path results +
agreement flags so iterations can compare across bench runs.
"""
from __future__ import annotations
import argparse
import datetime as _dt
import json
import os
import subprocess
import sys
import time
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
ARBORIST = REPO / ".venv" / "bin" / "arborist"
DEFAULT_SHARDS = Path.home() / ".arborist" / "shards"
DEFAULT_BUCKET = (
"https://nyc3.digitaloceanspaces.com/arborist/clones/manifest-sidecar.json"
)
DEFAULT_QWEN_ENDPOINT = "https://qwen.ai.unturf.com/v1"
DEFAULT_QWEN_MODEL = "Qwen3.6-27B-UD-Q4_K_XL.gguf"
def _load_fixture(path: Path) -> list[str]:
out = []
for line in path.read_text().splitlines():
s = line.strip()
if not s or s.startswith("#"):
continue
out.append(s)
return out
def _run(cmd: list[str], *, timeout_s: int) -> dict:
t0 = time.time()
try:
proc = subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout_s,
env={**os.environ, "ARBORIST_PROGRESS": "0"},
)
except subprocess.TimeoutExpired:
return {"_error": f"timeout {timeout_s}s", "_elapsed_s": timeout_s}
dt = time.time() - t0
if proc.returncode != 0:
return {"_error": f"exit {proc.returncode}",
"_stderr": proc.stderr[:300], "_elapsed_s": round(dt, 2)}
try:
d = json.loads(proc.stdout)
except json.JSONDecodeError as e:
return {"_error": f"parse: {e}", "_stdout_head": proc.stdout[:200],
"_elapsed_s": round(dt, 2)}
d["_elapsed_s"] = round(dt, 2)
return d
def _legacy(q, *, shards_dir):
return _run([
str(ARBORIST), "--shards-dir", str(shards_dir),
"query", "--top-k", "8", "--json", "--burn",
"--answer-mode", "claim_lattice", q,
], timeout_s=120)
def _corpus(q, *, shards_dir, endpoint, model):
return _run([
str(ARBORIST), "--shards-dir", str(shards_dir),
"corpus-query", q,
"--top-k", "4", "--json",
"--endpoint", endpoint, "--model", model,
], timeout_s=120)
def _cloud(q, *, bucket_url, endpoint, model):
return _run([
str(ARBORIST), "cloud", "query", q,
"--bucket-url", bucket_url,
"--endpoint", endpoint, "--model", model,
"--top-k", "4", "--json",
], timeout_s=240)
def _primary(r: dict) -> dict:
srcs = r.get("sources") or []
if not srcs:
return {"title": "", "uri": ""}
pri = next(
(s for s in srcs if s.get("source_role") == "primary_answer_source"),
srcs[0],
)
return {"title": (pri.get("title") or "")[:55],
"uri": pri.get("document_uri") or ""}
def main():
p = argparse.ArgumentParser()
p.add_argument(
"--fixture", type=Path,
default=REPO / "bench" / "qa_questions_smoke.txt",
)
p.add_argument("--shards-dir", type=Path, default=DEFAULT_SHARDS)
p.add_argument("--bucket-url", default=DEFAULT_BUCKET)
p.add_argument("--endpoint", default=DEFAULT_QWEN_ENDPOINT)
p.add_argument("--model", default=DEFAULT_QWEN_MODEL)
p.add_argument(
"--out-dir", type=Path,
default=REPO / "bench" / "three_way_results",
)
p.add_argument("--skip-legacy", action="store_true")
p.add_argument("--skip-corpus", action="store_true")
p.add_argument("--skip-cloud", action="store_true")
args = p.parse_args()
questions = _load_fixture(args.fixture)
args.out_dir.mkdir(parents=True, exist_ok=True)
stamp = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
out_path = args.out_dir / f"{stamp}.jsonl"
print(f"# {len(questions)} questions from {args.fixture}", file=sys.stderr)
print(f"# shards: {args.shards_dir}", file=sys.stderr)
print(f"# bucket: {args.bucket_url}", file=sys.stderr)
print(f"# llm: {args.endpoint} / {args.model}", file=sys.stderr)
print(file=sys.stderr)
n_full_agreement = 0
n_source_disagree = 0
with open(out_path, "w") as f:
for i, q in enumerate(questions, 1):
print(f"[{i}/{len(questions)}] {q}", file=sys.stderr)
legacy = {} if args.skip_legacy else _legacy(q, shards_dir=args.shards_dir)
corpus = {} if args.skip_corpus else _corpus(
q, shards_dir=args.shards_dir,
endpoint=args.endpoint, model=args.model,
)
cloud = {} if args.skip_cloud else _cloud(
q, bucket_url=args.bucket_url,
endpoint=args.endpoint, model=args.model,
)
row = {"question": q}
for label, r in (("legacy", legacy), ("corpus", corpus), ("cloud", cloud)):
row[label] = {
"audit_mode": r.get("audit_mode"),
"n_verified": r.get("n_verified"),
"n_quotes": r.get("n_quotes"),
"primary": _primary(r),
"elapsed_s": r.get("_elapsed_s"),
"error": r.get("_error"),
}
# Agreement: same primary URI across all PRESENT paths
uris = {
lbl: row[lbl]["primary"]["uri"]
for lbl in ("legacy", "corpus", "cloud")
if row[lbl]["primary"]["uri"]
}
distinct = set(uris.values())
row["agreement"] = {
"n_paths_with_source": len(uris),
"distinct_sources": len(distinct),
"all_agree": len(distinct) <= 1,
}
if row["agreement"]["all_agree"]:
n_full_agreement += 1
else:
n_source_disagree += 1
f.write(json.dumps(row, ensure_ascii=False) + "\n")
f.flush()
for label in ("legacy", "corpus", "cloud"):
r = row[label]
badge = "" if r["primary"]["uri"] and len(distinct) > 1 and r["primary"]["uri"] != min(uris.values()) else ""
err = f" ERR={r['error']}" if r["error"] else ""
print(f" {label:7s} {(r['audit_mode'] or '?'):<25} "
f"{r['primary']['title']}{badge}{err}",
file=sys.stderr)
if not row["agreement"]["all_agree"]:
print(f" ⚠ SOURCE DISAGREEMENT: {len(distinct)} distinct primaries",
file=sys.stderr)
print(file=sys.stderr)
print(f"=== SUMMARY ===", file=sys.stderr)
print(f" {len(questions)} questions", file=sys.stderr)
print(f" {n_full_agreement} all-paths agree on primary source", file=sys.stderr)
print(f" {n_source_disagree} paths-disagree", file=sys.stderr)
print(f" results: {out_path}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())