| """Tests for the evaluation harness. |
| |
| We test each layer independently plus one end-to-end run with a mocked |
| extractor. No OpenAI calls are made — the runner accepts any callable that |
| returns (ExtractionResult, ExtractionMetrics), which is what makes this |
| testable offline. |
| |
| Layers covered: comparators, flatten, doc-scoring, aggregation, runner, reports. |
| Version tag v2 (forces bytecode invalidation on mounted filesystems). |
| """ |
| from __future__ import annotations |
|
|
| from datetime import date |
|
|
| from src.data_prep.writer import read_jsonl |
| from src.eval.comparators import ( |
| compare, |
| match_date, |
| match_exact, |
| match_money, |
| match_number, |
| match_text, |
| ) |
| from src.eval.flatten import flatten_model |
| from src.eval.metrics import aggregate, micro_macro, score_doc |
| from src.eval.runner import run_eval |
| from src.schemas import ExtractionResult, Receipt |
| from src.utils.cost_tracker import ExtractionMetrics |
|
|
| |
|
|
| class TestComparators: |
| def test_text_fuzzy_match(self): |
| assert match_text("TAN WOON YANN", "Tan Woon Yann")[0] |
| assert match_text("TAN WOON YANN SDN BHD", "TAN WOON YANN")[0] |
| assert match_text("Starbucks", "McDonalds")[0] is False |
|
|
| def test_text_null_handling(self): |
| assert match_text(None, None) == (True, 1.0) |
| assert match_text("x", None)[0] is False |
| assert match_text(None, "x")[0] is False |
|
|
| def test_money_within_tolerance(self): |
| |
| assert match_money(72.00, 72.01)[0] is True |
| |
| assert match_money(1000.00, 1004.00)[0] is True |
|
|
| def test_money_outside_tolerance(self): |
| |
| assert match_money(1.00, 1.05)[0] is False |
| |
| assert match_money(100.00, 102.00)[0] is False |
|
|
| def test_money_null(self): |
| assert match_money(None, None)[0] is True |
| assert match_money(0.0, None)[0] is False |
|
|
| def test_number_exact(self): |
| assert match_number(3, 3)[0] is True |
| assert match_number(3, 3.0000001)[0] is True |
| assert match_number(3, 4)[0] is False |
|
|
| def test_date_iso(self): |
| assert match_date(date(2018, 6, 25), "2018-06-25")[0] is True |
| assert match_date(date(2018, 6, 25), date(2018, 6, 25))[0] is True |
| assert match_date(date(2018, 6, 25), "2018-06-26")[0] is False |
|
|
| def test_exact_normalizes(self): |
| assert match_exact("USD", " usd ")[0] is True |
| assert match_exact("USD", "EUR")[0] is False |
|
|
| def test_dispatch(self): |
| assert compare("Hello world", "hello world", "text")[0] |
| assert compare(1.00, 1.005, "money")[0] |
| assert compare(date(2020, 1, 1), "2020-01-01", "date")[0] |
| assert compare("USD", "usd", "exact")[0] |
| assert compare(3, 3, "number")[0] |
|
|
|
|
| |
|
|
| class TestFlatten: |
| def test_receipt_flatten_basic(self): |
| r = Receipt(merchant="ACME", total=10.00, currency="USD") |
| flat = flatten_model(r, Receipt) |
| assert flat["merchant"] == ("ACME", "text") |
| assert flat["total"] == (10.00, "money") |
| assert flat["currency"] == ("USD", "exact") |
|
|
| def test_receipt_flatten_nested_address(self): |
| r = Receipt( |
| merchant="X", |
| total=1.0, |
| currency="USD", |
| merchant_address={"line1": "123 Main St", "city": "NYC"}, |
| ) |
| flat = flatten_model(r, Receipt) |
| assert flat["merchant_address.line1"][0] == "123 Main St" |
| assert flat["merchant_address.city"] == ("NYC", "text") |
| assert flat["merchant_address.postal_code"] == (None, "exact") |
|
|
| def test_flatten_line_items(self): |
| r = Receipt( |
| merchant="X", |
| total=5.0, |
| currency="USD", |
| line_items=[{"description": "coffee", "quantity": 1, "total": 5.0}], |
| ) |
| flat = flatten_model(r, Receipt) |
| assert flat["line_items[]"] == (1, "number") |
| assert flat["line_items[0].description"] == ("coffee", "text") |
| assert flat["line_items[0].unit_price"] == (None, "money") |
| assert flat["line_items[0].total"] == (5.0, "money") |
|
|
| def test_flatten_dict_and_model_symmetric(self): |
| gt = { |
| "merchant": "ACME", "total": 10.00, "currency": "USD", |
| "merchant_address": None, "merchant_phone": None, |
| "transaction_date": None, "transaction_time": None, |
| "receipt_number": None, "line_items": [], "subtotal": None, |
| "tax": None, "tip": None, "payment_method": None, |
| } |
| pred = Receipt(merchant="ACME", total=10.00, currency="USD") |
| assert set(flatten_model(gt, Receipt)) == set(flatten_model(pred, Receipt)) |
|
|
|
|
| |
|
|
| class TestScoring: |
| def _pair(self, gt, pred): |
| return score_doc("doc_1", flatten_model(pred, Receipt), flatten_model(gt, Receipt)) |
|
|
| def test_perfect_match(self): |
| r = Receipt(merchant="ACME", total=10.00, currency="USD") |
| stat, counts = self._pair(r, r) |
| assert stat.exact_match |
| for tp, fp, fn, _tn in counts.values(): |
| assert fp == 0 and fn == 0 |
|
|
| def test_wrong_merchant_is_mismatch(self): |
| gt = Receipt(merchant="ACME", total=10.0, currency="USD") |
| pred = Receipt(merchant="BETA STORE", total=10.0, currency="USD") |
| stat, counts = self._pair(gt, pred) |
| assert stat.exact_match is False |
| assert counts["merchant"] == (0, 1, 1, 0) |
| assert counts["total"] == (1, 0, 0, 0) |
|
|
| def test_missing_field_is_fn(self): |
| gt = Receipt(merchant="ACME", total=10.0, currency="USD", tax=1.0) |
| pred = Receipt(merchant="ACME", total=10.0, currency="USD") |
| _stat, counts = self._pair(gt, pred) |
| assert counts["tax"] == (0, 0, 1, 0) |
|
|
| def test_hallucinated_field_is_fp(self): |
| gt = Receipt(merchant="ACME", total=10.0, currency="USD") |
| pred = Receipt(merchant="ACME", total=10.0, currency="USD", tax=1.0) |
| _stat, counts = self._pair(gt, pred) |
| assert counts["tax"] == (0, 1, 0, 0) |
|
|
|
|
| |
|
|
| class TestAggregation: |
| def test_micro_macro_perfect(self): |
| counts = [ |
| {"merchant": (1, 0, 0, 0), "total": (1, 0, 0, 0)}, |
| {"merchant": (1, 0, 0, 0), "total": (1, 0, 0, 0)}, |
| ] |
| stats = aggregate(counts, {"merchant": "text", "total": "money"}) |
| summary = micro_macro(stats) |
| assert summary["micro_f1"] == 1.0 |
| assert summary["macro_f1"] == 1.0 |
|
|
| def test_micro_macro_partial(self): |
| counts = [ |
| {"merchant": (1, 0, 0, 0), "total": (1, 0, 0, 0)}, |
| {"merchant": (0, 1, 1, 0), "total": (1, 0, 0, 0)}, |
| ] |
| stats = aggregate(counts, {"merchant": "text", "total": "money"}) |
| assert stats["merchant"].precision == 0.5 |
| assert stats["merchant"].recall == 0.5 |
| assert stats["merchant"].f1 == 0.5 |
| assert stats["total"].f1 == 1.0 |
| assert micro_macro(stats)["macro_f1"] == 0.75 |
|
|
| def test_micro_macro_all_wrong(self): |
| counts = [{"merchant": (0, 1, 1, 0)}] |
| stats = aggregate(counts, {"merchant": "text"}) |
| assert micro_macro(stats)["micro_f1"] == 0.0 |
|
|
|
|
| |
|
|
| class TestRunner: |
| def _fake_extractor(self, mutator=None): |
| def _extract(record): |
| gt = record["ground_truth"] |
| data = Receipt.model_validate(gt) |
| if mutator is not None: |
| data = mutator(data) |
| return ( |
| ExtractionResult( |
| document_type="receipt", |
| data=data, |
| field_confidences=[], |
| overall_confidence=1.0, |
| warnings=[], |
| ), |
| ExtractionMetrics( |
| input_tokens=100, output_tokens=50, latency_ms=250.0, model="fake" |
| ), |
| ) |
| return _extract |
|
|
| def test_perfect_run_on_samples(self): |
| records = read_jsonl("data/samples/sroie_sample.jsonl") |
| report = run_eval(records, self._fake_extractor(), doc_type="receipt") |
| s = report.summary() |
| assert s["n_docs"] == len(records) |
| assert s["errors"] == 0 |
| assert s["micro_f1"] == 1.0 |
| assert s["macro_f1"] == 1.0 |
| assert s["doc_exact_match"] == 1.0 |
|
|
| def test_run_with_extractor_error(self): |
| def broken(_rec): |
| raise RuntimeError("api down") |
|
|
| records = read_jsonl("data/samples/sroie_sample.jsonl")[:2] |
| report = run_eval(records, broken, doc_type="receipt") |
| assert report.n_errors == len(records) |
| assert report.aggregate["micro_f1"] == 0.0 |
|
|
| def test_run_with_wrong_merchant(self): |
| def wrong_merchant(r: Receipt) -> Receipt: |
| return r.model_copy(update={"merchant": "TOTALLY WRONG NAME"}) |
|
|
| records = read_jsonl("data/samples/sroie_sample.jsonl") |
| report = run_eval( |
| records, self._fake_extractor(mutator=wrong_merchant), doc_type="receipt" |
| ) |
| assert report.field_stats["merchant"].f1 == 0.0 |
| assert 0.0 < report.aggregate["micro_f1"] < 1.0 |
| assert report.doc_exact_match_rate == 0.0 |
|
|
|
|
| |
|
|
| class TestReports: |
| def test_write_reports_creates_all_three(self, tmp_path): |
| from src.eval.report import write_reports |
|
|
| records = [ |
| { |
| "id": "smoke", |
| "ground_truth": { |
| "merchant": "ACME", "total": 1.0, "currency": "USD", |
| "merchant_address": None, "merchant_phone": None, |
| "transaction_date": None, "transaction_time": None, |
| "receipt_number": None, "line_items": [], "subtotal": None, |
| "tax": None, "tip": None, "payment_method": None, |
| }, |
| } |
| ] |
|
|
| def extractor(record): |
| data = Receipt.model_validate(record["ground_truth"]) |
| return ( |
| ExtractionResult( |
| document_type="receipt", |
| data=data, |
| field_confidences=[], |
| overall_confidence=1.0, |
| warnings=[], |
| ), |
| ExtractionMetrics( |
| input_tokens=1, output_tokens=1, latency_ms=1.0, model="fake" |
| ), |
| ) |
|
|
| report = run_eval(records, extractor, doc_type="receipt", model_label="fake") |
| paths = write_reports(report, tmp_path) |
|
|
| assert paths["csv"].exists() |
| assert paths["json"].exists() |
| assert paths["markdown"].exists() |
| md = paths["markdown"].read_text() |
| assert "Micro F1" in md |
| assert "1.0000" in md |
|
|