Viewing files at commit b76ab8b42dfdCommit detailsView latest

scripts/bench.py 2401 lines · 100.6 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
        if not content_fields["content_integrity_check_passed"] and failure_reason is None:
718
            failure_reason = content_fields["content_integrity_failure_reason"]
719
    if process_returncode == 0 and failure_reason is not None:
720
        row["returncode"] = 1
721
        row["stderr"] = str(failure_reason or "integrity_check_failed")
722
    return row
723
724
725
def remote_skip_row(
726
    subject: Subject,
727
    scenario: Scenario,
728
    op: str,
729
    run_index: int,
730
    metadata: dict[str, Any],
731
    reason: str,
732
) -> dict[str, Any]:
733
    return {
734
        **metadata,
735
        **subject_provenance(subject, metadata),
736
        "subject": subject.name,
737
        "subject_kind": subject.kind,
738
        "subject_label": subject.label,
739
        "git_mode": subject.git_mode,
740
        "scenario": scenario.name,
741
        "run": run_index,
742
        "operation": op,
743
        "elapsed_ms": 0.0,
744
        "returncode": 77,
745
        "command": [],
746
        "skipped": True,
747
        "skip_reason": reason,
748
        "tool_call_count": 0,
749
        "vcs_tool_call_count": 0,
750
        "terminal_tool_call_count": 0,
751
    }
752
753
754
def git_huge_binary_diff_limit_reason(subject: Subject, scenario: Scenario, op: str) -> str | None:
755
    if subject.kind != "git":
756
        return None
757
    if subject.git_mode == "lfs":
758
        return None
759
    if op not in GIT_HUGE_BINARY_DIFF_OPS:
760
        return None
761
    if not scenario.binary or scenario.file_size < GIT_HUGE_BINARY_DIFF_LIMIT_BYTES:
762
        return None
763
    return (
764
        "git_huge_binary_diff_fixture_limit: stock/tuned git diff rows are intentionally "
765
        "skipped for generated >=1GiB binary fixtures because git may fail while generating "
766
        "binary diffstat; use git_lfs or smaller binary fixtures for this capability"
767
    )
768
769
770
def git_huge_binary_diff_skip_row(
771
    subject: Subject,
772
    scenario: Scenario,
773
    op: str,
774
    command: list[str],
775
    run_index: int,
776
    metadata: dict[str, Any],
777
) -> dict[str, Any]:
778
    reason = git_huge_binary_diff_limit_reason(subject, scenario, op)
779
    if reason is None:
780
        raise ValueError(f"{op} is not a huge-binary git diff skip")
781
    row = remote_skip_row(subject, scenario, op, run_index, metadata, reason)
782
    row.update(
783
        {
784
            "command": command,
785
            "failure_classification": "git_huge_binary_diff_fixture_limit",
786
            "notes": (
787
                "preflighted fixture-limit skip; no subprocess was run for this row, "
788
                "so stderr is intentionally empty"
789
            ),
790
        }
791
    )
792
    return row
793
794
795
_OAK_JSON_SUPPORT_CACHE: dict[tuple[str, tuple[str, ...], tuple[str, ...]], str | None] = {}
796
_OAK_VERSION_CACHE: dict[str, tuple[int, int, int] | None] = {}
797
798
799
def parse_semver_triplet(text: str) -> tuple[int, int, int] | None:
800
    match = re.search(r"\b(\d+)\.(\d+)\.(\d+)\b", text)
801
    if match is None:
802
        return None
803
    return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
804
805
806
def oak_cli_semver(vcs: str) -> tuple[int, int, int] | None:
807
    if vcs in _OAK_VERSION_CACHE:
808
        return _OAK_VERSION_CACHE[vcs]
809
    try:
810
        probe = subprocess.run(
811
            [vcs, "--version"],
812
            capture_output=True,
813
            text=True,
814
            env=base_env(),
815
            timeout=15,
816
            check=False,
817
        )
818
    except (FileNotFoundError, subprocess.TimeoutExpired):
819
        _OAK_VERSION_CACHE[vcs] = None
820
        return None
821
    version = parse_semver_triplet((probe.stdout or "") + "\n" + (probe.stderr or ""))
822
    _OAK_VERSION_CACHE[vcs] = version
823
    return version
824
825
826
def oak_json_probe_version_skip_reason(vcs: str, spec: dict[str, Any]) -> str | None:
827
    minimum = spec.get("minimum_oak_version")
828
    if not minimum:
829
        return None
830
    required = parse_semver_triplet(str(minimum))
831
    actual = oak_cli_semver(vcs)
832
    if required is None:
833
        return f"capability_gap:oak_json_probe_bad_minimum_version:{minimum}"
834
    if actual is None:
835
        return f"capability_gap:oak_json_probe_version_unknown:requires_oak>={minimum}"
836
    if actual < required:
837
        actual_text = ".".join(str(part) for part in actual)
838
        return f"capability_gap:oak_json_probe_requires_oak>={minimum}:found_{actual_text}"
839
    return None
840
841
842
def oak_json_probe_skip_reason(vcs: str, help_args: list[str], command_args: list[str]) -> str | None:
843
    """Return a capability skip reason, or None when the JSON command is present."""
844
    key = (vcs, tuple(help_args), tuple(command_args))
845
    if key in _OAK_JSON_SUPPORT_CACHE:
846
        return _OAK_JSON_SUPPORT_CACHE[key]
847
    if not help_args:
848
        reason = "capability_gap:oak_json_probe_help_missing"
849
        _OAK_JSON_SUPPORT_CACHE[key] = reason
850
        return reason
851
    try:
852
        probe = subprocess.run(
853
            [vcs, *help_args],
854
            capture_output=True,
855
            text=True,
856
            env=base_env(),
857
            timeout=15,
858
            check=False,
859
        )
860
    except FileNotFoundError:
861
        reason = f"missing_binary:{vcs}"
862
        _OAK_JSON_SUPPORT_CACHE[key] = reason
863
        return reason
864
    except subprocess.TimeoutExpired:
865
        reason = "capability_gap:oak_json_probe_help_timeout:" + " ".join(help_args)
866
        _OAK_JSON_SUPPORT_CACHE[key] = reason
867
        return reason
868
    help_text = (probe.stdout or "") + "\n" + (probe.stderr or "")
869
    if probe.returncode != 0:
870
        reason = "capability_gap:oak_json_probe_help_failed:" + " ".join(help_args)
871
    elif "--json" in command_args and "--json" not in help_text:
872
        reason = "capability_gap:oak_json_flag_missing:" + " ".join(command_args)
873
    else:
874
        reason = None
875
    _OAK_JSON_SUPPORT_CACHE[key] = reason
876
    return reason
877
878
879
def json_field_oracle_fields(stdout_text: str, required_fields: list[str]) -> dict[str, Any]:
880
    """JSON parse and top-level required-field completeness for CLI capability rows."""
881
    base: dict[str, Any] = {
882
        "json_required_fields": required_fields,
883
        "json_required_field_count": len(required_fields),
884
    }
885
    try:
886
        payload = json.loads(stdout_text)
887
    except json.JSONDecodeError as exc:
888
        return {
889
            **base,
890
            "json_parse_success": False,
891
            "json_payload_type": None,
892
            "json_present_required_field_count": 0,
893
            "json_missing_required_fields": required_fields,
894
            "json_field_completeness": 0.0 if required_fields else None,
895
            "json_oracle_passed": False,
896
            "json_error": f"{exc.msg} at line {exc.lineno} column {exc.colno}",
897
        }
898
    payload_type = type(payload).__name__
899
    if not isinstance(payload, dict):
900
        return {
901
            **base,
902
            "json_parse_success": True,
903
            "json_payload_type": payload_type,
904
            "json_present_required_field_count": 0,
905
            "json_missing_required_fields": required_fields,
906
            "json_field_completeness": 0.0 if required_fields else None,
907
            "json_oracle_passed": False,
908
            "json_error": "expected top-level JSON object",
909
        }
910
    missing = [field for field in required_fields if field not in payload]
911
    present = len(required_fields) - len(missing)
912
    return {
913
        **base,
914
        "json_parse_success": True,
915
        "json_payload_type": payload_type,
916
        "json_present_required_field_count": present,
917
        "json_missing_required_fields": missing,
918
        "json_field_completeness": round(present / len(required_fields), 4) if required_fields else None,
919
        "json_oracle_passed": not missing,
920
    }
921
922
923
def json_field_oracle_unmeasured_fields(required_fields: list[str], reason: str) -> dict[str, Any]:
924
    """Null JSON validation fields when the runner did not capture a complete payload."""
925
    return {
926
        "json_required_fields": required_fields,
927
        "json_required_field_count": len(required_fields),
928
        "json_parse_success": None,
929
        "json_payload_type": None,
930
        "json_present_required_field_count": None,
931
        "json_missing_required_fields": None,
932
        "json_field_completeness": None,
933
        "json_oracle_passed": None,
934
        "json_validation_unmeasured_reason": reason,
935
    }
936
937
938
def run_agent_native_json_probe(
939
    subject: Subject,
940
    scenario: Scenario,
941
    op: str,
942
    cwd: Path,
943
    spec: dict[str, Any],
944
    run_index: int,
945
    metadata: dict[str, Any],
946
    admitted_output_chars: int,
947
) -> dict[str, Any]:
948
    vcs = str(subject.bin)
949
    command_args = list(spec["args"])
950
    help_args = list(spec["help_args"])
951
    command = [vcs, *command_args]
952
    skip_reason = oak_json_probe_version_skip_reason(vcs, spec)
953
    if skip_reason is None:
954
        skip_reason = oak_json_probe_skip_reason(vcs, help_args, command_args)
955
    if skip_reason is not None:
956
        row = remote_skip_row(subject, scenario, op, run_index, metadata, skip_reason)
957
        row["intended_command"] = command
958
        row["capability_probe_command"] = [vcs, *help_args]
959
        if spec.get("minimum_oak_version") is not None:
960
            row["minimum_oak_version"] = str(spec["minimum_oak_version"])
961
        return row
962
963
    from oakbench.conformance import tree_manifest
964
    before_manifest = tree_manifest(cwd) if op == "oak.branch.diff.json" else None
965
    cache = page_cache_step(metadata)
966
    capture = run_timed(
967
        command,
968
        cwd,
969
        base_env(),
970
        admitted_output_chars,
971
        full_output_bytes=JSON_PROBE_MAX_BYTES,
972
    )
973
    required_fields = list(spec["required_fields"])
974
    if capture.stdout_full_text is not None and capture.stdout_full_text_truncated is False:
975
        json_validation_source = "full_stdout_capture"
976
        json_fields = json_field_oracle_fields(capture.stdout_full_text, required_fields)
977
    elif capture.stdout_full_text_truncated is True or (
978
        capture.stdout_full_text is None and capture.stdout_truncated
979
    ):
980
        json_validation_source = None
981
        json_fields = json_field_oracle_unmeasured_fields(
982
            required_fields,
983
            "stdout_exceeded_json_validation_capture_limit",
984
        )
985
    else:
986
        json_validation_source = "admitted_stdout_capture"
987
        json_fields = json_field_oracle_fields(capture.stdout_text, required_fields)
988
    result = {
989
        **metadata,
990
        **cache,
991
        **subject_provenance(subject, metadata),
992
        "subject": subject.name,
993
        "subject_kind": subject.kind,
994
        "subject_label": subject.label,
995
        "git_mode": subject.git_mode,
996
        "scenario": scenario.name,
997
        "run": run_index,
998
        "operation": op,
999
        "elapsed_ms": round(capture.elapsed_ms, 3),
1000
        # 77 is the HARNESS's skip sentinel; a subject exiting 77 is a subject
1001
        # failure (QA P1 A7), recorded with its real code in process_returncode.
1002
        "returncode": subject_returncode(capture.returncode),
1003
        "process_returncode": capture.returncode,
1004
        "command": command,
1005
        "peak_rss_bytes": capture.peak_rss_bytes,
1006
        "peak_rss_source": PEAK_RSS_SOURCE if capture.peak_rss_bytes is not None else None,
1007
        "cpu_user_ms": capture.cpu_user_ms,
1008
        "cpu_sys_ms": capture.cpu_sys_ms,
1009
        "io_read_blocks": capture.io_read_blocks,
1010
        "io_write_blocks": capture.io_write_blocks,
1011
        "major_page_faults": capture.major_page_faults,
1012
        "rusage_source": RUSAGE_SOURCE if capture.cpu_user_ms is not None else None,
1013
        "measurement_source": "direct_cli_timed_subprocess_json_capability_probe",
1014
        "probe_output_bytes": capture.stdout_bytes + capture.stderr_bytes,
1015
        "output_format": spec.get("output_format", "json"),
1016
        **interaction_metrics(
1017
            command,
1018
            capture.stdout_text,
1019
            capture.stderr_text,
1020
            capture.stdout_bytes,
1021
            capture.stderr_bytes,
1022
            capture.stdout_truncated,
1023
            capture.stderr_truncated,
1024
            tool_call_count=1,
1025
        ),
1026
        "json_validation_source": json_validation_source,
1027
        **json_fields,
1028
    }
1029
    if capture.returncode != 0:
1030
        result["stderr"] = capture.stderr_text[-4000:]
1031
    elif result["json_oracle_passed"] is False:
1032
        result["returncode"] = 1
1033
        result["stderr"] = str(result.get("json_error") or "json_required_fields_missing")
1034
    if before_manifest is not None:
1035
        result.update(process_returncode=capture.returncode, measurement_phase="agent_native_probe",
1036
                      cache_state="hash_warmed_native_diff")
1037
        result = oakbench_semantics.finalize_patch_evidence(result, before_manifest, tree_manifest(cwd), application_required=False)
1038
    return result
1039
1040
1041
def run_agent_native_json_probes(
1042
    subject: Subject,
1043
    scenario: Scenario,
1044
    cwd: Path,
1045
    run_index: int,
1046
    metadata: dict[str, Any],
1047
    admitted_output_chars: int,
1048
) -> list[dict[str, Any]]:
1049
    probes = semantics().agent_native_json_probes(subject.kind, BENCH_TASK_BRANCH)
1050
    rows = []
1051
    for op, spec in probes.items():
1052
        row = run_agent_native_json_probe(subject, scenario, op, cwd, spec, run_index, metadata, admitted_output_chars)
1053
        rows.append(row)
1054
        if row.get("integrity_ok") is False:
1055
            break
1056
    return rows
1057
1058
1059
def load_tier_skip_row(
1060
    subject: Subject,
1061
    scenario: Scenario,
1062
    run_index: int,
1063
    metadata: dict[str, Any],
1064
    reason: str,
1065
    load_tier: str,
1066
) -> dict[str, Any]:
1067
    """Explicit skip row for a load tier the host cannot apply.
1068
1069
    A row may never claim load that was not applied: environment_suspect is
1070
    None (suspicion machinery did not run, ADR-0002) and load_tier records the
1071
    REQUESTED tier so the gap is visible, not hidden.
1072
    """
1073
    row = remote_skip_row(subject, scenario, "scenario.load_tier_skipped", run_index, metadata, reason)
1074
    row["load_tier"] = load_tier
1075
    row["environment_suspect"] = None
1076
    return row
1077
1078
1079
def load_tier_skip_rows(
1080
    subjects: list[Subject],
1081
    scenarios: list[Scenario],
1082
    metadata: dict[str, Any],
1083
    reason: str,
1084
    load_tier: str,
1085
) -> list[dict[str, Any]]:
1086
    """One skip row per scenario x run x subject for an unappliable load tier."""
1087
    return [
1088
        load_tier_skip_row(subject, scenario, run_index, metadata, reason, load_tier)
1089
        for scenario in scenarios
1090
        for run_index in range(scenario.runs)
1091
        for subject in subjects
1092
    ]
1093
1094
1095
def oak_network_branch_name(
1096
    bench_id: str,
1097
    scenario_name: str,
1098
    subject_name: str,
1099
    run_index: int,
1100
    *,
1101
    pid: int | None = None,
1102
    token: str | None = None,
1103
) -> str:
1104
    """Disposable remote branch name: readable prefix plus per-run uniquifier."""
1105
    return oakbench_remotes.disposable_branch(
1106
        bench_id, scenario_name, subject_name, run_index, pid=pid, token=token
1107
    )
1108
1109
1110
def run_oak_network_remote_phase(
1111
    subject: Subject,
1112
    scenario: Scenario,
1113
    repo: Path,
1114
    run_index: int,
1115
    metadata: dict[str, Any],
1116
    admitted_output_chars: int,
1117
    remote_repo: str,
1118
) -> list[dict[str, Any]]:
1119
    """Push to, clone from, and sync with a real Oak server.
1120
1121
    Network transport: latency includes the server round trip, so these rows
1122
    are oak-vs-previous-oak trend evidence, never a git-vs-oak comparison
1123
    (git's remote phase is local-file by construction).
1124
1125
    Same-named branches with unrelated histories conflict on the server, so
1126
    each run pushes a unique bench branch (untimed setup); the disposable
1127
    remote accumulates branches by design.
1128
    """
1129
    rows: list[dict[str, Any]] = []
1130
    vcs = str(subject.bin)
1131
    bench_id = str(metadata.get("bench_id", "bench"))
1132
    branch_name = oak_network_branch_name(bench_id, scenario.name, subject.name, run_index)
1133
    clone_dir = repo.parent / f"netclone-{subject.name}-{run_index}"
1134
    # Untimed: a unique branch is a precondition (same-named unrelated
1135
    # histories are rejected by the server), not the measurement.
1136
    subprocess.run(
1137
        [vcs, "switch", "-c", branch_name],
1138
        cwd=repo,
1139
        env=base_env(),
1140
        stdout=subprocess.DEVNULL,
1141
        stderr=subprocess.DEVNULL,
1142
        check=False,
1143
    )
1144
    push_row = run_command(
1145
        subject,
1146
        scenario,
1147
        "remote.net.push.first",
1148
        repo,
1149
        [vcs, "push", "--repo", remote_repo],
1150
        run_index,
1151
        metadata,
1152
        admitted_output_chars,
1153
    )
1154
    rows.append({**push_row, "remote_repo": remote_repo, "remote_transport": "network", "cache_state": "cold_remote_branch"})
1155
    clone_row = run_command(
1156
        subject,
1157
        scenario,
1158
        "remote.net.clone.cold",
1159
        repo.parent,
1160
        [vcs, "clone", remote_repo, str(clone_dir)],
1161
        run_index,
1162
        metadata,
1163
        admitted_output_chars,
1164
    )
1165
    rows.append(
1166
        {
1167
            **clone_row,
1168
            "remote_repo": remote_repo,
1169
            "remote_transport": "network",
1170
            "cache_state": "cold_objects_network",
1171
            "notes": "clones the disposable repo's default content, not this scenario's fixture; comparable only to other remote.net.clone.cold rows",
1172
        }
1173
    )
1174
    if clone_row["returncode"] == 0:
1175
        fetch_row = run_command(
1176
            subject,
1177
            scenario,
1178
            "remote.net.fetch.uptodate",
1179
            clone_dir,
1180
            [vcs, "fetch"],
1181
            run_index,
1182
            metadata,
1183
            admitted_output_chars,
1184
        )
1185
        rows.append({**fetch_row, "remote_repo": remote_repo, "remote_transport": "network", "cache_state": "warm"})
1186
    return rows
1187
1188
1189
def run_remote_phase(
1190
    subject: Subject,
1191
    scenario: Scenario,
1192
    repo: Path,
1193
    run_index: int,
1194
    metadata: dict[str, Any],
1195
    admitted_output_chars: int,
1196
) -> list[dict[str, Any]]:
1197
    """Push to, clone from, and sync with a local remote.
1198
1199
    Agents pay clone/pull/push constantly: every fresh workspace is a cold
1200
    clone, every task start is a pull, every task end is a push. A local
1201
    filesystem remote isolates the VCS object-transfer and checkout cost from
1202
    network jitter; "cold" means no local object store, while the OS page
1203
    cache stays warm (recorded in cache_state so rows never overclaim).
1204
1205
    Oak's remote model needs a configured server remote; until one is wired,
1206
    Oak rows are explicit skips (returncode 77), never silently absent.
1207
    """
