Viewing files at commit 2735d7f5558eCommit detailsView latest

scripts/devloop.py 901 lines · 40.7 KB · python
1
#!/usr/bin/env python3
2
"""One command, one verdict: is this Oak changeset better, same, or worse?
3
4
The inner loop for agents developing Oak against this suite:
5
6
1. Resolve (or `cargo build --release`) the local Oak binary from the Oak
7
   source checkout.
8
2. Measure the host's noise floor with an A/A null test: the SAME baseline
9
   binary runs as two subjects on the micro profile, and the spread between
10
   them is what zero looks like on this machine right now. Latency deltas
11
   smaller than the floor are never claimed (a benchmark that doesn't know
12
   its own noise will happily report it as signal).
13
3. Run the comparison lanes (core, workflow, contention) with git, the
14
   installed Oak baseline, and the local changeset.
15
4. Print a one-screen verdict and exit 0 (PASS), 1 (REGRESSED), or 2 (UNMEASURED).
16
17
Examples:
18
19
    python3 scripts/devloop.py                       # build ../oak, full quick loop
20
    python3 scripts/devloop.py --oak-local-bin ../oak/target/release/oak --skip-build
21
    python3 scripts/devloop.py --lanes core          # fastest signal only
22
23
Thresholds follow the suite's regression policy: efficiency gates (exact
24
metrics: tool calls, output bytes) apply to the Oak-baseline comparison only
25
(ADR-0004); the Git comparison is a latency guardrail.
26
"""
27
28
from __future__ import annotations
29
30
import argparse
31
import dataclasses
32
import json
33
import math
34
import shutil
35
import statistics
36
import subprocess
37
import sys
38
import time
39
from datetime import datetime, timezone
40
from pathlib import Path
41
from typing import Any, Optional
42
43
from oakbench import remotes as oakbench_remotes
44
from oakbench.reporting import percentile_nearest, slower_pct
45
from oakbench.rows import output_bytes as row_output_bytes
46
from oakbench.rows import row_returncode
47
from oakbench.rows import token_total as row_token_total
48
from oakbench.rows import tool_calls as row_tool_calls
49
from oakbench.subjects import DEFAULT_OAK_REPO
50
51
ROOT = Path(__file__).resolve().parents[1]
52
SCRIPTS = ROOT / "scripts"
53
LANES = ("core", "workflow", "contention")
54
DEFAULT_CONTENTION_WORKERS = "2,8"
55
DEFAULT_CONTENTION_COMMITS_PER_WORKER = 3
56
DEFAULT_CONTENTION_MODES = "shared_checkout,workspace_per_task,clone_push_storm"
57
SKIP_RETURNCODE = 77
58
59
# Operations excluded from the noise floor: process spawn and sub-steps have
60
# the largest relative jitter and are not the deltas the verdict gates on.
61
NOISE_FLOOR_EXCLUDED = ("proc.spawn",)
62
NOISE_FLOOR_MIN_PCT = 3.0
63
64
65
def parse_args() -> argparse.Namespace:
66
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
67
    parser.add_argument("--oak-repo", type=Path, default=DEFAULT_OAK_REPO)
68
    parser.add_argument("--oak-local-bin", type=Path, help="Use this binary instead of building --oak-repo.")
69
    parser.add_argument(
70
        "--oak-baseline-bin",
71
        type=Path,
72
        help="Oak baseline binary (default: `oak` on PATH = released install).",
73
    )
74
    parser.add_argument("--skip-build", action="store_true", help="Never run cargo; require --oak-local-bin.")
75
    parser.add_argument("--lanes", default="core,workflow,contention", help=f"Comma list from: {', '.join(LANES)}")
76
    parser.add_argument(
77
        "--contention-workers",
78
        default=DEFAULT_CONTENTION_WORKERS,
79
        help="Comma-separated worker tiers for the contention lane; launch gates use 16,32,64,128.",
80
    )
81
    parser.add_argument(
82
        "--contention-commits-per-worker",
83
        type=int,
84
        default=DEFAULT_CONTENTION_COMMITS_PER_WORKER,
85
    )
86
    parser.add_argument(
87
        "--contention-modes",
88
        default=DEFAULT_CONTENTION_MODES,
89
        help="Comma-separated parallel_contention modes to run in devloop.",
90
    )
91
    parser.add_argument("--runs", type=int, default=2, help="Repetitions per comparison lane.")
92
    parser.add_argument("--noise-floor-runs", type=int, default=3, help="A/A repetitions on the micro profile.")
93
    parser.add_argument("--skip-noise-floor", action="store_true")
94
    parser.add_argument("--results", type=Path, default=ROOT / "results" / "devloop")
95
    parser.add_argument("--oak-threshold-pct", type=float, default=10.0)
96
    parser.add_argument("--git-threshold-pct", type=float, default=25.0)
97
    parser.add_argument("--min-delta-ms", type=float, default=5.0)
98
    parser.add_argument("--output-bytes-threshold-pct", type=float, default=25.0)
99
    parser.add_argument(
100
        "--scorecard",
101
        action="store_true",
102
        help="Append a target scorecard vs the CURRENT Git Baseline Book (informational; never changes the verdict exit code).",
103
    )
104
    return parser.parse_args()
105
106
107
def scorecard_lines(rows: list[dict[str, Any]], root: Path = ROOT, noise_floor_pct: float | None = None) -> list[str]:
108
    """Target-TDD lines for the devloop report; regression-TDD owns the exit code."""
109
    targets_path = root / "config" / "targets.json"
110
    if not targets_path.exists():
111
        return ["- no targets config (config/targets.json missing)"]
112
    current = root / "baselines" / "git" / "CURRENT"
113
    if not current.exists():
114
        return ["- no baseline book (baselines/git/CURRENT missing); run scripts/baseline_campaign.py run"]
115
    book_id = current.read_text().strip()
116
    book_path = root / "baselines" / "git" / book_id / "book.json"
117
    if not book_path.exists():
118
        return [f"- baseline book {book_id!r} is named by CURRENT but has no book.json"]
119
    from oakbench.baseline_book import load_baseline_book, load_targets
120
    from oakbench.scorecard import evaluate_targets
121
122
    baseline = load_baseline_book(book_path)
123
    if noise_floor_pct is not None:
124
        baseline = {
125
            key: dataclasses.replace(
126
                cell,
127
                noise_floor_pct=max(float(cell.noise_floor_pct or 0.0), float(noise_floor_pct)),
128
            )
129
            for key, cell in baseline.items()
130
        }
131
    results = evaluate_targets(rows, load_targets(targets_path), baseline)
132
    lines = []
133
    for item in results:
134
        ratio = item.get("ratio")
135
        ratio_text = "" if ratio is None else f" ratio {ratio:.3f} vs goal {item['goal_value']:.3f}"
136
        notes = "; ".join(str(note) for note in item.get("messages") or [])
137
        lines.append(f"- {item['verdict']} `{item['target_id']}`{ratio_text}" + (f" — {notes}" if notes else ""))
138
    return lines or ["- no targets defined"]
139
140
141
def run_lane(command: list[str], label: str) -> None:
142
    print(f"[devloop] {label}: {' '.join(str(part) for part in command)}", flush=True)
143
    proc = subprocess.run(command, cwd=ROOT, check=False)
144
    # Lane exit codes signal benchmark-operation failures; the verdict reads
145
    # those from the rows, so a nonzero lane exit is not fatal here.
146
    if proc.returncode not in (0, 1):
147
        raise SystemExit(f"{label} failed to run (exit {proc.returncode})")
148
149
150
def _csv(value: str) -> list[str]:
151
    return [item.strip() for item in value.split(",") if item.strip()]
152
153
154
def remote_preflight_requirements(
155
    lanes: list[str],
156
    *,
157
    contention_modes: list[str] | None = None,
158
    contention_workers: list[str] | None = None,
159
) -> list[oakbench_remotes.RemoteRequirement]:
160
    requirements: list[oakbench_remotes.RemoteRequirement] = []
161
    if "core" in lanes:
162
        requirements.append(
163
            oakbench_remotes.RemoteRequirement(
164
                purpose="default",
165
                lane="devloop/core",
166
                operations=(
167
                    "remote.net.push.first",
168
                    "remote.net.clone.cold",
169
                    "remote.net.fetch.uptodate",
170
                ),
171
            )
172
        )
173
    if "workflow" in lanes:
174
        requirements.append(
175
            oakbench_remotes.RemoteRequirement(
176
                purpose="sync",
177
                lane="devloop/workflow",
178
                operations=(
179
                    "sync_push_divergence/workflow.skipped",
180
                    "sync_pull_upstream/workflow.skipped",
181
                    "sync_pull_dirty/workflow.skipped",
182
                    "sync_non_ff_after_amend/workflow.skipped",
183
                ),
184
            )
185
        )
186
    if "contention" in lanes:
187
        modes = contention_modes or _csv(DEFAULT_CONTENTION_MODES)
188
        workers = contention_workers or _csv(DEFAULT_CONTENTION_WORKERS)
189
        operations: list[str] = []
190
        if "workspace_per_task" in modes:
191
            operations.extend(
192
                f"contention_workspace_per_task_w{workers_level}/parallel.total"
193
                for workers_level in workers
194
            )
195
        if "clone_push_storm" in modes:
196
            operations.extend(
197
                f"contention_clone_push_storm_w{workers_level}/parallel.total"
198
                for workers_level in workers
199
            )
200
        if operations:
201
            requirements.append(
202
                oakbench_remotes.RemoteRequirement(
203
                    purpose="fleet",
204
                    lane="devloop/contention",
205
                    operations=tuple(operations),
206
                )
207
            )
208
    return requirements
209
210
211
def build_oak_local(oak_repo: Path) -> Path:
212
    cargo_toml = oak_repo / "Cargo.toml"
213
    if not cargo_toml.exists():
214
        raise SystemExit(
215
            f"No Cargo.toml at {oak_repo}; pass --oak-local-bin or --oak-repo. "
216
            "devloop never mutates the Oak source checkout, it only builds it."
217
        )
218
    if shutil.which("cargo") is None:
219
        raise SystemExit("cargo is not installed; pass --oak-local-bin instead of building.")
220
    print(f"[devloop] building oak from {oak_repo} (cargo build --release)", flush=True)
221
    start = time.perf_counter()
222
    proc = subprocess.run(
223
        ["cargo", "build", "--release"],
224
        cwd=oak_repo,
225
        text=True,
226
        stdout=subprocess.PIPE,
227
        stderr=subprocess.STDOUT,
228
        check=False,
229
    )
230
    elapsed = time.perf_counter() - start
231
    if proc.returncode != 0:
232
        tail = "\n".join(proc.stdout.splitlines()[-25:])
233
        raise SystemExit(f"cargo build failed (exit {proc.returncode}) after {elapsed:.0f}s:\n{tail}")
234
    binary = oak_repo / "target" / "release" / "oak"
