Viewing files at commit f86d1109f5dfCommit detailsView latest

scripts/oakbench/scorecard.py 288 lines · 11.6 KB · python
1
"""Target scorecard evaluation."""
2
3
from __future__ import annotations
4
5
from typing import Any
6
7
from oakbench.baseline_book import BaselineCell, Target, iter_jsonl, metric_value
8
from oakbench.integrity import content_integrity_public_trust_passed
9
from oakbench.rows import number_or_none, row_returncode
10
from oakbench.populations import population_conflicts
11
from oakbench.stats import bootstrap_ci, named_statistic, required_n_for_relative_delta
12
13
VERDICTS = ("GREEN", "RED", "AMBER", "UNDECIDABLE", "UNMEASURED", "INVALID")
14
15
# One JSONL loader for scorecard inputs; baseline_book owns the implementation.
16
load_jsonl = iter_jsonl
17
18
19
def evaluate_targets(
20
    rows: list[dict[str, Any]],
21
    targets: list[Target],
22
    baseline: dict[tuple[str, str | None, str, str, str, str, str], BaselineCell],
23
    *,
24
    bootstrap_samples: int = 2000,
25
    confidence: float = 0.95,
26
    seed: int = 20260609,
27
) -> list[dict[str, Any]]:
28
    return [
29
        evaluate_target(
30
            rows,
31
            target,
32
            baseline.get(target.baseline_key),
33
            bootstrap_samples=bootstrap_samples,
34
            confidence=confidence,
35
            seed=seed,
36
        )
37
        for target in targets
38
    ]
39
40
41
def evaluate_target(
42
    rows: list[dict[str, Any]],
43
    target: Target,
44
    baseline: BaselineCell | None,
45
    *,
46
    bootstrap_samples: int,
47
    confidence: float,
48
    seed: int,
49
) -> dict[str, Any]:
50
    matched = [row for row in rows if _row_matches(row, target)]
51
    skipped = [row for row in matched if row_returncode(row) == 77]
52
    successful = [row for row in matched if row_returncode(row) == 0]
53
    failures = [row for row in matched if row_returncode(row) not in (0, 77)]
54
    values = [value for row in successful if (value := metric_value(row, target.metric)) is not None]
55
56
    base = {
57
        "target_id": target.target_id,
58
        "verdict": "UNMEASURED",
59
        "lane": target.lane,
60
        "profile": target.profile,
61
        "scenario": target.scenario,
62
        "operation": target.operation,
63
        "subject": target.subject,
64
        "comparator": target.comparator,
65
        "metric": target.metric,
66
        "goal_type": target.goal_type,
67
        "goal_value": target.goal_value,
68
        # "n" is the number of rows that actually contributed to the statistic
69
        # (metric measured, per ADR-0002 null-means-unmeasured); "n_successful"
70
        # is the pool it was drawn from. CI consumers should read "n".
71
        "n": len(values),
72
        "n_successful": len(successful),
73
        "skip_count": len(skipped),
74
        "failure_count": len(failures),
75
        "messages": [],
76
    }
77
78
    if not matched:
79
        base["messages"].append("no matching rows")
80
        return base
81
    if skipped and not successful and not failures:
82
        base["messages"].append("; ".join(sorted({str(row.get("skip_reason") or "skipped") for row in skipped})))
83
        return base
84
    if baseline is None:
85
        base["messages"].append("missing baseline cell")
86
        return base
87
88
    guard_errors = guard_violations(matched, successful, failures, target.guards)
89
    conflicts = population_conflicts(successful)
90
    if conflicts:
91
        guard_errors.append(f"incompatible measurement population: {', '.join(conflicts)}")
92
    if guard_errors:
93
        base.update({"verdict": "INVALID", "messages": guard_errors})
94
        return base
95
96
    if not values:
97
        base["messages"].append("metric unmeasured in matching successful rows")
98
        return base
99
    unmeasured = len(successful) - len(values)
100
    if unmeasured:
101
        base["messages"].append(f"metric unmeasured on {unmeasured} of {len(successful)} successful rows")
102
    if baseline.value == 0:
103
        base.update({"verdict": "UNDECIDABLE", "messages": ["baseline value is zero; ratio target is undefined"]})
104
        return base