1208
    if subject.kind != "git":
1209
        resolution = oakbench_remotes.resolve_oak_remote()
1210
        remote_repo = resolution.repo or ""
1211
        if not remote_repo:
1212
            reason = (
1213
                f"oak_remote_not_configured: set {OAK_REMOTE_ENV} to a DISPOSABLE ORG/REPO "
1214
                "(e.g. oak/oak-benchmarks-tmp) to measure real-server push/clone/fetch"
1215
            )
1216
            return [
1217
                remote_skip_row(subject, scenario, op, run_index, metadata, reason)
1218
                for op in OAK_NET_REMOTE_OPS
1219
            ]
1220
        return run_oak_network_remote_phase(
1221
            subject, scenario, repo, run_index, metadata, admitted_output_chars, remote_repo
1222
        )
1223
1224
    rows: list[dict[str, Any]] = []
1225
    vcs = str(subject.bin)
1226
    # Untimed setup: the remote existing is a precondition, not the measurement.
1227
    bare = oakbench_remotes.make_git_bare_remote(
1228
        vcs, repo.parent, name=f"remote-{subject.name}-{run_index}.git"
1229
    )
1230
    remote_dir = Path(bare.repo)
1231
    clone_dir = repo.parent / f"clone-{subject.name}-{run_index}"
1232
    oakbench_remotes.attach_git_origin(vcs, repo, bare)
1233
    push_row = run_command(
1234
        subject,
1235
        scenario,
1236
        "remote.push.first",
1237
        repo,
1238
        [vcs, "push", "origin", "HEAD"],
1239
        run_index,
1240
        metadata,
1241
        admitted_output_chars,
1242
    )
1243
    rows.append({**push_row, "cache_state": "cold_remote_warm_page_cache", "remote_transport": "local_file"})
1244
    # Untimed: point the bare remote's HEAD at the pushed branch so the clone
1245
    # checks out a working tree (precondition, not measurement).
1246
    branch_name = oakbench_remotes.current_git_branch(vcs, repo)
1247
    oakbench_remotes.point_git_bare_head(vcs, bare, branch_name)
1248
    clone_row = run_command(
1249
        subject,
1250
        scenario,
1251
        "remote.clone.cold",
1252
        repo.parent,
1253
        [vcs, "clone", str(remote_dir), str(clone_dir)],
1254
        run_index,
1255
        metadata,
1256
        admitted_output_chars,
1257
    )
1258
    rows.append({**clone_row, "cache_state": "cold_objects_warm_page_cache", "remote_transport": "local_file"})
1259
    if clone_row["returncode"] == 0:
1260
        pull_row = run_command(
1261
            subject,
1262
            scenario,
1263
            "remote.pull.uptodate",
1264
            clone_dir,
1265
            [vcs, "pull"],
1266
            run_index,
1267
            metadata,
1268
            admitted_output_chars,
1269
        )
1270
        rows.append({**pull_row, "cache_state": "warm", "remote_transport": "local_file"})
1271
    return rows
1272
1273
1274
def subject_returncode(code: int) -> int:
1275
    """Row returncode for a subject process: never the harness skip sentinel."""
1276
    return 1 if code == 77 else code
1277
1278
1279
def page_cache_step(metadata: dict[str, Any]) -> dict[str, Any]:
1280
    """Cache state for the command about to be timed; purges in cold mode.
1281
1282
    Runs AFTER every harness read for this op (attestation fingerprints and
1283
    payload hashes), so a cold row starts from a purged page cache. Warm rows
1284
    say what warmed them. ``cache_state`` set by the caller (remote/replay
1285
    phases) is preserved; ``page_cache_state`` is the always-present label.
1286
    """
1287
    mode = metadata.get("cache_mode_requested") or "warm"
1288
    fields: dict[str, Any] = {"cache_mode_requested": mode}
1289
    if mode == "cold":
1290
        purge = oakbench_cachectl.purge_fs_caches()
1291
        cold = oakbench_cachectl.cache_fields("cold", purge)
1292
        fields.update(page_cache_state="purged" if cold["cache_state"] == "cold" else "unknown",
1293
                      page_cache_reason=cold["cache_state_reason"])
1294
        if "cache_state" not in metadata:
1295
            fields.update(cache_state=cold["cache_state"], cache_state_reason=cold["cache_state_reason"])
1296
        return fields
1297
    fields.update(page_cache_state="warm",
1298
                  page_cache_reason="no_purge: fixture copied and earlier ops/harness reads ran in this checkout")
1299
    if "cache_state" not in metadata:
1300
        fields.update(cache_state="warm", cache_state_reason="sequence_warm_no_purge")
1301
    return fields
1302
1303
1304
def run_command(
1305
    subject: Subject,
1306
    scenario: Scenario,
1307
    op: str,
1308
    cwd: Path,
1309
    command: list[str],
1310
    run_index: int,
1311
    metadata: dict[str, Any],
1312
    admitted_output_chars: int,
1313
) -> dict[str, Any]:
1314
    cache = page_cache_step(metadata)
1315
    capture = run_timed(command, cwd, base_env(), admitted_output_chars)
1316
    result = {
1317
        **metadata,
1318
        **cache,
1319
        **subject_provenance(subject, metadata),
1320
        "subject": subject.name,
1321
        "subject_kind": subject.kind,
1322
        "subject_label": subject.label,
1323
        "git_mode": subject.git_mode,
1324
        "scenario": scenario.name,
1325
        "run": run_index,
1326
        "operation": op,
1327
        "elapsed_ms": round(capture.elapsed_ms, 3),
1328
        # 77 is the HARNESS's skip sentinel; a subject exiting 77 is a subject
1329
        # failure (QA P1 A7), recorded with its real code in process_returncode.
1330
        "returncode": subject_returncode(capture.returncode),
1331
        "process_returncode": capture.returncode,
1332
        "command": command,
1333
        "peak_rss_bytes": capture.peak_rss_bytes,
1334
        "peak_rss_source": PEAK_RSS_SOURCE if capture.peak_rss_bytes is not None else None,
1335
        "cpu_user_ms": capture.cpu_user_ms,
1336
        "cpu_sys_ms": capture.cpu_sys_ms,
1337
        "io_read_blocks": capture.io_read_blocks,
1338
        "io_write_blocks": capture.io_write_blocks,
1339
        "major_page_faults": capture.major_page_faults,
1340
        "rusage_source": RUSAGE_SOURCE if capture.cpu_user_ms is not None else None,
1341
        "measurement_source": "direct_cli_timed_subprocess",
1342
        **interaction_metrics(
1343
            command,
1344
            capture.stdout_text,
1345
            capture.stderr_text,
1346
            capture.stdout_bytes,
1347
            capture.stderr_bytes,
1348
            capture.stdout_truncated,
1349
            capture.stderr_truncated,
1350
            tool_call_count=1,
1351
        ),
1352
    }
1353
    if capture.returncode != 0:
1354
        result["stderr"] = capture.stderr_text[-4000:]
1355
    return result
1356
1357
1358
def fixture_fingerprint(root: Path) -> dict[str, Any]:
1359
    """Content identity of a generated fixture (git-sizer-style counts plus a
1360
    digest over every path, size and content hash). Untimed; computed once per
1361
    invocation, outside the measurement lock."""
1362
    digest = hashlib.sha256()
1363
    files = 0
1364
    total = 0
1365
    largest = 0
1366
    max_depth = 0
1367
    for path in sorted(root.rglob("*")):
1368
        if not path.is_file() or path.name == ".fixture-ready":
1369
            continue
1370
        rel = path.relative_to(root).as_posix()
1371
        size = path.stat().st_size
1372
        files += 1
1373
        total += size
1374
        largest = max(largest, size)
1375
        max_depth = max(max_depth, rel.count("/") + 1)
1376
        digest.update(rel.encode("utf-8", "surrogateescape") + b"\0" + str(size).encode() + b"\0")
1377
        with path.open("rb") as handle:
1378
            for chunk in iter(lambda: handle.read(1024 * 1024), b""):
1379
                digest.update(chunk)
1380
    return {"fixture_sha256": digest.hexdigest(),
1381
            "fixture_stats": {"files": files, "bytes": total, "largest_file_bytes": largest,
1382
                              "max_path_depth": max_depth}}
1383
1384
1385
def write_pattern_file(path: Path, size: int, seed: str, binary: bool) -> None:
1386
    oakbench_fixtures.write_pattern_file(path, size, seed, binary=binary, prefix="oakbench")
1387
1388
1389
def scenario_shape_id(scenario: Scenario) -> str:
1390
    """Fixture identity is the byte shape, not the scenario label.
1391
1392
    Keying the cache on shape (and recording it in the ready marker) makes a
1393
    name collision across profiles a cache miss instead of silent corruption:
1394
    a smoke run can never poison a later standard run sharing the workdir.
1395
    """
1396
    kind = "bin" if scenario.binary else "txt"
1397
    return f"{scenario.file_count}x{scenario.file_size}-{kind}"
1398
1399
1400
def fixture_path(root: Path, scenario: Scenario) -> Path:
1401
    return root / "fixtures" / f"{scenario.name}-{scenario_shape_id(scenario)}"
1402
1403
1404
def ensure_fixture(root: Path, scenario: Scenario) -> Path:
1405
    dest = fixture_path(root, scenario)
1406
    marker = dest / ".fixture-ready"
1407
    expected_marker = f"{scenario_shape_id(scenario)}\n"
1408
    if marker.exists() and marker.read_text() == expected_marker:
1409
        return dest
1410
    if dest.exists():
1411
        if marker.exists() and marker.read_text() == expected_marker:
1412
            return dest
1413
        shutil.rmtree(dest)
1414
1415
    temp = dest.parent / f"{dest.name}.building-{os.getpid()}"
