Skip to content

Commit c5c2066

Browse files
fix(bench): address bradjin8 review on search, cache, and baselines
1 parent 29ad5a7 commit c5c2066

7 files changed

Lines changed: 131 additions & 23 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,5 @@ coverage.xml
4646
.hypothesis/
4747
benchmark-results.json
4848
benchmarks/_raw.json
49+
benchmarks/_merged.json
4950
benchmarks/_ci/

Makefile

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
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 --source local
9+
python -c "import os, subprocess, sys; \
10+
cmd = [sys.executable, 'scripts/reduce_baselines.py', 'benchmarks/_raw.json', 'benchmarks/baselines.json', '--slack', '1.5', '--source', 'local']; \
11+
(subprocess.run(cmd, check=True), print('Updated benchmarks/baselines.json', file=sys.stderr)) if os.environ.get('FORCE') == '1' else print('Wrote benchmarks/_raw.json only. Set FORCE=1 to overwrite benchmarks/baselines.json.', file=sys.stderr)"
1012

1113
# Deprecated alias — kept for muscle memory; see seed-baselines-local warning above.
1214
update-baselines: seed-baselines-local

benchmarks/README.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,16 @@ pytest tests/benchmarks/ --benchmark-only -o addopts= -v
1717
| Group | What |
1818
|-------|------|
1919
| parse | `list_workspace_projects(..., nocache=True)` over 10 / 50 / 200 synthetic composers |
20-
| export | `POST /api/export` (ZIP) over 10 / 50 composer corpora |
21-
| search | `GET /api/search` over a 50-composer synthetic corpus |
22-
| summary-cache | cache lookup (hit/miss), fingerprint (10/50/200), round-trip, tab-summary lookup |
20+
| export | `POST /api/export` (ZIP) over 10 / 50 composer corpora (capped at 50 for CI runtime; parse goes to 200) |
21+
| search | `GET /api/search` over a 50-composer corpus**live-scan** (`test_search_full_corpus_live_scan`, `NO_SEARCH_INDEX=1`) and **FTS index** (`test_search_full_corpus_indexed`, pre-built index) |
22+
| summary-cache | projects lookup (hit/miss), composer-map lookup (hit/miss), fingerprint (10/50/200), round-trip, tab-summary lookup |
2323

2424
Synthetic corpora are built in `tests/benchmarks/conftest.py` — no real Cursor storage dependency.
2525

26+
### Adding a benchmark group
27+
28+
Every `@pytest.mark.benchmark(group="...")` name must appear in `GATED_GROUPS` inside `scripts/reduce_baselines.py`. Otherwise `reduce_baselines.py` fails at refresh time with an unknown-group error. Update both the test marker and `GATED_GROUPS` when introducing a new group.
29+
2630
## CI gate
2731

2832
The `benchmarks` job on **ubuntu-latest** runs the full `tests/benchmarks/` suite (`--benchmark-json=benchmark-results.json`), then `scripts/check_benchmark_regression.py benchmark-results.json benchmarks/baselines.json`.
@@ -35,18 +39,22 @@ The `benchmarks` job on **ubuntu-latest** runs the full `tests/benchmarks/` suit
3539

3640
Pinned runner: `ubuntu-latest`, `--benchmark-min-rounds=5`.
3741

42+
Sub-millisecond benches (e.g. `test_summary_cache_lookup`, `test_composer_map_cache_lookup`) can be high-variance on shared runners. If the gate becomes flaky, raise `--slack` for those entries or reintroduce targeted exclusions in `EXCLUDED_FROM_GATE`.
43+
3844
## Refresh baselines
3945

4046
After intentional performance work, capture on **ubuntu-latest** (same OS as the gated CI job). Download `benchmark-results.json` from a CI artifact when possible:
4147

4248
```bash
43-
python scripts/reduce_baselines.py benchmark-results.json benchmarks/baselines.json --slack 1.5
49+
python scripts/reduce_baselines.py benchmark-results.json benchmarks/baselines.json --slack 1.5 --source ubuntu-latest-ci
4450
```
4551

4652
For a quick local snapshot only (may not match CI timings):
4753

4854
```bash
4955
make seed-baselines-local
56+
# writes benchmarks/_raw.json only; does not overwrite benchmarks/baselines.json
57+
make seed-baselines-local FORCE=1 # also runs reduce_baselines into benchmarks/baselines.json
5058
```
5159

5260
`make update-baselines` is a deprecated alias for `seed-baselines-local`. Do not commit baselines from macOS/Windows unless you accept cross-OS gate skew.
@@ -56,5 +64,5 @@ make seed-baselines-local
5664
| Target | Purpose |
5765
|--------|---------|
5866
| `make check-benchmarks` | Run suite + regression gate locally |
59-
| `make seed-baselines-local` | Capture local timings into `benchmarks/baselines.json` (with slack) |
67+
| `make seed-baselines-local` | Capture local timings to `benchmarks/_raw.json` (use `FORCE=1` to update `baselines.json`) |
6068
| `make clean-benchmark-artifacts` | Remove `benchmark-results.json` and `benchmarks/_raw.json` |

