ledgerlens / eval /run_eval.py
Abdr007's picture
LedgerLens — deployed tree
6741fc6
Raw
History Blame Contribute Delete
27.3 kB
"""Evaluation harness — field-level accuracy and anomaly precision/recall.
Spec §7/§8: *"`eval/` runs the labelled set and prints field-level accuracy —
your resume numbers come from here."*
What it does, in order:
1. Materialises `eval/testset/` as **real files** (PDFs and degraded scans) with a
`labels.json` of ground truth computed from the generator's specs, never read
back from the rendered document.
2. Pushes every file through the **real pipeline** in dependency order, so the
duplicate detector sees the same history the product would.
3. Scores each field with a type-aware comparison — money to the cent, dates as
calendar dates, vendor names normalised the way the duplicate detector
normalises them — and scores anomalies as precision / recall / F1 against the
labels.
Numbers printed here are the numbers that belong on a resume, and the report
records which model produced them so a live figure is never confused with an
offline-baseline one.
python eval/run_eval.py # score the labelled set
python eval/run_eval.py --regenerate # rebuild the documents first
python eval/run_eval.py --json out.json # machine-readable report
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
import time
from dataclasses import dataclass, field
from datetime import UTC, date, datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
_API_ROOT = Path(__file__).resolve().parent.parent / "apps" / "api"
sys.path.insert(0, str(_API_ROOT))
from sqlalchemy import text # noqa: E402
from app.core.bootstrap import init_schema # noqa: E402
from app.core.db import dispose_engine, init_engine, transaction # noqa: E402
from app.core.logging import configure_logging # noqa: E402
from app.core.settings import get_settings # noqa: E402
from app.core.tracing import get_tracer # noqa: E402
from app.deps import get_claude_client # noqa: E402
from app.devtools.corpus import build_eval_corpus # noqa: E402
from app.devtools.documents import degrade_to_scan, render_invoice_pdf # noqa: E402
from app.models.enums import AnomalyType # noqa: E402
from app.models.schemas import parse_date, parse_money # noqa: E402
from app.pipeline.anomaly import normalise_vendor # noqa: E402
from app.pipeline.orchestrator import PipelineOrchestrator # noqa: E402
TESTSET_DIR = Path(__file__).resolve().parent / "testset"
RESULTS_DIR = Path(__file__).resolve().parent / "results"
LABELS_PATH = TESTSET_DIR / "labels.json"
SCALAR_FIELDS = (
"vendor",
"invoice_number",
"issue_date",
"due_date",
"subtotal",
"tax",
"total",
"currency",
"payment_terms",
)
MONEY_FIELDS = frozenset({"subtotal", "tax", "total"})
DATE_FIELDS = frozenset({"issue_date", "due_date"})
_CENT = Decimal("0.01")
# ---------------------------------------------------------------------------
# Scoring
# ---------------------------------------------------------------------------
def _norm_text(value: Any) -> str:
return " ".join(str(value).strip().lower().split()) if value is not None else ""
def field_matches(name: str, expected: Any, observed: Any) -> bool:
"""Type-aware comparison.
Money is compared to the cent rather than as a string, so `1200` and
`"1,200.00"` agree. Vendor names go through the same normalisation the
duplicate detector uses, so `GULF METALS L.L.C.` and `Gulf Metals LLC` agree —
marking those wrong would understate accuracy for a difference the product
itself treats as identical.
"""
if expected in (None, "") and observed in (None, ""):
return True
if expected in (None, "") or observed in (None, ""):
return False
if name in MONEY_FIELDS:
left, right = parse_money(expected), parse_money(observed)
if left is None or right is None:
return False
return abs(left - right) <= _CENT
if name in DATE_FIELDS:
left_date, right_date = parse_date(expected), parse_date(observed)
return left_date is not None and left_date == right_date
if name == "vendor":
return normalise_vendor(str(expected)) == normalise_vendor(str(observed))
if name == "invoice_number":
return _norm_text(expected).replace(" ", "") == _norm_text(observed).replace(" ", "")
return _norm_text(expected) == _norm_text(observed)
def score_line_items(expected: list[dict[str, Any]], observed: list[dict[str, Any]]) -> tuple[int, int]:
"""Positionally matched line items: (matched, expected_total).
A line counts only when description, quantity and amount all agree — a
partial row is not a usable row for an accounts-payable team.
"""
matched = 0
for index, want in enumerate(expected):
if index >= len(observed):
break
got = observed[index]
if (
_norm_text(want.get("description")) == _norm_text(got.get("description"))
and field_matches("qty_num", parse_money(want.get("qty")), parse_money(got.get("qty")))
and field_matches("amount", want.get("amount"), got.get("amount"))
):
matched += 1
return matched, len(expected)
@dataclass
class FieldTally:
correct: int = 0
total: int = 0
@property
def accuracy(self) -> float:
return self.correct / self.total if self.total else 0.0
@dataclass
class DocumentResult:
filename: str
status: str
latency_ms: int
cost_usd: float
lane: str | None
correct_fields: int
total_fields: int
line_items_matched: int
line_items_expected: int
expected_anomalies: set[str]
observed_anomalies: set[str]
mismatches: list[str] = field(default_factory=list)
unreadable: bool = False
#: "generated" (this repository rendered it) or "real" (it did not). Scored
#: separately, because one number over both would describe neither.
population: str = "generated"
@property
def accuracy(self) -> float:
return self.correct_fields / self.total_fields if self.total_fields else 0.0
# ---------------------------------------------------------------------------
# Test set materialisation
# ---------------------------------------------------------------------------
def materialise_testset(*, force: bool) -> list[dict[str, Any]]:
"""Write the labelled documents to disk and return their manifest."""
TESTSET_DIR.mkdir(parents=True, exist_ok=True)
corpus = build_eval_corpus()
if LABELS_PATH.exists() and not force:
manifest: list[dict[str, Any]] = json.loads(LABELS_PATH.read_text())
if all((TESTSET_DIR / entry["filename"]).exists() for entry in manifest):
return manifest
manifest = []
for item in corpus:
pdf = render_invoice_pdf(item.spec)
payload = degrade_to_scan(pdf) if item.as_scan else pdf
(TESTSET_DIR / item.filename).write_bytes(payload)
manifest.append(
{
"filename": item.filename,
"content_type": "image/jpeg" if item.as_scan else "application/pdf",
"is_scan": item.as_scan,
"expected_anomalies": sorted(str(a) for a in item.expected_anomalies),
"tags": list(item.spec.tags),
"ground_truth": item.spec.ground_truth(),
}
)
LABELS_PATH.write_text(json.dumps(manifest, indent=2))
return manifest
PRIVATE_DIR = TESTSET_DIR / "private"
PRIVATE_LABELS = PRIVATE_DIR / "labels.json"
def load_private_manifest() -> list[dict[str, Any]]:
"""Real documents, scored from outside version control.
The generated corpus establishes that the pipeline is correct on the format it
was built for. It cannot say anything about invoices this repository did not
author — one renderer, one layout, and the offline parser's patterns written
against the captions that renderer prints.
A real invoice cannot be committed to answer that. It carries a vendor, an
address, a tax number, line items and the recipient's own details, and this
repository is public; `labels.json` would publish the contents in plaintext
even where the file itself is ignored. So real documents live here, outside
git, and are scored as a **separate population**. Averaging them together with
the generated set would produce a single number describing neither.
Absent directory means absent population — `make eval` stays reproducible for
anyone who clones this.
"""
if not PRIVATE_LABELS.exists():
return []
entries: list[dict[str, Any]] = json.loads(PRIVATE_LABELS.read_text())
missing = [e["filename"] for e in entries if not (PRIVATE_DIR / e["filename"]).exists()]
if missing:
raise SystemExit(
f"{PRIVATE_LABELS} references files that are not present: {', '.join(missing)}"
)
return entries
def _population_scores(results: list[DocumentResult]) -> dict[str, tuple[int, int, int]]:
"""(correct_fields, total_fields, documents) per population."""
scores: dict[str, tuple[int, int, int]] = {}
for result in results:
correct, total, docs = scores.get(result.population, (0, 0, 0))
scores[result.population] = (
correct + result.correct_fields,
total + result.total_fields,
docs + 1,
)
return scores
def _public_document_entries(results: list[DocumentResult]) -> list[dict[str, Any]]:
"""Per-document rows for the persisted report.
The report is committed; the private documents deliberately are not. A real
invoice's *filename* can name a supplier, and `mismatches` quote extracted
values verbatim — so for the real population both are withheld and the row
carries scores under a positional alias instead. Nothing is lost in practice:
the console printed the full detail during the run, which is where a mismatch
gets debugged, and that output is not written anywhere.
"""
entries: list[dict[str, Any]] = []
private_seen = 0
for result in results:
public = result.population == "generated"
if not public:
private_seen += 1
entries.append(
{
"filename": result.filename if public else f"private-{private_seen:02d}",
"population": result.population,
"status": result.status,
"lane": result.lane,
"field_accuracy": round(result.accuracy, 4),
"line_items": f"{result.line_items_matched}/{result.line_items_expected}",
"expected_anomalies": sorted(result.expected_anomalies),
"observed_anomalies": sorted(result.observed_anomalies),
"unreadable_in_mode": result.unreadable,
"mismatches": result.mismatches if public else [],
}
)
return entries
def _blocked_expectations(
results: list[DocumentResult],
manifest: list[dict[str, Any]],
min_history_for_zscore: int,
) -> dict[str, set[str]]:
"""Labelled anomalies whose supporting evidence this run could not read.
Returns `{filename: {anomaly_type, ...}}`. These are excluded from recall so
the metric reflects detector quality rather than which lanes were configured.
"""
# Every scored document, both populations. Keyed on filename because that is
# what a DocumentResult carries; a private entry with a labelled anomaly used
# to miss this map entirely and raise KeyError below.
truth_by_file = {entry["filename"]: entry for entry in manifest}
readable_names = {r.filename for r in results if not r.unreadable}
blocked: dict[str, set[str]] = {}
for position, result in enumerate(results):
if result.unreadable or not result.expected_anomalies:
continue
entry = truth_by_file[result.filename]
vendor = entry["ground_truth"].get("vendor")
priors = [r for r in results[:position]]
for kind in result.expected_anomalies:
if kind == str(AnomalyType.AMOUNT_ZSCORE):
same_vendor_readable = sum(
1
for prior in priors
if prior.filename in readable_names
and truth_by_file[prior.filename]["ground_truth"].get("vendor") == vendor
)
if same_vendor_readable < min_history_for_zscore:
blocked.setdefault(result.filename, set()).add(kind)
elif kind == str(AnomalyType.DUPLICATE):
# The pair is emitted original-then-copy, so the original is the
# most recent readable prior from the same vendor.
original_readable = any(
prior.filename in readable_names
and truth_by_file[prior.filename]["ground_truth"].get("vendor") == vendor
for prior in priors
)
if not original_readable:
blocked.setdefault(result.filename, set()).add(kind)
return blocked
# ---------------------------------------------------------------------------
# Runner
# ---------------------------------------------------------------------------
async def _reset_ledger() -> None:
async with transaction() as session:
await session.execute(
text(
"TRUNCATE anomalies, extractions, llm_traces, audit_log, failed_jobs, documents "
"RESTART IDENTITY CASCADE"
)
)
async def run_eval(*, regenerate: bool, keep_ledger: bool) -> dict[str, Any]:
settings = get_settings()
configure_logging("ERROR")
manifest = materialise_testset(force=regenerate)
private = load_private_manifest()
documents = [(TESTSET_DIR, entry, "generated") for entry in manifest]
documents += [(PRIVATE_DIR, entry, "real") for entry in private]
engine = init_engine(settings)
await init_schema(engine)
if not keep_ledger:
await _reset_ledger()
client = get_claude_client()
orchestrator = PipelineOrchestrator(client=client, settings=settings, tracer=get_tracer())
banner = f"{len(manifest)} generated"
if private:
banner += f" + {len(private)} real"
print(f"\nLedgerLens evaluation — {banner} labelled documents")
print(f"mode={client.mode} router={settings.model_router} extractor={settings.model_extractor}")
print("=" * 100)
tallies: dict[str, FieldTally] = {name: FieldTally() for name in SCALAR_FIELDS}
results: list[DocumentResult] = []
started = time.perf_counter()
for source_dir, entry, population in documents:
payload = (source_dir / entry["filename"]).read_bytes()
outcome = await orchestrator.ingest(
data=payload,
filename=entry["filename"],
declared_content_type=entry["content_type"],
)
await orchestrator.process(
document_id=outcome.document_id,
data=payload,
filename=entry["filename"],
media_type=outcome.media_type,
)
async with transaction() as session:
row = (
await session.execute(
text(
"SELECT d.status, d.latency_ms, d.cost_usd, d.lane, "
" e.vendor, e.invoice_number, e.issue_date, e.due_date, e.subtotal, "
" e.tax, e.total, e.currency, e.payment_terms, e.line_items "
"FROM documents d LEFT JOIN extractions e ON e.document_id = d.id "
"WHERE d.id = :id"
),
{"id": outcome.document_id},
)
).one()
flags = [
str(value)
for (value,) in (
await session.execute(
text("SELECT anomaly_type FROM anomalies WHERE document_id = :id"),
{"id": outcome.document_id},
)
).all()
]
truth = entry["ground_truth"]
mismatches: list[str] = []
field_scores: list[tuple[str, bool]] = []
correct = 0
for name in SCALAR_FIELDS:
expected = truth.get(name)
observed = getattr(row, name, None)
matched = field_matches(name, expected, observed)
if matched:
correct += 1
else:
mismatches.append(f"{name}: expected {expected!r}, got {observed!r}")
field_scores.append((name, matched))
matched_lines, expected_lines = score_line_items(
truth.get("line_items", []), list(row.line_items or [])
)
# A scan has no text layer. Without a vision model there is nothing to
# read, and the pipeline correctly routes it to NEEDS_REVIEW rather than
# inventing fields. Scoring that as an extraction error would blame the
# extractor for a capability the run was not configured to have, so it is
# counted separately and called out explicitly.
unreadable = (
client.mode == "offline" and bool(entry["is_scan"]) and row.vendor is None
)
# Tally only documents this run could actually read.
if not unreadable:
for name, matched in field_scores:
tallies[name].total += 1
if matched:
tallies[name].correct += 1
results.append(
DocumentResult(
filename=entry["filename"],
status=row.status,
latency_ms=int(row.latency_ms or 0),
cost_usd=float(row.cost_usd or 0),
lane=row.lane,
correct_fields=correct,
total_fields=len(SCALAR_FIELDS),
line_items_matched=matched_lines,
line_items_expected=expected_lines,
expected_anomalies=set(entry["expected_anomalies"]),
observed_anomalies=set(flags),
mismatches=mismatches,
unreadable=unreadable,
population=population,
)
)
elapsed = time.perf_counter() - started
# ---- Report ---------------------------------------------------------
print(f"{'document':24} {'status':13} {'lane':7} {'fields':>8} {'lines':>8} {'ms':>6} anomalies")
print("-" * 100)
for result in results:
flags = ",".join(sorted(result.observed_anomalies)) or "-"
print(
f"{result.filename:24} {result.status:13} {result.lane or '-':7} "
f"{result.correct_fields}/{result.total_fields:<6} "
f"{result.line_items_matched}/{result.line_items_expected:<6} "
f"{result.latency_ms:>6} {flags}"
)
for problem in result.mismatches:
print(f"{'':24} └─ {problem}")
readable = [r for r in results if not r.unreadable]
unreadable = [r for r in results if r.unreadable]
total_fields = sum(r.total_fields for r in readable)
correct_fields = sum(r.correct_fields for r in readable)
total_lines = sum(r.line_items_expected for r in readable)
matched_lines = sum(r.line_items_matched for r in readable)
# Anomaly scoring uses readable documents only, and additionally discounts
# expectations whose *evidence* was unreadable. A duplicate cannot be matched
# against an original the run could not read, and a per-vendor z-score needs a
# minimum number of readable priors before it is even computed. Counting those
# as misses would measure the absent vision model, not the detector.
blocked = _blocked_expectations(
results, manifest + private, settings.min_history_for_zscore
)
true_positive = sum(len(r.expected_anomalies & r.observed_anomalies) for r in readable)
false_positive = sum(len(r.observed_anomalies - r.expected_anomalies) for r in readable)
false_negative = sum(
len(r.expected_anomalies - r.observed_anomalies - blocked.get(r.filename, set()))
for r in readable
)
precision = true_positive / (true_positive + false_positive) if (true_positive + false_positive) else 1.0
recall = true_positive / (true_positive + false_negative) if (true_positive + false_negative) else 1.0
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
blocked_expectations = sum(len(r.expected_anomalies) for r in unreadable) + sum(
len(kinds) for kinds in blocked.values()
)
latencies = sorted(r.latency_ms for r in results)
p95 = latencies[min(int(len(latencies) * 0.95), len(latencies) - 1)] if latencies else 0
total_cost = sum(r.cost_usd for r in results)
print("=" * 100)
if unreadable:
names = ", ".join(r.filename for r in unreadable)
print(
f"\n{len(unreadable)} document(s) could not be read in this mode and are"
f" excluded from scoring:\n {names}\n"
" These are scans with no text layer. The pipeline routed them to"
" NEEDS_REVIEW rather than\n inventing fields, which is the correct"
" behaviour. Configure ANTHROPIC_API_KEY to score them\n through the"
" Claude vision lane."
)
print("\nFIELD-LEVEL ACCURACY" + (f" (over {len(readable)} readable documents)" if unreadable else ""))
for name in SCALAR_FIELDS:
tally = tallies[name]
bar = "#" * int(tally.accuracy * 24)
print(f" {name:16} {tally.accuracy:7.1%} {tally.correct:>2}/{tally.total:<2} {bar}")
overall = correct_fields / total_fields if total_fields else 0.0
line_accuracy = matched_lines / total_lines if total_lines else 0.0
print(f" {'-' * 16}")
print(f" {'OVERALL':16} {overall:7.1%} {correct_fields}/{total_fields}")
print(f" {'line items':16} {line_accuracy:7.1%} {matched_lines}/{total_lines}")
populations = _population_scores(readable)
if len(populations) > 1:
# Deliberately not averaged. A combined figure over documents this
# repository rendered and documents it did not would describe neither, and
# the real number is the one worth quoting.
print("\n by population")
for name, (correct, total, docs) in sorted(populations.items()):
share = correct / total if total else 0.0
print(f" {name:12} {share:7.1%} {correct}/{total} over {docs} document(s)")
print("\nANOMALY DETECTION")
print(f" precision {precision:6.1%} recall {recall:6.1%} F1 {f1:6.1%}")
print(
f" true positives {true_positive}, false positives {false_positive}, "
f"false negatives {false_negative}"
)
if blocked_expectations:
detail = "; ".join(
f"{name}: {', '.join(sorted(kinds))} (evidence unreadable in this mode)"
for name, kinds in sorted(blocked.items())
)
print(
f" {blocked_expectations} labelled anomaly/anomalies were not scorable in this mode"
)
if detail:
print(f" {detail}")
print("\nCOST & LATENCY")
print(f" documents {len(results)}")
print(f" mean latency {sum(latencies) / len(latencies):.0f} ms" if latencies else "")
print(f" p95 latency {p95} ms")
print(f" total cost ${total_cost:.4f}")
print(f" cost/document ${total_cost / len(results):.5f}" if results else "")
print(f" wall clock {elapsed:.1f}s")
if client.mode == "offline":
print(
"\nNOTE: these are OFFLINE BASELINE numbers from the deterministic\n"
"rule-based extractor. Set ANTHROPIC_API_KEY and re-run to measure the\n"
"Claude vision + tool-use pipeline. Both are recorded in the report."
)
report: dict[str, Any] = {
"generated_at": datetime.now(UTC).isoformat(),
"mode": client.mode,
"router_model": settings.model_router,
"extractor_model": settings.model_extractor,
"documents": len(results),
"documents_scored": len(readable),
"documents_unreadable_in_mode": [r.filename for r in unreadable],
"populations": {
name: {
"documents": docs,
"correct_fields": correct,
"total_fields": total,
"field_accuracy": round(correct / total, 4) if total else 0.0,
}
for name, (correct, total, docs) in _population_scores(readable).items()
},
"field_accuracy": {name: round(tallies[name].accuracy, 4) for name in SCALAR_FIELDS},
"overall_field_accuracy": round(overall, 4),
"line_item_accuracy": round(line_accuracy, 4),
"anomaly": {
# Keyed on filename, so a private document would be named here too.
"blocked_by_mode": {
name: sorted(kinds)
for name, kinds in blocked.items()
if name in {r.filename for r in results if r.population == "generated"}
},
"blocked_by_mode_private": sum(
len(kinds)
for name, kinds in blocked.items()
if name not in {r.filename for r in results if r.population == "generated"}
),
"precision": round(precision, 4),
"recall": round(recall, 4),
"f1": round(f1, 4),
"true_positives": true_positive,
"false_positives": false_positive,
"false_negatives": false_negative,
},
"latency_ms": {
"mean": round(sum(latencies) / len(latencies), 1) if latencies else 0,
"p95": p95,
},
"cost_usd": {
"total": round(total_cost, 6),
"per_document": round(total_cost / len(results), 6) if results else 0,
},
"per_document": _public_document_entries(results),
}
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
stamped = RESULTS_DIR / f"eval-{client.mode}-{date.today().isoformat()}.json"
stamped.write_text(json.dumps(report, indent=2))
print(f"\nReport written to {stamped.relative_to(Path.cwd()) if stamped.is_relative_to(Path.cwd()) else stamped}\n")
await dispose_engine()
return report
def main() -> int:
parser = argparse.ArgumentParser(description="Score LedgerLens on the labelled test set.")
parser.add_argument("--regenerate", action="store_true", help="Rebuild the documents first.")
parser.add_argument(
"--keep-ledger",
action="store_true",
help="Do not truncate the ledger first (scores against existing history).",
)
parser.add_argument("--json", type=Path, help="Also write the report to this path.")
args = parser.parse_args()
report = asyncio.run(run_eval(regenerate=args.regenerate, keep_ledger=args.keep_ledger))
if args.json:
args.json.write_text(json.dumps(report, indent=2))
# Exit non-zero if the labelled anomalies were not all found: a regression in
# the detector should fail CI, not print a lower number nobody reads.
return 0 if report["anomaly"]["recall"] >= 1.0 else 1
if __name__ == "__main__":
raise SystemExit(main())