Skip to content

[P1] N-05: Rolling RMSSD splices across rejected beats; Swift/Kotlin windows drift #9

Closed

Description

@bhelm

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:
    1. 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).
    2. Ectopic decision scope: Swift re-decides per raw window; Kotlin decides once globally — edge neighborhoods differ.
    3. 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.

Activity

  1. bhelm commented on Aug 18, 2026

    @bhelm
    OwnerAuthor

    Upstream follow-up

    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.

  2. bhelm commented on Aug 19, 2026

    @bhelm
    OwnerAuthor

    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.

  3. bhelm commented on Aug 19, 2026

    @bhelm
    OwnerAuthor

    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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions