Viewing files at commit 46ad54da0178Commit detailsView latest

scripts/oakbench/scorecard.py 430 lines · 19.4 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 benjamini_hochberg_detail, bootstrap_ci, named_statistic, required_n_for_relative_delta, validate_interval_settings
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
    fdr: float = 0.05,
31
) -> list[dict[str, Any]]:
32
    validate_interval_settings(bootstrap_samples, confidence)
33
    if mode not in {"diagnostic", "claim"}:
34
        raise ValueError("mode must be diagnostic or claim")
35
    results = [
36
        evaluate_target(
37
            rows,
38
            target,
39
            baseline.get(target.baseline_key),
40
            bootstrap_samples=bootstrap_samples,
41
            confidence=confidence,
42
            seed=seed,
43
            mode=mode,
44
        )
45
        for target in targets
46
    ]
47
    if mode == "claim":
48
        apply_claim_fdr(results, fdr=fdr)
49
    return results
50
51
52
def apply_claim_fdr(results: list[dict[str, Any]], *, fdr: float = 0.05) -> None:
53
    """Benjamini-Hochberg across every decisive claim-mode target (ADR-0011).
54
55
    The family is all GREEN/RED verdicts in one claim evaluation; each carries
56
    a one-sided bootstrap p-value for its decision. A decisive verdict that BH
57
    does not confirm is capped at AMBER. There is no global floor here: each
58
    cell's noise floor is its own (claim eligibility requires per-cell A/A).
59
    """
60
    decisive = [item for item in results if item.get("verdict") in ("GREEN", "RED") and item.get("p_value") is not None]
61
    if not decisive:
62
        return
63
    detail = benjamini_hochberg_detail([item["p_value"] for item in decisive], fdr=fdr)
64
    for item, rejected in zip(decisive, detail["rejected"]):
65
        item["fdr_family_size"] = len(decisive)
66
        item["fdr_rejected"] = rejected
67
        if not rejected:
68
            item["messages"] = list(item.get("messages") or []) + [
69
                f"not significant after Benjamini-Hochberg (q={fdr}) across {len(decisive)} claim targets"]
70
            item["verdict"] = "AMBER"
71
            item["claim_eligible"] = False
72
73
74
def evaluate_target(
75
    rows: list[dict[str, Any]],
76
    target: Target,
77
    baseline: BaselineCell | None,
78
    *,
79
    bootstrap_samples: int,
80
    confidence: float,
81
    seed: int,
82
    mode: str = "diagnostic",
83
) -> dict[str, Any]:
84
    matched = [row for row in rows if _row_matches(row, target)]
85
    skipped = [row for row in matched if row_returncode(row) == 77]
86
    successful = [row for row in matched if row_returncode(row) == 0]
87
    failures = [row for row in matched if row_returncode(row) not in (0, 77)]
88
    values = [value for row in successful if (value := metric_value(row, target.metric)) is not None]
89
90
    base = {
91
        "target_id": target.target_id,
92
        "verdict": "UNMEASURED",
93
        "lane": target.lane,
94
        "profile": target.profile,
95
        "scenario": target.scenario,
96
        "operation": target.operation,
97
        "subject": target.subject,
98
        "comparator": target.comparator,
99
        "metric": target.metric,
100
        "goal_type": target.goal_type,
101
        "goal_value": target.goal_value,
102
        # "n" is the number of rows that actually contributed to the statistic
103
        # (metric measured, per ADR-0002 null-means-unmeasured); "n_successful"
104
        # is the pool it was drawn from. CI consumers should read "n".
105
        "n": len(values),
106
        "n_successful": len(successful),
107
        "skip_count": len(skipped),
108
        "failure_count": len(failures),
109
        "messages": [],
110
        "mode": mode,
111
        "claim_eligible": False,
112
        "independent_n": independent_run_count(successful),
113
        "confidence": confidence,
114
        "bootstrap_samples": bootstrap_samples,
115
    }
