alto-llm-corrector / scripts /benchmark.py
Claude
bench: ECE/Brier calibration harness + blocking ceilings on the real corpus
befdc2b unverified
Raw
History Blame Contribute Delete
13.9 kB
#!/usr/bin/env python3
"""Versioned correction benchmark over the ground-truth corpus (P4.2).
Measures a producer against ``tests/corpus_gt/`` (source vs
human/synthetic reference, same line IDs) and emits a JSON report a
release can cite: library version, corpus version, §11 policy
fingerprint, producer identity, per-case and aggregate metrics.
python scripts/benchmark.py --producer rules --out report.json
python scripts/benchmark.py --producer oracle
python scripts/benchmark.py --producer cassette:recorded.json
Producers:
- ``rules`` — ``default_french_ocr_rules()`` (deterministic, offline);
- ``oracle`` — a cassette derived from the REFERENCE (upper bound: what
a perfect producer would propose; the engine's guards still arbitrate);
- ``cassette:<path>`` — replay a recorded ``{line_id: corrected_text}``
JSON (e.g. captured LLM responses), deterministic and CI-runnable.
Metrics per case: micro-averaged CER/WER before/after (Levenshtein over
characters/tokens vs the reference), improved/degraded/unchanged line
counts, false positives (line already correct, output changed it),
fallback lines, reconcile outcomes, structural losses, latency and peak
memory. The report carries no wall-clock timestamp — two runs on the
same inputs produce comparable documents (latency/memory fields are
informational and naturally vary).
House rule (P4.2): no guard threshold or temperature-ramp default
changes without a measured improvement HERE.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
import time
import tracemalloc
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "packages" / "corrigenda" / "src"))
import corrigenda # noqa: E402
from corrigenda import ( # noqa: E402
CorrectionResult,
EditScript,
ProducerMetadata,
ProducerOptions,
ReplaceLine,
RulesProducer,
Usage,
default_french_ocr_rules,
)
from corrigenda.core.pipeline import CorrectionPipeline # noqa: E402
from corrigenda.core.schemas import ConfidencePolicy, CorrectionRequest # noqa: E402
BENCHMARK_VERSION = "1"
DEFAULT_CORPUS = (
REPO_ROOT / "packages" / "corrigenda" / "tests" / "corpus_gt" / "manifest.json"
)
# ---------------------------------------------------------------------------
# Metrics
# ---------------------------------------------------------------------------
def _levenshtein(a: str | list[str], b: str | list[str]) -> int:
"""Plain two-row DP edit distance (chars for CER, tokens for WER)."""
if a == b:
return 0
if not a:
return len(b)
if not b:
return len(a)
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, start=1):
cur = [i]
for j, cb in enumerate(b, start=1):
cur.append(
min(
prev[j] + 1, # deletion
cur[j - 1] + 1, # insertion
prev[j - 1] + (ca != cb), # substitution
)
)
prev = cur
return prev[-1]
def _rates(pairs: list[tuple[str, str]]) -> tuple[float, float]:
"""Micro-averaged (CER, WER) of (reference, hypothesis) pairs."""
char_dist = sum(_levenshtein(ref, hyp) for ref, hyp in pairs)
char_len = sum(len(ref) for ref, _ in pairs)
word_dist = sum(_levenshtein(ref.split(), hyp.split()) for ref, hyp in pairs)
word_len = sum(len(ref.split()) for ref, _ in pairs)
return (
char_dist / char_len if char_len else 0.0,
word_dist / word_len if word_len else 0.0,
)
# ---------------------------------------------------------------------------
# Producers
# ---------------------------------------------------------------------------
class CassetteProducer:
"""Replay a recorded ``{line_id: corrected_text}`` mapping (P4.2).
Deterministic and offline: lines absent from the cassette (or equal
to their OCR text) get no op — no edit, never an error.
"""
wants_geometry = False
wants_image = False
requires_full_coverage = False
def __init__(self, cassette: dict[str, str], *, label: str) -> None:
self._cassette = cassette
digest = hashlib.sha256(
json.dumps(cassette, sort_keys=True, ensure_ascii=False).encode("utf-8")
).hexdigest()[:16]
self.metadata = ProducerMetadata(name=label, configuration_fingerprint=digest)
async def produce(
self, payload: CorrectionRequest, *, options: ProducerOptions
) -> tuple[EditScript, Usage | None]:
ops = [
ReplaceLine(line_id=ln.line_id, text=self._cassette[ln.line_id])
for ln in payload.lines
if self._cassette.get(ln.line_id, ln.ocr_text) != ln.ocr_text
]
return EditScript(ops=ops), None
def _make_producer(spec: str, reference_texts: dict[str, str]):
if spec == "rules":
return RulesProducer(default_french_ocr_rules())
if spec == "oracle":
return CassetteProducer(reference_texts, label="oracle")
if spec.startswith("cassette:"):
path = Path(spec.split(":", 1)[1])
cassette = json.loads(path.read_text(encoding="utf-8"))
return CassetteProducer(cassette, label="cassette")
raise SystemExit(f"unknown producer {spec!r} (rules | oracle | cassette:<path>)")
# ---------------------------------------------------------------------------
# One case
# ---------------------------------------------------------------------------
class _NullObserver:
def on_event(self, event_type, payload) -> None:
pass
def _line_texts(path: Path) -> dict[str, str]:
document = corrigenda.load(path)
return {
lm.line_id: lm.ocr_text for page in document.manifest.pages for lm in page.lines
}
def run_case(case_dir: Path, case: dict, producer_spec: str) -> dict:
source = case_dir / case["source"]
reference = case_dir / case["reference"]
ref_texts = _line_texts(reference)
document = corrigenda.load(source)
producer = _make_producer(producer_spec, ref_texts)
pipeline = CorrectionPipeline(
producer=producer,
observer=_NullObserver(),
# Phase 2 — the calibration harness scores every line's decision
# confidence against ground truth (ECE/Brier below).
confidence_policy=ConfidencePolicy(mode="report_only"),
)
tracemalloc.start()
started = time.perf_counter()
result: CorrectionResult = pipeline.run_sync(
document_manifest=document.manifest,
source_files=document.source_paths,
run_id=f"benchmark-{case['name']}",
)
latency_s = time.perf_counter() - started
_, peak_bytes = tracemalloc.get_traced_memory()
tracemalloc.stop()
improved = degraded = unchanged = false_positives = 0
before_pairs: list[tuple[str, str]] = []
after_pairs: list[tuple[str, str]] = []
for decision in result.decisions.decisions:
ref = ref_texts.get(decision.ref.line_id)
if ref is None:
continue # reference misses the line — misaligned corpus entry
src, out = decision.source_text, decision.final_text
before_pairs.append((ref, src))
after_pairs.append((ref, out))
d_before = _levenshtein(ref, src)
d_after = _levenshtein(ref, out)
if d_after < d_before:
improved += 1
elif d_after > d_before:
degraded += 1
else:
unchanged += 1
if d_before == 0 and out != ref:
false_positives += 1
cer_before, wer_before = _rates(before_pairs)
cer_after, wer_after = _rates(after_pairs)
pages = max(1, len(document.manifest.pages))
losses = result.report.format_losses
# Phase 2 calibration — (predicted decision confidence, was the
# decided text exactly right) per line, pooled by main() into the
# aggregate ECE/Brier. write_wc stays locked until these numbers,
# measured on a real corpus, say the confidences can be trusted.
calibration_pairs: list[tuple[float, float]] = []
for outcome in result.report.lines:
ref = ref_texts.get(outcome.line_id)
if ref is None or outcome.confidence is None:
continue
calibration_pairs.append(
(
outcome.confidence.decision,
1.0 if outcome.decision.final_text == ref else 0.0,
)
)
return {
"name": case["name"],
"format": case["format"],
"lines": len(after_pairs),
"cer_before": round(cer_before, 6),
"cer_after": round(cer_after, 6),
"wer_before": round(wer_before, 6),
"wer_after": round(wer_after, 6),
"lines_improved": improved,
"lines_degraded": degraded,
"lines_unchanged": unchanged,
"false_positives": false_positives,
"fallback_lines": result.fallback_lines,
"fallback_reasons": result.fallback_reasons,
"reconcile": {
"coherent": result.reconcile_metrics.coherent,
"fallback": result.reconcile_metrics.fallback,
"neutralised": result.reconcile_metrics.neutralised,
},
"format_losses": losses,
"latency_s_per_page": round(latency_s / pages, 4),
"peak_memory_mb": round(peak_bytes / (1024 * 1024), 2),
"calibration": _calibration_metrics(calibration_pairs),
# Popped by main() into the pooled aggregate, never serialized.
"_calibration_pairs": calibration_pairs,
}
def _calibration_metrics(
pairs: list[tuple[float, float]], bins: int = 10
) -> dict[str, float | int]:
"""Brier score + expected calibration error over equal-width bins.
``pairs`` = (predicted confidence, 1.0 if the decided text matched
ground truth else 0.0). Both metrics in [0, 1], lower is better.
"""
if not pairs:
return {"lines": 0, "brier": 0.0, "ece": 0.0, "bins": bins}
brier = sum((p - c) ** 2 for p, c in pairs) / len(pairs)
ece = 0.0
for b in range(bins):
lo, hi = b / bins, (b + 1) / bins
in_bin = [
(p, c) for p, c in pairs if (lo <= p < hi) or (b == bins - 1 and p == hi)
]
if not in_bin:
continue
avg_conf = sum(p for p, _ in in_bin) / len(in_bin)
accuracy = sum(c for _, c in in_bin) / len(in_bin)
ece += abs(avg_conf - accuracy) * len(in_bin) / len(pairs)
return {
"lines": len(pairs),
"brier": round(brier, 6),
"ece": round(ece, 6),
"bins": bins,
}
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--corpus", type=Path, default=DEFAULT_CORPUS)
parser.add_argument("--producer", default="rules")
parser.add_argument("--out", type=Path, default=None)
args = parser.parse_args(argv)
manifest = json.loads(args.corpus.read_text(encoding="utf-8"))
case_dir = args.corpus.parent
cases = [run_case(case_dir, case, args.producer) for case in manifest["cases"]]
# Aggregate: micro over all cases (weighted by reference length via
# the summed distances the per-case rates already encode — recompute
# from line counts would be macro; keep it simple and honest: macro
# across cases, each case already micro across its lines).
def _avg(key: str) -> float:
return round(sum(c[key] for c in cases) / len(cases), 6) if cases else 0.0
# Producer identity + policy fingerprint come from a default pipeline
# around the same producer spec (first case's reference for oracle).
first_ref = (
_line_texts(case_dir / manifest["cases"][0]["reference"])
if manifest["cases"]
else {}
)
producer = _make_producer(args.producer, first_ref)
pipeline = CorrectionPipeline(producer=producer, observer=_NullObserver())
md = pipeline.producer_metadata
report = {
"benchmark_version": BENCHMARK_VERSION,
"lib_version": corrigenda.__version__,
"corpus_version": manifest["corpus_version"],
"config_fingerprint": pipeline.config_fingerprint(),
"producer": {
"spec": args.producer,
"name": md.name,
"version": md.version,
"implementation": md.implementation,
"configuration_fingerprint": md.configuration_fingerprint,
},
"cases": cases,
"aggregate": {
"cases": len(cases),
"lines": sum(c["lines"] for c in cases),
"cer_before": _avg("cer_before"),
"cer_after": _avg("cer_after"),
"wer_before": _avg("wer_before"),
"wer_after": _avg("wer_after"),
"lines_improved": sum(c["lines_improved"] for c in cases),
"lines_degraded": sum(c["lines_degraded"] for c in cases),
"false_positives": sum(c["false_positives"] for c in cases),
"fallback_lines": sum(c["fallback_lines"] for c in cases),
# Phase 2 — micro-pooled over EVERY line of every case (a
# macro average across cases would let a tiny case swamp the
# aggregate calibration).
"calibration": _calibration_metrics(
[pair for c in cases for pair in c["_calibration_pairs"]]
),
},
}
for c in cases:
del c["_calibration_pairs"] # pooled above, never serialized
payload = json.dumps(report, indent=2, ensure_ascii=False)
if args.out:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(payload + "\n", encoding="utf-8")
else:
print(payload)
return 0
if __name__ == "__main__":
raise SystemExit(main())