mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-17 18:35:19 +02:00
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>
155 lines
5.2 KiB
Python
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()
|