Log in
scripts/bench.py 2099 lines · 82.2 KB · blame Source
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1
#!/usr/bin/env python3
2
"""Benchmark Git and Oak on agent-shaped repository workloads.
3
4
The harness is intentionally dependency-free. It generates deterministic
5
fixtures, copies each fixture per subject, times only VCS commands, and writes
6
JSONL plus a Markdown summary.
7
"""
8
9
from __future__ import annotations
10
11
import argparse
12
import fnmatch
13
import functools
14
import hashlib
15
import json
16
import math
17
import os
18
import platform
19
import random
20
import re
21
import secrets
22
import shutil
23
import statistics
24
import subprocess
25
import tempfile
26
import time
27
from dataclasses import dataclass
28
from datetime import datetime, timezone
29
from pathlib import Path
30
from typing import Any
31
32
from oakbench import environment as oakbench_environment
33
from oakbench import envwatch as oakbench_envwatch
34
from oakbench import fixtures as oakbench_fixtures
35
from oakbench import loadgen as oakbench_loadgen
36
from oakbench import integrity as oakbench_integrity
37
from oakbench import output_semantics as oakbench_semantics
38
from oakbench import remotes as oakbench_remotes
39
from oakbench import tokens as oakbench_tokens
40
from oakbench.tokens import COST_WEIGHTS_NOTE
41
from oakbench.command_semantics import semantics
42
from oakbench.environment import command_display
43
from oakbench.execution import PEAK_RSS_SOURCE, run_timed
44
from oakbench.reporting import delta_pct, fmt_delta_lower_better, fmt_num
45
from oakbench.results import ResultsStore
46
from oakbench.rows import row_returncode
47
from oakbench.runner import runner_fields
48
from oakbench.runlock import measurement_lock
49
from oakbench.subjects import (
50
    DEFAULT_OAK_REPO,
51
    Subject,
52
    binary_sha256,
53
    load_subjects,
54
    source_metadata,
55
    subject_details,
56
    subject_version,
57
)
58
59
ROOT = Path(__file__).resolve().parents[1]
60
DEFAULT_WORKDIR = Path(tempfile.gettempdir()) / "oak-bench-work"
61
DEFAULT_ADMITTED_OUTPUT_CHARS = 20_000
62
JSON_PROBE_MAX_BYTES = 64 * 1024 * 1024
63
COMMAND_SEMANTICS_VERSION = semantics().version
64
TRACKS = ("agent-default", "core-equivalent")
65
GIT_MODES_CONFIG = ROOT / "config" / "git_modes.json"
66
BENCH_TASK_BRANCH = "agent-task"
67
68
# Tuned Git modes. Setup commands run after repo.init as recorded
69
# mode.setup.<step> operation rows (excluded from cross-op averages but never
70
# absorbed), so the timed operations measure the tuned behavior while the
71
# setup cost stays visible. LFS is fixture-aware: it only applies to scenarios
72
# listed in config/git_modes.json applies_to and tracks the generated *.bin
73
# fixture files before the first snapshot. Each entry is (operation suffix,
74
# git args): the suffix names the mode.setup.<suffix> row, so multi-step
75
# setups (lfs install + lfs track) record one row per step exactly like the
76
# single-step fsmonitor mode records mode.setup.fsmonitor.
77
LFS_TRACK_PATTERNS: tuple[str, ...] = ("*.bin",)
78
WIRED_GIT_MODES: dict[str, list[tuple[str, list[str]]]] = {
79
    "untracked_cache": [("untracked_cache", ["update-index", "--untracked-cache"])],
80
    "split_index": [("split_index", ["update-index", "--split-index"])],
81
    "fsmonitor": [("fsmonitor", ["config", "core.fsmonitor", "true"])],
82
    "lfs": [
83
        ("lfs_install", ["lfs", "install", "--local"]),
84
        ("lfs_track", ["lfs", "track", *LFS_TRACK_PATTERNS]),
85
    ],
86
}
87
88
89
def base_env() -> dict[str, str]:
90
    return oakbench_environment.base_env(
91
        author_name="Oak Bench",
92
        author_email="[email protected]",
93
        oak_author="oakbench",
94
    )
95
96
97
def probe_env() -> dict[str, str]:
98
    env = base_env()
99
    env.pop("NO_COLOR", None)
100
    env.pop("CLICOLOR", None)
101
    return env
102
103
104
def subject_provenance(subject: Subject, metadata: dict[str, Any]) -> dict[str, Any]:
105
    """Per-row binary identity for the subject that executed the command."""
106
    details = metadata.get("subject_details")
107
    digest: str | None = None
108
    if isinstance(details, dict):
109
        item = details.get(subject.name)
110
        if isinstance(item, dict):
111
            value = item.get("binary_sha256")
112
            digest = value if isinstance(value, str) else None
113
    return {"binary_sha256": digest or binary_sha256(subject.bin)}
114
115
116
@dataclass(frozen=True)
117
class Scenario:
118
    name: str
119
    file_count: int
120
    file_size: int
121
    dirty_count: int
122
    binary: bool
123
    runs: int
124
125
126
PROFILES: dict[str, list[Scenario]] = {
127
    # micro: noise-floor probes (devloop A/A), integration tests, and the
128
    # fastest possible regression signal. Same tiny_text shape as smoke
129
    # (identical scenario name = identical meaning, ADR-0005), more reps.
130
    "micro": [
131
        Scenario("tiny_text", 24, 1024, 4, False, 3),
132
    ],
133
    "smoke": [
134
        Scenario("tiny_text", 24, 1024, 4, False, 1),
135
        Scenario("single_medium_binary", 1, 8 * 1024 * 1024, 1, True, 1),
136
        Scenario("few_large_binaries", 4, 4 * 1024 * 1024, 2, True, 1),
137
    ],
138
    "standard": [
139
        # NOT tiny_text: 100 files is a different shape, and a different shape
140
        # must be a different scenario name (ADR-0005).
141
        Scenario("tiny_text_100", 100, 1024, 12, False, 3),
142
        Scenario("many_small_files", 5_000, 512, 500, False, 3),
143
        Scenario("wide_dirty_tree", 2_000, 1024, 1_000, False, 3),
144
        Scenario("single_large_binary", 1, 128 * 1024 * 1024, 1, True, 3),
145
        Scenario("many_large_binaries", 32, 8 * 1024 * 1024, 8, True, 3),
146
    ],
147
    "large": [
148
        Scenario("many_small_files_50k", 50_000, 256, 5_000, False, 5),
149
        Scenario("single_huge_binary_1gb", 1, 1024 * 1024 * 1024, 1, True, 5),
150
        Scenario("many_large_binaries_multigb", 128, 32 * 1024 * 1024, 32, True, 5),
151
    ],
152
}
153
GIT_HUGE_BINARY_DIFF_LIMIT_BYTES = 1024 * 1024 * 1024
154
GIT_HUGE_BINARY_DIFF_OPS = {
155
    "diff.dirty",
156
    "diff.dirty.determinism",
157
    "diff.dirty.inforecall",
158
    "diff.full.inforecall",
159
}
160
161
162
def parse_args() -> argparse.Namespace:
163
    parser = argparse.ArgumentParser(description=__doc__)
164
    parser.add_argument("--profile", choices=sorted(PROFILES), default="smoke")
165
    parser.add_argument("--subjects", help="Comma-separated subject names")
166
    parser.add_argument("--config", type=Path, default=ROOT / "config" / "subjects.toml")
167
    parser.add_argument("--workdir", type=Path, default=DEFAULT_WORKDIR)
168
    parser.add_argument("--results", type=Path, default=ROOT / "results")
169
    parser.add_argument(
170
        "--oak-repo",
171
        type=Path,
172
        default=DEFAULT_OAK_REPO,
173
        help="Optional Oak source checkout for provenance metadata. Defaults to $OAK_REPO or ../oak.",
174
    )
175
    parser.add_argument("--keep-workdirs", action="store_true")
176
    parser.add_argument("--git-bin", type=Path)
177
    parser.add_argument("--oak-installed-bin", type=Path)
178
    parser.add_argument("--oak-local-bin", type=Path)
179
    parser.add_argument("--runs", type=int, help="Override scenario run count")
180
    parser.add_argument("--skip-diff", action="store_true", help="Skip dirty diff timing")
181
    parser.add_argument(
182
        "--track",
183
        choices=TRACKS,
184
        default="agent-default",
185
        help=(
186
            "agent-default measures commands an agent would likely call today; "
187
            "core-equivalent prefers matching semantic output levels across VCSs."
188
        ),
189
    )
190
    parser.add_argument(
191
        "--randomize-subject-order",
192
        action="store_true",
193
        help="Shuffle subject order per scenario run to reduce cache/order bias.",
194
    )
195
    parser.add_argument(
196
        "--admitted-output-chars",
197
        type=int,
198
        default=DEFAULT_ADMITTED_OUTPUT_CHARS,
199
        help=(
200
            "Per-command stdout/stderr characters counted as agent-visible output "
201
            "for token estimates. Full output is not stored in JSONL."
202
        ),
203
    )
204
    parser.add_argument(
205
        "--git-modes",
206
        default="",
207
        help=(
208
            "Comma-separated tuned Git modes to run as extra subjects: "
209
            + ", ".join(sorted(WIRED_GIT_MODES))
210
            + ". Mode setup runs untimed after repo.init. Required for credible "
211
            "Oak-vs-Git status/diff claims; stock Git alone overstates Oak wins."
212
        ),
213
    )
214
    parser.add_argument(
215
        "--skip-determinism-probe",
216
        action="store_true",
217
        help="Skip the output byte-stability and ANSI-pollution probe rows.",
218
    )
219
    parser.add_argument(
220
        "--skip-remote",
221
        action="store_true",
222
        help=(
223
            "Skip remote.push.first / remote.clone.cold / remote.pull.uptodate rows. "
224
            "Clone duplicates the repo on disk; consider skipping for the large profile."
225
        ),
226
    )
227
    parser.add_argument(
228
        "--load-tier",
229
        choices=sorted(oakbench_loadgen.LOAD_TIERS),
230
        default="none",
231
        help=(
232
            "Inject a machine-independent stress-ng background load tier while "
233
            "measuring (noisy-neighbor realism). Requires the optional stress-ng "
234
            "binary; when it is missing under a non-none tier every row becomes "
235
            "an explicit skip row β€” load that was not applied is never claimed."
236
        ),
237
    )
238
    parser.add_argument(
239
        "--dirty-spectrum",
240
        action="store_true",
241
        help=(
242
            "Additionally measure status.dirty.p01/.p10/.p50 (and diff.dirty.pNN unless "
243
            "--skip-diff): pNN dirties NN%% of tracked files (deterministic seeded "
244
            "selection, fixed appended line, untimed) before the timed command, then "
245
            "restores the tree byte-identically. Off by default; without this flag the "
246
            "operation mix and every existing row are unchanged."
247
        ),
248
    )
249
    return parser.parse_args()
250
251
252
def expand_git_modes(subjects: list[Subject], raw_modes: str) -> list[Subject]:
253
    requested = [item.strip() for item in raw_modes.split(",") if item.strip()]
254
    if not requested:
255
        return subjects
256
    normalized = [mode[len("git_") :] if mode.startswith("git_") else mode for mode in requested]
257
    unknown = [mode for mode in normalized if mode not in WIRED_GIT_MODES]
258
    if unknown:
259
        raise SystemExit(
260
            "Unknown or unwired git modes: "
261
            + ", ".join(unknown)
262
            + ". Wired modes: "
263
            + ", ".join(sorted(WIRED_GIT_MODES))
264
            + ". Parameterized modes (sparse-checkout, worktree) live in config/git_modes.json "
265
            "and need fixture-specific wiring."
266
        )
267
    base_git = next((subject for subject in subjects if subject.kind == "git" and subject.git_mode is None), None)
268
    if base_git is None:
269
        raise SystemExit("--git-modes requires a stock git subject to derive tuned variants from")
270
    tuned = [
271
        Subject(
272
            name=f"{base_git.name}_{mode}",
273
            kind="git",
274
            label=f"{base_git.label} ({mode.replace('_', '-')})",
275
            bin=base_git.bin,
276
            git_mode=mode,
277
        )
278
        for mode in normalized
279
    ]
280
    return subjects + tuned
281
282
283
def git_lfs_available(git_bin: Path) -> bool:
284
    try:
285
        proc = subprocess.run(
286
            [str(git_bin), "lfs", "version"],
287
            env=base_env(),
288
            stdout=subprocess.DEVNULL,
289
            stderr=subprocess.DEVNULL,
290
            timeout=10,
291
            check=False,
292
        )
293
    except (OSError, subprocess.TimeoutExpired):
294
        return False
295
    return proc.returncode == 0
296
297
298
@functools.lru_cache(maxsize=1)
299
def git_modes_config() -> dict[str, dict[str, Any]]:
300
    """config/git_modes.json modes table β€” the single source of truth for
301
    where a tuned mode applies (and for its fairness notes)."""
302
    return json.loads(GIT_MODES_CONFIG.read_text())["modes"]
303
304
305
@functools.lru_cache(maxsize=1)
306
def known_scenario_names() -> frozenset[str]:
307
    return frozenset(scenario.name for profile in PROFILES.values() for scenario in profile)
308
309
310
def git_mode_applies_to_scenario(mode: str | None, scenario: Scenario) -> tuple[bool, str | None]:
311
    """Decide from config/git_modes.json whether a tuned mode measures this scenario.
312
313
    A mode's applies_to list is enforced when its entries name concrete
314
    scenarios (the git_lfs list does): scenarios outside the list emit
315
    scenario.mode_skipped rows instead of running under the tuned label.
316
    Some modes' applies_to entries are fixture-class tags ("large-tree",
317
    "huge-tree", ...) that name no scenario; until those tags are mapped to
318
    scenario names they keep the legacy applies-everywhere behavior. The
319
    binary-flag check stays as the fallback for an lfs mode entry without an
320
    applies_to list.
321
    """
322
    if not mode:
323
        return True, None
324
    spec = git_modes_config().get(f"git_{mode}", {})
325
    applies_to = spec.get("applies_to") or []
326
    if "all" in applies_to:
327
        return True, None
328
    if applies_to and (scenario.name in applies_to or known_scenario_names() & set(applies_to)):
329
        if scenario.name in applies_to:
330
            return True, None
331
        return False, (
332
            f"git_{mode}_not_applicable: scenario {scenario.name} is not in the "
333
            f"config/git_modes.json applies_to list for git_{mode}"
334
        )
335
    if mode == "lfs" and not scenario.binary:
336
        return False, "git_lfs_not_applicable: scenario has no binary fixture files"
337
    return True, None
338
339
340
def lfs_untracked_extensions(repo: Path, patterns: tuple[str, ...] = LFS_TRACK_PATTERNS) -> list[str]:
341
    """Extensions of fixture payload files no LFS track pattern covers.
342
343
    Called only for binary scenarios, where every generated payload file is
344
    binary content (ensure_fixture writes a single extension per scenario
345
    today, so with *.bin tracked this passes; the guard exists for future
346
    fixtures that add other extensions). Sorted so the skip reason is stable.
347
    """
348
    missing: set[str] = set()
349
    for path in repo.rglob("*"):
350
        if not path.is_file():
351
            continue
352
        rel = path.relative_to(repo)
353
        if rel.parts[0] == ".git" or rel.name == ".gitattributes":
354
            continue
355
        if any(fnmatch.fnmatch(rel.name, pattern) for pattern in patterns):
356
            continue
357
        missing.add(rel.suffix.lstrip(".") or rel.name)
358
    return sorted(missing)
359
360
361
def interaction_metrics(
362
    command: Any,
363
    stdout_text: str,
364
    stderr_text: str,
365
    stdout_bytes: int,
366
    stderr_bytes: int,
367
    stdout_truncated: bool,
368
    stderr_truncated: bool,
369
    tool_call_count: int,
370
) -> dict[str, Any]:
371
    return {
372
        "tool_call_count": tool_call_count,
373
        "vcs_tool_call_count": tool_call_count,
374
        "terminal_tool_call_count": tool_call_count,
375
        **oakbench_tokens.interaction_token_fields(
376
            command_display(command),
377
            stdout_text,
378
            stderr_text,
379
            stdout_bytes,
380
            stderr_bytes,
381
            stdout_truncated,
382
            stderr_truncated,
383
            tool_call_count=tool_call_count,
384
        ),
385
        "tool_calls": {
386
            "total": tool_call_count,
387
            "vcs": tool_call_count,
388
            "terminal": tool_call_count,
389
        },
390
    }
391
392
393
def merge_interaction_metrics(command: Any, rows: list[dict[str, Any]]) -> dict[str, Any]:
394
    tool_calls = sum(oakbench_tokens.int_or_zero(row.get("tool_call_count")) for row in rows)
395
    return {
396
        "tool_call_count": tool_calls,
397
        "vcs_tool_call_count": tool_calls,
398
        "terminal_tool_call_count": tool_calls,
399
        **oakbench_tokens.summed_token_fields(
400
            rows,
401
            "sum_of_subcommands_command_plus_admitted_output_chars_div_4",
402
            command_chars=len(command_display(command)),
403
        ),
404
        "tool_calls": {
405
            "total": tool_calls,
406
            "vcs": tool_calls,
407
            "terminal": tool_calls,
408
        },
409
    }
410
411
412
def hash_and_count_ansi(path_handle: Any) -> tuple[str, int, int]:
413
    path_handle.flush()
414
    path_handle.seek(0)
415
    digest = hashlib.sha256()
416
    ansi_bytes = 0
417
    total = 0
418
    for chunk in iter(lambda: path_handle.read(1024 * 1024), b""):
419
        digest.update(chunk)
420
        ansi_bytes += chunk.count(b"\x1b")
421
        total += len(chunk)
422
    return digest.hexdigest(), ansi_bytes, total
423
424
425
def tail_text_from_handle(path_handle: Any, max_bytes: int = 4000) -> str:
426
    path_handle.flush()
427
    path_handle.seek(0, os.SEEK_END)
428
    size = path_handle.tell()
429
    path_handle.seek(max(0, size - max_bytes))
430
    return path_handle.read(max_bytes).decode("utf-8", "replace")
431
432
433
def run_determinism_probe(
434
    subject: Subject,
435
    scenario: Scenario,
436
    base_op: str,
437
    cwd: Path,
438
    command: list[str],
439
    run_index: int,
440
    metadata: dict[str, Any],
441
) -> dict[str, Any]:
442
    """Run the same command twice on identical state.
443
444
    Byte-identical output for identical state is what makes provider prompt
445
    caches hit; instability (timestamps, ordering, progress output) silently
446
    raises token cost for every downstream agent.
447
    """
448
    hashes: list[str] = []
449
    ansi_total = 0
450
    output_bytes = 0
451
    elapsed: list[float] = []
452
    returncode = 0
453
    stdout_excerpt = ""
454
    stderr_excerpt = ""
455
    for _ in range(2):
456
        start = time.perf_counter()
457
        with tempfile.TemporaryFile() as stdout_fh, tempfile.TemporaryFile() as stderr_fh:
458
            proc = subprocess.run(
459
                command,
460
                cwd=cwd,
461
                env=probe_env(),
462
                stdout=stdout_fh,
463
                stderr=stderr_fh,
464
                check=False,
465
            )
466
            elapsed.append((time.perf_counter() - start) * 1000)
467
            stdout_hash, stdout_ansi, stdout_total = hash_and_count_ansi(stdout_fh)
468
            stderr_hash, stderr_ansi, stderr_total = hash_and_count_ansi(stderr_fh)
469
            if proc.returncode != 0:
470
                stdout_excerpt = tail_text_from_handle(stdout_fh)
471
                stderr_excerpt = tail_text_from_handle(stderr_fh)
472
        hashes.append(stdout_hash + ":" + stderr_hash)
473
        ansi_total += stdout_ansi + stderr_ansi
474
        output_bytes += stdout_total + stderr_total
475
        returncode = proc.returncode
476
    row = {
477
        **metadata,
478
        **subject_provenance(subject, metadata),
479
        "subject": subject.name,
480
        "subject_kind": subject.kind,
481
        "subject_label": subject.label,
482
        "git_mode": subject.git_mode,
483
        "scenario": scenario.name,
484
        "run": run_index,
485
        "operation": f"{base_op}.determinism",
486
        "elapsed_ms": round(elapsed[-1], 3),
487
        "returncode": returncode,
488
        "command": command,
489
        "output_stable": hashes[0] == hashes[1],
490
        "ansi_escape_count_non_tty": ansi_total,
491
        "probe_output_bytes": output_bytes,
492
        "tool_call_count": 0,
493
        "vcs_tool_call_count": 0,
494
        "terminal_tool_call_count": 0,
495
        "notes": "diagnostic probe row; excluded from token/tool-call deltas (tool_call_count=0)",
496
    }
497
    if returncode != 0:
498
        row["stdout"] = stdout_excerpt
499
        row["stderr"] = stderr_excerpt
500
    return row
501
502
503
# Cap on probe output read into memory for recall scoring. Recall is computed
504
# on what fits; the truncation flag marks rows where the cap bit.
505
INFO_PROBE_MAX_CHARS = 16 * 1024 * 1024
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
506
PATCH_PROBE_MAX_BYTES = 16 * 1024 * 1024
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
507
508
509
def run_information_probe(
510
    subject: Subject,
511
    scenario: Scenario,
512
    base_op: str,
513
    cwd: Path,
514
    command: list[str],
515
    run_index: int,
516
    metadata: dict[str, Any],
517
    changed_paths: list[str],
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
518
    pristine_fixture: Path | None = None,
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
519
) -> dict[str, Any]:
520
    """Score one status/diff output against the ground-truth changed set.
521
522
    Bytes saved are only a win if the file list is still recoverable from the
523
    output; this row makes "compact but lossy" visible as information_recall
524
    < 1.0 instead of looking like a token improvement. diff rows additionally
525
    record unified-diff structure for piped-tooling compatibility.
526
    """
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
527
    expected_manifest = None
528
    patch_skip_reason = None
529
    if base_op == "diff.full" and pristine_fixture is not None:
530
        if scenario.file_count * scenario.file_size > PATCH_PROBE_MAX_BYTES:
531
            patch_skip_reason = "patch_fixture_exceeds_16MiB_limit"
532
        elif subject.git_mode == "lfs":
533
            patch_skip_reason = "lfs_filters_require_separate_patch_contract"
534
        else:
535
            from oakbench.conformance import tree_manifest
536
            expected_manifest = tree_manifest(cwd)
537
    capture = run_timed(command, cwd, base_env(), INFO_PROBE_MAX_CHARS,
538
                        raw_output_bytes=PATCH_PROBE_MAX_BYTES if expected_manifest is not None else None)
539
    # stderr may contain arbitrary diagnostics mentioning a path; it is not a
540
    # path-bearing stdout protocol and must never inflate recall or precision.
541
    output_text = capture.stdout_text
542
    output_format = semantics().output_format(subject.kind, base_op,
543
                                             metadata.get("benchmark_track", "agent-default"))
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
544
    row = {
545
        **metadata,
546
        **subject_provenance(subject, metadata),
547
        "subject": subject.name,
548
        "subject_kind": subject.kind,
549
        "subject_label": subject.label,
550
        "git_mode": subject.git_mode,
551
        "scenario": scenario.name,
552
        "run": run_index,
553
        "operation": f"{base_op}.inforecall",
554
        "elapsed_ms": round(capture.elapsed_ms, 3),
555
        "returncode": capture.returncode,
556
        "command": command,
557
        "probe_output_bytes": capture.stdout_bytes + capture.stderr_bytes,
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
558
        "output_format": output_format,
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
559
        **oakbench_semantics.information_recall_fields(
560
            output_text,
561
            changed_paths,
562
            capture.stdout_bytes + capture.stderr_bytes,
563
            capture.stdout_truncated or capture.stderr_truncated,
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
564
            output_format,
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
565
        ),
566
        "tool_call_count": 0,
567
        "vcs_tool_call_count": 0,
568
        "terminal_tool_call_count": 0,
569
        "notes": "diagnostic probe row; excluded from token/tool-call deltas (tool_call_count=0)",
570
    }
571
    if base_op.startswith("diff."):
572
        row.update(oakbench_semantics.unified_diff_compat_fields(capture.stdout_text))
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
573
    if base_op == "diff.full":
574
        row.update(patch_oracle_version=oakbench_semantics.PATCH_ORACLE_VERSION,
575
                   patch_apply_ok=None, patch_tree_matches=None,
576
                   patch_application_required=not scenario.binary,
577
                   patch_apply_source="git_apply_exact_tree")
578
        if capture.returncode != 0:
579
            row["patch_skip_reason"] = "subject_diff_command_failed"
580
        elif expected_manifest is None:
581
            row["patch_skip_reason"] = patch_skip_reason or "pristine_fixture_unavailable"
582
        elif capture.stdout_raw is None:
583
            row["patch_skip_reason"] = "patch_output_exceeds_capture_limit"
584
        else:
585
            row.update(oakbench_semantics.patch_application_fields(
586
                capture.stdout_raw, pristine_fixture, expected_manifest, cwd.parent,
587
                pristine_excluded_root_paths=(".fixture-ready",),
588
            ))
589
            row = oakbench_semantics.finalize_patch_evidence(
590
                row, expected_manifest, tree_manifest(cwd), application_required=not scenario.binary)
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
591
    if capture.returncode != 0:
592
        row["stderr"] = capture.stderr_text[-4000:]
593
    return row
594
595
596
REMOTE_OPS = ("remote.push.first", "remote.clone.cold", "remote.pull.uptodate")
597
# Oak remote ops are network-transport (a real Oak server) while git's are
598
# local-file; different transports are different measurements, so the names
599
# differ (ADR-0005) and no report ever computes a git-vs-oak delta between
600
# them. The oak ops answer the release-blocking trend: did this changeset
601
# make oak's real push/clone/sync slower?
602
OAK_NET_REMOTE_OPS = ("remote.net.push.first", "remote.net.clone.cold", "remote.net.fetch.uptodate")
603
OAK_REMOTE_ENV = oakbench_remotes.OAK_REMOTE_ENV
604
605
606
def apply_state_change_attestation(
607
    row: dict[str, Any],
608
    repo: Path,
609
    before_fingerprint: str | None,
610
    op: str,
611
    *,
612
    expected_hashes: dict[str, str] | None = None,
613
    expected_payloads: dict[str, str | bytes] | None = None,
614
) -> dict[str, Any]:
615
    process_returncode = row_returncode(row)
616
    fields = oakbench_integrity.state_change_fields(
617
        repo,
618
        before_fingerprint,
619
        operation=op,
620
        process_returncode=process_returncode,
621
    )
622
    row["process_returncode"] = process_returncode
623
    row.update(fields)
624
    failure_reason = fields["integrity_failure_reason"] if not fields["integrity_check_passed"] else None
625
    if expected_hashes or expected_payloads:
626
        content_fields = oakbench_integrity.content_integrity_fields(
627
            repo,
628
            operation=op,
629
            process_returncode=process_returncode,
630
            expected_hashes=expected_hashes,
631
            expected_payloads=expected_payloads,
632
        )
633
        row.update(content_fields)
634
        if not content_fields["content_integrity_check_passed"] and failure_reason is None:
635
            failure_reason = content_fields["content_integrity_failure_reason"]
636
    if process_returncode == 0 and failure_reason is not None:
637
        row["returncode"] = 1
638
        row["stderr"] = str(failure_reason or "integrity_check_failed")
639
    return row
640
641
642
def remote_skip_row(
643
    subject: Subject,
644
    scenario: Scenario,
645
    op: str,
646
    run_index: int,
647
    metadata: dict[str, Any],
648
    reason: str,
649
) -> dict[str, Any]:
650
    return {
651
        **metadata,
652
        **subject_provenance(subject, metadata),
653
        "subject": subject.name,
654
        "subject_kind": subject.kind,
655
        "subject_label": subject.label,
656
        "git_mode": subject.git_mode,
657
        "scenario": scenario.name,
658
        "run": run_index,
659
        "operation": op,
660
        "elapsed_ms": 0.0,
661
        "returncode": 77,
662
        "command": [],
663
        "skipped": True,
664
        "skip_reason": reason,
665
        "tool_call_count": 0,
666
        "vcs_tool_call_count": 0,
667
        "terminal_tool_call_count": 0,
668
    }
669
670
671
def git_huge_binary_diff_limit_reason(subject: Subject, scenario: Scenario, op: str) -> str | None:
672
    if subject.kind != "git":
673
        return None
674
    if subject.git_mode == "lfs":
675
        return None
676
    if op not in GIT_HUGE_BINARY_DIFF_OPS:
677
        return None
678
    if not scenario.binary or scenario.file_size < GIT_HUGE_BINARY_DIFF_LIMIT_BYTES:
679
        return None
680
    return (
681
        "git_huge_binary_diff_fixture_limit: stock/tuned git diff rows are intentionally "
682
        "skipped for generated >=1GiB binary fixtures because git may fail while generating "
683
        "binary diffstat; use git_lfs or smaller binary fixtures for this capability"
684
    )
685
686
687
def git_huge_binary_diff_skip_row(
688
    subject: Subject,
689
    scenario: Scenario,
690
    op: str,
691
    command: list[str],
692
    run_index: int,
693
    metadata: dict[str, Any],
694
) -> dict[str, Any]:
695
    reason = git_huge_binary_diff_limit_reason(subject, scenario, op)
696
    if reason is None:
697
        raise ValueError(f"{op} is not a huge-binary git diff skip")
698
    row = remote_skip_row(subject, scenario, op, run_index, metadata, reason)
699
    row.update(
700
        {
701
            "command": command,
702
            "failure_classification": "git_huge_binary_diff_fixture_limit",
703
            "notes": (
704
                "preflighted fixture-limit skip; no subprocess was run for this row, "
705
                "so stderr is intentionally empty"
706
            ),
707
        }
708
    )
709
    return row
710
711
712
_OAK_JSON_SUPPORT_CACHE: dict[tuple[str, tuple[str, ...], tuple[str, ...]], str | None] = {}
713
_OAK_VERSION_CACHE: dict[str, tuple[int, int, int] | None] = {}
714
715
716
def parse_semver_triplet(text: str) -> tuple[int, int, int] | None:
717
    match = re.search(r"\b(\d+)\.(\d+)\.(\d+)\b", text)
718
    if match is None:
719
        return None
720
    return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
721
722
723
def oak_cli_semver(vcs: str) -> tuple[int, int, int] | None:
724
    if vcs in _OAK_VERSION_CACHE:
725
        return _OAK_VERSION_CACHE[vcs]
726
    try:
727
        probe = subprocess.run(
728
            [vcs, "--version"],
729
            capture_output=True,
730
            text=True,
731
            env=base_env(),
732
            timeout=15,
733
            check=False,
734
        )
735
    except (FileNotFoundError, subprocess.TimeoutExpired):
736
        _OAK_VERSION_CACHE[vcs] = None
737
        return None
738
    version = parse_semver_triplet((probe.stdout or "") + "\n" + (probe.stderr or ""))
739
    _OAK_VERSION_CACHE[vcs] = version
740
    return version
741
742
743
def oak_json_probe_version_skip_reason(vcs: str, spec: dict[str, Any]) -> str | None:
744
    minimum = spec.get("minimum_oak_version")
745
    if not minimum:
746
        return None
747
    required = parse_semver_triplet(str(minimum))
748
    actual = oak_cli_semver(vcs)
749
    if required is None:
750
        return f"capability_gap:oak_json_probe_bad_minimum_version:{minimum}"
751
    if actual is None:
752
        return f"capability_gap:oak_json_probe_version_unknown:requires_oak>={minimum}"
753
    if actual < required:
754
        actual_text = ".".join(str(part) for part in actual)
755
        return f"capability_gap:oak_json_probe_requires_oak>={minimum}:found_{actual_text}"
756
    return None
757
758
759
def oak_json_probe_skip_reason(vcs: str, help_args: list[str], command_args: list[str]) -> str | None:
760
    """Return a capability skip reason, or None when the JSON command is present."""
761
    key = (vcs, tuple(help_args), tuple(command_args))
762
    if key in _OAK_JSON_SUPPORT_CACHE:
763
        return _OAK_JSON_SUPPORT_CACHE[key]
764
    if not help_args:
765
        reason = "capability_gap:oak_json_probe_help_missing"
766
        _OAK_JSON_SUPPORT_CACHE[key] = reason
767
        return reason
768
    try:
769
        probe = subprocess.run(
770
            [vcs, *help_args],
771
            capture_output=True,
772
            text=True,
773
            env=base_env(),
774
            timeout=15,
775
            check=False,
776
        )
777
    except FileNotFoundError:
778
        reason = f"missing_binary:{vcs}"
779
        _OAK_JSON_SUPPORT_CACHE[key] = reason
780
        return reason
781
    except subprocess.TimeoutExpired:
782
        reason = "capability_gap:oak_json_probe_help_timeout:" + " ".join(help_args)
783
        _OAK_JSON_SUPPORT_CACHE[key] = reason
784
        return reason
785
    help_text = (probe.stdout or "") + "\n" + (probe.stderr or "")
786
    if probe.returncode != 0:
787
        reason = "capability_gap:oak_json_probe_help_failed:" + " ".join(help_args)
788
    elif "--json" in command_args and "--json" not in help_text:
789
        reason = "capability_gap:oak_json_flag_missing:" + " ".join(command_args)
790
    else:
791
        reason = None
792
    _OAK_JSON_SUPPORT_CACHE[key] = reason
793
    return reason
794
795
796
def json_field_oracle_fields(stdout_text: str, required_fields: list[str]) -> dict[str, Any]:
797
    """JSON parse and top-level required-field completeness for CLI capability rows."""
798
    base: dict[str, Any] = {
799
        "json_required_fields": required_fields,
800
        "json_required_field_count": len(required_fields),
801
    }
802
    try:
803
        payload = json.loads(stdout_text)
804
    except json.JSONDecodeError as exc:
805
        return {
806
            **base,
807
            "json_parse_success": False,
808
            "json_payload_type": None,
809
            "json_present_required_field_count": 0,
810
            "json_missing_required_fields": required_fields,
811
            "json_field_completeness": 0.0 if required_fields else None,
812
            "json_oracle_passed": False,
813
            "json_error": f"{exc.msg} at line {exc.lineno} column {exc.colno}",
814
        }