1416
    if temp.exists():
1417
        shutil.rmtree(temp)
1418
    temp.mkdir(parents=True)
1419
    try:
1420
        ext = "bin" if scenario.binary else "txt"
1421
        width = max(4, len(str(scenario.file_count)))
1422
        for index in range(scenario.file_count):
1423
            subdir = temp / f"group-{index // 1000:03d}"
1424
            path = subdir / f"file-{index:0{width}d}.{ext}"
1425
            write_pattern_file(path, scenario.file_size, f"{scenario.name}:{index}", scenario.binary)
1426
        (temp / ".fixture-ready").write_text(expected_marker)
1427
        try:
1428
            os.rename(temp, dest)
1429
        except OSError:
1430
            if marker.exists() and marker.read_text() == expected_marker:
1431
                shutil.rmtree(temp)
1432
                return dest
1433
            got = marker.read_text().strip() if marker.exists() else "missing"
1434
            raise RuntimeError(
1435
                f"fixture {dest} exists with wrong shape marker "
1436
                f"(expected {expected_marker.strip()!r}, got {got!r})"
1437
            ) from None
1438
        return dest
1439
    except Exception:
1440
        if temp.exists():
1441
            shutil.rmtree(temp)
1442
        raise
1443
1444
1445
def mutate_files(repo: Path, scenario: Scenario, subject: Subject, run_index: int) -> list[str]:
1446
    """Mutate the scenario's dirty set; returns repo-relative changed paths.
1447
1448
    Mutation bytes are identical across subjects within each run.
1449
    The returned list is the ground truth the information-recall probe scores
1450
    status/diff output against.
1451
    """
1452
    ext = "bin" if scenario.binary else "txt"
1453
    paths = sorted(repo.glob(f"group-*/*.{ext}"))[: scenario.dirty_count]
1454
    if not paths and scenario.file_count == 1:
1455
        paths = sorted(repo.glob(f"**/*.{ext}"))[:1]
1456
    for index, path in enumerate(paths):
1457
        marker = f"\nmutated {scenario.name} {run_index} {index}\n".encode()
1458
        if scenario.binary:
1459
            with path.open("r+b") as fh:
1460
                size = path.stat().st_size
