Viewing files at commit f0c34b306c79Commit detailsView latest

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