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-08: Production resting-HR path has no artifact gate — a single-sample bin can set daily RHR #10
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.
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.
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.
Activity
bhelm commentedon Aug 17, 2026
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
SleepStagerbin 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.bhelm commentedon Aug 18, 2026
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
recoveryIndexSlopeaccept every non-empty five-minute bin, even a single sample or a sub-physiological mean. Six lone samples att = 0, 300, 600, 900, 1200, 1500with BPM60, 59, 58, 57, 56, 55produce a slope of-12 bpm/hour;restingHRwould 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.