Viewing files at commit 2735d7f5558eCommit detailsView latest

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