Viewing files at commit ebd567552629Commit detailsView latest

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