From 54ea87d74f373c081e03f5b9361b2cf536b963a0 Mon Sep 17 00:00:00 2001 From: Mohamed Yasser Date: Sun, 20 Sep 2026 12:48:44 +0300 Subject: [PATCH 1/3] fix(analytics): wire primary-session resting HR to fix nap floor distortion (#1169) Wires primarySessionRestingHR into restingHRDaily on both Swift and Kotlin AnalyticsEngine engines. When valid samples exist in the primary sleep session, calculates resting HR as the sample mean of the longest/primary sleep window (resolving issue #1169 and nap-induced floor distortion). Cleanly falls back to the previous session minimum if primary session coverage is sparse. Validated via Tools/parity_ledger.py and Tools/parity_ratchet.py --base origin/main --offline (0 errors, decreases test-only-callsite debt). --- .../Sources/StrandAnalytics/AnalyticsEngine.swift | 6 +++++- .../app/src/main/java/com/noop/analytics/AnalyticsEngine.kt | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index dfd7b9f04b..f264d36da6 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -699,7 +699,11 @@ public enum AnalyticsEngine { // call site" a scattered filter invites. let physiologyOnly = matched.filter { !$0.hrOnly } let physiologySessions = physiologyOnly.isEmpty ? matched : physiologyOnly - let restingHRDaily = physiologySessions.compactMap { $0.restingHR }.min() + // Resting Heart Rate: Use PrimarySessionRestingHR (arithmetic sample mean of the longest/primary + // sleep session, #1169), eliminating daytime nap floor distortion. + // Cleanly falls back to physiologySessions.compactMap { $0.restingHR }.min() when coverage is sparse. + let restingHRDaily: Int? = primarySessionRestingHR(sessions: physiologySessions, hr: hr).map { Int($0.rounded()) } + ?? physiologySessions.compactMap { $0.restingHR }.min() // Daily avg HRV = in-bed-weighted mean of per-session avg HRV. let avgHRVDaily: Double? = { if deepHrvWindow { diff --git a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt index 863de4de39..458f052590 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt @@ -576,7 +576,11 @@ object AnalyticsEngine { // only way to scope them is through the session set itself — which is precisely the "one forgotten // call site" a scattered filter invites. val physiologySessions = matched.filter { !it.hrOnly }.ifEmpty { matched } - val restingHRDaily: Int? = physiologySessions.mapNotNull { it.restingHR }.minOrNull() + // Resting Heart Rate: Use PrimarySessionRestingHR (arithmetic sample mean of the longest/primary + // sleep session, #1169), eliminating daytime nap floor distortion. + // Cleanly falls back to physiologySessions.mapNotNull { it.restingHR }.minOrNull() when coverage is sparse. + val restingHRDaily: Int? = primarySessionRestingHR(physiologySessions, hr)?.roundToInt() + ?: physiologySessions.mapNotNull { it.restingHR }.minOrNull() // Daily avg HRV = in-bed-weighted mean of per-session avg HRV. val avgHRVDaily: Double? = if (deepHrvWindow) { // #141: WHOOP-style HRV — pool RMSSD over DEEP-stage 5-min windows only (slow-wave sleep), From dcf73b1ba5784eab7ffe7f3d51ea156d249f8b20 Mon Sep 17 00:00:00 2001 From: Mohamed Yasser Date: Sun, 20 Sep 2026 15:39:20 +0300 Subject: [PATCH 2/3] fix(analytics): preserve ring/device-provided resting HR when present in providedSleep (#804) --- .../Sources/StrandAnalytics/AnalyticsEngine.swift | 6 +++++- .../app/src/main/java/com/noop/analytics/AnalyticsEngine.kt | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index f264d36da6..60235fefde 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -701,8 +701,12 @@ public enum AnalyticsEngine { let physiologySessions = physiologyOnly.isEmpty ? matched : physiologyOnly // Resting Heart Rate: Use PrimarySessionRestingHR (arithmetic sample mean of the longest/primary // sleep session, #1169), eliminating daytime nap floor distortion. + // #804: Preserve ring/device-provided resting HR when present in `providedSleep`. // Cleanly falls back to physiologySessions.compactMap { $0.restingHR }.min() when coverage is sparse. - let restingHRDaily: Int? = primarySessionRestingHR(sessions: physiologySessions, hr: hr).map { Int($0.rounded()) } + let providedPrimaryRHR = physiologySessions.max(by: { ($0.end - $0.start) < ($1.end - $1.start) }) + .flatMap { p in providedSleep.first(where: { $0.start == p.start && $0.end == p.end })?.restingHR } + let restingHRDaily: Int? = providedPrimaryRHR + ?? primarySessionRestingHR(sessions: physiologySessions, hr: hr).map { Int($0.rounded()) } ?? physiologySessions.compactMap { $0.restingHR }.min() // Daily avg HRV = in-bed-weighted mean of per-session avg HRV. let avgHRVDaily: Double? = { diff --git a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt index 458f052590..30917ecbb0 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt @@ -578,8 +578,12 @@ object AnalyticsEngine { val physiologySessions = matched.filter { !it.hrOnly }.ifEmpty { matched } // Resting Heart Rate: Use PrimarySessionRestingHR (arithmetic sample mean of the longest/primary // sleep session, #1169), eliminating daytime nap floor distortion. + // #804: Preserve ring/device-provided resting HR when present in `providedSleep`. // Cleanly falls back to physiologySessions.mapNotNull { it.restingHR }.minOrNull() when coverage is sparse. - val restingHRDaily: Int? = primarySessionRestingHR(physiologySessions, hr)?.roundToInt() + val providedPrimaryRHR = physiologySessions.maxByOrNull { it.end - it.start } + ?.let { p -> providedSleep.firstOrNull { it.start == p.start && it.end == p.end }?.restingHR } + val restingHRDaily: Int? = providedPrimaryRHR + ?: primarySessionRestingHR(physiologySessions, hr)?.roundToInt() ?: physiologySessions.mapNotNull { it.restingHR }.minOrNull() // Daily avg HRV = in-bed-weighted mean of per-session avg HRV. val avgHRVDaily: Double? = if (deepHrvWindow) { From cb079325b09e2c00aa0bb6e90ae7c05daafc6125 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Wed, 23 Sep 2026 18:49:03 +1200 Subject: [PATCH 3/3] analytics: say that PrimarySessionRestingHR is wired, because it now is The helper's doc block was headed "Deliberately PURE and UNWIRED" and stated that nothing consumes it, that switching the consumers is a re-baselining of core scores, and that the issue asks for a larger multi-participant holdout first. The commit under this one performs that switch: restingHRDaily now prefers a device-provided primary-session value, then this mean, and falls back to the old floor only when coverage is sparse. Leaving the block in place would put a doc asserting nothing consumes the helper directly above a helper that sets the headline resting HR, and with it recovery, strain, workout detection and energy. Rewritten on both platforms to say what is true: that it is wired, that the switch was a maintainer call made ahead of the holdout the issue asks for, that the MAE evidence is one participant over five nights, and that #2284 changes the statistic the fallback rests on, so the baseline those figures were measured against will not survive unchanged. --- .../PrimarySessionRestingHR.swift | 19 +++++++++++++------ .../noop/analytics/PrimarySessionRestingHR.kt | 16 +++++++++++----- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/PrimarySessionRestingHR.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/PrimarySessionRestingHR.swift index 5eac24c4e1..c64eac27fe 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/PrimarySessionRestingHR.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/PrimarySessionRestingHR.swift @@ -10,12 +10,19 @@ import Foundation /// references, a pre-declared dev/holdout split, no fitted offset) found the primary-session sample mean /// tracked both references far better: rounded MAE vs the official target 6.0→2.0 (dev) / 7.5→0.8 (holdout). /// -/// ## Deliberately PURE and UNWIRED -/// This computes the metric and is unit-tested, but **nothing consumes it yet**. The shipped headline AND the -/// recovery / strain / workout-detection / energy inputs all read `restingHRDaily` (the floor) in -/// `AnalyticsEngine`, so switching them is a re-baselining of core scores — which the issue itself says needs -/// a larger multi-participant, pre-declared holdout first. That is out of scope here; this lands the -/// transparent, testable definition so that validation and any later wiring have something concrete to use. +/// ## WIRED as of #2358 — this sets the shipped daily resting HR +/// It was landed pure and unwired, on the reasoning that switching the consumers is a re-baselining of core +/// scores and that the issue asks for a larger multi-participant holdout first. #2358 made the switch anyway, +/// as a maintainer call: `AnalyticsEngine.restingHRDaily` now prefers a device-provided primary-session value, +/// then THIS mean, and falls back to the old `restingHR.min()` floor only when coverage is sparse. So the +/// headline resting HR and everything reading it — recovery, strain, workout detection, energy — come from +/// here on any day with a covered primary session. +/// +/// What that means for the evidence below: the MAE figures are from ONE participant over five nights against +/// a pre-declared split, which is the holdout the issue says is not yet large enough. They justified building +/// the metric; they are thinner than the change they now carry. #2284 separately replaces what a session's +/// `restingHR` IS (deep-sleep mean rather than lowest 5-minute bin), which moves the `.min()` fallback under +/// this, so the comparison baseline those numbers were measured against no longer exists unchanged. /// /// ## Definition (documented per the issue) /// - **Primary session**: the LONGEST session by duration; ties resolve to the FIRST (stable). A shorter nap diff --git a/android/app/src/main/java/com/noop/analytics/PrimarySessionRestingHR.kt b/android/app/src/main/java/com/noop/analytics/PrimarySessionRestingHR.kt index 95c5c0f5e6..20e8d98e56 100644 --- a/android/app/src/main/java/com/noop/analytics/PrimarySessionRestingHR.kt +++ b/android/app/src/main/java/com/noop/analytics/PrimarySessionRestingHR.kt @@ -11,11 +11,17 @@ package com.noop.analytics * split, no fitted offset) found the primary-session sample mean tracked both far better: rounded MAE vs the * official target 6.0->2.0 (dev) / 7.5->0.8 (holdout). * - * ## Deliberately PURE and UNWIRED - * This computes the metric and is unit-tested, but nothing consumes it yet. The shipped headline AND the - * recovery / strain / workout-detection / energy inputs all read the floor `restingHRDaily`, so switching - * them is a re-baselining of core scores that the issue itself says needs a larger multi-participant, - * pre-declared holdout first. Out of scope here; this lands the transparent, testable definition. + * ## WIRED as of #2358 — this sets the shipped daily resting HR + * It was landed pure and unwired, on the reasoning that switching the consumers is a re-baselining of + * core scores and that the issue asks for a larger multi-participant holdout first. #2358 made the + * switch anyway, as a maintainer call: `AnalyticsEngine.restingHRDaily` now prefers a device-provided + * primary-session value, then THIS mean, and falls back to the old `restingHR.min()` floor only when + * coverage is sparse. So the headline resting HR and everything reading it — recovery, strain, workout + * detection, energy — come from here on any day with a covered primary session. + * + * What that means for the evidence below: the MAE figures are from ONE participant over five nights + * against a pre-declared split, which is the holdout the issue says is not yet large enough. #2284 + * separately replaces what a session's `restingHR` IS, which moves the fallback under this. * * ## Definition * - **Primary session**: the LONGEST by duration; ties resolve to the FIRST. A nap never replaces the night.