Viewing files at commit 2310099b696dCommit detailsView latest

scripts/devloop.py 879 lines · 39.0 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 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 same_patch_failure(left: dict[str, Any], right: dict[str, Any]) -> bool:
387
    fields = ("patch_input_sha256", "patch_expected_tree_sha256", "patch_apply_returncode",
388
              "patch_failure_reason", "patch_oracle_version", "patch_instrument_sha256",
389
              "patch_instrument_version")
390
    fingerprints = all(isinstance(left.get(field), str) and len(left[field]) == 64
391
                       for field in fields[:2])
392
    return (fingerprints and left.get("patch_apply_ok") is False and right.get("patch_apply_ok") is False
393
            and type(left.get("patch_apply_returncode")) is int
394
            and bool(left.get("patch_failure_reason")) and bool(left.get("patch_oracle_version"))
395
            and isinstance(left.get("patch_instrument_sha256"), str) and len(left["patch_instrument_sha256"]) == 64
396
            and bool(left.get("patch_instrument_version"))
397
            and all(left.get(field) == right.get(field) for field in fields))
398
399
400
def preexisting_patch_breach(breach: str, rows: list[dict[str, Any]]) -> bool:
401
    target_rows = [row for row in rows if row.get("subject") == "oak_local"
402
                   and breach.startswith(f"{breach.split(' ', 1)[0]} `{row.get('scenario')}/{row.get('operation')}`:")]
403
    if not target_rows:
404
        return False
405
    for target_row in target_rows:
406
        baseline_rows = [row for row in rows if row.get("subject") == "oak_installed"
407
                         and (row.get("scenario"), row.get("operation")) ==
408
                         (target_row.get("scenario"), target_row.get("operation"))]
409
        baseline_rows = [base for base in baseline_rows if base.get("run") == target_row.get("run")]
410
        if not baseline_rows or not all(same_patch_failure(target_row, base) for base in baseline_rows):
411
            return False
412
    return True
413
414
415
def compare_subjects(
416
    rows: list[dict[str, Any]],
417
    target: str,
418
    baseline: str,
419
    label: str,
420
    latency_threshold_pct: float,
421
    min_delta_ms: float,
422
    *,
423
    efficiency_gates: bool,
424
    output_bytes_threshold_pct: float,
425
    diagnostics: list[str] | None = None,
426
) -> tuple[list[str], list[str]]:
427
    """Returns (regressions, improvements) lines for one baseline comparison."""
428
    diagnostics = diagnostics if diagnostics is not None else []
429
    gate_ops = vcs_operations(rows)
430
    latency = median_by_key(rows, lambda row: row.get("elapsed_ms"))
431
    tools = median_by_key(rows, row_tool_calls)
432
    bytes_out = median_by_key(rows, row_output_bytes)
433
    tokens = median_by_key(rows, row_token_total)
434
    outcomes = outcome_counts_by_key(rows)
435
436
    regressions: list[str] = []
437
    improvements: list[str] = []
438
    scenario_ops = sorted(
439
        {
440
            (scenario, op)
441
            for subject, scenario, op in set(latency) | set(outcomes)
442
            if subject in {target, baseline}
443
        }
444
    )
445
    for scenario, operation in scenario_ops:
446
        if (scenario, operation) not in gate_ops or diagnostic_operation(operation):
447
            continue
448
        target_key = (target, scenario, operation)
449
        baseline_key = (baseline, scenario, operation)
450
451
        target_outcome = outcomes.get(target_key, {"success": 0, "failure": 0, "skip": 0})
452
        baseline_outcome = outcomes.get(baseline_key, {"success": 0, "failure": 0, "skip": 0})
453
        target_successes = target_outcome["success"]
454
        target_failures = target_outcome["failure"]
455
        target_skips = target_outcome["skip"]
456
        baseline_successes = baseline_outcome["success"]
457
        baseline_failures = baseline_outcome["failure"]
458
        if baseline_successes and target_successes == 0 and target_failures == 0:
459
            diagnostics.append(f"UNMEASURED `{scenario}/{operation}` vs {label}: "
460
                               f"target has no measured attempts ({target_skips} skipped)")
461
        elif baseline_successes and target_successes == 0:
462
            regressions.append(
463
                f"NO SUCCESS `{scenario}/{operation}` vs {label}: target had 0 successful run(s) "
464
                f"({target_failures} failed, {target_skips} skipped), baseline had {baseline_successes}"
465
            )
466
        else:
467
            target_attempts = target_successes + target_failures
468
            baseline_attempts = baseline_successes + baseline_failures
469
            if target_attempts and baseline_attempts:
470
                target_failure_rate = target_failures / target_attempts
471
                baseline_failure_rate = baseline_failures / baseline_attempts
472
                target_less_reliable = target_failures > baseline_failures or target_successes < baseline_successes
473
                if target_failure_rate > baseline_failure_rate and target_less_reliable:
474
                    if baseline_failures == 0:
475
                        regressions.append(
476
                            f"FAIL `{scenario}/{operation}` vs {label}: {target_failures} failed run(s), "
477
                            "baseline clean"
478
                        )
479
                    else:
480
                        regressions.append(
481
                            f"FAIL-RATE `{scenario}/{operation}` vs {label}: "
482
                            f"{baseline_failures}/{baseline_attempts} -> {target_failures}/{target_attempts} "
483
                            f"failed run(s); successes {baseline_successes} -> {target_successes}"
484
                        )
485
486
        if baseline_key not in latency:
487
            continue
488
489
        pct = slower_pct(latency[baseline_key], latency.get(target_key))
490
        delta = (latency.get(target_key) or 0) - latency[baseline_key]
491
        if pct is not None:
492
            if pct >= latency_threshold_pct and delta >= min_delta_ms:
493
                regressions.append(
494
                    f"SLOWER `{scenario}/{operation}` vs {label}: {pct:+.1f}% "
495
                    f"({latency[baseline_key]:.1f} -> {latency[target_key]:.1f} ms)"
496
                )
497
            elif pct <= -latency_threshold_pct and -delta >= min_delta_ms:
498
                improvements.append(
499
                    f"faster `{scenario}/{operation}` vs {label}: {pct:+.1f}% "
500
                    f"({latency[baseline_key]:.1f} -> {latency[target_key]:.1f} ms)"
501
                )
502
503
        if efficiency_gates:
504
            tool_pct = slower_pct(tools.get(baseline_key), tools.get(target_key))
505
            if tool_pct is not None and tool_pct > 0:
506
                regressions.append(
507
                    f"TOOLS `{scenario}/{operation}` vs {label}: {tool_pct:+.1f}% "
508
                    f"({tools[baseline_key]:.0f} -> {tools[target_key]:.0f} calls)"
509
                )
510
            bytes_pct = slower_pct(bytes_out.get(baseline_key), bytes_out.get(target_key))
511
            if bytes_pct is not None and bytes_pct > output_bytes_threshold_pct:
512
                regressions.append(
513
                    f"OUTPUT `{scenario}/{operation}` vs {label}: {bytes_pct:+.1f}% "
514
                    f"({bytes_out[baseline_key]:.0f} -> {bytes_out[target_key]:.0f} bytes)"
515
                )
516
            elif bytes_pct is not None and bytes_pct < -output_bytes_threshold_pct:
517
                improvements.append(
518
                    f"leaner `{scenario}/{operation}` vs {label}: {bytes_pct:+.1f}% output bytes"
519
                )
520
            token_pct = slower_pct(tokens.get(baseline_key), tokens.get(target_key))
521
            if token_pct is not None and token_pct < -output_bytes_threshold_pct:
522
                improvements.append(f"fewer tokens `{scenario}/{operation}` vs {label}: {token_pct:+.1f}%")
523
524
    # Output-sufficiency gates: exact semantic metrics from the inforecall
525
    # probe rows, always on. A changeset can win every token/latency gate by
526
    # dropping changed-file names from output or breaking piped-diff
527
    # structure; that is a regression, not an optimization.
528
    oracle_versions: dict[tuple[str, str, str], set[Any]] = {}
529
    for row in rows:
530
        if "information_recall" in row or "information_precision" in row:
531
            key = (str(row.get("subject")), str(row.get("scenario")), str(row.get("operation")))
