Viewing files at commit 13de5e5480b3Commit detailsView latest

scripts/bench.py 2056 lines · 79.8 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
507
508
def run_information_probe(
509
    subject: Subject,
510
    scenario: Scenario,
511
    base_op: str,
512
    cwd: Path,
513
    command: list[str],
514
    run_index: int,
515
    metadata: dict[str, Any],
516
    changed_paths: list[str],
517
) -> dict[str, Any]:
518
    """Score one status/diff output against the ground-truth changed set.
519
520
    Bytes saved are only a win if the file list is still recoverable from the
521
    output; this row makes "compact but lossy" visible as information_recall
522
    < 1.0 instead of looking like a token improvement. diff rows additionally
523
    record unified-diff structure for piped-tooling compatibility.
524
    """
525
    capture = run_timed(command, cwd, base_env(), INFO_PROBE_MAX_CHARS)
526
    output_text = capture.stdout_text + "\n" + capture.stderr_text
527
    row = {
528
        **metadata,
529
        **subject_provenance(subject, metadata),
530
        "subject": subject.name,
531
        "subject_kind": subject.kind,
532
        "subject_label": subject.label,
533
        "git_mode": subject.git_mode,
534
        "scenario": scenario.name,
535
        "run": run_index,
536
        "operation": f"{base_op}.inforecall",
537
        "elapsed_ms": round(capture.elapsed_ms, 3),
538
        "returncode": capture.returncode,
539
        "command": command,
540
        "probe_output_bytes": capture.stdout_bytes + capture.stderr_bytes,
541
        **oakbench_semantics.information_recall_fields(
542
            output_text,
543
            changed_paths,
544
            capture.stdout_bytes + capture.stderr_bytes,
545
            capture.stdout_truncated or capture.stderr_truncated,
546
        ),
547
        "tool_call_count": 0,
548
        "vcs_tool_call_count": 0,
549
        "terminal_tool_call_count": 0,
550
        "notes": "diagnostic probe row; excluded from token/tool-call deltas (tool_call_count=0)",
551
    }
552
    if base_op.startswith("diff."):
553
        row.update(oakbench_semantics.unified_diff_compat_fields(capture.stdout_text))
554
    if capture.returncode != 0:
555
        row["stderr"] = capture.stderr_text[-4000:]
556
    return row
557
558
559
REMOTE_OPS = ("remote.push.first", "remote.clone.cold", "remote.pull.uptodate")
560
# Oak remote ops are network-transport (a real Oak server) while git's are
561
# local-file; different transports are different measurements, so the names
562
# differ (ADR-0005) and no report ever computes a git-vs-oak delta between
563
# them. The oak ops answer the release-blocking trend: did this changeset
564
# make oak's real push/clone/sync slower?
565
OAK_NET_REMOTE_OPS = ("remote.net.push.first", "remote.net.clone.cold", "remote.net.fetch.uptodate")
566
OAK_REMOTE_ENV = oakbench_remotes.OAK_REMOTE_ENV
567
568
569
def apply_state_change_attestation(
570
    row: dict[str, Any],
571
    repo: Path,
572
    before_fingerprint: str | None,
573
    op: str,
574
    *,
575
    expected_hashes: dict[str, str] | None = None,
576
    expected_payloads: dict[str, str | bytes] | None = None,
577
) -> dict[str, Any]:
578
    process_returncode = row_returncode(row)
579
    fields = oakbench_integrity.state_change_fields(
580
        repo,
581
        before_fingerprint,
582
        operation=op,
583
        process_returncode=process_returncode,
584
    )
585
    row["process_returncode"] = process_returncode
586
    row.update(fields)
587
    failure_reason = fields["integrity_failure_reason"] if not fields["integrity_check_passed"] else None
588
    if expected_hashes or expected_payloads:
589
        content_fields = oakbench_integrity.content_integrity_fields(
590
            repo,
591
            operation=op,
592
            process_returncode=process_returncode,
593
            expected_hashes=expected_hashes,
594
            expected_payloads=expected_payloads,
595
        )
596
        row.update(content_fields)
597
        if not content_fields["content_integrity_check_passed"] and failure_reason is None:
598
            failure_reason = content_fields["content_integrity_failure_reason"]
599
    if process_returncode == 0 and failure_reason is not None:
600
        row["returncode"] = 1
601
        row["stderr"] = str(failure_reason or "integrity_check_failed")
602
    return row
603
604
605
def remote_skip_row(
606
    subject: Subject,
607
    scenario: Scenario,
608
    op: str,
609
    run_index: int,
610
    metadata: dict[str, Any],
611
    reason: str,
612
) -> dict[str, Any]:
613
    return {
614
        **metadata,
615
        **subject_provenance(subject, metadata),
616
        "subject": subject.name,
617
        "subject_kind": subject.kind,
618
        "subject_label": subject.label,
619
        "git_mode": subject.git_mode,
620
        "scenario": scenario.name,
621
        "run": run_index,
622
        "operation": op,
623
        "elapsed_ms": 0.0,
624
        "returncode": 77,
625
        "command": [],
626
        "skipped": True,
627
        "skip_reason": reason,
628
        "tool_call_count": 0,
629
        "vcs_tool_call_count": 0,
630
        "terminal_tool_call_count": 0,
631
    }
632
633
634
def git_huge_binary_diff_limit_reason(subject: Subject, scenario: Scenario, op: str) -> str | None:
635
    if subject.kind != "git":
636
        return None
637
    if subject.git_mode == "lfs":
638
        return None
639
    if op not in GIT_HUGE_BINARY_DIFF_OPS:
640
        return None
641
    if not scenario.binary or scenario.file_size < GIT_HUGE_BINARY_DIFF_LIMIT_BYTES:
642
        return None
643
    return (
644
        "git_huge_binary_diff_fixture_limit: stock/tuned git diff rows are intentionally "
645
        "skipped for generated >=1GiB binary fixtures because git may fail while generating "
646
        "binary diffstat; use git_lfs or smaller binary fixtures for this capability"
647
    )
648
649
650
def git_huge_binary_diff_skip_row(
651
    subject: Subject,
652
    scenario: Scenario,
653
    op: str,
654
    command: list[str],
655
    run_index: int,
656
    metadata: dict[str, Any],
657
) -> dict[str, Any]:
658
    reason = git_huge_binary_diff_limit_reason(subject, scenario, op)
659
    if reason is None:
660
        raise ValueError(f"{op} is not a huge-binary git diff skip")
661
    row = remote_skip_row(subject, scenario, op, run_index, metadata, reason)
662
    row.update(
663
        {
664
            "command": command,
665
            "failure_classification": "git_huge_binary_diff_fixture_limit",
666
            "notes": (
667
                "preflighted fixture-limit skip; no subprocess was run for this row, "
668
                "so stderr is intentionally empty"
669
            ),
670
        }
671
    )
672
    return row
673
674
675
_OAK_JSON_SUPPORT_CACHE: dict[tuple[str, tuple[str, ...], tuple[str, ...]], str | None] = {}
676
_OAK_VERSION_CACHE: dict[str, tuple[int, int, int] | None] = {}
677
678
679
def parse_semver_triplet(text: str) -> tuple[int, int, int] | None:
680
    match = re.search(r"\b(\d+)\.(\d+)\.(\d+)\b", text)
681
    if match is None:
682
        return None
683
    return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
684
685
686
def oak_cli_semver(vcs: str) -> tuple[int, int, int] | None:
687
    if vcs in _OAK_VERSION_CACHE:
688
        return _OAK_VERSION_CACHE[vcs]
689
    try:
690
        probe = subprocess.run(
691
            [vcs, "--version"],
692
            capture_output=True,
693
            text=True,
694
            env=base_env(),
695
            timeout=15,
696
            check=False,
697
        )
698
    except (FileNotFoundError, subprocess.TimeoutExpired):
699
        _OAK_VERSION_CACHE[vcs] = None
700
        return None
701
    version = parse_semver_triplet((probe.stdout or "") + "\n" + (probe.stderr or ""))
702
    _OAK_VERSION_CACHE[vcs] = version
703
    return version
704
705
706
def oak_json_probe_version_skip_reason(vcs: str, spec: dict[str, Any]) -> str | None:
707
    minimum = spec.get("minimum_oak_version")
708
    if not minimum:
709
        return None
710
    required = parse_semver_triplet(str(minimum))
711
    actual = oak_cli_semver(vcs)
712
    if required is None:
713
        return f"capability_gap:oak_json_probe_bad_minimum_version:{minimum}"
714
    if actual is None:
715
        return f"capability_gap:oak_json_probe_version_unknown:requires_oak>={minimum}"
716
    if actual < required:
717
        actual_text = ".".join(str(part) for part in actual)
718
        return f"capability_gap:oak_json_probe_requires_oak>={minimum}:found_{actual_text}"
719
    return None
720
721
722
def oak_json_probe_skip_reason(vcs: str, help_args: list[str], command_args: list[str]) -> str | None:
723
    """Return a capability skip reason, or None when the JSON command is present."""
724
    key = (vcs, tuple(help_args), tuple(command_args))
725
    if key in _OAK_JSON_SUPPORT_CACHE:
726
        return _OAK_JSON_SUPPORT_CACHE[key]
727
    if not help_args:
728
        reason = "capability_gap:oak_json_probe_help_missing"
729
        _OAK_JSON_SUPPORT_CACHE[key] = reason
730
        return reason
731
    try:
732
        probe = subprocess.run(
733
            [vcs, *help_args],
734
            capture_output=True,
735
            text=True,
736
            env=base_env(),
737
            timeout=15,
738
            check=False,
739
        )
740
    except FileNotFoundError:
741
        reason = f"missing_binary:{vcs}"
742
        _OAK_JSON_SUPPORT_CACHE[key] = reason
743
        return reason
744
    except subprocess.TimeoutExpired:
745
        reason = "capability_gap:oak_json_probe_help_timeout:" + " ".join(help_args)
746
        _OAK_JSON_SUPPORT_CACHE[key] = reason
747
        return reason
748
    help_text = (probe.stdout or "") + "\n" + (probe.stderr or "")
749
    if probe.returncode != 0:
750
        reason = "capability_gap:oak_json_probe_help_failed:" + " ".join(help_args)
751
    elif "--json" in command_args and "--json" not in help_text:
752
        reason = "capability_gap:oak_json_flag_missing:" + " ".join(command_args)
753
    else:
754
        reason = None
755
    _OAK_JSON_SUPPORT_CACHE[key] = reason
756
    return reason
757
758
759
def json_field_oracle_fields(stdout_text: str, required_fields: list[str]) -> dict[str, Any]:
760
    """JSON parse and top-level required-field completeness for CLI capability rows."""
761
    base: dict[str, Any] = {
762
        "json_required_fields": required_fields,
763
        "json_required_field_count": len(required_fields),
764
    }
765
    try:
766
        payload = json.loads(stdout_text)
767
    except json.JSONDecodeError as exc:
768
        return {
769
            **base,
770
            "json_parse_success": False,
771
            "json_payload_type": None,
772
            "json_present_required_field_count": 0,
773
            "json_missing_required_fields": required_fields,
774
            "json_field_completeness": 0.0 if required_fields else None,
775
            "json_oracle_passed": False,
776
            "json_error": f"{exc.msg} at line {exc.lineno} column {exc.colno}",
777
        }
778
    payload_type = type(payload).__name__
779
    if not isinstance(payload, dict):
780
        return {
781
            **base,
782
            "json_parse_success": True,
783
            "json_payload_type": payload_type,
784
            "json_present_required_field_count": 0,
785
            "json_missing_required_fields": required_fields,
786
            "json_field_completeness": 0.0 if required_fields else None,
787
            "json_oracle_passed": False,
788
            "json_error": "expected top-level JSON object",
789
        }
790
    missing = [field for field in required_fields if field not in payload]
791
    present = len(required_fields) - len(missing)
792
    return {
793
        **base,
794
        "json_parse_success": True,
795
        "json_payload_type": payload_type,
796
        "json_present_required_field_count": present,
797
        "json_missing_required_fields": missing,
798
        "json_field_completeness": round(present / len(required_fields), 4) if required_fields else None,
799
        "json_oracle_passed": not missing,
800
    }
801
802
803
def json_field_oracle_unmeasured_fields(required_fields: list[str], reason: str) -> dict[str, Any]:
804
    """Null JSON validation fields when the runner did not capture a complete payload."""
805
    return {
806
        "json_required_fields": required_fields,
807
        "json_required_field_count": len(required_fields),
808
        "json_parse_success": None,
809
        "json_payload_type": None,
810
        "json_present_required_field_count": None,
811
        "json_missing_required_fields": None,
812
        "json_field_completeness": None,
813
        "json_oracle_passed": None,
814
        "json_validation_unmeasured_reason": reason,
815
    }
816
817
818
def run_agent_native_json_probe(
819
    subject: Subject,
820
    scenario: Scenario,
821
    op: str,
822
    cwd: Path,
823
    spec: dict[str, Any],
824
    run_index: int,
825
    metadata: dict[str, Any],
826
    admitted_output_chars: int,
827
) -> dict[str, Any]:
828
    vcs = str(subject.bin)
829
    command_args = list(spec["args"])
830
    help_args = list(spec["help_args"])
831
    command = [vcs, *command_args]
832
    skip_reason = oak_json_probe_version_skip_reason(vcs, spec)
833
    if skip_reason is None:
834
        skip_reason = oak_json_probe_skip_reason(vcs, help_args, command_args)
835
    if skip_reason is not None:
836
        row = remote_skip_row(subject, scenario, op, run_index, metadata, skip_reason)
837
        row["intended_command"] = command
838
        row["capability_probe_command"] = [vcs, *help_args]
839
        if spec.get("minimum_oak_version") is not None:
840
            row["minimum_oak_version"] = str(spec["minimum_oak_version"])
841
        return row
842
843
    capture = run_timed(
844
        command,
845
        cwd,
846
        base_env(),
847
        admitted_output_chars,
848
        full_output_bytes=JSON_PROBE_MAX_BYTES,
849
    )
850
    required_fields = list(spec["required_fields"])
851
    if capture.stdout_full_text is not None and capture.stdout_full_text_truncated is False:
852
        json_validation_source = "full_stdout_capture"
853
        json_fields = json_field_oracle_fields(capture.stdout_full_text, required_fields)
854
    elif capture.stdout_full_text_truncated is True or (
855
        capture.stdout_full_text is None and capture.stdout_truncated
856
    ):
857
        json_validation_source = None
858
        json_fields = json_field_oracle_unmeasured_fields(
859
            required_fields,
860
            "stdout_exceeded_json_validation_capture_limit",
861
        )
862
    else:
863
        json_validation_source = "admitted_stdout_capture"
864
        json_fields = json_field_oracle_fields(capture.stdout_text, required_fields)
865
    result = {
866
        **metadata,
867
        **subject_provenance(subject, metadata),
868
        "subject": subject.name,
869
        "subject_kind": subject.kind,
870
        "subject_label": subject.label,
871
        "git_mode": subject.git_mode,
872
        "scenario": scenario.name,
873
        "run": run_index,
874
        "operation": op,
875
        "elapsed_ms": round(capture.elapsed_ms, 3),
876
        "returncode": capture.returncode,
877
        "command": command,
878
        "peak_rss_bytes": capture.peak_rss_bytes,
879
        "peak_rss_source": PEAK_RSS_SOURCE if capture.peak_rss_bytes is not None else None,
880
        "measurement_source": "direct_cli_timed_subprocess_json_capability_probe",
881
        "probe_output_bytes": capture.stdout_bytes + capture.stderr_bytes,
882
        **interaction_metrics(
883
            command,
884
            capture.stdout_text,
885
            capture.stderr_text,
886
            capture.stdout_bytes,
887
            capture.stderr_bytes,
888
            capture.stdout_truncated,
889
            capture.stderr_truncated,
890
            tool_call_count=1,
891
        ),
892
        "json_validation_source": json_validation_source,
893
        **json_fields,
894
    }
895
    if capture.returncode != 0:
896
        result["stderr"] = capture.stderr_text[-4000:]
897
    elif result["json_oracle_passed"] is False:
898
        result["returncode"] = 1
899
        result["stderr"] = str(result.get("json_error") or "json_required_fields_missing")
900
    return result
901
902
903
def run_agent_native_json_probes(
904
    subject: Subject,
905
    scenario: Scenario,
906
    cwd: Path,
907
    run_index: int,
908
    metadata: dict[str, Any],
909
    admitted_output_chars: int,
910
) -> list[dict[str, Any]]:
911
    probes = semantics().agent_native_json_probes(subject.kind, BENCH_TASK_BRANCH)
912
    return [
913
        run_agent_native_json_probe(
914
            subject, scenario, op, cwd, spec, run_index, metadata, admitted_output_chars
915
        )
916
        for op, spec in probes.items()
917
    ]
