Skip to content

Commit b4bf963

Browse files
fix: harden benchmark gate per PR #91 review
Upload benchmark-results.json even when the gate fails; add BenchmarkDataError handling to reduce_baselines; require explicit groups key; expose --threshold CLI flag; add reduce_baselines and boundary tests; clarify README.
1 parent 2482951 commit b4bf963

7 files changed

Lines changed: 181 additions & 16 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ jobs:
239239
run: python scripts/check_benchmark_regression.py benchmark-results.json benchmarks/baselines.json
240240

241241
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
242+
if: always()
242243
with:
243244
name: benchmark-results
244245
path: benchmark-results.json

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: update-baselines check-benchmarks
1+
.PHONY: update-baselines check-benchmarks clean-benchmark-artifacts
22

33
update-baselines:
44
pytest tests/benchmarks/ --benchmark-only --benchmark-json=benchmarks/_raw.json -o addopts=
@@ -7,3 +7,6 @@ update-baselines:
77
check-benchmarks:
88
pytest tests/benchmarks/ --benchmark-only --benchmark-json=benchmark-results.json -o addopts=
99
python scripts/check_benchmark_regression.py benchmark-results.json benchmarks/baselines.json
10+
11+
clean-benchmark-artifacts:
12+
rm -f benchmarks/_raw.json benchmark-results.json

