Spaces:
Sleeping
Sleeping
File size: 9,772 Bytes
f1a9398 b6beb2d f1a9398 | 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 | """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
|