Viewing files at commit 46ad54da0178Commit detailsView latest

Browse files
tests/test_scorecard.py 308 lines · 12.7 KB · python
1
from __future__ import annotations
2
3
import json
4
import subprocess
5
import sys
6
import tempfile
7
import unittest
8
from pathlib import Path
9
10
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
11
12
from oakbench.baseline_book import load_baseline_book, load_targets
13
from oakbench.scorecard import evaluate_targets
14
15
16
def row(subject: str, elapsed: float, returncode: int = 0, **extra) -> dict:
17
    base = {
18
        "bench_id": "b",
19
        "profile": "smoke",
20
        "scenario": "tiny_text",
21
        "operation": "status.clean",
22
        "run": extra.pop("run", 0),
23
        "subject": subject,
24
        "subject_kind": "oak" if subject.startswith("oak") else "git",
25
        "elapsed_ms": elapsed,
26
        "returncode": returncode,
27
        "command": [subject, "status"],
28
    }
29
    base.update(extra)
30
    return base
31
32
33
def baseline(value: float = 10.0, noise_floor_pct: float | None = None) -> dict:
34
    cell = {
35
        "lane": "core",
36
        "profile": "smoke",
37
        "scenario": "tiny_text",
38
        "operation": "status.clean",
39
        "subject": "git",
40
        "metric": "elapsed_ms",
41
        "statistic": "median",
42
        "value": value,
43
        "n": 50,
44
    }
45
    if noise_floor_pct is not None:
46
        cell["noise_floor_pct"] = noise_floor_pct
47
    return {"schema_version": 1, "cells": [cell]}
48
49
50
def baseline_with_ci(value: float, low: float, high: float) -> dict:
51
    book = baseline(value)
52
    book["cells"][0]["ci_low"] = low
53
    book["cells"][0]["ci_high"] = high
54
    return book
55
56
57
def targets(goal: float = 0.8, guards: dict | None = None) -> dict:
58
    return {
59
        "schema_version": 1,
60
        "targets": [
61
            {
62
                "id": "t",
63
                "lane": "core",
64
                "profile": "smoke",
65
                "scenario": "tiny_text",
66
                "operation": "status.clean",
67
                "subject": "oak_local",
68
                "comparator": "git",
69
                "metric": "elapsed_ms",
70
                "goal": {"type": "ratio_at_most", "value": goal},
71
                "guards": guards or {"success_rate_at_least": 1.0},
72
            }
73
        ],
74
    }
75
76
77
class ScorecardTest(unittest.TestCase):
78
    def test_failed_trial_identity_is_checked_even_when_failure_guard_allows_it(self):
79
        result = self.eval([row("oak_local", 5, runner_class="host-a"),
80
                            row("oak_local", 50, returncode=1, runner_class="host-b")],
81
                           targets(0.8, guards={"success_rate_at_least": 0.5}))[0]
82
        self.assertEqual(result["verdict"], "INVALID")
83
        self.assertTrue(any("runner_class" in message for message in result["messages"]))
84
85
    def test_green_when_ratio_ci_clears_goal(self) -> None:
86
        result = self.eval([row("oak_local", 5.0), row("oak_local", 6.0)], targets(0.8))[0]
87
        self.assertEqual(result["verdict"], "GREEN")
88
        self.assertLessEqual(result["ratio_ci_high"], 0.8)
89
90
    def test_red_when_ratio_ci_misses_goal(self) -> None:
91
        result = self.eval([row("oak_local", 9.0), row("oak_local", 10.0)], targets(0.8))[0]
92
        self.assertEqual(result["verdict"], "RED")
93
94
    def test_unmeasured_when_rows_absent_or_skipped(self) -> None:
95
        result = self.eval([], targets(0.8))[0]
96
        self.assertEqual(result["verdict"], "UNMEASURED")
97
        skipped = row("oak_local", 0.0, returncode=77, skipped=True, skip_reason="missing remote")
98
        result = self.eval([skipped], targets(0.8))[0]
99
        self.assertEqual(result["verdict"], "UNMEASURED")
100
        self.assertIn("missing remote", result["messages"][0])
101
102
    def test_invalid_when_guard_fails(self) -> None:
103
        failed = row("oak_local", 5.0, returncode=1)
104
        result = self.eval([failed], targets(0.8))[0]
105
        self.assertEqual(result["verdict"], "INVALID")
106
107
    def test_undecidable_when_goal_tighter_than_noise_floor(self) -> None:
108
        result = self.eval(
109
            [row("oak_local", 9.8), row("oak_local", 10.0)],
110
            targets(0.99),
111
            baseline_book=baseline(noise_floor_pct=5.0),
112
        )[0]
113
        self.assertEqual(result["verdict"], "UNDECIDABLE")
114
115
    def test_obvious_green_survives_positive_noise_floor(self) -> None:
116
        result = self.eval(
117
            [row("oak_local", 5.0), row("oak_local", 5.0)],
118
            targets(1.0),
119
            baseline_book=baseline(noise_floor_pct=5.0),
120
        )[0]
121
        self.assertEqual(result["verdict"], "GREEN")
122
123
    def test_lucky_narrow_ci_inside_noise_floor_is_not_green(self) -> None:
124
        # Ratio 0.97 with a zero-width bootstrap CI clears a 0.99 goal by less
125
        # than the 5% noise floor: instrument luck, not a verdict (ADR-0008).
126
        result = self.eval(
127
            [row("oak_local", 9.7), row("oak_local", 9.7)],
128
            targets(0.99),
129
            baseline_book=baseline(noise_floor_pct=5.0),
130
        )[0]
131
        self.assertEqual(result["verdict"], "UNDECIDABLE")
132
133
    def test_goal_above_parity_measures_margin_from_parity(self) -> None:
134
        # A 1.5x ceiling has a 50% margin; a 5% noise floor must not make a
135
        # straddling result UNDECIDABLE — it is AMBER with a required n.
136
        result = self.eval(
137
            [row("oak_local", 14.0), row("oak_local", 16.0)],
138
            targets(1.5),
139
            baseline_book=baseline(noise_floor_pct=5.0),
140
        )[0]
141
        self.assertEqual(result["verdict"], "AMBER")
142
        self.assertIsNotNone(result.get("required_n"))
143
144
    def test_null_returncode_rows_are_failures_not_crashes(self) -> None:
145
        broken = row("oak_local", 5.0)
146
        broken["returncode"] = None
147
        result = self.eval([broken], targets(0.8))[0]
148
        self.assertEqual(result["verdict"], "INVALID")
149
150
    def test_rows_must_match_lane_and_profile_exactly(self) -> None:
151
        workflow_row = row("oak_local", 5.0, phase="workflow")
152
        result = self.eval([workflow_row], targets(0.8))[0]
153
        self.assertEqual(result["verdict"], "UNMEASURED")
154
155
        null_profile_target = targets(0.8)
156
        null_profile_target["targets"][0]["profile"] = None
157
        result = self.eval([row("oak_local", 5.0)], null_profile_target)[0]
158
        self.assertEqual(result["verdict"], "UNMEASURED")
159
160
    def test_missing_guard_metric_is_invalid(self) -> None:
161
        result = self.eval(
162
            [row("oak_local", 5.0, information_recall=1.0), row("oak_local", 5.0)],
163
            targets(0.8, guards={"success_rate_at_least": 1.0, "information_recall_at_least": 1.0}),
164
        )[0]
165
        self.assertEqual(result["verdict"], "INVALID")
166
        self.assertIn("information_recall guard is unmeasured", "; ".join(result["messages"]))
167
168
        result = self.eval(
169
            [row("oak_local", 5.0)],
170
            targets(0.8, guards={"success_rate_at_least": 1.0, "pipe_compatible_unified": True}),
171
        )[0]
172
        self.assertEqual(result["verdict"], "INVALID")
173
174
    def test_weak_content_integrity_source_is_measured_without_public_trust_guard(self) -> None:
175
        measured_only = row(
176
            "oak_local",
177
            5.0,
178
            content_integrity_check_passed=True,
179
            content_integrity_source="worktree_payload_sha256",
180
            content_integrity_sources=["worktree_payload_sha256"],
181
            content_integrity_public_trust_passed=False,
182
        )
183
        result = self.eval([measured_only], targets(0.8))[0]
184
        self.assertEqual(result["verdict"], "GREEN")
185
186
    def test_public_content_trust_guard_rejects_weak_sources(self) -> None:
187
        weak_sources = [
188
            ("mixed_payload_sources", ["git_head_payload_sha256", "git_lfs_worktree_payload_sha256"]),
189
            ("worktree_payload_sha256", ["worktree_payload_sha256"]),
190
            ("git_lfs_worktree_payload_sha256", ["git_lfs_worktree_payload_sha256"]),
191
        ]
192
        for source, sources in weak_sources:
193
            with self.subTest(source=source):
194
                measured_only = row(
195
                    "oak_local",
196
                    5.0,
197
                    content_integrity_check_passed=True,
198
                    content_integrity_source=source,
199
                    content_integrity_sources=sources,
200
                    content_integrity_public_trust_passed=False,
201
                )
202
                result = self.eval(
203
                    [measured_only],
204
                    targets(0.8, guards={"success_rate_at_least": 1.0, "content_integrity_public_trust": True}),
205
                )[0]
206
                self.assertEqual(result["verdict"], "INVALID")
207
                self.assertIn("weak content_integrity_source", "; ".join(result["messages"]))
