"""Tests for the revalidation path (``eval.revalidate``) and its reporting. The cache freezes a ``confidence`` scalar and a ``validation`` report at predict time. These tests pin the two properties that make recomputing them trustworthy: 1. **Fidelity.** Recomputation over an up-to-date cache reproduces the cached values exactly -- otherwise "revalidated" numbers would differ from production for reasons unrelated to any rule change. 2. **Visibility.** When the cache and the current rules disagree, the report says so in both modes, and always names which rule set produced its numbers. Everything here is offline and synthetic; no dataset and no model are involved. """ from __future__ import annotations from datetime import date from pathlib import Path from typing import Any import pytest from docfield.core import aggregate_model_signal from docfield.routing.score import score from docfield.schema.models import Document from docfield.validation.rules import validate from eval.cache import write_entry from eval.revalidate import detect_drift, revalidate_entries, revalidate_entry from eval.score import build_report, format_report def _real_entry( example_id: str, document: Document, *, gold: dict[str, Any] | None = None, today: date | None = None, ) -> dict[str, Any]: """Build a cache entry exactly as the predict phase would, from a Document. Mirrors ``eval.predict._build_entry``: the cached confidence and validation are the real ones produced by the pipeline's own pure functions, so a faithful revalidation must reproduce them exactly. """ report = validate(document, today=today) signal = aggregate_model_signal(document.field_confidence) confidence = score(document, report, signal) return { "id": example_id, "dataset": "synthetic", "gold": gold or {}, "labeled_fields": ["total"], "predicted": document.model_dump(mode="json"), "confidence": confidence, "decision": "review", "modality": "image", "backend": "stub", "validation": report.to_dict(), "error": None, } def _clean_document() -> Document: """A document that passes every hard rule and every soft rule.""" return Document( doc_type="receipt", vendor_name="Acme", currency="SGD", document_date="2019-01-15", line_items=[{"description": "widget", "quantity": 2, "unit_price": 5.0, "amount": 10.0}], subtotal=10.0, tax=1.0, total=11.0, ) # --------------------------------------------------------------------------- # Fidelity of the recomputation # --------------------------------------------------------------------------- def test_revalidate_reproduces_an_up_to_date_cache() -> None: """An entry already consistent with current rules recomputes identically.""" entry = _real_entry("clean", _clean_document()) current = revalidate_entry(entry) assert current["confidence"] == pytest.approx(entry["confidence"]) assert current["validation"] == entry["validation"] assert detect_drift(entry, current) is None def test_revalidate_recomputes_a_stale_confidence() -> None: """A tampered cached scalar is replaced by the recomputed one, and flagged.""" entry = _real_entry("stale", _clean_document()) true_confidence = entry["confidence"] entry["confidence"] = 0.99 # pretend an older rule set scored it differently current = revalidate_entry(entry) assert current["confidence"] == pytest.approx(true_confidence) drift = detect_drift(entry, current) assert drift is not None assert drift.confidence_changed assert drift.cached_confidence == pytest.approx(0.99) assert drift.current_confidence == pytest.approx(true_confidence) def test_revalidate_recovers_model_signal_from_field_confidence() -> None: """The model signal is rebuilt from the document, not flattened to neutral. A backend that exposed per-field confidence must be scored against that signal on revalidation, exactly as the core did at predict time. Averaging 0.9 and 0.7 gives 0.8, above the 0.5 neutral prior -- so a revalidation that ignored ``field_confidence`` would score lower and silently disagree with production. """ document = _clean_document().model_copy( update={"field_confidence": {"total": 0.9, "tax": 0.7}} ) entry = _real_entry("signal", document) current = revalidate_entry(entry) assert current["confidence"] == pytest.approx(0.8) assert detect_drift(entry, current) is None def test_revalidate_leaves_the_predicted_document_untouched() -> None: """Recomputation rewrites scores, never the model's recorded output.""" entry = _real_entry("keep", _clean_document()) current = revalidate_entry(entry) assert current["predicted"] == entry["predicted"] assert current["gold"] == entry["gold"] assert current["backend"] == entry["backend"] # --------------------------------------------------------------------------- # Drift detection # --------------------------------------------------------------------------- def test_drift_detects_a_changed_soft_rule_outcome() -> None: """A soft-failure set that no longer matches current rules is reported.""" entry = _real_entry("soft", _clean_document()) entry["validation"] = { **entry["validation"], "soft_failures": ["S2"], # as if S2 had failed under an older rule set } entry["confidence"] = entry["confidence"] - 0.1 drift = detect_drift(entry, revalidate_entry(entry)) assert drift is not None assert drift.cached_soft == ("S2",) assert drift.current_soft == () assert "S2" in drift.describe() def test_drift_detects_a_changed_hard_failure_verdict() -> None: """A routing-relevant drift (hard-failure flip) is marked as such.""" entry = _real_entry("hard", _clean_document()) entry["validation"] = {**entry["validation"], "hard_failed": True} drift = detect_drift(entry, revalidate_entry(entry)) assert drift is not None assert drift.routing_changed assert "hard_failed" in drift.describe() def test_revalidate_entries_preserves_order_and_length() -> None: """Every entry is returned, in order; only disagreements appear in drift.""" good = _real_entry("good", _clean_document()) bad = _real_entry("bad", _clean_document()) bad["confidence"] = 0.11 recomputed, drift = revalidate_entries([good, bad]) assert [e["id"] for e in recomputed] == ["good", "bad"] assert len(recomputed) == 2 assert [d.id for d in drift] == ["bad"] # --------------------------------------------------------------------------- # Reporting: the numbers must be attributable to a rule set # --------------------------------------------------------------------------- def test_build_report_revalidate_uses_current_scores(tmp_path: Path) -> None: """--revalidate scores the recomputed confidences; the default does not.""" entry = _real_entry("e1", _clean_document(), gold={"total": "11.00"}) true_confidence = entry["confidence"] entry["confidence"] = 0.05 # stale: below every threshold in the sweep write_entry(tmp_path, "synthetic", entry) cached = build_report("synthetic", cache_base=tmp_path) current = build_report("synthetic", cache_base=tmp_path, revalidate=True) assert cached.score_source == "cached" assert not cached.revalidated assert cached.confidence_hist == {0.05: 1} assert cached.sweep[0].n_accepted == 0 # stale score accepts nothing assert current.revalidated assert current.confidence_hist == {round(true_confidence, 2): 1} assert current.sweep[0].n_accepted == 1 # recomputed score clears 0.50 def test_build_report_reports_drift_in_both_modes(tmp_path: Path) -> None: """Drift is detected whether or not the recomputed scores are used.""" entry = _real_entry("e1", _clean_document(), gold={"total": "11.00"}) entry["confidence"] = 0.05 write_entry(tmp_path, "synthetic", entry) for revalidate in (False, True): report = build_report("synthetic", cache_base=tmp_path, revalidate=revalidate) assert len(report.drift) == 1, revalidate assert report.drift[0].id == "e1" def test_report_text_names_which_rules_produced_the_numbers(tmp_path: Path) -> None: """The rendered report always states the score source unambiguously.""" entry = _real_entry("e1", _clean_document(), gold={"total": "11.00"}) entry["confidence"] = 0.05 write_entry(tmp_path, "synthetic", entry) cached_text = format_report(build_report("synthetic", cache_base=tmp_path)) current_text = format_report( build_report("synthetic", cache_base=tmp_path, revalidate=True) ) assert "AS CACHED at predict time" in cached_text assert "CURRENT RULES" not in cached_text assert "--revalidate" in cached_text # tells the reader how to get current numbers assert "CURRENT RULES" in current_text assert "AS CACHED at predict time" not in current_text # Both must surface the drift warning rather than hiding it. for text in (cached_text, current_text): assert "WARNING" in text assert "disagree with current rules" in text def test_report_text_confirms_a_clean_cache(tmp_path: Path) -> None: """A cache consistent with current rules reports an explicit all-clear.""" write_entry( tmp_path, "synthetic", _real_entry("e1", _clean_document(), gold={"total": "11.00"}) ) text = format_report(build_report("synthetic", cache_base=tmp_path)) assert "OK -- all 1 cached scores agree with current rules" in text assert "WARNING" not in text