Skip to content

Skip already-rejected peaks in XQRS backsearch - #596

Merged
tompollard merged 1 commit into
MIT-LCP:mainfrom
TheNetherWatcher:perf/xqrs-backsearch
Oct 31, 2026
Merged

tompollard merged 1 commit into
MIT-LCP:mainfrom
TheNetherWatcher:perf/xqrs-backsearch

Conversation

@TheNetherWatcher

@TheNetherWatcher TheNetherWatcher commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

When backsearch runs and finds nothing, last_qrs_peak_num stays put. On the next peak backsearch runs again over the same range plus one more peak, and so on. A long stretch with no QRS complexes (lead off, flat line, low-amplitude noise) is therefore rescanned at every peak, which is quadratic in the length of the stretch. I think this is what #352 is seeing: most records are fine, the ones with a long bad section take hours.

Record 100 with a gap of low-amplitude noise inserted:

gap before after
10 min 1.7 s 0.4 s
40 min 23.4 s 1.3 s

The fix remembers the last peak a backsearch checked and starts the next one after it. A peak's backsearch result only depends on qrs_thr, last_qrs_ind and the signal, which only change in _update_qrs, and _update_qrs always moves last_qrs_peak_num up to the current peak. So the skipped peaks would have been rejected again, and the detector ends up in exactly the same state.

Testing:

  • New test: inserts a 2-minute noise gap into record 100 and checks backsearch never checks the same peak twice, and that beats on both sides are found. Fails on the old code.
  • Compared old vs new on 300 random cases (sample-data records with inserted noise gaps, flat sections and amplitude changes; random hr_init, ref_period, t_inspect_period, with and without learning). qrs_inds, backsearch_qrs_inds and all running parameters were identical.
  • I also wrote a short Lean proof of the "same state after every peak" argument: https://github.com/TheNetherWatcher/lean-perf-proofs/tree/main/wfdb-xqrs-backsearch. The detector is modelled abstractly there, so it relies on the three properties above, which I checked by reading the code.

Fixes the slowdown in #352.

A backsearch that finds nothing leaves last_qrs_peak_num unchanged, so the
next one rescans the same peaks. Long stretches without QRS complexes were
rescanned at every peak, which is quadratic. Start each backsearch after
the last peak already checked; the result is unchanged because those peaks
can only pass after a QRS update, which moves last_qrs_peak_num past them.

@tompollard tompollard left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks, nice find! I checked it locally. The new test fails on main (backsearch checks 856 peaks 366,768 times in the 2-minute gap) and passes here. A random comparison of old and new code on sample-data records with inserted noise, flat and low-amplitude gaps gave identical qrs_inds, backsearch_qrs_inds and running parameters. Timings were similar to yours: 10 min gap 2.3 s → 0.7 s, 40 min gap 28.2 s → 1.6 s.

The skip depends on two things about the current code that look like they could be changed later:

  • _update_noise doesn't recalculate qrs_thr.
  • _update_qrs sets last_qrs_peak_num = self.peak_num (the current peak) rather than the peak_num argument.

If someone changes either of those later, this could break without any test catching it. I've raised an issue to suggest we might want to add a comment about this: #597

@tompollard
tompollard merged commit 534e522 into MIT-LCP:main Oct 31, 2026
18 checks passed
tompollard pushed a commit that referenced this pull request Oct 31, 2026
The skip added in #596 is only correct while _update_noise leaves qrs_thr
alone and _update_qrs moves last_qrs_peak_num to the current peak. Name
both in the comment and add a test that fails if either changes.
tompollard added a commit that referenced this pull request Oct 31, 2026
Closes #597.

The skip added in #596 is only correct while two things stay true:

- `_update_noise` doesn't change `qrs_thr`.
- `_update_qrs` sets `last_qrs_peak_num` to the current peak.

This PR:

- Uses the comment suggested in #597 above the skip in `_backsearch`.
- Adds `test_xqrs_backsearch_skip_assumptions`, which runs XQRS on
record 100 with a 2-minute low-amplitude gap followed by scaled-down
beats (so backsearch actually finds QRS complexes) and checks both
properties after every update.

I first tried a test comparing the skip against a full rescan, but it
still passed when I broke either property, because the data never hit
the case where they matter. The direct check fails for both changes:

- `_update_noise` also recalculating `qrs_thr` → fails
- `_update_qrs` setting `last_qrs_peak_num = peak_num` → fails

No change in behaviour, so no changelog entry. `ruff check` is clean and
`pytest -m "not network"` passes (83 tests).
@TheNetherWatcher
TheNetherWatcher deleted the perf/xqrs-backsearch branch November 1, 2026 06:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants