Viewing files at commit 46ad54da0178Commit detailsView latest

Browse files
scripts/oakbench/baseline_book.py 393 lines · 15.0 KB · python
1
"""Git Baseline Book and target loading.
2
3
Baseline Books are compact committed summaries (ADR-0006). Targets are the
4
red/green contract for scorecards (ADR-0008). Both are JSON here so the
5
harness stays stdlib-only.
6
"""
7
8
from __future__ import annotations
9
10
import json
11
import hashlib
12
from dataclasses import dataclass
13
from datetime import datetime, timezone
14
from pathlib import Path
15
from typing import Any
16
17
from oakbench.rows import number_or_none, output_bytes, row_returncode, token_total, tool_calls
18
from oakbench.stats import bootstrap_ci, named_statistic, validate_interval_settings
19
from oakbench.populations import comparison_identity, independent_run_count, population_conflicts
20
21
22
@dataclass(frozen=True)
23
class BaselineCell:
24
    lane: str
25
    profile: str | None
26
    scenario: str
27
    operation: str
28
    subject: str
29
    metric: str
30
    statistic: str
31
    value: float
32
    ci_low: float | None = None
33
    ci_high: float | None = None
34
    n: int | None = None
35
    noise_floor_pct: float | None = None
36
    comparison_identity: dict[str, Any] | None = None
37
    independent_n: int | None = None
38
    attempted_n: int | None = None
39
    failure_n: int | None = None
40
    skipped_n: int | None = None
41
    confidence: float | None = None
42
    # "per_cell_aa": floor measured for THIS cell from an interleaved A/A Git
43
    # control (ADR-0011); "operator_global": one CLI number for every cell.
44
    noise_floor_source: str | None = None
45
46
    @property
47
    def key(self) -> tuple[str, str | None, str, str, str, str, str]:
48
        return (self.lane, self.profile, self.scenario, self.operation, self.subject, self.metric, self.statistic)
49
50
51
@dataclass(frozen=True)
52
class Target:
53
    target_id: str
54
    lane: str
55
    profile: str | None
56
    scenario: str
57
    operation: str
58
    subject: str
59
    comparator: str
60
    metric: str
61
    statistic: str
62
    goal_type: str
63
    goal_value: float
64
    guards: dict[str, Any]
65
66
    @property
67
    def baseline_key(self) -> tuple[str, str | None, str, str, str, str, str]:
68
        return (self.lane, self.profile, self.scenario, self.operation, self.comparator, self.metric, self.statistic)
69
70
71
def load_baseline_book(path: Path) -> dict[tuple[str, str | None, str, str, str, str, str], BaselineCell]:
72
    raw = json.loads(path.read_text())
73
    if raw.get("schema_version") not in (1, 2):
74
        raise ValueError(f"unsupported baseline book schema_version: {raw.get('schema_version')!r}")
75
    cells = raw.get("cells")
76
    if not isinstance(cells, list):
77
        raise ValueError("baseline book requires list-valued 'cells'")
78
    parsed = [_parse_cell(cell) for cell in cells]
79
    result = {}
80
    for cell in parsed:
81
        if cell.key in result:
82
            raise ValueError(f"duplicate baseline cell: {cell.key}")
83
        result[cell.key] = cell
84
    return result
85
86
87
def _parse_cell(cell: dict[str, Any]) -> BaselineCell:
88
    required = ("lane", "scenario", "operation", "subject", "metric", "statistic", "value")
89
    missing = [key for key in required if key not in cell]
90
    if missing:
91
        raise ValueError(f"baseline cell missing keys: {', '.join(missing)}")
92
    identity = cell.get("comparison_identity")
93
    if identity is not None and (not isinstance(identity, dict) or identity.get("schema_version") != 1):
94
        raise ValueError("unsupported baseline comparison_identity")
95
    return BaselineCell(
96
        lane=str(cell["lane"]),
97
        profile=None if cell.get("profile") is None else str(cell.get("profile")),
98
        scenario=str(cell["scenario"]),
99
        operation=str(cell["operation"]),
100
        subject=str(cell["subject"]),
101
        metric=str(cell["metric"]),
102
        statistic=str(cell["statistic"]),
103
        value=float(cell["value"]),
104
        ci_low=_optional_float(cell.get("ci_low")),
105
        ci_high=_optional_float(cell.get("ci_high")),
106
        n=_optional_int(cell.get("n")),
107
        noise_floor_pct=_optional_float(cell.get("noise_floor_pct")),
108
        comparison_identity=identity,
109
        independent_n=_optional_int(cell.get("independent_n")),
110
        attempted_n=_optional_int(cell.get("attempted_n")),
111
        failure_n=_optional_int(cell.get("failure_n")),
112
        skipped_n=_optional_int(cell.get("skipped_n")),
113
        confidence=_optional_float(cell.get("confidence")),
114
        noise_floor_source=cell.get("noise_floor_source") if isinstance(cell.get("noise_floor_source"), str) else None,
115
    )
116
117
118
def load_targets(path: Path) -> list[Target]:
119
    raw = json.loads(path.read_text())
120
    if raw.get("schema_version") != 1:
121
        raise ValueError(f"unsupported targets schema_version: {raw.get('schema_version')!r}")
122
    targets = raw.get("targets")
123
    if not isinstance(targets, list):
124
        raise ValueError("targets config requires list-valued 'targets'")
125
    return [_parse_target(target) for target in targets]
126
127
128
def _parse_target(target: dict[str, Any]) -> Target:
129
    required = ("id", "lane", "scenario", "operation", "subject", "comparator", "metric", "goal")
130
    missing = [key for key in required if key not in target]
131
    if missing:
132
        raise ValueError(f"target missing keys: {', '.join(missing)}")
133
    goal = target["goal"]
134
    if not isinstance(goal, dict):
135
        raise ValueError(f"target {target.get('id')!r} goal must be an object")
136
    if "type" not in goal:
137
        raise ValueError(f"target {target.get('id')!r} goal requires a 'type' field")
138
    goal_type = str(goal["type"])
139
    if goal_type != "ratio_at_most":
140
        raise ValueError(f"target {target.get('id')!r} unsupported goal type {goal_type!r}")
141
    if number_or_none(goal.get("value")) is None:
142
        raise ValueError(f"target {target.get('id')!r} goal requires a numeric 'value' field")
143
    guards = target.get("guards", {})
144
    if not isinstance(guards, dict):
145
        raise ValueError(f"target {target.get('id')!r} guards must be an object")
146
    return Target(
147
        target_id=str(target["id"]),
148
        lane=str(target["lane"]),
149
        profile=None if target.get("profile") is None else str(target.get("profile")),
150
        scenario=str(target["scenario"]),
151
        operation=str(target["operation"]),
152
        subject=str(target["subject"]),
153
        comparator=str(target["comparator"]),
154
        metric=str(target["metric"]),
155
        statistic=str(target.get("statistic", "median")),
156
        goal_type=goal_type,
157
        goal_value=float(goal["value"]),
158
        guards=guards,
159
    )
160
161
162
def _optional_float(value: Any) -> float | None:
163
    if value is None:
164
        return None
165
    return float(value)
166
167
168
def _optional_int(value: Any) -> int | None:
169
    if value is None:
170
        return None
171
    if isinstance(value, bool):
172
        raise ValueError("boolean is not an integer")
173
    return int(value)
174
175
176
DEFAULT_BASELINE_METRICS = ("elapsed_ms", "tokens", "tool_calls", "output_bytes")
177
DEFAULT_BASELINE_STATISTICS = ("median", "mean")
178
SUBOP_SUFFIXES = (".add", ".commit")
179
180
181
def iter_jsonl(paths: list[Path]) -> list[dict[str, Any]]:
182
    rows: list[dict[str, Any]] = []
183
    for path in paths:
184
        with path.open() as fh:
185
            for raw_line in fh:
186
                line = raw_line.strip()
187
                if line:
188
                    rows.append(json.loads(line))
189
    return rows
190
191
192
def metric_value(row: dict[str, Any], metric: str) -> float | None:
193
    if metric == "elapsed_ms":
194
        return number_or_none(row.get("elapsed_ms"))
195
    if metric == "tokens":
196
        return token_total(row)
197
    if metric == "tool_calls":
