Skip to content

[store] A 500 ms R-R fill value is banked as a real interval, unflagged by tsSuspect, on both WHOOP 5 transports #2371

Description

@ayiskakov

What

An exact rrMs = 500 value appears ~20× more often than any neighbouring interval and is a
sentinel/fill, not a real beat — yet it is stored in rrInterval as a genuine interval with
tsSuspect NULL, on both the v18 historical and standard-profile transports.

Found while cross-checking R-R units on a WHOOP MG (fw 50.39.1.0), from a .noopbak backup
(314k labelled beats over 4 nights).

Evidence

Histogram around 500 ms (both WHOOP 5 channels, srcChannel 5 and 7):

495ms: 58   496ms: 45   497ms: 62   498ms: 51   499ms: 55
500ms: 1355            <-- ~20x its neighbours
501ms: 66   502ms: 74   503ms: 84   504ms: 78   505ms: 30

It cannot be a real interval — the strap's own HR contradicts it. 500 ms == 120 bpm, but at the
same-second hrSample the strap reports a resting rate every time:

mean strapHR at non-500 beats : 69.1 bpm
mean strapHR at 500 ms beats  : 66.2 bpm   (500 ms would require ~120 bpm)
top strapHR values at 500 ms beats: 60, 62, 61, 59, 64 bpm …

So the strap simultaneously asserts "≈66 bpm" and emits an interval meaning "120 bpm" for the same
instant. It skews slightly toward rest (66.2 vs 69.1), consistent with a fill emitted when the
sensor could not resolve a clean interval during quiet periods rather than a motion artifact.

Distribution: 1,355 beats across 1,027 distinct timestamps; on both transports (historical
878/165,565 = 0.53%, standard 477/148,566 = 0.32%); mostly isolated, with occasional short runs
(historical run-lengths {1:479, 2:78, 3:39, 4:15, 5:8, 6:2, 7:2}). tsSuspect is NULL on every
one of the 1,355.

Impact — scoped honestly

Shipped HRV and Charge are protected: both HRV paths run HRVAnalyzer.cleanRR
(range [300,2000] + Malik >20%-from-local-median), and a 500 ms beat among ~900 ms neighbours is
~44% off the local median, so it is rejected before RMSSD in the nightly (AnalyticsEngine) and
windowed (ResonanceEngine.scorePace) paths alike. This is not a "wrong HRV" report.

The concern is data hygiene:

  1. A known-bad fill is banked as a real interval with tsSuspect unset — precisely the flag
    meant to mark untrustworthy beats.
  2. It silently spends the ectopic-rejection budget (HRVAnalyzer.maxRejectFraction). Tiny here
    (0.3–0.5%), but it is noise eating headroom that a genuinely noisy night also needs.
  3. Any consumer that reads raw rrInterval without running the full clean inherits a 44%-off
    outlier.
  4. It pollutes the raw R-R corpus being banked for future R-R/HRV research, where a discrete spike
    at one value is exactly the kind of artifact a later analysis would trip on.

Where it likely originates

The value is emitted at decode/ingest, since it carries a real ts and rides both transports. The
fix is one of: drop a beat that decodes to the fill, or set tsSuspect on it so every reader
inherits the doubt and the cleaners/telemetry can see it. Setting the flag is the smaller, more
conservative change and keeps the raw row for anyone studying the fill itself.

Deliberately not proposing the exact decode site without a second capture confirming 500 is the
literal fill on other firmwares — this is filed as a data observation with a proposed direction, not
a decoder patch.

Environment

WHOOP MG (DIS-attested), fw 50.39.1.0, iOS app 11.8.x build-from-source. Backup analysed locally;
raw values above are counts and interval lengths only — no health content. Reproducible from any
5/MG .noopbak with the query set on request.

Refs #1505, #1008.

Activity

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