815
    payload_type = type(payload).__name__
816
    if not isinstance(payload, dict):
817
        return {
818
            **base,
819
            "json_parse_success": True,
820
            "json_payload_type": payload_type,
821
            "json_present_required_field_count": 0,
822
            "json_missing_required_fields": required_fields,
823
            "json_field_completeness": 0.0 if required_fields else None,
824
            "json_oracle_passed": False,
825
            "json_error": "expected top-level JSON object",
826
        }
827
    missing = [field for field in required_fields if field not in payload]
828
    present = len(required_fields) - len(missing)
829
    return {
830
        **base,
831
        "json_parse_success": True,
832
        "json_payload_type": payload_type,
833
        "json_present_required_field_count": present,
834
        "json_missing_required_fields": missing,
835
        "json_field_completeness": round(present / len(required_fields), 4) if required_fields else None,
836
        "json_oracle_passed": not missing,
837
    }
838
839
840
def json_field_oracle_unmeasured_fields(required_fields: list[str], reason: str) -> dict[str, Any]:
841
    """Null JSON validation fields when the runner did not capture a complete payload."""
842
    return {
843
        "json_required_fields": required_fields,
844
        "json_required_field_count": len(required_fields),
845
        "json_parse_success": None,
846
        "json_payload_type": None,
847
        "json_present_required_field_count": None,
848
        "json_missing_required_fields": None,
849
        "json_field_completeness": None,
850
        "json_oracle_passed": None,
851
        "json_validation_unmeasured_reason": reason,
852
    }
853
854
855
def run_agent_native_json_probe(
856
    subject: Subject,
857
    scenario: Scenario,
858
    op: str,
859
    cwd: Path,
860
    spec: dict[str, Any],
861
    run_index: int,
862
    metadata: dict[str, Any],
863
    admitted_output_chars: int,
864
) -> dict[str, Any]:
865
    vcs = str(subject.bin)
866
    command_args = list(spec["args"])
867
    help_args = list(spec["help_args"])
868
    command = [vcs, *command_args]
869
    skip_reason = oak_json_probe_version_skip_reason(vcs, spec)
870
    if skip_reason is None:
871
        skip_reason = oak_json_probe_skip_reason(vcs, help_args, command_args)
872
    if skip_reason is not None:
873
        row = remote_skip_row(subject, scenario, op, run_index, metadata, skip_reason)
874
        row["intended_command"] = command
875
        row["capability_probe_command"] = [vcs, *help_args]
876
        if spec.get("minimum_oak_version") is not None:
877
            row["minimum_oak_version"] = str(spec["minimum_oak_version"])
878
        return row
879
880
    capture = run_timed(
881
        command,
882
        cwd,
883
        base_env(),
884
        admitted_output_chars,
885
        full_output_bytes=JSON_PROBE_MAX_BYTES,
886
    )
887
    required_fields = list(spec["required_fields"])
888
    if capture.stdout_full_text is not None and capture.stdout_full_text_truncated is False:
889
        json_validation_source = "full_stdout_capture"
890
        json_fields = json_field_oracle_fields(capture.stdout_full_text, required_fields)
891
    elif capture.stdout_full_text_truncated is True or (
892
        capture.stdout_full_text is None and capture.stdout_truncated
893
    ):
894
        json_validation_source = None
895
        json_fields = json_field_oracle_unmeasured_fields(
896
            required_fields,
897
            "stdout_exceeded_json_validation_capture_limit",
898
        )
899
    else:
900
        json_validation_source = "admitted_stdout_capture"
901
        json_fields = json_field_oracle_fields(capture.stdout_text, required_fields)
902
    result = {
903
        **metadata,
904
        **subject_provenance(subject, metadata),
905
        "subject": subject.name,
906
        "subject_kind": subject.kind,
907
        "subject_label": subject.label,
908
        "git_mode": subject.git_mode,
909
        "scenario": scenario.name,
910
        "run": run_index,
911
        "operation": op,
912
        "elapsed_ms": round(capture.elapsed_ms, 3),
913
        "returncode": capture.returncode,
914
        "command": command,
915
        "peak_rss_bytes": capture.peak_rss_bytes,
916
        "peak_rss_source": PEAK_RSS_SOURCE if capture.peak_rss_bytes is not None else None,
917
        "measurement_source": "direct_cli_timed_subprocess_json_capability_probe",
918
        "probe_output_bytes": capture.stdout_bytes + capture.stderr_bytes,
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
919
        "output_format": spec.get("output_format", "json"),
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
920
        **interaction_metrics(
921
            command,
922
            capture.stdout_text,
923
            capture.stderr_text,
924
            capture.stdout_bytes,
925
            capture.stderr_bytes,
926
            capture.stdout_truncated,
927
            capture.stderr_truncated,
928
            tool_call_count=1,
929
        ),
930
        "json_validation_source": json_validation_source,
931
        **json_fields,
932
    }
933
    if capture.returncode != 0:
934
        result["stderr"] = capture.stderr_text[-4000:]
935
    elif result["json_oracle_passed"] is False:
936
        result["returncode"] = 1
937
        result["stderr"] = str(result.get("json_error") or "json_required_fields_missing")
938
    return result
939
940
941
def run_agent_native_json_probes(
942
    subject: Subject,
943
    scenario: Scenario,
944
    cwd: Path,
945
    run_index: int,
946
    metadata: dict[str, Any],
947
    admitted_output_chars: int,
948
) -> list[dict[str, Any]]:
949
    probes = semantics().agent_native_json_probes(subject.kind, BENCH_TASK_BRANCH)
950
    return [
951
        run_agent_native_json_probe(
952
            subject, scenario, op, cwd, spec, run_index, metadata, admitted_output_chars
953
        )
954
        for op, spec in probes.items()
955
    ]
956
957
958
def load_tier_skip_row(
959
    subject: Subject,
960
    scenario: Scenario,
961
    run_index: int,
962
    metadata: dict[str, Any],
963
    reason: str,
964
    load_tier: str,
965
) -> dict[str, Any]:
966
    """Explicit skip row for a load tier the host cannot apply.
967
968
    A row may never claim load that was not applied: environment_suspect is
969
    None (suspicion machinery did not run, ADR-0002) and load_tier records the
970
    REQUESTED tier so the gap is visible, not hidden.
971
    """
972
    row = remote_skip_row(subject, scenario, "scenario.load_tier_skipped", run_index, metadata, reason)
973
    row["load_tier"] = load_tier
974
    row["environment_suspect"] = None
975
    return row
976
977
978
def load_tier_skip_rows(
979
    subjects: list[Subject],
980
    scenarios: list[Scenario],
981
    metadata: dict[str, Any],
982
    reason: str,
983
    load_tier: str,
984
) -> list[dict[str, Any]]:
985
    """One skip row per scenario x run x subject for an unappliable load tier."""
986
    return [
987
        load_tier_skip_row(subject, scenario, run_index, metadata, reason, load_tier)
988
        for scenario in scenarios
989
        for run_index in range(scenario.runs)
990
        for subject in subjects
991
    ]
992
993
994
def oak_network_branch_name(
995
    bench_id: str,
996
    scenario_name: str,
997
    subject_name: str,
998
    run_index: int,
999
    *,
1000
    pid: int | None = None,
1001
    token: str | None = None,
1002
) -> str:
1003
    """Disposable remote branch name: readable prefix plus per-run uniquifier."""
1004
    return oakbench_remotes.disposable_branch(
1005
        bench_id, scenario_name, subject_name, run_index, pid=pid, token=token
1006
    )
1007
1008
1009
def run_oak_network_remote_phase(
1010
    subject: Subject,
1011
    scenario: Scenario,
1012
    repo: Path,
1013
    run_index: int,
1014
    metadata: dict[str, Any],
1015
    admitted_output_chars: int,
1016
    remote_repo: str,
1017
) -> list[dict[str, Any]]:
1018
    """Push to, clone from, and sync with a real Oak server.
1019
1020
    Network transport: latency includes the server round trip, so these rows
1021
    are oak-vs-previous-oak trend evidence, never a git-vs-oak comparison
1022
    (git's remote phase is local-file by construction).
1023
1024
    Same-named branches with unrelated histories conflict on the server, so
1025
    each run pushes a unique bench branch (untimed setup); the disposable
1026
    remote accumulates branches by design.
1027
    """
1028
    rows: list[dict[str, Any]] = []
1029
    vcs = str(subject.bin)
1030
    bench_id = str(metadata.get("bench_id", "bench"))
1031
    branch_name = oak_network_branch_name(bench_id, scenario.name, subject.name, run_index)
1032
    clone_dir = repo.parent / f"netclone-{subject.name}-{run_index}"
1033
    # Untimed: a unique branch is a precondition (same-named unrelated
1034
    # histories are rejected by the server), not the measurement.
1035
    subprocess.run(
1036
        [vcs, "switch", "-c", branch_name],
1037
        cwd=repo,
1038
        env=base_env(),
1039
        stdout=subprocess.DEVNULL,
1040
        stderr=subprocess.DEVNULL,
1041
        check=False,
1042
    )
1043
    push_row = run_command(
1044
        subject,
1045
        scenario,
1046
        "remote.net.push.first",
1047
        repo,
1048
        [vcs, "push", "--repo", remote_repo],
1049
        run_index,
1050
        metadata,
1051
        admitted_output_chars,
1052
    )
1053
    rows.append({**push_row, "remote_repo": remote_repo, "remote_transport": "network", "cache_state": "cold_remote_branch"})
1054
    clone_row = run_command(
1055
        subject,
1056
        scenario,
1057
        "remote.net.clone.cold",
1058
        repo.parent,
1059
        [vcs, "clone", remote_repo, str(clone_dir)],
1060
        run_index,
1061
        metadata,
1062
        admitted_output_chars,
1063
    )
1064
    rows.append(
1065
        {
1066
            **clone_row,
1067
            "remote_repo": remote_repo,
1068
            "remote_transport": "network",
1069
            "cache_state": "cold_objects_network",
1070
            "notes": "clones the disposable repo's default content, not this scenario's fixture; comparable only to other remote.net.clone.cold rows",
1071
        }
1072
    )
1073
    if clone_row["returncode"] == 0:
1074
        fetch_row = run_command(
1075
            subject,
1076
            scenario,
1077
            "remote.net.fetch.uptodate",
1078
            clone_dir,
1079
            [vcs, "fetch"],
1080
            run_index,
1081
            metadata,
1082
            admitted_output_chars,
1083
        )
1084
        rows.append({**fetch_row, "remote_repo": remote_repo, "remote_transport": "network", "cache_state": "warm"})
1085
    return rows
1086
1087
1088
def run_remote_phase(
1089
    subject: Subject,
1090
    scenario: Scenario,
1091
    repo: Path,
1092
    run_index: int,
1093
    metadata: dict[str, Any],
1094
    admitted_output_chars: int,
1095
) -> list[dict[str, Any]]:
1096
    """Push to, clone from, and sync with a local remote.
1097
1098
    Agents pay clone/pull/push constantly: every fresh workspace is a cold
1099
    clone, every task start is a pull, every task end is a push. A local
1100
    filesystem remote isolates the VCS object-transfer and checkout cost from
1101
    network jitter; "cold" means no local object store, while the OS page
1102
    cache stays warm (recorded in cache_state so rows never overclaim).
1103
1104
    Oak's remote model needs a configured server remote; until one is wired,
1105
    Oak rows are explicit skips (returncode 77), never silently absent.
1106
    """
1107
    if subject.kind != "git":
1108
        resolution = oakbench_remotes.resolve_oak_remote()
1109
        remote_repo = resolution.repo or ""
1110
        if not remote_repo:
1111
            reason = (
1112
                f"oak_remote_not_configured: set {OAK_REMOTE_ENV} to a DISPOSABLE ORG/REPO "
1113
                "(e.g. oak/oak-benchmarks-tmp) to measure real-server push/clone/fetch"
1114
            )
1115
            return [
1116
                remote_skip_row(subject, scenario, op, run_index, metadata, reason)
1117
                for op in OAK_NET_REMOTE_OPS
1118
            ]
1119
        return run_oak_network_remote_phase(
1120
            subject, scenario, repo, run_index, metadata, admitted_output_chars, remote_repo
1121
        )
1122
1123
    rows: list[dict[str, Any]] = []
1124
    vcs = str(subject.bin)
1125
    # Untimed setup: the remote existing is a precondition, not the measurement.
1126
    bare = oakbench_remotes.make_git_bare_remote(
1127
        vcs, repo.parent, name=f"remote-{subject.name}-{run_index}.git"
1128
    )
1129
    remote_dir = Path(bare.repo)
1130
    clone_dir = repo.parent / f"clone-{subject.name}-{run_index}"
1131
    oakbench_remotes.attach_git_origin(vcs, repo, bare)
1132
    push_row = run_command(
1133
        subject,
1134
        scenario,
1135
        "remote.push.first",
1136
        repo,
1137
        [vcs, "push", "origin", "HEAD"],
1138
        run_index,
1139
        metadata,
1140
        admitted_output_chars,
1141
    )
1142
    rows.append({**push_row, "cache_state": "cold_remote_warm_page_cache", "remote_transport": "local_file"})
1143
    # Untimed: point the bare remote's HEAD at the pushed branch so the clone
1144
    # checks out a working tree (precondition, not measurement).
1145
    branch_name = oakbench_remotes.current_git_branch(vcs, repo)
1146
    oakbench_remotes.point_git_bare_head(vcs, bare, branch_name)
1147
    clone_row = run_command(
1148
        subject,
1149
        scenario,
1150
        "remote.clone.cold",
1151
        repo.parent,
1152
        [vcs, "clone", str(remote_dir), str(clone_dir)],
1153
        run_index,
1154
        metadata,
1155
        admitted_output_chars,
1156
    )
1157
    rows.append({**clone_row, "cache_state": "cold_objects_warm_page_cache", "remote_transport": "local_file"})
1158
    if clone_row["returncode"] == 0:
1159
        pull_row = run_command(
1160
            subject,
1161
            scenario,
1162
            "remote.pull.uptodate",
1163
            clone_dir,
1164
            [vcs, "pull"],
1165
            run_index,
1166
            metadata,
1167
            admitted_output_chars,
1168
        )
1169
        rows.append({**pull_row, "cache_state": "warm", "remote_transport": "local_file"})
1170
    return rows
1171
1172
1173
def run_command(
1174
    subject: Subject,
1175
    scenario: Scenario,
1176
    op: str,
1177
    cwd: Path,
1178
    command: list[str],
1179
    run_index: int,
1180
    metadata: dict[str, Any],
1181
    admitted_output_chars: int,
1182
) -> dict[str, Any]:
1183
    capture = run_timed(command, cwd, base_env(), admitted_output_chars)
1184
    result = {
1185
        **metadata,
1186
        **subject_provenance(subject, metadata),
1187
        "subject": subject.name,
1188
        "subject_kind": subject.kind,
1189
        "subject_label": subject.label,
1190
        "git_mode": subject.git_mode,
1191
        "scenario": scenario.name,
1192
        "run": run_index,
1193
        "operation": op,
1194
        "elapsed_ms": round(capture.elapsed_ms, 3),
1195
        "returncode": capture.returncode,
1196
        "command": command,
1197
        "peak_rss_bytes": capture.peak_rss_bytes,
1198
        "peak_rss_source": PEAK_RSS_SOURCE if capture.peak_rss_bytes is not None else None,
1199
        "measurement_source": "direct_cli_timed_subprocess",
1200
        **interaction_metrics(
1201
            command,
1202
            capture.stdout_text,
1203
            capture.stderr_text,
1204
            capture.stdout_bytes,
1205
            capture.stderr_bytes,
1206
            capture.stdout_truncated,
1207
            capture.stderr_truncated,
1208
            tool_call_count=1,
1209
        ),
1210
    }
1211
    if capture.returncode != 0:
1212
        result["stderr"] = capture.stderr_text[-4000:]
1213
    return result
1214
1215
1216
def write_pattern_file(path: Path, size: int, seed: str, binary: bool) -> None:
1217
    oakbench_fixtures.write_pattern_file(path, size, seed, binary=binary, prefix="oakbench")
1218
1219
1220
def scenario_shape_id(scenario: Scenario) -> str:
1221
    """Fixture identity is the byte shape, not the scenario label.
1222
1223
    Keying the cache on shape (and recording it in the ready marker) makes a
1224
    name collision across profiles a cache miss instead of silent corruption:
1225
    a smoke run can never poison a later standard run sharing the workdir.
1226
    """
1227
    kind = "bin" if scenario.binary else "txt"
1228
    return f"{scenario.file_count}x{scenario.file_size}-{kind}"
1229
1230
1231
def fixture_path(root: Path, scenario: Scenario) -> Path:
1232
    return root / "fixtures" / f"{scenario.name}-{scenario_shape_id(scenario)}"
1233
1234
1235
def ensure_fixture(root: Path, scenario: Scenario) -> Path:
1236
    dest = fixture_path(root, scenario)
1237
    marker = dest / ".fixture-ready"
1238
    expected_marker = f"{scenario_shape_id(scenario)}\n"
1239
    if marker.exists() and marker.read_text() == expected_marker:
1240
        return dest
1241
    if dest.exists():
1242
        if marker.exists() and marker.read_text() == expected_marker:
1243
            return dest
1244
        shutil.rmtree(dest)
1245
1246
    temp = dest.parent / f"{dest.name}.building-{os.getpid()}"
1247
    if temp.exists():
1248
        shutil.rmtree(temp)
1249
    temp.mkdir(parents=True)
1250
    try:
1251
        ext = "bin" if scenario.binary else "txt"
1252
        width = max(4, len(str(scenario.file_count)))
1253
        for index in range(scenario.file_count):
1254
            subdir = temp / f"group-{index // 1000:03d}"
1255
            path = subdir / f"file-{index:0{width}d}.{ext}"
1256
            write_pattern_file(path, scenario.file_size, f"{scenario.name}:{index}", scenario.binary)
1257
        (temp / ".fixture-ready").write_text(expected_marker)
1258
        try:
1259
            os.rename(temp, dest)
1260
        except OSError:
1261
            if marker.exists() and marker.read_text() == expected_marker:
1262
                shutil.rmtree(temp)
1263
                return dest
1264
            got = marker.read_text().strip() if marker.exists() else "missing"
1265
            raise RuntimeError(
1266
                f"fixture {dest} exists with wrong shape marker "
1267
                f"(expected {expected_marker.strip()!r}, got {got!r})"
1268
            ) from None
1269
        return dest
1270
    except Exception:
1271
        if temp.exists():
1272
            shutil.rmtree(temp)
1273
        raise
1274
1275
1276
def mutate_files(repo: Path, scenario: Scenario, subject: Subject, run_index: int) -> list[str]:
1277
    """Mutate the scenario's dirty set; returns repo-relative changed paths.
1278
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1279
    Mutation bytes are identical across subjects within each run.
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1280
    The returned list is the ground truth the information-recall probe scores
1281
    status/diff output against.
1282
    """
1283
    ext = "bin" if scenario.binary else "txt"
1284
    paths = sorted(repo.glob(f"group-*/*.{ext}"))[: scenario.dirty_count]
1285
    if not paths and scenario.file_count == 1:
1286
        paths = sorted(repo.glob(f"**/*.{ext}"))[:1]
1287
    for index, path in enumerate(paths):
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1288
        marker = f"\nmutated {scenario.name} {run_index} {index}\n".encode()
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1289
        if scenario.binary:
1290
            with path.open("r+b") as fh:
1291
                size = path.stat().st_size