198
        return tool_calls(row)
199
    if metric == "output_bytes":
200
        return output_bytes(row)
201
    return number_or_none(row.get(metric))
202
203
204
def cells_from_rows(
205
    rows: list[dict[str, Any]],
206
    *,
207
    lane: str,
208
    metrics: tuple[str, ...] = DEFAULT_BASELINE_METRICS,
209
    statistics_names: tuple[str, ...] = DEFAULT_BASELINE_STATISTICS,
210
    subjects: set[str] | None = None,
211
    include_subops: bool = False,
212
    bootstrap_samples: int = 2000,
213
    confidence: float = 0.95,
214
    seed: int = 20260609,
215
    noise_floor_pct: float | None = None,
216
    aa_control_subject: str | None = "git_aa",
217
) -> list[BaselineCell]:
218
    validate_interval_settings(bootstrap_samples, confidence)
219
    from oakbench import cellstats
220
    aa_samples: dict[tuple[str | None, str, str, str], dict[int, float]] = {}
221
    grouped: dict[tuple[str | None, str, str, str, str], list[float]] = {}
222
    observations = {}
223
    outcomes = {}
224
    for row in rows:
225
        if str(row.get("subject_kind") or "") != "git":
226
            continue
227
        subject = str(row.get("subject") or "")
228
        if aa_control_subject and subject == aa_control_subject:
229
            # The A/A control only feeds per-cell floors; it is never a book cell.
230
            if row_returncode(row) == 0 and isinstance(row.get("elapsed_ms"), (int, float)):
231
                key = (None if row.get("profile") is None else str(row.get("profile")),
232
                       str(row.get("scenario") or ""), str(row.get("operation") or ""), "elapsed_ms")
233
                aa_samples.setdefault(key, {})[len(aa_samples.get(key, {}))
234
                                                if type(row.get("run")) is not int else row["run"]] = float(row["elapsed_ms"])
235
            continue
236
        if subjects is not None and subject not in subjects:
237
            continue
238
        operation = str(row.get("operation") or "")
239
        if not include_subops and operation.endswith(SUBOP_SUFFIXES):
240
            continue
241
        profile = None if row.get("profile") is None else str(row.get("profile"))
242
        scenario = str(row.get("scenario") or "")
243
        outcomes.setdefault((profile, scenario, operation, subject), []).append(row)
244
        if row_returncode(row) != 0:
245
            continue
246
        for metric in metrics:
247
            value = metric_value(row, metric)
248
            if value is None:
249
                continue
250
            key = (profile, scenario, operation, subject, metric)
251
            grouped.setdefault(key, []).append(float(value))
252
            observations.setdefault(key, []).append(row)
253
254
    cells: list[BaselineCell] = []
255
    for (profile, scenario, operation, subject, metric), values in sorted(grouped.items()):
256
        observed = observations[(profile, scenario, operation, subject, metric)]
257
        all_rows = outcomes[(profile, scenario, operation, subject)]
258
        conflicts = population_conflicts(all_rows)
259
        if conflicts:
260
            raise ValueError(f"incompatible baseline population {scenario}/{operation}: {', '.join(conflicts)}")
261
        cell_floor = noise_floor_pct
262
        floor_source = "operator_global" if noise_floor_pct is not None else None
263
        if aa_control_subject and subject == "git" and metric == "elapsed_ms":
264
            base = {(row.get("run") if type(row.get("run")) is int else index): float(value)
265
                    for index, (row, value) in enumerate(zip(observed, values))}
266
            per_cell = cellstats.aa_floor_pct(base, aa_samples.get((profile, scenario, operation, metric), {}))
267
            if per_cell is not None:
268
                cell_floor = max(per_cell, noise_floor_pct or 0.0)
269
                floor_source = "per_cell_aa"
270
        for statistic_name in statistics_names:
271
            stat = named_statistic(statistic_name)
272
            ci_low, ci_high = bootstrap_ci(
273
                values,
274
                statistic=statistic_name,
275
                samples=bootstrap_samples,
276
                confidence=confidence,
277
                seed=seed,
278
            )