116
117
    if not matched:
118
        base["messages"].append("no matching rows")
119
        return base
120
    if skipped and not successful and not failures:
121
        base["messages"].append("; ".join(sorted({str(row.get("skip_reason") or "skipped") for row in skipped})))
122
        return base
123
    if baseline is None:
124
        base["messages"].append("missing baseline cell")
125
        return base
126
127
    guard_errors = guard_violations(matched, successful, failures, target.guards)
128
    # Outcome denominators are part of the experiment, not a separate identity
129
    # population. Failed/skipped trials cannot silently come from another host.
130
    conflicts = population_conflicts(matched)
131
    if conflicts:
132
        guard_errors.append(f"incompatible measurement population: {', '.join(conflicts)}")
133
    candidate_identity = comparison_identity(successful[0]) if successful else None
134
    if baseline.comparison_identity is not None and candidate_identity is not None:
135
        mismatch = comparison_conflicts(baseline.comparison_identity, candidate_identity)
136
        if mismatch:
137
            guard_errors.append(f"baseline comparison identity mismatch: {', '.join(mismatch)}")
138
    if guard_errors:
139
        base.update({"verdict": "INVALID", "messages": guard_errors})
140
        return base
141
142
    if not values:
143
        base["messages"].append("metric unmeasured in matching successful rows")
144
        return base
145
    eligibility_errors = claim_eligibility_errors(successful, matched, target, baseline, confidence)
146
    base["claim_ineligibility_reasons"] = eligibility_errors
147
    if mode == "claim" and eligibility_errors:
148
        base.update(verdict="AMBER", messages=eligibility_errors)
149
        return base
150
    base["claim_eligible"] = mode == "claim"
151
    if mode == "diagnostic":
152
        base["messages"].append("diagnostic only; not eligible for public claims")
153
    unmeasured = len(successful) - len(values)
154
    if unmeasured:
155
        base["messages"].append(f"metric unmeasured on {unmeasured} of {len(successful)} successful rows")
156
    if baseline.value == 0:
157
        base.update({"verdict": "UNDECIDABLE", "messages": ["baseline value is zero; ratio target is undefined"]})
158
        return base
159
160
    observed = statistic_value(values, target.statistic)
161
    ci_low, ci_high = bootstrap_ci(
162
        values,
163
        statistic=target.statistic,
164
        samples=bootstrap_samples,
165
        confidence=confidence,
166
        seed=seed,
167
    )
168
    baseline_denominators = _baseline_denominators(baseline)
169
    if baseline_denominators is None:
170
        base.update(
171
            {
172
                "verdict": "UNDECIDABLE",
173
                "messages": ["baseline confidence interval crosses zero; ratio target is undefined"],
174
            }
175
        )
176
        return base
177
    baseline_low, baseline_high = baseline_denominators
178
    ratio = observed / baseline.value
179
    ratio_low = ci_low / baseline_high
180
    ratio_high = ci_high / baseline_low
181
    base.update(
182
        {
183
            "value": observed,
184
            "ci_low": ci_low,
185
            "ci_high": ci_high,
186
            "baseline_value": baseline.value,
187
            "baseline_ci_low": baseline.ci_low,
188
            "baseline_ci_high": baseline.ci_high,
189
            "ratio": ratio,
190
            "ratio_ci_low": ratio_low,
191
            "ratio_ci_high": ratio_high,
192
        }
193
    )
194
195
    # GREEN/RED must clear the goal by more than the baseline noise floor:
196
    # the bootstrap CI only sees within-sample variance, so a narrow CI that
197
    # lands inside the noise band is instrument luck, not a verdict (ADR-0008:
198
    # a target can never out-resolve the instrument). Decisive results — CI
199
    # bounds that survive noise inflation — stay GREEN/RED.
200
    noise = float(baseline.noise_floor_pct or 0.0) / 100.0
201
    decisively_green = ratio_high * (1.0 + noise) <= target.goal_value