208
209
    def test_public_content_trust_guard_accepts_strong_source(self) -> None:
210
        strong = row(
211
            "oak_local",
212
            5.0,
213
            content_integrity_check_passed=True,
214
            content_integrity_source="git_head_payload_sha256",
215
            content_integrity_sources=["git_head_payload_sha256"],
216
            content_integrity_public_trust_passed=True,
217
        )
218
        result = self.eval(
219
            [strong],
220
            targets(0.8, guards={"success_rate_at_least": 1.0, "content_integrity_public_trust": True}),
221
        )[0]
222
        self.assertEqual(result["verdict"], "GREEN")
223
224
    def test_partial_metric_coverage_caps_green_to_amber(self) -> None:
225
        # 5 successful rows but the metric is measured on only 1; a decisive
226
        # under-goal value from that lone row must not be GREEN (ADR-0008
227
        # partial-coverage rule), and n must report contributing rows only.
228
        rows = [row("oak_local", 5.0, run=0)] + [row("oak_local", None, run=i) for i in range(1, 5)]
229
        result = self.eval(rows, targets(0.8))[0]
230
        self.assertEqual(result["verdict"], "AMBER")
231
        self.assertEqual(result["n"], 1)
232
        self.assertEqual(result["n_successful"], 5)
233
        self.assertIn("metric unmeasured on 4 of 5 successful rows", result["messages"])
234
235
    def test_full_metric_coverage_reports_matching_counts_and_green(self) -> None:
236
        result = self.eval([row("oak_local", 5.0), row("oak_local", 6.0)], targets(0.8))[0]
237
        self.assertEqual(result["verdict"], "GREEN")
238
        self.assertEqual(result["n"], 2)
239
        self.assertEqual(result["n_successful"], 2)
240
241
    def test_partial_metric_coverage_caps_red_to_amber(self) -> None:
242
        # Symmetric cap: missing data cannot fail a target either.
243
        rows = [row("oak_local", 15.0, run=0)] + [row("oak_local", None, run=i) for i in range(1, 5)]
244
        result = self.eval(rows, targets(0.8))[0]
245
        self.assertEqual(result["verdict"], "AMBER")
246
        self.assertEqual(result["n"], 1)
247
        self.assertEqual(result["n_successful"], 5)
248
        self.assertIn("metric unmeasured on 4 of 5 successful rows", result["messages"])
249
250
    def test_baseline_uncertainty_makes_ratio_ci_conservative(self) -> None:
251
        result = self.eval(
252
            [row("oak_local", 8.0), row("oak_local", 8.0)],
253
            targets(0.8),
254
            baseline_book=baseline_with_ci(10.0, 9.0, 11.0),
255
        )[0]
256
        self.assertEqual(result["verdict"], "AMBER")
257
        self.assertGreater(result["ratio_ci_high"], 0.8)
258
259
    def test_cli_json_output(self) -> None:
260
        with tempfile.TemporaryDirectory() as tmp:
261
            root = Path(tmp)
262
            rows = root / "rows.jsonl"
263
            rows.write_text(json.dumps(row("oak_local", 5.0)) + "\n")
264
            book = root / "book.json"
265
            book.write_text(json.dumps(baseline()))
266
            target_file = root / "targets.json"
267
            target_file.write_text(json.dumps(targets(0.8)))
268
            proc = subprocess.run(
269
                [
270
                    sys.executable,
271
                    "scripts/scorecard.py",
272
                    "--mode", "diagnostic",
273
                    "--baseline-book",
274
                    str(book),
275
                    "--targets",
276
                    str(target_file),
277
                    "--bootstrap-samples",
278
                    "20",
279
                    "--json",
280
                    str(rows),
281
                ],
282
                cwd=Path(__file__).resolve().parents[1],
283
                text=True,
284
                stdout=subprocess.PIPE,
285
                stderr=subprocess.PIPE,
286
                check=False,
287
            )
288
        self.assertEqual(proc.returncode, 0, proc.stderr)
289
        parsed = json.loads(proc.stdout)
290
        self.assertEqual(parsed[0]["verdict"], "GREEN")
291
292
    def eval(self, rows: list[dict], target_config: dict, baseline_book: dict | None = None) -> list[dict]:
293
        with tempfile.TemporaryDirectory() as tmp:
294
            root = Path(tmp)
295
            target_path = root / "targets.json"
296
            target_path.write_text(json.dumps(target_config))
297
            book_path = root / "book.json"
298
            book_path.write_text(json.dumps(baseline_book or baseline()))
299
            return evaluate_targets(
300
                rows,
301
                load_targets(target_path),
302
                load_baseline_book(book_path),
303
                bootstrap_samples=50,
304
            )
305
306
307
if __name__ == "__main__":
308
    unittest.main()