| 1 | #!/usr/bin/env python3
|
| 2 | """Print a Markdown regression report for Oak benchmark JSONL files."""
|
| 3 |
|
| 4 | from __future__ import annotations
|
| 5 |
|
| 6 | import argparse
|
| 7 | import json
|
| 8 | import statistics
|
| 9 | import sys
|
| 10 | from dataclasses import dataclass
|
| 11 | from pathlib import Path
|
| 12 | from typing import Any, Iterable, Optional
|
| 13 |
|
| 14 | from oakbench.reporting import (
|
| 15 | delta_ms,
|
| 16 | fmt_ms,
|
| 17 | fmt_num,
|
| 18 | fmt_pct,
|
| 19 | fmt_rate,
|
| 20 | lower_is_better_pct,
|
| 21 | median,
|
| 22 | percentile_nearest,
|
| 23 | slower_pct,
|
| 24 | )
|
| 25 | from oakbench.rows import (
|
| 26 | number_or_none,
|
| 27 | output_bytes as row_output_bytes,
|
| 28 | output_truncated as row_output_truncated,
|
| 29 | row_returncode,
|
| 30 | token_total as row_token_total,
|
| 31 | tool_calls as row_tool_calls,
|
| 32 | )
|
| 33 |
|
| 34 |
|
| 35 | SUBOP_SUFFIXES = (".add", ".commit")
|
| 36 | SKIP_RETURNCODE = 77
|
| 37 |
|
| 38 |
|
| 39 | @dataclass(frozen=True)
|
| 40 | class Aggregate:
|
| 41 | subject: str
|
| 42 | scenario: str
|
| 43 | operation: str
|
| 44 | sample_count: int
|
| 45 | total_count: int
|
| 46 | failure_count: int
|
| 47 | skip_count: int
|
| 48 | p50_ms: Optional[float]
|
| 49 | p90_ms: Optional[float]
|
| 50 | avg_tokens: Optional[float]
|
| 51 | avg_tool_calls: Optional[float]
|
| 52 | avg_output_bytes: Optional[float]
|
| 53 | truncated_count: int
|
| 54 | avg_information_recall: Optional[float] = None
|
| 55 | pipe_compat_rate: Optional[float] = None
|
| 56 |
|
| 57 | @property
|
| 58 | def failure_rate(self) -> float:
|
| 59 | # Skip rows (returncode 77) are unmeasured coverage, not failures:
|
| 60 | # they leave both the numerator and the denominator (ADR-0002).
|
| 61 | measured = self.total_count - self.skip_count
|
| 62 | if measured <= 0:
|
| 63 | return 0.0
|
| 64 | return self.failure_count / measured
|
| 65 |
|
| 66 |
|
| 67 | @dataclass(frozen=True)
|
| 68 | class Regression:
|
| 69 | baseline_label: str
|
| 70 | baseline_subject: str
|
| 71 | target_subject: str
|
| 72 | scenario: str
|
| 73 | operation: str
|
| 74 | reasons: tuple[str, ...]
|
| 75 | target: Aggregate
|
| 76 | baseline: Aggregate
|
| 77 | p50_delta_ms: Optional[float]
|
| 78 | p50_slower_pct: Optional[float]
|
| 79 | p90_delta_ms: Optional[float]
|
| 80 | p90_slower_pct: Optional[float]
|
| 81 | failure_delta_pp: float
|
| 82 | severity: float
|
| 83 |
|
| 84 |
|
| 85 | def parse_args() -> argparse.Namespace:
|
| 86 | parser = argparse.ArgumentParser(description=__doc__)
|
| 87 | parser.add_argument("jsonl", nargs="+", type=Path, help="One or more benchmark JSONL files")
|
| 88 | parser.add_argument("--target-subject", default="oak_local")
|
| 89 | parser.add_argument("--git-subject", default="git")
|
| 90 | parser.add_argument(
|
| 91 | "--oak-baseline",
|
| 92 | help="Default: oak_main when present, otherwise oak_installed",
|
| 93 | )
|
| 94 | parser.add_argument("--oak-threshold-pct", type=float, default=10.0)
|
| 95 | parser.add_argument("--git-threshold-pct", type=float, default=25.0)
|
| 96 | parser.add_argument("--min-delta-ms", type=float, default=5.0)
|
| 97 | parser.add_argument(
|
| 98 | "--failure-rate-threshold-pct",
|
| 99 | type=float,
|
| 100 | default=0.0,
|
| 101 | help="Allowed failure-rate increase in percentage points before flagging",
|
| 102 | )
|
| 103 | parser.add_argument(
|
| 104 | "--tool-calls-threshold-pct",
|
| 105 | type=float,
|
| 106 | default=0.0,
|
| 107 | help=(
|
| 108 | "Flag when target avg tool calls exceed the baseline by more than this percent. "
|
| 109 | "Tool calls are exact in the CLI harness, so the default flags any increase. "
|
| 110 | "Set negative to disable."
|
| 111 | ),
|
| 112 | )
|
| 113 | parser.add_argument(
|
| 114 | "--output-bytes-threshold-pct",
|
| 115 | type=float,
|
| 116 | default=25.0,
|
| 117 | help=(
|
| 118 | "Flag when target avg output bytes exceed the baseline by more than this percent. "
|
| 119 | "Output bytes are exact and drive agent context cost. Set negative to disable."
|
| 120 | ),
|
| 121 | )
|
| 122 | parser.add_argument(
|
| 123 | "--tokens-threshold-pct",
|
| 124 | type=float,
|
| 125 | default=-1.0,
|
| 126 | help=(
|
| 127 | "Flag when target avg tokens exceed the baseline by more than this percent. "
|
| 128 | "Disabled by default because char/4 estimates are advisory; gate on output bytes "
|
| 129 | "and tool calls (exact) instead, or set this once provider-reported rows dominate."
|
| 130 | ),
|
| 131 | )
|
| 132 | parser.add_argument("--include-subops", action="store_true")
|
| 133 | parser.add_argument("--limit", type=int, default=40, help="Maximum flagged regressions to print")
|
| 134 | parser.add_argument("--fail-on-regression", action="store_true")
|
| 135 | return parser.parse_args()
|
| 136 |
|
| 137 |
|
| 138 | def is_suboperation(operation: str) -> bool:
|
| 139 | return operation.endswith(SUBOP_SUFFIXES)
|
| 140 |
|
| 141 |
|
| 142 | def iter_rows(paths: Iterable[Path]) -> Iterable[tuple[Path, int, dict[str, Any]]]:
|
| 143 | for path in paths:
|
| 144 | with path.open() as fh:
|
| 145 | for line_number, raw_line in enumerate(fh, start=1):
|
| 146 | line = raw_line.strip()
|
| 147 | if not line:
|
| 148 | continue
|
| 149 | try:
|
| 150 | row = json.loads(line)
|
| 151 | except json.JSONDecodeError as exc:
|
| 152 | raise SystemExit(f"{path}:{line_number}: invalid JSON: {exc}") from exc
|
| 153 | if not isinstance(row, dict):
|
| 154 | raise SystemExit(f"{path}:{line_number}: expected a JSON object")
|
| 155 | yield path, line_number, row
|
| 156 |
|
| 157 |
|
| 158 |
|
| 159 |
|
| 160 |
|
| 161 |
|
| 162 |
|
| 163 |
|
| 164 |
|
| 165 |
|
| 166 |
|
| 167 |
|
| 168 |
|
| 169 |
|
| 170 |
|
| 171 | def choose_oak_baseline(subjects: set[str], explicit: Optional[str]) -> Optional[str]:
|
| 172 | if explicit:
|
| 173 | return explicit if explicit in subjects else None
|
| 174 | if "oak_main" in subjects:
|
| 175 | return "oak_main"
|
| 176 | if "oak_installed" in subjects:
|
| 177 | return "oak_installed"
|
| 178 | return None
|
| 179 |
|
| 180 |
|
| 181 | def choose_targets(
|
| 182 | subjects: set[str],
|
| 183 | target_subject: str,
|
| 184 | git_subject: str,
|
| 185 | oak_baseline: Optional[str],
|
| 186 | ) -> list[str]:
|
| 187 | if target_subject in subjects:
|
| 188 | return [target_subject]
|
| 189 | excluded = {git_subject}
|
| 190 | if oak_baseline:
|
| 191 | excluded.add(oak_baseline)
|
| 192 | return sorted(subject for subject in subjects if subject not in excluded)
|
| 193 |
|
| 194 |
|
| 195 | def subject_order(subject: str, git_subject: str, oak_baseline: Optional[str], targets: list[str]) -> tuple[int, str]:
|
| 196 | if subject == git_subject:
|
| 197 | return (0, subject)
|
| 198 | if oak_baseline and subject == oak_baseline:
|
| 199 | return (1, subject)
|
| 200 | if subject in targets:
|
| 201 | return (2, subject)
|
| 202 | return (3, subject)
|
| 203 |
|
| 204 |
|
| 205 | def build_aggregates(args: argparse.Namespace) -> tuple[
|
| 206 | dict[tuple[str, str, str], Aggregate],
|
| 207 | dict[str, Any],
|
| 208 | ]:
|
| 209 | samples: dict[tuple[str, str, str], list[float]] = {}
|
| 210 | token_samples: dict[tuple[str, str, str], list[float]] = {}
|
| 211 | tool_samples: dict[tuple[str, str, str], list[float]] = {}
|
| 212 | output_samples: dict[tuple[str, str, str], list[float]] = {}
|
| 213 | truncations: dict[tuple[str, str, str], int] = {}
|
| 214 | totals: dict[tuple[str, str, str], int] = {}
|
| 215 | failures: dict[tuple[str, str, str], int] = {}
|
| 216 | skips: dict[tuple[str, str, str], int] = {}
|
| 217 | recall_samples: dict[tuple[str, str, str], list[float]] = {}
|
| 218 | pipe_samples: dict[tuple[str, str, str], list[float]] = {}
|
| 219 | meta: dict[str, Any] = {
|
| 220 | "bench_ids": set(),
|
| 221 | "profiles": set(),
|
| 222 | "hosts": set(),
|
| 223 | "subjects": set(),
|
| 224 | "source_hashes": set(),
|
| 225 | "skipped": 0,
|
| 226 | "row_count": 0,
|
| 227 | "paths": [str(path) for path in args.jsonl],
|
| 228 | }
|
| 229 |
|
| 230 | for _, _, row in iter_rows(args.jsonl):
|
| 231 | meta["row_count"] += 1
|
| 232 | subject = str(row.get("subject") or "")
|
| 233 | scenario = str(row.get("scenario") or "")
|
| 234 | operation = str(row.get("operation") or "")
|
| 235 | if not subject or not scenario or not operation:
|
| 236 | meta["skipped"] += 1
|
| 237 | continue
|
| 238 | if not args.include_subops and is_suboperation(operation):
|
| 239 | continue
|
| 240 |
|
| 241 | key = (subject, scenario, operation)
|
| 242 | totals[key] = totals.get(key, 0) + 1
|
| 243 | meta["subjects"].add(subject)
|
| 244 | if row.get("bench_id"):
|
| 245 | meta["bench_ids"].add(str(row["bench_id"]))
|
| 246 | if row.get("profile"):
|
| 247 | meta["profiles"].add(str(row["profile"]))
|
| 248 | host_parts = [row.get("host"), row.get("platform"), row.get("machine")]
|
| 249 | if any(host_parts):
|
| 250 | meta["hosts"].add(" / ".join(str(part) for part in host_parts if part))
|
| 251 | source = row.get("source")
|
| 252 | if isinstance(source, dict) and source.get("oak_hash"):
|
| 253 | meta["source_hashes"].add(str(source["oak_hash"]))
|
| 254 |
|
| 255 | # Skip rows are recorded coverage gaps (returncode 77 + skipped):
|
| 256 | # they must not count as failures and must not feed any average β
|
| 257 | # a skipped Oak capability is unmeasured, not "0 tokens, 0 tools".
|
| 258 | returncode = row_returncode(row)
|
| 259 | if returncode == SKIP_RETURNCODE or row.get("skipped"):
|
| 260 | skips[key] = skips.get(key, 0) + 1
|
| 261 | continue
|
| 262 | if returncode != 0:
|
| 263 | failures[key] = failures.get(key, 0) + 1
|
| 264 | continue
|
| 265 |
|
| 266 | # Efficiency averages sample successful rows only: a failed command's
|
| 267 | # token/output cost is real but belongs to the failure metrics, not to
|
| 268 | # the "what does this operation cost when it works" trend line.
|
| 269 | token_total = row_token_total(row)
|
| 270 | if token_total is not None:
|
| 271 | token_samples.setdefault(key, []).append(token_total)
|
| 272 |
|
| 273 | tool_total = row_tool_calls(row)
|
| 274 | if tool_total is not None:
|
| 275 | tool_samples.setdefault(key, []).append(tool_total)
|
| 276 |
|
| 277 | output_total = row_output_bytes(row)
|
| 278 | if output_total is not None:
|
| 279 | output_samples.setdefault(key, []).append(output_total)
|
| 280 |
|
| 281 | if row_output_truncated(row):
|
| 282 | truncations[key] = truncations.get(key, 0) + 1
|
| 283 |
|
| 284 | # Output sufficiency from inforecall probe rows: recall of the
|
| 285 | # ground-truth changed set and piped-diff structural compatibility.
|
| 286 | recall = row.get("information_recall")
|
| 287 | if isinstance(recall, (int, float)) and not isinstance(recall, bool):
|
| 288 | recall_samples.setdefault(key, []).append(float(recall))
|
| 289 | pipe_compatible = row.get("pipe_compatible_unified")
|
| 290 | if isinstance(pipe_compatible, bool):
|
| 291 | pipe_samples.setdefault(key, []).append(1.0 if pipe_compatible else 0.0)
|
| 292 |
|
| 293 | try:
|
| 294 | elapsed_ms = float(row["elapsed_ms"])
|
| 295 | except (KeyError, TypeError, ValueError):
|
| 296 | meta["skipped"] += 1
|
| 297 | continue
|
| 298 | samples.setdefault(key, []).append(elapsed_ms)
|
| 299 |
|
| 300 | aggregates: dict[tuple[str, str, str], Aggregate] = {}
|
| 301 | for key in sorted(totals):
|
| 302 | subject, scenario, operation = key
|
| 303 | values = samples.get(key, [])
|
| 304 | aggregates[key] = Aggregate(
|
| 305 | subject=subject,
|
| 306 | scenario=scenario,
|
| 307 | operation=operation,
|
| 308 | sample_count=len(values),
|
| 309 | total_count=totals[key],
|
| 310 | failure_count=failures.get(key, 0),
|
| 311 | skip_count=skips.get(key, 0),
|
| 312 | p50_ms=median(values),
|
| 313 | p90_ms=percentile_nearest(values, 90.0),
|
| 314 | avg_tokens=statistics.mean(token_samples[key]) if key in token_samples else None,
|
| 315 | avg_tool_calls=statistics.mean(tool_samples[key]) if key in tool_samples else None,
|
| 316 | avg_output_bytes=statistics.mean(output_samples[key]) if key in output_samples else None,
|
| 317 | truncated_count=truncations.get(key, 0),
|
| 318 | avg_information_recall=statistics.mean(recall_samples[key]) if key in recall_samples else None,
|
| 319 | pipe_compat_rate=statistics.mean(pipe_samples[key]) if key in pipe_samples else None,
|
| 320 | )
|
| 321 | return aggregates, meta
|
| 322 |
|
| 323 |
|
| 324 | def compare_target(
|
| 325 | aggregates: dict[tuple[str, str, str], Aggregate],
|
| 326 | target_subject: str,
|
| 327 | baseline_subject: str,
|
| 328 | baseline_label: str,
|
| 329 | threshold_pct: float,
|
| 330 | min_delta_ms: float,
|
| 331 | failure_threshold_pp: float,
|
| 332 | tool_calls_threshold_pct: float = -1.0,
|
| 333 | output_bytes_threshold_pct: float = -1.0,
|
| 334 | tokens_threshold_pct: float = -1.0,
|
| 335 | ) -> list[Regression]:
|
| 336 | regressions: list[Regression] = []
|
| 337 | scenario_ops = sorted({(scenario, operation) for _, scenario, operation in aggregates})
|
| 338 |
|
| 339 | for scenario, operation in scenario_ops:
|
| 340 | target = aggregates.get((target_subject, scenario, operation))
|
| 341 | baseline = aggregates.get((baseline_subject, scenario, operation))
|
| 342 | if target is None or baseline is None:
|
| 343 | continue
|
| 344 |
|
| 345 | p50_delta = delta_ms(baseline.p50_ms, target.p50_ms)
|
| 346 | p50_pct = slower_pct(baseline.p50_ms, target.p50_ms)
|
| 347 | p90_delta = delta_ms(baseline.p90_ms, target.p90_ms)
|
| 348 | p90_pct = slower_pct(baseline.p90_ms, target.p90_ms)
|
| 349 | failure_delta_pp = (target.failure_rate - baseline.failure_rate) * 100.0
|
| 350 |
|
| 351 | reasons: list[str] = []
|
| 352 | severities: list[float] = []
|
| 353 |
|
| 354 | if p50_delta is not None and p50_pct is not None:
|
| 355 | if p50_delta >= min_delta_ms and p50_pct >= threshold_pct:
|
| 356 | reasons.append("p50")
|
| 357 | severities.append(p50_pct / max(threshold_pct, 0.001))
|
| 358 |
|
| 359 | if p90_delta is not None and p90_pct is not None:
|
| 360 | if p90_delta >= min_delta_ms and p90_pct >= threshold_pct:
|
| 361 | reasons.append("p90")
|
| 362 | severities.append(p90_pct / max(threshold_pct, 0.001))
|
| 363 |
|
| 364 | if failure_delta_pp > failure_threshold_pp:
|
| 365 | reasons.append("failure-rate")
|
| 366 | severities.append(max(1.0, failure_delta_pp / max(failure_threshold_pp, 0.1)))
|
| 367 |
|
| 368 | # Agent-efficiency gates. Tool calls and output bytes are exact for the
|
| 369 | # CLI harness; token estimates are advisory unless explicitly enabled.
|
| 370 | efficiency_checks = (
|
| 371 | ("tool-calls", tool_calls_threshold_pct, baseline.avg_tool_calls, target.avg_tool_calls),
|
| 372 | ("output-bytes", output_bytes_threshold_pct, baseline.avg_output_bytes, target.avg_output_bytes),
|
| 373 | ("tokens", tokens_threshold_pct, baseline.avg_tokens, target.avg_tokens),
|
| 374 | )
|
| 375 | for reason, eff_threshold, base_value, target_value in efficiency_checks:
|
| 376 | if eff_threshold < 0:
|
| 377 | continue
|
| 378 | pct = lower_is_better_pct(base_value, target_value)
|
| 379 | if pct is not None and pct > eff_threshold:
|
| 380 | reasons.append(reason)
|
| 381 | severities.append(pct / max(eff_threshold, 1.0))
|
| 382 |
|
| 383 | # Output-sufficiency gates: exact semantic metrics, always on when
|
| 384 | # both sides measured them. Tokens and time can improve while
|
| 385 | # actionability regresses β fewer bytes that drop changed-file names
|
| 386 | # (recall) or break piped-diff structure (pipe-compat) is a
|
| 387 | # regression, not a win.
|
| 388 | if (
|
| 389 | target.avg_information_recall is not None
|
| 390 | and baseline.avg_information_recall is not None
|
| 391 | and target.avg_information_recall < baseline.avg_information_recall - 1e-6
|
| 392 | ):
|
| 393 | reasons.append("information-recall")
|
| 394 | severities.append(2.0)
|
| 395 | if (
|
| 396 | target.pipe_compat_rate is not None
|
| 397 | and baseline.pipe_compat_rate is not None
|
| 398 | and target.pipe_compat_rate < baseline.pipe_compat_rate - 1e-6
|
| 399 | ):
|
| 400 | reasons.append("pipe-compat")
|
| 401 | severities.append(2.0)
|
| 402 |
|
| 403 | if reasons:
|
| 404 | regressions.append(
|
| 405 | Regression(
|
| 406 | baseline_label=baseline_label,
|
| 407 | baseline_subject=baseline_subject,
|
| 408 | target_subject=target_subject,
|
| 409 | scenario=scenario,
|
| 410 | operation=operation,
|
| 411 | reasons=tuple(reasons),
|
| 412 | target=target,
|
| 413 | baseline=baseline,
|
| 414 | p50_delta_ms=p50_delta,
|
| 415 | p50_slower_pct=p50_pct,
|
| 416 | p90_delta_ms=p90_delta,
|
| 417 | p90_slower_pct=p90_pct,
|
| 418 | failure_delta_pp=failure_delta_pp,
|
| 419 | severity=max(severities) if severities else 0.0,
|
| 420 | )
|
| 421 | )
|
| 422 |
|
| 423 | return regressions
|
| 424 |
|
| 425 |
|
| 426 | def markdown_report(args: argparse.Namespace) -> tuple[str, bool]:
|
| 427 | aggregates, meta = build_aggregates(args)
|
| 428 | subjects = set(meta["subjects"])
|
| 429 | oak_baseline = choose_oak_baseline(subjects, args.oak_baseline)
|
| 430 | targets = choose_targets(subjects, args.target_subject, args.git_subject, oak_baseline)
|
| 431 |
|
| 432 | regressions: list[Regression] = []
|
| 433 | for target in targets:
|
| 434 | if args.git_subject in subjects and target != args.git_subject:
|
| 435 | regressions.extend(
|
| 436 | compare_target(
|
| 437 | aggregates,
|
| 438 | target,
|
| 439 | args.git_subject,
|
| 440 | "Git",
|
| 441 | args.git_threshold_pct,
|
| 442 | args.min_delta_ms,
|
| 443 | args.failure_rate_threshold_pct,
|
| 444 | )
|
| 445 | )
|
| 446 | if oak_baseline and target != oak_baseline:
|
| 447 | # Efficiency gates apply to the Oak-baseline comparison only: the
|
| 448 | # release-blocking question is "did this changeset make Oak worse",
|
| 449 | # not "does Oak emit more text than Git" (tracked separately).
|
| 450 | regressions.extend(
|
| 451 | compare_target(
|
| 452 | aggregates,
|
| 453 | target,
|
| 454 | oak_baseline,
|
| 455 | "Oak baseline",
|
| 456 | args.oak_threshold_pct,
|
| 457 | args.min_delta_ms,
|
| 458 | args.failure_rate_threshold_pct,
|
| 459 | tool_calls_threshold_pct=args.tool_calls_threshold_pct,
|
| 460 | output_bytes_threshold_pct=args.output_bytes_threshold_pct,
|
| 461 | tokens_threshold_pct=args.tokens_threshold_pct,
|
| 462 | )
|
| 463 | )
|
| 464 |
|
| 465 | regressions.sort(
|
| 466 | key=lambda item: (
|
| 467 | "failure-rate" not in item.reasons,
|
| 468 | -item.severity,
|
| 469 | -(item.p90_delta_ms or 0.0),
|
| 470 | item.scenario,
|
| 471 | item.operation,
|
| 472 | )
|
| 473 | )
|
| 474 |
|
| 475 | lines: list[str] = [
|
| 476 | "# Oak Benchmark Regression Report",
|
| 477 | "",
|
| 478 | (
|
| 479 | "Positive comparison percentages mean the target subject is higher than the baseline; "
|
| 480 | "for time that is slower, and for tokens/tool calls that is more expensive."
|
| 481 | ),
|
| 482 | "",
|
| 483 | f"- Inputs: {', '.join(f'`{path}`' for path in meta['paths'])}",
|
| 484 | f"- Rows read: {meta['row_count']}",
|
| 485 | f"- Bench IDs: {format_set(meta['bench_ids'])}",
|
| 486 | f"- Profiles: {format_set(meta['profiles'])}",
|
| 487 | f"- Source hashes: {format_set(meta['source_hashes'])}",
|
| 488 | f"- Hosts: {format_set(meta['hosts'])}",
|
| 489 | f"- Target subjects: {', '.join(f'`{subject}`' for subject in targets) or '`none`'}",
|
| 490 | f"- Git baseline: `{args.git_subject}`" if args.git_subject in subjects else f"- Git baseline: `{args.git_subject}` missing",
|
| 491 | f"- Oak baseline: `{oak_baseline}`" if oak_baseline else "- Oak baseline: missing",
|
| 492 | (
|
| 493 | f"- Thresholds: Oak {args.oak_threshold_pct:.1f}%, Git {args.git_threshold_pct:.1f}%, "
|
| 494 | f"minimum {args.min_delta_ms:.1f} ms, failure delta {args.failure_rate_threshold_pct:.1f} pp"
|
| 495 | ),
|
| 496 | (
|
| 497 | "- Efficiency gates vs Oak baseline (exact metrics): tool calls "
|
| 498 | + (f"+{args.tool_calls_threshold_pct:.1f}%" if args.tool_calls_threshold_pct >= 0 else "off")
|
| 499 | + ", output bytes "
|
| 500 | + (f"+{args.output_bytes_threshold_pct:.1f}%" if args.output_bytes_threshold_pct >= 0 else "off")
|
| 501 | + ", tokens (advisory estimate) "
|
| 502 | + (f"+{args.tokens_threshold_pct:.1f}%" if args.tokens_threshold_pct >= 0 else "off")
|
| 503 | ),
|
| 504 | ]
|
| 505 |
|
| 506 | if meta["skipped"]:
|
| 507 | lines.append(f"- Skipped malformed rows: {meta['skipped']}")
|
| 508 |
|
| 509 | lines.extend(["", "## Status", ""])
|
| 510 | if regressions:
|
| 511 | lines.append(f"Regressions flagged: {len(regressions)}")
|
| 512 | else:
|
| 513 | lines.append("No regressions exceeded the configured thresholds.")
|
| 514 |
|
| 515 | lines.extend(
|
| 516 | [
|
| 517 | "",
|
| 518 | "## Subject Averages",
|
| 519 | "",
|
| 520 | (
|
| 521 | "Token values are provider-reported when present; otherwise they are benchmark-estimated "
|
| 522 | "from normalized transcript characters or direct CLI output."
|
| 523 | ),
|
| 524 | "Output bytes are full stdout/stderr byte counts. Truncated rows indicate admitted text was capped.",
|
| 525 | "",
|
| 526 | "| Subject | Avg ms/op | Avg tokens/op | Avg output bytes/op | Avg tool calls/op | Tokens vs Git | Tokens vs Oak | Tools vs Git | Tools vs Oak |",
|
| 527 | "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
|
| 528 | ]
|
| 529 | )
|
| 530 | ordered_subjects = sorted(
|
| 531 | subjects,
|
| 532 | key=lambda subject: subject_order(subject, args.git_subject, oak_baseline, targets),
|
| 533 | )
|
| 534 | git_avg_tokens = weighted_subject_average(aggregates, args.git_subject, "avg_tokens")
|
| 535 | git_avg_tools = weighted_subject_average(aggregates, args.git_subject, "avg_tool_calls")
|
| 536 | oak_avg_tokens = weighted_subject_average(aggregates, oak_baseline, "avg_tokens") if oak_baseline else None
|
| 537 | oak_avg_tools = weighted_subject_average(aggregates, oak_baseline, "avg_tool_calls") if oak_baseline else None
|
| 538 | for subject in ordered_subjects:
|
| 539 | avg_time = weighted_subject_average(aggregates, subject, "p50_ms")
|
| 540 | avg_tokens = weighted_subject_average(aggregates, subject, "avg_tokens")
|
| 541 | avg_output = weighted_subject_average(aggregates, subject, "avg_output_bytes")
|
| 542 | avg_tools = weighted_subject_average(aggregates, subject, "avg_tool_calls")
|
| 543 | lines.append(
|
| 544 | "| "
|
| 545 | f"`{subject}` | "
|
| 546 | f"{fmt_ms(avg_time)} | "
|
| 547 | f"{fmt_num(avg_tokens)} | "
|
| 548 | f"{fmt_num(avg_output, 0)} | "
|
| 549 | f"{fmt_num(avg_tools, 2)} | "
|
| 550 | f"{fmt_pct(lower_is_better_pct(git_avg_tokens, avg_tokens)) if subject != args.git_subject else ''} | "
|
| 551 | f"{fmt_pct(lower_is_better_pct(oak_avg_tokens, avg_tokens)) if subject != oak_baseline and subject != args.git_subject else ''} | "
|
| 552 | f"{fmt_pct(lower_is_better_pct(git_avg_tools, avg_tools)) if subject != args.git_subject else ''} | "
|
| 553 | f"{fmt_pct(lower_is_better_pct(oak_avg_tools, avg_tools)) if subject != oak_baseline and subject != args.git_subject else ''} |"
|
| 554 | )
|
| 555 |
|
| 556 | if regressions:
|
| 557 | lines.extend(
|
| 558 | [
|
| 559 | "",
|
| 560 | "## Flagged Regressions",
|
| 561 | "",
|
| 562 | "| Baseline | Subject | Scenario / operation | Reason | p50 ms | Baseline p50 | p50 slower | p90 ms | Baseline p90 | p90 slower | Failure delta |",
|
| 563 | "| --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
|
| 564 | ]
|
| 565 | )
|
| 566 | for item in regressions[: args.limit]:
|
| 567 | lines.append(
|
| 568 | "| "
|
| 569 | f"{item.baseline_label} `{item.baseline_subject}` | "
|
| 570 | f"`{item.target_subject}` | "
|
| 571 | f"`{item.scenario}/{item.operation}` | "
|
| 572 | f"{', '.join(item.reasons)} | "
|
| 573 | f"{fmt_ms(item.target.p50_ms)} | "
|
| 574 | f"{fmt_ms(item.baseline.p50_ms)} | "
|
| 575 | f"{fmt_pct(item.p50_slower_pct)} | "
|
| 576 | f"{fmt_ms(item.target.p90_ms)} | "
|
| 577 | f"{fmt_ms(item.baseline.p90_ms)} | "
|
| 578 | f"{fmt_pct(item.p90_slower_pct)} | "
|
| 579 | f"{item.failure_delta_pp:+.1f} pp |"
|
| 580 | )
|
| 581 | if len(regressions) > args.limit:
|
| 582 | lines.append(f"\n_Only the first {args.limit} regressions are shown._")
|
| 583 |
|
| 584 | lines.extend(
|
| 585 | [
|
| 586 | "",
|
| 587 | "## Metrics",
|
| 588 | "",
|
| 589 | "| Scenario / operation | Subject | n | Failures | Skips | Failure rate | p50 ms | p90 ms | Avg tokens | Avg output bytes | Trunc rows | Avg tools | vs Git p50 | vs Git tokens | vs Git tools | vs Oak p50 | vs Oak tokens | vs Oak tools |",
|
| 590 | "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
|
| 591 | ]
|
| 592 | )
|
| 593 |
|
| 594 | scenario_ops = sorted({(scenario, operation) for _, scenario, operation in aggregates})
|
| 595 |
|
| 596 | for scenario, operation in scenario_ops:
|
| 597 | git_base = aggregates.get((args.git_subject, scenario, operation))
|
| 598 | oak_base = aggregates.get((oak_baseline, scenario, operation)) if oak_baseline else None
|
| 599 | for subject in ordered_subjects:
|
| 600 | aggregate = aggregates.get((subject, scenario, operation))
|
| 601 | if aggregate is None:
|
| 602 | continue
|
| 603 | vs_git = ""
|
| 604 | if git_base and subject != args.git_subject:
|
| 605 | vs_git = fmt_pct(slower_pct(git_base.p50_ms, aggregate.p50_ms))
|
| 606 | vs_oak = ""
|
| 607 | if oak_base and subject != oak_baseline:
|
| 608 | vs_oak = fmt_pct(slower_pct(oak_base.p50_ms, aggregate.p50_ms))
|
| 609 | vs_git_tokens = ""
|
| 610 | if git_base and subject != args.git_subject:
|
| 611 | vs_git_tokens = fmt_pct(lower_is_better_pct(git_base.avg_tokens, aggregate.avg_tokens))
|
| 612 | vs_git_tools = ""
|
| 613 | if git_base and subject != args.git_subject:
|
| 614 | vs_git_tools = fmt_pct(lower_is_better_pct(git_base.avg_tool_calls, aggregate.avg_tool_calls))
|
| 615 | vs_oak_tokens = ""
|
| 616 | if oak_base and subject != oak_baseline and subject != args.git_subject:
|
| 617 | vs_oak_tokens = fmt_pct(lower_is_better_pct(oak_base.avg_tokens, aggregate.avg_tokens))
|
| 618 | vs_oak_tools = ""
|
| 619 | if oak_base and subject != oak_baseline and subject != args.git_subject:
|
| 620 | vs_oak_tools = fmt_pct(lower_is_better_pct(oak_base.avg_tool_calls, aggregate.avg_tool_calls))
|
| 621 | lines.append(
|
| 622 | "| "
|
| 623 | f"`{scenario}/{operation}` | "
|
| 624 | f"`{subject}` | "
|
| 625 | f"{aggregate.sample_count} | "
|
| 626 | f"{aggregate.failure_count} | "
|
| 627 | f"{aggregate.skip_count} | "
|
| 628 | f"{fmt_rate(aggregate.failure_rate)} | "
|
| 629 | f"{fmt_ms(aggregate.p50_ms)} | "
|
| 630 | f"{fmt_ms(aggregate.p90_ms)} | "
|
| 631 | f"{fmt_num(aggregate.avg_tokens)} | "
|
| 632 | f"{fmt_num(aggregate.avg_output_bytes, 0)} | "
|
| 633 | f"{aggregate.truncated_count} | "
|
| 634 | f"{fmt_num(aggregate.avg_tool_calls, 2)} | "
|
| 635 | f"{vs_git} | "
|
| 636 | f"{vs_git_tokens} | "
|
| 637 | f"{vs_git_tools} | "
|
| 638 | f"{vs_oak} | "
|
| 639 | f"{vs_oak_tokens} | "
|
| 640 | f"{vs_oak_tools} |"
|
| 641 | )
|
| 642 |
|
| 643 | return "\n".join(lines) + "\n", bool(regressions)
|
| 644 |
|
| 645 |
|
| 646 | def format_set(values: set[str]) -> str:
|
| 647 | if not values:
|
| 648 | return "`none`"
|
| 649 | return ", ".join(f"`{value}`" for value in sorted(values))
|
| 650 |
|
| 651 |
|
| 652 |
|
| 653 | def weighted_subject_average(
|
| 654 | aggregates: dict[tuple[str, str, str], Aggregate],
|
| 655 | subject: str,
|
| 656 | attr: str,
|
| 657 | ) -> Optional[float]:
|
| 658 | total_weight = 0
|
| 659 | total_value = 0.0
|
| 660 | for aggregate in aggregates.values():
|
| 661 | if aggregate.subject != subject:
|
| 662 | continue
|
| 663 | value = getattr(aggregate, attr)
|
| 664 | if value is None:
|
| 665 | continue
|
| 666 | weight = max(aggregate.total_count, 1)
|
| 667 | total_weight += weight
|
| 668 | total_value += float(value) * weight
|
| 669 | if total_weight == 0:
|
| 670 | return None
|
| 671 | return total_value / total_weight
|
| 672 |
|
| 673 |
|
| 674 | def main() -> int:
|
| 675 | args = parse_args()
|
| 676 | report, has_regressions = markdown_report(args)
|
| 677 | sys.stdout.write(report)
|
| 678 | if has_regressions and args.fail_on_regression:
|
| 679 | return 1
|
| 680 | return 0
|
| 681 |
|
| 682 |
|
| 683 | if __name__ == "__main__":
|
| 684 | raise SystemExit(main())
|