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