Spaces:
Sleeping
phase 5: evaluation harness (SROIE)
Browse filesTwo-phase harness so inference runs exactly once and tuning is free:
- predict phase (eval/predict.py): runs process_document over a dataset slice,
saves PIL images to temp files, caches gold + predicted Document + confidence
+ validation report to eval/cache/ keyed by id. The only phase that hits the
model; idempotent (cached ids skipped) so re-runs never re-bill.
- score phase (eval/score.py + eval/metrics.py): loads the cache and computes
per-field and per-critical-field precision/recall/F1, the auto-accept critical
precision, and a threshold sweep (0.50-0.99) that replays the real route() over
cached (confidence, report), honoring the hard-failure override. Fully offline.
Datasets: SROIE (jsdnrs/ICDAR2019-SROIE test) wired end-to-end; CORD and an
invoice-JSON set scaffolded as adapters but not wired (load() raises, absent from
WIRED_DATASETS). Only dataset-labeled fields are scored.
Comparison (eval/normalize.py) reuses the schema's number/date coercion, then
compares money cent-exact (NOT the reconciliation relative epsilon, which would
score a materially-wrong total as correct and inflate critical precision), dates
on ISO equality, text case/whitespace-insensitive.
18 offline unit tests (no model, no downloads) pin the comparator and the score
phase on synthetic cached entries, incl. a regression test for the money
tolerance and the hard-failure override. Harness adversarially reviewed before
running; eval/cache/ is git-ignored (no benchmark data committed).
- .gitignore +3 -0
- eval/__init__.py +15 -0
- eval/cache.py +116 -0
- eval/datasets/__init__.py +61 -0
- eval/datasets/base.py +67 -0
- eval/datasets/cord.py +51 -0
- eval/datasets/invoice_json.py +59 -0
- eval/datasets/sroie.py +75 -0
- eval/metrics.py +265 -0
- eval/normalize.py +135 -0
- eval/predict.py +199 -0
- eval/run_eval.py +103 -0
- eval/score.py +221 -0
- pyproject.toml +5 -0
- tests/test_eval.py +314 -0
- uv.lock +0 -0
|
@@ -1,6 +1,9 @@
|
|
| 1 |
# Runtime data (inbox/processed/review/exports) and the local SQLite DB
|
| 2 |
data/
|
| 3 |
|
|
|
|
|
|
|
|
|
|
| 4 |
# Secrets and local environment
|
| 5 |
.env
|
| 6 |
|
|
|
|
| 1 |
# Runtime data (inbox/processed/review/exports) and the local SQLite DB
|
| 2 |
data/
|
| 3 |
|
| 4 |
+
# Evaluation prediction cache (no benchmark data committed to git; build-plan 5.1)
|
| 5 |
+
eval/cache/
|
| 6 |
+
|
| 7 |
# Secrets and local environment
|
| 8 |
.env
|
| 9 |
|
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation harness for the document-extraction agent (build-plan phase 5).
|
| 2 |
+
|
| 3 |
+
The harness is deliberately split into two phases so that model inference
|
| 4 |
+
happens exactly once and threshold tuning is free:
|
| 5 |
+
|
| 6 |
+
- **predict** (``eval.predict``) runs ``core.process_document`` over a dataset
|
| 7 |
+
slice and caches each result (gold labels, predicted document, confidence,
|
| 8 |
+
validation report) to ``eval/cache/`` keyed by example id. This is the only
|
| 9 |
+
phase that calls a model backend and the only phase that spends API quota.
|
| 10 |
+
- **score** (``eval.score``) loads the cache and computes every metric plus the
|
| 11 |
+
threshold sweep purely offline. Re-tuning never re-runs inference.
|
| 12 |
+
|
| 13 |
+
See ``docs/03_data_and_extraction_spec.md`` section 6 for the evaluation
|
| 14 |
+
methodology and ``docs/05_build_plan.md`` phase 5 for the task definition.
|
| 15 |
+
"""
|
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Read/write the per-example prediction cache and reconstruct reports.
|
| 2 |
+
|
| 3 |
+
The predict phase writes one JSON file per example under
|
| 4 |
+
``eval/cache/<dataset>/<id>.json``; the score phase reads them back. Keeping the
|
| 5 |
+
model output on disk is what makes tuning free: the threshold sweep replays the
|
| 6 |
+
pure ``route`` function over the cached ``(confidence, validation)`` pairs and
|
| 7 |
+
never touches a model.
|
| 8 |
+
|
| 9 |
+
A cached entry has this shape::
|
| 10 |
+
|
| 11 |
+
{
|
| 12 |
+
"id": "X00016469670",
|
| 13 |
+
"dataset": "sroie",
|
| 14 |
+
"gold": {"vendor_name": ..., "total": ..., ...},
|
| 15 |
+
"labeled_fields": ["vendor_name", "vendor_address", "document_date", "total"],
|
| 16 |
+
"predicted": { ...Document.model_dump(mode="json")... },
|
| 17 |
+
"confidence": 0.5,
|
| 18 |
+
"decision": "review", # decision at the predict-run threshold (informational)
|
| 19 |
+
"modality": "image",
|
| 20 |
+
"backend": "gemini",
|
| 21 |
+
"validation": { "hard_failed": bool, "results": [...], ... },
|
| 22 |
+
"error": null
|
| 23 |
+
}
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import json
|
| 29 |
+
import re
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
from typing import Any
|
| 32 |
+
|
| 33 |
+
from doc_agent.validation.rules import RuleResult, ValidationReport
|
| 34 |
+
|
| 35 |
+
# Default location for the cache; git-ignored (no evaluation data in the repo).
|
| 36 |
+
DEFAULT_CACHE_BASE = Path("eval/cache")
|
| 37 |
+
|
| 38 |
+
_UNSAFE_ID = re.compile(r"[^A-Za-z0-9._-]")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _safe_filename(example_id: str) -> str:
|
| 42 |
+
"""Turn an example id into a filesystem-safe file stem."""
|
| 43 |
+
return _UNSAFE_ID.sub("_", example_id)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def dataset_dir(cache_base: Path, dataset: str) -> Path:
|
| 47 |
+
"""Return the cache directory for a dataset (not created)."""
|
| 48 |
+
return Path(cache_base) / dataset
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def write_entry(cache_base: Path, dataset: str, entry: dict[str, Any]) -> Path:
|
| 52 |
+
"""Write one cache entry to ``<cache_base>/<dataset>/<id>.json``.
|
| 53 |
+
|
| 54 |
+
Args:
|
| 55 |
+
cache_base: Root cache directory.
|
| 56 |
+
dataset: Dataset name (subdirectory).
|
| 57 |
+
entry: The entry dict; must contain an ``"id"`` key.
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
The path the entry was written to.
|
| 61 |
+
"""
|
| 62 |
+
directory = dataset_dir(cache_base, dataset)
|
| 63 |
+
directory.mkdir(parents=True, exist_ok=True)
|
| 64 |
+
path = directory / f"{_safe_filename(str(entry['id']))}.json"
|
| 65 |
+
path.write_text(json.dumps(entry, indent=2, default=str), encoding="utf-8")
|
| 66 |
+
return path
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def read_entries(cache_base: Path, dataset: str) -> list[dict[str, Any]]:
|
| 70 |
+
"""Load all cached entries for a dataset, sorted by filename.
|
| 71 |
+
|
| 72 |
+
Args:
|
| 73 |
+
cache_base: Root cache directory.
|
| 74 |
+
dataset: Dataset name (subdirectory).
|
| 75 |
+
|
| 76 |
+
Returns:
|
| 77 |
+
A list of entry dicts (empty if the directory does not exist).
|
| 78 |
+
"""
|
| 79 |
+
directory = dataset_dir(cache_base, dataset)
|
| 80 |
+
if not directory.exists():
|
| 81 |
+
return []
|
| 82 |
+
return [
|
| 83 |
+
json.loads(path.read_text(encoding="utf-8"))
|
| 84 |
+
for path in sorted(directory.glob("*.json"))
|
| 85 |
+
]
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def existing_ids(cache_base: Path, dataset: str) -> set[str]:
|
| 89 |
+
"""Return the set of example ids already cached for a dataset."""
|
| 90 |
+
return {str(entry["id"]) for entry in read_entries(cache_base, dataset)}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def report_from_dict(validation: dict[str, Any]) -> ValidationReport:
|
| 94 |
+
"""Reconstruct a :class:`ValidationReport` from its cached dict form.
|
| 95 |
+
|
| 96 |
+
This lets the score phase replay the real ``route`` function over cached
|
| 97 |
+
results -- in particular ``report.hard_failed`` is recomputed from the
|
| 98 |
+
per-rule results, so the hard-failure override is honored during the sweep.
|
| 99 |
+
|
| 100 |
+
Args:
|
| 101 |
+
validation: The ``validation`` sub-dict of a cache entry (as produced by
|
| 102 |
+
``ValidationReport.to_dict``).
|
| 103 |
+
|
| 104 |
+
Returns:
|
| 105 |
+
A ``ValidationReport`` whose ``results`` mirror the cached rule outcomes.
|
| 106 |
+
"""
|
| 107 |
+
results = tuple(
|
| 108 |
+
RuleResult(
|
| 109 |
+
code=item["code"],
|
| 110 |
+
severity=item["severity"],
|
| 111 |
+
status=item["status"],
|
| 112 |
+
message=item.get("message", ""),
|
| 113 |
+
)
|
| 114 |
+
for item in validation.get("results", [])
|
| 115 |
+
)
|
| 116 |
+
return ValidationReport(results=results)
|
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dataset adapters mapping public benchmark labels onto the ``Document`` schema.
|
| 2 |
+
|
| 3 |
+
Each adapter yields :class:`~eval.datasets.base.GoldExample` records: an id, the
|
| 4 |
+
input (a PIL image or a file path), and a gold dict keyed by ``Document`` field
|
| 5 |
+
names. Only the fields a dataset actually labels appear in ``labeled_fields``;
|
| 6 |
+
the scorer restricts every metric to that set (an unlabeled field is neither a
|
| 7 |
+
false positive nor a miss -- there is simply no ground truth for it).
|
| 8 |
+
|
| 9 |
+
SROIE is wired end-to-end first (T10). CORD and the invoice-JSON set are
|
| 10 |
+
scaffolded as adapters with their intended field mappings documented, but are
|
| 11 |
+
intentionally not wired yet -- calling ``load`` on them raises.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from eval.datasets.base import DatasetAdapter, GoldExample
|
| 17 |
+
from eval.datasets.cord import CordAdapter
|
| 18 |
+
from eval.datasets.invoice_json import InvoiceJsonAdapter
|
| 19 |
+
from eval.datasets.sroie import SroieAdapter
|
| 20 |
+
|
| 21 |
+
# Registry of all known adapters, keyed by stable name.
|
| 22 |
+
ADAPTERS: dict[str, type[DatasetAdapter]] = {
|
| 23 |
+
SroieAdapter.name: SroieAdapter,
|
| 24 |
+
CordAdapter.name: CordAdapter,
|
| 25 |
+
InvoiceJsonAdapter.name: InvoiceJsonAdapter,
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
# Adapters proven end-to-end and safe to run the predict phase against. The
|
| 29 |
+
# others are scaffolds; ``get_adapter`` still returns them (so their metadata is
|
| 30 |
+
# inspectable) but ``eval.predict`` refuses to run an unwired dataset.
|
| 31 |
+
WIRED_DATASETS: frozenset[str] = frozenset({SroieAdapter.name})
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def get_adapter(name: str) -> DatasetAdapter:
|
| 35 |
+
"""Instantiate a dataset adapter by name.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
name: The adapter's stable name (e.g. "sroie").
|
| 39 |
+
|
| 40 |
+
Returns:
|
| 41 |
+
A new adapter instance.
|
| 42 |
+
|
| 43 |
+
Raises:
|
| 44 |
+
KeyError: If no adapter is registered under ``name``.
|
| 45 |
+
"""
|
| 46 |
+
if name not in ADAPTERS:
|
| 47 |
+
available = ", ".join(sorted(ADAPTERS))
|
| 48 |
+
raise KeyError(f"Unknown dataset {name!r}; available: {available}")
|
| 49 |
+
return ADAPTERS[name]()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
__all__ = [
|
| 53 |
+
"ADAPTERS",
|
| 54 |
+
"WIRED_DATASETS",
|
| 55 |
+
"DatasetAdapter",
|
| 56 |
+
"GoldExample",
|
| 57 |
+
"SroieAdapter",
|
| 58 |
+
"CordAdapter",
|
| 59 |
+
"InvoiceJsonAdapter",
|
| 60 |
+
"get_adapter",
|
| 61 |
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Common types for dataset adapters.
|
| 2 |
+
|
| 3 |
+
A :class:`GoldExample` is the unit the predict phase consumes: an id, the input
|
| 4 |
+
(a PIL image for image datasets, or a file path for file-based ones), and the
|
| 5 |
+
gold labels mapped onto ``Document`` field names. :class:`DatasetAdapter` is the
|
| 6 |
+
protocol every concrete adapter satisfies.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from collections.abc import Iterator
|
| 12 |
+
from dataclasses import dataclass, field
|
| 13 |
+
from typing import Any, Protocol, runtime_checkable
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass(frozen=True)
|
| 17 |
+
class GoldExample:
|
| 18 |
+
"""One evaluation example with its ground-truth labels.
|
| 19 |
+
|
| 20 |
+
Attributes:
|
| 21 |
+
id: Stable, unique identifier for the example (used as the cache key).
|
| 22 |
+
gold: Ground-truth values keyed by ``Document`` field name. A value is
|
| 23 |
+
``None`` (or absent) when the dataset does not label that field for
|
| 24 |
+
this example. Values are raw (strings/numbers as the dataset stores
|
| 25 |
+
them); the scorer normalizes them before comparison.
|
| 26 |
+
image: The input image as a PIL ``Image`` for image datasets, or
|
| 27 |
+
``None`` for file-based datasets.
|
| 28 |
+
source_path: Path to an input file for file-based datasets, or ``None``
|
| 29 |
+
when the input is an in-memory ``image``.
|
| 30 |
+
suffix: File extension to use when writing ``image`` to a temp file so
|
| 31 |
+
modality detection sees the right type (e.g. ".png").
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
id: str
|
| 35 |
+
gold: dict[str, Any]
|
| 36 |
+
image: Any = None
|
| 37 |
+
source_path: str | None = None
|
| 38 |
+
suffix: str = ".png"
|
| 39 |
+
metadata: dict[str, Any] = field(default_factory=dict)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@runtime_checkable
|
| 43 |
+
class DatasetAdapter(Protocol):
|
| 44 |
+
"""Interface every dataset adapter implements.
|
| 45 |
+
|
| 46 |
+
Attributes:
|
| 47 |
+
name: Stable identifier for the dataset (e.g. "sroie").
|
| 48 |
+
hf_id: The Hugging Face dataset id it loads from.
|
| 49 |
+
labeled_fields: The ``Document`` field names this dataset provides gold
|
| 50 |
+
labels for; the scorer computes metrics only for these.
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
name: str
|
| 54 |
+
hf_id: str
|
| 55 |
+
labeled_fields: tuple[str, ...]
|
| 56 |
+
|
| 57 |
+
def load(self, limit: int | None = None) -> Iterator[GoldExample]:
|
| 58 |
+
"""Yield gold examples, at most ``limit`` of them.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
limit: Maximum number of examples to yield; ``None`` for all.
|
| 62 |
+
|
| 63 |
+
Yields:
|
| 64 |
+
:class:`GoldExample` records in a fixed, deterministic order so a
|
| 65 |
+
given ``limit`` always selects the same held-out slice.
|
| 66 |
+
"""
|
| 67 |
+
...
|
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CORD (v2) dataset adapter -- scaffold, not yet wired.
|
| 2 |
+
|
| 3 |
+
CORD is ~11,000 Indonesian receipts captured in the wild with rich key-value
|
| 4 |
+
and line-item labels. The ``naver-clova-ix/cord-v2`` build stores ground truth
|
| 5 |
+
as a JSON string in ``ground_truth`` (a ``gt_parse`` object with ``menu`` line
|
| 6 |
+
items and a ``sub_total`` / ``total`` block).
|
| 7 |
+
|
| 8 |
+
Intended field mapping onto the ``Document`` schema (to implement when wired):
|
| 9 |
+
|
| 10 |
+
- ``gt_parse.total.total_price`` -> ``total`` (critical)
|
| 11 |
+
- ``gt_parse.sub_total.subtotal_price``-> ``subtotal``
|
| 12 |
+
- ``gt_parse.sub_total.tax_price`` -> ``tax`` (critical)
|
| 13 |
+
- ``gt_parse.menu[*]`` -> ``line_items`` (name/cnt/price)
|
| 14 |
+
- store name (where present) -> ``vendor_name``
|
| 15 |
+
|
| 16 |
+
This adapter is intentionally left unwired for T10 (SROIE-first). ``load`` raises
|
| 17 |
+
so an accidental predict run against CORD fails fast rather than spending quota
|
| 18 |
+
on an unvalidated mapping.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
from collections.abc import Iterator
|
| 24 |
+
|
| 25 |
+
from eval.datasets.base import GoldExample
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class CordAdapter:
|
| 29 |
+
"""Scaffold adapter for the CORD v2 receipt benchmark (not wired)."""
|
| 30 |
+
|
| 31 |
+
name: str = "cord"
|
| 32 |
+
hf_id: str = "naver-clova-ix/cord-v2"
|
| 33 |
+
split: str = "test"
|
| 34 |
+
# Fields CORD would provide once the gt_parse mapping is implemented.
|
| 35 |
+
labeled_fields: tuple[str, ...] = ("vendor_name", "subtotal", "tax", "total")
|
| 36 |
+
|
| 37 |
+
def load(self, limit: int | None = None) -> Iterator[GoldExample]:
|
| 38 |
+
"""Not implemented -- CORD is scaffolded but not wired for T10.
|
| 39 |
+
|
| 40 |
+
Args:
|
| 41 |
+
limit: Unused.
|
| 42 |
+
|
| 43 |
+
Raises:
|
| 44 |
+
NotImplementedError: Always; wire the ``gt_parse`` mapping first.
|
| 45 |
+
"""
|
| 46 |
+
raise NotImplementedError(
|
| 47 |
+
"CORD adapter is scaffolded but not wired (T10 is SROIE-first). "
|
| 48 |
+
"Implement the gt_parse -> Document mapping and add 'cord' to "
|
| 49 |
+
"WIRED_DATASETS before running the predict phase against it."
|
| 50 |
+
)
|
| 51 |
+
yield # pragma: no cover -- makes this a generator for the Protocol.
|
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Invoice-JSON dataset adapter -- scaffold, not yet wired.
|
| 2 |
+
|
| 3 |
+
The ``GokulRajaR/invoice-ocr-json`` set pairs native invoice images with
|
| 4 |
+
structured JSON ground truth (key-values covering invoice number, dates, and
|
| 5 |
+
monetary totals) -- convenient for the eval harness because the labels are
|
| 6 |
+
already close to the ``Document`` schema.
|
| 7 |
+
|
| 8 |
+
Intended field mapping onto the ``Document`` schema (to implement when wired):
|
| 9 |
+
|
| 10 |
+
- invoice number field -> ``invoice_number`` (critical)
|
| 11 |
+
- invoice/issue date -> ``document_date``
|
| 12 |
+
- due date -> ``due_date``
|
| 13 |
+
- tax/VAT amount -> ``tax`` (critical)
|
| 14 |
+
- grand total -> ``total`` (critical)
|
| 15 |
+
- vendor/seller name -> ``vendor_name``
|
| 16 |
+
|
| 17 |
+
This is the dataset that would exercise ``invoice_number`` and ``tax`` -- the two
|
| 18 |
+
critical fields SROIE does not label. It is intentionally left unwired for T10;
|
| 19 |
+
``load`` raises so a predict run cannot spend quota on an unvalidated mapping.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
from collections.abc import Iterator
|
| 25 |
+
|
| 26 |
+
from eval.datasets.base import GoldExample
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class InvoiceJsonAdapter:
|
| 30 |
+
"""Scaffold adapter for the invoice-OCR-JSON benchmark (not wired)."""
|
| 31 |
+
|
| 32 |
+
name: str = "invoice_json"
|
| 33 |
+
hf_id: str = "GokulRajaR/invoice-ocr-json"
|
| 34 |
+
split: str = "train"
|
| 35 |
+
labeled_fields: tuple[str, ...] = (
|
| 36 |
+
"vendor_name",
|
| 37 |
+
"invoice_number",
|
| 38 |
+
"document_date",
|
| 39 |
+
"due_date",
|
| 40 |
+
"tax",
|
| 41 |
+
"total",
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
def load(self, limit: int | None = None) -> Iterator[GoldExample]:
|
| 45 |
+
"""Not implemented -- this set is scaffolded but not wired for T10.
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
limit: Unused.
|
| 49 |
+
|
| 50 |
+
Raises:
|
| 51 |
+
NotImplementedError: Always; implement the JSON->Document mapping
|
| 52 |
+
first.
|
| 53 |
+
"""
|
| 54 |
+
raise NotImplementedError(
|
| 55 |
+
"invoice_json adapter is scaffolded but not wired (T10 is "
|
| 56 |
+
"SROIE-first). Implement the JSON -> Document mapping and add "
|
| 57 |
+
"'invoice_json' to WIRED_DATASETS before running predict against it."
|
| 58 |
+
)
|
| 59 |
+
yield # pragma: no cover -- makes this a generator for the Protocol.
|
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SROIE (ICDAR 2019) dataset adapter -- wired end-to-end.
|
| 2 |
+
|
| 3 |
+
SROIE is ~1,000 real scanned receipts with four labeled key fields. Using the
|
| 4 |
+
``jsdnrs/ICDAR2019-SROIE`` mirror, the "test" split is the held-out evaluation
|
| 5 |
+
slice (it is never tuned against). Each example exposes:
|
| 6 |
+
|
| 7 |
+
- ``key`` -> the example id
|
| 8 |
+
- ``image`` -> a PIL image of the receipt (scan modality)
|
| 9 |
+
- ``entities`` -> {"company", "date", "address", "total"}
|
| 10 |
+
|
| 11 |
+
Field mapping onto the ``Document`` schema (data spec section 2):
|
| 12 |
+
|
| 13 |
+
- ``entities["company"]`` -> ``vendor_name``
|
| 14 |
+
- ``entities["address"]`` -> ``vendor_address``
|
| 15 |
+
- ``entities["date"]`` -> ``document_date`` (day-first D/M/Y, e.g. 15/01/2019)
|
| 16 |
+
- ``entities["total"]`` -> ``total``
|
| 17 |
+
|
| 18 |
+
SROIE does **not** label ``tax`` or ``invoice_number``, so of the three critical
|
| 19 |
+
fields only ``total`` is scored here. The dataset is loaded in streaming mode so
|
| 20 |
+
a small slice does not download the full split.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
from collections.abc import Iterator
|
| 26 |
+
|
| 27 |
+
from eval.datasets.base import GoldExample
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class SroieAdapter:
|
| 31 |
+
"""Adapter for the SROIE scanned-receipt benchmark."""
|
| 32 |
+
|
| 33 |
+
name: str = "sroie"
|
| 34 |
+
hf_id: str = "jsdnrs/ICDAR2019-SROIE"
|
| 35 |
+
split: str = "test"
|
| 36 |
+
labeled_fields: tuple[str, ...] = (
|
| 37 |
+
"vendor_name",
|
| 38 |
+
"vendor_address",
|
| 39 |
+
"document_date",
|
| 40 |
+
"total",
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
def load(self, limit: int | None = None) -> Iterator[GoldExample]:
|
| 44 |
+
"""Yield the first ``limit`` SROIE test examples as gold examples.
|
| 45 |
+
|
| 46 |
+
Streaming keeps a small slice cheap (no full-split download). The first
|
| 47 |
+
``limit`` examples form a fixed, reproducible slice.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
limit: Maximum number of examples to yield; ``None`` for the whole
|
| 51 |
+
split.
|
| 52 |
+
|
| 53 |
+
Yields:
|
| 54 |
+
:class:`GoldExample` records with the receipt image and mapped gold.
|
| 55 |
+
"""
|
| 56 |
+
from datasets import load_dataset
|
| 57 |
+
|
| 58 |
+
dataset = load_dataset(self.hf_id, split=self.split, streaming=True)
|
| 59 |
+
for index, example in enumerate(dataset):
|
| 60 |
+
if limit is not None and index >= limit:
|
| 61 |
+
break
|
| 62 |
+
entities = example.get("entities") or {}
|
| 63 |
+
gold = {
|
| 64 |
+
"vendor_name": entities.get("company"),
|
| 65 |
+
"vendor_address": entities.get("address"),
|
| 66 |
+
"document_date": entities.get("date"),
|
| 67 |
+
"total": entities.get("total"),
|
| 68 |
+
}
|
| 69 |
+
yield GoldExample(
|
| 70 |
+
id=str(example["key"]),
|
| 71 |
+
gold=gold,
|
| 72 |
+
image=example["image"],
|
| 73 |
+
suffix=".png",
|
| 74 |
+
metadata={"dataset": self.name, "index": index},
|
| 75 |
+
)
|
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Metric aggregation and the threshold sweep (pure functions over cache entries).
|
| 2 |
+
|
| 3 |
+
Everything here operates on already-cached entries (see ``eval.cache``) and never
|
| 4 |
+
calls a model, so it is fully unit-testable on synthetic data and re-runnable for
|
| 5 |
+
free. Two computations implement the evaluation methodology (data spec section 6):
|
| 6 |
+
|
| 7 |
+
- :func:`compute_field_metrics` -- per-field precision / recall / F1 over the
|
| 8 |
+
whole slice, restricted to the fields a dataset labels.
|
| 9 |
+
- :func:`sweep_thresholds` -- for each candidate threshold, replay the real
|
| 10 |
+
``route`` over the cached ``(confidence, validation)`` pairs (honoring the
|
| 11 |
+
hard-failure override) and report auto-accept volume and the precision/recall
|
| 12 |
+
of the auto-accepted critical fields -- the precision/recall trade-off curve.
|
| 13 |
+
|
| 14 |
+
Definitions (field level, against ground truth):
|
| 15 |
+
|
| 16 |
+
- A predicted value is *present* if it normalizes to a non-absent value; a match
|
| 17 |
+
requires the gold to be present too and the normalized values to agree.
|
| 18 |
+
- *precision* = matches / predicted-present. *recall* = matches / gold-present.
|
| 19 |
+
- *F1* = harmonic mean.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
from collections.abc import Sequence
|
| 25 |
+
from dataclasses import dataclass
|
| 26 |
+
from typing import Any
|
| 27 |
+
|
| 28 |
+
from doc_agent.routing.score import route
|
| 29 |
+
|
| 30 |
+
from eval.cache import report_from_dict
|
| 31 |
+
from eval.normalize import is_present, values_match
|
| 32 |
+
|
| 33 |
+
# Critical, precision-prioritised fields (data spec section 2 / CLAUDE.md).
|
| 34 |
+
CRITICAL_FIELDS: tuple[str, ...] = ("total", "tax", "invoice_number")
|
| 35 |
+
|
| 36 |
+
# Threshold grid for the sweep: 0.50 -> 0.99 inclusive at 0.01 steps.
|
| 37 |
+
THRESHOLDS: tuple[float, ...] = tuple(round(0.50 + 0.01 * i, 2) for i in range(50))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _f1(precision: float | None, recall: float | None) -> float | None:
|
| 41 |
+
"""Harmonic mean of precision and recall (``None`` if both are undefined)."""
|
| 42 |
+
if precision is None and recall is None:
|
| 43 |
+
return None
|
| 44 |
+
if not precision or not recall: # covers None or 0.0 on either side
|
| 45 |
+
return 0.0
|
| 46 |
+
return 2 * precision * recall / (precision + recall)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@dataclass(frozen=True)
|
| 50 |
+
class FieldMetric:
|
| 51 |
+
"""Precision / recall / F1 for one field over a slice.
|
| 52 |
+
|
| 53 |
+
Attributes:
|
| 54 |
+
field: The ``Document`` field name.
|
| 55 |
+
n_pred: Number of examples where the pipeline produced a value.
|
| 56 |
+
n_gold: Number of examples where the gold labels a value.
|
| 57 |
+
n_match: Number of examples where prediction and gold agree.
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
field: str
|
| 61 |
+
n_pred: int
|
| 62 |
+
n_gold: int
|
| 63 |
+
n_match: int
|
| 64 |
+
|
| 65 |
+
@property
|
| 66 |
+
def precision(self) -> float | None:
|
| 67 |
+
"""matches / predicted-present, or ``None`` if nothing was predicted."""
|
| 68 |
+
return self.n_match / self.n_pred if self.n_pred else None
|
| 69 |
+
|
| 70 |
+
@property
|
| 71 |
+
def recall(self) -> float | None:
|
| 72 |
+
"""matches / gold-present, or ``None`` if there is no gold."""
|
| 73 |
+
return self.n_match / self.n_gold if self.n_gold else None
|
| 74 |
+
|
| 75 |
+
@property
|
| 76 |
+
def f1(self) -> float | None:
|
| 77 |
+
"""Harmonic mean of precision and recall."""
|
| 78 |
+
return _f1(self.precision, self.recall)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@dataclass(frozen=True)
|
| 82 |
+
class SweepRow:
|
| 83 |
+
"""One threshold's auto-accept volume and critical-field trade-off.
|
| 84 |
+
|
| 85 |
+
Attributes:
|
| 86 |
+
threshold: The candidate auto-accept threshold.
|
| 87 |
+
n_total: Total examples in the slice.
|
| 88 |
+
n_accepted: How many examples ``route`` auto-accepts at this threshold.
|
| 89 |
+
crit_pred: Predicted-present critical values among accepted examples
|
| 90 |
+
(the denominator of auto-accept precision).
|
| 91 |
+
crit_match: Correct critical values among accepted examples.
|
| 92 |
+
crit_gold_total: Gold-present critical values across the whole slice
|
| 93 |
+
(the denominator of critical recall).
|
| 94 |
+
"""
|
| 95 |
+
|
| 96 |
+
threshold: float
|
| 97 |
+
n_total: int
|
| 98 |
+
n_accepted: int
|
| 99 |
+
crit_pred: int
|
| 100 |
+
crit_match: int
|
| 101 |
+
crit_gold_total: int
|
| 102 |
+
|
| 103 |
+
@property
|
| 104 |
+
def accept_rate(self) -> float:
|
| 105 |
+
"""Fraction of the slice auto-accepted at this threshold."""
|
| 106 |
+
return self.n_accepted / self.n_total if self.n_total else 0.0
|
| 107 |
+
|
| 108 |
+
@property
|
| 109 |
+
def crit_precision(self) -> float | None:
|
| 110 |
+
"""Precision on critical fields over the auto-accepted subset.
|
| 111 |
+
|
| 112 |
+
This is the metric the operating point targets (>= 0.98). ``None`` when
|
| 113 |
+
no critical value was auto-accepted (precision undefined).
|
| 114 |
+
"""
|
| 115 |
+
return self.crit_match / self.crit_pred if self.crit_pred else None
|
| 116 |
+
|
| 117 |
+
@property
|
| 118 |
+
def crit_recall(self) -> float | None:
|
| 119 |
+
"""Correctly auto-accepted critical values / all gold critical values.
|
| 120 |
+
|
| 121 |
+
The recall "kept" at this threshold; the rest is review-queue volume.
|
| 122 |
+
"""
|
| 123 |
+
return self.crit_match / self.crit_gold_total if self.crit_gold_total else None
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def compute_field_metrics(
|
| 127 |
+
entries: Sequence[dict[str, Any]],
|
| 128 |
+
fields: Sequence[str],
|
| 129 |
+
) -> list[FieldMetric]:
|
| 130 |
+
"""Compute per-field precision/recall/F1 over the slice.
|
| 131 |
+
|
| 132 |
+
Args:
|
| 133 |
+
entries: Cached prediction entries (each with ``predicted`` and ``gold``).
|
| 134 |
+
fields: The ``Document`` field names to score (a dataset's labeled set).
|
| 135 |
+
|
| 136 |
+
Returns:
|
| 137 |
+
One :class:`FieldMetric` per field, in the order of ``fields``.
|
| 138 |
+
"""
|
| 139 |
+
metrics: list[FieldMetric] = []
|
| 140 |
+
for field in fields:
|
| 141 |
+
n_pred = n_gold = n_match = 0
|
| 142 |
+
for entry in entries:
|
| 143 |
+
predicted = entry.get("predicted", {}).get(field)
|
| 144 |
+
gold = entry.get("gold", {}).get(field)
|
| 145 |
+
if is_present(field, predicted):
|
| 146 |
+
n_pred += 1
|
| 147 |
+
if is_present(field, gold):
|
| 148 |
+
n_gold += 1
|
| 149 |
+
if values_match(field, predicted, gold):
|
| 150 |
+
n_match += 1
|
| 151 |
+
metrics.append(FieldMetric(field, n_pred, n_gold, n_match))
|
| 152 |
+
return metrics
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def sweep_thresholds(
|
| 156 |
+
entries: Sequence[dict[str, Any]],
|
| 157 |
+
critical_fields: Sequence[str],
|
| 158 |
+
thresholds: Sequence[float] = THRESHOLDS,
|
| 159 |
+
) -> list[SweepRow]:
|
| 160 |
+
"""Replay ``route`` across thresholds and measure the critical-field trade-off.
|
| 161 |
+
|
| 162 |
+
For each threshold the real ``route`` is applied to every entry's cached
|
| 163 |
+
``(confidence, validation)`` pair -- so a hard-failure entry is forced to
|
| 164 |
+
review at *every* threshold, exactly as in production -- and the auto-accepted
|
| 165 |
+
subset's critical-field precision and recall are computed. No inference runs.
|
| 166 |
+
|
| 167 |
+
Args:
|
| 168 |
+
entries: Cached prediction entries.
|
| 169 |
+
critical_fields: The critical fields the dataset labels (the subset of
|
| 170 |
+
``total``/``tax``/``invoice_number`` with gold present).
|
| 171 |
+
thresholds: The candidate thresholds to sweep. Defaults to
|
| 172 |
+
:data:`THRESHOLDS` (0.50->0.99).
|
| 173 |
+
|
| 174 |
+
Returns:
|
| 175 |
+
One :class:`SweepRow` per threshold, in ``thresholds`` order.
|
| 176 |
+
"""
|
| 177 |
+
reports = {entry["id"]: report_from_dict(entry.get("validation", {})) for entry in entries}
|
| 178 |
+
n_total = len(entries)
|
| 179 |
+
|
| 180 |
+
# Denominator for critical recall: gold-present critical values across all.
|
| 181 |
+
crit_gold_total = sum(
|
| 182 |
+
1
|
| 183 |
+
for entry in entries
|
| 184 |
+
for field in critical_fields
|
| 185 |
+
if is_present(field, entry.get("gold", {}).get(field))
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
rows: list[SweepRow] = []
|
| 189 |
+
for threshold in thresholds:
|
| 190 |
+
crit_pred = crit_match = n_accepted = 0
|
| 191 |
+
for entry in entries:
|
| 192 |
+
report = reports[entry["id"]]
|
| 193 |
+
decision = route(entry.get("confidence", 0.0), report, threshold=threshold)
|
| 194 |
+
if decision != "accept":
|
| 195 |
+
continue
|
| 196 |
+
n_accepted += 1
|
| 197 |
+
for field in critical_fields:
|
| 198 |
+
predicted = entry.get("predicted", {}).get(field)
|
| 199 |
+
gold = entry.get("gold", {}).get(field)
|
| 200 |
+
if is_present(field, predicted):
|
| 201 |
+
crit_pred += 1
|
| 202 |
+
if values_match(field, predicted, gold):
|
| 203 |
+
crit_match += 1
|
| 204 |
+
rows.append(
|
| 205 |
+
SweepRow(
|
| 206 |
+
threshold=threshold,
|
| 207 |
+
n_total=n_total,
|
| 208 |
+
n_accepted=n_accepted,
|
| 209 |
+
crit_pred=crit_pred,
|
| 210 |
+
crit_match=crit_match,
|
| 211 |
+
crit_gold_total=crit_gold_total,
|
| 212 |
+
)
|
| 213 |
+
)
|
| 214 |
+
return rows
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def confidence_histogram(
|
| 218 |
+
entries: Sequence[dict[str, Any]],
|
| 219 |
+
ndigits: int = 2,
|
| 220 |
+
) -> dict[float, int]:
|
| 221 |
+
"""Count cached confidence scores, rounded, for distribution reporting.
|
| 222 |
+
|
| 223 |
+
Surfaces why the sweep looks the way it does: when a backend exposes no
|
| 224 |
+
per-field confidence the scorer starts from a neutral 0.5, capping scores at
|
| 225 |
+
0.5, so almost nothing clears a threshold above 0.5.
|
| 226 |
+
|
| 227 |
+
Args:
|
| 228 |
+
entries: Cached prediction entries.
|
| 229 |
+
ndigits: Rounding precision for bucketing confidences.
|
| 230 |
+
|
| 231 |
+
Returns:
|
| 232 |
+
A dict mapping rounded confidence to count, ascending by confidence.
|
| 233 |
+
"""
|
| 234 |
+
counts: dict[float, int] = {}
|
| 235 |
+
for entry in entries:
|
| 236 |
+
bucket = round(float(entry.get("confidence", 0.0)), ndigits)
|
| 237 |
+
counts[bucket] = counts.get(bucket, 0) + 1
|
| 238 |
+
return dict(sorted(counts.items()))
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def smallest_threshold_meeting(
|
| 242 |
+
rows: Sequence[SweepRow],
|
| 243 |
+
target_precision: float,
|
| 244 |
+
) -> SweepRow | None:
|
| 245 |
+
"""Return the lowest-threshold row whose critical precision meets a target.
|
| 246 |
+
|
| 247 |
+
Reported as analysis only -- the operator chooses the actual threshold.
|
| 248 |
+
|
| 249 |
+
Args:
|
| 250 |
+
rows: Sweep rows (assumed ascending by threshold).
|
| 251 |
+
target_precision: The critical auto-accept precision to meet (e.g. 0.98).
|
| 252 |
+
|
| 253 |
+
Returns:
|
| 254 |
+
The first row (lowest threshold) with a defined critical precision at or
|
| 255 |
+
above ``target_precision`` and at least one auto-accepted example, or
|
| 256 |
+
``None`` if no threshold achieves it.
|
| 257 |
+
"""
|
| 258 |
+
for row in rows:
|
| 259 |
+
if (
|
| 260 |
+
row.n_accepted > 0
|
| 261 |
+
and row.crit_precision is not None
|
| 262 |
+
and row.crit_precision >= target_precision
|
| 263 |
+
):
|
| 264 |
+
return row
|
| 265 |
+
return None
|
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Value normalization and per-field comparison for evaluation (pure, no I/O).
|
| 2 |
+
|
| 3 |
+
Before predicted and gold values are compared they must be normalized so that
|
| 4 |
+
cosmetic differences (casing, whitespace, currency symbols, date formats, the
|
| 5 |
+
JSON round-trip through the cache) do not count as errors -- exactly the
|
| 6 |
+
normalization the evaluation methodology calls for (data spec section 6, step 2).
|
| 7 |
+
|
| 8 |
+
The normalization reuses the pipeline's own coercion helpers so eval judges
|
| 9 |
+
values the same way the pipeline produces them:
|
| 10 |
+
|
| 11 |
+
- **money** fields go through the schema's number coercion and are compared for
|
| 12 |
+
**exact equality at cent precision** (``round(x, 2)``). This deliberately does
|
| 13 |
+
*not* reuse the validation module's ``money_close``: that check carries a 0.5%
|
| 14 |
+
relative tolerance whose purpose is to absorb accumulated line-item rounding in
|
| 15 |
+
the H2/H3 arithmetic cross-checks (data spec section 3). Applied to a single
|
| 16 |
+
gold-vs-prediction comparison it would count a materially-wrong total as
|
| 17 |
+
correct (e.g. 502.00 vs a gold of 500.00 -> within 0.5% of 500), which would
|
| 18 |
+
overstate exactly the ``total``/``tax`` auto-accept precision this harness
|
| 19 |
+
exists to measure against the >= 0.98 target. Section 6 asks only for
|
| 20 |
+
normalization then comparison; cent-exact equality is that comparison, with the
|
| 21 |
+
cent rounding absorbing sub-cent floating-point representation noise.
|
| 22 |
+
- **date** fields go through the schema's date coercion (day-first for ambiguous
|
| 23 |
+
D/M/Y, matching SROIE) and are compared for exact ISO-date equality.
|
| 24 |
+
- **text** fields are lower-cased and whitespace-collapsed, then compared for
|
| 25 |
+
exact equality.
|
| 26 |
+
|
| 27 |
+
A value that normalizes to ``None`` (absent, blank, or unparseable) is treated as
|
| 28 |
+
"not present": it can never match, so predicting a value where the gold is absent
|
| 29 |
+
counts against precision, and missing a gold value counts against recall.
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
import re
|
| 35 |
+
from datetime import date
|
| 36 |
+
from typing import Any
|
| 37 |
+
|
| 38 |
+
from doc_agent.schema.models import _coerce_date, _coerce_number
|
| 39 |
+
|
| 40 |
+
# How each schema field is compared. Fields not listed default to "text".
|
| 41 |
+
FIELD_KIND: dict[str, str] = {
|
| 42 |
+
"doc_type": "text",
|
| 43 |
+
"vendor_name": "text",
|
| 44 |
+
"vendor_address": "text",
|
| 45 |
+
"invoice_number": "text",
|
| 46 |
+
"currency": "text",
|
| 47 |
+
"document_date": "date",
|
| 48 |
+
"due_date": "date",
|
| 49 |
+
"subtotal": "money",
|
| 50 |
+
"tax": "money",
|
| 51 |
+
"total": "money",
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _normalize_text(value: Any) -> str | None:
|
| 56 |
+
"""Lower-case and whitespace-collapse a text value; blanks become ``None``."""
|
| 57 |
+
if value is None:
|
| 58 |
+
return None
|
| 59 |
+
collapsed = re.sub(r"\s+", " ", str(value).strip().lower())
|
| 60 |
+
return collapsed or None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _normalize_money(value: Any) -> float | None:
|
| 64 |
+
"""Coerce a monetary value to ``float``; unparseable/absent becomes ``None``."""
|
| 65 |
+
try:
|
| 66 |
+
return _coerce_number(value)
|
| 67 |
+
except (ValueError, TypeError):
|
| 68 |
+
return None
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _normalize_date(value: Any) -> date | None:
|
| 72 |
+
"""Coerce a date value to ``datetime.date``; unparseable/absent becomes ``None``."""
|
| 73 |
+
try:
|
| 74 |
+
return _coerce_date(value)
|
| 75 |
+
except (ValueError, TypeError):
|
| 76 |
+
return None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def normalize(field: str, value: Any) -> Any:
|
| 80 |
+
"""Normalize a single value according to its field's comparison kind.
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
field: The ``Document`` field name.
|
| 84 |
+
value: The raw predicted or gold value.
|
| 85 |
+
|
| 86 |
+
Returns:
|
| 87 |
+
A normalized comparable value (``float`` for money, ``date`` for dates,
|
| 88 |
+
lower-cased string for text), or ``None`` when the value is absent,
|
| 89 |
+
blank, or unparseable.
|
| 90 |
+
"""
|
| 91 |
+
kind = FIELD_KIND.get(field, "text")
|
| 92 |
+
if kind == "money":
|
| 93 |
+
return _normalize_money(value)
|
| 94 |
+
if kind == "date":
|
| 95 |
+
return _normalize_date(value)
|
| 96 |
+
return _normalize_text(value)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def is_present(field: str, value: Any) -> bool:
|
| 100 |
+
"""Whether ``value`` normalizes to a real (non-absent) value for ``field``.
|
| 101 |
+
|
| 102 |
+
Args:
|
| 103 |
+
field: The ``Document`` field name.
|
| 104 |
+
value: The raw value to test.
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
``True`` if the value normalizes to something other than ``None``.
|
| 108 |
+
"""
|
| 109 |
+
return normalize(field, value) is not None
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def values_match(field: str, predicted: Any, gold: Any) -> bool:
|
| 113 |
+
"""Whether a predicted value matches the gold value for ``field``.
|
| 114 |
+
|
| 115 |
+
Both sides are normalized first. A match requires both to be present;
|
| 116 |
+
monetary fields match within the rounding tolerance, dates and text match on
|
| 117 |
+
exact normalized equality.
|
| 118 |
+
|
| 119 |
+
Args:
|
| 120 |
+
field: The ``Document`` field name being compared.
|
| 121 |
+
predicted: The pipeline's predicted value (possibly a JSON-cached form).
|
| 122 |
+
gold: The dataset's ground-truth value.
|
| 123 |
+
|
| 124 |
+
Returns:
|
| 125 |
+
``True`` if the values are considered equal after normalization.
|
| 126 |
+
"""
|
| 127 |
+
left = normalize(field, predicted)
|
| 128 |
+
right = normalize(field, gold)
|
| 129 |
+
if left is None or right is None:
|
| 130 |
+
return False
|
| 131 |
+
if FIELD_KIND.get(field, "text") == "money":
|
| 132 |
+
# Cent-exact: no relative tolerance, so a materially-wrong total is never
|
| 133 |
+
# scored correct. round() absorbs sub-cent float representation noise.
|
| 134 |
+
return round(left, 2) == round(right, 2)
|
| 135 |
+
return left == right
|
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Predict phase: run the core over a dataset slice and cache each result.
|
| 2 |
+
|
| 3 |
+
This is the **only** phase that calls a model backend and the only phase that
|
| 4 |
+
spends API quota. It is deliberately idempotent: an example already present in
|
| 5 |
+
the cache is skipped unless ``overwrite`` is set, so a re-run after an
|
| 6 |
+
interruption resumes rather than re-billing.
|
| 7 |
+
|
| 8 |
+
For each example the PIL image is written to a temporary file (``core`` takes a
|
| 9 |
+
path, not bytes) with an extension that makes modality detection classify it as
|
| 10 |
+
an image; ``process_document`` then runs the full detect -> acquire -> extract
|
| 11 |
+
-> validate -> score -> route pipeline. The gold labels, predicted document,
|
| 12 |
+
confidence, and validation report are cached; nothing is persisted to the app's
|
| 13 |
+
SQLite store (eval is not production ingestion).
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import logging
|
| 19 |
+
import tempfile
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import Any
|
| 23 |
+
|
| 24 |
+
from doc_agent.backends.base import create_backend
|
| 25 |
+
from doc_agent.config import Settings, load_config
|
| 26 |
+
from doc_agent.core import process_document
|
| 27 |
+
|
| 28 |
+
from eval.cache import DEFAULT_CACHE_BASE, existing_ids, write_entry
|
| 29 |
+
from eval.datasets import WIRED_DATASETS, get_adapter
|
| 30 |
+
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass(frozen=True)
|
| 35 |
+
class PredictStats:
|
| 36 |
+
"""Summary of a predict-phase run.
|
| 37 |
+
|
| 38 |
+
Attributes:
|
| 39 |
+
dataset: The dataset name.
|
| 40 |
+
requested: The requested slice size (``limit``), or ``None`` for all.
|
| 41 |
+
processed: Examples actually sent through the pipeline this run.
|
| 42 |
+
skipped: Examples skipped because they were already cached.
|
| 43 |
+
accepted: Processed examples the pipeline auto-accepted.
|
| 44 |
+
review: Processed examples routed to review.
|
| 45 |
+
errors: Processed examples whose pipeline stage raised (routed to review).
|
| 46 |
+
failed: Examples that could not be prepared at all (e.g. the image would
|
| 47 |
+
not encode); logged and skipped so one bad input never aborts the run.
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
dataset: str
|
| 51 |
+
requested: int | None
|
| 52 |
+
processed: int
|
| 53 |
+
skipped: int
|
| 54 |
+
accepted: int
|
| 55 |
+
review: int
|
| 56 |
+
errors: int
|
| 57 |
+
failed: int
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _process_example(
|
| 61 |
+
example: Any,
|
| 62 |
+
dataset: str,
|
| 63 |
+
labeled_fields: tuple[str, ...],
|
| 64 |
+
settings: Settings,
|
| 65 |
+
backend: Any,
|
| 66 |
+
) -> dict[str, Any]:
|
| 67 |
+
"""Save one example's image to a temp file, run the pipeline, build the entry.
|
| 68 |
+
|
| 69 |
+
The temp file is created first and unlinked in a ``finally`` so it is cleaned
|
| 70 |
+
up even if ``image.save`` or the pipeline raises. ``process_document`` itself
|
| 71 |
+
never raises (rule 6); only image encoding can, and the caller isolates that.
|
| 72 |
+
|
| 73 |
+
Args:
|
| 74 |
+
example: The :class:`~eval.datasets.base.GoldExample` to process.
|
| 75 |
+
dataset: Dataset name (recorded in the entry).
|
| 76 |
+
labeled_fields: The dataset's labeled fields (recorded in the entry).
|
| 77 |
+
settings: Validated configuration.
|
| 78 |
+
backend: The extraction backend to use.
|
| 79 |
+
|
| 80 |
+
Returns:
|
| 81 |
+
A JSON-serializable cache entry for the example.
|
| 82 |
+
"""
|
| 83 |
+
with tempfile.NamedTemporaryFile(suffix=example.suffix, delete=False) as handle:
|
| 84 |
+
temp_path = Path(handle.name)
|
| 85 |
+
try:
|
| 86 |
+
example.image.save(temp_path)
|
| 87 |
+
result = process_document(temp_path, settings=settings, backend=backend)
|
| 88 |
+
finally:
|
| 89 |
+
temp_path.unlink(missing_ok=True)
|
| 90 |
+
return _build_entry(example, result, dataset, labeled_fields)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _build_entry(
|
| 94 |
+
example: Any,
|
| 95 |
+
result: Any,
|
| 96 |
+
dataset: str,
|
| 97 |
+
labeled_fields: tuple[str, ...],
|
| 98 |
+
) -> dict[str, Any]:
|
| 99 |
+
"""Assemble a JSON-serializable cache entry from an example and its result."""
|
| 100 |
+
return {
|
| 101 |
+
"id": example.id,
|
| 102 |
+
"dataset": dataset,
|
| 103 |
+
"gold": example.gold,
|
| 104 |
+
"labeled_fields": list(labeled_fields),
|
| 105 |
+
"predicted": result.document.model_dump(mode="json"),
|
| 106 |
+
"confidence": result.confidence,
|
| 107 |
+
"decision": result.decision,
|
| 108 |
+
"modality": result.modality,
|
| 109 |
+
"backend": result.backend_name,
|
| 110 |
+
"validation": result.report.to_dict(),
|
| 111 |
+
"error": result.error,
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def run_predict(
|
| 116 |
+
dataset: str,
|
| 117 |
+
limit: int | None,
|
| 118 |
+
*,
|
| 119 |
+
settings: Settings | None = None,
|
| 120 |
+
cache_base: Path = DEFAULT_CACHE_BASE,
|
| 121 |
+
overwrite: bool = False,
|
| 122 |
+
) -> PredictStats:
|
| 123 |
+
"""Run the pipeline over a dataset slice and cache each result.
|
| 124 |
+
|
| 125 |
+
Args:
|
| 126 |
+
dataset: Name of a wired dataset adapter (e.g. "sroie").
|
| 127 |
+
limit: Number of examples to process (the held-out slice size); ``None``
|
| 128 |
+
for the whole split.
|
| 129 |
+
settings: Validated configuration; loaded from the environment when
|
| 130 |
+
``None`` (must select a backend that can read images).
|
| 131 |
+
cache_base: Root cache directory. Defaults to ``eval/cache``.
|
| 132 |
+
overwrite: Re-process and overwrite examples already cached. Defaults to
|
| 133 |
+
``False`` so re-runs resume without re-billing.
|
| 134 |
+
|
| 135 |
+
Returns:
|
| 136 |
+
A :class:`PredictStats` summary of the run.
|
| 137 |
+
|
| 138 |
+
Raises:
|
| 139 |
+
ValueError: If ``dataset`` is not wired for the predict phase.
|
| 140 |
+
"""
|
| 141 |
+
if dataset not in WIRED_DATASETS:
|
| 142 |
+
wired = ", ".join(sorted(WIRED_DATASETS))
|
| 143 |
+
raise ValueError(
|
| 144 |
+
f"Dataset {dataset!r} is scaffolded but not wired for prediction. "
|
| 145 |
+
f"Wired datasets: {wired}."
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
adapter = get_adapter(dataset)
|
| 149 |
+
settings = settings or load_config()
|
| 150 |
+
backend = create_backend(settings)
|
| 151 |
+
already = set() if overwrite else existing_ids(cache_base, dataset)
|
| 152 |
+
|
| 153 |
+
processed = skipped = accepted = review = errors = failed = 0
|
| 154 |
+
logger.info("eval-predict: dataset=%s limit=%s backend=%s", dataset, limit, backend.name)
|
| 155 |
+
|
| 156 |
+
for example in adapter.load(limit):
|
| 157 |
+
if example.id in already:
|
| 158 |
+
skipped += 1
|
| 159 |
+
logger.info("eval-predict: skip cached id=%s", example.id)
|
| 160 |
+
continue
|
| 161 |
+
|
| 162 |
+
# Isolate per-example preparation failures (e.g. an un-encodable image) so
|
| 163 |
+
# one bad input logs and is skipped rather than aborting the whole slice.
|
| 164 |
+
try:
|
| 165 |
+
entry = _process_example(example, dataset, adapter.labeled_fields, settings, backend)
|
| 166 |
+
except Exception as exc: # noqa: BLE001 -- never let one document halt the run.
|
| 167 |
+
failed += 1
|
| 168 |
+
logger.error("eval-predict: could not prepare id=%s -- skipping: %s", example.id, exc)
|
| 169 |
+
continue
|
| 170 |
+
|
| 171 |
+
write_entry(cache_base, dataset, entry)
|
| 172 |
+
|
| 173 |
+
processed += 1
|
| 174 |
+
if entry["error"]:
|
| 175 |
+
errors += 1
|
| 176 |
+
if entry["decision"] == "accept":
|
| 177 |
+
accepted += 1
|
| 178 |
+
else:
|
| 179 |
+
review += 1
|
| 180 |
+
logger.info(
|
| 181 |
+
"eval-predict: id=%s decision=%s confidence=%.3f error=%s",
|
| 182 |
+
example.id,
|
| 183 |
+
entry["decision"],
|
| 184 |
+
entry["confidence"],
|
| 185 |
+
bool(entry["error"]),
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
stats = PredictStats(
|
| 189 |
+
dataset=dataset,
|
| 190 |
+
requested=limit,
|
| 191 |
+
processed=processed,
|
| 192 |
+
skipped=skipped,
|
| 193 |
+
accepted=accepted,
|
| 194 |
+
review=review,
|
| 195 |
+
errors=errors,
|
| 196 |
+
failed=failed,
|
| 197 |
+
)
|
| 198 |
+
logger.info("eval-predict: done %s", stats)
|
| 199 |
+
return stats
|
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Command-line entry point for the two-phase evaluation harness.
|
| 2 |
+
|
| 3 |
+
Usage::
|
| 4 |
+
|
| 5 |
+
# Phase 1 -- runs the model, spends quota; start with a small slice.
|
| 6 |
+
uv run python -m eval.run_eval predict --dataset sroie --limit 20
|
| 7 |
+
|
| 8 |
+
# Phase 2 -- offline; recompute metrics and sweep as often as you like.
|
| 9 |
+
uv run python -m eval.run_eval score --dataset sroie
|
| 10 |
+
|
| 11 |
+
The predict phase caches results under ``eval/cache/<dataset>/`` and is
|
| 12 |
+
idempotent (already-cached ids are skipped unless ``--overwrite``). The score
|
| 13 |
+
phase reads that cache and prints the metrics tables and threshold sweep; it
|
| 14 |
+
never re-runs inference.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import argparse
|
| 20 |
+
import logging
|
| 21 |
+
import sys
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
from eval.cache import DEFAULT_CACHE_BASE
|
| 25 |
+
from eval.predict import run_predict
|
| 26 |
+
from eval.score import build_report, format_report
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _add_common(parser: argparse.ArgumentParser) -> None:
|
| 30 |
+
parser.add_argument("--dataset", default="sroie", help="Dataset adapter name (default: sroie).")
|
| 31 |
+
parser.add_argument(
|
| 32 |
+
"--cache-base",
|
| 33 |
+
type=Path,
|
| 34 |
+
default=DEFAULT_CACHE_BASE,
|
| 35 |
+
help="Root cache directory (default: eval/cache).",
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _build_parser() -> argparse.ArgumentParser:
|
| 40 |
+
parser = argparse.ArgumentParser(prog="eval.run_eval", description=__doc__)
|
| 41 |
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
| 42 |
+
|
| 43 |
+
predict = subparsers.add_parser("predict", help="Run the model over a slice and cache results.")
|
| 44 |
+
_add_common(predict)
|
| 45 |
+
predict.add_argument(
|
| 46 |
+
"--limit",
|
| 47 |
+
type=int,
|
| 48 |
+
default=20,
|
| 49 |
+
help="Number of examples to process (the held-out slice size; default: 20).",
|
| 50 |
+
)
|
| 51 |
+
predict.add_argument(
|
| 52 |
+
"--overwrite",
|
| 53 |
+
action="store_true",
|
| 54 |
+
help="Re-process and overwrite already-cached examples.",
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
score = subparsers.add_parser("score", help="Compute metrics + sweep from the cache (offline).")
|
| 58 |
+
_add_common(score)
|
| 59 |
+
|
| 60 |
+
return parser
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def main(argv: list[str] | None = None) -> int:
|
| 64 |
+
"""Run the CLI.
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
argv: Argument list (defaults to ``sys.argv[1:]``).
|
| 68 |
+
|
| 69 |
+
Returns:
|
| 70 |
+
Process exit code (0 on success).
|
| 71 |
+
"""
|
| 72 |
+
logging.basicConfig(
|
| 73 |
+
level=logging.INFO,
|
| 74 |
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
| 75 |
+
)
|
| 76 |
+
args = _build_parser().parse_args(argv)
|
| 77 |
+
|
| 78 |
+
if args.command == "predict":
|
| 79 |
+
stats = run_predict(
|
| 80 |
+
args.dataset,
|
| 81 |
+
args.limit,
|
| 82 |
+
cache_base=args.cache_base,
|
| 83 |
+
overwrite=args.overwrite,
|
| 84 |
+
)
|
| 85 |
+
print(
|
| 86 |
+
f"\nPredict complete for {stats.dataset}: "
|
| 87 |
+
f"processed={stats.processed} skipped={stats.skipped} "
|
| 88 |
+
f"accepted={stats.accepted} review={stats.review} errors={stats.errors} "
|
| 89 |
+
f"failed={stats.failed}\n"
|
| 90 |
+
f"Now run: uv run python -m eval.run_eval score --dataset {stats.dataset}"
|
| 91 |
+
)
|
| 92 |
+
return 0
|
| 93 |
+
|
| 94 |
+
if args.command == "score":
|
| 95 |
+
report = build_report(args.dataset, cache_base=args.cache_base)
|
| 96 |
+
print(format_report(report))
|
| 97 |
+
return 0
|
| 98 |
+
|
| 99 |
+
return 1 # pragma: no cover -- argparse enforces a valid subcommand.
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
if __name__ == "__main__":
|
| 103 |
+
sys.exit(main())
|
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Score phase: compute all metrics and the threshold sweep from the cache.
|
| 2 |
+
|
| 3 |
+
Purely offline -- it loads cached predictions (``eval.cache``), runs the pure
|
| 4 |
+
aggregations (``eval.metrics``), and formats human-readable tables. Re-running
|
| 5 |
+
with a different threshold grid or comparison never re-runs inference, which is
|
| 6 |
+
the whole point of the two-phase split.
|
| 7 |
+
|
| 8 |
+
The formatted output covers the four things the methodology asks for (data spec
|
| 9 |
+
section 6): per-field precision/recall/F1, per-critical-field metrics, document
|
| 10 |
+
routing stats, and the threshold sweep trade-off curve. It also prints the
|
| 11 |
+
confidence distribution (so a flat sweep is explained by the backend exposing no
|
| 12 |
+
per-field confidence) and, as analysis only, the lowest threshold that reaches a
|
| 13 |
+
target auto-accept precision -- the operator still chooses the value.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from dataclasses import dataclass
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from typing import Any
|
| 21 |
+
|
| 22 |
+
from eval.cache import DEFAULT_CACHE_BASE, read_entries
|
| 23 |
+
from eval.metrics import (
|
| 24 |
+
CRITICAL_FIELDS,
|
| 25 |
+
THRESHOLDS,
|
| 26 |
+
FieldMetric,
|
| 27 |
+
SweepRow,
|
| 28 |
+
compute_field_metrics,
|
| 29 |
+
confidence_histogram,
|
| 30 |
+
smallest_threshold_meeting,
|
| 31 |
+
sweep_thresholds,
|
| 32 |
+
)
|
| 33 |
+
from eval.normalize import is_present
|
| 34 |
+
|
| 35 |
+
# Auto-accept precision target on critical fields (data spec section 6).
|
| 36 |
+
TARGET_CRITICAL_PRECISION: float = 0.98
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass(frozen=True)
|
| 40 |
+
class ScoreReport:
|
| 41 |
+
"""Everything the score phase computed for one dataset slice."""
|
| 42 |
+
|
| 43 |
+
dataset: str
|
| 44 |
+
n: int
|
| 45 |
+
labeled_fields: tuple[str, ...]
|
| 46 |
+
critical_labeled: tuple[str, ...]
|
| 47 |
+
field_metrics: list[FieldMetric]
|
| 48 |
+
sweep: list[SweepRow]
|
| 49 |
+
confidence_hist: dict[float, int]
|
| 50 |
+
n_error: int
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _labeled_fields(entries: list[dict[str, Any]]) -> tuple[str, ...]:
|
| 54 |
+
"""Union of the ``labeled_fields`` recorded across cached entries."""
|
| 55 |
+
seen: list[str] = []
|
| 56 |
+
for entry in entries:
|
| 57 |
+
for field in entry.get("labeled_fields", []):
|
| 58 |
+
if field not in seen:
|
| 59 |
+
seen.append(field)
|
| 60 |
+
return tuple(seen)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _critical_labeled(labeled: tuple[str, ...], entries: list[dict[str, Any]]) -> tuple[str, ...]:
|
| 64 |
+
"""Critical fields the dataset actually labels *and* has gold present for.
|
| 65 |
+
|
| 66 |
+
A critical field with no gold anywhere in the slice cannot be scored, so it
|
| 67 |
+
is excluded from the critical-precision denominators.
|
| 68 |
+
"""
|
| 69 |
+
result: list[str] = []
|
| 70 |
+
for field in CRITICAL_FIELDS:
|
| 71 |
+
if field not in labeled:
|
| 72 |
+
continue
|
| 73 |
+
if any(is_present(field, entry.get("gold", {}).get(field)) for entry in entries):
|
| 74 |
+
result.append(field)
|
| 75 |
+
return tuple(result)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def build_report(
|
| 79 |
+
dataset: str,
|
| 80 |
+
*,
|
| 81 |
+
cache_base: Path = DEFAULT_CACHE_BASE,
|
| 82 |
+
thresholds: tuple[float, ...] = THRESHOLDS,
|
| 83 |
+
) -> ScoreReport:
|
| 84 |
+
"""Load the cache for a dataset and compute the full score report.
|
| 85 |
+
|
| 86 |
+
Args:
|
| 87 |
+
dataset: Dataset name whose cache to score.
|
| 88 |
+
cache_base: Root cache directory. Defaults to ``eval/cache``.
|
| 89 |
+
thresholds: The threshold grid to sweep.
|
| 90 |
+
|
| 91 |
+
Returns:
|
| 92 |
+
A :class:`ScoreReport`.
|
| 93 |
+
|
| 94 |
+
Raises:
|
| 95 |
+
FileNotFoundError: If no cached entries exist for the dataset.
|
| 96 |
+
"""
|
| 97 |
+
entries = read_entries(cache_base, dataset)
|
| 98 |
+
if not entries:
|
| 99 |
+
raise FileNotFoundError(
|
| 100 |
+
f"No cached predictions for dataset {dataset!r} under {cache_base}. "
|
| 101 |
+
"Run the predict phase first."
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
labeled = _labeled_fields(entries)
|
| 105 |
+
critical_labeled = _critical_labeled(labeled, entries)
|
| 106 |
+
field_metrics = compute_field_metrics(entries, labeled)
|
| 107 |
+
sweep = sweep_thresholds(entries, critical_labeled, thresholds)
|
| 108 |
+
hist = confidence_histogram(entries)
|
| 109 |
+
n_error = sum(1 for entry in entries if entry.get("error"))
|
| 110 |
+
|
| 111 |
+
return ScoreReport(
|
| 112 |
+
dataset=dataset,
|
| 113 |
+
n=len(entries),
|
| 114 |
+
labeled_fields=labeled,
|
| 115 |
+
critical_labeled=critical_labeled,
|
| 116 |
+
field_metrics=field_metrics,
|
| 117 |
+
sweep=sweep,
|
| 118 |
+
confidence_hist=hist,
|
| 119 |
+
n_error=n_error,
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# --- Formatting ----------------------------------------------------------------
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def _pct(value: float | None) -> str:
|
| 127 |
+
"""Format an optional ratio as a percentage, or 'n/a' when undefined."""
|
| 128 |
+
return " n/a" if value is None else f"{value * 100:5.1f}%"
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _format_field_table(report: ScoreReport) -> list[str]:
|
| 132 |
+
lines = [
|
| 133 |
+
"Per-field metrics (whole slice):",
|
| 134 |
+
f" {'field':<16} {'P':>7} {'R':>7} {'F1':>7} {'pred':>4} {'gold':>4} {'ok':>4}",
|
| 135 |
+
]
|
| 136 |
+
for metric in report.field_metrics:
|
| 137 |
+
marker = " *" if metric.field in report.critical_labeled else " "
|
| 138 |
+
lines.append(
|
| 139 |
+
f"{marker}{metric.field:<16} {_pct(metric.precision)} {_pct(metric.recall)} "
|
| 140 |
+
f"{_pct(metric.f1)} {metric.n_pred:>4} {metric.n_gold:>4} {metric.n_match:>4}"
|
| 141 |
+
)
|
| 142 |
+
lines.append(" (* = critical field)")
|
| 143 |
+
return lines
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _format_sweep_table(report: ScoreReport, coarse_step: int = 5) -> list[str]:
|
| 147 |
+
lines = [
|
| 148 |
+
"Threshold sweep (critical fields on the auto-accepted subset):",
|
| 149 |
+
f" {'thr':>5} {'accept':>7} {'accept%':>8} {'crit P':>8} {'crit R':>8}",
|
| 150 |
+
]
|
| 151 |
+
for index, row in enumerate(report.sweep):
|
| 152 |
+
# Print a coarse grid plus the final threshold to keep it readable.
|
| 153 |
+
is_grid = index % coarse_step == 0 or index == len(report.sweep) - 1
|
| 154 |
+
if not is_grid:
|
| 155 |
+
continue
|
| 156 |
+
lines.append(
|
| 157 |
+
f" {row.threshold:>5.2f} {row.n_accepted:>7} {row.accept_rate * 100:>7.1f}% "
|
| 158 |
+
f"{_pct(row.crit_precision)} {_pct(row.crit_recall)}"
|
| 159 |
+
)
|
| 160 |
+
return lines
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def _format_confidence(report: ScoreReport) -> list[str]:
|
| 164 |
+
lines = ["Confidence distribution (cached scores):"]
|
| 165 |
+
for value, count in report.confidence_hist.items():
|
| 166 |
+
bar = "#" * count
|
| 167 |
+
lines.append(f" {value:>5.2f} {count:>3} {bar}")
|
| 168 |
+
return lines
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _format_routing(report: ScoreReport) -> list[str]:
|
| 172 |
+
target = smallest_threshold_meeting(report.sweep, TARGET_CRITICAL_PRECISION)
|
| 173 |
+
lines = [
|
| 174 |
+
"Operating point analysis (you choose the threshold):",
|
| 175 |
+
f" Target: auto-accept precision on critical fields "
|
| 176 |
+
f"{report.critical_labeled or '(none labeled)'} >= "
|
| 177 |
+
f"{TARGET_CRITICAL_PRECISION:.2f}",
|
| 178 |
+
]
|
| 179 |
+
if not report.critical_labeled:
|
| 180 |
+
lines.append(
|
| 181 |
+
" This dataset labels none of total/tax/invoice_number, so critical "
|
| 182 |
+
"auto-accept precision cannot be measured here."
|
| 183 |
+
)
|
| 184 |
+
elif target is None:
|
| 185 |
+
lines.append(
|
| 186 |
+
" No threshold in the sweep reaches the target with any "
|
| 187 |
+
"auto-accepted document (see the confidence distribution above)."
|
| 188 |
+
)
|
| 189 |
+
else:
|
| 190 |
+
lines.append(
|
| 191 |
+
f" Lowest qualifying threshold: {target.threshold:.2f} "
|
| 192 |
+
f"(accept {target.n_accepted}/{target.n_total} = "
|
| 193 |
+
f"{target.accept_rate * 100:.1f}%, crit P {_pct(target.crit_precision)}, "
|
| 194 |
+
f"crit R {_pct(target.crit_recall)})."
|
| 195 |
+
)
|
| 196 |
+
return lines
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def format_report(report: ScoreReport) -> str:
|
| 200 |
+
"""Render a :class:`ScoreReport` as a plain-text report.
|
| 201 |
+
|
| 202 |
+
Args:
|
| 203 |
+
report: The computed score report.
|
| 204 |
+
|
| 205 |
+
Returns:
|
| 206 |
+
A multi-line string ready to print.
|
| 207 |
+
"""
|
| 208 |
+
header = [
|
| 209 |
+
"=" * 68,
|
| 210 |
+
f"Evaluation: {report.dataset} (n={report.n}, errors={report.n_error})",
|
| 211 |
+
f"Labeled fields: {', '.join(report.labeled_fields) or '(none)'}",
|
| 212 |
+
"=" * 68,
|
| 213 |
+
]
|
| 214 |
+
sections = [
|
| 215 |
+
header,
|
| 216 |
+
_format_field_table(report),
|
| 217 |
+
_format_confidence(report),
|
| 218 |
+
_format_sweep_table(report),
|
| 219 |
+
_format_routing(report),
|
| 220 |
+
]
|
| 221 |
+
return "\n\n".join("\n".join(section) for section in sections)
|
|
@@ -8,9 +8,11 @@ authors = [
|
|
| 8 |
]
|
| 9 |
requires-python = ">=3.11"
|
| 10 |
dependencies = [
|
|
|
|
| 11 |
"docling>=2.107.0",
|
| 12 |
"google-genai>=2.10.0",
|
| 13 |
"gradio>=6.19.0",
|
|
|
|
| 14 |
"pydantic>=2.13.4",
|
| 15 |
"pydantic-settings>=2.14.2",
|
| 16 |
"watchdog>=6.0.0",
|
|
@@ -25,6 +27,9 @@ line-length = 100
|
|
| 25 |
|
| 26 |
[tool.pytest.ini_options]
|
| 27 |
testpaths = ["tests"]
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
[dependency-groups]
|
| 30 |
dev = [
|
|
|
|
| 8 |
]
|
| 9 |
requires-python = ">=3.11"
|
| 10 |
dependencies = [
|
| 11 |
+
"datasets[vision]>=5.0.0",
|
| 12 |
"docling>=2.107.0",
|
| 13 |
"google-genai>=2.10.0",
|
| 14 |
"gradio>=6.19.0",
|
| 15 |
+
"pillow>=12.2.0",
|
| 16 |
"pydantic>=2.13.4",
|
| 17 |
"pydantic-settings>=2.14.2",
|
| 18 |
"watchdog>=6.0.0",
|
|
|
|
| 27 |
|
| 28 |
[tool.pytest.ini_options]
|
| 29 |
testpaths = ["tests"]
|
| 30 |
+
# Repo root on the path so tests can import the top-level `eval` package
|
| 31 |
+
# (the harness lives at ./eval, outside the installed src/doc_agent package).
|
| 32 |
+
pythonpath = ["."]
|
| 33 |
|
| 34 |
[dependency-groups]
|
| 35 |
dev = [
|
|
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for the evaluation harness (build-plan phase 5).
|
| 2 |
+
|
| 3 |
+
Fully offline: no model calls, no dataset downloads. The comparison function is
|
| 4 |
+
tested directly, and the score-phase computation is tested on hand-built cached
|
| 5 |
+
entries with known, hand-computed metrics. The threshold sweep is tested for the
|
| 6 |
+
hard-failure override and the precision/recall trade-off.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
import pytest
|
| 15 |
+
|
| 16 |
+
from eval.cache import read_entries, report_from_dict, write_entry
|
| 17 |
+
from eval.metrics import (
|
| 18 |
+
THRESHOLDS,
|
| 19 |
+
compute_field_metrics,
|
| 20 |
+
confidence_histogram,
|
| 21 |
+
smallest_threshold_meeting,
|
| 22 |
+
sweep_thresholds,
|
| 23 |
+
)
|
| 24 |
+
from eval.normalize import is_present, normalize, values_match
|
| 25 |
+
from eval.score import build_report
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
# Comparison function (normalize / values_match)
|
| 30 |
+
# ---------------------------------------------------------------------------
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_money_matches_cent_exact() -> None:
|
| 34 |
+
"""Monetary values match only when equal at cent precision."""
|
| 35 |
+
assert values_match("total", 193.0, "193.00")
|
| 36 |
+
assert values_match("total", "1,234.56", "1234.56")
|
| 37 |
+
assert values_match("total", 100.004, 100.0) # sub-cent noise rounds away
|
| 38 |
+
assert not values_match("total", 100.0, 100.01) # a genuine 1-cent difference
|
| 39 |
+
assert not values_match("total", 100.0, 105.0)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def test_money_rejects_relative_tolerance_error() -> None:
|
| 43 |
+
"""Regression: a materially-wrong total within 0.5% is NOT scored correct.
|
| 44 |
+
|
| 45 |
+
The eval comparator must not reuse the reconciliation relative epsilon, or a
|
| 46 |
+
$2 error on a $500 total (and $10 on $2000) would inflate critical precision.
|
| 47 |
+
"""
|
| 48 |
+
assert not values_match("total", 502.0, "500.00") # within money_close's 0.5% band
|
| 49 |
+
assert not values_match("total", 2010.0, 2000.0) # +/-$10 window at $2000
|
| 50 |
+
assert not values_match("tax", 9.05, 9.0) # 5-cent tax error
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_money_handles_currency_symbols_and_separators() -> None:
|
| 54 |
+
"""Currency symbols and thousands separators normalize away."""
|
| 55 |
+
assert normalize("total", "$1,000.00") == pytest.approx(1000.0)
|
| 56 |
+
assert values_match("total", "RM 193.00", "193.0")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_date_matches_day_first_format() -> None:
|
| 60 |
+
"""SROIE-style day-first dates match the ISO-cached prediction."""
|
| 61 |
+
# gold "15/01/2019" (D/M/Y) vs predicted cached ISO "2019-01-15".
|
| 62 |
+
assert values_match("document_date", "2019-01-15", "15/01/2019")
|
| 63 |
+
assert not values_match("document_date", "2019-01-16", "15/01/2019")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_text_matches_case_and_whitespace_insensitive() -> None:
|
| 67 |
+
"""Text matches after lower-casing and whitespace collapsing."""
|
| 68 |
+
assert values_match("vendor_name", "OJC Marketing SDN BHD", "ojc marketing sdn bhd")
|
| 69 |
+
assert not values_match("vendor_name", "Acme Corp", "Beta LLC")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_absent_values_never_match() -> None:
|
| 73 |
+
"""A present prediction against absent gold (or vice versa) is not a match."""
|
| 74 |
+
assert not values_match("total", 100.0, None)
|
| 75 |
+
assert not values_match("total", None, 100.0)
|
| 76 |
+
assert not values_match("vendor_name", "", "acme")
|
| 77 |
+
assert not is_present("vendor_name", " ")
|
| 78 |
+
assert not is_present("total", "N/A")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_unparseable_money_is_absent() -> None:
|
| 82 |
+
"""An unparseable monetary string normalizes to None (absent), not a crash."""
|
| 83 |
+
assert normalize("total", "not a number") is None
|
| 84 |
+
assert not is_present("total", "abc")
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
# Synthetic cached entries with known metrics
|
| 89 |
+
# ---------------------------------------------------------------------------
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _validation(hard_failed: bool, *, hard_codes: list[str] | None = None) -> dict[str, Any]:
|
| 93 |
+
"""Build a minimal validation dict as ValidationReport.to_dict would."""
|
| 94 |
+
results = []
|
| 95 |
+
for code in hard_codes or []:
|
| 96 |
+
results.append(
|
| 97 |
+
{"code": code, "severity": "hard", "status": "fail", "message": "synthetic"}
|
| 98 |
+
)
|
| 99 |
+
return {
|
| 100 |
+
"hard_failed": hard_failed,
|
| 101 |
+
"results": results,
|
| 102 |
+
"hard_failures": list(hard_codes or []),
|
| 103 |
+
"soft_failures": [],
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _entry(
|
| 108 |
+
example_id: str,
|
| 109 |
+
*,
|
| 110 |
+
predicted: dict[str, Any],
|
| 111 |
+
gold: dict[str, Any],
|
| 112 |
+
confidence: float,
|
| 113 |
+
hard_failed: bool = False,
|
| 114 |
+
hard_codes: list[str] | None = None,
|
| 115 |
+
labeled: tuple[str, ...] = ("vendor_name", "vendor_address", "document_date", "total"),
|
| 116 |
+
) -> dict[str, Any]:
|
| 117 |
+
return {
|
| 118 |
+
"id": example_id,
|
| 119 |
+
"dataset": "synthetic",
|
| 120 |
+
"gold": gold,
|
| 121 |
+
"labeled_fields": list(labeled),
|
| 122 |
+
"predicted": predicted,
|
| 123 |
+
"confidence": confidence,
|
| 124 |
+
"decision": "review",
|
| 125 |
+
"modality": "image",
|
| 126 |
+
"backend": "stub",
|
| 127 |
+
"validation": _validation(hard_failed, hard_codes=hard_codes),
|
| 128 |
+
"error": None,
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@pytest.fixture
|
| 133 |
+
def synthetic_entries() -> list[dict[str, Any]]:
|
| 134 |
+
"""Four entries with hand-computable per-field metrics.
|
| 135 |
+
|
| 136 |
+
total field: preds present on all 4; golds present on all 4;
|
| 137 |
+
- e1 correct, e2 correct, e3 wrong value, e4 correct -> 3/4 match.
|
| 138 |
+
vendor_name: preds present on 3 (e4 missing); golds present on 4;
|
| 139 |
+
- e1 correct, e2 correct, e3 correct -> 3 match.
|
| 140 |
+
=> precision 3/3 = 1.0, recall 3/4 = 0.75.
|
| 141 |
+
"""
|
| 142 |
+
return [
|
| 143 |
+
_entry(
|
| 144 |
+
"e1",
|
| 145 |
+
predicted={"vendor_name": "Acme", "total": 100.0},
|
| 146 |
+
gold={"vendor_name": "acme", "total": "100.00", "vendor_address": None,
|
| 147 |
+
"document_date": None},
|
| 148 |
+
confidence=0.90,
|
| 149 |
+
),
|
| 150 |
+
_entry(
|
| 151 |
+
"e2",
|
| 152 |
+
predicted={"vendor_name": "Beta", "total": 50.0},
|
| 153 |
+
gold={"vendor_name": "beta", "total": "50.00", "vendor_address": None,
|
| 154 |
+
"document_date": None},
|
| 155 |
+
confidence=0.80,
|
| 156 |
+
),
|
| 157 |
+
_entry(
|
| 158 |
+
"e3",
|
| 159 |
+
predicted={"vendor_name": "Gamma", "total": 999.0}, # total wrong
|
| 160 |
+
gold={"vendor_name": "gamma", "total": "10.00", "vendor_address": None,
|
| 161 |
+
"document_date": None},
|
| 162 |
+
confidence=0.70,
|
| 163 |
+
),
|
| 164 |
+
_entry(
|
| 165 |
+
"e4",
|
| 166 |
+
predicted={"vendor_name": None, "total": 25.0}, # vendor missing
|
| 167 |
+
gold={"vendor_name": "delta", "total": "25.00", "vendor_address": None,
|
| 168 |
+
"document_date": None},
|
| 169 |
+
confidence=0.60,
|
| 170 |
+
),
|
| 171 |
+
]
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def test_field_metrics_match_hand_computed(synthetic_entries: list[dict[str, Any]]) -> None:
|
| 175 |
+
"""Per-field precision/recall/F1 equal the hand-computed values."""
|
| 176 |
+
metrics = {m.field: m for m in compute_field_metrics(
|
| 177 |
+
synthetic_entries, ("vendor_name", "total"))}
|
| 178 |
+
|
| 179 |
+
total = metrics["total"]
|
| 180 |
+
assert (total.n_pred, total.n_gold, total.n_match) == (4, 4, 3)
|
| 181 |
+
assert total.precision == pytest.approx(0.75)
|
| 182 |
+
assert total.recall == pytest.approx(0.75)
|
| 183 |
+
assert total.f1 == pytest.approx(0.75)
|
| 184 |
+
|
| 185 |
+
vendor = metrics["vendor_name"]
|
| 186 |
+
assert (vendor.n_pred, vendor.n_gold, vendor.n_match) == (3, 4, 3)
|
| 187 |
+
assert vendor.precision == pytest.approx(1.0)
|
| 188 |
+
assert vendor.recall == pytest.approx(0.75)
|
| 189 |
+
assert vendor.f1 == pytest.approx(2 * 1.0 * 0.75 / (1.0 + 0.75))
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def test_field_precision_none_when_no_prediction() -> None:
|
| 193 |
+
"""Precision is None (undefined) when nothing was predicted for a field."""
|
| 194 |
+
entries = [
|
| 195 |
+
_entry("e1", predicted={"total": None}, gold={"total": "5.00"}, confidence=0.9),
|
| 196 |
+
]
|
| 197 |
+
(metric,) = compute_field_metrics(entries, ("total",))
|
| 198 |
+
assert metric.n_pred == 0
|
| 199 |
+
assert metric.precision is None
|
| 200 |
+
assert metric.recall == pytest.approx(0.0)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
# ---------------------------------------------------------------------------
|
| 204 |
+
# Threshold sweep
|
| 205 |
+
# ---------------------------------------------------------------------------
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def test_sweep_accept_count_falls_as_threshold_rises(
|
| 209 |
+
synthetic_entries: list[dict[str, Any]],
|
| 210 |
+
) -> None:
|
| 211 |
+
"""Higher thresholds auto-accept no more documents than lower ones."""
|
| 212 |
+
rows = sweep_thresholds(synthetic_entries, ("total",), THRESHOLDS)
|
| 213 |
+
accept_counts = [row.n_accepted for row in rows]
|
| 214 |
+
assert accept_counts == sorted(accept_counts, reverse=True)
|
| 215 |
+
# At 0.50 every clean doc (confidence >= 0.50) is accepted; all 4 here.
|
| 216 |
+
assert rows[0].threshold == 0.50
|
| 217 |
+
assert rows[0].n_accepted == 4
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def test_sweep_hard_failure_never_accepted() -> None:
|
| 221 |
+
"""A hard-failed document is forced to review at every threshold."""
|
| 222 |
+
entries = [
|
| 223 |
+
_entry(
|
| 224 |
+
"hard",
|
| 225 |
+
predicted={"total": 100.0},
|
| 226 |
+
gold={"total": "100.00"},
|
| 227 |
+
confidence=0.99, # very confident...
|
| 228 |
+
hard_failed=True,
|
| 229 |
+
hard_codes=["H2"], # ...but a hard rule failed.
|
| 230 |
+
),
|
| 231 |
+
]
|
| 232 |
+
rows = sweep_thresholds(entries, ("total",), THRESHOLDS)
|
| 233 |
+
assert all(row.n_accepted == 0 for row in rows)
|
| 234 |
+
# And the reconstructed report reports the hard failure.
|
| 235 |
+
report = report_from_dict(entries[0]["validation"])
|
| 236 |
+
assert report.hard_failed is True
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def test_sweep_critical_precision_and_recall(
|
| 240 |
+
synthetic_entries: list[dict[str, Any]],
|
| 241 |
+
) -> None:
|
| 242 |
+
"""At threshold 0.50 the critical (total) precision/recall match hand calc.
|
| 243 |
+
|
| 244 |
+
All 4 accepted; total correct on 3/4 => precision 0.75; gold present on 4 =>
|
| 245 |
+
recall 3/4 = 0.75.
|
| 246 |
+
"""
|
| 247 |
+
rows = sweep_thresholds(synthetic_entries, ("total",), THRESHOLDS)
|
| 248 |
+
row_050 = rows[0]
|
| 249 |
+
assert row_050.crit_pred == 4
|
| 250 |
+
assert row_050.crit_match == 3
|
| 251 |
+
assert row_050.crit_precision == pytest.approx(0.75)
|
| 252 |
+
assert row_050.crit_recall == pytest.approx(0.75)
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def test_smallest_threshold_meeting_target() -> None:
|
| 256 |
+
"""The lowest qualifying threshold is found when a clean high-conf doc exists."""
|
| 257 |
+
entries = [
|
| 258 |
+
_entry("ok", predicted={"total": 10.0}, gold={"total": "10.00"}, confidence=0.95),
|
| 259 |
+
]
|
| 260 |
+
rows = sweep_thresholds(entries, ("total",), THRESHOLDS)
|
| 261 |
+
target = smallest_threshold_meeting(rows, 0.98)
|
| 262 |
+
assert target is not None
|
| 263 |
+
# confidence 0.95 accepts for thresholds <= 0.95; precision is 1.0 (perfect).
|
| 264 |
+
assert target.threshold == 0.50
|
| 265 |
+
assert target.crit_precision == pytest.approx(1.0)
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def test_smallest_threshold_meeting_none_when_unreachable() -> None:
|
| 269 |
+
"""Returns None when no threshold reaches the target precision."""
|
| 270 |
+
entries = [
|
| 271 |
+
_entry("bad", predicted={"total": 999.0}, gold={"total": "10.00"}, confidence=0.95),
|
| 272 |
+
]
|
| 273 |
+
rows = sweep_thresholds(entries, ("total",), THRESHOLDS)
|
| 274 |
+
assert smallest_threshold_meeting(rows, 0.98) is None
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def test_confidence_histogram_counts() -> None:
|
| 278 |
+
"""The histogram buckets rounded confidences."""
|
| 279 |
+
entries = [
|
| 280 |
+
_entry("a", predicted={}, gold={}, confidence=0.50),
|
| 281 |
+
_entry("b", predicted={}, gold={}, confidence=0.50),
|
| 282 |
+
_entry("c", predicted={}, gold={}, confidence=0.40),
|
| 283 |
+
]
|
| 284 |
+
hist = confidence_histogram(entries)
|
| 285 |
+
assert hist == {0.40: 1, 0.50: 2}
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
# ---------------------------------------------------------------------------
|
| 289 |
+
# End-to-end score phase over a written cache (still offline)
|
| 290 |
+
# ---------------------------------------------------------------------------
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def test_build_report_from_written_cache(
|
| 294 |
+
tmp_path: Path, synthetic_entries: list[dict[str, Any]]
|
| 295 |
+
) -> None:
|
| 296 |
+
"""Writing entries then building a report round-trips and computes metrics."""
|
| 297 |
+
for entry in synthetic_entries:
|
| 298 |
+
write_entry(tmp_path, "synthetic", entry)
|
| 299 |
+
|
| 300 |
+
assert len(read_entries(tmp_path, "synthetic")) == 4
|
| 301 |
+
|
| 302 |
+
report = build_report("synthetic", cache_base=tmp_path)
|
| 303 |
+
assert report.n == 4
|
| 304 |
+
assert "total" in report.labeled_fields
|
| 305 |
+
# SROIE-like labeling: total is the only critical field labeled here.
|
| 306 |
+
assert report.critical_labeled == ("total",)
|
| 307 |
+
total = next(m for m in report.field_metrics if m.field == "total")
|
| 308 |
+
assert total.n_match == 3
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def test_build_report_raises_without_cache(tmp_path: Path) -> None:
|
| 312 |
+
"""Scoring a dataset with no cache raises a clear error."""
|
| 313 |
+
with pytest.raises(FileNotFoundError):
|
| 314 |
+
build_report("missing", cache_base=tmp_path)
|
|
The diff for this file is too large to render.
See raw diff
|
|
|