279
            cells.append(
280
                BaselineCell(
281
                    lane=lane,
282
                    profile=profile,
283
                    scenario=scenario,
284
                    operation=operation,
285
                    subject=subject,
286
                    metric=metric,
287
                    statistic=statistic_name,
288
                    value=float(stat(values)),
289
                    ci_low=ci_low,
290
                    ci_high=ci_high,
291
                    n=len(values),
292
                    noise_floor_pct=cell_floor,
293
                    noise_floor_source=floor_source,
294
                    comparison_identity=comparison_identity(observed[0]),
295
                    independent_n=independent_run_count(observed),
296
                    attempted_n=len(all_rows),
297
                    failure_n=sum(row_returncode(row) not in (0, 77) for row in all_rows),
298
                    skipped_n=sum(row_returncode(row) == 77 for row in all_rows),
299
                    confidence=confidence,
300
                )
301
            )
302
    return cells
303
304
305
def book_json(cells: list[BaselineCell]) -> dict[str, Any]:
306
    return {
307
        "schema_version": 2,
308
        "cells": [
309
            {
310
                "lane": cell.lane,
311
                "profile": cell.profile,
312
                "scenario": cell.scenario,
313
                "operation": cell.operation,
314
                "subject": cell.subject,
315
                "metric": cell.metric,
316
                "statistic": cell.statistic,
317
                "value": cell.value,
318
                "ci_low": cell.ci_low,
319
                "ci_high": cell.ci_high,
320
                "n": cell.n,
321
                "noise_floor_pct": cell.noise_floor_pct,
322
                "noise_floor_source": cell.noise_floor_source,
323
                "comparison_identity": cell.comparison_identity,
324
                "independent_n": cell.independent_n,
325
                "attempted_n": cell.attempted_n,
326
                "failure_n": cell.failure_n,
327
                "skipped_n": cell.skipped_n,
328
                "confidence": cell.confidence,
329
            }
330
            for cell in cells
331
        ],
332
    }
333
334
335
def manifest_json(
336
    *,
337
    book_id: str,
338
    row_paths: list[Path],
339
    runner_class: str | None,
340
    runner_id: str | None = None,
341
    runner_profile: dict[str, Any] | None = None,
342
    git_version: str | None = None,
343
    git_binary_sha256: str | None = None,
344
    raw_storage_keys: list[str] | None = None,
345
    suite_hash: str | None = None,
346
) -> dict[str, Any]:
347
    return {
348
        "schema_version": 1,
349
        "book_id": book_id,
350
        "generated_at_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
351
        "suite_hash": suite_hash,
352
        "runner_id": runner_id,
353
        "runner_class": runner_class,
354
        "runner_profile": runner_profile,
355
        "git_version": git_version,
356
        "git_binary_sha256": git_binary_sha256,
357
        "raw_storage_keys": raw_storage_keys or [],
358
        "raw_inputs": [
359
            {
360
                "path": str(path),
361
                "sha256": sha256_file(path),
362
                "bytes": path.stat().st_size,
363
            }
364
            for path in row_paths
365
        ],
366
    }
367
368
369
def book_markdown(book_id: str, cells: list[BaselineCell]) -> str:
370
    lines = [
371
        f"# Git Baseline Book: `{book_id}`",
372
        "",
373
        "| Subject | Scenario / operation | Metric | Statistic | n | Value | CI |",
374
        "| --- | --- | --- | --- | ---: | ---: | --- |",
375
    ]
376
    for cell in cells:
377
        ci = ""
378
        if cell.ci_low is not None and cell.ci_high is not None:
379
            ci = f"[{cell.ci_low:.3f}, {cell.ci_high:.3f}]"
380
        lines.append(
381
            f"| `{cell.subject}` | `{cell.scenario}/{cell.operation}` | `{cell.metric}` | "
382
            f"`{cell.statistic}` | {cell.n or 0} | {cell.value:.3f} | {ci} |"
383
        )
384
    lines.append("")
385
    return "\n".join(lines)
386
387
388
def sha256_file(path: Path) -> str:
389
    digest = hashlib.sha256()
390
    with path.open("rb") as fh:
391
        for chunk in iter(lambda: fh.read(1024 * 1024), b""):
392
            digest.update(chunk)
393
    return digest.hexdigest()