Log in
scripts/row_parity.py 194 lines · 7.3 KB · blame Source
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1
#!/usr/bin/env python3
2
"""Prove a harness change is measurement-identical (ADR-0005).
3
4
Compares two result JSONL files row-by-row on the fields that must not move
5
when only harness code changed: the commands that ran, the operations
6
recorded, tool-call accounting, and agent-emitted token estimates (command
7
text is deterministic; output tokens and timings legitimately vary run to
8
run). Coverage changes (operations present in only one file) are reported
9
separately from drift.
10
11
This codifies the verification used for the oakbench extraction: before
12
trusting a refactor, run the same lane before and after and require zero
13
invariant drift.
14
15
Usage:
16
17
    python3 scripts/row_parity.py old/latest.jsonl new/latest.jsonl
18
    python3 scripts/row_parity.py old.jsonl new.jsonl --strict-coverage
19
20
Exit codes: 0 parity, 1 invariant drift (or coverage change with
21
--strict-coverage), 2 usage/input error.
22
"""
23
24
from __future__ import annotations
25
26
import argparse
27
import json
28
import re
29
from pathlib import Path
30
from typing import Any
31
32
# Run-timestamp directory segments (workdirs are keyed by run id, e.g.
33
# runs/20260611T194547Z/...) legitimately differ between the two runs being
34
# compared; commands embedding such paths (clone destinations, bare remotes)
35
# are normalized before comparison so the run id itself never reads as drift.
36
RUN_TS_PATTERN = re.compile(r"\d{8}T\d{6}Z")
37
# Disposable remote branch names (oakbench.remotes.disposable_branch) end in
38
# per-run pid/token uniquifiers that must never collide on the server β€” and
39
# therefore never match across two runs either.
40
BENCH_BRANCH_PATTERN = re.compile(r"bench-[\w.-]+-r\d+-[0-9a-f]+-[0-9a-f]+")
41
42
43
def normalize_value(value: Any) -> Any:
44
    if isinstance(value, str):
45
        value = RUN_TS_PATTERN.sub("<RUN_TS>", value)
46
        return BENCH_BRANCH_PATTERN.sub("<BENCH_BRANCH>", value)
47
    if isinstance(value, list):
48
        return [normalize_value(item) for item in value]
49
    return value
50
51
# Fields that must be identical for the same row identity when only harness
52
# code changed. Timings, output bytes, and output-derived token estimates are
53
# deliberately NOT here: they vary run to run.
54
INVARIANT_FIELDS = (
55
    "command",
56
    "subject_kind",
57
    "benchmark_track",
58
    "command_semantics_version",
59
    "git_mode",
60
    "step_kind",
61
    "phase",
62
    "expected_returncodes",
63
    "tool_call_count",
64
    "vcs_tool_call_count",
65
    "terminal_tool_call_count",
66
    "test_tool_call_count",
67
    "estimated_tokens_input",
68
    "estimated_tokens_agent_emitted",
69
)
70
71
# Fields where drift is reported as a warning, not a failure: deterministic in
72
# most scenarios but legitimately variable in some (e.g. retry-dependent).
73
WARN_FIELDS = ("returncode", "process_returncode")
74
75
IDENTITY_FIELDS = ("scenario", "subject", "operation", "run", "phase", "workflow", "worker", "task_id")
76
77
78
def parse_args() -> argparse.Namespace:
79
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
80
    parser.add_argument("old", type=Path, help="Result JSONL from before the change.")
81
    parser.add_argument("new", type=Path, help="Result JSONL from after the change.")
82
    parser.add_argument(
83
        "--strict-coverage",
84
        action="store_true",
85
        help="Also fail when row identities appear in only one file (default: report only).",
86
    )
87
    parser.add_argument(
88
        "--ignore-fields",
89
        default="",
90
        help="Comma-separated invariant fields to skip, e.g. when a change intentionally moves them.",
91
    )
92
    parser.add_argument("--limit", type=int, default=30, help="Max differences to print per section.")
93
    return parser.parse_args()
94
95
96
def load_rows(path: Path) -> list[dict[str, Any]]:
97
    rows: list[dict[str, Any]] = []
98
    for line_number, line in enumerate(path.read_text().splitlines(), 1):
99
        if not line.strip():
100
            continue
101
        try:
102
            row = json.loads(line)
103
        except json.JSONDecodeError as exc:
104
            raise SystemExit(f"{path}:{line_number}: invalid JSON: {exc}")
105
        if isinstance(row, dict):
106
            rows.append(row)
107
    return rows
108
109
110
def identity(row: dict[str, Any]) -> tuple:
111
    return tuple(json.dumps(row.get(field), sort_keys=True) for field in IDENTITY_FIELDS)
112
113
114
def identity_display(row: dict[str, Any]) -> str:
115
    parts = [f"{field}={row.get(field)}" for field in IDENTITY_FIELDS if row.get(field) is not None]
116
    return " ".join(parts)
117
118
119
def index_rows(rows: list[dict[str, Any]], label: str) -> dict[tuple, dict[str, Any]]:
120
    indexed: dict[tuple, dict[str, Any]] = {}
121
    duplicates = 0
122
    for row in rows:
123
        key = identity(row)
124
        if key in indexed:
125
            duplicates += 1
126
        indexed[key] = row
127
    if duplicates:
128
        print(f"note: {label} contains {duplicates} duplicate row identities; last occurrence wins")
129
    return indexed
130
131
132
def main() -> int:
133
    args = parse_args()
134
    for path in (args.old, args.new):
135
        if not path.exists():
136
            print(f"input not found: {path}")
137
            return 2
138
    ignored = {item.strip() for item in args.ignore_fields.split(",") if item.strip()}
139
    fields = [field for field in INVARIANT_FIELDS if field not in ignored]
140
141
    old_rows = index_rows(load_rows(args.old), "old")
142
    new_rows = index_rows(load_rows(args.new), "new")
143
144
    shared = [key for key in old_rows if key in new_rows]
145
    only_old = [key for key in old_rows if key not in new_rows]
146
    only_new = [key for key in new_rows if key not in old_rows]
147
148
    drift: list[str] = []
149
    warnings: list[str] = []
150
    for key in shared:
151
        old_row, new_row = old_rows[key], new_rows[key]
152
        for field in fields:
153
            if field in old_row or field in new_row:
154
                if normalize_value(old_row.get(field)) != normalize_value(new_row.get(field)):
155
                    drift.append(
156
                        f"{identity_display(old_row)} :: {field}: {old_row.get(field)!r} -> {new_row.get(field)!r}"
157
                    )
158
        for field in WARN_FIELDS:
159
            if field in old_row and old_row.get(field) != new_row.get(field):
160
                warnings.append(
161
                    f"{identity_display(old_row)} :: {field}: {old_row.get(field)!r} -> {new_row.get(field)!r}"
162
                )
163
164
    print("# Row Parity Report")
165
    print()
166
    print(f"- Old: `{args.old}` ({len(old_rows)} unique row identities)")
167
    print(f"- New: `{args.new}` ({len(new_rows)} unique row identities)")
168
    print(f"- Shared identities compared: {len(shared)}")
169
    print(f"- Invariant fields checked: {', '.join(fields)}")
170
    print()
171
172
    def section(title: str, items: list[str]) -> None:
173
        print(f"## {title} ({len(items)})")
174
        print()
175
        for item in items[: args.limit]:
176
            print(f"- {item}")
177
        if len(items) > args.limit:
178
            print(f"- ... and {len(items) - args.limit} more")
179
        if not items:
180
            print("- none")
181
        print()
182
183
    section("Invariant drift (FAIL)", drift)
184
    section("Returncode drift (warning)", warnings)
185
    section("Coverage only in old", [identity_display(old_rows[k]) for k in only_old])
186
    section("Coverage only in new", [identity_display(new_rows[k]) for k in only_new])
187
188
    failed = bool(drift) or (args.strict_coverage and (only_old or only_new))
189
    print(f"VERDICT: {'DRIFT' if failed else 'PARITY'}")
190
    return 1 if failed else 0
191
192
193
if __name__ == "__main__":
194
    raise SystemExit(main())