Skip to content

fix(hrv): make the windowed rMSSD gap-aware, like the nightly one already is (#1448) - #1449

Merged
ryanbr merged 1 commit into
mainfrom
fix/rolling-rmssd-gap-aware
Aug 19, 2026
Merged

ryanbr merged 1 commit into
mainfrom
fix/rolling-rmssd-gap-aware

Conversation

@ryanbr

@ryanbr ryanbr commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Closes #1448.

The defect

The Deep Timeline's windowed rMSSD took successive differences across beats the artifact filter had
removed
. Dropping a beat joins two intervals that were never adjacent, and their difference is a
splice, not a physiological delta — the spurious large delta this analyzer's gap-aware pair (#204/#195)
exists to exclude.

The nightly analyze() already used cleanRRGapAware + rmssdGapAware. rollingRmssd used
cleanRR + rmssdRaw three lines away in the same file — on both platforms.

Swift Kotlin
nightly analyze gap-aware ✅ gap-aware ✅
rollingRmssd raw ❌ raw ❌

The twins agreed with each other while both were wrong, which is exactly why no parity check ever
surfaced it. It bites hardest on the messiest data — on a WHOOP 4.0 that is most of it (#1008) — and the
trace exists specifically to be an honest windowed rMSSD (#803) rather than the raw-interval chart it
replaced.

Why this stays narrow

  • CleanSeries.nn is documented as byte-identical to cleanRR over the same input, so the
    minBeatsPerWindow survivor gate does not move.
  • rmssdGapAware is identical to rmssdRaw when there are no gaps, so a clean window is
    unchanged. Only windows that actually lost a beat move.

The one visible consequence: a window whose survivors share no adjacent pair now emits nothing
rather than a number assembled entirely from splices. That is honest absence, and consistent with the
existing sparse-window gate that already prefers no point over a noisy one.

Tests

Mirrored fixtures per platform, built on the range filter so the drop is deterministic rather than
depending on Malik's neighbour median:

  • five 1000 ms beats, an out-of-range 2400 ms beat, five 1150 ms beats → the 150 ms step is pure splice,
    and the emitted value goes from a filter-invented 50.0 → 0.0
  • the control is the important half: the same two runs with no beat between them still read exactly
    50.0, proving this is not a numbers-move for clean data

Verification

  • Android 4,070 tests / 0 failures (--rerun-tasks, both new vectors confirmed by name)
  • i18n and doc-comment gates clean
  • Both changed files sit under Packages/ and Android, so swift-packages executes the Swift twin
    — no app-target Swift touched, so no app-build gate needed
  • Not device-tested: this is pure analytics with no BLE path, and the fixtures cover it

…eady is (#1448)

The Deep Timeline's windowed rMSSD took successive differences across beats the artifact
filter had removed. Dropping a beat joins two intervals that were never adjacent, and their
difference is a splice rather than a physiological delta - the spurious large delta this
analyzer's gap-aware pair (#204/#195) exists to exclude.

The nightly analyze() already used cleanRRGapAware + rmssdGapAware. rollingRmssd used
cleanRR + rmssdRaw, three lines away in the same file, on BOTH platforms - so the twins
agreed with each other while both were wrong, which is why no parity check surfaced it. That
matters most on the messiest data, which on a WHOOP 4.0 is most of it (#1008), and the trace
exists precisely to be an honest windowed rMSSD (#803) rather than the raw-interval chart it
replaced.

Two things keep this narrow. CleanSeries.nn is byte-identical to cleanRR over the same input,
so the minBeatsPerWindow survivor gate is unchanged; and rmssdGapAware equals rmssdRaw when a
window has no gaps, so only windows that actually lost a beat move. The one visible
consequence: a window whose survivors share no adjacent pair now emits nothing rather than a
number built entirely from splices - honest absence, consistent with the existing sparse-window
gate.

Mirrored fixtures per platform, and the control is the important half: a gapless window still
reads exactly 50.0 ms, proving this is not a numbers-move for clean data, while the same two
runs separated by an out-of-range beat drop from a filter-invented 50.0 to 0.0.

Android 4,070 tests / 0 failures; i18n and doc-comment gates clean. Both files sit under
Packages / Android, so swift-packages executes the Swift twin - no app-target Swift touched.
@ryanbr
ryanbr merged commit 374144a into main Aug 19, 2026
14 checks passed
@ryanbr
ryanbr deleted the fix/rolling-rmssd-gap-aware branch August 19, 2026 07:44
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.

[hrv] Windowed rMSSD splices across dropped beats — rollingRmssd should use the gap-aware pair the nightly path already uses

1 participant