Skip to content

Commit 87a747c

Browse files
fix(bench): gate all baseline benchmarks and validate finite ratios
1 parent fcda8ed commit 87a747c

12 files changed

Lines changed: 239 additions & 63 deletions

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ jobs:
115115
# exercise Flask routes via app.test_client(). Only listed files — not
116116
# `pytest tests/` — to avoid re-collecting unittest.TestCase classes above.
117117
# -o addopts= avoids inheriting benchmark-only options from pyproject.toml.
118-
run: python -m pytest tests/test_api_search.py tests/test_api_workspaces.py tests/test_api_export.py tests/test_pdf_export.py tests/test_search_helpers.py tests/test_check_benchmark_regression.py -v --tb=short -o addopts=
118+
run: python -m pytest tests/test_api_search.py tests/test_api_workspaces.py tests/test_api_export.py tests/test_pdf_export.py tests/test_search_helpers.py tests/test_check_benchmark_regression.py tests/test_reduce_baselines.py -v --tb=short -o addopts=
119119

120120
# ── PyInstaller desktop build (Windows only, once per workflow) ────────
121121
# Closes #44. Builds the onedir bundle and smoke-tests --help so the

Makefile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
seed-baselines-local:
77
@echo "WARNING: seed-baselines-local uses this host's timings; CI gates on ubuntu-latest." >&2
88
python -m pytest tests/benchmarks/ --benchmark-only --benchmark-json=benchmarks/_raw.json -o addopts=
9-
python scripts/reduce_baselines.py benchmarks/_raw.json benchmarks/baselines.json --slack 1.5
9+
python scripts/reduce_baselines.py benchmarks/_raw.json benchmarks/baselines.json --slack 1.5 --source local
1010

1111
# Deprecated alias — kept for muscle memory; see seed-baselines-local warning above.
1212
update-baselines: seed-baselines-local
@@ -16,4 +16,4 @@ check-benchmarks:
1616
python scripts/check_benchmark_regression.py benchmark-results.json benchmarks/baselines.json
1717

1818
clean-benchmark-artifacts:
19-
rm -f benchmarks/_raw.json benchmark-results.json
19+
python -c "import pathlib; [p.unlink(missing_ok=True) for p in (pathlib.Path('benchmarks/_raw.json'), pathlib.Path('benchmark-results.json'))]"

benchmarks/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ The `benchmarks` job on **ubuntu-latest** runs the full `tests/benchmarks/` suit
3131
- **Fail** when a gated mean is **<50%** of baseline (stale — refresh after intentional speedups)
3232
- **Fail** when a gated baseline name has no current result
3333
- **Warn** for benchmarks without a baseline entry
34-
- **Skip gate** for `EXCLUDED_FROM_GATE` names (smallest parse corpus, full-corpus search — sub-ms CI noise)
34+
- All benchmarks listed in `baselines.json` are gated (no exclusion list)
3535

3636
Pinned runner: `ubuntu-latest`, `--benchmark-min-rounds=5`.
3737

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. Values multiplied by 1.5\u00d7 slack at generation time. Excluded from gate (recorded for reference): test_list_workspace_projects_nocache[composers-10], test_search_full_corpus. Refresh after intentional speedups via reduce_baselines.py.",
3-
"updated": "2026-06-25T21:14:16Z",
2+
"_note": "Gated means from ubuntu-latest CI benchmark-results.json. Values multiplied by 1.5\u00d7 slack at generation time. Refresh after intentional speedups via reduce_baselines.py.",
3+
"updated": "2026-06-25T21:48:35Z",
44
"machine": "Linux",
55
"groups": {
66
"parse": {

scripts/check_benchmark_regression.py

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,15 @@
44

55
import argparse
66
import json
7+
import math
78
import sys
89
from pathlib import Path
910

1011
THRESHOLD = 1.20
1112
STALE_FLOOR = 0.50
1213

13-
# Sub-ms timings are too noisy for a fixed 20% gate on ubuntu CI.
14-
EXCLUDED_FROM_GATE = frozenset(
15-
{
16-
"test_list_workspace_projects_nocache[composers-10]",
17-
"test_search_full_corpus",
18-
}
19-
)
14+
# Benchmarks gated via baselines.json; empty set means all baseline entries are checked.
15+
EXCLUDED_FROM_GATE: frozenset[str] = frozenset()
2016

2117

2218
class BenchmarkDataError(ValueError):
@@ -106,6 +102,17 @@ def load_baseline_means(baselines_path: str | Path) -> dict[str, float]:
106102
return means
107103

108104

105+
def _validate_gate_ratios(threshold: float, stale_floor: float) -> None:
106+
if not math.isfinite(threshold):
107+
raise BenchmarkDataError("threshold must be finite")
108+
if threshold <= 1:
109+
raise BenchmarkDataError("threshold must be greater than 1")
110+
if not math.isfinite(stale_floor):
111+
raise BenchmarkDataError("stale_floor must be finite")
112+
if not 0 < stale_floor < 1:
113+
raise BenchmarkDataError("stale_floor must be between 0 and 1 (exclusive)")
114+
115+
109116
def check_regression(
110117
results_path: str | Path,
111118
baselines_path: str | Path,
@@ -114,6 +121,7 @@ def check_regression(
114121
stale_floor: float = STALE_FLOOR,
115122
) -> int:
116123
"""Return 0 when within threshold; 1 when any gated benchmark regresses or is stale."""
124+
_validate_gate_ratios(threshold, stale_floor)
117125
flat = load_results(results_path)
118126
baseline_means = load_baseline_means(baselines_path)
119127

@@ -179,12 +187,6 @@ def main(argv: list[str] | None = None) -> int:
179187
help="fail when current mean is below this fraction of baseline (default: 0.50)",
180188
)
181189
args = parser.parse_args(argv)
182-
if args.threshold <= 1:
183-
print("ERROR: --threshold must be greater than 1", file=sys.stderr)
184-
return 2
185-
if not 0 < args.stale_floor < 1:
186-
print("ERROR: --stale-floor must be between 0 and 1 (exclusive)", file=sys.stderr)
187-
return 2
188190
try:
189191
return check_regression(
190192
args.results_path,

scripts/reduce_baselines.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import argparse
66
import json
7+
import math
78
import sys
89
from datetime import UTC, datetime
910
from pathlib import Path
@@ -23,6 +24,8 @@
2324

2425
def _positive_float(value: str) -> float:
2526
parsed = float(value)
27+
if not math.isfinite(parsed):
28+
raise argparse.ArgumentTypeError("slack must be a finite number")
2629
if parsed <= 0:
2730
raise argparse.ArgumentTypeError("slack must be greater than zero")
2831
return parsed
@@ -63,11 +66,23 @@ def reduce_baselines(
6366
) from exc
6467
bench_name = normalize_benchmark_name(str(raw_name))
6568
group = entry.get("group")
69+
if group is None:
70+
raise BenchmarkDataError(
71+
f"{path} benchmarks[{index}] ({bench_name!r}) missing required 'group'"
72+
)
6673
if group not in GATED_GROUPS:
67-
continue
74+
raise BenchmarkDataError(
75+
f"{path} benchmarks[{index}] ({bench_name!r}) has unknown group {group!r}; "
76+
f"expected one of {GATED_GROUPS}"
77+
)
6878
groups[group][bench_name] = mean * slack
6979

7080
excluded = ", ".join(sorted(EXCLUDED_FROM_GATE))
81+
excluded_note = (
82+
f" Excluded from gate (recorded for reference): {excluded}."
83+
if excluded
84+
else ""
85+
)
7186
slack_note = f" Values multiplied by {slack}× slack at generation time." if slack != 1.0 else ""
7287
machine_info = raw.get("machine_info")
7388
machine = machine_info.get("system") if isinstance(machine_info, dict) else None
@@ -79,8 +94,7 @@ def reduce_baselines(
7994
output: dict[str, object] = {
8095
"_note": (
8196
f"Gated means from {source_label}."
82-
f"{slack_note} "
83-
f"Excluded from gate (recorded for reference): {excluded}. "
97+
f"{slack_note}{excluded_note} "
8498
"Refresh after intentional speedups via reduce_baselines.py."
8599
),
86100
"updated": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),

tests/benchmarks/conftest.py

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@
1414
from app import create_app
1515
from services import summary_cache
1616
from services.summary_cache import fingerprint_workspace_storage
17-
18-
BENCH_SEARCH_TERM = "bench-search-token"
17+
from tests.benchmarks.constants import BENCH_SEARCH_TERM
1918

2019

2120
def make_workspace_entries(workspace_root: Path, count: int) -> list[dict[str, Any]]:
@@ -117,6 +116,26 @@ def build_bench_storage(root: Path, composer_count: int) -> dict[str, str]:
117116
}
118117

119118

119+
def _make_bench_flask_client(
120+
storage: dict[str, str],
121+
tmp_path: Path,
122+
monkeypatch: pytest.MonkeyPatch,
123+
*,
124+
state_subdir: str = ".cursor-chat-browser",
125+
) -> FlaskClient:
126+
"""Flask test client with env + export state patched for synthetic storage."""
127+
monkeypatch.setenv("WORKSPACE_PATH", storage["workspace_path"])
128+
monkeypatch.setenv("CLI_CHATS_PATH", storage["cli_chats_path"])
129+
monkeypatch.setenv("CURSOR_CHAT_BROWSER_NO_SEARCH_INDEX", "1")
130+
state_dir = tmp_path / state_subdir
131+
state_dir.mkdir()
132+
monkeypatch.setattr("api.export_api._get_state_dir", lambda: str(state_dir))
133+
app = create_app()
134+
app.config["TESTING"] = True
135+
app.config["EXCLUSION_RULES"] = []
136+
return app.test_client()
137+
138+
120139
@pytest.fixture
121140
def summary_cache_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
122141
"""Redirect summary-cache files to an isolated temp directory."""
@@ -193,13 +212,7 @@ def bench_env(
193212
@pytest.fixture
194213
def bench_client(bench_env: dict[str, str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> FlaskClient:
195214
"""Flask test client bound to synthetic bench storage."""
196-
state_dir = tmp_path / ".cursor-chat-browser"
197-
state_dir.mkdir()
198-
monkeypatch.setattr("api.export_api._get_state_dir", lambda: str(state_dir))
199-
app = create_app()
200-
app.config["TESTING"] = True
201-
app.config["EXCLUSION_RULES"] = []
202-
return app.test_client()
215+
return _make_bench_flask_client(bench_env, tmp_path, monkeypatch)
203216

204217

205218
@pytest.fixture
@@ -209,13 +222,9 @@ def bench_client_search_corpus(
209222
) -> FlaskClient:
210223
"""Flask client over a fixed 50-composer corpus for search benchmarks."""
211224
storage = build_bench_storage(tmp_path / "search_storage", 50)
212-
monkeypatch.setenv("WORKSPACE_PATH", storage["workspace_path"])
213-
monkeypatch.setenv("CLI_CHATS_PATH", storage["cli_chats_path"])
214-
monkeypatch.setenv("CURSOR_CHAT_BROWSER_NO_SEARCH_INDEX", "1")
215-
state_dir = tmp_path / ".cursor-chat-browser-search"
216-
state_dir.mkdir()
217-
monkeypatch.setattr("api.export_api._get_state_dir", lambda: str(state_dir))
218-
app = create_app()
219-
app.config["TESTING"] = True
220-
app.config["EXCLUSION_RULES"] = []
221-
return app.test_client()
225+
return _make_bench_flask_client(
226+
storage,
227+
tmp_path,
228+
monkeypatch,
229+
state_subdir=".cursor-chat-browser-search",
230+
)

tests/benchmarks/constants.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""Shared constants for benchmark corpora (importable outside conftest)."""
2+
3+
BENCH_SEARCH_TERM = "bench-search-token"

tests/benchmarks/test_search_bench.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66
from flask.testing import FlaskClient
77

8-
from tests.benchmarks.conftest import BENCH_SEARCH_TERM
8+
from tests.benchmarks.constants import BENCH_SEARCH_TERM
99

1010

1111
@pytest.mark.benchmark(group="search")

tests/benchmarks/test_summary_cache_bench.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ def _run() -> None:
7474
get_cached_projects(fp)
7575

7676
benchmark(_run)
77+
cached = get_cached_projects(fp)
78+
assert cached is not None
79+
cached_projects, cached_warnings = cached
80+
assert cached_projects == projects
81+
assert cached_warnings == []
7782

7883

7984
@pytest.mark.benchmark(group="summary-cache")

0 commit comments

Comments
 (0)