Viewing files at commit 2310099b696dCommit detailsView latest

scripts/bench.py 2102 lines · 82.4 KB · python
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
506
PATCH_PROBE_MAX_BYTES = 16 * 1024 * 1024
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],
518
    pristine_fixture: Path | None = None,
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
    """
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"))
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,
558
        "output_format": output_format,
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,
564
            output_format,
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))
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
            if tree_manifest(cwd) != expected_manifest:
590
                row.update(patch_apply_ok=False, patch_failure_reason="diff_mutated_subject_tree")
591
            if not scenario.binary and row.get("patch_apply_ok") is False:
592
                row.update(returncode=1, integrity_ok=False,
593
                           integrity_failure_reason=row.get("patch_failure_reason"))
594
    if capture.returncode != 0:
595
        row["stderr"] = capture.stderr_text[-4000:]
596
    return row
597
598
599
REMOTE_OPS = ("remote.push.first", "remote.clone.cold", "remote.pull.uptodate")
600
# Oak remote ops are network-transport (a real Oak server) while git's are
601
# local-file; different transports are different measurements, so the names
602
# differ (ADR-0005) and no report ever computes a git-vs-oak delta between
603
# them. The oak ops answer the release-blocking trend: did this changeset
604
# make oak's real push/clone/sync slower?
605
OAK_NET_REMOTE_OPS = ("remote.net.push.first", "remote.net.clone.cold", "remote.net.fetch.uptodate")
606
OAK_REMOTE_ENV = oakbench_remotes.OAK_REMOTE_ENV
607
608
609
def apply_state_change_attestation(
610
    row: dict[str, Any],
611
    repo: Path,
612
    before_fingerprint: str | None,
613
    op: str,
614
    *,
615
    expected_hashes: dict[str, str] | None = None,
616
    expected_payloads: dict[str, str | bytes] | None = None,
617
) -> dict[str, Any]:
618
    process_returncode = row_returncode(row)
619
    fields = oakbench_integrity.state_change_fields(
620
        repo,
621
        before_fingerprint,
622
        operation=op,
623
        process_returncode=process_returncode,
624
    )
625
    row["process_returncode"] = process_returncode
626
    row.update(fields)
627
    failure_reason = fields["integrity_failure_reason"] if not fields["integrity_check_passed"] else None
628
    if expected_hashes or expected_payloads:
629
        content_fields = oakbench_integrity.content_integrity_fields(
630
            repo,
631
            operation=op,
632
            process_returncode=process_returncode,
633
            expected_hashes=expected_hashes,
634
            expected_payloads=expected_payloads,
635
        )
636
        row.update(content_fields)
637
        if not content_fields["content_integrity_check_passed"] and failure_reason is None:
638
            failure_reason = content_fields["content_integrity_failure_reason"]
639
    if process_returncode == 0 and failure_reason is not None:
640
        row["returncode"] = 1
641
        row["stderr"] = str(failure_reason or "integrity_check_failed")
642
    return row
643
644
645
def remote_skip_row(
646
    subject: Subject,
647
    scenario: Scenario,
648
    op: str,
649
    run_index: int,
650
    metadata: dict[str, Any],
651
    reason: str,
652
) -> dict[str, Any]:
653
    return {
654
        **metadata,
655
        **subject_provenance(subject, metadata),
656
        "subject": subject.name,
657
        "subject_kind": subject.kind,
658
        "subject_label": subject.label,
659
        "git_mode": subject.git_mode,
660
        "scenario": scenario.name,
661
        "run": run_index,
662
        "operation": op,
663
        "elapsed_ms": 0.0,
664
        "returncode": 77,
665
        "command": [],
666
        "skipped": True,
667
        "skip_reason": reason,
668
        "tool_call_count": 0,
669
        "vcs_tool_call_count": 0,
670
        "terminal_tool_call_count": 0,
671
    }
672
673
674
def git_huge_binary_diff_limit_reason(subject: Subject, scenario: Scenario, op: str) -> str | None:
675
    if subject.kind != "git":
676
        return None
677
    if subject.git_mode == "lfs":
678
        return None
679
    if op not in GIT_HUGE_BINARY_DIFF_OPS:
680
        return None
681
    if not scenario.binary or scenario.file_size < GIT_HUGE_BINARY_DIFF_LIMIT_BYTES:
682
        return None
683
    return (
684
        "git_huge_binary_diff_fixture_limit: stock/tuned git diff rows are intentionally "
685
        "skipped for generated >=1GiB binary fixtures because git may fail while generating "
686
        "binary diffstat; use git_lfs or smaller binary fixtures for this capability"
687
    )
688
689
690
def git_huge_binary_diff_skip_row(
691
    subject: Subject,
692
    scenario: Scenario,
693
    op: str,
694
    command: list[str],
695
    run_index: int,
696
    metadata: dict[str, Any],
697
) -> dict[str, Any]:
698
    reason = git_huge_binary_diff_limit_reason(subject, scenario, op)
699
    if reason is None:
700
        raise ValueError(f"{op} is not a huge-binary git diff skip")
701
    row = remote_skip_row(subject, scenario, op, run_index, metadata, reason)
702
    row.update(
703
        {
704
            "command": command,
705
            "failure_classification": "git_huge_binary_diff_fixture_limit",
706
            "notes": (
707
                "preflighted fixture-limit skip; no subprocess was run for this row, "
708
                "so stderr is intentionally empty"
709
            ),
710
        }
711
    )
712
    return row
713
714
715
_OAK_JSON_SUPPORT_CACHE: dict[tuple[str, tuple[str, ...], tuple[str, ...]], str | None] = {}
716
_OAK_VERSION_CACHE: dict[str, tuple[int, int, int] | None] = {}
717
718
719
def parse_semver_triplet(text: str) -> tuple[int, int, int] | None:
720
    match = re.search(r"\b(\d+)\.(\d+)\.(\d+)\b", text)
721
    if match is None:
722
        return None
723
    return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
724
725
726
def oak_cli_semver(vcs: str) -> tuple[int, int, int] | None:
727
    if vcs in _OAK_VERSION_CACHE:
728
        return _OAK_VERSION_CACHE[vcs]
729
    try:
730
        probe = subprocess.run(
731
            [vcs, "--version"],
732
            capture_output=True,
733
            text=True,
734
            env=base_env(),
735
            timeout=15,
736
            check=False,
737
        )
738
    except (FileNotFoundError, subprocess.TimeoutExpired):
739
        _OAK_VERSION_CACHE[vcs] = None
740
        return None
741
    version = parse_semver_triplet((probe.stdout or "") + "\n" + (probe.stderr or ""))
742
    _OAK_VERSION_CACHE[vcs] = version
743
    return version
744
745
746
def oak_json_probe_version_skip_reason(vcs: str, spec: dict[str, Any]) -> str | None:
747
    minimum = spec.get("minimum_oak_version")
748
    if not minimum:
749
        return None
750
    required = parse_semver_triplet(str(minimum))
751
    actual = oak_cli_semver(vcs)
752
    if required is None:
753
        return f"capability_gap:oak_json_probe_bad_minimum_version:{minimum}"
754
    if actual is None:
755
        return f"capability_gap:oak_json_probe_version_unknown:requires_oak>={minimum}"
756
    if actual < required:
757
        actual_text = ".".join(str(part) for part in actual)
758
        return f"capability_gap:oak_json_probe_requires_oak>={minimum}:found_{actual_text}"
759
    return None
760
761
762
def oak_json_probe_skip_reason(vcs: str, help_args: list[str], command_args: list[str]) -> str | None:
763
    """Return a capability skip reason, or None when the JSON command is present."""
764
    key = (vcs, tuple(help_args), tuple(command_args))
765
    if key in _OAK_JSON_SUPPORT_CACHE:
766
        return _OAK_JSON_SUPPORT_CACHE[key]
767
    if not help_args:
768
        reason = "capability_gap:oak_json_probe_help_missing"
769
        _OAK_JSON_SUPPORT_CACHE[key] = reason
770
        return reason
771
    try:
772
        probe = subprocess.run(
773
            [vcs, *help_args],
774
            capture_output=True,
775
            text=True,
776
            env=base_env(),
777
            timeout=15,
778
            check=False,
779
        )
780
    except FileNotFoundError:
781
        reason = f"missing_binary:{vcs}"
782
        _OAK_JSON_SUPPORT_CACHE[key] = reason
783
        return reason
784
    except subprocess.TimeoutExpired:
785
        reason = "capability_gap:oak_json_probe_help_timeout:" + " ".join(help_args)
786
        _OAK_JSON_SUPPORT_CACHE[key] = reason
787
        return reason
788
    help_text = (probe.stdout or "") + "\n" + (probe.stderr or "")
789
    if probe.returncode != 0:
790
        reason = "capability_gap:oak_json_probe_help_failed:" + " ".join(help_args)
791
    elif "--json" in command_args and "--json" not in help_text:
792
        reason = "capability_gap:oak_json_flag_missing:" + " ".join(command_args)
793
    else:
794
        reason = None
795
    _OAK_JSON_SUPPORT_CACHE[key] = reason
796
    return reason
797
798
799
def json_field_oracle_fields(stdout_text: str, required_fields: list[str]) -> dict[str, Any]:
800
    """JSON parse and top-level required-field completeness for CLI capability rows."""
801
    base: dict[str, Any] = {
802
        "json_required_fields": required_fields,
803
        "json_required_field_count": len(required_fields),
804
    }
805
    try:
806
        payload = json.loads(stdout_text)
807
    except json.JSONDecodeError as exc:
808
        return {
809
            **base,
810
            "json_parse_success": False,
811
            "json_payload_type": None,
812
            "json_present_required_field_count": 0,
813
            "json_missing_required_fields": required_fields,
814
            "json_field_completeness": 0.0 if required_fields else None,
815
            "json_oracle_passed": False,
816
            "json_error": f"{exc.msg} at line {exc.lineno} column {exc.colno}",
817
        }
818
    payload_type = type(payload).__name__
819
    if not isinstance(payload, dict):
820
        return {
821
            **base,
822
            "json_parse_success": True,
823
            "json_payload_type": payload_type,
824
            "json_present_required_field_count": 0,
825
            "json_missing_required_fields": required_fields,
826
            "json_field_completeness": 0.0 if required_fields else None,
827
            "json_oracle_passed": False,
828
            "json_error": "expected top-level JSON object",
829
        }
830
    missing = [field for field in required_fields if field not in payload]
831
    present = len(required_fields) - len(missing)
832
    return {
833
        **base,
834
        "json_parse_success": True,
835
        "json_payload_type": payload_type,
836
        "json_present_required_field_count": present,
837
        "json_missing_required_fields": missing,
838
        "json_field_completeness": round(present / len(required_fields), 4) if required_fields else None,
839
        "json_oracle_passed": not missing,
840
    }
841
842
843
def json_field_oracle_unmeasured_fields(required_fields: list[str], reason: str) -> dict[str, Any]:
844
    """Null JSON validation fields when the runner did not capture a complete payload."""
845
    return {
846
        "json_required_fields": required_fields,
847
        "json_required_field_count": len(required_fields),
848
        "json_parse_success": None,
849
        "json_payload_type": None,
850
        "json_present_required_field_count": None,
851
        "json_missing_required_fields": None,
852
        "json_field_completeness": None,
853
        "json_oracle_passed": None,
854
        "json_validation_unmeasured_reason": reason,
855
    }
856
857
858
def run_agent_native_json_probe(
859
    subject: Subject,
860
    scenario: Scenario,
861
    op: str,
862
    cwd: Path,
863
    spec: dict[str, Any],
864
    run_index: int,
865
    metadata: dict[str, Any],
866
    admitted_output_chars: int,
867
) -> dict[str, Any]:
868
    vcs = str(subject.bin)
869
    command_args = list(spec["args"])
870
    help_args = list(spec["help_args"])
871
    command = [vcs, *command_args]
872
    skip_reason = oak_json_probe_version_skip_reason(vcs, spec)
873
    if skip_reason is None:
874
        skip_reason = oak_json_probe_skip_reason(vcs, help_args, command_args)
875
    if skip_reason is not None:
876
        row = remote_skip_row(subject, scenario, op, run_index, metadata, skip_reason)
877
        row["intended_command"] = command
878
        row["capability_probe_command"] = [vcs, *help_args]
879
        if spec.get("minimum_oak_version") is not None:
880
            row["minimum_oak_version"] = str(spec["minimum_oak_version"])
881
        return row
882
883
    capture = run_timed(
884
        command,
885
        cwd,
886
        base_env(),
887
        admitted_output_chars,
888
        full_output_bytes=JSON_PROBE_MAX_BYTES,
889
    )
890
    required_fields = list(spec["required_fields"])
891
    if capture.stdout_full_text is not None and capture.stdout_full_text_truncated is False:
892
        json_validation_source = "full_stdout_capture"
893
        json_fields = json_field_oracle_fields(capture.stdout_full_text, required_fields)
894
    elif capture.stdout_full_text_truncated is True or (
895
        capture.stdout_full_text is None and capture.stdout_truncated
896
    ):
897
        json_validation_source = None
898
        json_fields = json_field_oracle_unmeasured_fields(
899
            required_fields,
900
            "stdout_exceeded_json_validation_capture_limit",
901
        )
902
    else:
903
        json_validation_source = "admitted_stdout_capture"
904
        json_fields = json_field_oracle_fields(capture.stdout_text, required_fields)
905
    result = {
906
        **metadata,
907
        **subject_provenance(subject, metadata),
908
        "subject": subject.name,
909
        "subject_kind": subject.kind,
910
        "subject_label": subject.label,
911
        "git_mode": subject.git_mode,
912
        "scenario": scenario.name,
913
        "run": run_index,
914
        "operation": op,
915
        "elapsed_ms": round(capture.elapsed_ms, 3),
916
        "returncode": capture.returncode,
917
        "command": command,
918
        "peak_rss_bytes": capture.peak_rss_bytes,
919
        "peak_rss_source": PEAK_RSS_SOURCE if capture.peak_rss_bytes is not None else None,
920
        "measurement_source": "direct_cli_timed_subprocess_json_capability_probe",
921
        "probe_output_bytes": capture.stdout_bytes + capture.stderr_bytes,
922
        "output_format": spec.get("output_format", "json"),
923
        **interaction_metrics(
924
            command,
925
            capture.stdout_text,
926
            capture.stderr_text,
927
            capture.stdout_bytes,
928
            capture.stderr_bytes,
929
            capture.stdout_truncated,
930
            capture.stderr_truncated,
931
            tool_call_count=1,
932
        ),
933
        "json_validation_source": json_validation_source,
934
        **json_fields,
935
    }
936
    if capture.returncode != 0:
937
        result["stderr"] = capture.stderr_text[-4000:]
938
    elif result["json_oracle_passed"] is False:
939
        result["returncode"] = 1
940
        result["stderr"] = str(result.get("json_error") or "json_required_fields_missing")
941
    return result
942
943
944
def run_agent_native_json_probes(
945
    subject: Subject,
946
    scenario: Scenario,
947
    cwd: Path,
948
    run_index: int,
949
    metadata: dict[str, Any],
950
    admitted_output_chars: int,
951
) -> list[dict[str, Any]]:
952
    probes = semantics().agent_native_json_probes(subject.kind, BENCH_TASK_BRANCH)
953
    return [
954
        run_agent_native_json_probe(
955
            subject, scenario, op, cwd, spec, run_index, metadata, admitted_output_chars
956
        )
957
        for op, spec in probes.items()
958
    ]
959
960
961
def load_tier_skip_row(
962
    subject: Subject,
963
    scenario: Scenario,
964
    run_index: int,
965
    metadata: dict[str, Any],
966
    reason: str,
967
    load_tier: str,
968
) -> dict[str, Any]:
969
    """Explicit skip row for a load tier the host cannot apply.