918
919
920
def load_tier_skip_row(
921
    subject: Subject,
922
    scenario: Scenario,
923
    run_index: int,
924
    metadata: dict[str, Any],
925
    reason: str,
926
    load_tier: str,
927
) -> dict[str, Any]:
928
    """Explicit skip row for a load tier the host cannot apply.
929
930
    A row may never claim load that was not applied: environment_suspect is
931
    None (suspicion machinery did not run, ADR-0002) and load_tier records the
932
    REQUESTED tier so the gap is visible, not hidden.
933
    """
934
    row = remote_skip_row(subject, scenario, "scenario.load_tier_skipped", run_index, metadata, reason)
935
    row["load_tier"] = load_tier
936
    row["environment_suspect"] = None
937
    return row
938
939
940
def load_tier_skip_rows(
941
    subjects: list[Subject],
942
    scenarios: list[Scenario],
943
    metadata: dict[str, Any],
944
    reason: str,
945
    load_tier: str,
946
) -> list[dict[str, Any]]:
947
    """One skip row per scenario x run x subject for an unappliable load tier."""
948
    return [
949
        load_tier_skip_row(subject, scenario, run_index, metadata, reason, load_tier)
950
        for scenario in scenarios
951
        for run_index in range(scenario.runs)
952
        for subject in subjects
953
    ]
954
955
956
def oak_network_branch_name(
957
    bench_id: str,
958
    scenario_name: str,
959
    subject_name: str,
960
    run_index: int,
961
    *,
962
    pid: int | None = None,
963
    token: str | None = None,
964
) -> str:
965
    """Disposable remote branch name: readable prefix plus per-run uniquifier."""
966
    return oakbench_remotes.disposable_branch(
967
        bench_id, scenario_name, subject_name, run_index, pid=pid, token=token
968
    )
969
970
971
def run_oak_network_remote_phase(
972
    subject: Subject,
973
    scenario: Scenario,
974
    repo: Path,
975
    run_index: int,
976
    metadata: dict[str, Any],
977
    admitted_output_chars: int,
978
    remote_repo: str,
979
) -> list[dict[str, Any]]:
980
    """Push to, clone from, and sync with a real Oak server.
981
982
    Network transport: latency includes the server round trip, so these rows
983
    are oak-vs-previous-oak trend evidence, never a git-vs-oak comparison
984
    (git's remote phase is local-file by construction).
985
986
    Same-named branches with unrelated histories conflict on the server, so
987
    each run pushes a unique bench branch (untimed setup); the disposable
988
    remote accumulates branches by design.
989
    """
990
    rows: list[dict[str, Any]] = []
991
    vcs = str(subject.bin)
992
    bench_id = str(metadata.get("bench_id", "bench"))
993
    branch_name = oak_network_branch_name(bench_id, scenario.name, subject.name, run_index)
994
    clone_dir = repo.parent / f"netclone-{subject.name}-{run_index}"
995
    # Untimed: a unique branch is a precondition (same-named unrelated
996
    # histories are rejected by the server), not the measurement.
997
    subprocess.run(
998
        [vcs, "switch", "-c", branch_name],
999
        cwd=repo,
1000
        env=base_env(),
1001
        stdout=subprocess.DEVNULL,
1002
        stderr=subprocess.DEVNULL,
1003
        check=False,
1004
    )
1005
    push_row = run_command(
1006
        subject,
1007
        scenario,
1008
        "remote.net.push.first",
1009
        repo,
1010
        [vcs, "push", "--repo", remote_repo],
1011
        run_index,
1012
        metadata,
1013
        admitted_output_chars,
1014
    )
1015
    rows.append({**push_row, "remote_repo": remote_repo, "remote_transport": "network", "cache_state": "cold_remote_branch"})
1016
    clone_row = run_command(
1017
        subject,
1018
        scenario,
1019
        "remote.net.clone.cold",
1020
        repo.parent,
1021
        [vcs, "clone", remote_repo, str(clone_dir)],
1022
        run_index,
1023
        metadata,
1024
        admitted_output_chars,
1025
    )
1026
    rows.append(
1027
        {
1028
            **clone_row,
1029
            "remote_repo": remote_repo,
1030
            "remote_transport": "network",
1031
            "cache_state": "cold_objects_network",
1032
            "notes": "clones the disposable repo's default content, not this scenario's fixture; comparable only to other remote.net.clone.cold rows",
1033
        }
1034
    )
1035
    if clone_row["returncode"] == 0:
1036
        fetch_row = run_command(
1037
            subject,
1038
            scenario,
1039
            "remote.net.fetch.uptodate",
1040
            clone_dir,
1041
            [vcs, "fetch"],
1042
            run_index,
1043
            metadata,
1044
            admitted_output_chars,
1045
        )
1046
        rows.append({**fetch_row, "remote_repo": remote_repo, "remote_transport": "network", "cache_state": "warm"})
1047
    return rows
1048
1049
1050
def run_remote_phase(
1051
    subject: Subject,
1052
    scenario: Scenario,
1053
    repo: Path,
1054
    run_index: int,
1055
    metadata: dict[str, Any],
1056
    admitted_output_chars: int,
1057
) -> list[dict[str, Any]]:
1058
    """Push to, clone from, and sync with a local remote.
1059
1060
    Agents pay clone/pull/push constantly: every fresh workspace is a cold
1061
    clone, every task start is a pull, every task end is a push. A local
1062
    filesystem remote isolates the VCS object-transfer and checkout cost from
1063
    network jitter; "cold" means no local object store, while the OS page
1064
    cache stays warm (recorded in cache_state so rows never overclaim).
1065
1066
    Oak's remote model needs a configured server remote; until one is wired,
1067
    Oak rows are explicit skips (returncode 77), never silently absent.
1068
    """
1069
    if subject.kind != "git":
1070
        resolution = oakbench_remotes.resolve_oak_remote()
1071
        remote_repo = resolution.repo or ""
1072
        if not remote_repo:
1073
            reason = (
1074
                f"oak_remote_not_configured: set {OAK_REMOTE_ENV} to a DISPOSABLE ORG/REPO "
1075
                "(e.g. oak/oak-benchmarks-tmp) to measure real-server push/clone/fetch"
1076
            )
1077
            return [
1078
                remote_skip_row(subject, scenario, op, run_index, metadata, reason)
1079
                for op in OAK_NET_REMOTE_OPS
1080
            ]
1081
        return run_oak_network_remote_phase(
1082
            subject, scenario, repo, run_index, metadata, admitted_output_chars, remote_repo
1083
        )
1084
1085
    rows: list[dict[str, Any]] = []
1086
    vcs = str(subject.bin)
1087
    # Untimed setup: the remote existing is a precondition, not the measurement.
1088
    bare = oakbench_remotes.make_git_bare_remote(
1089
        vcs, repo.parent, name=f"remote-{subject.name}-{run_index}.git"
1090
    )
1091
    remote_dir = Path(bare.repo)
1092
    clone_dir = repo.parent / f"clone-{subject.name}-{run_index}"
1093
    oakbench_remotes.attach_git_origin(vcs, repo, bare)
1094
    push_row = run_command(
1095
        subject,
1096
        scenario,
1097
        "remote.push.first",
1098
        repo,
1099
        [vcs, "push", "origin", "HEAD"],
1100
        run_index,
1101
        metadata,
1102
        admitted_output_chars,
1103
    )
1104
    rows.append({**push_row, "cache_state": "cold_remote_warm_page_cache", "remote_transport": "local_file"})
1105
    # Untimed: point the bare remote's HEAD at the pushed branch so the clone
1106
    # checks out a working tree (precondition, not measurement).
1107
    branch_name = oakbench_remotes.current_git_branch(vcs, repo)
1108
    oakbench_remotes.point_git_bare_head(vcs, bare, branch_name)
1109
    clone_row = run_command(
1110
        subject,
1111
        scenario,
1112
        "remote.clone.cold",
1113
        repo.parent,
1114
        [vcs, "clone", str(remote_dir), str(clone_dir)],
1115
        run_index,
1116
        metadata,
1117
        admitted_output_chars,
1118
    )
1119
    rows.append({**clone_row, "cache_state": "cold_objects_warm_page_cache", "remote_transport": "local_file"})
1120
    if clone_row["returncode"] == 0:
1121
        pull_row = run_command(
1122
            subject,
1123
            scenario,
1124
            "remote.pull.uptodate",
1125
            clone_dir,
1126
            [vcs, "pull"],
1127
            run_index,
1128
            metadata,
1129
            admitted_output_chars,
1130
        )
1131
        rows.append({**pull_row, "cache_state": "warm", "remote_transport": "local_file"})
1132
    return rows
1133
1134
1135
def run_command(
1136
    subject: Subject,
1137
    scenario: Scenario,
1138
    op: str,
1139
    cwd: Path,
1140
    command: list[str],
1141
    run_index: int,
1142
    metadata: dict[str, Any],
1143
    admitted_output_chars: int,
1144
) -> dict[str, Any]:
1145
    capture = run_timed(command, cwd, base_env(), admitted_output_chars)
1146
    result = {
1147
        **metadata,
1148
        **subject_provenance(subject, metadata),
1149
        "subject": subject.name,
1150
        "subject_kind": subject.kind,
1151
        "subject_label": subject.label,
1152
        "git_mode": subject.git_mode,
1153
        "scenario": scenario.name,
1154
        "run": run_index,
1155
        "operation": op,
1156
        "elapsed_ms": round(capture.elapsed_ms, 3),
1157
        "returncode": capture.returncode,
1158
        "command": command,
1159
        "peak_rss_bytes": capture.peak_rss_bytes,
1160
        "peak_rss_source": PEAK_RSS_SOURCE if capture.peak_rss_bytes is not None else None,
1161
        "measurement_source": "direct_cli_timed_subprocess",
1162
        **interaction_metrics(
1163
            command,
1164
            capture.stdout_text,
1165
            capture.stderr_text,
1166
            capture.stdout_bytes,
1167
            capture.stderr_bytes,
1168
            capture.stdout_truncated,
1169
            capture.stderr_truncated,
1170
            tool_call_count=1,
1171
        ),
1172
    }
1173
    if capture.returncode != 0:
1174
        result["stderr"] = capture.stderr_text[-4000:]
1175
    return result
1176
1177
1178
def write_pattern_file(path: Path, size: int, seed: str, binary: bool) -> None:
1179
    oakbench_fixtures.write_pattern_file(path, size, seed, binary=binary, prefix="oakbench")
1180
1181
1182
def scenario_shape_id(scenario: Scenario) -> str:
1183
    """Fixture identity is the byte shape, not the scenario label.
1184
1185
    Keying the cache on shape (and recording it in the ready marker) makes a
1186
    name collision across profiles a cache miss instead of silent corruption:
1187
    a smoke run can never poison a later standard run sharing the workdir.
1188
    """
1189
    kind = "bin" if scenario.binary else "txt"
1190
    return f"{scenario.file_count}x{scenario.file_size}-{kind}"
1191
1192
1193
def fixture_path(root: Path, scenario: Scenario) -> Path:
1194
    return root / "fixtures" / f"{scenario.name}-{scenario_shape_id(scenario)}"
1195
1196
1197
def ensure_fixture(root: Path, scenario: Scenario) -> Path:
1198
    dest = fixture_path(root, scenario)
1199
    marker = dest / ".fixture-ready"
1200
    expected_marker = f"{scenario_shape_id(scenario)}\n"
1201
    if marker.exists() and marker.read_text() == expected_marker:
1202
        return dest
1203
    if dest.exists():
1204
        if marker.exists() and marker.read_text() == expected_marker:
1205
            return dest
1206
        shutil.rmtree(dest)
1207
1208
    temp = dest.parent / f"{dest.name}.building-{os.getpid()}"
1209
    if temp.exists():
1210
        shutil.rmtree(temp)
1211
    temp.mkdir(parents=True)
1212
    try:
1213
        ext = "bin" if scenario.binary else "txt"
1214
        width = max(4, len(str(scenario.file_count)))
1215
        for index in range(scenario.file_count):
1216
            subdir = temp / f"group-{index // 1000:03d}"
1217
            path = subdir / f"file-{index:0{width}d}.{ext}"
1218
            write_pattern_file(path, scenario.file_size, f"{scenario.name}:{index}", scenario.binary)
1219
        (temp / ".fixture-ready").write_text(expected_marker)
1220
        try:
1221
            os.rename(temp, dest)
1222
        except OSError:
1223
            if marker.exists() and marker.read_text() == expected_marker:
1224
                shutil.rmtree(temp)
1225
                return dest
1226
            got = marker.read_text().strip() if marker.exists() else "missing"
1227
            raise RuntimeError(
1228
                f"fixture {dest} exists with wrong shape marker "
1229
                f"(expected {expected_marker.strip()!r}, got {got!r})"
1230
            ) from None
1231
        return dest
1232
    except Exception:
1233
        if temp.exists():
1234
            shutil.rmtree(temp)
1235
        raise
1236
1237
1238
def mutate_files(repo: Path, scenario: Scenario, subject: Subject, run_index: int) -> list[str]:
1239
    """Mutate the scenario's dirty set; returns repo-relative changed paths.
1240
1241
    The returned list is the ground truth the information-recall probe scores
1242
    status/diff output against.
1243
    """
1244
    ext = "bin" if scenario.binary else "txt"
1245
    paths = sorted(repo.glob(f"group-*/*.{ext}"))[: scenario.dirty_count]
1246
    if not paths and scenario.file_count == 1:
1247
        paths = sorted(repo.glob(f"**/*.{ext}"))[:1]
1248
    for index, path in enumerate(paths):
1249
        marker = f"\nmutated {scenario.name} {subject.name} {run_index} {index}\n".encode()
1250
        if scenario.binary:
1251
            with path.open("r+b") as fh:
1252
                size = path.stat().st_size