202
    decisively_red = ratio_low * (1.0 - noise) > target.goal_value
203
    # Partial metric coverage caps decisive verdicts at AMBER (ADR-0008): the
204
    # statistic only saw the rows where the metric was measured, so a decisive
205
    # bound may be an artifact of which rows happened to record it. Dropping
206
    # information can never show green — and, symmetrically, missing data
207
    # cannot fail a target either, so RED is capped to AMBER as well.
208
    if (decisively_green or decisively_red) and unmeasured:
209
        required = required_n_for_relative_delta(float(baseline.noise_floor_pct or 0.0), _target_delta_pct(target), len(values))
210
        base.update({"verdict": "AMBER", "required_n": required})
211
    elif decisively_green:
212
        base["verdict"] = "GREEN"
213
        base["p_value"] = _one_sided_p(values, target, baseline, noise, "green", bootstrap_samples, seed)
214
    elif decisively_red:
215
        base["verdict"] = "RED"
216
        base["p_value"] = _one_sided_p(values, target, baseline, noise, "red", bootstrap_samples, seed)
217
    elif _noise_makes_undecidable(target, baseline):
218
        required = required_n_for_relative_delta(float(baseline.noise_floor_pct or 0.0), _target_delta_pct(target), len(values))
219
        base.update(
220
            {
221
                "verdict": "UNDECIDABLE",
222
                "required_n": required,
223
                "messages": ["target is tighter than the baseline noise floor"],
224
            }
225
        )
226
    else:
227
        required = required_n_for_relative_delta(float(baseline.noise_floor_pct or 0.0), _target_delta_pct(target), len(values))
228
        base.update({"verdict": "AMBER", "required_n": required})
229
        base["messages"].append("confidence interval straddles target")
230
    return base
231
232
233
def _one_sided_p(values, target, baseline, noise, direction, samples, seed) -> float:
234
    """Bootstrap p that the noise-inflated ratio is on the wrong side of the goal.
235
236
    Candidate uncertainty is resampled; the saved baseline enters through its
237
    recorded CI (normal approximation), since books keep summaries, not samples.
238
    """
239
    import random as _random
240
    rng = _random.Random(seed)
241
    spread = 0.0
242
    if baseline.ci_low is not None and baseline.ci_high is not None:
243
        spread = max(0.0, (baseline.ci_high - baseline.ci_low) / (2 * 1.959964))
244
    stat = named_statistic(target.statistic)
245
    wrong = 0
246
    for _ in range(samples):
247
        sample = [values[rng.randrange(len(values))] for _ in values]
248
        denominator = rng.gauss(baseline.value, spread) if spread else baseline.value
249
        if denominator <= 0:
250
            wrong += 1
251
            continue
252
        ratio = stat(sample) / denominator
253
        if direction == "green":
254
            wrong += ratio * (1.0 + noise) > target.goal_value
255
        else:
256
            wrong += ratio * (1.0 - noise) <= target.goal_value
257
    return (wrong + 1) / (samples + 1)
258
259
260
def claim_eligibility_errors(successful, matched, target, baseline, confidence) -> list[str]:
261
    errors = []
262
    if confidence < 0.95:
263
        errors.append("candidate confidence must be at least 0.95 for claims")
264
    if (baseline.confidence is None or not math.isfinite(baseline.confidence) or
265
            not 0.95 <= baseline.confidence < 1):
266
        errors.append("recorded baseline confidence must be at least 0.95 and below 1 for claims")
267
    measured = [row for row in successful if metric_value(row, target.metric) is not None]
268
    # n=30 is a conservative eligibility floor, not a precision guarantee.
269
    minimum = max(30, {"p95": 20, "p99": 100}.get(target.statistic, 0))
270
    n = independent_run_count(measured)
271
    if n is None or n < minimum:
272
        errors.append(f"candidate requires at least {minimum} distinct recorded trials; observed {n}")
273
    if n is not None and n != len(measured):