105
106
    observed = statistic_value(values, target.statistic)
107
    ci_low, ci_high = bootstrap_ci(
108
        values,
109
        statistic=target.statistic,
110
        samples=bootstrap_samples,
111
        confidence=confidence,
112
        seed=seed,
113
    )
114
    baseline_denominators = _baseline_denominators(baseline)
115
    if baseline_denominators is None:
116
        base.update(
117
            {
118
                "verdict": "UNDECIDABLE",
119
                "messages": ["baseline confidence interval crosses zero; ratio target is undefined"],
120
            }
121
        )
122
        return base
123
    baseline_low, baseline_high = baseline_denominators
124
    ratio = observed / baseline.value
125
    ratio_low = ci_low / baseline_high
126
    ratio_high = ci_high / baseline_low
127
    base.update(
128
        {
129
            "value": observed,
130
            "ci_low": ci_low,
131
            "ci_high": ci_high,
132
            "baseline_value": baseline.value,
133
            "baseline_ci_low": baseline.ci_low,
134
            "baseline_ci_high": baseline.ci_high,
135
            "ratio": ratio,
136
            "ratio_ci_low": ratio_low,
137
            "ratio_ci_high": ratio_high,
138
        }
139
    )
140
141
    # GREEN/RED must clear the goal by more than the baseline noise floor:
142
    # the bootstrap CI only sees within-sample variance, so a narrow CI that
143
    # lands inside the noise band is instrument luck, not a verdict (ADR-0008:
144
    # a target can never out-resolve the instrument). Decisive results — CI
145
    # bounds that survive noise inflation — stay GREEN/RED.
146
    noise = float(baseline.noise_floor_pct or 0.0) / 100.0
147
    decisively_green = ratio_high * (1.0 + noise) <= target.goal_value
148
    decisively_red = ratio_low * (1.0 - noise) > target.goal_value
149
    # Partial metric coverage caps decisive verdicts at AMBER (ADR-0008): the
150
    # statistic only saw the rows where the metric was measured, so a decisive
151
    # bound may be an artifact of which rows happened to record it. Dropping
152
    # information can never show green — and, symmetrically, missing data
153
    # cannot fail a target either, so RED is capped to AMBER as well.
154
    if (decisively_green or decisively_red) and unmeasured:
155
        required = required_n_for_relative_delta(float(baseline.noise_floor_pct or 0.0), _target_delta_pct(target), len(values))
156
        base.update({"verdict": "AMBER", "required_n": required})
157
    elif decisively_green:
158
        base["verdict"] = "GREEN"
159
    elif decisively_red:
160
        base["verdict"] = "RED"
161
    elif _noise_makes_undecidable(target, baseline):
162
        required = required_n_for_relative_delta(float(baseline.noise_floor_pct or 0.0), _target_delta_pct(target), len(values))
163
        base.update(
164
            {
165
                "verdict": "UNDECIDABLE",
166
                "required_n": required,
167
                "messages": ["target is tighter than the baseline noise floor"],
168
            }
169
        )
170
    else:
171
        required = required_n_for_relative_delta(float(baseline.noise_floor_pct or 0.0), _target_delta_pct(target), len(values))
172
        base.update({"verdict": "AMBER", "required_n": required})
173
        base["messages"].append("confidence interval straddles target")
174
    return base
175
176
177
def statistic_value(values: list[float], statistic: str) -> float:
178
    return float(named_statistic(statistic)(values))
179
180
181
def guard_violations(
182
    matched: list[dict[str, Any]],
183
    successful: list[dict[str, Any]],
184
    failures: list[dict[str, Any]],
185
    guards: dict[str, Any],
186
) -> list[str]:
187
    errors: list[str] = []
188
    if failures:
189
        errors.append(f"{len(failures)} matching rows failed")
190
    if "success_rate_at_least" in guards:
191
        success_rate = len(successful) / len(matched) if matched else 0.0
192
        if success_rate < float(guards["success_rate_at_least"]):
193
            errors.append(f"success_rate {success_rate:.3f} below guard {float(guards['success_rate_at_least']):.3f}")
194
    if "information_recall_at_least" in guards:
195
        _minimum_guard(successful, "information_recall", float(guards["information_recall_at_least"]), errors)
196
    if "information_precision_at_least" in guards:
197
        _minimum_guard(successful, "information_precision", float(guards["information_precision_at_least"]), errors)
198
    if guards.get("pipe_compatible_unified") is True:
199
        missing = [row for row in successful if row.get("pipe_compatible_unified") is None]
200
        bad = [row for row in successful if row.get("pipe_compatible_unified") is False]
201
        if missing:
202
            errors.append(f"{len(missing)} rows have unmeasured pipe_compatible_unified guard")
203
        if bad:
204
            errors.append(f"{len(bad)} rows failed pipe_compatible_unified guard")
205
    if guards.get("content_integrity_public_trust") is True:
206
        missing = [row for row in successful if row.get("content_integrity_check_passed") is None]
207
        failed = [row for row in successful if row.get("content_integrity_check_passed") is False]
208
        weak = [
209
            row
210
            for row in successful
211
            if row.get("content_integrity_check_passed") is True
212
            and not content_integrity_public_trust_passed(row)
213
        ]
214
        if missing:
215
            errors.append(f"{len(missing)} rows have unmeasured content_integrity_public_trust guard")
216
        if failed:
217
            errors.append(f"{len(failed)} rows failed content_integrity_public_trust guard")
218
        if weak:
219
            errors.append(f"{len(weak)} rows have weak content_integrity_source for public trust")
220
    return errors
221
222
223
def _minimum_guard(rows: list[dict[str, Any]], key: str, minimum: float, errors: list[str]) -> None:
224
    values = [value for row in rows if (value := _number_or_none(row.get(key))) is not None]
225
    if len(values) != len(rows):
226
        errors.append(f"{key} guard is unmeasured on {len(rows) - len(values)} rows")
227
    elif min(values) < minimum:
228
        errors.append(f"{key} minimum {min(values):.3f} below guard {minimum:.3f}")
229
230
231
def _row_matches(row: dict[str, Any], target: Target) -> bool:
232
    lane = _row_lane(row)
233
    return (
234
        lane == target.lane
235
        and str(row.get("subject")) == target.subject
236
        and str(row.get("scenario")) == target.scenario
237
        and str(row.get("operation")) == target.operation
238
        and (
239
            (target.profile is None and row.get("profile") is None)
240
            or (target.profile is not None and str(row.get("profile")) == target.profile)
241
        )
242
    )
243
244
245
def _row_lane(row: dict[str, Any]) -> str | None:
246
    for key in ("lane", "benchmark_lane", "measurement_lane"):
247
        if row.get(key) is not None:
248
            return str(row[key])
249
    if "workflow" in row or "phase" in row:
250
        return "workflow"
251
    if all(key in row for key in ("token_metrics", "turn_metrics", "tool_call_metrics")):
252
        return "agent"
253
    if "contention_mode" in row or "workers" in row:
254
        return "contention"
255
    if str(row.get("scenario") or "") == "task_loop":
256
        return "task-loop"
257
    if row.get("subject_kind") is None and row.get("subject") is not None:
258
        return "mount"
259
    if all(key in row for key in ("profile", "scenario", "operation", "subject", "subject_kind", "command")):
260
        return "core"
261
    return None
262
263
264
def _noise_makes_undecidable(target: Target, baseline: BaselineCell) -> bool:
265
    if baseline.noise_floor_pct is None:
266
        return False
267
    return baseline.noise_floor_pct > _target_delta_pct(target)
268
269
270
def _baseline_denominators(baseline: BaselineCell) -> tuple[float, float] | None:
271
    low = baseline.ci_low if baseline.ci_low is not None else baseline.value
272
    high = baseline.ci_high if baseline.ci_high is not None else baseline.value
273
    if low <= 0 or high <= 0:
274
        return None
275
    return low, high
276
277
278
def _target_delta_pct(target: Target) -> float:
279
    # The margin the instrument must resolve is the distance from parity in
280
    # either direction: a goal of 1.5x demands the same 50% resolution a goal
281
    # of 0.5x does. A parity goal (1.0) has zero margin and is undecidable
282
    # whenever any noise floor exists.
283
    if target.goal_type == "ratio_at_most":
284
        return abs(1.0 - target.goal_value) * 100.0
285
    return 0.0
286
287
288
_number_or_none = number_or_none