Skip to content

test: register --run-perf and let latency budgets govern (#1160) - #1197

Merged
github-actions[bot] merged 4 commits into
mainfrom
test/issue-1160-run-perf-flag
Jul 30, 2026
Merged

test: register --run-perf and let latency budgets govern (#1160)#1197
github-actions[bot] merged 4 commits into
mainfrom
test/issue-1160-run-perf-flag

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Third acceptance criterion of #1160"Register the perf flag or delete the perf tests." Registers it, and fixes the second defect that only becomes visible once the flag works. The umbrella stays open.

Defect 1 — the opt-in did not exist

Five perf tests across four modules gate a wall-clock assertion on a _has_run_perf helper that reads --run-perf. Nothing registered that option. test_bm25_index.py carried a pytest_addoption stub, but pytest only invokes that hook from conftest.py or an installed plugin — never from a plain test module. So getoption always raised, every helper fell back to False, and the flag was not merely ignored:

$ pytest --run-perf tests/test_bm25_index.py
ERROR: usage: pytest [options] [file_or_dir] ...
pytest: error: unrecognized arguments: --run-perf
$ echo $?
4

The tests were reachable only by hand-editing the guard. No measurement of retrieval latency at realistic store size has run in a long time.

Defect 2 — registering the flag exposed a test that could not pass

pyproject.toml sets a global timeout = 5, sized for unit tests. Every perf test asserts its own, larger budget, so pytest-timeout decided the outcome ahead of the assertion:

test asserts measures outcome at timeout = 5
test_eigsolve_under_budget_n10k elapsed < 30.0 6.76s killed at 5.0s in the ARPACK solve — always fails
test_build_latency_at_n_10k (hrr) elapsed <= 5.0 3.12s bound equals the cap: unpassable at its own boundary
test_score_latency_under_5ms_n50k median ≤ 5ms 3.07s <2s headroom on a slower host, for a per-query assertion
test_probe_latency_at_n_50k (hrr) ≤ 30ms 1.34s ok
test_heat_kernel_latency_at_n_50k_under_10ms ≤ 10ms 0.10s ok

The eigsolve test's own comment already read "allow 30s for noisy hosts" — it was arguing with the harness and losing. Registering the flag without this fix would have shipped a guaranteed-failing opt-in, which is why both land together.

Each perf test now carries @pytest.mark.timeout(_PERF_TIMEOUT_S) at 120s — the per-test override convention pyproject.toml:125-127 already documents. The bound is deliberately generous because it is only a hang guard; the budget is the assertion.

Verification

  • All five pass with the flag: 14.87s together, eigsolve at 6.76s (i.e. it genuinely needed the override, not just headroom).
  • Default behaviour unchanged: all five still skip without --run-perf; full suite 6237 passed, 69 skipped (6234 baseline + 3 new).
  • Mutation-verified, each in isolation: un-registering the flag fails the registration test; dropping the eigsolve timeout override fails the budget test; breaking the AST scan fails the vacuity test. No mutation left the suite green.
  • 4 signed commits, ordered so the tree is green at each; discretion grep clean on added lines.

The guard

tests/test_perf_gate_wiring.py pins both failure modes. The registration check is behavioural, not source-levelconfig.getoption("--run-perf") raises if the option is absent, so a revert to the no-op stub fails it rather than passing a grep. The budget check walks the AST for every test calling _has_run_perf, resolves its @pytest.mark.timeout override, and compares it against the timeout value read from pyproject.toml, so it tracks that value if it changes instead of hard-coding 5.

New module rather than an addition to an existing one, so this can merge in any order relative to #1194 and #1195.

Deliberately not included: a CI job

No workflow runs --run-perf. I have not added one, and that is a judgement call worth stating rather than burying: these are wall-clock assertions on shared runners, and this repo has a documented history of latency tests false-failing under load. A nightly job is the natural home if that noise is acceptable — but making a flaky perf gate visible is a different decision from making the tests runnable, and only the second is unambiguously an improvement. Happy to wire the nightly if you want it.

Note

test_eigsolve_under_budget_n10k is the same failure family as this umbrella's parked finding "the 5 s global timeout equals SQLite's busy_timeout, making the lock-contention path structurally untestable" — a global cap chosen for unit tests silently governing tests whose own budgets exceed it. Worth considering together when that one is dispositioned.

Summary by Sourcery

Register the pytest --run-perf option and ensure latency benchmarks are governed by their own budgets rather than the global timeout.

New Features:

  • Introduce a pytest command-line flag (--run-perf) to opt into running latency benchmark tests.

Bug Fixes:

  • Fix unreachable perf tests by registering the --run-perf option used by the _has_run_perf gate.
  • Prevent guaranteed timeouts in perf tests by exempting them from the global 5s pytest-timeout cap.

Enhancements:

  • Apply consistent per-test timeout overrides to all perf-gated latency benchmarks so they can run to completion as long as their assertions allow.
  • Centralize ownership of the perf flag in tests/conftest.py instead of per-module stubs.

Documentation:

  • Update test module docstrings and CHANGELOG to describe the perf gate behaviour and the new --run-perf option.

Tests:

  • Add tests that assert the --run-perf option is registered and that all perf-gated tests carry a timeout override above the global pytest timeout.
  • Add an AST-based guard test to ensure the perf gate scan remains non-vacuous as the test suite evolves.

Summary by CodeRabbit

  • Bug Fixes
    • Performance benchmarks can now be enabled reliably with the --run-perf option.
    • Opt-in latency tests now use appropriate per-test time limits instead of the shorter default timeout.
    • Added validation to ensure performance tests remain reachable and correctly configured.
  • Documentation
    • Updated the changelog with details about the performance test fixes.

@robotrocketscience robotrocketscience added the author-Kulili PR coordination mutex label Jul 30, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @robotrocketscience, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 04040733-87f7-4f31-9a28-4ccc1df50b43

📥 Commits

Reviewing files that changed from the base of the PR and between 4e66e15 and b7858dd.

📒 Files selected for processing (7)
  • CHANGELOG/v4.md
  • tests/conftest.py
  • tests/test_bm25_index.py
  • tests/test_graph_spectral.py
  • tests/test_heat_kernel.py
  • tests/test_hrr_struct_index.py
  • tests/test_perf_gate_wiring.py

📝 Walkthrough

Walkthrough

Changes

The test suite registers the --run-perf option globally, adds 120-second per-test timeouts to latency benchmarks, removes a local option stub, and introduces AST-based checks for perf-gate coverage and timeout consistency. The unreleased changelog documents these fixes.

Performance benchmark execution

Layer / File(s) Summary
Register perf flag and update benchmark timeouts
tests/conftest.py, tests/test_bm25_index.py, tests/test_graph_spectral.py, tests/test_heat_kernel.py, tests/test_hrr_struct_index.py
The suite registers --run-perf; perf-gated benchmarks use explicit 120-second timeout overrides, and the BM25 module removes its local option stub.
Validate perf gate configuration
tests/test_perf_gate_wiring.py
AST-based checks verify option registration, detect perf-gated tests, require at least five gates, and ensure each override exceeds the global timeout.
Document the benchmark fix
CHANGELOG/v4.md
Adds an unreleased fixed entry describing the option registration and timeout changes.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: registering --run-perf and adjusting perf-test timeout behavior.
Description check ✅ Passed The description covers the why, linked issue context, verification, and reviewer notes, though it does not follow the template headings exactly.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/issue-1160-run-perf-flag

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@robotrocketscience robotrocketscience added the attn:review Needs review (PR open, awaiting reviewer) label Jul 30, 2026
@sourcery-ai

sourcery-ai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Reviewer's Guide

Registers the --run-perf pytest option globally, adds per-test timeout overrides for all latency benchmarks, and introduces a guard test module that behaviorally enforces both the registration and timeout invariants so perf tests become runnable and governed by their own latency budgets rather than the global pytest timeout.

Sequence diagram for running perf tests with --run-perf and enforced timeouts

sequenceDiagram
  participant CLI as cli_pytest
  participant Pytest as pytest_runner
  participant Conftest as tests_conftest_py
  participant PerfTest as perf_test_function

  CLI->>Pytest: invoke_pytest_with_args(--run-perf)
  Pytest->>Conftest: load_conftest_hooks()
  Conftest->>Pytest: pytest_addoption_register_run_perf()

  Pytest->>PerfTest: execute_perf_test()
  PerfTest->>Pytest: config_getoption("--run-perf") via _has_run_perf
  alt [run-perf enabled]
    PerfTest->>PerfTest: apply_pytest_mark_timeout(120)
    PerfTest->>PerfTest: run_latency_measurement_and_assert_budget()
  else [run-perf disabled]
    PerfTest->>PerfTest: skip_test_due_to_perf_guard()
  end
Loading

File-Level Changes

Change Details Files
Register the --run-perf option in pytest so perf-gated latency tests can be opted into via the CLI without editing guards.
  • Moved the historical pytest_addoption stub out of the bm25 test module and replaced it with a proper implementation in the shared test configuration.
  • Implemented pytest_addoption in the tests configuration to register --run-perf with a boolean flag, defaulting to False so existing suite behaviour is unchanged.
  • Documented the purpose and semantics of the --run-perf flag in the test configuration and changelog, including which test modules consume it.
tests/test_bm25_index.py
tests/conftest.py
CHANGELOG/v4.md
Ensure all latency benchmarks are governed by their own wall-clock assertions rather than the global pytest timeout by adding per-test timeout overrides.
  • Introduced a shared _PERF_TIMEOUT_S constant in each perf-heavy test module to represent a generous per-test timeout value used only as a hang guard.
  • Applied @pytest.mark.timeout(_PERF_TIMEOUT_S) decorators to all perf-gated tests that call the _has_run_perf helper so they outrank the global timeout = 5 setting.
  • Updated perf test docstrings and comments to explain the interaction with the global timeout and how the override restores the assertion as the deciding factor.
tests/test_bm25_index.py
tests/test_hrr_struct_index.py
tests/test_graph_spectral.py
tests/test_heat_kernel.py
Add a dedicated guard test module that behaviorally verifies --run-perf registration and statically enforces timeout overrides for all perf-gated tests.
  • Implemented a behavioural check that calls request.config.getoption("--run-perf") to ensure the option is registered and fails if it is not.
  • Added an AST-based scan over test modules to discover all tests that call the perf gate helper and resolve any @pytest.mark.timeout overrides, including named constants.
  • Introduced assertions that the scan is non-vacuous (finds at least the known perf gates) and that every discovered perf-gated test has a timeout override strictly larger than the global pytest timeout read from pyproject.toml.
  • Structured the guard tests so changes to the global timeout or introduction/removal of perf gates will surface as failures rather than silently weakening the invariants.
tests/test_perf_gate_wiring.py
pyproject.toml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions

Copy link
Copy Markdown

PR-size soft cap

This PR is over the advisory size threshold:

  • 226 changed lines (limit: 200)
  • 7 changed files (limit: 3)

Bigger PRs collide with more open work, which under the parallel-session workflow tends to produce repeated attn:merge-conflict cycles (see #602). When practical, split into smaller PRs that each touch a focused surface.

This is advisory only — nothing is blocked. If the size is intentional (large refactor, module removal, generated code), apply the size:override label and this comment will be removed on the next push.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Setr:2026-07-30T16:49:24Z]

Four modules gate a latency assertion on a `_has_run_perf` helper that
reads `--run-perf`, but nothing registered the option.
`test_bm25_index.py` carried a `pytest_addoption` stub, and pytest only
invokes that hook from `conftest.py` or an installed plugin — never from
a plain test module. So `getoption` always raised, every helper fell
back to False, and `pytest --run-perf` exited 4 with
`unrecognized arguments`: the five perf tests could not be opted into at
all, only reached by editing the guard.

Registered in tests/conftest.py, which is where the hook is actually
called, and once rather than per module so there is one authority for
the flag. Defaults to False, so the suite skips them exactly as before.

Refs #1160.
With the flag registered, the perf tests become reachable — and one of
them could not pass. `pyproject.toml` sets a global `timeout = 5` sized
for unit tests, while every perf test asserts its own larger
wall-clock budget, so pytest-timeout decided the outcome ahead of the
assertion:

  test_eigsolve_under_budget_n10k  asserts elapsed < 30s, measures
  6.76s, was killed at 5.0s inside the ARPACK solve — a guaranteed
  failure, and the test's own comment already said "allow 30s for noisy
  hosts".

Two more were latent rather than broken: hrr's test_build_latency_at_n_10k
asserts `elapsed <= 5.0`, exactly the global cap, so it could never pass
at its own boundary; bm25's test_score_latency_under_5ms_n50k spends
3.07s building a 50k store before measuring, leaving under 2s of headroom
on a slower host for an assertion about per-query milliseconds.

Each now carries `@pytest.mark.timeout(_PERF_TIMEOUT_S)` at 120s — the
per-test override convention pyproject.toml:125-127 documents. The bound
is deliberately generous because it is only a hang guard; the budget is
the assertion. Also drops the no-op `pytest_addoption` stub and the
docstrings describing the flag as unregistered.

All five now pass with --run-perf (14.87s total) and still skip without
it.

Refs #1160.
Guards both ways the latency benchmarks were unreachable. The
registration check is behavioural rather than source-level —
`config.getoption("--run-perf")` raises if the option is absent, so
reverting to the no-op stub fails it. The budget check walks the AST for
every test calling `_has_run_perf`, resolves its
`@pytest.mark.timeout` override against the `timeout` value read from
pyproject.toml, and fails if the harness could still kill the test
before its own assertion runs.

Scan vacuity is pinned too, since finding no perf gates would satisfy
the budget assertion for free.

Mutation-verified: un-registering the flag fails the first test,
dropping the eigsolve timeout override fails the second, breaking the
scan fails the third.

Refs #1160.
@robotrocketscience
robotrocketscience force-pushed the test/issue-1160-run-perf-flag branch from 9c0a5de to b7858dd Compare July 30, 2026 16:53
@robotrocketscience

Copy link
Copy Markdown
Owner Author

Approve — with one correction to the diagnosis. The fix is right either way, but the PR understates the defect.

"Always fails" is host-dependent, and that makes it worse, not smaller

Reproduced here with the eigsolve override removed:

$ pytest --run-perf tests/test_graph_spectral.py -k eigsolve
1 passed in 4.81s

4.81s against the 5.0s global cap — it passes, with 0.19s of margin. The table says 6.76s and "always fails." Both measurements are honest; the pre-fix outcome was simply decided by hardware and runner load rather than by the 30s budget the test asserts.

That is a worse failure mode than the one described. A test that always fails gets noticed and fixed. A test that flips at ±0.2s under load becomes a flake, and this repo already has a documented history of exactly that with latency assertions. The justification for the override is stronger stated as the harness cap was deciding the outcome instead of the assertion — which holds on either side of 5s — than as the test could never pass, which holds only on some hosts.

Verified independently

  • Defect 1 reproduces. pytest --run-perf on mainunrecognized arguments, exit 4. Confirms the module-level pytest_addoption stub was never invoked.
  • All five pass with the flag, 11.56s together: eigsolve 4.78s, bm25 score 2.36s, hrr build 2.27s, hrr probe 1.05s, heat kernel 0.08s.
  • Default behaviour unchanged: 101 passed / 7 skipped across the four modules with no flag.
  • Both mutations caught, each in isolation. Removing the conftest.py registration fails test_run_perf_option_is_registered with ValueError: no option named '--run-perf' — behavioural as claimed, not a source grep. Removing one @pytest.mark.timeout fails test_every_perf_test_outranks_the_global_timeout and names the offending test.
  • The AST scan resolves exactly the five known gates, all at 120 — checked by executing _perf_gated_tests() directly rather than trusting the count assertion to be non-vacuous about itself.
  • Nothing in CI changes: no workflow passes --run-perf.

Rebased and pushed

#1200 merged while this was under review; both add to [Unreleased]. Resolved insert-only — the band-check entry stays where it is, the perf-gate entry appended after it. Deliberately not re-sorted: reordering that block re-adds already-merged lines to the diff and trips the discretion gate for no benefit.

Now at b7858dd2: FF on main, 4 signed commits, identical 205/21 diff stat, discretion grep clean on added lines, affected modules green.

Notes, none blocking

  1. _PERF_TIMEOUT_S = 120 is duplicated across four modules, and has to be. _timeout_override resolves an ast.Name argument from module-level constants only, so hoisting the constant into conftest.py would make all five overrides unresolvable and the guard would report every one as an offender. Worth one line saying so where the constant is defined — otherwise the next reader deduplicates it and silently disarms the guard.

  2. The scan globs tests/test_*.py, not rglob. A perf gate added under tests/e2e/ would be invisible to both the vacuity check and the budget check. Nothing lives there now; a one-character change future-proofs it.

  3. assert len(gated) >= 5 means legitimately deleting a perf test fails the guard. That is the intended tradeoff, but the failure message reads as "the scan broke" when the cause may be "the test was removed on purpose."

On the deferred CI job

Agree, and I'd go further than "happy to wire it if you want." Don't. The local PR gate here already false-fails a median-latency assertion on loaded hardware; adding five more wall-clock assertions to a shared runner buys noise, not signal. Making them runnable on demand is the unambiguous improvement. A nightly that cries wolf would be a regression dressed as coverage.

The note about test_eigsolve_under_budget_n10k sharing a failure family with the parked "5s global timeout equals SQLite's busy_timeout" finding is a good catch and worth dispositioning together — both are a cap chosen for unit tests silently governing tests it was never sized for.

@robotrocketscience robotrocketscience added ready-to-merge Trigger merge-train: FF main to this PR's head and removed attn:review Needs review (PR open, awaiting reviewer) labels Jul 30, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Setr:2026-07-30T16:58:08Z]

@github-actions
github-actions Bot merged commit b7858dd into main Jul 30, 2026
35 of 36 checks passed
@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Jul 30, 2026
@github-actions

Copy link
Copy Markdown

merge-train: merged b7858ddmain via FF push.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author-Kulili PR coordination mutex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant