Skip to content

[P1] N-08: Production resting-HR path has no artifact gate — a single-sample bin can set daily RHR #10

Open

Description

@bhelm

Severity: P1 — a single-sample bin can become the day's resting HR and feed the recovery score.
From an external audit of 46cf3e5; re-verified against current main (f2271cd). The production path is worse than the audit describes.

Problem

The audit claimed: the gated resting-HR helper falls back to the minimum of all bin means — including the bins it just rejected — whenever no bin qualifies. That is true, but that helper isn't what production runs. The actual production path has no gate at all: any non-empty 5-minute bin (even one sample) can set the session minimum, which becomes the daily resting HR and enters recovery scoring.

Evidence (current main)

  • Gated helper + self-defeating fallback (currently without a production call site): Packages/StrandAnalytics/Sources/StrandAnalytics/RecoveryScorer.swift:270–283 — if let m = qualified.min() { floor = m } else if let m = means.min() { /* No bin cleared the artifact bar */ floor = m }. Kotlin twin: RecoveryScorer.kt:323–325.
  • Bar is thin even when applied: restingHRMinBinSamples = 5 for a 5-minute bin on a ~1 Hz stream (RecoveryScorer.swift:227–231, 270; RecoveryScorer.kt:264–269, 317) — the comment itself says a real bin holds hundreds of samples.
  • Ungated production path: Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift:2441–2445 — if !win.isEmpty { means.append(…) } … if let m = means.min() { return … } — no sample-count or plausibility check. Session values are then min-reduced to the day (AnalyticsEngine.swift:585) and passed as rhr into RecoveryScorer.recovery (AnalyticsEngine.swift:710–718). Kotlin mirrors all of it: SleepStager.kt:2655–2660, AnalyticsEngine.kt:479, 644–652.
  • Related dead surface: the recovery-index slope accepts any 6 non-empty bins (RecoveryScorer.swift:295, 317–325; Kotlin :340, 367–375), and the optional recoveryIndexSlope/effortBaseline/priorDayEffort parameters are never supplied by production callers — documentation implying they shape the score is misleading.

Fix

  • One resting-HR implementation, used by production: require bin sample-density/elapsed coverage and plausibility; return nil when no bin qualifies (surface "insufficient data" instead of an artifact number).
  • Apply the same gates to the slope bins; either wire the optional recovery-index/effort terms with persisted inputs + validation, or remove the public parameters.
  • Report coverage alongside the value.

Both platforms in one PR (parity contract).

Activity

  1. bhelm commented on Aug 17, 2026

    @bhelm
    OwnerAuthor

    Upstream context: ryanbr#550 (open) — "Resting heart rate too low and HRV too high" on Android — reports exactly the symptom this mechanism predicts: the ungated SleepStager bin minimum lets a thin/artifact bin set the session floor, and the daily min-reduction propagates it. That issue is a symptom report without a root cause; this one is a plausible root cause for its RHR half.

  2. bhelm commented on Aug 18, 2026

    @bhelm
    OwnerAuthor

    A Recovery parity audit found a deterministic fixture for the remaining Recovery Index part already described in this issue, so I am keeping it here rather than opening a duplicate.

    Both Swift and Kotlin recoveryIndexSlope accept every non-empty five-minute bin, even a single sample or a sub-physiological mean. Six lone samples at t = 0, 300, 600, 900, 1200, 1500 with BPM 60, 59, 58, 57, 56, 55 produce a slope of -12 bpm/hour; restingHR would reject those bins because each has fewer than five samples. This confirms that the two helpers currently share bin boundaries but not qualification/coverage rules.

    Acceptance for this part of #10: apply one documented qualification/coverage policy to Recovery Index on both platforms, and add paired tests at five/six qualified bins plus single-sample and sub-25-bpm bins. The reproduction is synthetic and needs no hardware testing.

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