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