Skip to content

Commit bfa0e43

Browse files
fix(test): address PR #120 benchmark review (gate, CI, baselines)
1 parent ffe8eeb commit bfa0e43

8 files changed

Lines changed: 241 additions & 21 deletions

File tree

.github/workflows/tests.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ jobs:
105105
run: |
106106
python -m pip install --upgrade pip
107107
python -m pip install -r requirements-lock.txt
108-
python -m pip install 'pytest>=8,<9' 'hypothesis>=6.100,<7'
108+
python -m pip install 'pytest>=8,<9' 'pytest-benchmark==4.0.0' 'hypothesis>=6.100,<7'
109109
110110
- name: Run unittest suite
111111
run: python -m unittest discover tests -v
@@ -218,6 +218,7 @@ jobs:
218218
# ── Performance benchmarks: summary cache (issue #7) ───────────────────────
219219
benchmarks:
220220
name: Performance benchmarks (gated)
221+
needs: [unittest]
221222
runs-on: ubuntu-latest
222223
steps:
223224
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -233,7 +234,7 @@ jobs:
233234
run: |
234235
python -m pip install --upgrade pip
235236
python -m pip install -r requirements-lock.txt
236-
python -m pip install 'pytest>=8,<9' 'pytest-benchmark>=4,<5'
237+
python -m pip install 'pytest>=8,<9' 'pytest-benchmark==4.0.0'
237238
238239
- name: Run summary-cache benchmarks
239240
run: >

benchmarks/baselines.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"_note": "Gated means from ubuntu-latest CI benchmark-results.json (PR #7 summary-cache benchmarks). Refresh via: pytest tests/benchmarks/ --benchmark-only --benchmark-json=benchmark-results.json -o addopts= then update these values.",
3-
"updated": "2026-06-25T12:00:00Z",
2+
"_note": "Gated means from ubuntu-latest CI benchmark-results.json (PR #120, run 28123677675). Refresh: pytest tests/benchmarks/ --benchmark-only --benchmark-json=benchmark-results.json -o addopts=",
3+
"updated": "2026-06-24T19:20:27Z",
44
"machine": "Linux",
55
"groups": {
66
"summary-cache": {

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ dev = [
3737
]
3838

3939
[tool.pytest.ini_options]
40+
pythonpath = ["."]
41+
addopts = "--benchmark-disable"
4042
testpaths = ["tests"]
4143
markers = [
4244
"benchmark: performance benchmarks (pytest-benchmark)",

scripts/check_benchmark_regression.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ class BenchmarkDataError(ValueError):
1414
"""Raised when benchmark JSON input is malformed or missing required fields."""
1515

1616

17+
def normalize_benchmark_name(name: str) -> str:
18+
"""Strip pytest node prefix so baselines match short or full benchmark names."""
19+
return str(name).rsplit("::", 1)[-1]
20+
21+
1722
def load_results(results_path: str | Path) -> dict[str, float]:
1823
path = Path(results_path)
1924
try:
@@ -34,13 +39,13 @@ def load_results(results_path: str | Path) -> dict[str, float]:
3439
if not isinstance(entry, dict):
3540
raise BenchmarkDataError(f"{path} benchmarks[{index}] must be an object")
3641
try:
37-
name = entry["name"]
42+
raw_name = entry["name"]
3843
mean = float(entry["stats"]["mean"])
3944
except (KeyError, TypeError, ValueError) as exc:
4045
raise BenchmarkDataError(
4146
f"{path} benchmarks[{index}] missing 'name' or 'stats.mean'"
4247
) from exc
43-
name = str(name)
48+
name = normalize_benchmark_name(str(raw_name))
4449
if name in results:
4550
raise BenchmarkDataError(f"{path} duplicate benchmark name {name!r}")
4651
results[name] = mean
@@ -67,13 +72,17 @@ def load_baseline_means(baselines_path: str | Path) -> dict[str, float]:
6772
means: dict[str, float] = {}
6873
for group_name, value in groups.items():
6974
if not isinstance(value, dict):
70-
continue
75+
raise BenchmarkDataError(
76+
f"{path} groups[{group_name!r}] must be an object of benchmark means"
77+
)
7178
for name, mean in value.items():
72-
name = str(name)
73-
if name in means:
74-
raise BenchmarkDataError(f"{path} duplicate benchmark name {name!r} across groups")
79+
bench_name = normalize_benchmark_name(str(name))
80+
if bench_name in means:
81+
raise BenchmarkDataError(
82+
f"{path} duplicate benchmark name {bench_name!r} across groups"
83+
)
7584
try:
76-
means[name] = float(mean)
85+
means[bench_name] = float(mean)
7786
except (TypeError, ValueError) as exc:
7887
raise BenchmarkDataError(
7988
f"{path} groups[{group_name!r}][{name!r}] is not a numeric mean"

tests/benchmarks/__init__.py

Whitespace-only changes.

tests/benchmarks/conftest.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,13 @@
22

33
from __future__ import annotations
44

5-
import os
6-
import sys
75
from pathlib import Path
86
from typing import Any
97

108
import pytest
119

12-
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13-
if REPO_ROOT not in sys.path:
14-
sys.path.insert(0, REPO_ROOT)
15-
16-
from services import summary_cache # noqa: E402
17-
from services.summary_cache import fingerprint_workspace_storage # noqa: E402
10+
from services import summary_cache
11+
from services.summary_cache import fingerprint_workspace_storage
1812

1913

2014
def make_workspace_entries(workspace_root: Path, count: int) -> list[dict[str, Any]]:
@@ -38,7 +32,11 @@ def make_workspace_entries(workspace_root: Path, count: int) -> list[dict[str, A
3832

3933
@pytest.fixture
4034
def summary_cache_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
41-
"""Redirect summary-cache files to an isolated temp directory."""
35+
"""Redirect summary-cache files to an isolated temp directory.
36+
37+
Patches ``CACHE_DIR`` (also used by tab-summary paths via ``_tab_summaries_path``)
38+
plus the projects/composer-map file constants used by current benchmarks.
39+
"""
4240
cache_dir = tmp_path / "cache"
4341
cache_dir.mkdir()
4442
monkeypatch.setattr(summary_cache, "CACHE_DIR", cache_dir)
@@ -86,4 +84,5 @@ def workspace_fingerprint(synthetic_workspace: tuple[str, list[dict[str, Any]]])
8684

8785
@pytest.fixture
8886
def stale_fingerprint(workspace_fingerprint: dict[str, Any]) -> dict[str, Any]:
89-
return {**workspace_fingerprint, "global_db_mtime_ns": 9_999_999_999}
87+
"""Return a fingerprint guaranteed to differ from the stored one."""
88+
return {**workspace_fingerprint, "rules_digest": "deadbeefdeadbeef"}

tests/benchmarks/test_summary_cache_bench.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
set_cached_projects,
1414
)
1515

16+
1617
@pytest.mark.benchmark(group="summary-cache")
1718
def test_summary_cache_hit(
1819
benchmark,
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
"""Tests for scripts/check_benchmark_regression.py."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
7+
import pytest
8+
9+
from scripts.check_benchmark_regression import (
10+
BenchmarkDataError,
11+
check_regression,
12+
load_baseline_means,
13+
load_results,
14+
normalize_benchmark_name,
15+
)
16+
17+
GATED_BENCH = "test_summary_cache_hit"
18+
19+
20+
def _write_results(path, benchmarks: list[dict]) -> None:
21+
path.write_text(
22+
json.dumps({"benchmarks": benchmarks}, indent=2),
23+
encoding="utf-8",
24+
)
25+
26+
27+
def _write_baselines(path, groups: dict[str, dict[str, float]]) -> None:
28+
path.write_text(
29+
json.dumps({"groups": groups}, indent=2),
30+
encoding="utf-8",
31+
)
32+
33+
34+
def test_normalize_benchmark_name_strips_module_prefix() -> None:
35+
full = "tests/benchmarks/test_summary_cache_bench.py::test_summary_cache_hit"
36+
assert normalize_benchmark_name(full) == "test_summary_cache_hit"
37+
assert normalize_benchmark_name("test_summary_cache_hit") == "test_summary_cache_hit"
38+
39+
40+
def test_load_results_normalizes_full_node_id(tmp_path) -> None:
41+
path = tmp_path / "results.json"
42+
_write_results(
43+
path,
44+
[
45+
{
46+
"name": "tests/benchmarks/test_summary_cache_bench.py::test_summary_cache_hit",
47+
"stats": {"mean": 0.0001},
48+
}
49+
],
50+
)
51+
52+
assert load_results(path)["test_summary_cache_hit"] == pytest.approx(0.0001)
53+
54+
55+
def test_missing_baseline_warns_without_failing(
56+
tmp_path, capsys: pytest.CaptureFixture[str]
57+
) -> None:
58+
results = tmp_path / "results.json"
59+
baselines = tmp_path / "baselines.json"
60+
_write_results(
61+
results,
62+
[
63+
{"name": "test_new_bench", "stats": {"mean": 0.01}},
64+
{"name": GATED_BENCH, "stats": {"mean": 0.0001}},
65+
],
66+
)
67+
_write_baselines(
68+
baselines,
69+
{"summary-cache": {GATED_BENCH: 0.0001}},
70+
)
71+
72+
assert check_regression(results, baselines) == 0
73+
out = capsys.readouterr().out
74+
assert "WARN: 'test_new_bench' has no baseline yet" in out
75+
76+
77+
def test_regression_over_threshold_fails(tmp_path, capsys: pytest.CaptureFixture[str]) -> None:
78+
results = tmp_path / "results.json"
79+
baselines = tmp_path / "baselines.json"
80+
_write_results(
81+
results,
82+
[{"name": GATED_BENCH, "stats": {"mean": 0.00025}}],
83+
)
84+
_write_baselines(
85+
baselines,
86+
{"summary-cache": {GATED_BENCH: 0.0002}},
87+
)
88+
89+
assert check_regression(results, baselines) == 1
90+
out = capsys.readouterr().out
91+
assert "REGRESSION" in out
92+
93+
94+
def test_within_threshold_passes(tmp_path) -> None:
95+
results = tmp_path / "results.json"
96+
baselines = tmp_path / "baselines.json"
97+
_write_results(
98+
results,
99+
[{"name": GATED_BENCH, "stats": {"mean": 0.00022}}],
100+
)
101+
_write_baselines(
102+
baselines,
103+
{"summary-cache": {GATED_BENCH: 0.0002}},
104+
)
105+
106+
assert check_regression(results, baselines) == 0
107+
108+
109+
def test_load_results_rejects_malformed_json(tmp_path) -> None:
110+
path = tmp_path / "bad.json"
111+
path.write_text("{not json", encoding="utf-8")
112+
with pytest.raises(BenchmarkDataError, match="invalid JSON"):
113+
load_results(path)
114+
115+
116+
def test_load_results_requires_benchmarks_array(tmp_path) -> None:
117+
path = tmp_path / "results.json"
118+
path.write_text("{}", encoding="utf-8")
119+
with pytest.raises(BenchmarkDataError, match="'benchmarks' array"):
120+
load_results(path)
121+
122+
123+
def test_load_results_rejects_missing_file(tmp_path) -> None:
124+
with pytest.raises(BenchmarkDataError, match="cannot read"):
125+
load_results(tmp_path / "missing.json")
126+
127+
128+
def test_zero_baseline_skips_ratio_check(tmp_path, capsys: pytest.CaptureFixture[str]) -> None:
129+
results = tmp_path / "results.json"
130+
baselines = tmp_path / "baselines.json"
131+
_write_results(
132+
results,
133+
[{"name": GATED_BENCH, "stats": {"mean": 0.00025}}],
134+
)
135+
_write_baselines(
136+
baselines,
137+
{"summary-cache": {GATED_BENCH: 0.0}},
138+
)
139+
140+
assert check_regression(results, baselines) == 0
141+
assert f"baseline for '{GATED_BENCH}' is zero" in capsys.readouterr().out
142+
143+
144+
def test_exactly_at_threshold_passes(tmp_path) -> None:
145+
results = tmp_path / "results.json"
146+
baselines = tmp_path / "baselines.json"
147+
_write_results(
148+
results,
149+
[{"name": GATED_BENCH, "stats": {"mean": 0.00024}}],
150+
)
151+
_write_baselines(
152+
baselines,
153+
{"summary-cache": {GATED_BENCH: 0.0002}},
154+
)
155+
156+
assert check_regression(results, baselines) == 0
157+
158+
159+
def test_missing_current_result_fails(tmp_path, capsys: pytest.CaptureFixture[str]) -> None:
160+
results = tmp_path / "results.json"
161+
baselines = tmp_path / "baselines.json"
162+
_write_results(results, [])
163+
_write_baselines(
164+
baselines,
165+
{"summary-cache": {GATED_BENCH: 0.0002}},
166+
)
167+
168+
assert check_regression(results, baselines) == 1
169+
out = capsys.readouterr().out
170+
assert "MISSING" in out
171+
assert "no current result for gated baseline" in out
172+
173+
174+
def test_main_reports_benchmark_data_error(tmp_path, capsys: pytest.CaptureFixture[str]) -> None:
175+
from scripts.check_benchmark_regression import main
176+
177+
bad = tmp_path / "bad.json"
178+
bad.write_text("{}", encoding="utf-8")
179+
baselines = tmp_path / "baselines.json"
180+
_write_baselines(baselines, {"summary-cache": {GATED_BENCH: 0.0002}})
181+
182+
assert main([str(bad), str(baselines)]) == 2
183+
assert "ERROR:" in capsys.readouterr().err
184+
185+
186+
def test_duplicate_baseline_name_raises(tmp_path) -> None:
187+
baselines = tmp_path / "baselines.json"
188+
_write_baselines(
189+
baselines,
190+
{
191+
"summary-cache": {GATED_BENCH: 0.0002},
192+
"export": {GATED_BENCH: 0.0003},
193+
},
194+
)
195+
196+
with pytest.raises(BenchmarkDataError, match="duplicate benchmark name"):
197+
load_baseline_means(baselines)
198+
199+
200+
def test_load_baseline_means_rejects_non_dict_group(tmp_path) -> None:
201+
baselines = tmp_path / "baselines.json"
202+
baselines.write_text(
203+
json.dumps({"groups": {"summary-cache": "not-a-dict"}}),
204+
encoding="utf-8",
205+
)
206+
207+
with pytest.raises(BenchmarkDataError, match="must be an object"):
208+
load_baseline_means(baselines)

0 commit comments

Comments
 (0)