88e86b3faccd
Rebuild benchmarks as a clean single-root reposi
2 months ago
| 1 | #!/usr/bin/env python3
|
| 2 | """Summarize the biggest metric changes between two benchmark JSONL files.
|
| 3 |
|
| 4 | The report is intentionally compact: it compares matching row identities,
|
| 5 | aggregates metric values per subject/scenario/operation group, and prints the
|
| 6 | largest improvements first. Positive percentage changes mean the new file is
|
| 7 | better for lower-is-better metrics and worse for higher-is-better metrics.
|
| 8 | """
|
| 9 |
|
| 10 | from __future__ import annotations
|
| 11 |
|
| 12 | import argparse
|
| 13 | import json
|
| 14 | import math
|
| 15 | import statistics
|
| 16 | from pathlib import Path
|
| 17 | from typing import Any
|
| 18 |
|
| 19 |
|
| 20 | IDENTITY_FIELDS = (
|
| 21 | "lane",
|
| 22 | "profile",
|
| 23 | "scenario",
|
| 24 | "subject",
|
| 25 | "subject_kind",
|
| 26 | "benchmark_track",
|
| 27 | "instruction_level",
|
| 28 | "agent_environment",
|
| 29 | "transport",
|
| 30 | "remote_transport",
|
| 31 | "git_mode",
|
| 32 | "mode",
|
| 33 | "contention_mode",
|
| 34 | "workers",
|
| 35 | "platform",
|
| 36 | "driver",
|
| 37 | "protection_variant",
|
| 38 | "platform_comparison_key",
|
| 39 | "operation",
|
| 40 | "run",
|
| 41 | "phase",
|
| 42 | "workflow",
|
| 43 | "worker",
|
| 44 | "task_id",
|
| 45 | )
|
| 46 | DEFAULT_METRICS = (
|
| 47 | "elapsed_ms",
|
| 48 | "estimated_tokens_total",
|
| 49 | "estimated_tokens_input",
|
| 50 | "estimated_tokens_output",
|
| 51 | "estimated_cost_weighted_tokens",
|
| 52 | "raw_output_bytes",
|
| 53 | "probe_output_bytes",
|
| 54 | "tool_call_count",
|
| 55 | "vcs_tool_call_count",
|
| 56 | "terminal_tool_call_count",
|
| 57 | "peak_rss_bytes",
|
| 58 | )
|
| 59 | HIGHER_IS_BETTER = {
|
| 60 | "information_recall",
|
| 61 | "information_precision",
|
| 62 | "triage_recall",
|
| 63 | "triage_precision",
|
| 64 | "json_parse_success",
|
| 65 | "json_oracle_passed",
|
| 66 | "output_stable",
|
| 67 | }
|
| 68 |
|
| 69 |
|
| 70 | def parse_args() -> argparse.Namespace:
|
| 71 | parser = argparse.ArgumentParser(description=__doc__)
|
| 72 | parser.add_argument("old", type=Path, help="Baseline JSONL")
|
| 73 | parser.add_argument("new", type=Path, help="Candidate JSONL")
|
| 74 | parser.add_argument("--limit", type=int, default=10, help="Maximum rows to print")
|
| 75 | parser.add_argument(
|
| 76 | "--metrics",
|
| 77 | default=",".join(DEFAULT_METRICS),
|
| 78 | help="Comma-separated metric fields to compare",
|
| 79 | )
|
| 80 | return parser.parse_args()
|
| 81 |
|
| 82 |
|
| 83 | def load_rows(path: Path) -> list[dict[str, Any]]:
|
| 84 | rows: list[dict[str, Any]] = []
|
| 85 | for line_number, line in enumerate(path.read_text().splitlines(), 1):
|
| 86 | if not line.strip():
|
| 87 | continue
|
| 88 | try:
|
| 89 | row = json.loads(line)
|
| 90 | except json.JSONDecodeError as exc:
|
| 91 | raise SystemExit(f"{path}:{line_number}: invalid JSON: {exc}") from exc
|
| 92 | if isinstance(row, dict):
|
| 93 | rows.append(row)
|
| 94 | return rows
|
| 95 |
|
| 96 |
|
| 97 | def identity_fields_for(old_rows: list[dict[str, Any]], new_rows: list[dict[str, Any]]) -> tuple[str, ...]:
|
| 98 | old_fields = {key for row in old_rows for key in row}
|
| 99 | new_fields = {key for row in new_rows for key in row}
|
| 100 | fields = tuple(field for field in IDENTITY_FIELDS if field in old_fields and field in new_fields)
|
| 101 | return fields or ("scenario", "subject", "operation", "run")
|
| 102 |
|
| 103 |
|
| 104 | def identity(row: dict[str, Any], fields: tuple[str, ...]) -> tuple[str, ...]:
|
| 105 | return tuple(json.dumps(row.get(field), sort_keys=True) for field in fields)
|
| 106 |
|
| 107 |
|
| 108 | def identity_display(row: dict[str, Any], fields: tuple[str, ...]) -> str:
|
| 109 | parts = [f"{field}={row.get(field)}" for field in fields if row.get(field) is not None]
|
| 110 | return " ".join(parts)
|
| 111 |
|
| 112 |
|
| 113 | def index_rows(rows: list[dict[str, Any]], fields: tuple[str, ...]) -> dict[tuple[str, ...], dict[str, Any]]:
|
| 114 | indexed: dict[tuple[str, ...], dict[str, Any]] = {}
|
| 115 | for row in rows:
|
| 116 | indexed[identity(row, fields)] = row
|
| 117 | return indexed
|
| 118 |
|
| 119 |
|
| 120 | def metric_value(row: dict[str, Any], metric: str) -> float | None:
|
| 121 | value = row.get(metric)
|
| 122 | if value is None or isinstance(value, bool):
|
| 123 | return None
|
| 124 | try:
|
| 125 | parsed = float(value)
|
| 126 | except (TypeError, ValueError):
|
| 127 | return None
|
| 128 | return parsed if math.isfinite(parsed) else None
|
| 129 |
|
| 130 |
|
| 131 | def improvement_pct(metric: str, old: float, new: float) -> float | None:
|
| 132 | if old == 0:
|
| 133 | return None
|
| 134 | if metric in HIGHER_IS_BETTER:
|
| 135 | return ((new - old) / abs(old)) * 100.0
|
| 136 | return ((old - new) / abs(old)) * 100.0
|
| 137 |
|
| 138 |
|
| 139 | def main() -> int:
|
| 140 | args = parse_args()
|
| 141 | old_path = args.old
|
| 142 | new_path = args.new
|
| 143 | for path in (old_path, new_path):
|
| 144 | if not path.exists():
|
| 145 | raise SystemExit(f"input not found: {path}")
|
| 146 |
|
| 147 | metrics = [item.strip() for item in args.metrics.split(",") if item.strip()]
|
| 148 | old_raw = load_rows(old_path)
|
| 149 | new_raw = load_rows(new_path)
|
| 150 | identity_fields = identity_fields_for(old_raw, new_raw)
|
| 151 | old_rows = index_rows(old_raw, identity_fields)
|
| 152 | new_rows = index_rows(new_raw, identity_fields)
|
| 153 |
|
| 154 | shared = [key for key in old_rows if key in new_rows]
|
| 155 | changes: list[dict[str, Any]] = []
|
| 156 | for key in shared:
|
| 157 | old_row = old_rows[key]
|
| 158 | new_row = new_rows[key]
|
| 159 | for metric in metrics:
|
| 160 | old_value = metric_value(old_row, metric)
|
| 161 | new_value = metric_value(new_row, metric)
|
| 162 | if old_value is None or new_value is None:
|
| 163 | continue
|
| 164 | pct = improvement_pct(metric, old_value, new_value)
|
| 165 | if pct is None:
|
| 166 | continue
|
| 167 | delta = new_value - old_value
|
| 168 | if delta == 0:
|
| 169 | continue
|
| 170 | changes.append(
|
| 171 | {
|
| 172 | "pct": pct,
|
| 173 | "delta": delta,
|
| 174 | "metric": metric,
|
| 175 | "identity": identity_display(old_row, identity_fields),
|
| 176 | "old": old_value,
|
| 177 | "new": new_value,
|
| 178 | }
|
| 179 | )
|
| 180 |
|
| 181 | improvements = sorted([item for item in changes if item["pct"] > 0], key=lambda item: item["pct"], reverse=True)
|
| 182 | regressions = sorted([item for item in changes if item["pct"] < 0], key=lambda item: item["pct"])
|
| 183 |
|
| 184 | print("# Result Delta Report")
|
| 185 | print()
|
| 186 | print(f"- Old: `{old_path}`")
|
| 187 | print(f"- New: `{new_path}`")
|
| 188 | print(f"- Shared row identities: {len(shared)}")
|
| 189 | print(f"- Identity fields: {', '.join(identity_fields)}")
|
| 190 | print(f"- Metrics compared: {', '.join(metrics)}")
|
| 191 | print()
|
| 192 | if old_raw and new_raw and not shared:
|
| 193 | print("No shared row identities; inputs may use incompatible scenario/subject/operation dimensions.")
|
| 194 | return 1
|
| 195 |
|
| 196 | def section(title: str, rows: list[dict[str, Any]]) -> None:
|
| 197 | print(f"## {title} ({len(rows)})")
|
| 198 | print()
|
| 199 | print("| Rank | Metric | Identity | Old | New | Delta | Improvement |")
|
| 200 | print("| --- | --- | --- | ---: | ---: | ---: | ---: |")
|
| 201 | for index, item in enumerate(rows[: args.limit], 1):
|
| 202 | print(
|
| 203 | f"| {index} | `{item['metric']}` | {item['identity']} | "
|
| 204 | f"{item['old']:.3f} | {item['new']:.3f} | {item['delta']:+.3f} | {item['pct']:+.1f}% |"
|
| 205 | )
|
| 206 | if not rows:
|
| 207 | print("| - | none | none | | | | |")
|
| 208 | print()
|
| 209 |
|
| 210 | section("Top Improvements", improvements)
|
| 211 | section("Top Regressions", regressions)
|
| 212 | return 0
|
| 213 |
|
| 214 |
|
| 215 | if __name__ == "__main__":
|
| 216 | raise SystemExit(main())
|