SurfSense/surfsense_evals/scripts/summarise_parser_compare_run.py
DESKTOP-RTLN3BA\$punk 9bcd50164d feat(evals): publish multimodal_doc parser_compare benchmark + n=171 report
Adds the full parser_compare experiment for the multimodal_doc suite:
six arms compared on 30 PDFs / 171 questions from MMLongBench-Doc with
anthropic/claude-sonnet-4.5 across the board.

Source code:
- core/parsers/{azure_di,llamacloud,pdf_pages}.py: direct parser SDK
  callers (Azure Document Intelligence prebuilt-read/layout, LlamaParse
  parse_page_with_llm/parse_page_with_agent) used by the LC arms,
  bypassing the SurfSense backend so each (basic/premium) extraction
  is a clean A/B independent of backend ETL routing.
- suites/multimodal_doc/parser_compare/{ingest,runner,prompt}.py:
  six-arm benchmark (native_pdf, azure_basic_lc, azure_premium_lc,
  llamacloud_basic_lc, llamacloud_premium_lc, surfsense_agentic) with
  byte-identical prompts per question, deterministic grader, Wilson
  CIs, and the per-page preprocessing tariff cost overlay.

Reproducibility:
- pyproject.toml + uv.lock pin pypdf, azure-ai-documentintelligence,
  llama-cloud-services as new deps.
- .env.example documents the AZURE_DI_* and LLAMA_CLOUD_API_KEY env
  vars now required for parser_compare.
- 12 analysis scripts under scripts/: retry pass with exponential
  backoff, post-retry accuracy merge, McNemar / latency / per-PDF
  stats, context-overflow hypothesis test, etc. Each produces one
  number cited by the blog report.

Citation surface:
- reports/blog/multimodal_doc_parser_compare_n171_report.md: 1219-line
  technical writeup (16 sections) covering headline accuracy, per-format
  accuracy, McNemar pairwise significance, latency / token / per-PDF
  distributions, error analysis, retry experiment, post-retry final
  accuracy, cost amortization model with closed-form derivation, threats
  to validity, and reproducibility appendix.
- data/multimodal_doc/runs/2026-05-14T00-53-19Z/parser_compare/{raw,
  raw_retries,raw_post_retry}.jsonl + run_artifact.json + retry summary
  whitelisted via data/.gitignore as the verifiable numbers source.

Gitignore:
- ignore logs_*.txt + retry_run.log; structured artifacts cover the
  citation surface, debug logs are noise.
- data/.gitignore default-ignores everything, whitelists the n=171 run
  artifacts only (parser manifest left ignored to avoid leaking local
  Windows usernames in absolute paths; manifest is fully regenerable
  via 'ingest multimodal_doc parser_compare').
- reports/.gitignore now whitelists hand-curated reports/blog/.

Also retires the abandoned CRAG Task 3 implementation (download script,
streaming Task 3 ingest, CragTask3Benchmark + tests) and trims the
runner / ingest module APIs to match.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 19:54:41 -07:00

122 lines
4.4 KiB
Python

