Viewing files at commit ebd567552629Commit detailsView latest

Browse files
tests/test_scorecard.py 301 lines · 12.2 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_green_when_ratio_ci_clears_goal(self) -> None:
79
        result = self.eval([row("oak_local", 5.0), row("oak_local", 6.0)], targets(0.8))[0]
80
        self.assertEqual(result["verdict"], "GREEN")
81
        self.assertLessEqual(result["ratio_ci_high"], 0.8)
82
83
    def test_red_when_ratio_ci_misses_goal(self) -> None:
84
        result = self.eval([row("oak_local", 9.0), row("oak_local", 10.0)], targets(0.8))[0]
85
        self.assertEqual(result["verdict"], "RED")
86
87
    def test_unmeasured_when_rows_absent_or_skipped(self) -> None:
88
        result = self.eval([], targets(0.8))[0]
89
        self.assertEqual(result["verdict"], "UNMEASURED")
90
        skipped = row("oak_local", 0.0, returncode=77, skipped=True, skip_reason="missing remote")
91
        result = self.eval([skipped], targets(0.8))[0]
92
        self.assertEqual(result["verdict"], "UNMEASURED")
93
        self.assertIn("missing remote", result["messages"][0])
94
95
    def test_invalid_when_guard_fails(self) -> None:
96
        failed = row("oak_local", 5.0, returncode=1)
97
        result = self.eval([failed], targets(0.8))[0]
98
        self.assertEqual(result["verdict"], "INVALID")
99
100
    def test_undecidable_when_goal_tighter_than_noise_floor(self) -> None:
101
        result = self.eval(
102
            [row("oak_local", 9.8), row("oak_local", 10.0)],
103
            targets(0.99),
104
            baseline_book=baseline(noise_floor_pct=5.0),
105
        )[0]
106
        self.assertEqual(result["verdict"], "UNDECIDABLE")
107
108
    def test_obvious_green_survives_positive_noise_floor(self) -> None:
109
        result = self.eval(
110
            [row("oak_local", 5.0), row("oak_local", 5.0)],
111
            targets(1.0),
112
            baseline_book=baseline(noise_floor_pct=5.0),
113
        )[0]
114
        self.assertEqual(result["verdict"], "GREEN")
115
116
    def test_lucky_narrow_ci_inside_noise_floor_is_not_green(self) -> None:
117
        # Ratio 0.97 with a zero-width bootstrap CI clears a 0.99 goal by less
118
        # than the 5% noise floor: instrument luck, not a verdict (ADR-0008).
119
        result = self.eval(
120
            [row("oak_local", 9.7), row("oak_local", 9.7)],
121
            targets(0.99),
122
            baseline_book=baseline(noise_floor_pct=5.0),
123
        )[0]
124
        self.assertEqual(result["verdict"], "UNDECIDABLE")
125
126
    def test_goal_above_parity_measures_margin_from_parity(self) -> None:
127
        # A 1.5x ceiling has a 50% margin; a 5% noise floor must not make a
128
        # straddling result UNDECIDABLE — it is AMBER with a required n.
129
        result = self.eval(
130
            [row("oak_local", 14.0), row("oak_local", 16.0)],
131
            targets(1.5),
132
            baseline_book=baseline(noise_floor_pct=5.0),
133
        )[0]
134
        self.assertEqual(result["verdict"], "AMBER")
135
        self.assertIsNotNone(result.get("required_n"))
136
137
    def test_null_returncode_rows_are_failures_not_crashes(self) -> None:
138
        broken = row("oak_local", 5.0)
139
        broken["returncode"] = None
140
        result = self.eval([broken], targets(0.8))[0]
141
        self.assertEqual(result["verdict"], "INVALID")
142
143
    def test_rows_must_match_lane_and_profile_exactly(self) -> None:
144
        workflow_row = row("oak_local", 5.0, phase="workflow")
145
        result = self.eval([workflow_row], targets(0.8))[0]
146
        self.assertEqual(result["verdict"], "UNMEASURED")
147
148
        null_profile_target = targets(0.8)
149
        null_profile_target["targets"][0]["profile"] = None
150
        result = self.eval([row("oak_local", 5.0)], null_profile_target)[0]
151
        self.assertEqual(result["verdict"], "UNMEASURED")
152
153
    def test_missing_guard_metric_is_invalid(self) -> None:
154
        result = self.eval(
155
            [row("oak_local", 5.0, information_recall=1.0), row("oak_local", 5.0)],
156
            targets(0.8, guards={"success_rate_at_least": 1.0, "information_recall_at_least": 1.0}),
157
        )[0]
158
        self.assertEqual(result["verdict"], "INVALID")
159
        self.assertIn("information_recall guard is unmeasured", "; ".join(result["messages"]))
160
161
        result = self.eval(
162
            [row("oak_local", 5.0)],
163
            targets(0.8, guards={"success_rate_at_least": 1.0, "pipe_compatible_unified": True}),
164
        )[0]
165
        self.assertEqual(result["verdict"], "INVALID")
166
167
    def test_weak_content_integrity_source_is_measured_without_public_trust_guard(self) -> None:
168
        measured_only = row(
169
            "oak_local",
170
            5.0,
171
            content_integrity_check_passed=True,
172
            content_integrity_source="worktree_payload_sha256",
173
            content_integrity_sources=["worktree_payload_sha256"],
174
            content_integrity_public_trust_passed=False,
175
        )
176
        result = self.eval([measured_only], targets(0.8))[0]
177
        self.assertEqual(result["verdict"], "GREEN")
178
179
    def test_public_content_trust_guard_rejects_weak_sources(self) -> None:
180
        weak_sources = [
181
            ("mixed_payload_sources", ["git_head_payload_sha256", "git_lfs_worktree_payload_sha256"]),
182
            ("worktree_payload_sha256", ["worktree_payload_sha256"]),
183
            ("git_lfs_worktree_payload_sha256", ["git_lfs_worktree_payload_sha256"]),
184
        ]
185
        for source, sources in weak_sources:
186
            with self.subTest(source=source):
187
                measured_only = row(
188
                    "oak_local",
189
                    5.0,
190
                    content_integrity_check_passed=True,
191
                    content_integrity_source=source,
192
                    content_integrity_sources=sources,
193
                    content_integrity_public_trust_passed=False,
194
                )
195
                result = self.eval(
196
                    [measured_only],
197
                    targets(0.8, guards={"success_rate_at_least": 1.0, "content_integrity_public_trust": True}),
198
                )[0]
199
                self.assertEqual(result["verdict"], "INVALID")
200
                self.assertIn("weak content_integrity_source", "; ".join(result["messages"]))
201
202
    def test_public_content_trust_guard_accepts_strong_source(self) -> None:
203
        strong = row(
204
            "oak_local",
205
            5.0,
206
            content_integrity_check_passed=True,
207
            content_integrity_source="git_head_payload_sha256",
208
            content_integrity_sources=["git_head_payload_sha256"],
209
            content_integrity_public_trust_passed=True,
210
        )
211
        result = self.eval(
212
            [strong],
213
            targets(0.8, guards={"success_rate_at_least": 1.0, "content_integrity_public_trust": True}),
214
        )[0]
215
        self.assertEqual(result["verdict"], "GREEN")
216
217
    def test_partial_metric_coverage_caps_green_to_amber(self) -> None:
218
        # 5 successful rows but the metric is measured on only 1; a decisive
219
        # under-goal value from that lone row must not be GREEN (ADR-0008
220
        # partial-coverage rule), and n must report contributing rows only.
221
        rows = [row("oak_local", 5.0, run=0)] + [row("oak_local", None, run=i) for i in range(1, 5)]
222
        result = self.eval(rows, targets(0.8))[0]
223
        self.assertEqual(result["verdict"], "AMBER")
224
        self.assertEqual(result["n"], 1)
225
        self.assertEqual(result["n_successful"], 5)
226
        self.assertIn("metric unmeasured on 4 of 5 successful rows", result["messages"])
227
228
    def test_full_metric_coverage_reports_matching_counts_and_green(self) -> None:
229
        result = self.eval([row("oak_local", 5.0), row("oak_local", 6.0)], targets(0.8))[0]
230
        self.assertEqual(result["verdict"], "GREEN")
231
        self.assertEqual(result["n"], 2)
232
        self.assertEqual(result["n_successful"], 2)
233
234
    def test_partial_metric_coverage_caps_red_to_amber(self) -> None:
235
        # Symmetric cap: missing data cannot fail a target either.
236
        rows = [row("oak_local", 15.0, run=0)] + [row("oak_local", None, run=i) for i in range(1, 5)]
237
        result = self.eval(rows, targets(0.8))[0]
238
        self.assertEqual(result["verdict"], "AMBER")
239
        self.assertEqual(result["n"], 1)
240
        self.assertEqual(result["n_successful"], 5)
241
        self.assertIn("metric unmeasured on 4 of 5 successful rows", result["messages"])
242
243
    def test_baseline_uncertainty_makes_ratio_ci_conservative(self) -> None:
244
        result = self.eval(
245
            [row("oak_local", 8.0), row("oak_local", 8.0)],
246
            targets(0.8),
247
            baseline_book=baseline_with_ci(10.0, 9.0, 11.0),
248
        )[0]
249
        self.assertEqual(result["verdict"], "AMBER")
250
        self.assertGreater(result["ratio_ci_high"], 0.8)
251
252
    def test_cli_json_output(self) -> None:
253
        with tempfile.TemporaryDirectory() as tmp:
254
            root = Path(tmp)
255
            rows = root / "rows.jsonl"
256
            rows.write_text(json.dumps(row("oak_local", 5.0)) + "\n")
257
            book = root / "book.json"
258
            book.write_text(json.dumps(baseline()))
259
            target_file = root / "targets.json"
260
            target_file.write_text(json.dumps(targets(0.8)))
261
            proc = subprocess.run(
262
                [
263
                    sys.executable,
264
                    "scripts/scorecard.py",
265
                    "--mode", "diagnostic",
266
                    "--baseline-book",
267
                    str(book),
268
                    "--targets",
269
                    str(target_file),
270
                    "--bootstrap-samples",
271
                    "20",
272
                    "--json",
273
                    str(rows),
274
                ],
275
                cwd=Path(__file__).resolve().parents[1],
276
                text=True,
277
                stdout=subprocess.PIPE,
278
                stderr=subprocess.PIPE,
279
                check=False,
280
            )
281
        self.assertEqual(proc.returncode, 0, proc.stderr)
282
        parsed = json.loads(proc.stdout)
283
        self.assertEqual(parsed[0]["verdict"], "GREEN")
284
285
    def eval(self, rows: list[dict], target_config: dict, baseline_book: dict | None = None) -> list[dict]:
286
        with tempfile.TemporaryDirectory() as tmp:
287
            root = Path(tmp)
288
            target_path = root / "targets.json"
289
            target_path.write_text(json.dumps(target_config))
290
            book_path = root / "book.json"
291
            book_path.write_text(json.dumps(baseline_book or baseline()))
292
            return evaluate_targets(
293
                rows,
294
                load_targets(target_path),
295
                load_baseline_book(book_path),
296
                bootstrap_samples=50,
297
            )
298
299
300
if __name__ == "__main__":
301
    unittest.main()