970
971
    A row may never claim load that was not applied: environment_suspect is
972
    None (suspicion machinery did not run, ADR-0002) and load_tier records the
973
    REQUESTED tier so the gap is visible, not hidden.
974
    """
975
    row = remote_skip_row(subject, scenario, "scenario.load_tier_skipped", run_index, metadata, reason)
976
    row["load_tier"] = load_tier
977
    row["environment_suspect"] = None
978
    return row
979
980
981
def load_tier_skip_rows(
982
    subjects: list[Subject],
983
    scenarios: list[Scenario],
984
    metadata: dict[str, Any],
985
    reason: str,
986
    load_tier: str,
987
) -> list[dict[str, Any]]:
988
    """One skip row per scenario x run x subject for an unappliable load tier."""
989
    return [
990
        load_tier_skip_row(subject, scenario, run_index, metadata, reason, load_tier)
991
        for scenario in scenarios
992
        for run_index in range(scenario.runs)
993
        for subject in subjects
994
    ]
995
996
997
def oak_network_branch_name(
998
    bench_id: str,
999
    scenario_name: str,
1000
    subject_name: str,
1001
    run_index: int,
1002
    *,
1003
    pid: int | None = None,
1004
    token: str | None = None,
1005
) -> str:
1006
    """Disposable remote branch name: readable prefix plus per-run uniquifier."""
1007
    return oakbench_remotes.disposable_branch(
1008
        bench_id, scenario_name, subject_name, run_index, pid=pid, token=token
1009
    )
1010
1011
1012
def run_oak_network_remote_phase(
1013
    subject: Subject,
1014
    scenario: Scenario,
1015
    repo: Path,
1016
    run_index: int,
1017
    metadata: dict[str, Any],
1018
    admitted_output_chars: int,
1019
    remote_repo: str,
1020
) -> list[dict[str, Any]]:
1021
    """Push to, clone from, and sync with a real Oak server.
