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