benchmarks/baselines.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"_note": "Gated means from ubuntu-latest CI benchmark-results.json. Values multiplied by 1.5x slack at generation time. Refresh after intentional speedups via reduce_baselines.py.",
3-
"updated": "2026-06-25T21:48:35Z",
3+
"updated": "2026-06-25T22:57:33Z",
44
"machine": "Linux",
55
"groups": {
66
"parse": {
@@ -13,7 +13,8 @@
1313
"test_post_export_zip[composers-50]": 0.04482855966665985
1414
},
1515
"search": {
16-
"test_search_full_corpus": 0.047164217833331655
16+
"test_search_full_corpus_live_scan": 0.047164217833331655,
17+
"test_search_full_corpus_indexed": 0.05494209932945618
1718
},
1819
"summary-cache": {
1920
"test_summary_cache_lookup[hit]": 9.224067718099102e-05,
@@ -23,7 +24,9 @@
2324
"test_fingerprint_workspace_entries[200]": 0.03069810573000666,
2425
"test_summary_cache_round_trip": 0.0004966099535917549,
2526
"test_tab_summary_cache_lookup[hit]": 0.00010487297799045405,
26-
"test_tab_summary_cache_lookup[miss]": 0.00010309520517204601
27+
"test_tab_summary_cache_lookup[miss]": 0.00010309520517204601,
28+
"test_composer_map_cache_lookup[hit]": 8.074544668606364e-05,
29+
"test_composer_map_cache_lookup[miss]": 9.495690246481993e-05
2730
}
2831
}
2932
}

tests/benchmarks/conftest.py

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,21 @@ def _make_bench_flask_client(
122122
monkeypatch: pytest.MonkeyPatch,
123123
*,
124124
state_subdir: str = ".cursor-chat-browser",
125+
live_scan_search: bool = False,
125126
) -> FlaskClient:
126-
"""Flask test client with env + export state patched for synthetic storage."""
127+
"""Flask test client with env + export state patched for synthetic storage.
128+
129+
When *live_scan_search* is True, set ``CURSOR_CHAT_BROWSER_NO_SEARCH_INDEX=1`` so
130+
``/api/search`` measures the live-scan fallback. Otherwise the FTS index path
131+
from #113 may be used when an index is built (see indexed search fixtures).
132+
"""
127133
monkeypatch.setenv("WORKSPACE_PATH", storage["workspace_path"])
128134
monkeypatch.setenv("CLI_CHATS_PATH", storage["cli_chats_path"])
129-
monkeypatch.setenv("CURSOR_CHAT_BROWSER_NO_SEARCH_INDEX", "1")
135+
if live_scan_search:
136+
monkeypatch.setenv("CURSOR_CHAT_BROWSER_NO_SEARCH_INDEX", "1")
137+
else:
138+
monkeypatch.delenv("CURSOR_CHAT_BROWSER_NO_SEARCH_INDEX", raising=False)
139+
monkeypatch.delenv("CURSOR_CHAT_BROWSER_NOCACHE", raising=False)
130140
state_dir = tmp_path / state_subdir
131141
state_dir.mkdir()
132142
monkeypatch.setattr("api.export_api._get_state_dir", lambda: str(state_dir))
@@ -138,7 +148,12 @@ def _make_bench_flask_client(
138148

139149
@pytest.fixture
140150
def summary_cache_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
141-
"""Redirect summary-cache files to an isolated temp directory."""
151+
"""Redirect summary-cache files to an isolated temp directory.
152+
153+
Tab-summary files use ``CACHE_DIR`` + hashed filenames only (see
154+
``summary_cache._tab_summaries_path``); they do not use
155+
``PROJECTS_CACHE_FILE`` or ``COMPOSER_MAP_CACHE_FILE``.
156+
"""
142157
cache_dir = tmp_path / "cache"
143158
cache_dir.mkdir()
144159
monkeypatch.setattr(summary_cache, "CACHE_DIR", cache_dir)
@@ -212,19 +227,54 @@ def bench_env(
212227
@pytest.fixture
213228
def bench_client(bench_env: dict[str, str], tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> FlaskClient:
214229
"""Flask test client bound to synthetic bench storage."""
215-
return _make_bench_flask_client(bench_env, tmp_path, monkeypatch)
230+
return _make_bench_flask_client(bench_env, tmp_path, monkeypatch, live_scan_search=True)
216231

217232

218233
@pytest.fixture
219234
def bench_client_search_corpus(
220235
tmp_path: Path,
221236
monkeypatch: pytest.MonkeyPatch,
222237
) -> FlaskClient:
223-
"""Flask client over a fixed 50-composer corpus for search benchmarks."""
238+
"""Flask client over a fixed 50-composer corpus (live-scan search path)."""
224239
storage = build_bench_storage(tmp_path / "search_storage", 50)
225240
return _make_bench_flask_client(
226241
storage,
227242
tmp_path,
228243
monkeypatch,
229244
state_subdir=".cursor-chat-browser-search",
245+
live_scan_search=True,
246+
)
247+
248+
249+
@pytest.fixture
250+
def bench_client_search_corpus_indexed(
251+
tmp_path: Path,
252+
monkeypatch: pytest.MonkeyPatch,
253+
) -> FlaskClient:
254+
"""Flask client with FTS index built for the 50-composer search corpus."""
255+
from services.search_index import build_search_index
256+
257+
monkeypatch.delenv("CURSOR_CHAT_BROWSER_NO_SEARCH_INDEX", raising=False)
258+
monkeypatch.delenv("CURSOR_CHAT_BROWSER_NOCACHE", raising=False)
259+
260+
storage = build_bench_storage(tmp_path / "search_indexed_storage", 50)
261+
cache_dir = tmp_path / "search_index_cache"
262+
cache_dir.mkdir()
263+
monkeypatch.setattr("services.search_index.CACHE_DIR", cache_dir)
264+
monkeypatch.setattr(
265+
"services.search_index.SEARCH_INDEX_POINTER_FILE",
266+
cache_dir / "search_index.active",
267+
)
268+
monkeypatch.setattr(
269+
"services.search_index.SEARCH_INDEX_FILE",
270+
cache_dir / "search_index.sqlite",
271+
)
272+
built = build_search_index(storage["workspace_path"], [], force=True)
273+
assert built is True
274+
return _make_bench_flask_client(
275+
storage,
276+
tmp_path,
277+
monkeypatch,
278+
state_subdir=".cursor-chat-browser-search-indexed",
279+
live_scan_search=False,
230280
)

tests/benchmarks/test_search_bench.py

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,41 @@
88
from tests.benchmarks.constants import BENCH_SEARCH_TERM
99

1010

11+
def _search_url() -> str:
12+
return f"/api/search?q={BENCH_SEARCH_TERM}&all_history=1"
13+
14+
15+
def _assert_search_response(response: object) -> None:
16+
assert response.status_code == 200 # type: ignore[attr-defined]
17+
body = response.get_json() # type: ignore[attr-defined]
18+
assert isinstance(body, dict)
19+
results = body.get("results")
20+
assert isinstance(results, list) and len(results) > 0
21+
22+
1123
@pytest.mark.benchmark(group="search")
12-
def test_search_full_corpus(
24+
def test_search_full_corpus_live_scan(
1325
benchmark,
1426
bench_client_search_corpus: FlaskClient,
1527
) -> None:
28+
"""Live-scan fallback only (``CURSOR_CHAT_BROWSER_NO_SEARCH_INDEX=1``)."""
29+
1630
def _run() -> object:
17-
return bench_client_search_corpus.get(
18-
f"/api/search?q={BENCH_SEARCH_TERM}&all_history=1",
19-
)
31+
return bench_client_search_corpus.get(_search_url())
2032

2133
response = benchmark(_run)
22-
assert response.status_code == 200
23-
body = response.get_json()
24-
assert isinstance(body, dict)
25-
results = body.get("results")
26-
assert isinstance(results, list) and len(results) > 0
34+
_assert_search_response(response)
35+
36+
37+
@pytest.mark.benchmark(group="search")
38+
def test_search_full_corpus_indexed(
39+
benchmark,
40+
bench_client_search_corpus_indexed: FlaskClient,
41+
) -> None:
42+
"""FTS index path (#113) with pre-built ``search_index.sqlite``."""
43+
44+
def _run() -> object:
45+
return bench_client_search_corpus_indexed.get(_search_url())
46+
47+
response = benchmark(_run)
48+
_assert_search_response(response)

tests/benchmarks/test_summary_cache_bench.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99

1010
from services.summary_cache import (
1111
fingerprint_workspace_storage,
12+
get_cached_composer_id_to_ws,
1213
get_cached_projects,
1314
get_cached_tab_summaries,
15+
set_cached_composer_id_to_ws,
1416
set_cached_projects,
1517
set_cached_tab_summaries,
1618
)
@@ -39,6 +41,26 @@ def test_summary_cache_lookup(
3941
assert result is None
4042

4143

44+
@pytest.mark.benchmark(group="summary-cache")
45+
@pytest.mark.parametrize("mode", ["hit", "miss"], ids=["hit", "miss"])
46+
def test_composer_map_cache_lookup(
47+
benchmark,
48+
mode: Literal["hit", "miss"],
49+
summary_cache_dir: Path,
50+
workspace_fingerprint: dict[str, Any],
51+
stale_fingerprint: dict[str, Any],
52+
) -> None:
53+
"""Time ``get_cached_composer_id_to_ws`` hit/miss (fingerprint mismatch on miss)."""
54+
mapping = {"cmp_0000": "ws_0000"}
55+
set_cached_composer_id_to_ws(workspace_fingerprint, mapping)
56+
lookup_fp = workspace_fingerprint if mode == "hit" else stale_fingerprint
57+
result = benchmark(get_cached_composer_id_to_ws, lookup_fp)
58+
if mode == "hit":
59+
assert result == mapping
60+
else:
61+
assert result is None
62+
63+
4264
@pytest.mark.benchmark(group="summary-cache")
4365
@pytest.mark.parametrize(
4466
"synthetic_workspace",

0 commit comments

Comments
 (0)