532
            oracle_versions.setdefault(key, set()).add(row.get("output_oracle_version"))
533
    incompatible_output = set()
534
    for (subject, scenario, operation), versions in oracle_versions.items():
535
        if subject != target:
536
            continue
537
        baseline_versions = oracle_versions.get((baseline, scenario, operation))
538
        if baseline_versions is not None and (len(versions) != 1 or versions != baseline_versions):
539
            incompatible_output.add((scenario, operation))
540
            regressions.append(f"OUTPUT-ORACLE `{scenario}/{operation}`: incompatible output oracle versions")
541
    output_rows = [row for row in rows if (str(row.get("scenario")), str(row.get("operation"))) not in incompatible_output]
542
    recall = median_by_key(output_rows, lambda row: row.get("information_recall"))
543
    precision = median_by_key(output_rows, lambda row: row.get("information_precision"))
544
    pipe_compat = median_by_key(
545
        rows,
546
        lambda row: (
547
            1.0
548
            if row.get("pipe_compatible_unified") is True
549
            else (0.0 if row.get("pipe_compatible_unified") is False else None)
550
        ),
551
    )
552
    for (subject, scenario, operation), value in sorted(recall.items()):
553
        if subject != target:
554
            continue
555
        base_value = recall.get((baseline, scenario, operation))
556
        if base_value is not None and value < base_value - 1e-6:
557
            regressions.append(
558
                f"RECALL `{scenario}/{operation}` vs {label}: changed-file recall "
559
                f"{base_value:.3f} -> {value:.3f} (output got cheaper by losing information)"
560
            )
561
    for (subject, scenario, operation), value in sorted(pipe_compat.items()):
562
        if subject != target:
563
            continue
564
        base_value = pipe_compat.get((baseline, scenario, operation))
565
        if base_value is not None and value < base_value - 1e-6:
566
            regressions.append(
567
                f"PIPE-COMPAT `{scenario}/{operation}` vs {label}: unified-diff structure lost "
568
                f"(rate {base_value:.2f} -> {value:.2f}); this legacy metric checks structure only"
569
            )
570
571
    for (subject, scenario, operation), value in sorted(precision.items()):
572
        if subject == target:
573
            base_value = precision.get((baseline, scenario, operation))
574
            if base_value is not None and value < base_value - 1e-6:
575
                regressions.append(f"PRECISION `{scenario}/{operation}` vs {label}: {base_value:.3f} -> {value:.3f}")
576
    patch_cells = {}
577
    for item in rows:
578
        if item.get("patch_application_required") is True or "patch_apply_ok" in item:
579
            key = (str(item.get("subject")), str(item.get("scenario")), str(item.get("operation")))
580
            patch_cells.setdefault(key, []).append(item)
581
    for scenario, operation in sorted({(key[1], key[2]) for key in patch_cells if key[0] in {target, baseline}}):
582
        base_rows = patch_cells.get((baseline, scenario, operation), [])
583
        target_rows = patch_cells.get((target, scenario, operation), [])
584
        proven_baseline = any(item.get("patch_apply_ok") is True for item in base_rows)
585
        prefix = f"PATCH-APPLY `{scenario}/{operation}`"
586
        if not target_rows:
587
            if proven_baseline:
588
                regressions.append(prefix + ": target omitted previously measured patch evidence")
589
            continue
590
        for item in target_rows:
591
            if item.get("patch_apply_ok") is None:
592
                diagnostics.append("UNMEASURED " + prefix + ": " + str(item.get("patch_skip_reason") or "patch evidence unavailable"))
593
                continue
594
            instrument_rows = [item, *[base for base in base_rows if base.get("run") == item.get("run")]]
595
            if any(not isinstance(probe.get("patch_instrument_sha256"), str)
596
                   or len(probe["patch_instrument_sha256"]) != 64
597
                   or not probe.get("patch_instrument_version") for probe in instrument_rows):
598
                diagnostics.append("UNMEASURED " + prefix + ": patch instrument identity unavailable")
599
                continue
600
            versions = {base.get("patch_oracle_version") for base in base_rows}
601
            if versions and versions != {item.get("patch_oracle_version")}:
602
                regressions.append(f"PATCH-ORACLE `{scenario}/{operation}`: incompatible or missing patch oracle version")