"""Slice the parser_compare raw.jsonl for the n=171 run.
Reports per-arm:
* tokens & cost stats (input/output mean, $/Q distribution)
* failures (status != ok or empty raw_text)
* answer_format breakdown (accuracy by str/int/float/list)
Plus surfsense agentic breakdown so we can compare apples to apples
even though the new_chat SSE doesn't surface per-call token counts.
"""
from __future__ import annotations
import json
import statistics
from collections import defaultdict
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
RUN_DIR = REPO / "data" / "multimodal_doc" / "runs" / "2026-05-14T00-53-19Z" / "parser_compare"
RAW = RUN_DIR / "raw.jsonl"
ARTIFACT = RUN_DIR / "run_artifact.json"
def main() -> None:
rows = [json.loads(line) for line in RAW.read_text(encoding="utf-8").splitlines() if line.strip()]
print(f"raw rows: {len(rows)}")
by_qid: dict[str, list[dict]] = defaultdict(list)
for row in rows:
by_qid[row["qid"]].append(row)
print(f"unique questions: {len(by_qid)}")
arm_metrics: dict[str, dict] = defaultdict(lambda: {
"n": 0, "n_correct": 0, "n_failed": 0, "n_empty": 0,
"costs": [], "in_tokens": [], "out_tokens": [], "latency_ms": [],
"by_format": defaultdict(lambda: {"n": 0, "correct": 0}),
})
for row in rows:
arm = row["arm"]
m = arm_metrics[arm]
m["n"] += 1
graded = row.get("graded") or {}
if graded.get("correct"):
m["n_correct"] += 1
err = row.get("error")
raw_text = row.get("raw_text") or ""
if err:
m["n_failed"] += 1
elif not raw_text.strip():
m["n_empty"] += 1
cost = row.get("cost_usd")
if cost is not None:
m["costs"].append(float(cost))
ut = row.get("usage") or {}
if ut.get("prompt_tokens"):
m["in_tokens"].append(ut["prompt_tokens"])
if ut.get("completion_tokens"):
m["out_tokens"].append(ut["completion_tokens"])
if row.get("latency_ms"):
m["latency_ms"].append(row["latency_ms"])
fmt = row.get("answer_format") or "unknown"
m["by_format"][fmt]["n"] += 1
if graded.get("correct"):
m["by_format"][fmt]["correct"] += 1
print()
print("=" * 100)
print(f"{'arm':<25} {'n':>4} {'acc%':>6} {'F1%':>6} {'fail':>5} {'$ mean':>10} {'$ median':>10} {'in tok mean':>12} {'out tok mean':>12} {'p50 ms':>8}")
print("=" * 100)
art = json.loads(ARTIFACT.read_text(encoding="utf-8"))
per_arm_art = art["metrics"]["per_arm"]
for arm, m in sorted(arm_metrics.items()):
acc = m["n_correct"] / m["n"] * 100
fail = m["n_failed"]
cost_mean = statistics.mean(m["costs"]) if m["costs"] else 0.0
cost_med = statistics.median(m["costs"]) if m["costs"] else 0.0
in_mean = statistics.mean(m["in_tokens"]) if m["in_tokens"] else 0
out_mean = statistics.mean(m["out_tokens"]) if m["out_tokens"] else 0
lat_p50 = statistics.median(m["latency_ms"]) if m["latency_ms"] else 0
f1 = per_arm_art.get(arm, {}).get("f1_mean", 0.0) * 100
print(
f"{arm:<25} {m['n']:>4} {acc:>5.1f}% {f1:>5.1f}% {fail:>5} "
f"${cost_mean:>9.4f} ${cost_med:>9.4f} {in_mean:>12.0f} {out_mean:>12.0f} {lat_p50:>8.0f}"
)
print()
print("by answer_format (accuracy):")
formats = sorted({f for m in arm_metrics.values() for f in m["by_format"].keys()})
header = f"{'arm':<25} " + " ".join(f"{f:>10}" for f in formats)
print(header)
print("-" * len(header))
for arm, m in sorted(arm_metrics.items()):
cells = []
for f in formats:
row = m["by_format"][f]
if row["n"] == 0:
cells.append(f"{'-':>10}")
else:
pct = row["correct"] / row["n"] * 100
cells.append(f"{pct:>5.0f}% ({row['correct']:>2}/{row['n']:>2})")
print(f"{arm:<25} " + " ".join(cells))
print()
print("=" * 100)
print("Aggregated cost (from run_artifact.json):")
for arm, row in per_arm_art.items():
print(
f" {arm:<25} acc={row['accuracy']*100:5.1f}% "
f" $/Q LLM={row['llm_cost_per_q']:.4f} "
f" preprocess total=${row['preprocess_cost_total']:.2f} "
f" $/Q total={row['total_cost_per_q']:.4f}"
)
if __name__ == "__main__":
main()