kennethzychew commited on
Commit
d2a6765
·
1 Parent(s): 8089454

phase 5: evaluation harness (SROIE)

Browse files

Two-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 CHANGED
@@ -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
 
eval/__init__.py ADDED
@@ -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
+ """
eval/cache.py ADDED
@@ -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)
eval/datasets/__init__.py ADDED
@@ -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
+ ]
eval/datasets/base.py ADDED
@@ -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
+ ...
eval/datasets/cord.py ADDED
@@ -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.
eval/datasets/invoice_json.py ADDED
@@ -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.
eval/datasets/sroie.py ADDED
@@ -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
+ )
eval/metrics.py ADDED
@@ -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
eval/normalize.py ADDED
@@ -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
eval/predict.py ADDED
@@ -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
eval/run_eval.py ADDED
@@ -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())
eval/score.py ADDED
@@ -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)
pyproject.toml CHANGED
@@ -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 = [
tests/test_eval.py ADDED
@@ -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)
uv.lock CHANGED
The diff for this file is too large to render. See raw diff