| """ |
| Unit tests for eval/run_eval.py β matching primitives only. |
| |
| These tests import run_eval DIRECTLY (not via app) and never load model weights. |
| No BUREAUCAT_NO_MODEL needed here because run_eval.py does NOT import app at |
| module scope β only inside run_eval() (lazy import contract). |
| |
| Test coverage: |
| Task 1 primitives (normalize, value_found, extract_values_from_section): |
| - normalize: collapses NBSP, thin space, narrow no-break space, lowercases |
| - value_found: long ISO date found in transcription with surrounding text |
| - value_found short-value boundary: "123" NOT matched inside "1234567" |
| - value_found short-value boundary: "123" IS matched when standing alone |
| - value_found whitespace tolerance: gold with thin-space found in transcription |
| - extract_values_from_section: em-dash separator, strips bullets |
| - extract_values_from_section: hyphen-with-spaces separator |
| - extract_values_from_section: "None found." yields no values |
| - importability: 'app' not in sys.modules after importing run_eval |
| (checked in a standalone -c probe, not here β see verify block in plan) |
| |
| Task 2 functions (evaluate, beginner_invariant): |
| - evaluate: clean result passes (invented=0, missing=0, all_sections_present, severity) |
| - evaluate: invented value (not in transcription) β pass=False |
| - evaluate: missing gold value (not in deadlines) β pass=False |
| - evaluate: empty section (tldr="") β pass=False even with zero invented/missing |
| - evaluate: severity MAE is computed but does not affect pass |
| - beginner_invariant: True when all fields present |
| - beginner_invariant: False when a section is empty |
| - beginner_invariant: False when severity is None |
| - beginner_invariant: False when transcription is empty |
| """ |
|
|
| import os |
| import sys |
|
|
| |
| _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| _EVAL_DIR = os.path.join(_REPO_ROOT, "eval") |
| if _REPO_ROOT not in sys.path: |
| sys.path.insert(0, _REPO_ROOT) |
| if _EVAL_DIR not in sys.path: |
| sys.path.insert(0, _EVAL_DIR) |
|
|
| import run_eval as e |
| from types import SimpleNamespace as S |
|
|
|
|
| |
| |
| |
|
|
| def test_normalize_case_insensitive(): |
| """normalize lowercases input.""" |
| assert e.normalize("1 234,56 KR") == e.normalize("1 234,56 kr") |
|
|
|
|
| def test_normalize_collapses_nbsp(): |
| """U+00A0 non-breaking space collapsed to ASCII space.""" |
| |
| text_with_nbsp = "1Β 234 kr" |
| text_plain = "1 234 kr" |
| assert e.normalize(text_with_nbsp) == e.normalize(text_plain) |
|
|
|
|
| def test_normalize_collapses_thin_space(): |
| """U+2009 thin space collapsed to ASCII space.""" |
| text_thin = "1β234 kr" |
| text_plain = "1 234 kr" |
| assert e.normalize(text_thin) == e.normalize(text_plain) |
|
|
|
|
| def test_normalize_collapses_narrow_nobreak(): |
| """U+202F narrow no-break space collapsed to ASCII space.""" |
| text_narrow = "1β―234 kr" |
| text_plain = "1 234 kr" |
| assert e.normalize(text_narrow) == e.normalize(text_plain) |
|
|
|
|
| def test_normalize_nfc(): |
| """NFC normalization handles combining characters from OCR.""" |
| |
| composed = "Γ©" |
| decomposed = "eΜ" |
| assert e.normalize(decomposed) == e.normalize(composed) |
|
|
|
|
| def test_normalize_collapses_multiple_spaces(): |
| """Multiple spaces (including mixed Unicode) collapse to single space.""" |
| assert e.normalize("a b") == "a b" |
| assert e.normalize("aΒ βb") == "a b" |
|
|
|
|
| |
| |
| |
|
|
| def test_value_found_long_date_in_transcription(): |
| """An ISO date (>= 6 digits) is found when present with surrounding text.""" |
| haystack = "Du ska betala senast 2026-06-15. GlΓΆm inte." |
| assert e.value_found("2026-06-15", haystack) is True |
|
|
|
|
| def test_value_found_long_date_absent(): |
| """A date not in the transcription is not found.""" |
| haystack = "Du ska betala senast 2026-06-15." |
| assert e.value_found("2026-07-99", haystack) is False |
|
|
|
|
| def test_value_found_short_boundary_inside_longer(): |
| """A 3-digit ref "123" is NOT found inside "1234567" (boundary rule, Pitfall 5).""" |
| haystack = "ref 1234567 i systemet" |
| assert e.value_found("123", haystack) is False |
|
|
|
|
| def test_value_found_short_boundary_standalone(): |
| """A 3-digit ref "123" IS found when it stands alone.""" |
| haystack = "OCR-nummer 123 gΓ€ller" |
| assert e.value_found("123", haystack) is True |
|
|
|
|
| def test_value_found_short_boundary_at_start(): |
| """A 3-digit ref at the start of the string is found.""" |
| haystack = "123 Γ€r ditt referensnummer" |
| assert e.value_found("123", haystack) is True |
|
|
|
|
| def test_value_found_whitespace_tolerance(): |
| """Gold "1 234 kr" (thin space) is found in transcription with plain space.""" |
| haystack = "belopp 1 234 kr forfaller 2026-06-15" |
| |
| assert e.value_found("1β234 kr", haystack) is True |
|
|
|
|
| def test_value_found_case_insensitive(): |
| """Match is case-insensitive (normalize lowercases).""" |
| assert e.value_found("JUNI", "betala i juni 2026") is True |
|
|
|
|
| def test_value_found_not_found_returns_false(): |
| """A value genuinely absent from haystack returns False.""" |
| assert e.value_found("2026-12-31", "betala senast 2026-06-15") is False |
|
|
|
|
| def test_value_found_six_digit_reference_uses_plain_substring(): |
| """A 6+ digit reference uses plain normalized substring (no boundary required).""" |
| |
| assert e.value_found("123456789", "OCR: 123456789") is True |
|
|
|
|
| def test_value_found_long_value_not_matched_inside_longer_digit_run(): |
| """WR-01: a value must NOT match inside a longer run of digits, regardless of |
| digit count β the non-digit boundary is applied universally.""" |
| |
| assert e.value_found("123456", "ref 1234567") is False |
| |
| assert e.value_found("12-345678", "ref 912-3456789") is False |
| |
| assert e.value_found("2026-06-15", "x12026-06-15") is False |
| |
| assert e.value_found("123456", "ref 123456 end") is True |
| assert e.value_found("12-345678", "dossier 12-345678.") is True |
| assert e.value_found("2026-06-15", "senast 2026-06-15") is True |
|
|
|
|
| |
| |
| |
|
|
| def test_extract_em_dash_separator(): |
| """Em-dash separator lines: verbatim value before ' β '.""" |
| section = "- 15 juni 2026 β last day to file\n- 1 234 kr β amount owed" |
| vals = e.extract_values_from_section(section) |
| assert vals == ["15 juni 2026", "1 234 kr"] |
|
|
|
|
| def test_extract_hyphen_with_spaces_separator(): |
| """Hyphen-with-spaces separator lines: verbatim value before ' - '.""" |
| section = "- 2026-06-15 - sista betalningsdag\n- 1 234 kr - fakturabelopp" |
| vals = e.extract_values_from_section(section) |
| assert vals == ["2026-06-15", "1 234 kr"] |
|
|
|
|
| def test_extract_none_found_yields_empty(): |
| """'None found.' line (and variants) yields no values.""" |
| assert e.extract_values_from_section("None found.") == [] |
| assert e.extract_values_from_section("none found.") == [] |
| assert e.extract_values_from_section("None found") == [] |
|
|
|
|
| def test_extract_blank_lines_skipped(): |
| """Blank lines between entries are skipped.""" |
| section = "\n- 2026-06-15 β sista dag\n\n- 1 234 kr β belopp\n" |
| vals = e.extract_values_from_section(section) |
| assert vals == ["2026-06-15", "1 234 kr"] |
|
|
|
|
| def test_extract_date_with_internal_hyphens_preserved(): |
| """ISO date 2026-06-15 is not split by internal hyphens β only ' - ' with spaces.""" |
| section = "- 2026-06-15 β sista dag" |
| vals = e.extract_values_from_section(section) |
| assert vals == ["2026-06-15"] |
|
|
|
|
| def test_extract_mixed_separators(): |
| """Mixed em-dash and hyphen-space separators in same section.""" |
| section = "- 15 juni 2026 β sista dag\n- 1 234 kr - belopp" |
| vals = e.extract_values_from_section(section) |
| assert vals == ["15 juni 2026", "1 234 kr"] |
|
|
|
|
| def test_extract_empty_section_yields_empty(): |
| """Empty section string yields empty list.""" |
| assert e.extract_values_from_section("") == [] |
|
|
|
|
| |
| |
| |
|
|
| def _gold( |
| deadlines=None, amounts=None, references=None, expected_severity=4 |
| ): |
| """Helper: build a gold dict with the pattern-6 sidecar schema.""" |
| return { |
| "expected_severity": expected_severity, |
| "deadlines": deadlines or [], |
| "amounts": amounts or [], |
| "references": references or [], |
| } |
|
|
|
|
| def _good_result(): |
| """A fully-populated StructuredResult stand-in that should PASS.""" |
| return S( |
| transcription="betala 1 234 kr senast 2026-06-15", |
| tldr="You must pay.", |
| why="You owe money.", |
| actions="Pay the invoice.", |
| deadlines="- 2026-06-15 β sista dag\n- 1 234 kr β belopp", |
| severity=4, |
| ) |
|
|
|
|
| def test_evaluate_clean_result_passes(): |
| """A clean, complete result passes all gate checks.""" |
| gold = _gold( |
| deadlines=[{"verbatim_swedish": "2026-06-15"}], |
| amounts=[{"verbatim_swedish": "1 234 kr"}], |
| ) |
| v = e.evaluate(_good_result(), gold) |
| assert v["pass"] is True |
| assert v["invented_count"] == 0 |
| assert v["missing_count"] == 0 |
| assert v["all_sections_present"] is True |
|
|
|
|
| def test_evaluate_invented_value_fails(): |
| """A value in deadlines section absent from transcription β pass=False.""" |
| result = S( |
| transcription="betala senast 2026-06-15", |
| tldr="You must pay.", |
| why="Because.", |
| actions="Pay it.", |
| deadlines="- 2026-07-99 β invented\n- 2026-06-15 β real", |
| severity=4, |
| ) |
| gold = _gold(deadlines=[{"verbatim_swedish": "2026-06-15"}]) |
| v = e.evaluate(result, gold) |
| assert v["pass"] is False |
| assert v["invented_count"] >= 1 |
|
|
|
|
| def test_evaluate_missing_gold_value_fails(): |
| """A gold verbatim_swedish value absent from deadlines section β pass=False.""" |
| result = S( |
| transcription="betala 1 234 kr senast 2026-06-15", |
| tldr="You must pay.", |
| why="Because.", |
| actions="Pay it.", |
| |
| deadlines="- 2026-06-15 β sista dag", |
| severity=4, |
| ) |
| gold = _gold( |
| deadlines=[{"verbatim_swedish": "2026-06-15"}], |
| amounts=[{"verbatim_swedish": "1 234 kr"}], |
| ) |
| v = e.evaluate(result, gold) |
| assert v["pass"] is False |
| assert v["missing_count"] >= 1 |
|
|
|
|
| def test_evaluate_empty_section_fails_sc1(): |
| """ |
| SC1 gate: an empty TL;DR section β pass=False even with zero invented/missing |
| and a valid severity. |
| """ |
| result = S( |
| transcription="betala 1 234 kr senast 2026-06-15", |
| tldr="", |
| why="Because.", |
| actions="Pay it.", |
| deadlines="- 2026-06-15 β sista dag\n- 1 234 kr β belopp", |
| severity=4, |
| ) |
| gold = _gold( |
| deadlines=[{"verbatim_swedish": "2026-06-15"}], |
| amounts=[{"verbatim_swedish": "1 234 kr"}], |
| ) |
| v = e.evaluate(result, gold) |
| assert v["pass"] is False |
| assert v["all_sections_present"] is False |
|
|
|
|
| def test_evaluate_empty_deadlines_section_fails_sc1(): |
| """An empty deadlines section β pass=False (SC1).""" |
| result = S( |
| transcription="betala 1 234 kr senast 2026-06-15", |
| tldr="Summary.", |
| why="Because.", |
| actions="Pay.", |
| deadlines="", |
| severity=4, |
| ) |
| gold = _gold() |
| v = e.evaluate(result, gold) |
| assert v["pass"] is False |
| assert v["all_sections_present"] is False |
|
|
|
|
| def test_evaluate_severity_none_fails(): |
| """severity=None β pass=False (schema_complete=False).""" |
| result = S( |
| transcription="betala 1 234 kr", |
| tldr="Summary.", |
| why="Because.", |
| actions="Pay.", |
| deadlines="- 1 234 kr β belopp", |
| severity=None, |
| ) |
| gold = _gold(amounts=[{"verbatim_swedish": "1 234 kr"}]) |
| v = e.evaluate(result, gold) |
| assert v["pass"] is False |
| assert v["schema_complete"] is False |
|
|
|
|
| def test_evaluate_severity_mae_advisory_does_not_fail(): |
| """Severity MAE is reported but does NOT cause pass=False (D-15).""" |
| result = S( |
| transcription="betala 1 234 kr senast 2026-06-15", |
| tldr="Summary.", |
| why="Because.", |
| actions="Pay.", |
| deadlines="- 2026-06-15 β sista dag\n- 1 234 kr β belopp", |
| severity=1, |
| ) |
| gold = _gold( |
| deadlines=[{"verbatim_swedish": "2026-06-15"}], |
| amounts=[{"verbatim_swedish": "1 234 kr"}], |
| expected_severity=4, |
| ) |
| v = e.evaluate(result, gold) |
| |
| assert v["pass"] is True |
| assert v["severity_mae"] == 3 |
|
|
|
|
| def test_evaluate_all_sections_present_in_verdict(): |
| """all_sections_present key is always present in the verdict dict.""" |
| v = e.evaluate(_good_result(), _gold()) |
| assert "all_sections_present" in v |
|
|
|
|
| def test_evaluate_references_checked_in_recall(): |
| """References in gold sidecar are checked in the recall (deadlines section).""" |
| result = S( |
| transcription="betala 1 234 kr OCR 123456789", |
| tldr="Summary.", |
| why="Because.", |
| actions="Pay.", |
| |
| deadlines="- 1 234 kr β belopp", |
| severity=3, |
| ) |
| gold = _gold( |
| amounts=[{"verbatim_swedish": "1 234 kr"}], |
| references=[{"verbatim_swedish": "123456789"}], |
| ) |
| v = e.evaluate(result, gold) |
| assert v["pass"] is False |
| assert v["missing_count"] >= 1 |
|
|
|
|
| |
| |
| |
|
|
| def test_beginner_invariant_fully_populated_passes(): |
| """A fully-populated beginner-mode result passes all D-08 invariants.""" |
| result = S( |
| transcription="betala 1 234 kr senast 2026-06-15", |
| tldr="Summary.", |
| why="Because.", |
| actions="Pay.", |
| deadlines="- 2026-06-15 β sista dag\n- 1 234 kr β belopp", |
| severity=4, |
| ) |
| ok, reasons = e.beginner_invariant(result) |
| assert ok is True |
| assert reasons == [] |
|
|
|
|
| def test_beginner_invariant_empty_section_fails(): |
| """beginner_invariant returns False when a section is empty.""" |
| result = S( |
| transcription="betala 1 234 kr senast 2026-06-15", |
| tldr="", |
| why="Because.", |
| actions="Pay.", |
| deadlines="- 2026-06-15 β sista dag", |
| severity=4, |
| ) |
| ok, reasons = e.beginner_invariant(result) |
| assert ok is False |
| assert len(reasons) >= 1 |
|
|
|
|
| def test_beginner_invariant_severity_none_fails(): |
| """beginner_invariant returns False when severity is None (D-08: SEVERITY line dropped).""" |
| result = S( |
| transcription="betala 1 234 kr", |
| tldr="Summary.", |
| why="Because.", |
| actions="Pay.", |
| deadlines="- 1 234 kr β belopp", |
| severity=None, |
| ) |
| ok, reasons = e.beginner_invariant(result) |
| assert ok is False |
| assert any("severity" in r.lower() for r in reasons) |
|
|
|
|
| def test_beginner_invariant_empty_transcription_fails(): |
| """beginner_invariant returns False when transcription is empty (D-08: block dropped).""" |
| result = S( |
| transcription="", |
| tldr="Summary.", |
| why="Because.", |
| actions="Pay.", |
| deadlines="- 2026-06-15 β sista dag", |
| severity=3, |
| ) |
| ok, reasons = e.beginner_invariant(result) |
| assert ok is False |
| assert any("transcription" in r.lower() for r in reasons) |
|
|
|
|
| def test_beginner_invariant_multiple_failures_all_reported(): |
| """beginner_invariant reports ALL failures, not just the first.""" |
| result = S( |
| transcription="", |
| tldr="", |
| why="Because.", |
| actions="Pay.", |
| deadlines="d", |
| severity=None, |
| ) |
| ok, reasons = e.beginner_invariant(result) |
| assert ok is False |
| assert len(reasons) >= 2 |
|
|
|
|
| if __name__ == "__main__": |
| import pytest |
| pytest.main([__file__, "-v"]) |
|
|