1292
                fh.seek(max(0, size // 2 - len(marker) // 2))
1293
                fh.write(marker[: min(len(marker), max(1, size))])
1294
        else:
1295
            with path.open("ab") as fh:
1296
                fh.write(marker)
1297
    return [path.relative_to(repo).as_posix() for path in paths]
1298
1299
1300
def copy_fixture(src: Path, dest: Path) -> None:
1301
    oakbench_fixtures.copy_fixture(src, dest, ignore_marker=True)
1302
1303
1304
# Dirty-tree spectrum (opt-in via --dirty-spectrum). pNN means NN% of the
1305
# fixture's tracked files are dirtied (ceil) before the timed command; the
1306
# dirtying and the restore are untimed setup/teardown. Scale is encoded in the
1307
# operation name per ADR-0005: status.dirty.p01 is a different measurement
1308
# than status.dirty and never shares its trend line. snapshot.dirty.pNN is
1309
# deliberately NOT implemented: a snapshot commits, so restoring cleanliness
1310
# would require history surgery or a re-copied fixture per step β€” not cheap,
1311
# and it would change the repository state every later operation sees.
1312
DIRTY_SPECTRUM_FRACTIONS: tuple[tuple[str, float], ...] = (
1313
    ("p01", 0.01),
1314
    ("p10", 0.10),
1315
    ("p50", 0.50),
1316
)
1317
DIRTY_SPECTRUM_BASE_OPS: tuple[str, ...] = ("status.dirty", "diff.dirty")
1318
1319
1320
def dirty_spectrum_operations(enabled: bool, skip_diff: bool = False) -> list[str]:
1321
    """Operation names the spectrum adds. Empty when the flag is off."""
1322
    if not enabled:
1323
        return []
1324
    names: list[str] = []
1325
    for label, _fraction in DIRTY_SPECTRUM_FRACTIONS:
1326
        names.append(f"status.dirty.{label}")
1327
        if not skip_diff:
1328
            names.append(f"diff.dirty.{label}")
1329
    return names
1330
1331
1332
def dirty_spectrum_marker(seed: str) -> bytes:
1333
    """Fixed edit content for one spectrum step.
1334
1335
    Deterministic (a pure function of the seed) so restore_dirty_fraction can
1336
    verify and strip exactly the bytes dirty_fraction appended.
1337
    """
1338
    return f"\ndirty-spectrum {seed}\n".encode("ascii")
1339
1340
1341
def tracked_fixture_files(repo_path: Path) -> list[Path]:
1342
    """Sorted tracked fixture files: everything outside VCS metadata dirs.
1343
1344
    After snapshot.initial every fixture file is tracked, so a filesystem walk
1345
    is the subject-neutral tracked set (no VCS command, identical for git and
1346
    oak subjects).
1347
    """
1348
    repo = Path(repo_path)
1349
    return sorted(
1350
        path
1351
        for path in repo.rglob("*")
1352
        if path.is_file() and not {".git", ".oak"}.intersection(path.relative_to(repo).parts)
1353
    )
1354
1355
1356
def dirty_fraction(repo_path: Path, fraction: float, seed: str) -> list[str]:
1357
    """Deterministically dirty ceil(fraction * N) tracked files.
1358
1359
    Selection is a seeded sample over the sorted tracked-file list; the edit
1360
    is one fixed appended line (same bytes for every selected file). Returns
1361
    the repo-relative POSIX paths of the dirtied files. Untimed by
1362
    construction: callers dirty first, then run_timed the measured command.
1363
    """
1364
    repo = Path(repo_path)
1365
    files = tracked_fixture_files(repo)
1366
    if not files or fraction <= 0:
1367
        return []
1368
    count = min(len(files), math.ceil(fraction * len(files)))
1369
    chosen = sorted(random.Random(seed).sample(files, count))
1370
    marker = dirty_spectrum_marker(seed)
1371
    for path in chosen:
1372
        with path.open("ab") as fh:
1373
            fh.write(marker)
1374
    return [path.relative_to(repo).as_posix() for path in chosen]
1375
1376
1377
def restore_dirty_fraction(repo_path: Path, changed_paths: list[str], seed: str) -> None:
1378
    """Undo dirty_fraction by stripping the exact appended marker bytes.
1379
1380
    Content returns byte-identical to the committed state, so the tree is
1381
    clean again before the next spectrum step (and before the pre-existing
1382
    operation sequence continues). Verification before truncation means a
1383
    mismatch fails loudly instead of silently corrupting a fixture copy.
1384
    """
1385
    repo = Path(repo_path)
1386
    marker = dirty_spectrum_marker(seed)
1387
    for rel in changed_paths:
1388
        path = repo / rel
1389
        size = path.stat().st_size
1390
        with path.open("r+b") as fh:
1391
            if size < len(marker):
1392
                raise RuntimeError(f"dirty-spectrum restore: {rel} is smaller than the marker")
1393
            fh.seek(size - len(marker))
1394
            if fh.read(len(marker)) != marker:
1395
                raise RuntimeError(f"dirty-spectrum restore: {rel} does not end with the expected marker")
1396
            fh.truncate(size - len(marker))
1397
1398
1399
def run_dirty_spectrum(
1400
    subject: Subject,
1401
    scenario: Scenario,
1402
    repo: Path,
1403
    ops: dict[str, list[str]],
1404
    run_index: int,
1405
    metadata: dict[str, Any],
1406
    skip_diff: bool,
1407
    admitted_output_chars: int,
1408
) -> list[dict[str, Any]]:
1409
    """Measure status (and diff) across deterministic dirty fractions.
1410
1411
    Runs on a clean committed tree. Per fraction: dirty (untimed), time the
1412
    same status/diff commands the existing status.dirty/diff.dirty rows use,
1413
    then restore (untimed) so each step starts from the identical clean state.
1414
    The seed is per scenario+fraction, never per subject or run, so every
1415
    subject measures the same dirtied file set.
1416
    """
1417
    rows: list[dict[str, Any]] = []
1418
    base_ops = [op for op in DIRTY_SPECTRUM_BASE_OPS if not (skip_diff and op == "diff.dirty")]
1419
    for label, fraction in DIRTY_SPECTRUM_FRACTIONS:
1420
        seed = f"dirty-spectrum:{scenario.name}:{label}"
1421
        changed = dirty_fraction(repo, fraction, seed)
1422
        try:
1423
            for base_op in base_ops:
1424
                row = run_command(
1425
                    subject,
1426
                    scenario,
1427
                    f"{base_op}.{label}",
1428
                    repo,
1429
                    ops[base_op],
1430
                    run_index,
1431
                    metadata,
1432
                    admitted_output_chars,
1433
                )
1434
                row["dirty_fraction"] = fraction
1435
                row["dirty_file_count"] = len(changed)
1436
                rows.append(row)
1437
        finally:
1438
            restore_dirty_fraction(repo, changed, seed)
1439
    return rows
1440
1441
1442
def ops_for(subject: Subject, track: str) -> dict[str, list[str]]:
1443
    """Non-snapshot operation commands from the command-semantics contract.
1444
1445
    Snapshot operations go through snapshot_message/run_git_snapshot so git
1446
    pays its stage+commit as two recorded calls. Where Oak lacks a compact
1447
    equivalent, core-equivalent rows use the closest available command; the
1448
    contract JSON records that gap explicitly.
1449
    """
1450
    sem = semantics()
1451
    vcs = str(subject.bin)
1452
    status = [vcs, *sem.status_args(subject.kind, track)]
1453
    return {
1454
        "repo.init": [vcs, *sem.init_args(subject.kind)],
1455
        "status.clean": status,
1456
        "status.dirty": list(status),
1457
        "diff.dirty": [vcs, *sem.diff_args(subject.kind, track)],
1458
        "branch.create": [vcs, *sem.branch_create_args(subject.kind)],
1459
    }
1460
1461
1462
def snapshot_message(op: str) -> str:
1463
    return "initial" if op == "snapshot.initial" else op
1464
1465
1466
def run_snapshot(
1467
    subject: Subject,
1468
    scenario: Scenario,
1469
    op: str,
1470
    cwd: Path,
1471
    run_index: int,
1472
    metadata: dict[str, Any],
1473
    admitted_output_chars: int,
1474
) -> list[dict[str, Any]]:
1475
    """Snapshot via the command-semantics contract.
1476
1477
    Subjects that stage separately (git) record the stage and commit as
1478
    sub-rows plus a combined canonical row; one-command subjects (oak) record
1479
    a single row. Agents pay every recorded call.
1480
    """
1481
    sem = semantics()
1482
    commands = sem.snapshot_commands(str(subject.bin), subject.kind, snapshot_message(op))
1483
    if len(commands) == 1:
1484
        before = oakbench_integrity.repository_state_fingerprint(cwd)
1485
        expected_hashes = oakbench_integrity.sample_worktree_payload_hashes(cwd)
1486
        row = run_command(subject, scenario, op, cwd, commands[0], run_index, metadata, admitted_output_chars)
1487
        return [apply_state_change_attestation(row, cwd, before, op, expected_hashes=expected_hashes)]
1488
1489
    stage_command, commit_command = commands
1490
    add_result = run_command(
1491
        subject, scenario, f"{op}.add", cwd, stage_command, run_index, metadata, admitted_output_chars
1492
    )
1493
    if add_result["returncode"] != 0:
1494
        return [add_result]
1495
    before_commit = oakbench_integrity.repository_state_fingerprint(cwd)
1496
    expected_hashes = oakbench_integrity.sample_worktree_payload_hashes(cwd)
1497
    commit_result = run_command(
1498
        subject, scenario, f"{op}.commit", cwd, commit_command, run_index, metadata, admitted_output_chars
1499
    )
1500
    apply_state_change_attestation(commit_result, cwd, before_commit, op, expected_hashes=expected_hashes)
1501
    subrows = [add_result, commit_result]
1502
    combined = {
1503
        **commit_result,
1504
        "operation": op,
1505
        "elapsed_ms": round(add_result["elapsed_ms"] + commit_result["elapsed_ms"], 3),
1506
        "returncode": commit_result["returncode"],
1507
        "command": [stage_command, commit_command],
1508
        **merge_interaction_metrics([stage_command, commit_command], subrows),
1509
    }
1510
    if add_result["returncode"] != 0 or commit_result["returncode"] != 0:
1511
        combined["stderr"] = (add_result.get("stderr", "") + "\n" + commit_result.get("stderr", ""))[-4000:]
1512
    return [add_result, commit_result, combined]
1513
1514
1515
def run_subject_scenario(
1516
    subject: Subject,
1517
    scenario: Scenario,
1518
    fixture: Path,
1519
    run_index: int,
1520
    run_root: Path,
1521
    metadata: dict[str, Any],
1522
    skip_diff: bool,
1523
    admitted_output_chars: int,
1524
    track: str,
1525
    skip_determinism_probe: bool = False,
1526
    skip_remote: bool = False,
1527
    dirty_spectrum: bool = False,
1528
) -> list[dict[str, Any]]:
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1529
    metadata = {**metadata, "benchmark_track": track}
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1530
    applies, skip_reason = git_mode_applies_to_scenario(subject.git_mode, scenario)
1531
    if not applies:
1532
        return [
1533
            remote_skip_row(
1534
                subject,
1535
                scenario,
1536
                "scenario.mode_skipped",
1537
                run_index,
1538
                metadata,
1539
                skip_reason or "git_mode_not_applicable",
1540
            )
1541
        ]
1542
    if subject.git_mode == "lfs" and not git_lfs_available(subject.bin):
1543
        return [
1544
            remote_skip_row(
1545
                subject,
1546
                scenario,
1547
                "scenario.mode_skipped",
1548
                run_index,
1549
                metadata,
1550
                "git_lfs_unavailable: install git-lfs to measure the git_lfs tuned comparator",
1551
            )
1552
        ]
1553
1554
    repo = run_root / scenario.name / subject.name / f"run-{run_index}"
1555
    copy_fixture(fixture, repo)
1556
    ops = ops_for(subject, track)
1557
    results: list[dict[str, Any]] = []
1558
1559
    def timed(op: str) -> None:
1560
        if op in {"snapshot.initial", "snapshot.dirty", "task.snapshot"}:
1561
            results.extend(run_snapshot(subject, scenario, op, repo, run_index, metadata, admitted_output_chars))
1562
            return
1563
        before = oakbench_integrity.repository_state_fingerprint(repo) if op == "branch.create" else None
1564
        row = run_command(subject, scenario, op, repo, ops[op], run_index, metadata, admitted_output_chars)
1565
        if op == "branch.create":
1566
            apply_state_change_attestation(row, repo, before, op)
1567
        results.append(row)
1568
1569
    # Binary startup overhead in isolation. Agent fleets invoke the VCS
1570
    # thousands of times a day; process spawn cost is paid on every call.
1571
    results.append(
1572
        run_command(
1573
            subject,
1574
            scenario,
1575
            "proc.spawn",
1576
            repo,
1577
            [str(subject.bin), "--version"],
1578
            run_index,
1579
            metadata,
1580
            admitted_output_chars,
1581
        )
1582
    )
1583
1584
    timed("repo.init")
1585
    if subject.git_mode:
1586
        for setup_op, setup_args in WIRED_GIT_MODES[subject.git_mode]:
1587
            setup_row = run_command(
1588
                subject,
1589
                scenario,
1590
                f"mode.setup.{setup_op}",
1591
                repo,
1592
                [str(subject.bin), *setup_args],
1593
                run_index,
1594
                metadata,
1595
                admitted_output_chars,
1596
            )
1597
            results.append(setup_row)
1598
            if setup_row["returncode"] != 0:
1599
                # A tuned subject whose setup failed would silently measure
1600
                # stock git under a tuned label. Stop here: the failed setup
1601
                # row is the evidence, and the run exits nonzero.
1602
                setup_row["notes"] = (
1603
                    f"mode setup failed; remaining {subject.name} operations for this run "
1604
                    "were aborted so stock-git numbers are never published under a tuned label"
1605
                )
1606
                return results
1607
    if subject.git_mode == "lfs":
1608
        untracked = lfs_untracked_extensions(repo)
1609
        if untracked:
1610
            # config/git_modes.json git_lfs fairness_note: the mode "requires
1611
            # ... fixture generation with LFS-tracked binaries". A fixture file
1612
            # the track patterns miss would be committed as a plain git blob,
1613
            # and an untracked comparator is an unfair comparator β€” so the
1614
            # mode setup fails closed here and the scenario's rows become an
1615
            # honest skip instead of git numbers under the git_lfs label.
1616
            results.append(
1617
                remote_skip_row(
1618
                    subject,
1619
                    scenario,
1620
                    "mode.setup.lfs_track_coverage",
1621
                    run_index,
1622
                    metadata,
1623
                    "lfs_track_incomplete:" + ",".join(untracked),
1624
                )
1625
            )
1626
            return results
1627
    timed("snapshot.initial")
1628
    timed("status.clean")
1629
    if dirty_spectrum:
1630
        # Opt-in spectrum rows run here, on the clean committed tree, before
1631
        # the scenario's own dirty mutation; each step restores cleanliness so
1632
        # the pre-existing operation sequence below is untouched.
1633
        results.extend(
1634
            run_dirty_spectrum(
1635
                subject, scenario, repo, ops, run_index, metadata, skip_diff, admitted_output_chars
1636
            )
1637
        )
1638
    changed_paths = mutate_files(repo, scenario, subject, run_index)
1639
    timed("status.dirty")
1640
    if not skip_determinism_probe:
1641
        results.append(
1642
            run_determinism_probe(
1643
                subject, scenario, "status.dirty", repo, ops["status.dirty"], run_index, metadata
1644
            )
1645
        )
1646
        results.append(
1647
            run_information_probe(
1648
                subject, scenario, "status.dirty", repo, ops["status.dirty"], run_index, metadata, changed_paths
1649
            )
1650
        )
1651
    if not skip_diff:
1652
        diff_dirty_skip = git_huge_binary_diff_limit_reason(subject, scenario, "diff.dirty")
1653
        if diff_dirty_skip is not None:
1654
            results.append(
1655
                git_huge_binary_diff_skip_row(
1656
                    subject, scenario, "diff.dirty", ops["diff.dirty"], run_index, metadata
1657
                )
1658
            )
1659
            if not skip_determinism_probe:
1660
                results.append(
1661
                    git_huge_binary_diff_skip_row(
1662
                        subject,
1663
                        scenario,
1664
                        "diff.dirty.determinism",
1665
                        ops["diff.dirty"],
1666
                        run_index,
1667
                        metadata,
1668
                    )
1669
                )
1670
                results.append(
1671
                    git_huge_binary_diff_skip_row(
1672
                        subject,
1673
                        scenario,
1674
                        "diff.dirty.inforecall",
1675
                        ops["diff.dirty"],
1676
                        run_index,
1677
                        metadata,
1678
                    )
1679
                )
1680
                full_diff_command = [str(subject.bin), *semantics().diff_args(subject.kind, "core-equivalent")]
1681
                results.append(
1682
                    git_huge_binary_diff_skip_row(
1683
                        subject,
1684
                        scenario,
1685
                        "diff.full.inforecall",
1686
                        full_diff_command,
1687
                        run_index,
1688
                        metadata,
1689
                    )
1690
                )
1691
        else:
1692
            timed("diff.dirty")
1693
            if not skip_determinism_probe:
1694
                results.append(
1695
                    run_determinism_probe(
1696
                        subject, scenario, "diff.dirty", repo, ops["diff.dirty"], run_index, metadata
1697
                    )
1698
                )
1699
                results.append(
1700
                    run_information_probe(
1701
                        subject, scenario, "diff.dirty", repo, ops["diff.dirty"], run_index, metadata, changed_paths
1702
                    )
1703
                )
1704
                # Pipe-compatibility is a property of the FULL diff (what scripts
1705
                # and patch-tooling consume), independent of the run's track.
1706
                full_diff_command = [str(subject.bin), *semantics().diff_args(subject.kind, "core-equivalent")]
1707
                results.append(
1708
                    run_information_probe(
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
1709
                        subject, scenario, "diff.full", repo, full_diff_command, run_index, metadata, changed_paths,
1710
                        pristine_fixture=fixture,
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1711
                    )
1712
                )
1713
    timed("snapshot.dirty")
1714
    timed("branch.create")
1715
    mutate_files(repo, scenario, subject, run_index + 10_000)
1716
    timed("task.snapshot")
1717
    results.extend(
1718
        run_agent_native_json_probes(
1719
            subject, scenario, repo, run_index, metadata, admitted_output_chars
1720
        )
1721
    )
1722
    if not skip_remote:
1723
        results.extend(
1724
            run_remote_phase(subject, scenario, repo, run_index, metadata, admitted_output_chars)
1725
        )
1726
    if subject.git_mode == "fsmonitor":
1727
        # One fsmonitor daemon spawns per repo; stop it so repeated benchmark
1728
        # runs do not accumulate daemons watching deleted workdirs. Untimed and
1729
        # unrecorded: teardown, not a measured operation.
1730
        subprocess.run(
1731
            [str(subject.bin), "fsmonitor--daemon", "stop"],
1732
            cwd=repo,
1733
            env=base_env(),
1734
            stdout=subprocess.DEVNULL,
1735
            stderr=subprocess.DEVNULL,
1736
            check=False,
1737
        )
1738
    return results
1739
1740
1741
def remote_preflight_requirements(
1742
    subjects: list[Subject],
1743
    *,
1744
    skip_remote: bool,
1745
) -> list[oakbench_remotes.RemoteRequirement]:
1746
    if skip_remote or not any(subject.kind == "oak" for subject in subjects):
1747
        return []
1748
    return [
1749
        oakbench_remotes.RemoteRequirement(
1750
            purpose="default",
1751
            lane="core",
1752
            operations=tuple(OAK_NET_REMOTE_OPS),
1753
        )
1754
    ]
1755
1756
1757
def metric_medians(rows: list[dict[str, Any]], metric: str) -> dict[tuple[str, str], float]:
1758
    grouped: dict[tuple[str, str], list[float]] = {}
1759
    for row in rows:
1760
        if row["returncode"] != 0:
1761
            continue
1762
        op = row["operation"]
1763
        if op.endswith(".add") or op.endswith(".commit"):
1764
            continue
1765
        try:
1766
            value = float(row[metric])
1767
        except (KeyError, TypeError, ValueError):
1768
            continue
1769
        key = (row["subject"], row["scenario"] + "/" + op)
1770
        grouped.setdefault(key, []).append(value)
1771
    return {key: statistics.median(values) for key, values in grouped.items()}
1772
1773
1774
def average_by_subject(rows: list[dict[str, Any]], metric: str) -> dict[str, float]:
1775
    grouped: dict[str, list[float]] = {}
1776
    for row in rows:
1777
        if row["returncode"] != 0:
1778
            continue
1779
        op = row["operation"]
1780
        if op.endswith(".add") or op.endswith(".commit"):
1781
            continue
1782
        # Diagnostic and setup rows stay out of cross-operation subject
1783
        # averages so trend lines keep comparing the same op mix.
1784
        # remote.* ops stay out too: subjects without a wired remote skip them,
1785
        # and an asymmetric op mix would silently skew the cross-op average.
1786
        if (
1787
            op.endswith(".determinism")
1788
            or op.endswith(".inforecall")
1789
            or op.startswith("mode.setup")
1790
            or op.startswith("remote.")
1791
            or op == "proc.spawn"
1792
        ):
1793
            continue
1794
        try:
1795
            value = float(row[metric])
1796
        except (KeyError, TypeError, ValueError):
1797
            continue
1798
        grouped.setdefault(str(row["subject"]), []).append(value)
1799
    return {subject: statistics.mean(values) for subject, values in grouped.items()}
1800
1801
1802
# Delta math and formatting live in oakbench.reporting; these aliases keep
1803
# the historical local names used throughout the summary writer.
1804
fmt_delta = fmt_delta_lower_better
1805
fmt_float = fmt_num
1806
1807
1808
def summary_text(rows: list[dict[str, Any]], subjects: list[Subject]) -> str:
1809
    by_key = metric_medians(rows, "elapsed_ms")
1810
    token_by_key = metric_medians(rows, "estimated_tokens_total")
1811
    tools_by_key = metric_medians(rows, "tool_call_count")
1812
    output_by_key = metric_medians(rows, "raw_output_bytes")
1813
    avg_ms = average_by_subject(rows, "elapsed_ms")
1814
    avg_tokens = average_by_subject(rows, "estimated_tokens_total")
1815
    avg_cost = average_by_subject(rows, "estimated_cost_weighted_tokens")
1816
    avg_tools = average_by_subject(rows, "tool_call_count")
1817
    avg_output = average_by_subject(rows, "raw_output_bytes")
1818
    git_name = next((subject.name for subject in subjects if subject.kind == "git"), None)
1819
    oak_baseline = next((subject.name for subject in subjects if subject.name in {"oak_installed", "oak_main"}), None)
1820
    lines = [
1821
        "# Oak Benchmark Summary",
1822
        "",
1823
        f"Track: `{rows[0].get('benchmark_track', 'unknown') if rows else 'unknown'}`.",
1824
        "Positive deltas mean the row subject is lower/better than the baseline for that metric.",
1825
        "Estimated tokens are a dependency-free proxy: command text plus admitted stdout/stderr characters divided by four.",
1826
        "Tool calls are exact terminal/VCS command counts for this direct CLI harness.",
1827
        "Output bytes are full stdout/stderr byte counts, even when admitted token text was capped.",
1828
        f"Cost-weighted tokens use billing direction ({COST_WEIGHTS_NOTE}): commands an agent types are model output, stdout it reads is model input.",
1829
        "",
1830
        "## Subject Averages",
1831
        "",
1832
        "| Subject | Avg ms/op | Avg est tokens/op | Avg cost-weighted/op | Avg output bytes/op | Avg tool calls/op | vs Git tokens | vs Oak tokens | vs Git tools | vs Oak tools |",
1833
        "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
1834
    ]
1835
    git_avg_tokens = avg_tokens.get(git_name) if git_name else None
1836
    git_avg_tools = avg_tools.get(git_name) if git_name else None
1837
    oak_avg_tokens = avg_tokens.get(oak_baseline) if oak_baseline else None
1838
    oak_avg_tools = avg_tools.get(oak_baseline) if oak_baseline else None
1839
    for subject in subjects:
1840
        token_value = avg_tokens.get(subject.name)
1841
        tool_value = avg_tools.get(subject.name)
1842
        lines.append(
1843
            "| "
1844
            f"`{subject.name}` | "
1845
            f"{fmt_float(avg_ms.get(subject.name))} | "
1846
            f"{fmt_float(token_value)} | "
1847
            f"{fmt_float(avg_cost.get(subject.name))} | "
1848
            f"{fmt_float(avg_output.get(subject.name))} | "
1849
            f"{fmt_float(tool_value, 2)} | "
1850
            f"{fmt_delta(git_avg_tokens, token_value, subject.name == git_name)} | "
1851
            f"{fmt_delta(oak_avg_tokens, token_value, subject.name == oak_baseline or subject.kind == 'git')} | "
1852
            f"{fmt_delta(git_avg_tools, tool_value, subject.name == git_name)} | "
1853
            f"{fmt_delta(oak_avg_tools, tool_value, subject.name == oak_baseline or subject.kind == 'git')} |"
1854
        )
1855
1856
    info_rows = [row for row in rows if str(row.get("operation", "")).endswith(".inforecall")]
1857
    if info_rows:
1858
        lines.extend(
1859
            [
1860
                "",
1861
                "## Output Information Content",
1862
                "",
1863
                "Bytes saved are only a win if the changed-file list survives. Recall is the",
1864
                "fraction of ground-truth changed paths recoverable from the command output;",
1865
                "pipe-compat means diff output kept unified hunk/file structure (or an explicit",
1866
                "binary notice) that scripts and `patch`-style tooling can consume.",
1867
                "",
1868
                "| Subject | Operation | Mean recall | Mean bytes/file named | Pipe-compat rate |",
1869
                "| --- | --- | ---: | ---: | ---: |",
1870
            ]
1871
        )
1872
        grouped: dict[tuple[str, str], list[dict[str, Any]]] = {}
1873
        for row in info_rows:
1874
            grouped.setdefault((str(row["subject"]), str(row["operation"])), []).append(row)
1875
        for (subject_name, op), op_rows in sorted(grouped.items()):
1876
            recalls = [row["information_recall"] for row in op_rows if row.get("information_recall") is not None]
1877
            byte_costs = [
1878
                row["bytes_per_changed_file_named"]
1879
                for row in op_rows
1880
                if row.get("bytes_per_changed_file_named") is not None
1881
            ]
1882
            compat_flags = [row["pipe_compatible_unified"] for row in op_rows if "pipe_compatible_unified" in row]
1883
            recall_cell = f"{statistics.mean(recalls):.3f}" if recalls else ""
1884
            bytes_cell = f"{statistics.mean(byte_costs):.0f}" if byte_costs else ""
1885
            compat_cell = f"{sum(1 for flag in compat_flags if flag) / len(compat_flags) * 100:.0f}%" if compat_flags else ""
1886
            lines.append(f"| `{subject_name}` | `{op}` | {recall_cell} | {bytes_cell} | {compat_cell} |")
1887
1888
    lines.extend(
1889
        [
1890
            "",
1891
            "## Operation Medians",
1892
            "",
1893
            "| Scenario / operation | Subject | Median ms | Median est tokens | Median output bytes | Median tool calls | vs Git time | vs Git tokens | vs Git tools | vs Oak time | vs Oak tokens | vs Oak tools |",
1894
            "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
1895
        ]
1896
    )
1897
    all_ops = sorted({scenario_op for _, scenario_op in by_key})
1898
    for scenario_op in all_ops:
1899
        git_base = by_key.get((git_name, scenario_op)) if git_name else None
1900
        git_token_base = token_by_key.get((git_name, scenario_op)) if git_name else None
1901
        git_tool_base = tools_by_key.get((git_name, scenario_op)) if git_name else None
1902
        oak_base = by_key.get((oak_baseline, scenario_op)) if oak_baseline else None
1903
        oak_token_base = token_by_key.get((oak_baseline, scenario_op)) if oak_baseline else None
1904
        oak_tool_base = tools_by_key.get((oak_baseline, scenario_op)) if oak_baseline else None
1905
        for subject in subjects:
1906
            value = by_key.get((subject.name, scenario_op))
1907
            if value is None:
1908
                continue
1909
            token_value = token_by_key.get((subject.name, scenario_op))
1910
            tool_value = tools_by_key.get((subject.name, scenario_op))
1911
            output_value = output_by_key.get((subject.name, scenario_op))
1912
            vs_git_time = "" if git_base is None or subject.name == git_name else f"{delta_pct(git_base, value):+.1f}%"
1913
            vs_git_tokens = (
1914
                ""
1915
                if git_token_base is None or token_value is None or subject.name == git_name
1916
                else f"{delta_pct(git_token_base, token_value):+.1f}%"
1917
            )
1918
            vs_git_tools = (
1919
                ""
1920
                if git_tool_base is None or tool_value is None or subject.name == git_name
1921
                else f"{delta_pct(git_tool_base, tool_value):+.1f}%"
1922
            )
1923
            vs_oak_time = (
1924
                ""
1925
                if oak_base is None or subject.name == oak_baseline or subject.kind == "git"
1926
                else f"{delta_pct(oak_base, value):+.1f}%"
1927
            )
1928
            vs_oak_tokens = (
1929
                ""
1930
                if oak_token_base is None or token_value is None or subject.name == oak_baseline or subject.kind == "git"
1931
                else f"{delta_pct(oak_token_base, token_value):+.1f}%"
1932
            )
1933
            vs_oak_tools = (
1934
                ""
1935
                if oak_tool_base is None or tool_value is None or subject.name == oak_baseline or subject.kind == "git"
1936
                else f"{delta_pct(oak_tool_base, tool_value):+.1f}%"
1937
            )
1938
            # Unmeasured renders blank, never 0 (ADR-0002): diagnostic rows
1939
            # carry no token/tool fields and must not look like free output.
1940
            lines.append(
1941
                f"| `{scenario_op}` | `{subject.name}` | {value:.1f} | "
1942
                f"{fmt_float(token_value)} | "
1943
                f"{fmt_float(output_value, 0)} | "
1944
                f"{fmt_float(tool_value)} | "
1945
                f"{vs_git_time} | {vs_git_tokens} | {vs_git_tools} | "
1946
                f"{vs_oak_time} | {vs_oak_tokens} | {vs_oak_tools} |"
1947
            )
1948
    skips = [row for row in rows if row.get("skipped")]
1949
    if skips:
1950
        lines.extend(["", "## Skipped (unmeasured, never hidden)", ""])
1951
        seen_reasons: dict[tuple[str, str], int] = {}
1952
        for row in skips:
1953
            seen_reasons[(str(row["subject"]), str(row.get("skip_reason", "")))] = (
1954
                seen_reasons.get((str(row["subject"]), str(row.get("skip_reason", ""))), 0) + 1
1955
            )
1956
        for (subject_name, reason), count in sorted(seen_reasons.items()):
1957
            lines.append(f"- `{subject_name}`: {count} rows skipped β€” {reason}")
1958
    failures = [row for row in rows if row["returncode"] != 0 and not row.get("skipped")]
1959
    if failures:
1960
        lines.extend(["", "## Failures", ""])
1961
        for row in failures[:20]:
1962
            reason = row.get("integrity_failure_reason") or row.get("content_integrity_failure_reason") or row.get(
1963
                "stderr", ""
1964
            )
1965
            lines.append(
1966
                f"- `{row['subject']}` `{row['scenario']}/{row['operation']}` "
1967
                f"exit {row['returncode']}: `{str(reason).splitlines()[-1:]}`"
1968
            )
1969
    return "\n".join(lines) + "\n"
1970
1971
1972
def main() -> int:
1973
    args = parse_args()
1974
    subjects = expand_git_modes(load_subjects(args), args.git_modes)
1975
    oakbench_remotes.print_remote_preflight_warnings(
1976
        oakbench_remotes.oak_remote_preflight_warnings(
1977
            remote_preflight_requirements(subjects, skip_remote=args.skip_remote)
1978
        )
1979
    )
1980
    scenarios = PROFILES[args.profile]
1981
    if args.runs:
1982
        scenarios = [
1983
            Scenario(s.name, s.file_count, s.file_size, s.dirty_count, s.binary, args.runs)
1984
            for s in scenarios
1985
        ]
1986
1987
    timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
1988
    args.results.mkdir(parents=True, exist_ok=True)
1989
    args.workdir.mkdir(parents=True, exist_ok=True)
1990
    run_root = args.workdir / "runs" / timestamp
1991
    fixture_root = args.workdir
1992
1993
    metadata = {
1994
        "bench_id": timestamp,
1995
        "profile": args.profile,
1996
        "benchmark_track": args.track,
1997
        "command_semantics_version": COMMAND_SEMANTICS_VERSION,
1998
        "timestamp_utc": timestamp,
1999
        "host": platform.node(),
2000
        "platform": platform.platform(),
2001
        "machine": platform.machine(),
2002
        "python": platform.python_version(),
2003
        "env_isolation_version": oakbench_environment.ENV_ISOLATION_VERSION,
e9b767a51662 Preserve measured agent failures and validate ca 9 days ago
2004
        "output_oracle_version": oakbench_semantics.OUTPUT_ORACLE_VERSION,
2005
        "patch_oracle_version": oakbench_semantics.PATCH_ORACLE_VERSION,
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
2006
        "subject_versions": {subject.name: subject_version(subject) for subject in subjects},
2007
        "subject_details": subject_details(subjects),
2008
        "source": source_metadata(args.oak_repo),
2009
        "randomized_subject_order": bool(args.randomize_subject_order),
2010
        **runner_fields(),
2011
    }
2012
2013
    # Fixture generation runs OUTSIDE the measurement lock: building file
2014
    # trees parallelizes safely (atomic shape-keyed builds), only timed
2015
    # measurement must serialize across processes.
2016
    fixtures: dict[str, Path] = {}
2017
    for scenario in scenarios:
2018
        print(
2019
            f"[fixture] {scenario.name}: {scenario.file_count} files x {scenario.file_size} bytes",
2020
            flush=True,
2021
        )
2022
        fixtures[scenario.name] = ensure_fixture(fixture_root, scenario)
2023
2024
    metadata["load_tier"] = args.load_tier
2025
2026
    rows: list[dict[str, Any]] = []
2027
    with measurement_lock("bench") as lock_info:
2028
        metadata["measurement_lock_wait_ms"] = lock_info.wait_ms
2029
        metadata["measurement_lock"] = "held" if lock_info.enabled else "disabled"
2030
        # Background load (untimed start/stop). Failing to start under a
2031
        # non-none tier converts the whole invocation to skip rows: load that
2032
        # was not applied is never claimed.
2033
        load_generator: oakbench_loadgen.LoadGenerator | None = None
2034
        if args.load_tier != "none":
2035
            load_generator = oakbench_loadgen.LoadGenerator(
2036
                args.load_tier, args.workdir / "loadgen-temp"
2037
            )
2038
            load_start = load_generator.start()
2039
            if not load_start["started"]:
2040
                reason = oakbench_loadgen.load_tier_unavailable_reason(str(load_start["reason"]))
2041
                rows = load_tier_skip_rows(subjects, scenarios, metadata, reason, args.load_tier)
2042
                load_generator = None
2043
        try:
2044
            if not rows:
2045
                for scenario in scenarios:
2046
                    fixture = fixtures[scenario.name]
2047
                    for run_index in range(scenario.runs):
2048
                        run_subjects = list(subjects)
2049
                        if args.randomize_subject_order:
2050
                            random.Random(f"{timestamp}:{scenario.name}:{run_index}").shuffle(run_subjects)
2051
                        run_metadata = {
2052
                            **metadata,
2053
                            "subject_order": [subject.name for subject in run_subjects],
2054
                        }
2055
                        for subject in run_subjects:
2056
                            print(f"[run] {scenario.name} run={run_index} subject={subject.name}", flush=True)
2057
                            env_before = oakbench_envwatch.sample_environment()
2058
                            subject_rows = run_subject_scenario(
2059
                                subject,
2060
                                scenario,
2061
                                fixture,
2062
                                run_index,
2063
                                run_root,
2064
                                run_metadata,
2065
                                args.skip_diff,
2066
                                args.admitted_output_chars,
2067
                                args.track,
2068
                                args.skip_determinism_probe,
2069
                                args.skip_remote,
2070
                                args.dirty_spectrum,
2071
                            )
2072
                            env_after = oakbench_envwatch.sample_environment()
2073
                            boundary = oakbench_loadgen.environment_boundary_fields(
2074
                                args.load_tier, env_before, env_after
2075
                            )
2076
                            for row in subject_rows:
2077
                                row.update(boundary)
2078
                            rows.extend(subject_rows)
2079
        finally:
2080
            if load_generator is not None:
2081
                load_stop = load_generator.stop()
2082
                # bogo-ops parsed ONLY as did-the-load-run verification,
2083
                # never reported as a benchmark number.
2084
                for row in rows:
2085
                    row["load_verified"] = load_stop.get("verified")
2086
2087
    store = ResultsStore(args.results, lane="core")
2088
    raw_path, summary_path = store.write(timestamp, rows, summary_text(rows, subjects))
2089
2090
    if not args.keep_workdirs:
2091
        shutil.rmtree(run_root, ignore_errors=True)
2092
2093
    print(f"[result] {raw_path}")
2094
    print(f"[summary] {summary_path}")
2095
    return 1 if any(row["returncode"] != 0 and not row.get("skipped") for row in rows) else 0
2096
2097
2098
if __name__ == "__main__":
2099
    raise SystemExit(main())