Viewing files at commit 1c9b819ab86bCommit detailsView latest

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