274
        errors.append("candidate contains duplicate trial IDs")
275
    if baseline.independent_n is None or baseline.independent_n < minimum:
276
        errors.append(f"baseline requires at least {minimum} distinct recorded trials")
277
    if baseline.independent_n != baseline.n:
278
        errors.append("baseline sample count differs from distinct trial count")
279
    if len(measured) != len(matched):
280
        errors.append("candidate contains failed, skipped or unmeasured trials")
281
    if (baseline.failure_n != 0 or baseline.skipped_n != 0 or
282
            baseline.attempted_n != baseline.n):
283
        errors.append("baseline outcomes missing or contain failed, skipped or unmeasured trials")
284
    noise = baseline.noise_floor_pct
285
    if noise is None or not math.isfinite(noise) or noise < 0:
286
        errors.append("measured finite nonnegative baseline noise floor required")
287
    elif target.metric.endswith("_ms") and getattr(baseline, "noise_floor_source", None) != "per_cell_aa":
288
        # ADR-0011: one operator-supplied floor for every cell is the flaw that
289
        # published improvements of a binary over itself; claims need the
290
        # cell's own interleaved A/A floor.
291
        errors.append("per-cell A/A noise floor required for latency claims (baseline floor is global or unknown)")
292
    if (baseline.ci_low is None or baseline.ci_high is None or
293
            not all(math.isfinite(x) for x in (baseline.ci_low, baseline.ci_high)) or
294
            not baseline.ci_low <= baseline.value <= baseline.ci_high):
295
        errors.append("valid measured baseline confidence interval required")
296
    identities = [("candidate", comparison_identity(successful[0]))] if successful else []
297
    if baseline.comparison_identity is None:
298
        errors.append("versioned baseline comparison identity required")
299
    else:
300
        identities.append(("baseline", baseline.comparison_identity))
301
    for label, identity in identities:
302
        missing = missing_claim_identity(identity, target.lane, target.metric, target.operation)
303
        if any(name in target.guards for name in ("information_recall_at_least", "information_precision_at_least")):
304
            if not identity.get("output_oracle_version"):
305
                missing.append("output_oracle_version")
306
        if target.guards.get("patch_apply_ok") and not identity.get("patch_oracle_version"):
307
            missing.append("patch_oracle_version")
308
        if missing:
309
            errors.append(f"{label} claim identity missing: {', '.join(missing)}")
310
    return errors
311
312
313
def statistic_value(values: list[float], statistic: str) -> float:
314
    return float(named_statistic(statistic)(values))
315
316
317
def guard_violations(
318
    matched: list[dict[str, Any]],
319
    successful: list[dict[str, Any]],
320
    failures: list[dict[str, Any]],
321
    guards: dict[str, Any],
322
) -> list[str]:
323
    errors: list[str] = []
324
    if failures:
325
        errors.append(f"{len(failures)} matching rows failed")
326
    if "success_rate_at_least" in guards:
327
        success_rate = len(successful) / len(matched) if matched else 0.0
328
        if success_rate < float(guards["success_rate_at_least"]):
329
            errors.append(f"success_rate {success_rate:.3f} below guard {float(guards['success_rate_at_least']):.3f}")
330
    if "information_recall_at_least" in guards:
331
        _minimum_guard(successful, "information_recall", float(guards["information_recall_at_least"]), errors)
332
    if "information_precision_at_least" in guards:
333
        _minimum_guard(successful, "information_precision", float(guards["information_precision_at_least"]), errors)
334
    if guards.get("pipe_compatible_unified") is True:
335
        missing = [row for row in successful if row.get("pipe_compatible_unified") is None]
336
        bad = [row for row in successful if row.get("pipe_compatible_unified") is False]
337
        if missing:
338
            errors.append(f"{len(missing)} rows have unmeasured pipe_compatible_unified guard")
339
        if bad:
340
            errors.append(f"{len(bad)} rows failed pipe_compatible_unified guard")
341
    if guards.get("patch_apply_ok") is True:
342
        if any(row.get("patch_apply_ok") is not True for row in successful):
343
            errors.append("patch_apply_ok guard is failed or unmeasured")
344
    if guards.get("content_integrity_public_trust") is True:
345
        missing = [row for row in successful if row.get("content_integrity_check_passed") is None]
346
        failed = [row for row in successful if row.get("content_integrity_check_passed") is False]
347
        weak = [
348
            row
349
            for row in successful
350
            if row.get("content_integrity_check_passed") is True
351
            and not content_integrity_public_trust_passed(row)
352
        ]
353
        if missing:
354
            errors.append(f"{len(missing)} rows have unmeasured content_integrity_public_trust guard")
355
        if failed:
356
            errors.append(f"{len(failed)} rows failed content_integrity_public_trust guard")
357
        if weak:
358
            errors.append(f"{len(weak)} rows have weak content_integrity_source for public trust")
359
    return errors
360
361
362
def _minimum_guard(rows: list[dict[str, Any]], key: str, minimum: float, errors: list[str]) -> None:
363
    if not rows:
364
        errors.append(f"{key} guard has no successful observations")
365
        return
366
    values = [value for row in rows if (value := _number_or_none(row.get(key))) is not None]
367
    if len(values) != len(rows):
368
        errors.append(f"{key} guard is unmeasured on {len(rows) - len(values)} rows")
369
    elif min(values) < minimum:
370
        errors.append(f"{key} minimum {min(values):.3f} below guard {minimum:.3f}")
371
372
373
def _row_matches(row: dict[str, Any], target: Target) -> bool:
374
    lane = _row_lane(row)
375
    return (
376
        lane == target.lane
377
        and str(row.get("subject")) == target.subject
378
        and str(row.get("scenario")) == target.scenario
379
        and str(row.get("operation")) == target.operation
380
        and (
381
            (target.profile is None and row.get("profile") is None)
382
            or (target.profile is not None and str(row.get("profile")) == target.profile)
383
        )
384
    )
385
386
387
def _row_lane(row: dict[str, Any]) -> str | None:
388
    for key in ("lane", "benchmark_lane", "measurement_lane"):
389
        if row.get(key) is not None:
390
            return str(row[key])
391
    if "workflow" in row or "phase" in row:
392
        return "workflow"
393
    if all(key in row for key in ("token_metrics", "turn_metrics", "tool_call_metrics")):
394
        return "agent"
395
    if "contention_mode" in row or "workers" in row:
396
        return "contention"
397
    if str(row.get("scenario") or "") == "task_loop":
398
        return "task-loop"
399
    if row.get("subject_kind") is None and row.get("subject") is not None:
400
        return "mount"
401
    if all(key in row for key in ("profile", "scenario", "operation", "subject", "subject_kind", "command")):
402
        return "core"
403
    return None
404
405
406
def _noise_makes_undecidable(target: Target, baseline: BaselineCell) -> bool:
407
    if baseline.noise_floor_pct is None:
408
        return False
409
    return baseline.noise_floor_pct > _target_delta_pct(target)
410
411
412
def _baseline_denominators(baseline: BaselineCell) -> tuple[float, float] | None:
413
    low = baseline.ci_low if baseline.ci_low is not None else baseline.value
414
    high = baseline.ci_high if baseline.ci_high is not None else baseline.value
415
    if low <= 0 or high <= 0:
416
        return None
417
    return low, high
418
419
420
def _target_delta_pct(target: Target) -> float:
421
    # The margin the instrument must resolve is the distance from parity in
422
    # either direction: a goal of 1.5x demands the same 50% resolution a goal
423
    # of 0.5x does. A parity goal (1.0) has zero margin and is undecidable
424
    # whenever any noise floor exists.
425
    if target.goal_type == "ratio_at_most":
426
        return abs(1.0 - target.goal_value) * 100.0
427
    return 0.0
428
429
430
_number_or_none = number_or_none