Spaces:
Sleeping
Sleeping
File size: 14,210 Bytes
d2a6765 fa6cf50 d2a6765 fa6cf50 d2a6765 6eb59bb d2a6765 6eb59bb d2a6765 fa6cf50 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 | """Unit tests for the evaluation harness (build-plan phase 5).
Fully offline: no model calls, no dataset downloads. The comparison function is
tested directly, and the score-phase computation is tested on hand-built cached
entries with known, hand-computed metrics. The threshold sweep is tested for the
hard-failure override and the precision/recall trade-off.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from eval.cache import errored_ids, read_entries, report_from_dict, write_entry
from eval.metrics import (
THRESHOLDS,
compute_field_metrics,
confidence_histogram,
smallest_threshold_meeting,
sweep_thresholds,
)
from eval.normalize import is_present, normalize, values_match
from eval.predict import run_predict
from eval.score import build_report
# ---------------------------------------------------------------------------
# Comparison function (normalize / values_match)
# ---------------------------------------------------------------------------
def test_money_matches_cent_exact() -> None:
"""Monetary values match only when equal at cent precision."""
assert values_match("total", 193.0, "193.00")
assert values_match("total", "1,234.56", "1234.56")
assert values_match("total", 100.004, 100.0) # sub-cent noise rounds away
assert not values_match("total", 100.0, 100.01) # a genuine 1-cent difference
assert not values_match("total", 100.0, 105.0)
def test_money_rejects_relative_tolerance_error() -> None:
"""Regression: a materially-wrong total within 0.5% is NOT scored correct.
The eval comparator must not adopt the reconciliation allowance, or a $2
error on a $500 total (and $10 on $2000) would inflate critical precision.
These gaps were inside the old 0.5% relative band; that band has since been
removed from validation as well (FC-2), but the comparator must stay
cent-exact whatever the validation allowance is, so this test stays.
"""
assert not values_match("total", 502.0, "500.00") # inside the old 0.5% band
assert not values_match("total", 2010.0, 2000.0) # +/-$10 window at $2000
assert not values_match("tax", 9.05, 9.0) # 5-cent tax error
def test_money_handles_currency_symbols_and_separators() -> None:
"""Currency symbols and thousands separators normalize away."""
assert normalize("total", "$1,000.00") == pytest.approx(1000.0)
assert values_match("total", "RM 193.00", "193.0")
def test_date_matches_day_first_format() -> None:
"""SROIE-style day-first dates match the ISO-cached prediction."""
# gold "15/01/2019" (D/M/Y) vs predicted cached ISO "2019-01-15".
assert values_match("document_date", "2019-01-15", "15/01/2019")
assert not values_match("document_date", "2019-01-16", "15/01/2019")
def test_text_matches_case_and_whitespace_insensitive() -> None:
"""Text matches after lower-casing and whitespace collapsing."""
assert values_match("vendor_name", "OJC Marketing SDN BHD", "ojc marketing sdn bhd")
assert not values_match("vendor_name", "Acme Corp", "Beta LLC")
def test_absent_values_never_match() -> None:
"""A present prediction against absent gold (or vice versa) is not a match."""
assert not values_match("total", 100.0, None)
assert not values_match("total", None, 100.0)
assert not values_match("vendor_name", "", "acme")
assert not is_present("vendor_name", " ")
assert not is_present("total", "N/A")
def test_unparseable_money_is_absent() -> None:
"""An unparseable monetary string normalizes to None (absent), not a crash."""
assert normalize("total", "not a number") is None
assert not is_present("total", "abc")
# ---------------------------------------------------------------------------
# Synthetic cached entries with known metrics
# ---------------------------------------------------------------------------
def _validation(hard_failed: bool, *, hard_codes: list[str] | None = None) -> dict[str, Any]:
"""Build a minimal validation dict as ValidationReport.to_dict would."""
results = []
for code in hard_codes or []:
results.append(
{"code": code, "severity": "hard", "status": "fail", "message": "synthetic"}
)
return {
"hard_failed": hard_failed,
"results": results,
"hard_failures": list(hard_codes or []),
"soft_failures": [],
}
def _entry(
example_id: str,
*,
predicted: dict[str, Any],
gold: dict[str, Any],
confidence: float,
hard_failed: bool = False,
hard_codes: list[str] | None = None,
labeled: tuple[str, ...] = ("vendor_name", "vendor_address", "document_date", "total"),
) -> dict[str, Any]:
return {
"id": example_id,
"dataset": "synthetic",
"gold": gold,
"labeled_fields": list(labeled),
"predicted": predicted,
"confidence": confidence,
"decision": "review",
"modality": "image",
"backend": "stub",
"validation": _validation(hard_failed, hard_codes=hard_codes),
"error": None,
}
@pytest.fixture
def synthetic_entries() -> list[dict[str, Any]]:
"""Four entries with hand-computable per-field metrics.
total field: preds present on all 4; golds present on all 4;
- e1 correct, e2 correct, e3 wrong value, e4 correct -> 3/4 match.
vendor_name: preds present on 3 (e4 missing); golds present on 4;
- e1 correct, e2 correct, e3 correct -> 3 match.
=> precision 3/3 = 1.0, recall 3/4 = 0.75.
"""
return [
_entry(
"e1",
predicted={"vendor_name": "Acme", "total": 100.0},
gold={"vendor_name": "acme", "total": "100.00", "vendor_address": None,
"document_date": None},
confidence=0.90,
),
_entry(
"e2",
predicted={"vendor_name": "Beta", "total": 50.0},
gold={"vendor_name": "beta", "total": "50.00", "vendor_address": None,
"document_date": None},
confidence=0.80,
),
_entry(
"e3",
predicted={"vendor_name": "Gamma", "total": 999.0}, # total wrong
gold={"vendor_name": "gamma", "total": "10.00", "vendor_address": None,
"document_date": None},
confidence=0.70,
),
_entry(
"e4",
predicted={"vendor_name": None, "total": 25.0}, # vendor missing
gold={"vendor_name": "delta", "total": "25.00", "vendor_address": None,
"document_date": None},
confidence=0.60,
),
]
def test_field_metrics_match_hand_computed(synthetic_entries: list[dict[str, Any]]) -> None:
"""Per-field precision/recall/F1 equal the hand-computed values."""
metrics = {m.field: m for m in compute_field_metrics(
synthetic_entries, ("vendor_name", "total"))}
total = metrics["total"]
assert (total.n_pred, total.n_gold, total.n_match) == (4, 4, 3)
assert total.precision == pytest.approx(0.75)
assert total.recall == pytest.approx(0.75)
assert total.f1 == pytest.approx(0.75)
vendor = metrics["vendor_name"]
assert (vendor.n_pred, vendor.n_gold, vendor.n_match) == (3, 4, 3)
assert vendor.precision == pytest.approx(1.0)
assert vendor.recall == pytest.approx(0.75)
assert vendor.f1 == pytest.approx(2 * 1.0 * 0.75 / (1.0 + 0.75))
def test_field_precision_none_when_no_prediction() -> None:
"""Precision is None (undefined) when nothing was predicted for a field."""
entries = [
_entry("e1", predicted={"total": None}, gold={"total": "5.00"}, confidence=0.9),
]
(metric,) = compute_field_metrics(entries, ("total",))
assert metric.n_pred == 0
assert metric.precision is None
assert metric.recall == pytest.approx(0.0)
# ---------------------------------------------------------------------------
# Threshold sweep
# ---------------------------------------------------------------------------
def test_sweep_accept_count_falls_as_threshold_rises(
synthetic_entries: list[dict[str, Any]],
) -> None:
"""Higher thresholds auto-accept no more documents than lower ones."""
rows = sweep_thresholds(synthetic_entries, ("total",), THRESHOLDS)
accept_counts = [row.n_accepted for row in rows]
assert accept_counts == sorted(accept_counts, reverse=True)
# At 0.50 every clean doc (confidence >= 0.50) is accepted; all 4 here.
assert rows[0].threshold == 0.50
assert rows[0].n_accepted == 4
def test_sweep_hard_failure_never_accepted() -> None:
"""A hard-failed document is forced to review at every threshold."""
entries = [
_entry(
"hard",
predicted={"total": 100.0},
gold={"total": "100.00"},
confidence=0.99, # very confident...
hard_failed=True,
hard_codes=["H2"], # ...but a hard rule failed.
),
]
rows = sweep_thresholds(entries, ("total",), THRESHOLDS)
assert all(row.n_accepted == 0 for row in rows)
# And the reconstructed report reports the hard failure.
report = report_from_dict(entries[0]["validation"])
assert report.hard_failed is True
def test_sweep_critical_precision_and_recall(
synthetic_entries: list[dict[str, Any]],
) -> None:
"""At threshold 0.50 the critical (total) precision/recall match hand calc.
All 4 accepted; total correct on 3/4 => precision 0.75; gold present on 4 =>
recall 3/4 = 0.75.
"""
rows = sweep_thresholds(synthetic_entries, ("total",), THRESHOLDS)
row_050 = rows[0]
assert row_050.crit_pred == 4
assert row_050.crit_match == 3
assert row_050.crit_precision == pytest.approx(0.75)
assert row_050.crit_recall == pytest.approx(0.75)
def test_smallest_threshold_meeting_target() -> None:
"""The lowest qualifying threshold is found when a clean high-conf doc exists."""
entries = [
_entry("ok", predicted={"total": 10.0}, gold={"total": "10.00"}, confidence=0.95),
]
rows = sweep_thresholds(entries, ("total",), THRESHOLDS)
target = smallest_threshold_meeting(rows, 0.98)
assert target is not None
# confidence 0.95 accepts for thresholds <= 0.95; precision is 1.0 (perfect).
assert target.threshold == 0.50
assert target.crit_precision == pytest.approx(1.0)
def test_smallest_threshold_meeting_none_when_unreachable() -> None:
"""Returns None when no threshold reaches the target precision."""
entries = [
_entry("bad", predicted={"total": 999.0}, gold={"total": "10.00"}, confidence=0.95),
]
rows = sweep_thresholds(entries, ("total",), THRESHOLDS)
assert smallest_threshold_meeting(rows, 0.98) is None
def test_confidence_histogram_counts() -> None:
"""The histogram buckets rounded confidences."""
entries = [
_entry("a", predicted={}, gold={}, confidence=0.50),
_entry("b", predicted={}, gold={}, confidence=0.50),
_entry("c", predicted={}, gold={}, confidence=0.40),
]
hist = confidence_histogram(entries)
assert hist == {0.40: 1, 0.50: 2}
# ---------------------------------------------------------------------------
# End-to-end score phase over a written cache (still offline)
# ---------------------------------------------------------------------------
def test_build_report_from_written_cache(
tmp_path: Path, synthetic_entries: list[dict[str, Any]]
) -> None:
"""Writing entries then building a report round-trips and computes metrics."""
for entry in synthetic_entries:
write_entry(tmp_path, "synthetic", entry)
assert len(read_entries(tmp_path, "synthetic")) == 4
report = build_report("synthetic", cache_base=tmp_path)
assert report.n == 4
assert "total" in report.labeled_fields
# SROIE-like labeling: total is the only critical field labeled here.
assert report.critical_labeled == ("total",)
total = next(m for m in report.field_metrics if m.field == "total")
assert total.n_match == 3
def test_build_report_raises_without_cache(tmp_path: Path) -> None:
"""Scoring a dataset with no cache raises a clear error."""
with pytest.raises(FileNotFoundError):
build_report("missing", cache_base=tmp_path)
# ---------------------------------------------------------------------------
# Targeted retry of failed predictions (--retry-errors)
# ---------------------------------------------------------------------------
def _cached(example_id: str, *, error: str | None) -> dict[str, Any]:
entry = _entry(
example_id,
predicted={"total": 1.0},
gold={"total": "1.00"},
confidence=0.5,
)
entry["error"] = error
return entry
def test_errored_ids_selects_only_failed_entries(tmp_path: Path) -> None:
"""The retry set is exactly the entries that produced no extraction.
A document the model read and the rules then rejected is a result, not a
failure, and must never be re-run -- re-running it would replace a frozen
prediction and break any before/after rule comparison built on it.
"""
for name, error in (("ok1", None), ("ok2", None), ("dead", "429 RESOURCE_EXHAUSTED")):
write_entry(tmp_path, "d", _cached(name, error=error))
assert errored_ids(tmp_path, "d") == {"dead"}
def test_errored_ids_is_empty_for_a_clean_cache(tmp_path: Path) -> None:
write_entry(tmp_path, "d", _cached("ok", error=None))
assert errored_ids(tmp_path, "d") == set()
def test_overwrite_and_retry_errors_are_mutually_exclusive(tmp_path: Path) -> None:
"""Silently letting one win would re-run 361 documents when 44 were meant."""
with pytest.raises(ValueError, match="mutually exclusive"):
run_predict("sroie", 1, cache_base=tmp_path, overwrite=True, retry_errors=True)
def test_retry_errors_refuses_an_empty_cache(tmp_path: Path) -> None:
"""Falling through to a full predict here would spend quota unasked."""
with pytest.raises(ValueError, match="needs an existing cache"):
run_predict("sroie", 1, cache_base=tmp_path, retry_errors=True)
|