"""OCR-vs-visual-retrieval degradation benchmark (issue #16, docs/prd.md sections 6, 8, and open risk #3). Open risk #3, verbatim: "Whether visual retrieval actually beats a strong OCR baseline on this specific corpus is an open question, not a known result." This module is the harness to answer it -- it is explicitly NOT built to confirm a foregone conclusion. It compares any number of named extraction approaches on the *same* held-out set with the *same* metric (reusing evaluation.field_accuracy, issue #15), split by a real corpus degradation axis, and reports whichever way the numbers come out. The two approaches the PRD contrasts: - baseline: OCR text -> LLM extraction (llm_extraction). Section 6 insists this be a *genuinely strong* baseline, not a strawman -- so it's the same schema-constrained tool-use extractor the product uses, not a weakened one. - visual: ColPali retrieves the relevant page -> multimodal LLM extraction from that page image (the combined pass from resolution.py, issue #10). Plugged in as one or more predictors so "zero-shot" and "lightly adapted" (section 9) are just two entries in the approaches map. The degradation axis is REAL, not synthetic noise (acceptance criterion): it reuses the exact OCR-damage text heuristic from notebooks/01_dataset_exploration.ipynb -- a spurious mid-sentence period (a lowercase letter, '.', whitespace, lowercase letter), which a real sentence boundary, followed by a capital, wouldn't produce. IMPORTANT caveat, surfaced by notebooks/02_colpali_retrieval_exploration.ipynb (cell 22) and carried here so the benchmark can't quietly overclaim: this is a TEXT-level degradation proxy. Text-level and image-level scan degradation are different axes -- that notebook found a case flagged "clean" by this text heuristic whose scanned image was visibly degraded (redaction block, faded print). A clean/degraded split on this proxy measures robustness to *OCR text* damage, which is exactly the axis where visual retrieval is hypothesized to help; it does not claim to measure visual scan quality. Blocked on real results until an LLM API key, ColPali compute, and a ground-truth labeling pass (issue #15) all exist. The harness and the real degraded-subset partition run today; the predictors are injected so the comparison logic is tested without live models. """ from __future__ import annotations import re from dataclasses import dataclass, field from pathlib import Path from typing import Callable, Mapping, Optional from endopath import colpali_retrieval, consensus, llm_extraction, resolution from endopath.evaluation import ( AccuracyReport, CasePredictions, FieldPrediction, GroundTruthCase, field_accuracy, ) # The real OCR-damage heuristic from notebook 01: a spurious mid-sentence # period. A genuine sentence boundary is followed by a capital letter; a # lowercase letter after "word. word" is an OCR artifact. # # Measured over the real corpus, this binary flag fires on ~91% of reports # (500/545 ingested UCEC image cases) -- one spurious period anywhere trips # it, so as a binary split it separates "essentially all reports" from a tiny # clean remainder, not worst-from-typical. ocr_damage_density() below grades # severity (matches per 1000 chars) so the benchmark can select a genuinely # *worst-N%* degraded subset -- still real corpus OCR damage, but a # discriminating axis rather than a near-constant flag. SPURIOUS_MID_SENTENCE_PERIOD = re.compile(r"[a-z]\.\s+[a-z]") DEGRADATION_CAVEAT = ( "Clean/degraded split is on a TEXT-level OCR-damage proxy (spurious " "mid-sentence periods). Text-level and image-level scan degradation are " "different axes (notebook 02 found a text-'clean' case with a visibly " "degraded image); this measures robustness to OCR text damage, not visual " "scan quality." ) Predictor = Callable[[str], CasePredictions] # case_barcode -> predictions def has_ocr_text_damage(text: str) -> bool: return bool(SPURIOUS_MID_SENTENCE_PERIOD.search(text or "")) def ocr_damage_density(text: str) -> float: """Graded OCR-damage severity: spurious-period matches per 1000 chars. The binary flag saturates (~91% of the corpus), so severity is what lets the benchmark rank reports and take the genuinely worst ones. Empty/short text scores 0.0 rather than dividing by zero.""" text = text or "" if not text: return 0.0 matches = len(SPURIOUS_MID_SENTENCE_PERIOD.findall(text)) return matches / (len(text) / 1000.0) def damage_flags_from_scores( scores: Mapping[str, float], *, top_fraction: float = 0.2 ) -> dict[str, bool]: """Turn graded damage scores into a clean/degraded split by flagging the worst `top_fraction` of cases as degraded. Ties at the cutoff are all included on the degraded side, so the split never depends on dict order. This is how the benchmark gets a balanced, meaningfully-worst degraded subset out of a proxy that would otherwise flag almost everything.""" if not 0.0 < top_fraction < 1.0: raise ValueError("top_fraction must be in (0, 1)") if not scores: return {} ordered = sorted(scores.values(), reverse=True) k = max(1, round(len(ordered) * top_fraction)) cutoff = ordered[k - 1] return {barcode: score >= cutoff for barcode, score in scores.items()} def load_ocr_damage_scores(reports_csv_path: Optional[Path | str] = None) -> dict[str, float]: """case_barcode -> graded OCR-damage density over the real corpus CSV.""" import pandas as pd path = Path(reports_csv_path) if reports_csv_path else Path("data") / "raw" / "TCGA_Reports.csv" reports = pd.read_csv(path) return { str(row["patient_filename"]).split(".")[0]: ocr_damage_density(str(row["text"])) for _, row in reports.iterrows() } def load_ocr_damage_flags(reports_csv_path: Optional[Path | str] = None) -> dict[str, bool]: """case_barcode -> whether its OCR text shows the spurious-period damage signal. Runs today against the real corpus CSV -- this is what makes the degraded subset real rather than synthetic. case_barcode is the part of patient_filename before the first '.', matching ingestion.py.""" import pandas as pd path = Path(reports_csv_path) if reports_csv_path else Path("data") / "raw" / "TCGA_Reports.csv" reports = pd.read_csv(path) flags: dict[str, bool] = {} for _, row in reports.iterrows(): case_barcode = str(row["patient_filename"]).split(".")[0] flags[case_barcode] = has_ocr_text_damage(str(row["text"])) return flags @dataclass class BenchmarkReport: # results[approach_name][subset] -> AccuracyReport; subset in {all, clean, degraded} results: dict[str, dict[str, AccuracyReport]] subset_case_counts: dict[str, int] = field(default_factory=dict) def degradation_gap(self, approach: str) -> Optional[float]: """clean accuracy minus degraded accuracy for one approach -- how much that approach loses on OCR-damaged reports. Positive = worse on degraded. None if either subset had nothing to score. Reported as a number, not a verdict; the point of the benchmark is that the sign and size are unknown until measured.""" clean = self.results.get(approach, {}).get("clean") degraded = self.results.get(approach, {}).get("degraded") if clean is None or degraded is None: return None if clean.accuracy is None or degraded.accuracy is None: return None return clean.accuracy - degraded.accuracy def run_benchmark( ground_truth: Mapping[str, GroundTruthCase], damage_by_case: Mapping[str, bool], approaches: Mapping[str, Predictor], *, numeric_tolerance: float = 0.0, numeric_tolerances: Optional[Mapping[str, float]] = None, ) -> BenchmarkReport: """Score every approach on the same held-out set with the same metric, split into all / clean / degraded subsets. A case whose damage flag is unknown lands only in 'all' -- it is not silently assumed clean.""" barcodes = list(ground_truth) clean = [b for b in barcodes if damage_by_case.get(b) is False] degraded = [b for b in barcodes if damage_by_case.get(b) is True] subsets = {"all": barcodes, "clean": clean, "degraded": degraded} results: dict[str, dict[str, AccuracyReport]] = {} for name, predictor in approaches.items(): preds = {b: predictor(b) for b in barcodes} results[name] = {} for subset_name, subset_barcodes in subsets.items(): gt_subset = {b: ground_truth[b] for b in subset_barcodes} results[name][subset_name] = field_accuracy( preds, gt_subset, numeric_tolerance=numeric_tolerance, numeric_tolerances=numeric_tolerances, ) return BenchmarkReport( results=results, subset_case_counts={k: len(v) for k, v in subsets.items()}, ) def format_report(report: BenchmarkReport) -> str: """Plain, neutral rendering -- states the numbers for every approach and subset without framing a winner. Ends with the text-vs-image caveat so a reader can't mistake the degradation axis for visual scan quality.""" lines: list[str] = [] counts = report.subset_case_counts lines.append( f"cases: all={counts.get('all', 0)}, clean={counts.get('clean', 0)}, " f"degraded={counts.get('degraded', 0)}" ) for approach, by_subset in report.results.items(): lines.append(f"\n{approach}:") for subset in ("all", "clean", "degraded"): rep = by_subset.get(subset) if rep is None: continue acc = f"{rep.accuracy:.3f}" if rep.accuracy is not None else "n/a" rec = f"{rep.recall:.3f}" if rep.recall is not None else "n/a" lines.append( f" {subset:8s} accuracy={acc} recall={rec} " f"(answered {rep.n_answered}/{rep.n_applicable})" ) gap = report.degradation_gap(approach) if gap is not None: lines.append(f" clean-minus-degraded accuracy gap: {gap:+.3f}") lines.append(f"\nCAVEAT: {DEGRADATION_CAVEAT}") return "\n".join(lines) # --- Real predictor wirings (thin glue; the comparison logic above is the # tested part). ------------------------------------------------------------ def make_ocr_llm_predictor(text_by_case: Mapping[str, str], *, client=None) -> Predictor: """The strong baseline: the same schema-constrained tool-use extractor the product uses, over OCR text. Not weakened -- section 7 requires the OCR baseline be genuinely strong before visual retrieval is trusted over it.""" def predict(case_barcode: str) -> CasePredictions: text = text_by_case[case_barcode] raw = llm_extraction.extract_raw(text, client=client) fields = llm_extraction.build_checklist_fields(raw, text) return { name: FieldPrediction(value=f.value, confidence=f.confidence) for name, f in fields.items() } return predict def _visual_predictions( text: str, image_paths: list[Path], probe_for: Callable[[str], Callable[[], object]], client, ) -> dict[str, FieldPrediction]: """Shared visual-pipeline loop: for each checklist field, run the field's embedding probe (which page does the retriever flag?) then the combined multimodal LLM extract on that page. `probe_for(name)` returns the field's probe callable -- the one thing that differs between the live-ColPali and precomputed-embeddings variants; everything downstream is identical.""" preds: dict[str, FieldPrediction] = {} for name in colpali_retrieval.FIELD_QUERIES: hit = probe_for(name)() extracted = resolution._build_combined_extract(name, text, image_paths, client=client)(hit) if extracted is not None: preds[name] = FieldPrediction(value=extracted.value, confidence=extracted.confidence) else: preds[name] = FieldPrediction(value=None, confidence=None) return preds def make_visual_llm_predictor( image_paths_by_case: Mapping[str, list[Path]], text_by_case: Mapping[str, str], *, client=None, margin_threshold: float = consensus.DEFAULT_VISUAL_MARGIN_THRESHOLD, ) -> Predictor: """The visual approach: ColPali retrieves the best page per field, then a multimodal LLM extracts from that page image (resolution.py's combined pass). Zero-shot as written; a lightly-adapted retriever would be a second predictor built the same way, not a change here. Embeds each case's pages live with ColPali (torch). When the corpus has already been embedded offline (issue #19), prefer make_precomputed_visual_llm_predictor, which scores the stored vectors in numpy and needs no GPU or model load.""" def predict(case_barcode: str) -> CasePredictions: image_paths = list(image_paths_by_case.get(case_barcode, [])) text = text_by_case.get(case_barcode, "") page_embeddings = resolution._embed_pages_if_available(image_paths) probe_for = lambda name: resolution._build_embedding_probe( # noqa: E731 name, page_embeddings, margin_threshold ) return _visual_predictions(text, image_paths, probe_for, client) return predict def make_precomputed_visual_llm_predictor( image_paths_by_case: Mapping[str, list[Path]], text_by_case: Mapping[str, str], page_embeddings_by_case: Mapping[str, list], query_embeddings: Mapping[str, object], *, client=None, margin_threshold: float = consensus.DEFAULT_VISUAL_MARGIN_THRESHOLD, ) -> Predictor: """Same visual pipeline as make_visual_llm_predictor, but the retrieval half scores precomputed page + query vectors (issue #19/#21) in numpy instead of embedding pages live. This is what makes the benchmark runnable on a corpus that was embedded offline: no torch, no ~6GB model load, no per-case GPU time. The combined multimodal LLM extraction still runs live (that is the approach under test); only the page-retrieval step reads stored vectors. `page_embeddings_by_case` maps case_barcode -> the case's per-page vectors (precomputed_retrieval.load_case_page_embeddings); `query_embeddings` maps field name -> query vector (precomputed_retrieval.load_query_embeddings). A case absent from page_embeddings_by_case falls back to a skipped embedding pass (probe returns None), exactly as a missing image would live.""" def predict(case_barcode: str) -> CasePredictions: image_paths = list(image_paths_by_case.get(case_barcode, [])) text = text_by_case.get(case_barcode, "") page_embs = page_embeddings_by_case.get(case_barcode) probe_for = lambda name: resolution._build_precomputed_embedding_probe( # noqa: E731 name, page_embs, query_embeddings, margin_threshold ) return _visual_predictions(text, image_paths, probe_for, client) return predict