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