Viewing files at commit dc75e12cb299Commit detailsView latest

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