603
            elif item.get("patch_apply_ok") is False and item.get("patch_application_required") is True:
604
                matched_rows = [base for base in base_rows if base.get("run") == item.get("run")]
605
                identical_failure = bool(matched_rows) and all(same_patch_failure(item, base) for base in matched_rows)
606
                if identical_failure:
607
                    diagnostics.append("PREEXISTING " + prefix + ": same measured defect in baseline and target")
608
                else:
609
                    regressions.append(prefix + ": exact patch application failed")
610
611
    return regressions, improvements
612
613
614
def contention_checks(rows: list[dict[str, Any]], target: str, baseline: str) -> tuple[list[str], list[str]]:
615
    regressions: list[str] = []
616
    improvements: list[str] = []
617
    totals: dict[tuple[str, str], dict[str, Any]] = {}
618
    for row in rows:
619
        if row.get("operation") != "parallel.total" or row.get("skipped"):
620
            continue
621
        totals[(str(row.get("subject")), str(row.get("scenario")))] = row
622
623
    for (subject, scenario), row in sorted(totals.items()):
624
        if subject != target:
625
            continue
626
        metrics = row.get("parallel_metrics", {})
627
        if metrics.get("integrity_check_passed") is False:
628
            regressions.append(f"INTEGRITY `{scenario}`: post-contention check failed for {target}")
629
        if (metrics.get("lost_updates_detected") or 0) > 0:
630
            regressions.append(
631
                f"LOST UPDATES `{scenario}`: {metrics['lost_updates_detected']} payload(s) lost for {target}"
632
            )
633
        base = totals.get((baseline, scenario))
634
        if base:
635
            target_tp = metrics.get("commit_throughput_per_s")
636
            base_tp = base.get("parallel_metrics", {}).get("commit_throughput_per_s")
637
            if target_tp and base_tp:
638
                pct = ((target_tp - base_tp) / base_tp) * 100.0
639
                if pct < -15.0:
640
                    regressions.append(
641
                        f"THROUGHPUT `{scenario}` vs Oak baseline: {pct:+.1f}% "
642
                        f"({base_tp:.1f} -> {target_tp:.1f} snapshots/s)"
643
                    )
644
                elif pct > 15.0:
645
                    improvements.append(f"throughput `{scenario}` vs Oak baseline: {pct:+.1f}%")
646
    return regressions, improvements
647
648
649
def main() -> int:
650
    args = parse_args()
651
    lanes = [lane.strip() for lane in args.lanes.split(",") if lane.strip()]
652
    unknown = [lane for lane in lanes if lane not in LANES]
653
    if unknown:
654
        raise SystemExit(f"unknown lanes: {', '.join(unknown)}; valid: {', '.join(LANES)}")
655
    contention_modes = _csv(args.contention_modes)
656
    contention_workers = _csv(args.contention_workers)
657
    oakbench_remotes.print_remote_preflight_warnings(
658
        oakbench_remotes.oak_remote_preflight_warnings(
659
            remote_preflight_requirements(
660
                lanes,
661
                contention_modes=contention_modes,
662
                contention_workers=contention_workers,
663
            )
664
        )
665
    )
666
667
    baseline_bin = args.oak_baseline_bin or (Path(shutil.which("oak")) if shutil.which("oak") else None)
668
    if baseline_bin is None:
669
        raise SystemExit("No Oak baseline: install oak or pass --oak-baseline-bin")
670
    if args.oak_local_bin:
671
        local_bin = args.oak_local_bin.resolve()
672
        if not local_bin.exists():
673
            raise SystemExit(f"--oak-local-bin does not exist: {local_bin}")
674
    elif args.skip_build:
675
        raise SystemExit("--skip-build requires --oak-local-bin")
676
    else:
677
        local_bin = build_oak_local(args.oak_repo)
678
679
    bench_id = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
680
    out = args.results / bench_id
681
    out.mkdir(parents=True, exist_ok=True)
682
    started = time.perf_counter()
683
684
    noise_pct = NOISE_FLOOR_MIN_PCT
685
    noise_ops = 0
686
    if not args.skip_noise_floor:
687
        noise_pct, noise_ops = measure_noise_floor(out / "aa", baseline_bin, args.noise_floor_runs)
688
    effective_oak_threshold = max(args.oak_threshold_pct, 2.0 * noise_pct)
689
    effective_git_threshold = max(args.git_threshold_pct, 2.0 * noise_pct)
690
691
    subject_flags = [
692
        "--subjects",
693
        "git,oak_installed,oak_local",
694
        "--oak-installed-bin",
695
        str(baseline_bin),
696
        "--oak-local-bin",
697
        str(local_bin),
698
    ]
699
700
    if "core" in lanes:
701
        run_lane(
702
            [
703
                sys.executable,
704
                str(SCRIPTS / "bench.py"),
705
                "--profile",
706
                "smoke",
707
                "--runs",
708
                str(args.runs),
709
                "--randomize-subject-order",
710
                *subject_flags,
711
                "--results",
712
                str(out / "core"),
713
            ],
714
            "core lane",
715
        )
716
    if "workflow" in lanes:
717
        run_lane(
718
            [
719
                sys.executable,
720
                str(SCRIPTS / "workflow_ab.py"),
721
                "--workflows",
722
                "all",
723
                "--runs",
724
                str(args.runs),
725
                "--randomize-subject-order",
726
                *subject_flags,
727
                "--results",
728
                str(out / "workflow"),
729
            ],
730
            "workflow lane",
731
        )
732
    if "contention" in lanes:
733
        run_lane(
734
            [
735
                sys.executable,
736
                str(SCRIPTS / "parallel_contention.py"),
737
                "--workers",
738
                args.contention_workers,
739
                "--commits-per-worker",
740
                str(args.contention_commits_per_worker),
741
                "--modes",
742
                args.contention_modes,
743
                *subject_flags,
744
                "--results",
745
                str(out / "contention"),
746
            ],
747
            "contention lane",
748
        )
749
750
    regressions: list[str] = []
751
    improvements: list[str] = []
752
    guardrails: list[str] = []
753
    known_gaps: list[str] = []
754
    null_notes: list[str] = []
755
    measurement_notes: list[str] = []
756
    scorecard_rows: list[dict[str, Any]] = []
757
    for lane in ("core", "workflow"):
758
        if lane not in lanes:
759
            continue
760
        rows = load_jsonl(out / lane / "latest.jsonl")
761
        scorecard_rows += rows
762
        # Per-lane embedded null control: identical-command steps (search,
763
        # read, edit, test) cannot differ between subjects, so their spread is
764
        # this lane's live noise measurement; thresholds rise to clear it.
765
        null_pct, null_ops = embedded_null_p95(rows, "oak_local", "oak_installed")
766
        lane_oak_threshold = max(effective_oak_threshold, 2.0 * null_pct)
767
        lane_git_threshold = max(effective_git_threshold, 2.0 * null_pct)
768
        if null_ops:
769
            null_notes.append(
770
                f"[{lane}] embedded null ({null_ops} identical-command ops): p95 spread {null_pct:.1f}%; "
771
                f"lane threshold {lane_oak_threshold:.1f}%"
772
            )
773
        lane_regressions, lane_improvements = compare_subjects(
774
            rows,
775
            "oak_local",
776
            "oak_installed",
777
            "Oak baseline",
778
            lane_oak_threshold,
779
            args.min_delta_ms,
780
            efficiency_gates=True,
781
            output_bytes_threshold_pct=args.output_bytes_threshold_pct,
782
            diagnostics=measurement_notes,
783
        )
784
        regressions += [f"[{lane}] {item}" for item in lane_regressions]
785
        improvements += [f"[{lane}] {item}" for item in lane_improvements]
786
787
        # Git guardrail: only breaches the CHANGESET introduced fail the
788
        # verdict. Pre-existing Oak-vs-Git gaps (also breached by the installed
789
        # baseline) are listed as known gaps — a verdict that fails every run
790
        # on inherited gaps teaches agents to ignore it (ADR-0004's lesson).
791
        local_breaches, git_improvements = compare_subjects(
792
            rows,
793
            "oak_local",
794
            "git",
795
            "Git guardrail",
796
            lane_git_threshold,
797
            max(args.min_delta_ms, 10.0),
798
            efficiency_gates=False,
799
            output_bytes_threshold_pct=args.output_bytes_threshold_pct,
800
        )
