SurfSense/surfsense_evals/scripts/test_context_overflow_hypothesis.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

155 lines
5.2 KiB
Python

"""Test the hypothesis: were the LC-arm errors actually context-window
overflow errors disguised as SSL / network failures?
If true, we'd expect:
(a) literal "prompt is too long" / "context_length_exceeded" / "exceeds .* tokens" strings,
(b) failures correlated with extraction size / input_tokens (large doc -> failure),
(c) failing requests near or over Sonnet 4.5's 200k input-token limit.
If false (transport-layer hypothesis), we'd expect:
(a) only SSL / 502 / empty stream / JSONDecode strings,
(b) failures NOT correlated with size (uniform across PDFs by time, not by tokens),
(c) failing requests well below the 200k limit.
"""
from __future__ import annotations
import json
import statistics
from collections import defaultdict
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
RUN = REPO / "data" / "multimodal_doc" / "runs" / "2026-05-14T00-53-19Z" / "parser_compare"
RAW = RUN / "raw.jsonl"
MANIFEST = REPO / "data" / "multimodal_doc" / "maps" / "parser_compare_doc_map.jsonl"
CONTEXT_HINTS = (
"context_length",
"context window",
"prompt is too long",
"exceeds",
"maximum context",
"input tokens",
"too many tokens",
"over the maximum",
"200000",
"200_000",
)
def main() -> None:
rows = [
json.loads(line) for line in RAW.read_text(encoding="utf-8").splitlines()
if line.strip()
]
extraction_size: dict[tuple[str, str], int] = {}
for line in MANIFEST.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
m = json.loads(line)
for arm, ext in (m.get("extractions") or {}).items():
extraction_size[(m["doc_id"], arm)] = int(ext.get("chars") or 0)
print("=" * 80)
print("(a) Literal 'context window' / 'prompt too long' error strings?")
print("=" * 80)
found = 0
for row in rows:
err = (row.get("error") or "").lower()
if not err:
continue
for hint in CONTEXT_HINTS:
if hint in err:
print(f" {row['arm']:<25} {row['qid']:<50}")
print(f" -> {err[:240]}")
found += 1
break
if not found:
print(" none found.")
print()
print("=" * 80)
print("(b) Extraction size for OK vs FAILED rows per arm")
print("=" * 80)
arm_buckets: dict[str, dict[str, list[int]]] = defaultdict(
lambda: {"ok": [], "fail": []}
)
parser_arms = (
"azure_basic_lc", "azure_premium_lc",
"llamacloud_basic_lc", "llamacloud_premium_lc",
)
for row in rows:
arm = row["arm"]
if arm not in parser_arms:
continue
size = extraction_size.get((row["doc_id"], arm), 0)
bucket = "fail" if (row.get("error") or not (row.get("raw_text") or "").strip()) else "ok"
arm_buckets[arm][bucket].append(size)
print(f"{'arm':<25} {'bucket':<5} {'n':>4} {'mean chars':>12} {'median':>10} {'max':>10}")
for arm in parser_arms:
for bucket in ("ok", "fail"):
sizes = arm_buckets[arm][bucket]
if not sizes:
print(f" {arm:<23} {bucket:<5} {0:>4} -")
continue
print(
f" {arm:<23} {bucket:<5} {len(sizes):>4} "
f"{statistics.mean(sizes):>12,.0f} "
f"{statistics.median(sizes):>10,.0f} "
f"{max(sizes):>10,}"
)
print()
print("=" * 80)
print("(c) Largest extraction each arm processed *successfully* vs *failed*")
print("=" * 80)
print(
"(Sonnet 4.5 input limit ~200k tokens ~= 800k chars. If failures were "
"context-overflow, max-OK would be near that cap. If max-OK is well "
"above max-FAIL, the model handled bigger contexts than the failed "
"ones, so size cannot be the cause.)"
)
print()
for arm in parser_arms:
ok_sizes = arm_buckets[arm]["ok"]
fail_sizes = arm_buckets[arm]["fail"]
if not ok_sizes:
continue
max_ok = max(ok_sizes)
max_fail = max(fail_sizes) if fail_sizes else 0
print(
f" {arm:<25} max OK = {max_ok:>10,} chars (~{max_ok / 4:>7,.0f} tokens) "
f"max FAIL = {max_fail:>10,} chars (~{max_fail / 4:>7,.0f} tokens)"
)
print()
print("=" * 80)
print("(d) Did the *known* overflow candidate fail?")
print("=" * 80)
print(
" 3M_2018_10K x llamacloud_premium = 908,733 chars (~227k tokens) "
"-- this is above Sonnet 4.5's 200k window."
)
print(" If transport hypothesis is correct, this should still fail with a "
"real overflow error.")
print(" If transport hypothesis is correct AND the model truncates silently, "
"it might 'succeed' but be wrong.")
print()
for row in rows:
if row["doc_id"] != "3M_2018_10K.pdf":
continue
if row["arm"] != "llamacloud_premium_lc":
continue
err = row.get("error") or "(none)"
graded = row.get("graded") or {}
print(
f" {row['qid']:<40} correct={graded.get('correct')!s:<5} "
f"err={err[:100]}"
)
if __name__ == "__main__":
main()