aditya0103's picture
chore: pre-CI cleanup + Task #8 Docker + CI
6267e20
Raw
History Blame Contribute Delete
11.2 kB
"""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
# --- Comparators -----------------------------------------------------------
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):
# Absolute tolerance: 0.01
assert match_money(72.00, 72.01)[0] is True
# Relative tolerance: 0.5% of 1000 = 5.00, so 4.00 delta passes
assert match_money(1000.00, 1004.00)[0] is True
def test_money_outside_tolerance(self):
# Small values where 0.5% rel is tiny and abs 0.01 is exceeded
assert match_money(1.00, 1.05)[0] is False
# 100 vs 102 -> abs 2 > 0.01, rel 2% > 0.5%
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]
# --- Flattener -------------------------------------------------------------
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))
# --- Doc-level scoring -----------------------------------------------------
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)
# --- Aggregation -----------------------------------------------------------
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
# --- End-to-end runner -----------------------------------------------------
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
# --- Reports ---------------------------------------------------------------
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