Viewing files at commit ebd567552629Commit detailsView latest

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