1022
1023
    Network transport: latency includes the server round trip, so these rows
1024
    are oak-vs-previous-oak trend evidence, never a git-vs-oak comparison
1025
    (git's remote phase is local-file by construction).
1026
1027
    Same-named branches with unrelated histories conflict on the server, so
1028
    each run pushes a unique bench branch (untimed setup); the disposable
1029
    remote accumulates branches by design.
1030
    """
1031
    rows: list[dict[str, Any]] = []
1032
    vcs = str(subject.bin)
1033
    bench_id = str(metadata.get("bench_id", "bench"))
1034
    branch_name = oak_network_branch_name(bench_id, scenario.name, subject.name, run_index)
1035
    clone_dir = repo.parent / f"netclone-{subject.name}-{run_index}"
1036
    # Untimed: a unique branch is a precondition (same-named unrelated
1037
    # histories are rejected by the server), not the measurement.
1038
    subprocess.run(
1039
        [vcs, "switch", "-c", branch_name],
1040
        cwd=repo,
1041
        env=base_env(),
1042
        stdout=subprocess.DEVNULL,
1043
        stderr=subprocess.DEVNULL,
1044
        check=False,
1045
    )
1046
    push_row = run_command(
1047
        subject,
1048
        scenario,
1049
        "remote.net.push.first",
1050
        repo,
1051
        [vcs, "push", "--repo", remote_repo],
1052
        run_index,
1053
        metadata,
1054
        admitted_output_chars,
1055
    )
1056
    rows.append({**push_row, "remote_repo": remote_repo, "remote_transport": "network", "cache_state": "cold_remote_branch"})
1057
    clone_row = run_command(
1058
        subject,
1059
        scenario,
1060
        "remote.net.clone.cold",
1061
        repo.parent,
1062
        [vcs, "clone", remote_repo, str(clone_dir)],
1063
        run_index,
1064
        metadata,
1065
        admitted_output_chars,
1066
    )
1067
    rows.append(
1068
        {
1069
            **clone_row,
1070
            "remote_repo": remote_repo,
1071
            "remote_transport": "network",
1072
            "cache_state": "cold_objects_network",
1073
            "notes": "clones the disposable repo's default content, not this scenario's fixture; comparable only to other remote.net.clone.cold rows",
1074
        }
1075
    )
1076
    if clone_row["returncode"] == 0:
1077
        fetch_row = run_command(
1078
            subject,
1079
            scenario,
1080
            "remote.net.fetch.uptodate",
1081
            clone_dir,
1082
            [vcs, "fetch"],
1083
            run_index,
1084
            metadata,
1085
            admitted_output_chars,
1086
        )
1087
        rows.append({**fetch_row, "remote_repo": remote_repo, "remote_transport": "network", "cache_state": "warm"})
1088
    return rows
1089
1090
1091
def run_remote_phase(
1092
    subject: Subject,
1093
    scenario: Scenario,
1094
    repo: Path,
1095
    run_index: int,
1096
    metadata: dict[str, Any],
1097
    admitted_output_chars: int,
1098
) -> list[dict[str, Any]]:
1099
    """Push to, clone from, and sync with a local remote.
1100
1101
    Agents pay clone/pull/push constantly: every fresh workspace is a cold
1102
    clone, every task start is a pull, every task end is a push. A local
1103
    filesystem remote isolates the VCS object-transfer and checkout cost from
1104
    network jitter; "cold" means no local object store, while the OS page
1105
    cache stays warm (recorded in cache_state so rows never overclaim).
1106
1107
    Oak's remote model needs a configured server remote; until one is wired,
1108
    Oak rows are explicit skips (returncode 77), never silently absent.
1109
    """
1110
    if subject.kind != "git":
1111
        resolution = oakbench_remotes.resolve_oak_remote()
1112
        remote_repo = resolution.repo or ""
1113
        if not remote_repo:
1114
            reason = (
1115
                f"oak_remote_not_configured: set {OAK_REMOTE_ENV} to a DISPOSABLE ORG/REPO "
1116
                "(e.g. oak/oak-benchmarks-tmp) to measure real-server push/clone/fetch"
1117
            )
1118
            return [
1119
                remote_skip_row(subject, scenario, op, run_index, metadata, reason)
1120
                for op in OAK_NET_REMOTE_OPS
1121
            ]
1122
        return run_oak_network_remote_phase(
1123
            subject, scenario, repo, run_index, metadata, admitted_output_chars, remote_repo
1124
        )
1125
1126
    rows: list[dict[str, Any]] = []
1127
    vcs = str(subject.bin)
1128
    # Untimed setup: the remote existing is a precondition, not the measurement.
1129
    bare = oakbench_remotes.make_git_bare_remote(
1130
        vcs, repo.parent, name=f"remote-{subject.name}-{run_index}.git"
1131
    )
1132
    remote_dir = Path(bare.repo)
1133
    clone_dir = repo.parent / f"clone-{subject.name}-{run_index}"
1134
    oakbench_remotes.attach_git_origin(vcs, repo, bare)
1135
    push_row = run_command(
1136
        subject,
1137
        scenario,
1138
        "remote.push.first",
1139
        repo,
1140
        [vcs, "push", "origin", "HEAD"],
1141
        run_index,
1142
        metadata,
1143
        admitted_output_chars,
1144
    )
1145
    rows.append({**push_row, "cache_state": "cold_remote_warm_page_cache", "remote_transport": "local_file"})
1146
    # Untimed: point the bare remote's HEAD at the pushed branch so the clone
1147
    # checks out a working tree (precondition, not measurement).
1148
    branch_name = oakbench_remotes.current_git_branch(vcs, repo)
1149
    oakbench_remotes.point_git_bare_head(vcs, bare, branch_name)
1150
    clone_row = run_command(
1151
        subject,
1152
        scenario,
1153
        "remote.clone.cold",
1154
        repo.parent,
1155
        [vcs, "clone", str(remote_dir), str(clone_dir)],
1156
        run_index,
1157
        metadata,
1158
        admitted_output_chars,
1159
    )
1160
    rows.append({**clone_row, "cache_state": "cold_objects_warm_page_cache", "remote_transport": "local_file"})
1161
    if clone_row["returncode"] == 0:
1162
        pull_row = run_command(
1163
            subject,
1164
            scenario,
1165
            "remote.pull.uptodate",
1166
            clone_dir,
1167
            [vcs, "pull"],
1168
            run_index,
1169
            metadata,
1170
            admitted_output_chars,
1171
        )
1172
        rows.append({**pull_row, "cache_state": "warm", "remote_transport": "local_file"})
1173
    return rows
1174
1175
1176
def run_command(
1177
    subject: Subject,
1178
    scenario: Scenario,
1179
    op: str,
1180
    cwd: Path,
1181
    command: list[str],
1182
    run_index: int,
1183
    metadata: dict[str, Any],
1184
    admitted_output_chars: int,
1185
) -> dict[str, Any]:
1186
    capture = run_timed(command, cwd, base_env(), admitted_output_chars)
1187
    result = {
1188
        **metadata,
1189
        **subject_provenance(subject, metadata),
1190
        "subject": subject.name,
1191
        "subject_kind": subject.kind,
1192
        "subject_label": subject.label,
1193
        "git_mode": subject.git_mode,
1194
        "scenario": scenario.name,
1195
        "run": run_index,
1196
        "operation": op,
1197
        "elapsed_ms": round(capture.elapsed_ms, 3),
1198
        "returncode": capture.returncode,
1199
        "command": command,
1200
        "peak_rss_bytes": capture.peak_rss_bytes,
1201
        "peak_rss_source": PEAK_RSS_SOURCE if capture.peak_rss_bytes is not None else None,
1202
        "measurement_source": "direct_cli_timed_subprocess",
1203
        **interaction_metrics(
1204
            command,
1205
            capture.stdout_text,
1206
            capture.stderr_text,
1207
            capture.stdout_bytes,
1208
            capture.stderr_bytes,
1209
            capture.stdout_truncated,
1210
            capture.stderr_truncated,
1211
            tool_call_count=1,
1212
        ),
1213
    }
1214
    if capture.returncode != 0:
1215
        result["stderr"] = capture.stderr_text[-4000:]
1216
    return result
1217
1218
1219
def write_pattern_file(path: Path, size: int, seed: str, binary: bool) -> None:
1220
    oakbench_fixtures.write_pattern_file(path, size, seed, binary=binary, prefix="oakbench")
1221
1222
1223
def scenario_shape_id(scenario: Scenario) -> str:
1224
    """Fixture identity is the byte shape, not the scenario label.
1225
1226
    Keying the cache on shape (and recording it in the ready marker) makes a
1227
    name collision across profiles a cache miss instead of silent corruption:
1228
    a smoke run can never poison a later standard run sharing the workdir.
1229
    """
1230
    kind = "bin" if scenario.binary else "txt"
1231
    return f"{scenario.file_count}x{scenario.file_size}-{kind}"
1232
1233
1234
def fixture_path(root: Path, scenario: Scenario) -> Path:
1235
    return root / "fixtures" / f"{scenario.name}-{scenario_shape_id(scenario)}"
1236
1237
1238
def ensure_fixture(root: Path, scenario: Scenario) -> Path:
1239
    dest = fixture_path(root, scenario)
1240
    marker = dest / ".fixture-ready"
1241
    expected_marker = f"{scenario_shape_id(scenario)}\n"
1242
    if marker.exists() and marker.read_text() == expected_marker:
1243
        return dest
1244
    if dest.exists():
1245
        if marker.exists() and marker.read_text() == expected_marker:
1246
            return dest
1247
        shutil.rmtree(dest)
1248
1249
    temp = dest.parent / f"{dest.name}.building-{os.getpid()}"
1250
    if temp.exists():
1251
        shutil.rmtree(temp)
1252
    temp.mkdir(parents=True)
1253
    try:
1254
        ext = "bin" if scenario.binary else "txt"
1255
        width = max(4, len(str(scenario.file_count)))
1256
        for index in range(scenario.file_count):
1257
            subdir = temp / f"group-{index // 1000:03d}"
1258
            path = subdir / f"file-{index:0{width}d}.{ext}"
1259
            write_pattern_file(path, scenario.file_size, f"{scenario.name}:{index}", scenario.binary)
1260
        (temp / ".fixture-ready").write_text(expected_marker)
1261
        try:
1262
            os.rename(temp, dest)
1263
        except OSError:
1264
            if marker.exists() and marker.read_text() == expected_marker:
1265
                shutil.rmtree(temp)
1266
                return dest
1267
            got = marker.read_text().strip() if marker.exists() else "missing"
1268
            raise RuntimeError(
1269
                f"fixture {dest} exists with wrong shape marker "
1270
                f"(expected {expected_marker.strip()!r}, got {got!r})"
1271
            ) from None
1272
        return dest
1273
    except Exception:
1274
        if temp.exists():
1275
            shutil.rmtree(temp)
1276
        raise
1277
1278
1279
def mutate_files(repo: Path, scenario: Scenario, subject: Subject, run_index: int) -> list[str]:
1280
    """Mutate the scenario's dirty set; returns repo-relative changed paths.
1281
1282
    Mutation bytes are identical across subjects within each run.
1283
    The returned list is the ground truth the information-recall probe scores
1284
    status/diff output against.
1285
    """
1286
    ext = "bin" if scenario.binary else "txt"
1287
    paths = sorted(repo.glob(f"group-*/*.{ext}"))[: scenario.dirty_count]
1288
    if not paths and scenario.file_count == 1:
1289
        paths = sorted(repo.glob(f"**/*.{ext}"))[:1]
1290
    for index, path in enumerate(paths):
1291
        marker = f"\nmutated {scenario.name} {run_index} {index}\n".encode()
1292
        if scenario.binary:
1293
            with path.open("r+b") as fh:
1294
                size = path.stat().st_size
1295
                fh.seek(max(0, size // 2 - len(marker) // 2))
1296
                fh.write(marker[: min(len(marker), max(1, size))])
1297
        else:
1298
            with path.open("ab") as fh:
1299
                fh.write(marker)
1300
    return [path.relative_to(repo).as_posix() for path in paths]
1301
1302
1303
def copy_fixture(src: Path, dest: Path) -> None:
1304
    oakbench_fixtures.copy_fixture(src, dest, ignore_marker=True)
1305
1306
1307
# Dirty-tree spectrum (opt-in via --dirty-spectrum). pNN means NN% of the
1308
# fixture's tracked files are dirtied (ceil) before the timed command; the
1309
# dirtying and the restore are untimed setup/teardown. Scale is encoded in the
1310
# operation name per ADR-0005: status.dirty.p01 is a different measurement
1311
# than status.dirty and never shares its trend line. snapshot.dirty.pNN is
1312
# deliberately NOT implemented: a snapshot commits, so restoring cleanliness
1313
# would require history surgery or a re-copied fixture per step — not cheap,
1314
# and it would change the repository state every later operation sees.
1315
DIRTY_SPECTRUM_FRACTIONS: tuple[tuple[str, float], ...] = (
1316
    ("p01", 0.01),
1317
    ("p10", 0.10),
1318
    ("p50", 0.50),
1319
)
1320
DIRTY_SPECTRUM_BASE_OPS: tuple[str, ...] = ("status.dirty", "diff.dirty")
1321
1322
1323
def dirty_spectrum_operations(enabled: bool, skip_diff: bool = False) -> list[str]:
1324
    """Operation names the spectrum adds. Empty when the flag is off."""
1325
    if not enabled:
1326
        return []
1327
    names: list[str] = []
1328
    for label, _fraction in DIRTY_SPECTRUM_FRACTIONS:
1329
        names.append(f"status.dirty.{label}")
1330
        if not skip_diff:
1331
            names.append(f"diff.dirty.{label}")
1332
    return names
1333
1334
1335
def dirty_spectrum_marker(seed: str) -> bytes:
1336
    """Fixed edit content for one spectrum step.
1337
1338
    Deterministic (a pure function of the seed) so restore_dirty_fraction can
1339
    verify and strip exactly the bytes dirty_fraction appended.
1340
    """
1341
    return f"\ndirty-spectrum {seed}\n".encode("ascii")
1342
1343
1344
def tracked_fixture_files(repo_path: Path) -> list[Path]:
1345
    """Sorted tracked fixture files: everything outside VCS metadata dirs.
1346
1347
    After snapshot.initial every fixture file is tracked, so a filesystem walk
1348
    is the subject-neutral tracked set (no VCS command, identical for git and
1349
    oak subjects).
1350
    """
1351
    repo = Path(repo_path)
1352
    return sorted(
1353
        path
1354
        for path in repo.rglob("*")
1355
        if path.is_file() and not {".git", ".oak"}.intersection(path.relative_to(repo).parts)
1356
    )
1357
1358
1359
def dirty_fraction(repo_path: Path, fraction: float, seed: str) -> list[str]:
1360
    """Deterministically dirty ceil(fraction * N) tracked files.
1361
1362
    Selection is a seeded sample over the sorted tracked-file list; the edit
1363
    is one fixed appended line (same bytes for every selected file). Returns
1364
    the repo-relative POSIX paths of the dirtied files. Untimed by
1365
    construction: callers dirty first, then run_timed the measured command.
1366
    """
1367
    repo = Path(repo_path)
1368
    files = tracked_fixture_files(repo)
1369
    if not files or fraction <= 0:
1370
        return []
1371
    count = min(len(files), math.ceil(fraction * len(files)))
1372
    chosen = sorted(random.Random(seed).sample(files, count))
1373
    marker = dirty_spectrum_marker(seed)
1374
    for path in chosen:
1375
        with path.open("ab") as fh:
1376
            fh.write(marker)
1377
    return [path.relative_to(repo).as_posix() for path in chosen]
1378
1379
1380
def restore_dirty_fraction(repo_path: Path, changed_paths: list[str], seed: str) -> None:
1381
    """Undo dirty_fraction by stripping the exact appended marker bytes.
1382
1383
    Content returns byte-identical to the committed state, so the tree is
1384
    clean again before the next spectrum step (and before the pre-existing
1385
    operation sequence continues). Verification before truncation means a
1386
    mismatch fails loudly instead of silently corrupting a fixture copy.
1387
    """
1388
    repo = Path(repo_path)
1389
    marker = dirty_spectrum_marker(seed)
1390
    for rel in changed_paths:
1391
        path = repo / rel
1392
        size = path.stat().st_size
1393
        with path.open("r+b") as fh:
1394
            if size < len(marker):
1395
                raise RuntimeError(f"dirty-spectrum restore: {rel} is smaller than the marker")
1396
            fh.seek(size - len(marker))
1397
            if fh.read(len(marker)) != marker:
1398
                raise RuntimeError(f"dirty-spectrum restore: {rel} does not end with the expected marker")
1399
            fh.truncate(size - len(marker))
1400
1401
1402
def run_dirty_spectrum(
1403
    subject: Subject,
1404
    scenario: Scenario,
1405
    repo: Path,
1406
    ops: dict[str, list[str]],
1407
    run_index: int,
1408
    metadata: dict[str, Any],
1409
    skip_diff: bool,
1410
    admitted_output_chars: int,
1411
) -> list[dict[str, Any]]:
1412
    """Measure status (and diff) across deterministic dirty fractions.
1413
1414
    Runs on a clean committed tree. Per fraction: dirty (untimed), time the
1415
    same status/diff commands the existing status.dirty/diff.dirty rows use,
1416
    then restore (untimed) so each step starts from the identical clean state.
1417
    The seed is per scenario+fraction, never per subject or run, so every
1418
    subject measures the same dirtied file set.
1419
    """
1420
    rows: list[dict[str, Any]] = []
1421
    base_ops = [op for op in DIRTY_SPECTRUM_BASE_OPS if not (skip_diff and op == "diff.dirty")]
1422
    for label, fraction in DIRTY_SPECTRUM_FRACTIONS:
1423
        seed = f"dirty-spectrum:{scenario.name}:{label}"
1424
        changed = dirty_fraction(repo, fraction, seed)
1425
        try:
1426
            for base_op in base_ops:
1427
                row = run_command(
1428
                    subject,
1429
                    scenario,
1430
                    f"{base_op}.{label}",
1431
                    repo,
1432
                    ops[base_op],
1433
                    run_index,
1434
                    metadata,
1435
                    admitted_output_chars,
1436
                )
1437
                row["dirty_fraction"] = fraction
1438
                row["dirty_file_count"] = len(changed)
1439
                rows.append(row)
1440
        finally:
1441
            restore_dirty_fraction(repo, changed, seed)
1442
    return rows
1443
1444
1445
def ops_for(subject: Subject, track: str) -> dict[str, list[str]]:
1446
    """Non-snapshot operation commands from the command-semantics contract.
1447
1448
    Snapshot operations go through snapshot_message/run_git_snapshot so git
1449
    pays its stage+commit as two recorded calls. Where Oak lacks a compact
1450
    equivalent, core-equivalent rows use the closest available command; the
1451
    contract JSON records that gap explicitly.
1452
    """
1453
    sem = semantics()
1454
    vcs = str(subject.bin)
1455
    status = [vcs, *sem.status_args(subject.kind, track)]
1456
    return {
1457
        "repo.init": [vcs, *sem.init_args(subject.kind)],
1458
        "status.clean": status,
1459
        "status.dirty": list(status),
1460
        "diff.dirty": [vcs, *sem.diff_args(subject.kind, track)],
1461
        "branch.create": [vcs, *sem.branch_create_args(subject.kind)],
1462
    }
1463
1464
1465
def snapshot_message(op: str) -> str:
1466
    return "initial" if op == "snapshot.initial" else op
1467
1468
1469
def run_snapshot(
1470
    subject: Subject,
1471
    scenario: Scenario,
1472
    op: str,
1473
    cwd: Path,
1474
    run_index: int,
1475
    metadata: dict[str, Any],
1476
    admitted_output_chars: int,
1477
) -> list[dict[str, Any]]:
1478
    """Snapshot via the command-semantics contract.
1479
1480
    Subjects that stage separately (git) record the stage and commit as
1481
    sub-rows plus a combined canonical row; one-command subjects (oak) record
1482
    a single row. Agents pay every recorded call.
1483
    """
1484
    sem = semantics()
1485
    commands = sem.snapshot_commands(str(subject.bin), subject.kind, snapshot_message(op))
1486
    if len(commands) == 1:
1487
        before = oakbench_integrity.repository_state_fingerprint(cwd)
1488
        expected_hashes = oakbench_integrity.sample_worktree_payload_hashes(cwd)
1489
        row = run_command(subject, scenario, op, cwd, commands[0], run_index, metadata, admitted_output_chars)
1490
        return [apply_state_change_attestation(row, cwd, before, op, expected_hashes=expected_hashes)]
1491
1492
    stage_command, commit_command = commands
1493
    add_result = run_command(
1494
        subject, scenario, f"{op}.add", cwd, stage_command, run_index, metadata, admitted_output_chars
1495
    )
1496
    if add_result["returncode"] != 0:
1497
        return [add_result]
1498
    before_commit = oakbench_integrity.repository_state_fingerprint(cwd)
1499
    expected_hashes = oakbench_integrity.sample_worktree_payload_hashes(cwd)
1500
    commit_result = run_command(
1501
        subject, scenario, f"{op}.commit", cwd, commit_command, run_index, metadata, admitted_output_chars
1502
    )
1503
    apply_state_change_attestation(commit_result, cwd, before_commit, op, expected_hashes=expected_hashes)
1504
    subrows = [add_result, commit_result]
1505
    combined = {
1506
        **commit_result,
1507
        "operation": op,
1508
        "elapsed_ms": round(add_result["elapsed_ms"] + commit_result["elapsed_ms"], 3),
1509
        "returncode": commit_result["returncode"],
1510
        "command": [stage_command, commit_command],
1511
        **merge_interaction_metrics([stage_command, commit_command], subrows),
1512
    }
1513
    if add_result["returncode"] != 0 or commit_result["returncode"] != 0:
1514
        combined["stderr"] = (add_result.get("stderr", "") + "\n" + commit_result.get("stderr", ""))[-4000:]
1515
    return [add_result, commit_result, combined]
1516
1517
1518
def run_subject_scenario(
1519
    subject: Subject,
1520
    scenario: Scenario,
1521
    fixture: Path,
1522
    run_index: int,
1523
    run_root: Path,
1524
    metadata: dict[str, Any],
1525
    skip_diff: bool,
1526
    admitted_output_chars: int,
1527
    track: str,
1528
    skip_determinism_probe: bool = False,
1529
    skip_remote: bool = False,
1530
    dirty_spectrum: bool = False,
1531
) -> list[dict[str, Any]]:
1532
    metadata = {**metadata, "benchmark_track": track}
1533
    applies, skip_reason = git_mode_applies_to_scenario(subject.git_mode, scenario)
1534
    if not applies:
1535
        return [
1536
            remote_skip_row(
1537
                subject,
1538
                scenario,
1539
                "scenario.mode_skipped",
1540
                run_index,
1541
                metadata,
1542
                skip_reason or "git_mode_not_applicable",
1543
            )
1544
        ]
1545
    if subject.git_mode == "lfs" and not git_lfs_available(subject.bin):
1546
        return [
1547
            remote_skip_row(
1548
                subject,
1549
                scenario,
1550
                "scenario.mode_skipped",
1551
                run_index,
1552
                metadata,
1553
                "git_lfs_unavailable: install git-lfs to measure the git_lfs tuned comparator",
1554
            )
1555
        ]
1556
1557
    repo = run_root / scenario.name / subject.name / f"run-{run_index}"
1558
    copy_fixture(fixture, repo)
1559
    ops = ops_for(subject, track)
1560
    results: list[dict[str, Any]] = []
1561
1562
    def timed(op: str) -> None:
1563
        if op in {"snapshot.initial", "snapshot.dirty", "task.snapshot"}:
1564
            results.extend(run_snapshot(subject, scenario, op, repo, run_index, metadata, admitted_output_chars))
1565
            return
1566
        before = oakbench_integrity.repository_state_fingerprint(repo) if op == "branch.create" else None
1567
        row = run_command(subject, scenario, op, repo, ops[op], run_index, metadata, admitted_output_chars)
1568
        if op == "branch.create":
1569
            apply_state_change_attestation(row, repo, before, op)
1570
        results.append(row)
1571
1572
    # Binary startup overhead in isolation. Agent fleets invoke the VCS
1573
    # thousands of times a day; process spawn cost is paid on every call.
1574
    results.append(
1575
        run_command(
1576
            subject,
1577
            scenario,
1578
            "proc.spawn",
1579
            repo,
1580
            [str(subject.bin), "--version"],
1581
            run_index,
1582
            metadata,
1583
            admitted_output_chars,
1584
        )
1585
    )
1586
1587
    timed("repo.init")
1588
    if subject.git_mode:
1589
        for setup_op, setup_args in WIRED_GIT_MODES[subject.git_mode]:
1590
            setup_row = run_command(
1591
                subject,
1592
                scenario,
1593
                f"mode.setup.{setup_op}",
1594
                repo,
1595
                [str(subject.bin), *setup_args],
1596
                run_index,
1597
                metadata,
1598
                admitted_output_chars,
1599
            )
1600
            results.append(setup_row)
1601
            if setup_row["returncode"] != 0:
1602
                # A tuned subject whose setup failed would silently measure
1603
                # stock git under a tuned label. Stop here: the failed setup
1604
                # row is the evidence, and the run exits nonzero.
1605
                setup_row["notes"] = (
1606
                    f"mode setup failed; remaining {subject.name} operations for this run "
1607
                    "were aborted so stock-git numbers are never published under a tuned label"
1608
                )
1609
                return results
1610
    if subject.git_mode == "lfs":
1611
        untracked = lfs_untracked_extensions(repo)
1612
        if untracked:
1613
            # config/git_modes.json git_lfs fairness_note: the mode "requires
1614
            # ... fixture generation with LFS-tracked binaries". A fixture file
1615
            # the track patterns miss would be committed as a plain git blob,
1616
            # and an untracked comparator is an unfair comparator — so the
1617
            # mode setup fails closed here and the scenario's rows become an
1618
            # honest skip instead of git numbers under the git_lfs label.
1619
            results.append(
1620
                remote_skip_row(
1621
                    subject,
1622
                    scenario,
1623
                    "mode.setup.lfs_track_coverage",
1624
                    run_index,
1625
                    metadata,
1626
                    "lfs_track_incomplete:" + ",".join(untracked),
1627
                )
1628
            )
1629
            return results
1630
    timed("snapshot.initial")
1631
    timed("status.clean")
1632
    if dirty_spectrum:
1633
        # Opt-in spectrum rows run here, on the clean committed tree, before
1634
        # the scenario's own dirty mutation; each step restores cleanliness so
1635
        # the pre-existing operation sequence below is untouched.
1636
        results.extend(
1637
            run_dirty_spectrum(
1638
                subject, scenario, repo, ops, run_index, metadata, skip_diff, admitted_output_chars
1639
            )
1640
        )
1641
    changed_paths = mutate_files(repo, scenario, subject, run_index)
1642
    timed("status.dirty")
1643
    if not skip_determinism_probe:
1644
        results.append(
1645
            run_determinism_probe(
1646
                subject, scenario, "status.dirty", repo, ops["status.dirty"], run_index, metadata
1647
            )
1648
        )
1649
        results.append(
1650
            run_information_probe(
1651
                subject, scenario, "status.dirty", repo, ops["status.dirty"], run_index, metadata, changed_paths
1652
            )
1653
        )
1654
    if not skip_diff:
1655
        diff_dirty_skip = git_huge_binary_diff_limit_reason(subject, scenario, "diff.dirty")
1656
        if diff_dirty_skip is not None:
1657
            results.append(
1658
                git_huge_binary_diff_skip_row(
1659
                    subject, scenario, "diff.dirty", ops["diff.dirty"], run_index, metadata
1660
                )
1661
            )
1662
            if not skip_determinism_probe:
1663
                results.append(
1664
                    git_huge_binary_diff_skip_row(
1665
                        subject,
1666
                        scenario,
1667
                        "diff.dirty.determinism",
1668
                        ops["diff.dirty"],
1669
                        run_index,
1670
                        metadata,
1671
                    )
1672
                )
1673
                results.append(
1674
                    git_huge_binary_diff_skip_row(
1675
                        subject,
1676
                        scenario,
1677
                        "diff.dirty.inforecall",
1678
                        ops["diff.dirty"],
1679
                        run_index,
1680
                        metadata,
1681
                    )
1682
                )
1683
                full_diff_command = [str(subject.bin), *semantics().diff_args(subject.kind, "core-equivalent")]
1684
                results.append(
1685
                    git_huge_binary_diff_skip_row(
1686
                        subject,
1687
                        scenario,
1688
                        "diff.full.inforecall",
1689
                        full_diff_command,
1690
                        run_index,
1691
                        metadata,
1692
                    )
1693
                )
1694
        else:
1695
            timed("diff.dirty")
1696
            if not skip_determinism_probe:
1697
                results.append(
1698
                    run_determinism_probe(
1699
                        subject, scenario, "diff.dirty", repo, ops["diff.dirty"], run_index, metadata
1700
                    )
1701
                )
1702
                results.append(
1703
                    run_information_probe(
1704
                        subject, scenario, "diff.dirty", repo, ops["diff.dirty"], run_index, metadata, changed_paths
1705
                    )
1706
                )
1707
                # Pipe-compatibility is a property of the FULL diff (what scripts
1708
                # and patch-tooling consume), independent of the run's track.
1709
                full_diff_command = [str(subject.bin), *semantics().diff_args(subject.kind, "core-equivalent")]
1710
                results.append(
1711
                    run_information_probe(
1712
                        subject, scenario, "diff.full", repo, full_diff_command, run_index, metadata, changed_paths,
1713
                        pristine_fixture=fixture,
1714
                    )
1715
                )
1716
    timed("snapshot.dirty")
1717
    timed("branch.create")
1718
    mutate_files(repo, scenario, subject, run_index + 10_000)
1719
    timed("task.snapshot")
1720
    results.extend(
1721
        run_agent_native_json_probes(
1722
            subject, scenario, repo, run_index, metadata, admitted_output_chars
1723
        )
1724
    )
1725
    if not skip_remote:
1726
        results.extend(
1727
            run_remote_phase(subject, scenario, repo, run_index, metadata, admitted_output_chars)
1728
        )
1729
    if subject.git_mode == "fsmonitor":
1730
        # One fsmonitor daemon spawns per repo; stop it so repeated benchmark
1731
        # runs do not accumulate daemons watching deleted workdirs. Untimed and
1732
        # unrecorded: teardown, not a measured operation.
1733
        subprocess.run(
1734
            [str(subject.bin), "fsmonitor--daemon", "stop"],
1735
            cwd=repo,
1736
            env=base_env(),
1737
            stdout=subprocess.DEVNULL,
1738
            stderr=subprocess.DEVNULL,
1739
            check=False,
1740
        )
1741
    return results
1742
1743
1744
def remote_preflight_requirements(
1745
    subjects: list[Subject],
1746
    *,
1747
    skip_remote: bool,
1748
) -> list[oakbench_remotes.RemoteRequirement]:
1749
    if skip_remote or not any(subject.kind == "oak" for subject in subjects):
1750
        return []
1751
    return [
1752
        oakbench_remotes.RemoteRequirement(
1753
            purpose="default",
1754
            lane="core",
1755
            operations=tuple(OAK_NET_REMOTE_OPS),
1756
        )
1757
    ]
1758
1759
1760
def metric_medians(rows: list[dict[str, Any]], metric: str) -> dict[tuple[str, str], float]:
1761
    grouped: dict[tuple[str, str], list[float]] = {}
1762
    for row in rows:
1763
        if row["returncode"] != 0:
1764
            continue
1765
        op = row["operation"]
1766
        if op.endswith(".add") or op.endswith(".commit"):
1767
            continue
1768
        try:
1769
            value = float(row[metric])
1770
        except (KeyError, TypeError, ValueError):
1771
            continue
1772
        key = (row["subject"], row["scenario"] + "/" + op)
1773
        grouped.setdefault(key, []).append(value)
1774
    return {key: statistics.median(values) for key, values in grouped.items()}
1775
1776
1777
def average_by_subject(rows: list[dict[str, Any]], metric: str) -> dict[str, float]:
1778
    grouped: dict[str, list[float]] = {}
1779
    for row in rows:
1780
        if row["returncode"] != 0:
1781
            continue
1782
        op = row["operation"]
1783
        if op.endswith(".add") or op.endswith(".commit"):
1784
            continue
1785
        # Diagnostic and setup rows stay out of cross-operation subject
1786
        # averages so trend lines keep comparing the same op mix.
1787
        # remote.* ops stay out too: subjects without a wired remote skip them,
1788
        # and an asymmetric op mix would silently skew the cross-op average.
1789
        if (
1790
            op.endswith(".determinism")
1791
            or op.endswith(".inforecall")
1792
            or op.startswith("mode.setup")
1793
            or op.startswith("remote.")
1794
            or op == "proc.spawn"
1795
        ):
1796
            continue
1797
        try:
1798
            value = float(row[metric])
1799
        except (KeyError, TypeError, ValueError):
1800
            continue
1801
        grouped.setdefault(str(row["subject"]), []).append(value)
1802
    return {subject: statistics.mean(values) for subject, values in grouped.items()}
1803
1804
1805
# Delta math and formatting live in oakbench.reporting; these aliases keep
1806
# the historical local names used throughout the summary writer.
1807
fmt_delta = fmt_delta_lower_better
1808
fmt_float = fmt_num
1809
1810
1811
def summary_text(rows: list[dict[str, Any]], subjects: list[Subject]) -> str:
1812
    by_key = metric_medians(rows, "elapsed_ms")
1813
    token_by_key = metric_medians(rows, "estimated_tokens_total")
1814
    tools_by_key = metric_medians(rows, "tool_call_count")
1815
    output_by_key = metric_medians(rows, "raw_output_bytes")
1816
    avg_ms = average_by_subject(rows, "elapsed_ms")
1817
    avg_tokens = average_by_subject(rows, "estimated_tokens_total")
1818
    avg_cost = average_by_subject(rows, "estimated_cost_weighted_tokens")
1819
    avg_tools = average_by_subject(rows, "tool_call_count")
1820
    avg_output = average_by_subject(rows, "raw_output_bytes")
1821
    git_name = next((subject.name for subject in subjects if subject.kind == "git"), None)
1822
    oak_baseline = next((subject.name for subject in subjects if subject.name in {"oak_installed", "oak_main"}), None)
1823
    lines = [
1824
        "# Oak Benchmark Summary",
1825
        "",
1826
        f"Track: `{rows[0].get('benchmark_track', 'unknown') if rows else 'unknown'}`.",
1827
        "Positive deltas mean the row subject is lower/better than the baseline for that metric.",
1828
        "Estimated tokens are a dependency-free proxy: command text plus admitted stdout/stderr characters divided by four.",
1829
        "Tool calls are exact terminal/VCS command counts for this direct CLI harness.",
1830
        "Output bytes are full stdout/stderr byte counts, even when admitted token text was capped.",
1831
        f"Cost-weighted tokens use billing direction ({COST_WEIGHTS_NOTE}): commands an agent types are model output, stdout it reads is model input.",
1832
        "",
1833
        "## Subject Averages",
1834
        "",
1835
        "| 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 |",
1836
        "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
1837
    ]
1838
    git_avg_tokens = avg_tokens.get(git_name) if git_name else None
1839
    git_avg_tools = avg_tools.get(git_name) if git_name else None
1840
    oak_avg_tokens = avg_tokens.get(oak_baseline) if oak_baseline else None
1841
    oak_avg_tools = avg_tools.get(oak_baseline) if oak_baseline else None
1842
    for subject in subjects:
1843
        token_value = avg_tokens.get(subject.name)
1844
        tool_value = avg_tools.get(subject.name)
1845
        lines.append(
1846
            "| "
1847
            f"`{subject.name}` | "
1848
            f"{fmt_float(avg_ms.get(subject.name))} | "
1849
            f"{fmt_float(token_value)} | "
1850
            f"{fmt_float(avg_cost.get(subject.name))} | "
1851
            f"{fmt_float(avg_output.get(subject.name))} | "
1852
            f"{fmt_float(tool_value, 2)} | "
1853
            f"{fmt_delta(git_avg_tokens, token_value, subject.name == git_name)} | "
1854
            f"{fmt_delta(oak_avg_tokens, token_value, subject.name == oak_baseline or subject.kind == 'git')} | "
1855
            f"{fmt_delta(git_avg_tools, tool_value, subject.name == git_name)} | "
1856
            f"{fmt_delta(oak_avg_tools, tool_value, subject.name == oak_baseline or subject.kind == 'git')} |"
1857
        )
1858
1859
    info_rows = [row for row in rows if str(row.get("operation", "")).endswith(".inforecall")]
1860
    if info_rows:
1861
        lines.extend(
1862
            [
1863
                "",
1864
                "## Output Information Content",
1865
                "",
1866
                "Bytes saved are only a win if the changed-file list survives. Recall is the",
1867
                "fraction of ground-truth changed paths recoverable from the command output;",
1868
                "pipe-compat means diff output kept unified hunk/file structure (or an explicit",
1869
                "binary notice) that scripts and `patch`-style tooling can consume.",
1870
                "",
1871
                "| Subject | Operation | Mean recall | Mean bytes/file named | Pipe-compat rate |",
1872
                "| --- | --- | ---: | ---: | ---: |",
1873
            ]
1874
        )
1875
        grouped: dict[tuple[str, str], list[dict[str, Any]]] = {}
1876
        for row in info_rows:
1877
            grouped.setdefault((str(row["subject"]), str(row["operation"])), []).append(row)
1878
        for (subject_name, op), op_rows in sorted(grouped.items()):
1879
            recalls = [row["information_recall"] for row in op_rows if row.get("information_recall") is not None]
1880
            byte_costs = [
1881
                row["bytes_per_changed_file_named"]
1882
                for row in op_rows
1883
                if row.get("bytes_per_changed_file_named") is not None
1884
            ]
1885
            compat_flags = [row["pipe_compatible_unified"] for row in op_rows if "pipe_compatible_unified" in row]
1886
            recall_cell = f"{statistics.mean(recalls):.3f}" if recalls else ""
1887
            bytes_cell = f"{statistics.mean(byte_costs):.0f}" if byte_costs else ""
1888
            compat_cell = f"{sum(1 for flag in compat_flags if flag) / len(compat_flags) * 100:.0f}%" if compat_flags else ""
1889
            lines.append(f"| `{subject_name}` | `{op}` | {recall_cell} | {bytes_cell} | {compat_cell} |")
1890
1891
    lines.extend(
1892
        [
1893
            "",
1894
            "## Operation Medians",
1895
            "",
1896
            "| 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 |",
1897
            "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
1898
        ]
1899
    )
1900
    all_ops = sorted({scenario_op for _, scenario_op in by_key})
1901
    for scenario_op in all_ops:
1902
        git_base = by_key.get((git_name, scenario_op)) if git_name else None
1903
        git_token_base = token_by_key.get((git_name, scenario_op)) if git_name else None
1904
        git_tool_base = tools_by_key.get((git_name, scenario_op)) if git_name else None
1905
        oak_base = by_key.get((oak_baseline, scenario_op)) if oak_baseline else None
1906
        oak_token_base = token_by_key.get((oak_baseline, scenario_op)) if oak_baseline else None
1907
        oak_tool_base = tools_by_key.get((oak_baseline, scenario_op)) if oak_baseline else None
1908
        for subject in subjects:
1909
            value = by_key.get((subject.name, scenario_op))
1910
            if value is None:
1911
                continue
1912
            token_value = token_by_key.get((subject.name, scenario_op))
1913
            tool_value = tools_by_key.get((subject.name, scenario_op))
1914
            output_value = output_by_key.get((subject.name, scenario_op))
1915
            vs_git_time = "" if git_base is None or subject.name == git_name else f"{delta_pct(git_base, value):+.1f}%"
1916
            vs_git_tokens = (
1917
                ""
1918
                if git_token_base is None or token_value is None or subject.name == git_name
1919
                else f"{delta_pct(git_token_base, token_value):+.1f}%"
1920
            )
1921
            vs_git_tools = (
1922
                ""
1923
                if git_tool_base is None or tool_value is None or subject.name == git_name
1924
                else f"{delta_pct(git_tool_base, tool_value):+.1f}%"
1925
            )
1926
            vs_oak_time = (
1927
                ""
1928
                if oak_base is None or subject.name == oak_baseline or subject.kind == "git"
1929
                else f"{delta_pct(oak_base, value):+.1f}%"
1930
            )
1931
            vs_oak_tokens = (
1932
                ""
1933
                if oak_token_base is None or token_value is None or subject.name == oak_baseline or subject.kind == "git"
1934
                else f"{delta_pct(oak_token_base, token_value):+.1f}%"
1935
            )
1936
            vs_oak_tools = (
1937
                ""
1938
                if oak_tool_base is None or tool_value is None or subject.name == oak_baseline or subject.kind == "git"
1939
                else f"{delta_pct(oak_tool_base, tool_value):+.1f}%"
1940
            )
1941
            # Unmeasured renders blank, never 0 (ADR-0002): diagnostic rows
1942
            # carry no token/tool fields and must not look like free output.
1943
            lines.append(
1944
                f"| `{scenario_op}` | `{subject.name}` | {value:.1f} | "
1945
                f"{fmt_float(token_value)} | "
1946
                f"{fmt_float(output_value, 0)} | "
1947
                f"{fmt_float(tool_value)} | "
1948
                f"{vs_git_time} | {vs_git_tokens} | {vs_git_tools} | "
1949
                f"{vs_oak_time} | {vs_oak_tokens} | {vs_oak_tools} |"
1950
            )
1951
    skips = [row for row in rows if row.get("skipped")]
1952
    if skips:
1953
        lines.extend(["", "## Skipped (unmeasured, never hidden)", ""])
1954
        seen_reasons: dict[tuple[str, str], int] = {}
1955
        for row in skips:
1956
            seen_reasons[(str(row["subject"]), str(row.get("skip_reason", "")))] = (
1957
                seen_reasons.get((str(row["subject"]), str(row.get("skip_reason", ""))), 0) + 1
1958
            )
1959
        for (subject_name, reason), count in sorted(seen_reasons.items()):
1960
            lines.append(f"- `{subject_name}`: {count} rows skipped — {reason}")
1961
    failures = [row for row in rows if row["returncode"] != 0 and not row.get("skipped")]
1962
    if failures:
1963
        lines.extend(["", "## Failures", ""])
1964
        for row in failures[:20]:
1965
            reason = row.get("integrity_failure_reason") or row.get("content_integrity_failure_reason") or row.get(
1966
                "stderr", ""
1967
            )
1968
            lines.append(
1969
                f"- `{row['subject']}` `{row['scenario']}/{row['operation']}` "
1970
                f"exit {row['returncode']}: `{str(reason).splitlines()[-1:]}`"
1971
            )
1972
    return "\n".join(lines) + "\n"
1973
1974
1975
def main() -> int:
1976
    args = parse_args()
1977
    subjects = expand_git_modes(load_subjects(args), args.git_modes)
1978
    oakbench_remotes.print_remote_preflight_warnings(
1979
        oakbench_remotes.oak_remote_preflight_warnings(
1980
            remote_preflight_requirements(subjects, skip_remote=args.skip_remote)
1981
        )
1982
    )
1983
    scenarios = PROFILES[args.profile]
1984
    if args.runs:
1985
        scenarios = [
1986
            Scenario(s.name, s.file_count, s.file_size, s.dirty_count, s.binary, args.runs)
1987
            for s in scenarios
1988
        ]
1989
1990
    timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
1991
    args.results.mkdir(parents=True, exist_ok=True)
1992
    args.workdir.mkdir(parents=True, exist_ok=True)
1993
    run_root = args.workdir / "runs" / timestamp
1994
    fixture_root = args.workdir
1995
1996
    metadata = {
1997
        "bench_id": timestamp,
1998
        "profile": args.profile,
1999
        "benchmark_track": args.track,
2000
        "command_semantics_version": COMMAND_SEMANTICS_VERSION,
2001
        "timestamp_utc": timestamp,
2002
        "host": platform.node(),
2003
        "platform": platform.platform(),
2004
        "machine": platform.machine(),
2005
        "python": platform.python_version(),
2006
        "env_isolation_version": oakbench_environment.ENV_ISOLATION_VERSION,
2007
        "output_oracle_version": oakbench_semantics.OUTPUT_ORACLE_VERSION,
2008
        "patch_oracle_version": oakbench_semantics.PATCH_ORACLE_VERSION,
2009
        "subject_versions": {subject.name: subject_version(subject) for subject in subjects},
2010
        "subject_details": subject_details(subjects),
2011
        "source": source_metadata(args.oak_repo),
2012
        "randomized_subject_order": bool(args.randomize_subject_order),
2013
        **runner_fields(),
2014
    }
2015
2016
    # Fixture generation runs OUTSIDE the measurement lock: building file
2017
    # trees parallelizes safely (atomic shape-keyed builds), only timed
2018
    # measurement must serialize across processes.
2019
    fixtures: dict[str, Path] = {}
2020
    for scenario in scenarios:
2021
        print(
2022
            f"[fixture] {scenario.name}: {scenario.file_count} files x {scenario.file_size} bytes",
2023
            flush=True,
2024
        )
2025
        fixtures[scenario.name] = ensure_fixture(fixture_root, scenario)
2026
2027
    metadata["load_tier"] = args.load_tier
2028
2029
    rows: list[dict[str, Any]] = []
2030
    with measurement_lock("bench") as lock_info:
2031
        metadata["measurement_lock_wait_ms"] = lock_info.wait_ms
2032
        metadata["measurement_lock"] = "held" if lock_info.enabled else "disabled"
2033
        # Background load (untimed start/stop). Failing to start under a
2034
        # non-none tier converts the whole invocation to skip rows: load that
2035
        # was not applied is never claimed.
2036
        load_generator: oakbench_loadgen.LoadGenerator | None = None
2037
        if args.load_tier != "none":
2038
            load_generator = oakbench_loadgen.LoadGenerator(
2039
                args.load_tier, args.workdir / "loadgen-temp"
2040
            )
2041
            load_start = load_generator.start()
2042
            if not load_start["started"]:
2043
                reason = oakbench_loadgen.load_tier_unavailable_reason(str(load_start["reason"]))
2044
                rows = load_tier_skip_rows(subjects, scenarios, metadata, reason, args.load_tier)
2045
                load_generator = None
2046
        try:
2047
            if not rows:
2048
                for scenario in scenarios:
2049
                    fixture = fixtures[scenario.name]
2050
                    for run_index in range(scenario.runs):
2051
                        run_subjects = list(subjects)
2052
                        if args.randomize_subject_order:
2053
                            random.Random(f"{timestamp}:{scenario.name}:{run_index}").shuffle(run_subjects)
2054
                        run_metadata = {
2055
                            **metadata,
2056
                            "subject_order": [subject.name for subject in run_subjects],
2057
                        }
2058
                        for subject in run_subjects:
2059
                            print(f"[run] {scenario.name} run={run_index} subject={subject.name}", flush=True)
2060
                            env_before = oakbench_envwatch.sample_environment()
2061
                            subject_rows = run_subject_scenario(
2062
                                subject,
2063
                                scenario,
2064
                                fixture,
2065
                                run_index,
2066
                                run_root,
2067
                                run_metadata,
2068
                                args.skip_diff,
2069
                                args.admitted_output_chars,
2070
                                args.track,
2071
                                args.skip_determinism_probe,
2072
                                args.skip_remote,
2073
                                args.dirty_spectrum,
2074
                            )
2075
                            env_after = oakbench_envwatch.sample_environment()
2076
                            boundary = oakbench_loadgen.environment_boundary_fields(
2077
                                args.load_tier, env_before, env_after
2078
                            )
2079
                            for row in subject_rows:
2080
                                row.update(boundary)
2081
                            rows.extend(subject_rows)
2082
        finally:
2083
            if load_generator is not None:
2084
                load_stop = load_generator.stop()
2085
                # bogo-ops parsed ONLY as did-the-load-run verification,
2086
                # never reported as a benchmark number.
2087
                for row in rows:
2088
                    row["load_verified"] = load_stop.get("verified")
2089
2090
    store = ResultsStore(args.results, lane="core")
2091
    raw_path, summary_path = store.write(timestamp, rows, summary_text(rows, subjects))
2092
2093
    if not args.keep_workdirs:
2094
        shutil.rmtree(run_root, ignore_errors=True)
2095
2096
    print(f"[result] {raw_path}")
2097
    print(f"[summary] {summary_path}")
2098
    return 1 if any(row["returncode"] != 0 and not row.get("skipped") for row in rows) else 0
2099
2100
2101
if __name__ == "__main__":
2102
    raise SystemExit(main())