Log in
scripts/result_delta_report.py 216 lines · 6.9 KB · python Blame
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())