benchmarks/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,4 @@ pytest tests/benchmarks/ --benchmark-only --benchmark-json=benchmarks/_raw.json
5353
python scripts/reduce_baselines.py benchmarks/_raw.json benchmarks/baselines.json
5454
```
5555

56-
Do not capture baselines on Windows/macOS for commit — ubuntu runners are slower and the gate will fail. Download `benchmark-results.json` from a green CI artifact to seed baselines if needed.
56+
Baselines must be captured on **ubuntu-latest** to match the gated CI runner. Cross-OS variance causes spurious failures. Download `benchmark-results.json` from a CI artifact to seed baselines if needed.

scripts/check_benchmark_regression.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import argparse
56
import json
67
import sys
78
from pathlib import Path
@@ -50,9 +51,11 @@ def load_baseline_means(baselines_path: str | Path) -> dict[str, float]:
5051
if not isinstance(data, dict):
5152
raise BenchmarkDataError(f"{path} root value must be an object")
5253

53-
groups = data.get("groups", data)
54+
if "groups" not in data:
55+
raise BenchmarkDataError(f"{path} missing required 'groups' key")
56+
groups = data["groups"]
5457
if not isinstance(groups, dict):
55-
raise BenchmarkDataError(f"{path} missing 'groups' object")
58+
raise BenchmarkDataError(f"{path} 'groups' must be an object")
5659

5760
means: dict[str, float] = {}
5861
for group_name, value in groups.items():
@@ -104,14 +107,25 @@ def check_regression(
104107

105108

106109
def main(argv: list[str] | None = None) -> int:
107-
argv = sys.argv[1:] if argv is None else argv
108-
if len(argv) != 2:
109-
print(
110-
"usage: check_benchmark_regression.py <results.json> <baselines.json>",
111-
file=sys.stderr,
110+
parser = argparse.ArgumentParser(description=__doc__)
111+
parser.add_argument("results_path", help="pytest-benchmark --benchmark-json output")
112+
parser.add_argument("baselines_path", help="path to benchmarks/baselines.json")
113+
parser.add_argument(
114+
"--threshold",
115+
type=float,
116+
default=THRESHOLD,
117+
help="fail when current mean exceeds baseline by more than this ratio (default: 1.20)",
118+
)
119+
args = parser.parse_args(argv)
120+
try:
121+
return check_regression(
122+
args.results_path,
123+
args.baselines_path,
124+
threshold=args.threshold,
112125
)
126+
except BenchmarkDataError as exc:
127+
print(f"ERROR: {exc}", file=sys.stderr)
113128
return 2
114-
return check_regression(argv[0], argv[1])
115129

116130

117131
if __name__ == "__main__":

scripts/reduce_baselines.py

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from datetime import UTC, datetime
99
from pathlib import Path
1010

11+
from scripts.check_benchmark_regression import BenchmarkDataError
12+
1113
GATED_GROUPS = ("parse", "export", "search")
1214

1315

@@ -24,13 +26,34 @@ def reduce_baselines(
2426
*,
2527
slack: float = 1.0,
2628
) -> dict[str, object]:
27-
raw = json.loads(Path(raw_path).read_text(encoding="utf-8"))
29+
path = Path(raw_path)
30+
try:
31+
raw = json.loads(path.read_text(encoding="utf-8"))
32+
except json.JSONDecodeError as exc:
33+
raise BenchmarkDataError(f"invalid JSON in {path}: {exc}") from exc
34+
35+
try:
36+
entries = raw["benchmarks"]
37+
except (KeyError, TypeError) as exc:
38+
raise BenchmarkDataError(f"{path} missing top-level 'benchmarks' array") from exc
39+
if not isinstance(entries, list):
40+
raise BenchmarkDataError(f"{path} 'benchmarks' must be an array")
41+
2842
groups: dict[str, dict[str, float]] = {group: {} for group in GATED_GROUPS}
29-
for entry in raw["benchmarks"]:
43+
for index, entry in enumerate(entries):
44+
if not isinstance(entry, dict):
45+
raise BenchmarkDataError(f"{path} benchmarks[{index}] must be an object")
46+
try:
47+
name = entry["name"]
48+
mean = float(entry["stats"]["mean"])
49+
except (KeyError, TypeError, ValueError) as exc:
50+
raise BenchmarkDataError(
51+
f"{path} benchmarks[{index}] missing 'name' or 'stats.mean'"
52+
) from exc
3053
group = entry.get("group")
3154
if group not in GATED_GROUPS:
3255
continue
33-
groups[group][entry["name"]] = float(entry["stats"]["mean"]) * slack
56+
groups[group][str(name)] = mean * slack
3457

3558
machine_info = raw.get("machine_info", {})
3659
output: dict[str, object] = {
@@ -39,8 +62,8 @@ def reduce_baselines(
3962
"machine": machine_info.get("system"),
4063
"groups": groups,
4164
}
42-
path = Path(out_path)
43-
path.write_text(json.dumps(output, indent=2) + "\n", encoding="utf-8")
65+
out = Path(out_path)
66+
out.write_text(json.dumps(output, indent=2) + "\n", encoding="utf-8")
4467
return output
4568

4669

@@ -55,7 +78,11 @@ def main(argv: list[str] | None = None) -> int:
5578
help="multiply means by this factor (must be > 0)",
5679
)
5780
args = parser.parse_args(argv)
58-
reduce_baselines(args.raw_path, args.out_path, slack=args.slack)
81+
try:
82+
reduce_baselines(args.raw_path, args.out_path, slack=args.slack)
83+
except BenchmarkDataError as exc:
84+
print(f"ERROR: {exc}", file=sys.stderr)
85+
return 2
5986
return 0
6087

6188

tests/test_check_benchmark_regression.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,45 @@ def test_zero_baseline_skips_ratio_check(tmp_path, capsys: pytest.CaptureFixture
105105

106106
assert check_regression(results, baselines) == 0
107107
assert "baseline for 'test_parse_session_small' is zero" in capsys.readouterr().out
108+
109+
110+
def test_exactly_at_threshold_passes(tmp_path) -> None:
111+
results = tmp_path / "results.json"
112+
baselines = tmp_path / "baselines.json"
113+
_write_results(
114+
results,
115+
[{"name": "test_parse_session_small", "stats": {"mean": 0.00012}}],
116+
)
117+
_write_baselines(
118+
baselines,
119+
{"parse": {"test_parse_session_small": 0.0001}},
120+
)
121+
122+
assert check_regression(results, baselines) == 0
123+
124+
125+
def test_missing_current_result_warns_without_failing(
126+
tmp_path, capsys: pytest.CaptureFixture[str]
127+
) -> None:
128+
results = tmp_path / "results.json"
129+
baselines = tmp_path / "baselines.json"
130+
_write_results(results, [])
131+
_write_baselines(
132+
baselines,
133+
{"parse": {"test_parse_session_small": 0.0001}},
134+
)
135+
136+
assert check_regression(results, baselines) == 0
137+
assert "no current result for baseline" in capsys.readouterr().out
138+
139+
140+
def test_main_reports_benchmark_data_error(tmp_path, capsys: pytest.CaptureFixture[str]) -> None:
141+
from scripts.check_benchmark_regression import main
142+
143+
bad = tmp_path / "bad.json"
144+
bad.write_text("{}", encoding="utf-8")
145+
baselines = tmp_path / "baselines.json"
146+
_write_baselines(baselines, {"parse": {"test_parse_session_small": 0.0001}})
147+
148+
assert main([str(bad), str(baselines)]) == 2
149+
assert "ERROR:" in capsys.readouterr().err

tests/test_reduce_baselines.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Tests for scripts/reduce_baselines.py."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
7+
import pytest
8+
9+
from scripts.check_benchmark_regression import BenchmarkDataError
10+
from scripts.reduce_baselines import reduce_baselines
11+
12+
13+
def _write_raw(path, benchmarks: list[dict], *, machine: str = "Linux") -> None:
14+
path.write_text(
15+
json.dumps(
16+
{
17+
"machine_info": {"system": machine},
18+
"benchmarks": benchmarks,
19+
},
20+
indent=2,
21+
),
22+
encoding="utf-8",
23+
)
24+
25+
26+
def test_reduce_baselines_writes_gated_groups_only(tmp_path) -> None:
27+
raw = tmp_path / "raw.json"
28+
out = tmp_path / "baselines.json"
29+
_write_raw(
30+
raw,
31+
[
32+
{"group": "parse", "name": "test_parse_session_small", "stats": {"mean": 0.0001}},
33+
{"group": "cache", "name": "test_cache_warm_hit", "stats": {"mean": 1e-05}},
34+
],
35+
)
36+
37+
output = reduce_baselines(raw, out)
38+
39+
assert output["machine"] == "Linux"
40+
assert "test_parse_session_small" in output["groups"]["parse"]
41+
assert "cache" not in output["groups"]
42+
written = json.loads(out.read_text(encoding="utf-8"))
43+
assert "test_cache_warm_hit" not in written["groups"]["parse"]
44+
45+
46+
def test_reduce_baselines_applies_slack(tmp_path) -> None:
47+
raw = tmp_path / "raw.json"
48+
out = tmp_path / "baselines.json"
49+
_write_raw(
50+
raw,
51+
[{"group": "search", "name": "test_search_full_corpus", "stats": {"mean": 0.002}}],
52+
)
53+
54+
reduce_baselines(raw, out, slack=1.5)
55+
data = json.loads(out.read_text(encoding="utf-8"))
56+
57+
assert data["groups"]["search"]["test_search_full_corpus"] == pytest.approx(0.003)
58+
59+
60+
def test_reduce_baselines_rejects_missing_benchmarks_key(tmp_path) -> None:
61+
raw = tmp_path / "raw.json"
62+
raw.write_text("{}", encoding="utf-8")
63+
64+
with pytest.raises(BenchmarkDataError, match="'benchmarks' array"):
65+
reduce_baselines(raw, tmp_path / "out.json")
66+
67+
68+
def test_reduce_baselines_cli_rejects_non_positive_slack(tmp_path) -> None:
69+
from scripts.reduce_baselines import main
70+
71+
raw = tmp_path / "raw.json"
72+
_write_raw(
73+
raw,
74+
[{"group": "parse", "name": "test_parse_session_small", "stats": {"mean": 0.0001}}],
75+
)
76+
77+
with pytest.raises(SystemExit):
78+
main([str(raw), str(tmp_path / "out.json"), "--slack", "0"])

0 commit comments

Comments
 (0)