You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[P1] N-05: Rolling RMSSD splices across rejected beats; Swift/Kotlin windows drift #9
Severity: P1 — displayed HRV timeline splices across rejected beats and violates the Swift/Kotlin parity contract.
From an external audit of 46cf3e5; every claim re-verified against current main (f2271cd). Not fixed by any commit since. (Not covered by the spot-capture-window fix — that adds spotCaptureOverCounted for the manual 60 s reading; the rolling path below is untouched.)
Problem
The nightly/headline HRV path correctly retains adjacency metadata and refuses to difference across a removed beat. The rolling RMSSD path bypasses that protection on both platforms: it cleans a window, then feeds the survivors to raw RMSSD, making the beats on either side of a rejected beat artificial neighbors. On top of that, the two platforms disagree in three ways, so identical data need not chart identically on Apple vs Android.
Evidence (current main)
Swift: Packages/StrandAnalytics/Sources/StrandAnalytics/HRVAnalyzer.swift:362–369 — let windowRaw = sorted[left...right].map { … } → let clean = cleanRR(windowRaw) → rmssdRaw(clean).
Kotlin: android/app/src/main/java/com/noop/analytics/HrvAnalyzer.kt:695–727 — rejectEctopic once over the whole stream, then rmssdRaw(span) per surviving window.
Platform drift:
Window boundary: Swift keeps the beat exactly at edgeTs − windowSec (while … edgeTs - sorted[left].ts > windowSec, HRVAnalyzer.swift:360–362); Kotlin drops it (while (lo < hi && kept[lo].ts <= tStart), HrvAnalyzer.kt:715–718).
Ectopic decision scope: Swift re-decides per raw window; Kotlin decides once globally — edge neighborhoods differ.
Kotlin re-attaches timestamps by matching RR values only (HrvAnalyzer.kt:695–707): with repeated equal intervals, a rejected earlier occurrence can be matched instead of the surviving later one.
Impact
The rolling chart can show artificial RMSSD deltas caused by the filter itself, and Apple/Android can disagree on identical input — a direct violation of the repo's #1 parity rule ("analytics must be byte-identical"). The nightly headline HRV value is not affected.
Fix
Run the gap-aware cleaner per window and then gap-aware RMSSD (cleanRRGapAware → rmssdGapAware), keeping original indices/timestamps instead of re-pairing by value.
Pick one boundary convention — (start, end] or [start, end] — document it, implement it on both platforms.
Add shared golden fixtures (Swift + Kotlin) for: a rejected beat mid-window, repeated equal RR values, and a beat exactly on the window boundary.
ryanbr/noop#1433 proposes fixes for the three measured rolling-rMSSD parity drifts:
(t - windowSec, t] window boundaries on both platforms
per-window cleaning on Android
removal of Kotlin's value-based timestamp rematching
Mirrored Swift and Kotlin regression fixtures cover each case. GitHub currently reports the PR as cleanly mergeable, with all reported checks passing.
This is intentionally a partial fix. It does not address the shared gap-aware rejected-beat splice described in this issue, so this issue remains open regardless of the outcome of that PR.
Upstream update: ryanbr#1433 merged as 7d7fe41. That completes the three measured cross-platform window/cleaning/rematching drifts. This issue remains open because the shared rejected-beat splice is explicitly outside that PR and still needs a gap-aware per-window fix.
Now fully resolved upstream. ryanbr#1433 merged the three measured cross-platform rolling-window drifts as 7d7fe41. Maintainer follow-up ryanbr#1449 then fixed the remaining shared rejected-beat splice with gap-aware cleaning and mirrored controls, merged as 374144a. All acceptance paths in this issue are now covered, so closing.
Activity
bhelm commentedon Aug 18, 2026
Upstream follow-up
ryanbr/noop#1433 proposes fixes for the three measured rolling-rMSSD parity drifts:
(t - windowSec, t]window boundaries on both platformsMirrored Swift and Kotlin regression fixtures cover each case. GitHub currently reports the PR as cleanly mergeable, with all reported checks passing.
This is intentionally a partial fix. It does not address the shared gap-aware rejected-beat splice described in this issue, so this issue remains open regardless of the outcome of that PR.
bhelm commentedon Aug 19, 2026
Upstream update: ryanbr#1433 merged as 7d7fe41. That completes the three measured cross-platform window/cleaning/rematching drifts. This issue remains open because the shared rejected-beat splice is explicitly outside that PR and still needs a gap-aware per-window fix.
bhelm commentedon Aug 19, 2026
Now fully resolved upstream. ryanbr#1433 merged the three measured cross-platform rolling-window drifts as 7d7fe41. Maintainer follow-up ryanbr#1449 then fixed the remaining shared rejected-beat splice with gap-aware cleaning and mirrored controls, merged as 374144a. All acceptance paths in this issue are now covered, so closing.