801
        baseline_breaches, _ = compare_subjects(
802
            rows,
803
            "oak_installed",
804
            "git",
805
            "Git guardrail",
806
            lane_git_threshold,
807
            max(args.min_delta_ms, 10.0),
808
            efficiency_gates=False,
809
            output_bytes_threshold_pct=args.output_bytes_threshold_pct,
810
        )
811
        preexisting = {breach.split(":")[0] for breach in baseline_breaches}
812
        for breach in local_breaches:
813
            is_patch = breach.startswith(("PATCH-APPLY ", "PATCH-ORACLE "))
814
            if breach.split(":")[0] in preexisting and (not is_patch or preexisting_patch_breach(breach, rows)):
815
                known_gaps.append(f"[{lane}] {breach}")
816
            else:
817
                guardrails.append(f"[{lane}] {breach}")
818
        improvements += [f"[{lane}] {item}" for item in git_improvements]
819
    if "contention" in lanes:
820
        rows = load_jsonl(out / "contention" / "latest.jsonl")
821
        scorecard_rows += rows
822
        lane_regressions, lane_improvements = contention_checks(rows, "oak_local", "oak_installed")
823
        regressions += [f"[contention] {item}" for item in lane_regressions]
824
        improvements += [f"[contention] {item}" for item in lane_improvements]
825
826
    elapsed_s = time.perf_counter() - started
827
    verdict = ("REGRESSED" if regressions or guardrails else
828
               "UNMEASURED" if any(note.startswith("UNMEASURED") for note in measurement_notes) else "PASS")
829
    lines = [
830
        "# Devloop Verdict",
831
        "",
832
        f"**{verdict}** — oak_local ({local_bin}) vs Oak baseline ({baseline_bin}) and Git, "
833
        f"lanes: {', '.join(lanes)}, runs={args.runs}, total {elapsed_s:.0f}s.",
834
        "",
835
        f"- Noise floor (A/A null test, {noise_ops} ops): p95 spread {noise_pct:.1f}% on identical binaries.",
836
        f"- Effective latency thresholds: Oak {effective_oak_threshold:.1f}%, Git {effective_git_threshold:.1f}% "
837
        f"(deltas below the noise floor are never claimed).",
838
        f"- Efficiency gates (exact metrics) vs Oak baseline only (ADR-0004): any tool-call increase, "
839
        f">{args.output_bytes_threshold_pct:.0f}% output bytes.",
840
        *[f"- {note}" for note in null_notes],
841
        "",
842
        f"## Regressions vs Oak baseline ({len(regressions)})",
843
        "",
844
        *([f"- {item}" for item in regressions] or ["- none"]),
845
        "",
846
        f"## New Git-guardrail breaches introduced by this changeset ({len(guardrails)})",
847
        "",
848
        *([f"- {item}" for item in guardrails] or ["- none"]),
849
        "",
850
        f"## Known Git gaps, pre-existing in the baseline ({len(known_gaps)}) — tracked, not failing",
851
        "",
852
        *([f"- {item}" for item in known_gaps] or ["- none"]),
853
        "",
854
        "## Correctness measurement notes",
855
        "",
856
        *([f"- {item}" for item in dict.fromkeys(measurement_notes)] or ["- none"]),
857
        "",
858
        f"## Improvements ({len(improvements)})",
859
        "",
860
        *([f"- {item}" for item in improvements[:20]] or ["- none"]),
861
        "",
862
        f"Raw rows: {out}/",
863
    ]
864
    if args.scorecard:
865
        lines += [
866
            "",
867
            "## Scorecard vs Git Baseline Book (informational)",
868
            "",
869
            *scorecard_lines(scorecard_rows, noise_floor_pct=noise_pct),
870
        ]
871
    report = "\n".join(lines) + "\n"
872
    (out / "verdict.md").write_text(report)
873
    print()
874
    print(report)
875
    return 0 if verdict == "PASS" else (2 if verdict == "UNMEASURED" else 1)
876
877
878
if __name__ == "__main__":
879
    raise SystemExit(main())