1253
                fh.seek(max(0, size // 2 - len(marker) // 2))
1254
                fh.write(marker[: min(len(marker), max(1, size))])
1255
        else:
1256
            with path.open("ab") as fh:
1257
                fh.write(marker)
1258
    return [path.relative_to(repo).as_posix() for path in paths]
1259
1260
1261
def copy_fixture(src: Path, dest: Path) -> None:
1262
    oakbench_fixtures.copy_fixture(src, dest, ignore_marker=True)
1263
1264
1265
# Dirty-tree spectrum (opt-in via --dirty-spectrum). pNN means NN% of the
1266
# fixture's tracked files are dirtied (ceil) before the timed command; the
1267
# dirtying and the restore are untimed setup/teardown. Scale is encoded in the
1268
# operation name per ADR-0005: status.dirty.p01 is a different measurement
1269
# than status.dirty and never shares its trend line. snapshot.dirty.pNN is
1270
# deliberately NOT implemented: a snapshot commits, so restoring cleanliness
1271
# would require history surgery or a re-copied fixture per step — not cheap,
1272
# and it would change the repository state every later operation sees.
1273
DIRTY_SPECTRUM_FRACTIONS: tuple[tuple[str, float], ...] = (
1274
    ("p01", 0.01),
1275
    ("p10", 0.10),
1276
    ("p50", 0.50),
1277
)
1278
DIRTY_SPECTRUM_BASE_OPS: tuple[str, ...] = ("status.dirty", "diff.dirty")
1279
1280
1281
def dirty_spectrum_operations(enabled: bool, skip_diff: bool = False) -> list[str]:
1282
    """Operation names the spectrum adds. Empty when the flag is off."""
1283
    if not enabled:
1284
        return []
1285
    names: list[str] = []
1286
    for label, _fraction in DIRTY_SPECTRUM_FRACTIONS:
1287
        names.append(f"status.dirty.{label}")
1288
        if not skip_diff:
1289
            names.append(f"diff.dirty.{label}")
1290
    return names
1291
1292
1293
def dirty_spectrum_marker(seed: str) -> bytes:
1294
    """Fixed edit content for one spectrum step.
1295
1296
    Deterministic (a pure function of the seed) so restore_dirty_fraction can
1297
    verify and strip exactly the bytes dirty_fraction appended.
1298
    """
1299
    return f"\ndirty-spectrum {seed}\n".encode("ascii")
1300
1301
1302
def tracked_fixture_files(repo_path: Path) -> list[Path]:
1303
    """Sorted tracked fixture files: everything outside VCS metadata dirs.
1304
1305
    After snapshot.initial every fixture file is tracked, so a filesystem walk
1306
    is the subject-neutral tracked set (no VCS command, identical for git and
1307
    oak subjects).
1308
    """
1309
    repo = Path(repo_path)
1310
    return sorted(
1311
        path
1312
        for path in repo.rglob("*")
1313
        if path.is_file() and not {".git", ".oak"}.intersection(path.relative_to(repo).parts)
1314
    )
1315
1316
1317
def dirty_fraction(repo_path: Path, fraction: float, seed: str) -> list[str]:
1318
    """Deterministically dirty ceil(fraction * N) tracked files.
1319
1320
    Selection is a seeded sample over the sorted tracked-file list; the edit
1321
    is one fixed appended line (same bytes for every selected file). Returns
1322
    the repo-relative POSIX paths of the dirtied files. Untimed by
1323
    construction: callers dirty first, then run_timed the measured command.
1324
    """
1325
    repo = Path(repo_path)
1326
    files = tracked_fixture_files(repo)
1327
    if not files or fraction <= 0:
1328
        return []
1329
    count = min(len(files), math.ceil(fraction * len(files)))
1330
    chosen = sorted(random.Random(seed).sample(files, count))
1331
    marker = dirty_spectrum_marker(seed)
1332
    for path in chosen:
1333
        with path.open("ab") as fh:
1334
            fh.write(marker)
1335
    return [path.relative_to(repo).as_posix() for path in chosen]
1336
1337
1338
def restore_dirty_fraction(repo_path: Path, changed_paths: list[str], seed: str) -> None:
1339
    """Undo dirty_fraction by stripping the exact appended marker bytes.
1340
1341
    Content returns byte-identical to the committed state, so the tree is
1342
    clean again before the next spectrum step (and before the pre-existing
1343
    operation sequence continues). Verification before truncation means a
1344
    mismatch fails loudly instead of silently corrupting a fixture copy.
1345
    """
1346
    repo = Path(repo_path)
1347
    marker = dirty_spectrum_marker(seed)
1348
    for rel in changed_paths:
1349
        path = repo / rel
1350
        size = path.stat().st_size
1351
        with path.open("r+b") as fh:
1352
            if size < len(marker):
1353
                raise RuntimeError(f"dirty-spectrum restore: {rel} is smaller than the marker")
1354
            fh.seek(size - len(marker))
1355
            if fh.read(len(marker)) != marker:
1356
                raise RuntimeError(f"dirty-spectrum restore: {rel} does not end with the expected marker")
1357
            fh.truncate(size - len(marker))
1358
1359
1360
def run_dirty_spectrum(
1361
    subject: Subject,
1362
    scenario: Scenario,
1363
    repo: Path,
1364
    ops: dict[str, list[str]],
1365
    run_index: int,
1366
    metadata: dict[str, Any],
1367
    skip_diff: bool,
1368
    admitted_output_chars: int,
1369
) -> list[dict[str, Any]]:
1370
    """Measure status (and diff) across deterministic dirty fractions.
1371
1372
    Runs on a clean committed tree. Per fraction: dirty (untimed), time the
1373
    same status/diff commands the existing status.dirty/diff.dirty rows use,
1374
    then restore (untimed) so each step starts from the identical clean state.
1375
    The seed is per scenario+fraction, never per subject or run, so every
1376
    subject measures the same dirtied file set.
1377
    """
1378
    rows: list[dict[str, Any]] = []
1379
    base_ops = [op for op in DIRTY_SPECTRUM_BASE_OPS if not (skip_diff and op == "diff.dirty")]
1380
    for label, fraction in DIRTY_SPECTRUM_FRACTIONS:
1381
        seed = f"dirty-spectrum:{scenario.name}:{label}"
1382
        changed = dirty_fraction(repo, fraction, seed)
1383
        try:
1384
            for base_op in base_ops:
1385
                row = run_command(
1386
                    subject,
1387
                    scenario,
1388
                    f"{base_op}.{label}",
1389
                    repo,
1390
                    ops[base_op],
1391
                    run_index,
1392
                    metadata,
1393
                    admitted_output_chars,
1394
                )
1395
                row["dirty_fraction"] = fraction
1396
                row["dirty_file_count"] = len(changed)
1397
                rows.append(row)
1398
        finally:
1399
            restore_dirty_fraction(repo, changed, seed)
1400
    return rows
1401
1402
1403
def ops_for(subject: Subject, track: str) -> dict[str, list[str]]:
1404
    """Non-snapshot operation commands from the command-semantics contract.
1405
1406
    Snapshot operations go through snapshot_message/run_git_snapshot so git
1407
    pays its stage+commit as two recorded calls. Where Oak lacks a compact
1408
    equivalent, core-equivalent rows use the closest available command; the
1409
    contract JSON records that gap explicitly.
1410
    """
1411
    sem = semantics()
1412
    vcs = str(subject.bin)
1413
    status = [vcs, *sem.status_args(subject.kind, track)]
1414
    return {
1415
        "repo.init": [vcs, *sem.init_args(subject.kind)],
1416
        "status.clean": status,
1417
        "status.dirty": list(status),
1418
        "diff.dirty": [vcs, *sem.diff_args(subject.kind, track)],
1419
        "branch.create": [vcs, *sem.branch_create_args(subject.kind)],
1420
    }
1421
1422
1423
def snapshot_message(op: str) -> str:
1424
    return "initial" if op == "snapshot.initial" else op
1425
1426
1427
def run_snapshot(
1428
    subject: Subject,
1429
    scenario: Scenario,
1430
    op: str,
1431
    cwd: Path,
1432
    run_index: int,
1433
    metadata: dict[str, Any],
1434
    admitted_output_chars: int,
1435
) -> list[dict[str, Any]]:
1436
    """Snapshot via the command-semantics contract.
1437
1438
    Subjects that stage separately (git) record the stage and commit as
1439
    sub-rows plus a combined canonical row; one-command subjects (oak) record
1440
    a single row. Agents pay every recorded call.
1441
    """
1442
    sem = semantics()
1443
    commands = sem.snapshot_commands(str(subject.bin), subject.kind, snapshot_message(op))
1444
    if len(commands) == 1:
1445
        before = oakbench_integrity.repository_state_fingerprint(cwd)
1446
        expected_hashes = oakbench_integrity.sample_worktree_payload_hashes(cwd)
1447
        row = run_command(subject, scenario, op, cwd, commands[0], run_index, metadata, admitted_output_chars)
1448
        return [apply_state_change_attestation(row, cwd, before, op, expected_hashes=expected_hashes)]
1449
1450
    stage_command, commit_command = commands
1451
    add_result = run_command(
1452
        subject, scenario, f"{op}.add", cwd, stage_command, run_index, metadata, admitted_output_chars
1453
    )
1454
    if add_result["returncode"] != 0:
1455
        return [add_result]
1456
    before_commit = oakbench_integrity.repository_state_fingerprint(cwd)
1457
    expected_hashes = oakbench_integrity.sample_worktree_payload_hashes(cwd)
1458
    commit_result = run_command(
1459
        subject, scenario, f"{op}.commit", cwd, commit_command, run_index, metadata, admitted_output_chars
1460
    )
1461
    apply_state_change_attestation(commit_result, cwd, before_commit, op, expected_hashes=expected_hashes)
1462
    subrows = [add_result, commit_result]
1463
    combined = {
1464
        **commit_result,
1465
        "operation": op,
1466
        "elapsed_ms": round(add_result["elapsed_ms"] + commit_result["elapsed_ms"], 3),
1467
        "returncode": commit_result["returncode"],
1468
        "command": [stage_command, commit_command],
1469
        **merge_interaction_metrics([stage_command, commit_command], subrows),
1470
    }
1471
    if add_result["returncode"] != 0 or commit_result["returncode"] != 0:
1472
        combined["stderr"] = (add_result.get("stderr", "") + "\n" + commit_result.get("stderr", ""))[-4000:]
1473
    return [add_result, commit_result, combined]
1474
1475
1476
def run_subject_scenario(
1477
    subject: Subject,
1478
    scenario: Scenario,
1479
    fixture: Path,
1480
    run_index: int,
1481
    run_root: Path,
1482
    metadata: dict[str, Any],
1483
    skip_diff: bool,
1484
    admitted_output_chars: int,
1485
    track: str,
1486
    skip_determinism_probe: bool = False,
1487
    skip_remote: bool = False,
1488
    dirty_spectrum: bool = False,
1489
) -> list[dict[str, Any]]:
1490
    applies, skip_reason = git_mode_applies_to_scenario(subject.git_mode, scenario)
1491
    if not applies:
1492
        return [
1493
            remote_skip_row(
1494
                subject,
1495
                scenario,
1496
                "scenario.mode_skipped",
1497
                run_index,
1498
                metadata,
1499
                skip_reason or "git_mode_not_applicable",
1500
            )
1501
        ]
1502
    if subject.git_mode == "lfs" and not git_lfs_available(subject.bin):
1503
        return [
1504
            remote_skip_row(
1505
                subject,
1506
                scenario,
1507
                "scenario.mode_skipped",
1508
                run_index,
1509
                metadata,
1510
                "git_lfs_unavailable: install git-lfs to measure the git_lfs tuned comparator",
1511
            )
1512
        ]
1513
1514
    repo = run_root / scenario.name / subject.name / f"run-{run_index}"
1515
    copy_fixture(fixture, repo)
1516
    ops = ops_for(subject, track)
1517
    results: list[dict[str, Any]] = []
1518
1519
    def timed(op: str) -> None:
1520
        if op in {"snapshot.initial", "snapshot.dirty", "task.snapshot"}:
1521
            results.extend(run_snapshot(subject, scenario, op, repo, run_index, metadata, admitted_output_chars))
1522
            return
1523
        before = oakbench_integrity.repository_state_fingerprint(repo) if op == "branch.create" else None
1524
        row = run_command(subject, scenario, op, repo, ops[op], run_index, metadata, admitted_output_chars)
1525
        if op == "branch.create":
1526
            apply_state_change_attestation(row, repo, before, op)
1527
        results.append(row)
1528
1529
    # Binary startup overhead in isolation. Agent fleets invoke the VCS
1530
    # thousands of times a day; process spawn cost is paid on every call.
1531
    results.append(
1532
        run_command(
1533
            subject,
1534
            scenario,
1535
            "proc.spawn",
1536
            repo,
1537
            [str(subject.bin), "--version"],
1538
            run_index,
1539
            metadata,
1540
            admitted_output_chars,
1541
        )
1542
    )
1543
1544
    timed("repo.init")
1545
    if subject.git_mode:
1546
        for setup_op, setup_args in WIRED_GIT_MODES[subject.git_mode]:
1547
            setup_row = run_command(
1548
                subject,
1549
                scenario,
1550
                f"mode.setup.{setup_op}",
1551
                repo,
1552
                [str(subject.bin), *setup_args],
1553
                run_index,
1554
                metadata,
1555
                admitted_output_chars,
1556
            )
1557
            results.append(setup_row)
1558
            if setup_row["returncode"] != 0:
1559
                # A tuned subject whose setup failed would silently measure
1560
                # stock git under a tuned label. Stop here: the failed setup
1561
                # row is the evidence, and the run exits nonzero.
1562
                setup_row["notes"] = (
1563
                    f"mode setup failed; remaining {subject.name} operations for this run "
1564
                    "were aborted so stock-git numbers are never published under a tuned label"
1565
                )
1566
                return results
1567
    if subject.git_mode == "lfs":
1568
        untracked = lfs_untracked_extensions(repo)
1569
        if untracked:
1570
            # config/git_modes.json git_lfs fairness_note: the mode "requires
1571
            # ... fixture generation with LFS-tracked binaries". A fixture file
1572
            # the track patterns miss would be committed as a plain git blob,
1573
            # and an untracked comparator is an unfair comparator — so the
1574
            # mode setup fails closed here and the scenario's rows become an
1575
            # honest skip instead of git numbers under the git_lfs label.
1576
            results.append(
1577
                remote_skip_row(
1578
                    subject,
1579
                    scenario,
1580
                    "mode.setup.lfs_track_coverage",
1581
                    run_index,
1582
                    metadata,
1583
                    "lfs_track_incomplete:" + ",".join(untracked),
1584
                )
1585
            )
1586
            return results
1587
    timed("snapshot.initial")
1588
    timed("status.clean")
1589
    if dirty_spectrum:
1590
        # Opt-in spectrum rows run here, on the clean committed tree, before
1591
        # the scenario's own dirty mutation; each step restores cleanliness so
1592
        # the pre-existing operation sequence below is untouched.
1593
        results.extend(
1594
            run_dirty_spectrum(
1595
                subject, scenario, repo, ops, run_index, metadata, skip_diff, admitted_output_chars
1596
            )
1597
        )
1598
    changed_paths = mutate_files(repo, scenario, subject, run_index)
1599
    timed("status.dirty")
1600
    if not skip_determinism_probe:
1601
        results.append(
1602
            run_determinism_probe(
1603
                subject, scenario, "status.dirty", repo, ops["status.dirty"], run_index, metadata
1604
            )
1605
        )
1606
        results.append(
1607
            run_information_probe(
1608
                subject, scenario, "status.dirty", repo, ops["status.dirty"], run_index, metadata, changed_paths
1609
            )
1610
        )
1611
    if not skip_diff:
1612
        diff_dirty_skip = git_huge_binary_diff_limit_reason(subject, scenario, "diff.dirty")
1613
        if diff_dirty_skip is not None:
1614
            results.append(
1615
                git_huge_binary_diff_skip_row(
1616
                    subject, scenario, "diff.dirty", ops["diff.dirty"], run_index, metadata
1617
                )
1618
            )
1619
            if not skip_determinism_probe:
1620
                results.append(
1621
                    git_huge_binary_diff_skip_row(
1622
                        subject,
1623
                        scenario,
1624
                        "diff.dirty.determinism",
1625
                        ops["diff.dirty"],
1626
                        run_index,
1627
                        metadata,
1628
                    )
1629
                )
1630
                results.append(
1631
                    git_huge_binary_diff_skip_row(
1632
                        subject,
1633
                        scenario,
1634
                        "diff.dirty.inforecall",
1635
                        ops["diff.dirty"],
1636
                        run_index,
1637
                        metadata,
1638
                    )
1639
                )
1640
                full_diff_command = [str(subject.bin), *semantics().diff_args(subject.kind, "core-equivalent")]
1641
                results.append(
1642
                    git_huge_binary_diff_skip_row(
1643
                        subject,
1644
                        scenario,
1645
                        "diff.full.inforecall",
1646
                        full_diff_command,
1647
                        run_index,
1648
                        metadata,
1649
                    )
1650
                )
1651
        else:
1652
            timed("diff.dirty")
1653
            if not skip_determinism_probe:
1654
                results.append(
1655
                    run_determinism_probe(
1656
                        subject, scenario, "diff.dirty", repo, ops["diff.dirty"], run_index, metadata
1657
                    )
1658
                )
1659
                results.append(
1660
                    run_information_probe(
1661
                        subject, scenario, "diff.dirty", repo, ops["diff.dirty"], run_index, metadata, changed_paths
1662
                    )
1663
                )
1664
                # Pipe-compatibility is a property of the FULL diff (what scripts
1665
                # and patch-tooling consume), independent of the run's track.
1666
                full_diff_command = [str(subject.bin), *semantics().diff_args(subject.kind, "core-equivalent")]
1667
                results.append(
1668
                    run_information_probe(
1669
                        subject, scenario, "diff.full", repo, full_diff_command, run_index, metadata, changed_paths
1670
                    )
1671
                )
1672
    timed("snapshot.dirty")
1673
    timed("branch.create")
1674
    mutate_files(repo, scenario, subject, run_index + 10_000)
1675
    timed("task.snapshot")
1676
    results.extend(
1677
        run_agent_native_json_probes(
1678
            subject, scenario, repo, run_index, metadata, admitted_output_chars
1679
        )
1680
    )
1681
    if not skip_remote:
1682
        results.extend(
1683
            run_remote_phase(subject, scenario, repo, run_index, metadata, admitted_output_chars)
1684
        )
1685
    if subject.git_mode == "fsmonitor":
1686
        # One fsmonitor daemon spawns per repo; stop it so repeated benchmark
1687
        # runs do not accumulate daemons watching deleted workdirs. Untimed and
1688
        # unrecorded: teardown, not a measured operation.
1689
        subprocess.run(
1690
            [str(subject.bin), "fsmonitor--daemon", "stop"],
1691
            cwd=repo,
1692
            env=base_env(),
1693
            stdout=subprocess.DEVNULL,
1694
            stderr=subprocess.DEVNULL,
1695
            check=False,
1696
        )
1697
    return results
1698
1699
1700
def remote_preflight_requirements(
1701
    subjects: list[Subject],
1702
    *,
1703
    skip_remote: bool,
1704
) -> list[oakbench_remotes.RemoteRequirement]:
1705
    if skip_remote or not any(subject.kind == "oak" for subject in subjects):
1706
        return []
1707
    return [
1708
        oakbench_remotes.RemoteRequirement(
1709
            purpose="default",
1710
            lane="core",
1711
            operations=tuple(OAK_NET_REMOTE_OPS),
1712
        )
1713
    ]
1714
1715
1716
def metric_medians(rows: list[dict[str, Any]], metric: str) -> dict[tuple[str, str], float]:
1717
    grouped: dict[tuple[str, str], list[float]] = {}
1718
    for row in rows:
1719
        if row["returncode"] != 0:
1720
            continue
1721
        op = row["operation"]
1722
        if op.endswith(".add") or op.endswith(".commit"):
1723
            continue
1724
        try:
1725
            value = float(row[metric])
1726
        except (KeyError, TypeError, ValueError):
1727
            continue
1728
        key = (row["subject"], row["scenario"] + "/" + op)
1729
        grouped.setdefault(key, []).append(value)
1730
    return {key: statistics.median(values) for key, values in grouped.items()}
1731
1732
1733
def average_by_subject(rows: list[dict[str, Any]], metric: str) -> dict[str, float]:
1734
    grouped: dict[str, list[float]] = {}
1735
    for row in rows:
1736
        if row["returncode"] != 0:
1737
            continue
1738
        op = row["operation"]
1739
        if op.endswith(".add") or op.endswith(".commit"):
1740
            continue
1741
        # Diagnostic and setup rows stay out of cross-operation subject
1742
        # averages so trend lines keep comparing the same op mix.
1743
        # remote.* ops stay out too: subjects without a wired remote skip them,
1744
        # and an asymmetric op mix would silently skew the cross-op average.
1745
        if (
1746
            op.endswith(".determinism")
1747
            or op.endswith(".inforecall")
1748
            or op.startswith("mode.setup")
1749
            or op.startswith("remote.")
1750
            or op == "proc.spawn"
1751
        ):
1752
            continue
1753
        try:
1754
            value = float(row[metric])
1755
        except (KeyError, TypeError, ValueError):
1756
            continue
1757
        grouped.setdefault(str(row["subject"]), []).append(value)
1758
    return {subject: statistics.mean(values) for subject, values in grouped.items()}
1759
1760
1761
# Delta math and formatting live in oakbench.reporting; these aliases keep
1762
# the historical local names used throughout the summary writer.
1763
fmt_delta = fmt_delta_lower_better
1764
fmt_float = fmt_num
1765
1766
1767
def summary_text(rows: list[dict[str, Any]], subjects: list[Subject]) -> str:
1768
    by_key = metric_medians(rows, "elapsed_ms")
1769
    token_by_key = metric_medians(rows, "estimated_tokens_total")
1770
    tools_by_key = metric_medians(rows, "tool_call_count")
1771
    output_by_key = metric_medians(rows, "raw_output_bytes")
1772
    avg_ms = average_by_subject(rows, "elapsed_ms")
1773
    avg_tokens = average_by_subject(rows, "estimated_tokens_total")
1774
    avg_cost = average_by_subject(rows, "estimated_cost_weighted_tokens")
1775
    avg_tools = average_by_subject(rows, "tool_call_count")
1776
    avg_output = average_by_subject(rows, "raw_output_bytes")
1777
    git_name = next((subject.name for subject in subjects if subject.kind == "git"), None)
1778
    oak_baseline = next((subject.name for subject in subjects if subject.name in {"oak_installed", "oak_main"}), None)
1779
    lines = [
1780
        "# Oak Benchmark Summary",
1781
        "",
1782
        f"Track: `{rows[0].get('benchmark_track', 'unknown') if rows else 'unknown'}`.",
1783
        "Positive deltas mean the row subject is lower/better than the baseline for that metric.",
1784
        "Estimated tokens are a dependency-free proxy: command text plus admitted stdout/stderr characters divided by four.",
1785
        "Tool calls are exact terminal/VCS command counts for this direct CLI harness.",
1786
        "Output bytes are full stdout/stderr byte counts, even when admitted token text was capped.",
1787
        f"Cost-weighted tokens use billing direction ({COST_WEIGHTS_NOTE}): commands an agent types are model output, stdout it reads is model input.",
1788
        "",
1789
        "## Subject Averages",
1790
        "",
1791
        "| 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 |",
1792
        "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
1793
    ]
1794
    git_avg_tokens = avg_tokens.get(git_name) if git_name else None
1795
    git_avg_tools = avg_tools.get(git_name) if git_name else None
1796
    oak_avg_tokens = avg_tokens.get(oak_baseline) if oak_baseline else None
1797
    oak_avg_tools = avg_tools.get(oak_baseline) if oak_baseline else None
1798
    for subject in subjects:
1799
        token_value = avg_tokens.get(subject.name)
1800
        tool_value = avg_tools.get(subject.name)
1801
        lines.append(
1802
            "| "
1803
            f"`{subject.name}` | "
1804
            f"{fmt_float(avg_ms.get(subject.name))} | "
1805
            f"{fmt_float(token_value)} | "
1806
            f"{fmt_float(avg_cost.get(subject.name))} | "
1807
            f"{fmt_float(avg_output.get(subject.name))} | "
1808
            f"{fmt_float(tool_value, 2)} | "
1809
            f"{fmt_delta(git_avg_tokens, token_value, subject.name == git_name)} | "
1810
            f"{fmt_delta(oak_avg_tokens, token_value, subject.name == oak_baseline or subject.kind == 'git')} | "
1811
            f"{fmt_delta(git_avg_tools, tool_value, subject.name == git_name)} | "
1812
            f"{fmt_delta(oak_avg_tools, tool_value, subject.name == oak_baseline or subject.kind == 'git')} |"
1813
        )
1814
1815
    info_rows = [row for row in rows if str(row.get("operation", "")).endswith(".inforecall")]
1816
    if info_rows:
1817
        lines.extend(
1818
            [
1819
                "",
1820
                "## Output Information Content",
1821
                "",
1822
                "Bytes saved are only a win if the changed-file list survives. Recall is the",
1823
                "fraction of ground-truth changed paths recoverable from the command output;",
1824
                "pipe-compat means diff output kept unified hunk/file structure (or an explicit",
1825
                "binary notice) that scripts and `patch`-style tooling can consume.",
1826
                "",
1827
                "| Subject | Operation | Mean recall | Mean bytes/file named | Pipe-compat rate |",
1828
                "| --- | --- | ---: | ---: | ---: |",
1829
            ]
1830
        )
1831
        grouped: dict[tuple[str, str], list[dict[str, Any]]] = {}
1832
        for row in info_rows:
1833
            grouped.setdefault((str(row["subject"]), str(row["operation"])), []).append(row)
1834
        for (subject_name, op), op_rows in sorted(grouped.items()):
1835
            recalls = [row["information_recall"] for row in op_rows if row.get("information_recall") is not None]
1836
            byte_costs = [
1837
                row["bytes_per_changed_file_named"]
1838
                for row in op_rows
1839
                if row.get("bytes_per_changed_file_named") is not None
1840
            ]
1841
            compat_flags = [row["pipe_compatible_unified"] for row in op_rows if "pipe_compatible_unified" in row]
1842
            recall_cell = f"{statistics.mean(recalls):.3f}" if recalls else ""
1843
            bytes_cell = f"{statistics.mean(byte_costs):.0f}" if byte_costs else ""
1844
            compat_cell = f"{sum(1 for flag in compat_flags if flag) / len(compat_flags) * 100:.0f}%" if compat_flags else ""
1845
            lines.append(f"| `{subject_name}` | `{op}` | {recall_cell} | {bytes_cell} | {compat_cell} |")
1846
1847
    lines.extend(
1848
        [
1849
            "",
1850
            "## Operation Medians",
1851
            "",
1852
            "| 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 |",
1853
            "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
1854
        ]
1855
    )
1856
    all_ops = sorted({scenario_op for _, scenario_op in by_key})
1857
    for scenario_op in all_ops:
1858
        git_base = by_key.get((git_name, scenario_op)) if git_name else None
1859
        git_token_base = token_by_key.get((git_name, scenario_op)) if git_name else None
1860
        git_tool_base = tools_by_key.get((git_name, scenario_op)) if git_name else None
1861
        oak_base = by_key.get((oak_baseline, scenario_op)) if oak_baseline else None
1862
        oak_token_base = token_by_key.get((oak_baseline, scenario_op)) if oak_baseline else None
1863
        oak_tool_base = tools_by_key.get((oak_baseline, scenario_op)) if oak_baseline else None
1864
        for subject in subjects:
1865
            value = by_key.get((subject.name, scenario_op))
1866
            if value is None:
1867
                continue
1868
            token_value = token_by_key.get((subject.name, scenario_op))
1869
            tool_value = tools_by_key.get((subject.name, scenario_op))
1870
            output_value = output_by_key.get((subject.name, scenario_op))
1871
            vs_git_time = "" if git_base is None or subject.name == git_name else f"{delta_pct(git_base, value):+.1f}%"
1872
            vs_git_tokens = (
1873
                ""
1874
                if git_token_base is None or token_value is None or subject.name == git_name
1875
                else f"{delta_pct(git_token_base, token_value):+.1f}%"
1876
            )
1877
            vs_git_tools = (
1878
                ""
1879
                if git_tool_base is None or tool_value is None or subject.name == git_name
1880
                else f"{delta_pct(git_tool_base, tool_value):+.1f}%"
1881
            )
1882
            vs_oak_time = (
1883
                ""
1884
                if oak_base is None or subject.name == oak_baseline or subject.kind == "git"
1885
                else f"{delta_pct(oak_base, value):+.1f}%"
1886
            )
1887
            vs_oak_tokens = (
1888
                ""
1889
                if oak_token_base is None or token_value is None or subject.name == oak_baseline or subject.kind == "git"
1890
                else f"{delta_pct(oak_token_base, token_value):+.1f}%"
1891
            )
1892
            vs_oak_tools = (
1893
                ""
1894
                if oak_tool_base is None or tool_value is None or subject.name == oak_baseline or subject.kind == "git"
1895
                else f"{delta_pct(oak_tool_base, tool_value):+.1f}%"
1896
            )
1897
            # Unmeasured renders blank, never 0 (ADR-0002): diagnostic rows
1898
            # carry no token/tool fields and must not look like free output.
1899
            lines.append(
1900
                f"| `{scenario_op}` | `{subject.name}` | {value:.1f} | "
1901
                f"{fmt_float(token_value)} | "
1902
                f"{fmt_float(output_value, 0)} | "
1903
                f"{fmt_float(tool_value)} | "
1904
                f"{vs_git_time} | {vs_git_tokens} | {vs_git_tools} | "
1905
                f"{vs_oak_time} | {vs_oak_tokens} | {vs_oak_tools} |"
1906
            )
1907
    skips = [row for row in rows if row.get("skipped")]
1908
    if skips:
1909
        lines.extend(["", "## Skipped (unmeasured, never hidden)", ""])
1910
        seen_reasons: dict[tuple[str, str], int] = {}
1911
        for row in skips:
1912
            seen_reasons[(str(row["subject"]), str(row.get("skip_reason", "")))] = (
1913
                seen_reasons.get((str(row["subject"]), str(row.get("skip_reason", ""))), 0) + 1
1914
            )
1915
        for (subject_name, reason), count in sorted(seen_reasons.items()):
1916
            lines.append(f"- `{subject_name}`: {count} rows skipped — {reason}")
1917
    failures = [row for row in rows if row["returncode"] != 0 and not row.get("skipped")]
1918
    if failures:
1919
        lines.extend(["", "## Failures", ""])
1920
        for row in failures[:20]:
1921
            reason = row.get("integrity_failure_reason") or row.get("content_integrity_failure_reason") or row.get(
1922
                "stderr", ""
1923
            )
1924
            lines.append(
1925
                f"- `{row['subject']}` `{row['scenario']}/{row['operation']}` "
1926
                f"exit {row['returncode']}: `{str(reason).splitlines()[-1:]}`"
1927
            )
1928
    return "\n".join(lines) + "\n"
1929
1930
1931
def main() -> int:
1932
    args = parse_args()
1933
    subjects = expand_git_modes(load_subjects(args), args.git_modes)
1934
    oakbench_remotes.print_remote_preflight_warnings(
1935
        oakbench_remotes.oak_remote_preflight_warnings(
1936
            remote_preflight_requirements(subjects, skip_remote=args.skip_remote)
1937
        )
1938
    )
1939
    scenarios = PROFILES[args.profile]
1940
    if args.runs:
1941
        scenarios = [
1942
            Scenario(s.name, s.file_count, s.file_size, s.dirty_count, s.binary, args.runs)
1943
            for s in scenarios
1944
        ]
1945
1946
    timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
1947
    args.results.mkdir(parents=True, exist_ok=True)
1948
    args.workdir.mkdir(parents=True, exist_ok=True)
1949
    run_root = args.workdir / "runs" / timestamp
1950
    fixture_root = args.workdir
1951
1952
    metadata = {
1953
        "bench_id": timestamp,
1954
        "profile": args.profile,
1955
        "benchmark_track": args.track,
1956
        "command_semantics_version": COMMAND_SEMANTICS_VERSION,
1957
        "timestamp_utc": timestamp,
1958
        "host": platform.node(),
1959
        "platform": platform.platform(),
1960
        "machine": platform.machine(),
1961
        "python": platform.python_version(),
1962
        "env_isolation_version": oakbench_environment.ENV_ISOLATION_VERSION,
1963
        "subject_versions": {subject.name: subject_version(subject) for subject in subjects},
1964
        "subject_details": subject_details(subjects),
1965
        "source": source_metadata(args.oak_repo),
1966
        "randomized_subject_order": bool(args.randomize_subject_order),
1967
        **runner_fields(),
1968
    }
1969
1970
    # Fixture generation runs OUTSIDE the measurement lock: building file
1971
    # trees parallelizes safely (atomic shape-keyed builds), only timed
1972
    # measurement must serialize across processes.
1973
    fixtures: dict[str, Path] = {}
1974
    for scenario in scenarios:
1975
        print(
1976
            f"[fixture] {scenario.name}: {scenario.file_count} files x {scenario.file_size} bytes",
1977
            flush=True,
1978
        )
1979
        fixtures[scenario.name] = ensure_fixture(fixture_root, scenario)
1980
1981
    metadata["load_tier"] = args.load_tier
1982
1983
    rows: list[dict[str, Any]] = []
1984
    with measurement_lock("bench") as lock_info:
1985
        metadata["measurement_lock_wait_ms"] = lock_info.wait_ms
1986
        metadata["measurement_lock"] = "held" if lock_info.enabled else "disabled"
1987
        # Background load (untimed start/stop). Failing to start under a
1988
        # non-none tier converts the whole invocation to skip rows: load that
1989
        # was not applied is never claimed.
1990
        load_generator: oakbench_loadgen.LoadGenerator | None = None
1991
        if args.load_tier != "none":
1992
            load_generator = oakbench_loadgen.LoadGenerator(
1993
                args.load_tier, args.workdir / "loadgen-temp"
1994
            )
1995
            load_start = load_generator.start()
1996
            if not load_start["started"]:
1997
                reason = oakbench_loadgen.load_tier_unavailable_reason(str(load_start["reason"]))
1998
                rows = load_tier_skip_rows(subjects, scenarios, metadata, reason, args.load_tier)
1999
                load_generator = None
2000
        try:
2001
            if not rows:
2002
                for scenario in scenarios:
2003
                    fixture = fixtures[scenario.name]
2004
                    for run_index in range(scenario.runs):
2005
                        run_subjects = list(subjects)
2006
                        if args.randomize_subject_order:
2007
                            random.Random(f"{timestamp}:{scenario.name}:{run_index}").shuffle(run_subjects)
2008
                        run_metadata = {
2009
                            **metadata,
2010
                            "subject_order": [subject.name for subject in run_subjects],
2011
                        }
2012
                        for subject in run_subjects:
2013
                            print(f"[run] {scenario.name} run={run_index} subject={subject.name}", flush=True)
2014
                            env_before = oakbench_envwatch.sample_environment()
2015
                            subject_rows = run_subject_scenario(
2016
                                subject,
2017
                                scenario,
2018
                                fixture,
2019
                                run_index,
2020
                                run_root,
2021
                                run_metadata,
2022
                                args.skip_diff,
2023
                                args.admitted_output_chars,
2024
                                args.track,
2025
                                args.skip_determinism_probe,
2026
                                args.skip_remote,
2027
                                args.dirty_spectrum,
2028
                            )
2029
                            env_after = oakbench_envwatch.sample_environment()
2030
                            boundary = oakbench_loadgen.environment_boundary_fields(
2031
                                args.load_tier, env_before, env_after
2032
                            )
2033
                            for row in subject_rows:
2034
                                row.update(boundary)
2035
                            rows.extend(subject_rows)
2036
        finally:
2037
            if load_generator is not None:
2038
                load_stop = load_generator.stop()
2039
                # bogo-ops parsed ONLY as did-the-load-run verification,
2040
                # never reported as a benchmark number.
2041
                for row in rows:
2042
                    row["load_verified"] = load_stop.get("verified")
2043
2044
    store = ResultsStore(args.results, lane="core")
2045
    raw_path, summary_path = store.write(timestamp, rows, summary_text(rows, subjects))
2046
2047
    if not args.keep_workdirs:
2048
        shutil.rmtree(run_root, ignore_errors=True)
2049
2050
    print(f"[result] {raw_path}")
2051
    print(f"[summary] {summary_path}")
2052
    return 1 if any(row["returncode"] != 0 and not row.get("skipped") for row in rows) else 0
2053
2054
2055
if __name__ == "__main__":
2056
    raise SystemExit(main())