235
    if not binary.exists():
236
        raise SystemExit(f"build succeeded but {binary} does not exist")
237
    print(f"[devloop] built {binary} in {elapsed:.0f}s", flush=True)
238
    return binary
239
240
241
def load_jsonl(path: Path) -> list[dict[str, Any]]:
242
    try:
243
        text = path.read_text()
244
    except FileNotFoundError:
245
        raise SystemExit(f"[devloop] result JSONL missing: {path}") from None
246
    except OSError as exc:
247
        raise SystemExit(f"[devloop] result JSONL unreadable: {path}: {exc}") from None
248
249
    rows: list[dict[str, Any]] = []
250
    for line_number, line in enumerate(text.splitlines(), start=1):
251
        stripped = line.strip()
252
        if not stripped:
253
            continue
254
        try:
255
            row = json.loads(stripped)
256
        except json.JSONDecodeError as exc:
257
            raise SystemExit(
258
                f"[devloop] malformed result JSONL: {path}:{line_number}: {exc.msg}"
259
            ) from None
260
        if not isinstance(row, dict):
261
            raise SystemExit(f"[devloop] result JSONL row is not an object: {path}:{line_number}") from None
262
        rows.append(row)
263
264
    if not rows:
265
        raise SystemExit(f"[devloop] result JSONL contains no rows: {path}")
266
    return rows
267
268
269
def median_by_key(
270
    rows: list[dict[str, Any]], value_of, *, successful_only: bool = True
271
) -> dict[tuple[str, str, str], float]:
272
    grouped: dict[tuple[str, str, str], list[float]] = {}
273
    for row in rows:
274
        operation = str(row.get("operation") or "")
275
        if operation.endswith((".add", ".commit")):
276
            continue
277
        if successful_only and row_returncode(row) != 0:
278
            continue
279
        value = value_of(row)
280
        if value is None:
281
            continue
282
        key = (str(row.get("subject")), str(row.get("scenario")), operation)
283
        grouped.setdefault(key, []).append(float(value))
284
    return {key: float(statistics.median(values)) for key, values in grouped.items()}
285
286
287
def outcome_counts_by_key(rows: list[dict[str, Any]]) -> dict[tuple[str, str, str], dict[str, int]]:
288
    counts: dict[tuple[str, str, str], dict[str, int]] = {}
289
    for row in rows:
290
        operation = str(row.get("operation") or "")
291
        if operation.endswith((".add", ".commit")):
292
            continue
293
        key = (str(row.get("subject")), str(row.get("scenario")), operation)
294
        item = counts.setdefault(key, {"success": 0, "failure": 0, "skip": 0})
295
        returncode = row_returncode(row)
296
        if returncode == 0 and not row.get("skipped"):
297
            item["success"] += 1
298
        elif returncode == SKIP_RETURNCODE or row.get("skipped"):
299
            item["skip"] += 1
300
        else:
301
            item["failure"] += 1
302
    return counts
303
304
305
def diagnostic_operation(operation: str) -> bool:
306
    return operation.endswith((".determinism", ".inforecall"))
307
308
309
def measure_noise_floor(aa_dir: Path, baseline_bin: Path, runs: int) -> tuple[float, int]:
310
    """A/A null test: same binary as both subjects. Returns (p95 symmetric ratio spread, ops)."""
311
    run_lane(
312
        [
313
            sys.executable,
314
            str(SCRIPTS / "bench.py"),
315
            "--profile",
316
            "micro",
317
            "--runs",
318
            str(runs),
319
            "--subjects",
320
            "oak_installed,oak_local",
321
            "--oak-installed-bin",
322
            str(baseline_bin),
323
            "--oak-local-bin",
324
            str(baseline_bin),
325
            "--skip-determinism-probe",
326
            "--skip-remote",
327
            "--randomize-subject-order",
328
            "--results",
329
            str(aa_dir),
330
        ],
331
        "noise floor (A/A)",
332
    )
333
    rows = load_jsonl(aa_dir / "latest.jsonl")
334
    medians = median_by_key(rows, lambda row: row.get("elapsed_ms"))
335
    spreads: list[float] = []
336
    for (subject, scenario, operation), value in medians.items():
337
        if subject != "oak_installed" or operation in NOISE_FLOOR_EXCLUDED or operation.startswith("mode.setup"):
338
            continue
339
        other = medians.get(("oak_local", scenario, operation))
340
        if other is None or not all(math.isfinite(x) and x > 0 for x in (value, other)):
341
            continue
342
        # Identical subjects have no privileged denominator. Use the larger
343
        # directed slowdown so swapping their labels cannot lower the floor.
344
        pct = slower_pct(min(value, other), max(value, other))
345
        if pct is not None:
346
            spreads.append(pct)
347
    if not spreads:
348
        return NOISE_FLOOR_MIN_PCT, 0
349
    p95 = percentile_nearest(spreads, 95.0) or NOISE_FLOOR_MIN_PCT
350
    return max(p95, NOISE_FLOOR_MIN_PCT), len(spreads)
351
352
353
def vcs_operations(rows: list[dict[str, Any]]) -> set[tuple[str, str]]:
354
    """(scenario, operation) pairs that actually invoke the subject's VCS.
355
356
    Latency gates apply only to these: search/read/edit/test steps run
357
    identical commands for every subject, so a delta there is host noise, not
358
    the changeset.
359
    """
360
    ops: set[tuple[str, str]] = set()
361
    for row in rows:
362
        if int(row.get("vcs_tool_call_count", 0) or 0) >= 1:
363
            ops.add((str(row.get("scenario")), str(row.get("operation"))))
364
    return ops
365
366
367
def embedded_null_p95(rows: list[dict[str, Any]], target: str, baseline: str) -> tuple[float, int]:
368
    """Noise estimate from identical-command steps: the spread between subjects
369
    on operations with zero VCS calls is what zero looks like in this lane."""
370
    latency = median_by_key(rows, lambda row: row.get("elapsed_ms"))
371
    vcs_ops = vcs_operations(rows)
372
    outcomes = outcome_counts_by_key(rows)
373
    spreads: list[float] = []
374
    for (subject, scenario, operation), value in latency.items():
375
        if subject != baseline or (scenario, operation) in vcs_ops or diagnostic_operation(operation):
376
            continue
377
        baseline_outcome = outcomes.get((baseline, scenario, operation), {})
378
        target_outcome = outcomes.get((target, scenario, operation), {})
379
        if baseline_outcome.get("failure", 0) or baseline_outcome.get("skip", 0):
380
            continue
381
        if target_outcome.get("failure", 0) or target_outcome.get("skip", 0):
382
            continue
383
        pct = slower_pct(value, latency.get((target, scenario, operation)))
384
        if pct is not None:
385
            spreads.append(abs(pct))
386
    if not spreads:
387
        return 0.0, 0
388
    return percentile_nearest(spreads, 95.0) or 0.0, len(spreads)
389
390
391
def same_patch_failure(left: dict[str, Any], right: dict[str, Any]) -> bool:
392
    if left.get("patch_failure_source") == "subject_tree_manifest_comparison":
393
        fields = ("patch_failure_source", "patch_failure_reason", "patch_oracle_version",
394
                  "patch_input_sha256", "patch_expected_tree_sha256", "patch_subject_tree_after_sha256")
395
        return (left.get("patch_apply_ok") is False and right.get("patch_apply_ok") is False
396
                and left.get("patch_failure_reason") == "diff_mutated_subject_tree"
397
                and all(isinstance(left.get(field), str) and len(left[field]) == 64 for field in fields[3:])
398
                and all(left.get(field) == right.get(field) for field in fields))
399
    fields = ("patch_input_sha256", "patch_expected_tree_sha256", "patch_apply_returncode",
400
              "patch_failure_reason", "patch_oracle_version", "patch_instrument_sha256",
401
              "patch_instrument_version")
402
    fingerprints = all(isinstance(left.get(field), str) and len(left[field]) == 64
403
                       for field in fields[:2])
404
    return (fingerprints and left.get("patch_apply_ok") is False and right.get("patch_apply_ok") is False
405
            and type(left.get("patch_apply_returncode")) is int
406
            and bool(left.get("patch_failure_reason")) and bool(left.get("patch_oracle_version"))
407
            and isinstance(left.get("patch_instrument_sha256"), str) and len(left["patch_instrument_sha256"]) == 64
408
            and bool(left.get("patch_instrument_version"))
409
            and all(left.get(field) == right.get(field) for field in fields))
410
411
412
def preexisting_patch_breach(breach: str, rows: list[dict[str, Any]]) -> bool:
413
    target_rows = [row for row in rows if row.get("subject") == "oak_local"
414
                   and breach.startswith(f"{breach.split(' ', 1)[0]} `{row.get('scenario')}/{row.get('operation')}`:")]
415
    if not target_rows:
416
        return False
417
    for target_row in target_rows:
418
        baseline_rows = [row for row in rows if row.get("subject") == "oak_installed"
419
                         and (row.get("scenario"), row.get("operation")) ==
420
                         (target_row.get("scenario"), target_row.get("operation"))]
421
        baseline_rows = [base for base in baseline_rows if base.get("run") == target_row.get("run")]
422
        if not baseline_rows or not all(same_patch_failure(target_row, base) for base in baseline_rows):
423
            return False
424
    return True
425
426
427
def compare_subjects(
428
    rows: list[dict[str, Any]],
429
    target: str,
430
    baseline: str,
431
    label: str,
432
    latency_threshold_pct: float,
433
    min_delta_ms: float,
434
    *,
435
    efficiency_gates: bool,
436
    output_bytes_threshold_pct: float,
437
    diagnostics: list[str] | None = None,
438
) -> tuple[list[str], list[str]]:
439
    """Returns (regressions, improvements) lines for one baseline comparison."""
440
    diagnostics = diagnostics if diagnostics is not None else []
441
    gate_ops = vcs_operations(rows)
442
    latency = median_by_key(rows, lambda row: row.get("elapsed_ms"))
443
    tools = median_by_key(rows, row_tool_calls)
444
    bytes_out = median_by_key(rows, row_output_bytes)
445
    tokens = median_by_key(rows, row_token_total)
446
    outcomes = outcome_counts_by_key(rows)
447
448
    regressions: list[str] = []
449
    improvements: list[str] = []
450
    scenario_ops = sorted(
451
        {
452
            (scenario, op)
453
            for subject, scenario, op in set(latency) | set(outcomes)
454
            if subject in {target, baseline}
455
        }
456
    )
457
    for scenario, operation in scenario_ops:
458
        if (scenario, operation) not in gate_ops or diagnostic_operation(operation):
459
            continue
460
        target_key = (target, scenario, operation)
461
        baseline_key = (baseline, scenario, operation)
462
463
        target_outcome = outcomes.get(target_key, {"success": 0, "failure": 0, "skip": 0})
464
        baseline_outcome = outcomes.get(baseline_key, {"success": 0, "failure": 0, "skip": 0})
465
        target_successes = target_outcome["success"]
466
        target_failures = target_outcome["failure"]
467
        target_skips = target_outcome["skip"]
468
        baseline_successes = baseline_outcome["success"]
469
        baseline_failures = baseline_outcome["failure"]
470
        if baseline_successes and target_successes == 0 and target_failures == 0 and target_skips > 0:
471
            diagnostics.append(f"UNMEASURED `{scenario}/{operation}` vs {label}: "
472
                               f"target has no measured attempts ({target_skips} skipped)")
473
        elif baseline_successes and target_successes == 0:
474
            regressions.append(
475
                f"NO SUCCESS `{scenario}/{operation}` vs {label}: target had 0 successful run(s) "
476
                f"({target_failures} failed, {target_skips} skipped), baseline had {baseline_successes}"
477
            )
478
        else:
479
            target_attempts = target_successes + target_failures
480
            baseline_attempts = baseline_successes + baseline_failures
481
            if target_attempts and baseline_attempts:
482
                target_failure_rate = target_failures / target_attempts
483
                baseline_failure_rate = baseline_failures / baseline_attempts
484
                target_less_reliable = target_failures > baseline_failures or target_successes < baseline_successes
485
                if target_failure_rate > baseline_failure_rate and target_less_reliable:
486
                    if baseline_failures == 0:
487
                        regressions.append(
488
                            f"FAIL `{scenario}/{operation}` vs {label}: {target_failures} failed run(s), "
489
                            "baseline clean"
490
                        )
491
                    else:
492
                        regressions.append(
493
                            f"FAIL-RATE `{scenario}/{operation}` vs {label}: "
494
                            f"{baseline_failures}/{baseline_attempts} -> {target_failures}/{target_attempts} "
495
                            f"failed run(s); successes {baseline_successes} -> {target_successes}"
496
                        )
497
498
        if baseline_key not in latency:
499
            continue
500
501
        pct = slower_pct(latency[baseline_key], latency.get(target_key))
502
        delta = (latency.get(target_key) or 0) - latency[baseline_key]
503
        if pct is not None:
504
            if pct >= latency_threshold_pct and delta >= min_delta_ms:
505
                regressions.append(
506
                    f"SLOWER `{scenario}/{operation}` vs {label}: {pct:+.1f}% "
507
                    f"({latency[baseline_key]:.1f} -> {latency[target_key]:.1f} ms)"
508
                )
509
            elif pct <= -latency_threshold_pct and -delta >= min_delta_ms:
510
                improvements.append(
511
                    f"faster `{scenario}/{operation}` vs {label}: {pct:+.1f}% "
512
                    f"({latency[baseline_key]:.1f} -> {latency[target_key]:.1f} ms)"
513
                )
514
515
        if efficiency_gates:
516
            tool_pct = slower_pct(tools.get(baseline_key), tools.get(target_key))
517
            if tool_pct is not None and tool_pct > 0:
518
                regressions.append(
519
                    f"TOOLS `{scenario}/{operation}` vs {label}: {tool_pct:+.1f}% "
520
                    f"({tools[baseline_key]:.0f} -> {tools[target_key]:.0f} calls)"
521
                )
522
            bytes_pct = slower_pct(bytes_out.get(baseline_key), bytes_out.get(target_key))
523
            if bytes_pct is not None and bytes_pct > output_bytes_threshold_pct:
524
                regressions.append(
525
                    f"OUTPUT `{scenario}/{operation}` vs {label}: {bytes_pct:+.1f}% "
526
                    f"({bytes_out[baseline_key]:.0f} -> {bytes_out[target_key]:.0f} bytes)"
527
                )
528
            elif bytes_pct is not None and bytes_pct < -output_bytes_threshold_pct:
529
                improvements.append(
530
                    f"leaner `{scenario}/{operation}` vs {label}: {bytes_pct:+.1f}% output bytes"
531
                )
532
            token_pct = slower_pct(tokens.get(baseline_key), tokens.get(target_key))
533
            if token_pct is not None and token_pct < -output_bytes_threshold_pct:
534
                improvements.append(f"fewer tokens `{scenario}/{operation}` vs {label}: {token_pct:+.1f}%")
535
536
    # Output-sufficiency gates: exact semantic metrics from the inforecall
537
    # probe rows, always on. A changeset can win every token/latency gate by
538
    # dropping changed-file names from output or breaking piped-diff
539
    # structure; that is a regression, not an optimization.
540
    oracle_versions: dict[tuple[str, str, str], set[Any]] = {}
541
    for row in rows:
542
        if "information_recall" in row or "information_precision" in row:
543
            key = (str(row.get("subject")), str(row.get("scenario")), str(row.get("operation")))
544
            oracle_versions.setdefault(key, set()).add(row.get("output_oracle_version"))
545
    incompatible_output = set()
546
    for (subject, scenario, operation), versions in oracle_versions.items():
547
        if subject != target:
548
            continue
549
        baseline_versions = oracle_versions.get((baseline, scenario, operation))
550
        if baseline_versions is not None and (len(versions) != 1 or versions != baseline_versions):
551
            incompatible_output.add((scenario, operation))
552
            regressions.append(f"OUTPUT-ORACLE `{scenario}/{operation}`: incompatible output oracle versions")
553
    output_rows = [row for row in rows if (str(row.get("scenario")), str(row.get("operation"))) not in incompatible_output]
554
    recall = median_by_key(output_rows, lambda row: row.get("information_recall"))
