| 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
|