1461
                fh.seek(max(0, size // 2 - len(marker) // 2))
1462
                fh.write(marker[: min(len(marker), max(1, size))])
1463
        else:
1464
            with path.open("ab") as fh:
1465
                fh.write(marker)
1466
    return [path.relative_to(repo).as_posix() for path in paths]
1467
1468
1469
def copy_fixture(src: Path, dest: Path) -> None:
1470
    oakbench_fixtures.copy_fixture(src, dest, ignore_marker=True)
1471
1472
1473
# Dirty-tree spectrum (opt-in via --dirty-spectrum). pNN means NN% of the
1474
# fixture's tracked files are dirtied (ceil) before the timed command; the
1475
# dirtying and the restore are untimed setup/teardown. Scale is encoded in the
1476
# operation name per ADR-0005: status.dirty.p01 is a different measurement
1477
# than status.dirty and never shares its trend line. snapshot.dirty.pNN is
1478
# deliberately NOT implemented: a snapshot commits, so restoring cleanliness
1479
# would require history surgery or a re-copied fixture per step — not cheap,
1480
# and it would change the repository state every later operation sees.
1481
DIRTY_SPECTRUM_FRACTIONS: tuple[tuple[str, float], ...] = (
1482
    ("p01", 0.01),
1483
    ("p10", 0.10),
1484
    ("p50", 0.50),
1485
)
1486
DIRTY_SPECTRUM_BASE_OPS: tuple[str, ...] = ("status.dirty", "diff.dirty")
1487
1488
1489
def dirty_spectrum_operations(enabled: bool, skip_diff: bool = False) -> list[str]:
1490
    """Operation names the spectrum adds. Empty when the flag is off."""
1491
    if not enabled:
1492
        return []
1493
    names: list[str] = []
1494
    for label, _fraction in DIRTY_SPECTRUM_FRACTIONS:
1495
        names.append(f"status.dirty.{label}")
1496
        if not skip_diff:
1497
            names.append(f"diff.dirty.{label}")
1498
    return names
1499
1500
1501
def dirty_spectrum_marker(seed: str) -> bytes:
1502
    """Fixed edit content for one spectrum step.
1503
1504
    Deterministic (a pure function of the seed) so restore_dirty_fraction can
1505
    verify and strip exactly the bytes dirty_fraction appended.
1506
    """
1507
    return f"\ndirty-spectrum {seed}\n".encode("ascii")
1508
1509
1510
def tracked_fixture_files(repo_path: Path) -> list[Path]:
1511
    """Sorted tracked fixture files: everything outside VCS metadata dirs.
1512
1513
    After snapshot.initial every fixture file is tracked, so a filesystem walk
1514
    is the subject-neutral tracked set (no VCS command, identical for git and
1515
    oak subjects).
1516
    """
1517
    repo = Path(repo_path)
1518
    return sorted(
1519
        path
1520
        for path in repo.rglob("*")
1521
        if path.is_file() and not {".git", ".oak"}.intersection(path.relative_to(repo).parts)
1522
    )
1523
1524
1525
def dirty_fraction(repo_path: Path, fraction: float, seed: str) -> list[str]:
1526
    """Deterministically dirty ceil(fraction * N) tracked files.
1527
1528
    Selection is a seeded sample over the sorted tracked-file list; the edit
1529
    is one fixed appended line (same bytes for every selected file). Returns
1530
    the repo-relative POSIX paths of the dirtied files. Untimed by
1531
    construction: callers dirty first, then run_timed the measured command.
1532
    """
1533
    repo = Path(repo_path)
1534
    files = tracked_fixture_files(repo)
1535
    if not files or fraction <= 0:
1536
        return []
1537
    count = min(len(files), math.ceil(fraction * len(files)))
1538
    chosen = sorted(random.Random(seed).sample(files, count))
1539
    marker = dirty_spectrum_marker(seed)
1540
    for path in chosen:
1541
        with path.open("ab") as fh:
1542
            fh.write(marker)
1543
    return [path.relative_to(repo).as_posix() for path in chosen]
1544
1545
1546
def restore_dirty_fraction(repo_path: Path, changed_paths: list[str], seed: str) -> None:
1547
    """Undo dirty_fraction by stripping the exact appended marker bytes.
1548
1549
    Content returns byte-identical to the committed state, so the tree is
1550
    clean again before the next spectrum step (and before the pre-existing
1551
    operation sequence continues). Verification before truncation means a
1552
    mismatch fails loudly instead of silently corrupting a fixture copy.
1553
    """
1554
    repo = Path(repo_path)
1555
    marker = dirty_spectrum_marker(seed)
1556
    for rel in changed_paths:
1557
        path = repo / rel
1558
        size = path.stat().st_size
1559
        with path.open("r+b") as fh:
1560
            if size < len(marker):
1561
                raise RuntimeError(f"dirty-spectrum restore: {rel} is smaller than the marker")
1562
            fh.seek(size - len(marker))
1563
            if fh.read(len(marker)) != marker:
1564
                raise RuntimeError(f"dirty-spectrum restore: {rel} does not end with the expected marker")
1565
            fh.truncate(size - len(marker))
1566
1567
1568
def run_dirty_spectrum(
1569
    subject: Subject,
1570
    scenario: Scenario,
1571
    repo: Path,
1572
    ops: dict[str, list[str]],
1573
    run_index: int,
1574
    metadata: dict[str, Any],
1575
    skip_diff: bool,
1576
    admitted_output_chars: int,
1577
) -> list[dict[str, Any]]:
1578
    """Measure status (and diff) across deterministic dirty fractions.
1579
1580
    Runs on a clean committed tree. Per fraction: dirty (untimed), time the
1581
    same status/diff commands the existing status.dirty/diff.dirty rows use,
1582
    then restore (untimed) so each step starts from the identical clean state.
1583
    The seed is per scenario+fraction, never per subject or run, so every
1584
    subject measures the same dirtied file set.
1585
    """
1586
    from oakbench.conformance import tree_manifest
1587
    rows: list[dict[str, Any]] = []
1588
    base_ops = [op for op in DIRTY_SPECTRUM_BASE_OPS if not (skip_diff and op == "diff.dirty")]
1589
    for label, fraction in DIRTY_SPECTRUM_FRACTIONS:
1590
        seed = f"dirty-spectrum:{scenario.name}:{label}"
1591
        changed = dirty_fraction(repo, fraction, seed)
1592
        mutated = False
1593
        try:
1594
            for base_op in base_ops:
1595
                before = tree_manifest(repo) if base_op.startswith("diff.") else None
1596
                row = run_command(
1597
                    subject,
1598
                    scenario,
1599
                    f"{base_op}.{label}",
1600
                    repo,
1601
                    ops[base_op],
1602
                    run_index,
1603
                    metadata,
1604
                    admitted_output_chars,
1605
                )
1606
                row["dirty_fraction"] = fraction
1607
                row["dirty_file_count"] = len(changed)
1608
                if before is not None:
1609
                    row.setdefault("process_returncode", row["returncode"])
1610
                    row = oakbench_semantics.finalize_patch_evidence(row, before, tree_manifest(repo), application_required=False)
1611
                    row["cache_state"] = "hash_warmed_isolated_dirty_replay"
1612
                rows.append(row)
1613
                mutated = row.get("integrity_ok") is False
1614
                if mutated:
1615
                    return rows
1616
        finally:
1617
            if not mutated:
1618
                restore_dirty_fraction(repo, changed, seed)
1619
    return rows
1620
1621
1622
def ops_for(subject: Subject, track: str) -> dict[str, list[str]]:
1623
    """Non-snapshot operation commands from the command-semantics contract.
1624
1625
    Snapshot operations go through snapshot_message/run_git_snapshot so git
1626
    pays its stage+commit as two recorded calls. Where Oak lacks a compact
1627
    equivalent, core-equivalent rows use the closest available command; the
1628
    contract JSON records that gap explicitly.
1629
    """
1630
    sem = semantics()
1631
    vcs = str(subject.bin)
1632
    status = [vcs, *sem.status_args(subject.kind, track)]
1633
    return {
1634
        "repo.init": [vcs, *sem.init_args(subject.kind)],
1635
        "status.clean": status,
1636
        "status.dirty": list(status),
1637
        "diff.dirty": [vcs, *sem.diff_args(subject.kind, track)],
1638
        "branch.create": [vcs, *sem.branch_create_args(subject.kind)],
1639
    }
1640
1641
1642
def snapshot_message(op: str) -> str:
1643
    return "initial" if op == "snapshot.initial" else op
1644
1645
1646
def run_snapshot(
1647
    subject: Subject,
1648
    scenario: Scenario,
1649
    op: str,
1650
    cwd: Path,
1651
    run_index: int,
1652
    metadata: dict[str, Any],
1653
    admitted_output_chars: int,
1654
) -> list[dict[str, Any]]:
1655
    """Snapshot via the command-semantics contract.
1656
1657
    Subjects that stage separately (git) record the stage and commit as
1658
    sub-rows plus a combined canonical row; one-command subjects (oak) record
1659
    a single row. Agents pay every recorded call.
1660
    """
1661
    sem = semantics()
1662
    commands = sem.snapshot_commands(str(subject.bin), subject.kind, snapshot_message(op))
1663
    reads = ["repository_state_fingerprint", "payload_sample_hashes"]
1664
    if len(commands) == 1:
1665
        before = oakbench_integrity.repository_state_fingerprint(cwd)
1666
        expected_hashes = oakbench_integrity.sample_worktree_payload_hashes(cwd)
1667
        row = run_command(subject, scenario, op, cwd, commands[0], run_index, metadata, admitted_output_chars)
1668
        row["harness_reads_before_op"] = reads
1669
        return [apply_state_change_attestation(row, cwd, before, op, expected_hashes=expected_hashes)]
1670
1671
    stage_command, commit_command = commands
1672
    add_result = run_command(
1673
        subject, scenario, f"{op}.add", cwd, stage_command, run_index, metadata, admitted_output_chars
1674
    )
1675
    if add_result["returncode"] != 0:
1676
        return [add_result]
1677
    before_commit = oakbench_integrity.repository_state_fingerprint(cwd)
1678
    expected_hashes = oakbench_integrity.sample_worktree_payload_hashes(cwd)
1679
    commit_result = run_command(
1680
        subject, scenario, f"{op}.commit", cwd, commit_command, run_index, metadata, admitted_output_chars
1681
    )
1682
    commit_result["harness_reads_before_op"] = reads
1683
    apply_state_change_attestation(commit_result, cwd, before_commit, op, expected_hashes=expected_hashes)
1684
    subrows = [add_result, commit_result]
1685
    combined = {
1686
        **commit_result,
1687
        "operation": op,
1688
        "elapsed_ms": round(add_result["elapsed_ms"] + commit_result["elapsed_ms"], 3),
1689
        "returncode": commit_result["returncode"],
1690
        "command": [stage_command, commit_command],
1691
        **merge_interaction_metrics([stage_command, commit_command], subrows),
1692
    }
1693
    if add_result["returncode"] != 0 or commit_result["returncode"] != 0:
1694
        combined["stderr"] = (add_result.get("stderr", "") + "\n" + commit_result.get("stderr", ""))[-4000:]
1695
    return [add_result, commit_result, combined]
1696
1697
1698
def run_isolated_dirty_phase(
1699
    subject: Subject, scenario: Scenario, fixture: Path, run_index: int,
1700
    run_root: Path, metadata: dict[str, Any], skip_diff: bool,
1701
    admitted_output_chars: int, track: str, skip_determinism_probe: bool,
1702
    dirty_spectrum: bool = False,
1703
) -> list[dict[str, Any]]:
1704
    """Reconstruct dirty state after the main chain; never reuse it on mutation.
1705
1706
    Setup and exact pre/post reads warm this disposable repository. Its one
1707
    timed diff is explicitly a hash-warmed replay, not an in-sequence or cold
1708
    measurement. Every subsequent output probe is diagnostic with null timing.
1709
    """
1710
    from oakbench.conformance import tree_manifest
1711
    suffix = "dirty-spectrum" if dirty_spectrum else "dirty-replay"
1712
    repo = run_root / scenario.name / subject.name / f"run-{run_index}-{suffix}"
1713
    copy_fixture(fixture, repo)
1714
    ops = ops_for(subject, track)
1715
    replay_metadata = {**metadata, "measurement_phase": "isolated_dirty_replay",
1716
                       "cache_state": "warm_isolated_fixture_replay"}
1717
    setup_commands = [ops["repo.init"]]
1718
    setup_commands += [[str(subject.bin), *args] for _, args in WIRED_GIT_MODES.get(subject.git_mode, [])]
1719
    setup_commands += semantics().snapshot_commands(str(subject.bin), subject.kind, snapshot_message("snapshot.initial"))
1720
    rows: list[dict[str, Any]] = []
1721
    # Every operation this phase would emit, in order, so an abort records
1722
    # the abandoned tail as skip rows (never "fewer samples").
1723
    if dirty_spectrum:
1724
        phase_ops = dirty_spectrum_operations(True, skip_diff)
1725
    else:
1726
        phase_ops = ([] if skip_diff else ["diff.dirty"])
1727
        if not skip_determinism_probe:
1728
            phase_ops += ["status.dirty.determinism", "status.dirty.inforecall"]
1729
            if not skip_diff:
1730
                phase_ops += ["diff.dirty.determinism", "diff.dirty.inforecall", "diff.full.inforecall"]
1731
1732
    def abandon(reason: str, **extra: Any) -> list[dict[str, Any]]:
1733
        recorded = {str(row.get("operation")) for row in rows}
1734
        for op in phase_ops:
1735
            if op not in recorded:
1736
                row = remote_skip_row(subject, scenario, op, run_index, replay_metadata, reason)
1737
                row.update(extra)
1738
                rows.append(row)
1739
        return rows
1740
1741
    try:
1742
        for command in setup_commands:
1743
            proc = subprocess.run(command, cwd=repo, env=base_env(), stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, check=False)
1744
            if proc.returncode:
1745
                return abandon("isolated_dirty_replay_setup_failed", setup_returncode=proc.returncode,
1746
                               stderr=proc.stderr.decode("utf-8", "replace")[-4000:])
1747
        if dirty_spectrum:
1748
            return run_dirty_spectrum(subject, scenario, repo, ops, run_index, replay_metadata, skip_diff, admitted_output_chars)
1749
        changed_paths = mutate_files(repo, scenario, subject, run_index)
1750
        if not skip_diff:
1751
            reason = git_huge_binary_diff_limit_reason(subject, scenario, "diff.dirty")
1752
            if reason is not None:
1753
                for operation in (["diff.dirty"] if skip_determinism_probe else
1754
                                  ["diff.dirty", "diff.dirty.determinism", "diff.dirty.inforecall", "diff.full.inforecall"]):
1755
                    rows.append(git_huge_binary_diff_skip_row(subject, scenario, operation, ops["diff.dirty"], run_index, replay_metadata))
1756
            else:
1757
                before = tree_manifest(repo)
1758
                row = run_command(subject, scenario, "diff.dirty", repo, ops["diff.dirty"], run_index,
1759
                                  {**replay_metadata, "cache_state": "hash_warmed_isolated_dirty_replay"}, admitted_output_chars)
1760
                row.setdefault("process_returncode", row["returncode"])
1761
                row = oakbench_semantics.finalize_patch_evidence(row, before, tree_manifest(repo), application_required=False)
1762
                rows.append(row)
1763
                if row.get("integrity_ok") is False:
1764
                    return abandon("aborted_after_failed_prerequisite:diff.dirty")
1765
        if skip_determinism_probe:
1766
            return rows
1767
        for base_op in ("status.dirty", "diff.dirty", "diff.full"):
1768
            if base_op.startswith("diff.") and (skip_diff or git_huge_binary_diff_limit_reason(subject, scenario, "diff.dirty") is not None):
1769
                continue
1770
            command = ([str(subject.bin), *semantics().diff_args(subject.kind, "core-equivalent")]
1771
                       if base_op == "diff.full" else ops[base_op])
1772
            if base_op != "diff.full":
1773
                row = run_determinism_probe(subject, scenario, base_op, repo, command, run_index, replay_metadata)
1774
                rows.append(row)
1775
                if row.get("integrity_ok") is False:
1776
                    return abandon(f"aborted_after_failed_prerequisite:{row.get('operation')}")
1777
            row = run_information_probe(subject, scenario, base_op, repo, command, run_index, replay_metadata,
1778
                                        changed_paths, pristine_fixture=fixture if base_op == "diff.full" else None)
1779
            rows.append(row)
1780
            if row.get("integrity_ok") is False:
1781
                return abandon(f"aborted_after_failed_prerequisite:{row.get('operation')}")
1782
        return rows
1783
    finally:
1784
        if subject.git_mode in FSMONITOR_MODES:
1785
            subprocess.run([str(subject.bin), "fsmonitor--daemon", "stop"], cwd=repo, env=base_env(),
1786
                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
1787
1788
1789
MAIN_TIMED_OPS = ("proc.spawn", "repo.init", "snapshot.initial", "status.clean", "status.dirty",
1790
                  "snapshot.dirty", "branch.create", "task.snapshot")
1791
1792
1793
def planned_operations(subject: Subject, *, skip_remote: bool, skip_diff: bool,
1794
                       skip_determinism_probe: bool) -> list[str]:
1795
    """Every operation row a complete scenario run would emit, in order.
1796
1797
    Used only to name abandoned operations as skip rows after an abort.
1798
    """
1799
    planned = list(MAIN_TIMED_OPS)
1800
    if not skip_remote:
1801
        planned += list(REMOTE_OPS if subject.kind == "git" else OAK_NET_REMOTE_OPS)
1802
    planned += list(semantics().agent_native_json_probes(subject.kind, BENCH_TASK_BRANCH))
1803
    if not skip_diff:
1804
        planned.append("diff.dirty")
1805
    if not skip_determinism_probe:
1806
        planned += ["status.dirty.determinism", "status.dirty.inforecall"]
1807
        if not skip_diff:
1808
            planned += ["diff.dirty.determinism", "diff.dirty.inforecall", "diff.full.inforecall"]
1809
    return planned
1810
1811
1812
def run_subject_scenario(
1813
    subject: Subject,
1814
    scenario: Scenario,
1815
    fixture: Path,
1816
    run_index: int,
1817
    run_root: Path,
1818
    metadata: dict[str, Any],
1819
    skip_diff: bool,
1820
    admitted_output_chars: int,
1821
    track: str,
1822
    skip_determinism_probe: bool = False,
1823
    skip_remote: bool = False,
1824
    dirty_spectrum: bool = False,
1825
) -> list[dict[str, Any]]:
1826
    metadata = {**metadata, "benchmark_track": track}
1827
    applies, skip_reason = git_mode_applies_to_scenario(subject.git_mode, scenario)
1828
    if not applies:
1829
        return [
1830
            remote_skip_row(
1831
                subject,
1832
                scenario,
1833
                "scenario.mode_skipped",
1834
                run_index,
1835
                metadata,
1836
                skip_reason or "git_mode_not_applicable",
1837
            )
1838
        ]
1839
    if subject.git_mode == "lfs" and not git_lfs_available(subject.bin):
1840
        return [
1841
            remote_skip_row(
1842
                subject,
1843
                scenario,
1844
                "scenario.mode_skipped",
1845
                run_index,
1846
                metadata,
1847
                "git_lfs_unavailable: install git-lfs to measure the git_lfs tuned comparator",
1848
            )
1849
        ]
1850
1851
    repo = run_root / scenario.name / subject.name / f"run-{run_index}"
1852
    copy_fixture(fixture, repo)
1853
    ops = ops_for(subject, track)
1854
    results: list[dict[str, Any]] = []
1855
    planned = planned_operations(subject, skip_remote=skip_remote, skip_diff=skip_diff,
1856
                                 skip_determinism_probe=skip_determinism_probe)
1857
1858
    def abort_after(op: str) -> list[dict[str, Any]]:
1859
        """A failed or mutating op ends this cell's timing: every abandoned
1860
        operation is a recorded skip row naming the failed prerequisite, so a
1861
        consumer never sees merely "fewer samples" (skips are never hidden)."""
1862
        recorded = {str(row.get("operation")) for row in results}
1863
        tail = planned[planned.index(op) + 1:] if op in planned else planned
1864
        results.extend(
1865
            remote_skip_row(subject, scenario, name, run_index, metadata,
1866
                            f"aborted_after_failed_prerequisite:{op}")
1867
            for name in tail if name not in recorded
1868
        )
1869
        return results
1870
1871
    def timed(op: str) -> bool:
1872
        start_index = len(results)
1873
        # Harness attestation reads (metadata fingerprint, payload sample
1874
        # hashes) happen BEFORE the cache step, so in cold mode the purge
1875
        # removes whatever they warmed; rows name the reads they followed.
1876
        if op in {"snapshot.initial", "snapshot.dirty", "task.snapshot"}:
1877
            results.extend(run_snapshot(subject, scenario, op, repo, run_index, metadata, admitted_output_chars))
1878
        else:
1879
            before = oakbench_integrity.repository_state_fingerprint(repo) if op == "branch.create" else None
1880
            row = run_command(subject, scenario, op, repo, ops[op], run_index, metadata, admitted_output_chars)
1881
            if op == "branch.create":
1882
                row["harness_reads_before_op"] = ["repository_state_fingerprint"]
1883
                apply_state_change_attestation(row, repo, before, op)
1884
            results.append(row)
1885
        return bool(results[start_index:]) and all(
1886
            row_returncode(row) == 0 and all(row.get(flag) is not False for flag in
1887
                ("integrity_ok", "integrity_check_passed", "content_integrity_check_passed"))
1888
            for row in results[start_index:])
1889
1890
    try:
1891
        # Binary startup overhead in isolation. Agent fleets invoke the VCS
1892
        # thousands of times a day; process spawn cost is paid on every call.
1893
        results.append(
1894
            run_command(
1895
                subject,
1896
                scenario,
1897
                "proc.spawn",
1898
                repo,
1899
                [str(subject.bin), "--version"],
1900
                run_index,
1901
                metadata,
1902
                admitted_output_chars,
1903
            )
1904
        )
1905
1906
        if row_returncode(results[-1]) != 0:
1907
            return abort_after("proc.spawn")
1908
        if not timed("repo.init"):
1909
            return abort_after("repo.init")
1910
        if subject.git_mode:
1911
            for setup_op, setup_args in WIRED_GIT_MODES[subject.git_mode]:
1912
                setup_row = run_command(
1913
                    subject,
1914
                    scenario,
1915
                    f"mode.setup.{setup_op}",
1916
                    repo,
1917
                    [str(subject.bin), *setup_args],
1918
                    run_index,
1919
                    metadata,
1920
                    admitted_output_chars,
1921
                )
1922
                results.append(setup_row)
1923
                if setup_row["returncode"] != 0:
1924
                    # A tuned subject whose setup failed would silently measure
1925
                    # stock git under a tuned label. Stop here: the failed setup
1926
                    # row is the evidence, and the run exits nonzero.
1927
                    setup_row["notes"] = (
1928
                        f"mode setup failed; remaining {subject.name} operations for this run "
1929
                        "were aborted so stock-git numbers are never published under a tuned label"
1930
                    )
1931
                    return abort_after("repo.init")
1932
        if subject.git_mode == "lfs":
1933
            untracked = lfs_untracked_extensions(repo)
1934
            if untracked:
1935
                # config/git_modes.json git_lfs fairness_note: the mode "requires
1936
                # ... fixture generation with LFS-tracked binaries". A fixture file
1937
                # the track patterns miss would be committed as a plain git blob,
1938
                # and an untracked comparator is an unfair comparator — so the
1939
                # mode setup fails closed here and the scenario's rows become an
1940
                # honest skip instead of git numbers under the git_lfs label.
1941
                results.append(
1942
                    remote_skip_row(
1943
                        subject,
1944
                        scenario,
1945
                        "mode.setup.lfs_track_coverage",
1946
                        run_index,
1947
                        metadata,
1948
                        "lfs_track_incomplete:" + ",".join(untracked),
1949
                    )
1950
                )
1951
                return abort_after("repo.init")
1952
        for op in ("snapshot.initial", "status.clean", "status.dirty", "snapshot.dirty", "branch.create", "task.snapshot"):
1953
            if op == "status.dirty":
1954
                mutate_files(repo, scenario, subject, run_index)
1955
            elif op == "task.snapshot":
1956
                mutate_files(repo, scenario, subject, run_index + 10_000)
1957
            if not timed(op):
1958
                return abort_after(op)
1959
        if not skip_remote:
1960
            results.extend(
1961
                run_remote_phase(subject, scenario, repo, run_index, metadata, admitted_output_chars)
1962
            )
1963
        results.extend(
1964
            run_agent_native_json_probes(
1965
                subject, scenario, repo, run_index, metadata, admitted_output_chars
1966
            )
1967
        )
1968
        if not skip_diff or not skip_determinism_probe:
1969
            results.extend(run_isolated_dirty_phase(subject, scenario, fixture, run_index, run_root,
1970
                           metadata, skip_diff, admitted_output_chars, track, skip_determinism_probe))
1971
        if dirty_spectrum:
1972
            results.extend(run_isolated_dirty_phase(subject, scenario, fixture, run_index, run_root,
1973
                           metadata, skip_diff, admitted_output_chars, track, True, dirty_spectrum=True))
1974
        return results
1975
    finally:
1976
        if subject.git_mode in FSMONITOR_MODES:
1977
            # One fsmonitor daemon spawns per repo; stop it so repeated benchmark
1978
            # runs do not accumulate daemons watching deleted workdirs. Untimed and
1979
            # unrecorded: teardown, not a measured operation.
1980
            subprocess.run(
1981
                [str(subject.bin), "fsmonitor--daemon", "stop"],
1982
                cwd=repo,
1983
                env=base_env(),
1984
                stdout=subprocess.DEVNULL,
1985
                stderr=subprocess.DEVNULL,
1986
                check=False,
1987
            )
1988
1989
1990
def remote_preflight_requirements(
1991
    subjects: list[Subject],
1992
    *,
1993
    skip_remote: bool,
1994
) -> list[oakbench_remotes.RemoteRequirement]:
1995
    if skip_remote or not any(subject.kind == "oak" for subject in subjects):
1996
        return []
1997
    return [
1998
        oakbench_remotes.RemoteRequirement(
1999
            purpose="default",
2000
            lane="core",
2001
            operations=tuple(OAK_NET_REMOTE_OPS),
2002
        )
2003
    ]
2004
2005
2006
def metric_medians(rows: list[dict[str, Any]], metric: str) -> dict[tuple[str, str], float]:
2007
    grouped: dict[tuple[str, str], list[float]] = {}
2008
    for row in rows:
2009
        if row["returncode"] != 0:
2010
            continue
2011
        op = row["operation"]
2012
        if op.endswith(".add") or op.endswith(".commit"):
2013
            continue
2014
        try:
2015
            value = float(row[metric])
2016
        except (KeyError, TypeError, ValueError):
2017
            continue
2018
        key = (row["subject"], row["scenario"] + "/" + op)
2019
        grouped.setdefault(key, []).append(value)
2020
    return {key: statistics.median(values) for key, values in grouped.items()}
2021
2022
2023
def average_by_subject(rows: list[dict[str, Any]], metric: str) -> dict[str, float]:
2024
    grouped: dict[str, list[float]] = {}
2025
    for row in rows:
2026
        if row["returncode"] != 0:
2027
            continue
2028
        op = row["operation"]
2029
        if op.endswith(".add") or op.endswith(".commit"):
2030
            continue
2031
        # Diagnostic and setup rows stay out of cross-operation subject
2032
        # averages so trend lines keep comparing the same op mix.
2033
        # remote.* ops stay out too: subjects without a wired remote skip them,
2034
        # and an asymmetric op mix would silently skew the cross-op average.
2035
        if (
2036
            op.endswith(".determinism")
2037
            or op.endswith(".inforecall")
2038
            or op.startswith("mode.setup")
2039
            or op.startswith("remote.")
2040
            or op == "proc.spawn"
2041
        ):
2042
            continue
2043
        try:
2044
            value = float(row[metric])
2045
        except (KeyError, TypeError, ValueError):
2046
            continue
2047
        grouped.setdefault(str(row["subject"]), []).append(value)
2048
    return {subject: statistics.mean(values) for subject, values in grouped.items()}
2049
2050
2051
# Delta math and formatting live in oakbench.reporting; these aliases keep
2052
# the historical local names used throughout the summary writer.
2053
fmt_delta = fmt_delta_lower_better
2054
fmt_float = fmt_num
2055
2056
2057
def strongest_git_baseline(by_key: dict[tuple[str, str], float], subjects: list[Subject]) -> dict[str, tuple[str, float]]:
2058
    """Per scenario/op, the FASTEST Git subject that measured it (stock or tuned).
2059
2060
    Oak is compared against the stronger baseline so a weaker Git mode (e.g.
2061
    fsmonitor IPC on a tiny tree) can never flatter Oak (ADR-0011).
2062
    """
2063
    git_names = [subject.name for subject in subjects if subject.kind == "git" and subject.name != "git_aa"]
2064
    best: dict[str, tuple[str, float]] = {}
2065
    for (name, scenario_op), value in by_key.items():
2066
        if "/mode.setup." in scenario_op or "/scenario." in scenario_op:
2067
            continue
2068
        if name in git_names and (scenario_op not in best or value < best[scenario_op][1]):
2069
            best[scenario_op] = (name, value)
2070
    return best
2071
2072
2073
def strongest_git_lines(by_key: dict[tuple[str, str], float], subjects: list[Subject]) -> list[str]:
2074
    git_subjects = [subject for subject in subjects if subject.kind == "git" and subject.name != "git_aa"]
2075
    if len(git_subjects) < 2:
2076
        return []
2077
    best = strongest_git_baseline(by_key, subjects)
2078
    oak_names = [subject.name for subject in subjects if subject.kind == "oak"]
2079
    lines = ["", "## Oak vs the strongest Git baseline per cell", "",
2080
             "| Scenario / operation | Strongest Git | Git median ms | " + " | ".join(f"`{n}` vs strongest" for n in oak_names) + " |",
2081
             "| --- | --- | ---: | " + " | ".join("---:" for _ in oak_names) + " |"]
2082
    for scenario_op, (name, value) in sorted(best.items()):
2083
        cells = []
2084
        for oak_name in oak_names:
2085
            oak_value = by_key.get((oak_name, scenario_op))
2086
            cells.append("" if oak_value is None else f"{delta_pct(value, oak_value):+.1f}%")
2087
        lines.append(f"| `{scenario_op}` | `{name}` | {value:.1f} | " + " | ".join(cells) + " |")
2088
    return lines
2089
2090
2091
def summary_text(rows: list[dict[str, Any]], subjects: list[Subject]) -> str:
2092
    by_key = metric_medians(rows, "elapsed_ms")
2093
    token_by_key = metric_medians(rows, "estimated_tokens_total")
2094
    tools_by_key = metric_medians(rows, "tool_call_count")
2095
    output_by_key = metric_medians(rows, "raw_output_bytes")
2096
    avg_ms = average_by_subject(rows, "elapsed_ms")
2097
    avg_tokens = average_by_subject(rows, "estimated_tokens_total")
2098
    avg_cost = average_by_subject(rows, "estimated_cost_weighted_tokens")
2099
    avg_tools = average_by_subject(rows, "tool_call_count")
2100
    avg_output = average_by_subject(rows, "raw_output_bytes")
2101
    git_name = next((subject.name for subject in subjects if subject.kind == "git"), None)
2102
    oak_baseline = next((subject.name for subject in subjects if subject.name in {"oak_installed", "oak_main"}), None)
2103
    lines = [
2104
        "# Oak Benchmark Summary",
2105
        "",
2106
        f"Track: `{rows[0].get('benchmark_track', 'unknown') if rows else 'unknown'}`.",
2107
        "Positive deltas mean the row subject is lower/better than the baseline for that metric.",
2108
        "Estimated tokens are a dependency-free proxy: command text plus admitted stdout/stderr characters divided by four.",
2109
        "Tool calls are exact terminal/VCS command counts for this direct CLI harness.",
2110
        "Output bytes are full stdout/stderr byte counts, even when admitted token text was capped.",
2111
        f"Cost-weighted tokens use billing direction ({COST_WEIGHTS_NOTE}): commands an agent types are model output, stdout it reads is model input.",
2112
        "",
2113
        "## Subject Averages",
2114
        "",
2115
        "| 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 |",
2116
        "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
2117
    ]
2118
    git_avg_tokens = avg_tokens.get(git_name) if git_name else None
2119
    git_avg_tools = avg_tools.get(git_name) if git_name else None
2120
    oak_avg_tokens = avg_tokens.get(oak_baseline) if oak_baseline else None
2121
    oak_avg_tools = avg_tools.get(oak_baseline) if oak_baseline else None
2122
    for subject in subjects:
2123
        token_value = avg_tokens.get(subject.name)
2124
        tool_value = avg_tools.get(subject.name)
2125
        lines.append(
2126
            "| "
2127
            f"`{subject.name}` | "
2128
            f"{fmt_float(avg_ms.get(subject.name))} | "
2129
            f"{fmt_float(token_value)} | "
2130
            f"{fmt_float(avg_cost.get(subject.name))} | "
2131
            f"{fmt_float(avg_output.get(subject.name))} | "
2132
            f"{fmt_float(tool_value, 2)} | "
2133
            f"{fmt_delta(git_avg_tokens, token_value, subject.name == git_name)} | "
2134
            f"{fmt_delta(oak_avg_tokens, token_value, subject.name == oak_baseline or subject.kind == 'git')} | "
2135
            f"{fmt_delta(git_avg_tools, tool_value, subject.name == git_name)} | "
2136
            f"{fmt_delta(oak_avg_tools, tool_value, subject.name == oak_baseline or subject.kind == 'git')} |"
2137
        )
2138
2139
    info_rows = [row for row in rows if str(row.get("operation", "")).endswith(".inforecall")]
2140
    if info_rows:
2141
        lines.extend(
2142
            [
2143
                "",
2144
                "## Output Information Content",
2145
                "",
2146
                "Bytes saved are only a win if the changed-file list survives. Recall is the",
2147
                "fraction of ground-truth changed paths recoverable from the command output;",
2148
                "pipe-compat means diff output kept unified hunk/file structure (or an explicit",
2149
                "binary notice) that scripts and `patch`-style tooling can consume.",
2150
                "",
2151
                "| Subject | Operation | Mean recall | Mean bytes/file named | Pipe-compat rate |",
2152
                "| --- | --- | ---: | ---: | ---: |",
2153
            ]
2154
        )
2155
        grouped: dict[tuple[str, str], list[dict[str, Any]]] = {}
2156
        for row in info_rows:
2157
            grouped.setdefault((str(row["subject"]), str(row["operation"])), []).append(row)
2158
        for (subject_name, op), op_rows in sorted(grouped.items()):
2159
            recalls = [row["information_recall"] for row in op_rows if row.get("information_recall") is not None]
2160
            byte_costs = [
2161
                row["bytes_per_changed_file_named"]
2162
                for row in op_rows
2163
                if row.get("bytes_per_changed_file_named") is not None
2164
            ]
2165
            compat_flags = [row["pipe_compatible_unified"] for row in op_rows if "pipe_compatible_unified" in row]
2166
            recall_cell = f"{statistics.mean(recalls):.3f}" if recalls else ""
2167
            bytes_cell = f"{statistics.mean(byte_costs):.0f}" if byte_costs else ""
2168
            compat_cell = f"{sum(1 for flag in compat_flags if flag) / len(compat_flags) * 100:.0f}%" if compat_flags else ""
2169
            lines.append(f"| `{subject_name}` | `{op}` | {recall_cell} | {bytes_cell} | {compat_cell} |")
2170
2171
    lines.extend(
2172
        [
2173
            "",
2174
            "## Operation Medians",
2175
            "",
2176
            "| 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 |",
2177
            "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
2178
        ]
2179
    )
2180
    all_ops = sorted({scenario_op for _, scenario_op in by_key})
2181
    for scenario_op in all_ops:
2182
        git_base = by_key.get((git_name, scenario_op)) if git_name else None
2183
        git_token_base = token_by_key.get((git_name, scenario_op)) if git_name else None
2184
        git_tool_base = tools_by_key.get((git_name, scenario_op)) if git_name else None
2185
        oak_base = by_key.get((oak_baseline, scenario_op)) if oak_baseline else None
2186
        oak_token_base = token_by_key.get((oak_baseline, scenario_op)) if oak_baseline else None
2187
        oak_tool_base = tools_by_key.get((oak_baseline, scenario_op)) if oak_baseline else None
2188
        for subject in subjects:
2189
            value = by_key.get((subject.name, scenario_op))
2190
            if value is None:
2191
                continue
2192
            token_value = token_by_key.get((subject.name, scenario_op))
2193
            tool_value = tools_by_key.get((subject.name, scenario_op))
2194
            output_value = output_by_key.get((subject.name, scenario_op))
2195
            vs_git_time = "" if git_base is None or subject.name == git_name else f"{delta_pct(git_base, value):+.1f}%"
2196
            vs_git_tokens = (
2197
                ""
2198
                if git_token_base is None or token_value is None or subject.name == git_name
2199
                else f"{delta_pct(git_token_base, token_value):+.1f}%"
2200
            )
2201
            vs_git_tools = (
2202
                ""
2203
                if git_tool_base is None or tool_value is None or subject.name == git_name
2204
                else f"{delta_pct(git_tool_base, tool_value):+.1f}%"
2205
            )
2206
            vs_oak_time = (
2207
                ""
2208
                if oak_base is None or subject.name == oak_baseline or subject.kind == "git"
2209
                else f"{delta_pct(oak_base, value):+.1f}%"
2210
            )
2211
            vs_oak_tokens = (
2212
                ""
2213
                if oak_token_base is None or token_value is None or subject.name == oak_baseline or subject.kind == "git"
2214
                else f"{delta_pct(oak_token_base, token_value):+.1f}%"
2215
            )
2216
            vs_oak_tools = (
2217
                ""
2218
                if oak_tool_base is None or tool_value is None or subject.name == oak_baseline or subject.kind == "git"
2219
                else f"{delta_pct(oak_tool_base, tool_value):+.1f}%"
2220
            )
2221
            # Unmeasured renders blank, never 0 (ADR-0002): diagnostic rows
2222
            # carry no token/tool fields and must not look like free output.
2223
            lines.append(
2224
                f"| `{scenario_op}` | `{subject.name}` | {value:.1f} | "
2225
                f"{fmt_float(token_value)} | "
2226
                f"{fmt_float(output_value, 0)} | "
2227
                f"{fmt_float(tool_value)} | "
2228
                f"{vs_git_time} | {vs_git_tokens} | {vs_git_tools} | "
2229
                f"{vs_oak_time} | {vs_oak_tokens} | {vs_oak_tools} |"
2230
            )
2231
    lines.extend(strongest_git_lines(by_key, subjects))
2232
    skips = [row for row in rows if row.get("skipped")]
2233
    if skips:
2234
        lines.extend(["", "## Skipped (unmeasured, never hidden)", ""])
2235
        seen_reasons: dict[tuple[str, str], int] = {}
2236
        for row in skips:
2237
            seen_reasons[(str(row["subject"]), str(row.get("skip_reason", "")))] = (
2238
                seen_reasons.get((str(row["subject"]), str(row.get("skip_reason", ""))), 0) + 1
2239
            )
2240
        for (subject_name, reason), count in sorted(seen_reasons.items()):
2241
            lines.append(f"- `{subject_name}`: {count} rows skipped — {reason}")
2242
    failures = [row for row in rows if row["returncode"] != 0 and not row.get("skipped")]
2243
    if failures:
2244
        lines.extend(["", "## Failures", ""])
2245
        for row in failures[:20]:
2246
            reason = row.get("integrity_failure_reason") or row.get("content_integrity_failure_reason") or row.get(
2247
                "stderr", ""
2248
            )
2249
            lines.append(
2250
                f"- `{row['subject']}` `{row['scenario']}/{row['operation']}` "
2251
                f"exit {row['returncode']}: `{str(reason).splitlines()[-1:]}`"
2252
            )
2253
    return "\n".join(lines) + "\n"
2254
2255
2256
def main() -> int:
2257
    args = parse_args()
2258
    subjects = expand_git_modes(load_subjects(args), args.git_modes)
2259
    oakbench_remotes.print_remote_preflight_warnings(
2260
        oakbench_remotes.oak_remote_preflight_warnings(
2261
            remote_preflight_requirements(subjects, skip_remote=args.skip_remote)
2262
        )
2263
    )
2264
    scenarios = PROFILES[args.profile]
2265
    if args.scenarios:
2266
        wanted = {name.strip() for name in args.scenarios.split(",") if name.strip()}
2267
        unknown = wanted - {scenario.name for scenario in scenarios}
2268
        if unknown:
2269
            raise SystemExit(f"--scenarios not in profile {args.profile}: {', '.join(sorted(unknown))}")
2270
        scenarios = [scenario for scenario in scenarios if scenario.name in wanted]
2271
    if args.runs:
2272
        scenarios = [
2273
            Scenario(s.name, s.file_count, s.file_size, s.dirty_count, s.binary, args.runs)
2274
            for s in scenarios
2275
        ]
2276
2277
    timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
2278
    args.results.mkdir(parents=True, exist_ok=True)
2279
    args.workdir.mkdir(parents=True, exist_ok=True)
2280
    run_root = args.workdir / "runs" / timestamp
2281
    fixture_root = args.workdir
2282
2283
    metadata = {
2284
        "bench_id": timestamp,
2285
        "profile": args.profile,
2286
        "benchmark_track": args.track,
2287
        "command_semantics_version": COMMAND_SEMANTICS_VERSION,
2288
        "timestamp_utc": timestamp,
2289
        "host": platform.node(),
2290
        "platform": platform.platform(),
2291
        "machine": platform.machine(),
2292
        "python": platform.python_version(),
2293
        "env_isolation_version": oakbench_environment.ENV_ISOLATION_VERSION,
2294
        "output_oracle_version": oakbench_semantics.OUTPUT_ORACLE_VERSION,
2295
        "patch_oracle_version": oakbench_semantics.PATCH_ORACLE_VERSION,
2296
        "subject_versions": {subject.name: subject_version(subject) for subject in subjects},
2297
        "subject_details": subject_details(subjects),
2298
        "source": source_metadata(args.oak_repo),
2299
        "randomized_subject_order": bool(args.randomize_subject_order),
2300
        "timing_contract_version": TIMING_CONTRACT_VERSION,
2301
        "cache_mode_requested": args.cache_mode,
2302
        **runner_fields(),
2303
    }
2304
    if args.cache_mode == "cold":
2305
        preflight = oakbench_cachectl.purge_fs_caches()
2306
        if not preflight.get("ok"):
2307
            raise SystemExit(
2308
                "--cache-mode cold refused: page-cache purge is not permitted on this host "
2309
                f"({preflight.get('method')}: {preflight.get('error')}). On macOS add a NOPASSWD "
2310
                "sudoers entry for /usr/sbin/purge; on Linux run with write access to "
2311
                "/proc/sys/vm/drop_caches. Cold rows are never claimed without a verified purge.")
2312
2313
    # Fixture generation runs OUTSIDE the measurement lock: building file
2314
    # trees parallelizes safely (atomic shape-keyed builds), only timed
2315
    # measurement must serialize across processes.
2316
    fixtures: dict[str, Path] = {}
2317
    for scenario in scenarios:
2318
        print(
2319
            f"[fixture] {scenario.name}: {scenario.file_count} files x {scenario.file_size} bytes",
2320
            flush=True,
2321
        )
2322
        fixtures[scenario.name] = ensure_fixture(fixture_root, scenario)
2323
    fixture_fingerprints = {name: fixture_fingerprint(path) for name, path in fixtures.items()}
2324
2325
    metadata["load_tier"] = args.load_tier
2326
2327
    rows: list[dict[str, Any]] = []
2328
    with measurement_lock("bench") as lock_info:
2329
        metadata["measurement_lock_wait_ms"] = lock_info.wait_ms
2330
        metadata["measurement_lock"] = "held" if lock_info.enabled else "disabled"
2331
        # Background load (untimed start/stop). Failing to start under a
2332
        # non-none tier converts the whole invocation to skip rows: load that
2333
        # was not applied is never claimed.
2334
        load_generator: oakbench_loadgen.LoadGenerator | None = None
2335
        if args.load_tier != "none":
2336
            load_generator = oakbench_loadgen.LoadGenerator(
2337
                args.load_tier, args.workdir / "loadgen-temp"
2338
            )
2339
            load_start = load_generator.start()
2340
            if not load_start["started"]:
2341
                reason = oakbench_loadgen.load_tier_unavailable_reason(str(load_start["reason"]))
2342
                rows = load_tier_skip_rows(subjects, scenarios, metadata, reason, args.load_tier)
2343
                load_generator = None
2344
        try:
2345
            if not rows:
2346
                for scenario in scenarios:
2347
                    fixture = fixtures[scenario.name]
2348
                    for run_index in range(scenario.runs):
2349
                        run_subjects = list(subjects)
2350
                        if args.randomize_subject_order:
2351
                            random.Random(f"{timestamp}:{scenario.name}:{run_index}").shuffle(run_subjects)
2352
                        run_metadata = {
2353
                            **metadata,
2354
                            "subject_order": [subject.name for subject in run_subjects],
2355
                            **fixture_fingerprints[scenario.name],
2356
                        }
2357
                        for subject in run_subjects:
2358
                            print(f"[run] {scenario.name} run={run_index} subject={subject.name}", flush=True)
2359
                            env_before = oakbench_envwatch.sample_environment()
2360
                            subject_rows = run_subject_scenario(
2361
                                subject,
2362
                                scenario,
2363
                                fixture,
2364
                                run_index,
2365
                                run_root,
2366
                                run_metadata,
2367
                                args.skip_diff,
2368
                                args.admitted_output_chars,
2369
                                args.track,
2370
                                args.skip_determinism_probe,
2371
                                args.skip_remote,
2372
                                args.dirty_spectrum,
2373
                            )
2374
                            env_after = oakbench_envwatch.sample_environment()
2375
                            boundary = oakbench_loadgen.environment_boundary_fields(
2376
                                args.load_tier, env_before, env_after
2377
                            )
2378
                            for row in subject_rows:
2379
                                row.update(boundary)
2380
                            rows.extend(subject_rows)
2381
        finally:
2382
            if load_generator is not None:
2383
                load_stop = load_generator.stop()
2384
                # bogo-ops parsed ONLY as did-the-load-run verification,
2385
                # never reported as a benchmark number.
2386
                for row in rows:
2387
                    row["load_verified"] = load_stop.get("verified")
2388
2389
    store = ResultsStore(args.results, lane="core")
2390
    raw_path, summary_path = store.write(timestamp, rows, summary_text(rows, subjects))
2391
2392
    if not args.keep_workdirs:
2393
        shutil.rmtree(run_root, ignore_errors=True)
2394
2395
    print(f"[result] {raw_path}")
2396
    print(f"[summary] {summary_path}")
2397
    return 1 if any(row["returncode"] != 0 and not row.get("skipped") for row in rows) else 0
2398
2399
2400
if __name__ == "__main__":
2401
    raise SystemExit(main())