Viewing files at commit 1c9b819ab86bCommit detailsView latest

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