555
    precision = median_by_key(output_rows, lambda row: row.get("information_precision"))
556
    pipe_compat = median_by_key(
557
        rows,
558
        lambda row: (
559
            1.0
560
            if row.get("pipe_compatible_unified") is True
561
            else (0.0 if row.get("pipe_compatible_unified") is False else None)
562
        ),
563
    )
564
    for (subject, scenario, operation), value in sorted(recall.items()):
565
        if subject != target:
566
            continue
567
        base_value = recall.get((baseline, scenario, operation))
568
        if base_value is not None and value < base_value - 1e-6:
569
            regressions.append(
570
                f"RECALL `{scenario}/{operation}` vs {label}: changed-file recall "
571
                f"{base_value:.3f} -> {value:.3f} (output got cheaper by losing information)"
572
            )
573
    for (subject, scenario, operation), value in sorted(pipe_compat.items()):
574
        if subject != target:
575
            continue
576
        base_value = pipe_compat.get((baseline, scenario, operation))
577
        if base_value is not None and value < base_value - 1e-6:
578
            regressions.append(
579
                f"PIPE-COMPAT `{scenario}/{operation}` vs {label}: unified-diff structure lost "
580
                f"(rate {base_value:.2f} -> {value:.2f}); this legacy metric checks structure only"
581
            )
582
583
    for (subject, scenario, operation), value in sorted(precision.items()):
584
        if subject == target:
585
            base_value = precision.get((baseline, scenario, operation))
586
            if base_value is not None and value < base_value - 1e-6:
587
                regressions.append(f"PRECISION `{scenario}/{operation}` vs {label}: {base_value:.3f} -> {value:.3f}")
588
    patch_cells = {}
589
    for item in rows:
590
        if item.get("patch_application_required") is True or "patch_apply_ok" in item:
591
            key = (str(item.get("subject")), str(item.get("scenario")), str(item.get("operation")))
592
            patch_cells.setdefault(key, []).append(item)
593
    for scenario, operation in sorted({(key[1], key[2]) for key in patch_cells if key[0] in {target, baseline}}):
594
        base_rows = patch_cells.get((baseline, scenario, operation), [])
595
        target_rows = patch_cells.get((target, scenario, operation), [])
596
        proven_baseline = any(item.get("patch_apply_ok") is True and item.get("patch_application_required") is True
597
                              for item in base_rows)
598
        prefix = f"PATCH-APPLY `{scenario}/{operation}`"
599
        if not target_rows:
600
            if proven_baseline:
601
                regressions.append(prefix + ": target omitted previously measured patch evidence")
602
            continue
603
        for item in target_rows:
604
            if item.get("patch_apply_ok") is None:
605
                severity = "UNMEASURED " if item.get("patch_application_required") is True else "INFO "
606
                diagnostics.append(severity + prefix + ": " + str(item.get("patch_skip_reason") or "patch evidence unavailable"))
607
                continue
608
            instrument_rows = [probe for probe in [item, *[base for base in base_rows if base.get("run") == item.get("run")]]
609
                               if probe.get("patch_apply_ok") is not None
610
                               and probe.get("patch_failure_source") != "subject_tree_manifest_comparison"]
611
            if any(not isinstance(probe.get("patch_instrument_sha256"), str)
612
                   or len(probe["patch_instrument_sha256"]) != 64
613
                   or not probe.get("patch_instrument_version") for probe in instrument_rows):
614
                severity = "UNMEASURED " if item.get("patch_application_required") is True else "INFO "
615
                diagnostics.append(severity + prefix + ": patch instrument identity unavailable")
616
                continue
617
            versions = {base.get("patch_oracle_version") for base in base_rows if base.get("patch_apply_ok") is not None}
618
            if versions and versions != {item.get("patch_oracle_version")}:
619
                message = f"PATCH-ORACLE `{scenario}/{operation}`: incompatible or missing patch oracle version"
620
                if item.get("patch_application_required") is True:
621
                    regressions.append(message)
622
                else:
623
                    diagnostics.append("INFO " + message)
624
            elif item.get("patch_apply_ok") is False and (item.get("patch_application_required") is True
625
                                                        or item.get("patch_failure_source") == "subject_tree_manifest_comparison"):
626
                matched_rows = [base for base in base_rows if base.get("run") == item.get("run")]
627
                identical_failure = bool(matched_rows) and all(same_patch_failure(item, base) for base in matched_rows)
628
                if identical_failure:
629
                    diagnostics.append("PREEXISTING " + prefix + ": same measured defect in baseline and target")
630
                else:
631
                    regressions.append(prefix + ": exact patch application failed")
632
633
    return regressions, improvements
634
635
636
def contention_checks(rows: list[dict[str, Any]], target: str, baseline: str) -> tuple[list[str], list[str]]:
637
    regressions: list[str] = []
638
    improvements: list[str] = []
639
    totals: dict[tuple[str, str], dict[str, Any]] = {}
640
    for row in rows:
641
        if row.get("operation") != "parallel.total" or row.get("skipped"):
642
            continue
643
        totals[(str(row.get("subject")), str(row.get("scenario")))] = row
644
645
    for (subject, scenario), row in sorted(totals.items()):
646
        if subject != target:
647
            continue
648
        metrics = row.get("parallel_metrics", {})
649
        if metrics.get("integrity_check_passed") is False:
650
            regressions.append(f"INTEGRITY `{scenario}`: post-contention check failed for {target}")
651
        if (metrics.get("lost_updates_detected") or 0) > 0:
652
            regressions.append(
653
                f"LOST UPDATES `{scenario}`: {metrics['lost_updates_detected']} payload(s) lost for {target}"
654
            )
655
        base = totals.get((baseline, scenario))
656
        if base:
657
            target_tp = metrics.get("commit_throughput_per_s")
658
            base_tp = base.get("parallel_metrics", {}).get("commit_throughput_per_s")
659
            if target_tp and base_tp:
660
                pct = ((target_tp - base_tp) / base_tp) * 100.0
661
                if pct < -15.0:
662
                    regressions.append(
663
                        f"THROUGHPUT `{scenario}` vs Oak baseline: {pct:+.1f}% "
664
                        f"({base_tp:.1f} -> {target_tp:.1f} snapshots/s)"
665
                    )
666
                elif pct > 15.0:
667
                    improvements.append(f"throughput `{scenario}` vs Oak baseline: {pct:+.1f}%")
668
    return regressions, improvements
669
670
671
def main() -> int:
672
    args = parse_args()
673
    lanes = [lane.strip() for lane in args.lanes.split(",") if lane.strip()]
674
    unknown = [lane for lane in lanes if lane not in LANES]
675
    if unknown:
676
        raise SystemExit(f"unknown lanes: {', '.join(unknown)}; valid: {', '.join(LANES)}")
677
    contention_modes = _csv(args.contention_modes)
678
    contention_workers = _csv(args.contention_workers)
679
    oakbench_remotes.print_remote_preflight_warnings(
680
        oakbench_remotes.oak_remote_preflight_warnings(
681
            remote_preflight_requirements(
682
                lanes,
683
                contention_modes=contention_modes,
684
                contention_workers=contention_workers,
685
            )
686
        )
687
    )
688
689
    baseline_bin = args.oak_baseline_bin or (Path(shutil.which("oak")) if shutil.which("oak") else None)
690
    if baseline_bin is None:
691
        raise SystemExit("No Oak baseline: install oak or pass --oak-baseline-bin")
692
    if args.oak_local_bin:
693
        local_bin = args.oak_local_bin.resolve()
694
        if not local_bin.exists():
695
            raise SystemExit(f"--oak-local-bin does not exist: {local_bin}")
696
    elif args.skip_build:
697
        raise SystemExit("--skip-build requires --oak-local-bin")
698
    else:
699
        local_bin = build_oak_local(args.oak_repo)
700
701
    bench_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
702
    out = args.results / bench_id
703
    out.mkdir(parents=True, exist_ok=True)
704
    started = time.perf_counter()
705
706
    noise_pct = NOISE_FLOOR_MIN_PCT
707
    noise_ops = 0
708
    if not args.skip_noise_floor:
709
        noise_pct, noise_ops = measure_noise_floor(out / "aa", baseline_bin, args.noise_floor_runs)
710
    effective_oak_threshold = max(args.oak_threshold_pct, 2.0 * noise_pct)
711
    effective_git_threshold = max(args.git_threshold_pct, 2.0 * noise_pct)
712
713
    subject_flags = [
714
        "--subjects",
715
        "git,oak_installed,oak_local",
716
        "--oak-installed-bin",
717
        str(baseline_bin),
718
        "--oak-local-bin",
719
        str(local_bin),
720
    ]
721
722
    if "core" in lanes:
723
        run_lane(
724
            [
725
                sys.executable,
726
                str(SCRIPTS / "bench.py"),
727
                "--profile",
728
                "smoke",
729
                "--runs",
730
                str(args.runs),
731
                "--randomize-subject-order",
732
                *subject_flags,
733
                "--results",
734
                str(out / "core"),
735
            ],
736
            "core lane",
737
        )
738
    if "workflow" in lanes:
739
        run_lane(
740
            [
741
                sys.executable,
742
                str(SCRIPTS / "workflow_ab.py"),
743
                "--workflows",
744
                "all",
745
                "--runs",
746
                str(args.runs),
747
                "--randomize-subject-order",
748
                *subject_flags,
749
                "--results",
750
                str(out / "workflow"),
751
            ],
752
            "workflow lane",
753
        )
754
    if "contention" in lanes:
755
        run_lane(
756
            [
757
                sys.executable,
758
                str(SCRIPTS / "parallel_contention.py"),
759
                "--workers",
760
                args.contention_workers,
761
                "--commits-per-worker",
762
                str(args.contention_commits_per_worker),
763
                "--modes",
764
                args.contention_modes,
765
                *subject_flags,
766
                "--results",
767
                str(out / "contention"),
768
            ],
769
            "contention lane",
770
        )
771
772
    regressions: list[str] = []
773
    improvements: list[str] = []
774
    guardrails: list[str] = []
775
    known_gaps: list[str] = []
776
    null_notes: list[str] = []
777
    measurement_notes: list[str] = []
778
    scorecard_rows: list[dict[str, Any]] = []
779
    for lane in ("core", "workflow"):
780
        if lane not in lanes:
781
            continue
782
        rows = load_jsonl(out / lane / "latest.jsonl")
783
        scorecard_rows += rows
784
        # Per-lane embedded null control: identical-command steps (search,
785
        # read, edit, test) cannot differ between subjects, so their spread is
786
        # this lane's live noise measurement; thresholds rise to clear it.
787
        null_pct, null_ops = embedded_null_p95(rows, "oak_local", "oak_installed")
788
        lane_oak_threshold = max(effective_oak_threshold, 2.0 * null_pct)
789
        lane_git_threshold = max(effective_git_threshold, 2.0 * null_pct)
790
        if null_ops:
791
            null_notes.append(
792
                f"[{lane}] embedded null ({null_ops} identical-command ops): p95 spread {null_pct:.1f}%; "
793
                f"lane threshold {lane_oak_threshold:.1f}%"
794
            )
795
        lane_regressions, lane_improvements = compare_subjects(
796
            rows,
797
            "oak_local",
798
            "oak_installed",
799
            "Oak baseline",
800
            lane_oak_threshold,
801
            args.min_delta_ms,
802
            efficiency_gates=True,
803
            output_bytes_threshold_pct=args.output_bytes_threshold_pct,
804
            diagnostics=measurement_notes,
805
        )
806
        regressions += [f"[{lane}] {item}" for item in lane_regressions]
807
        improvements += [f"[{lane}] {item}" for item in lane_improvements]
808
809
        # Git guardrail: only breaches the CHANGESET introduced fail the
810
        # verdict. Pre-existing Oak-vs-Git gaps (also breached by the installed
811
        # baseline) are listed as known gaps — a verdict that fails every run
812
        # on inherited gaps teaches agents to ignore it (ADR-0004's lesson).
813
        local_breaches, git_improvements = compare_subjects(
814
            rows,
815
            "oak_local",
816
            "git",
817
            "Git guardrail",
818
            lane_git_threshold,
819
            max(args.min_delta_ms, 10.0),
820
            efficiency_gates=False,
821
            output_bytes_threshold_pct=args.output_bytes_threshold_pct,
822
        )
823
        baseline_breaches, _ = compare_subjects(
824
            rows,
825
            "oak_installed",
826
            "git",
827
            "Git guardrail",
828
            lane_git_threshold,
829
            max(args.min_delta_ms, 10.0),
830
            efficiency_gates=False,
831
            output_bytes_threshold_pct=args.output_bytes_threshold_pct,
832
        )
833
        preexisting = {breach.split(":")[0] for breach in baseline_breaches}
834
        for breach in local_breaches:
835
            is_patch = breach.startswith(("PATCH-APPLY ", "PATCH-ORACLE "))
836
            if breach.split(":")[0] in preexisting and (not is_patch or preexisting_patch_breach(breach, rows)):
837
                known_gaps.append(f"[{lane}] {breach}")
838
            else:
839
                guardrails.append(f"[{lane}] {breach}")
840
        improvements += [f"[{lane}] {item}" for item in git_improvements]
841
    if "contention" in lanes:
842
        rows = load_jsonl(out / "contention" / "latest.jsonl")
843
        scorecard_rows += rows
844
        lane_regressions, lane_improvements = contention_checks(rows, "oak_local", "oak_installed")
845
        regressions += [f"[contention] {item}" for item in lane_regressions]
846
        improvements += [f"[contention] {item}" for item in lane_improvements]
847
848
    elapsed_s = time.perf_counter() - started
849
    verdict = ("REGRESSED" if regressions or guardrails else
850
               "UNMEASURED" if any(note.startswith("UNMEASURED") for note in measurement_notes) else "PASS")
851
    lines = [
852
        "# Devloop Verdict",
853
        "",
854
        f"**{verdict}** — oak_local ({local_bin}) vs Oak baseline ({baseline_bin}) and Git, "
855
        f"lanes: {', '.join(lanes)}, runs={args.runs}, total {elapsed_s:.0f}s.",
856
        "",
857
        f"- Noise floor (A/A null test, {noise_ops} ops): p95 spread {noise_pct:.1f}% on identical binaries.",
858
        f"- Effective latency thresholds: Oak {effective_oak_threshold:.1f}%, Git {effective_git_threshold:.1f}% "
859
        f"(deltas below the noise floor are never claimed).",
860
        f"- Efficiency gates (exact metrics) vs Oak baseline only (ADR-0004): any tool-call increase, "
861
        f">{args.output_bytes_threshold_pct:.0f}% output bytes.",
862
        *[f"- {note}" for note in null_notes],
863
        "",
864
        f"## Regressions vs Oak baseline ({len(regressions)})",
865
        "",
866
        *([f"- {item}" for item in regressions] or ["- none"]),
867
        "",
868
        f"## New Git-guardrail breaches introduced by this changeset ({len(guardrails)})",
869
        "",
870
        *([f"- {item}" for item in guardrails] or ["- none"]),
871
        "",
872
        f"## Known Git gaps, pre-existing in the baseline ({len(known_gaps)}) — tracked, not failing",
873
        "",
874
        *([f"- {item}" for item in known_gaps] or ["- none"]),
875
        "",
876
        "## Correctness measurement notes",
877
        "",
878
        *([f"- {item}" for item in dict.fromkeys(measurement_notes)] or ["- none"]),
879
        "",
880
        f"## Improvements ({len(improvements)})",
881
        "",
882
        *([f"- {item}" for item in improvements[:20]] or ["- none"]),
883
        "",
884
        f"Raw rows: {out}/",
885
    ]
886
    if args.scorecard:
887
        lines += [
888
            "",
889
            "## Scorecard vs Git Baseline Book (informational)",
890
            "",
891
            *scorecard_lines(scorecard_rows, noise_floor_pct=noise_pct),
892
        ]
893
    report = "\n".join(lines) + "\n"
894
    (out / "verdict.md").write_text(report)
895
    print()
896
    print(report)
897
    return 0 if verdict == "PASS" else (2 if verdict == "UNMEASURED" else 1)
898
899
900
if __name__ == "__main__":
901
    raise SystemExit(main())