From c7a678c328f0f3b7925aa6c93bdf209fcba60bad Mon Sep 17 00:00:00 2001 From: Pipiche Date: Wed, 23 Sep 2026 20:34:44 +0200 Subject: [PATCH] fix(hrv): do not replace a refused main night's HRV with the day's naps The day's HRV is the in-bed-weighted mean of every session's HRV, on the premise that the main overnight dominates. When the #1118 over-count gate refuses the main night, its HRV is nil and carries zero weight, so the naps became 100 % of the figure. Field case: a ring night at 1.31 coverage was refused, and the day's HRV became the mean of two 26-minute naps, (69.51 + 27.90) / 2 = 48.7 ms, which the baseline then folded as a full night. Now, when a main-night-group session's HRV is nil BECAUSE the gate refused it, only the main group is pooled, so the day holds (nil) unless another fragment of that night measured cleanly. A main night that simply banked no R-R is not refused, so #1884's fill-in from the day's other sessions is unchanged. The deep-window mode is untouched. The refusal test is one pure helper on both platforms, SleepStager.sessionHrvOverCounted, which sessionAvgHRV now gates on too, so "refused" has a single definition that the value and the day-level rule cannot disagree about. Tests: new AnalyticsEngineRefusedMainNightHrvTests (Swift) and its Kotlin twin, same fixtures and expected values: refused main night + clean nap -> nil (60.0 without the fix, on both platforms); no-R-R main night + nap -> 60; clean night + nap -> 41.25 as before; helper agrees with the value gate. StrandAnalytics 2053/0. Kotlin twin 4/0, HrvOverCountGateTest 7/0, AnalyticsEngineProvidedSleepTest 5/0; full Android suite shows the same 8 pre-existing failures as upstream/main, none in touched code. doc_comment_lint OK. Refs #1118, #1884 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AvMtCweTWqqM9iyDBz88XX --- .../StrandAnalytics/AnalyticsEngine.swift | 16 +++- .../Sources/StrandAnalytics/SleepStager.swift | 13 ++- ...lyticsEngineRefusedMainNightHrvTests.swift | 83 +++++++++++++++++++ .../com/noop/analytics/AnalyticsEngine.kt | 18 +++- .../java/com/noop/analytics/SleepStager.kt | 15 +++- .../AnalyticsEngineRefusedMainNightHrvTest.kt | 78 +++++++++++++++++ 6 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineRefusedMainNightHrvTests.swift create mode 100644 android/app/src/test/java/com/noop/analytics/AnalyticsEngineRefusedMainNightHrvTest.kt diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index 60235fefde..d68ae3fe96 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -723,7 +723,21 @@ public enum AnalyticsEngine { } return deep.isEmpty ? nil : deep.reduce(0, +) / Double(deep.count) } - let pairs = physiologySessions.compactMap { s -> (Double, Double)? in + // A main night REFUSED by the #1118 over-count gate is not replaced by the day's naps. The + // day-wide pool below rests on "the main overnight dominates" (see `physiologySessions`), which + // holds only while that night has a value: once the gate makes it nil it carries zero weight, + // and a 26-minute nap became 100 % of the day's HRV and was folded into the baseline as a + // night. So when a main-group session's HRV is nil BECAUSE the gate refused it, only the main + // group is pooled, and the day holds (nil) unless another fragment of that night measured + // cleanly. A main night that simply banked no R-R is not refused, so #1884's fill-in from the + // day's other sessions is unchanged. Byte-parity twin of Kotlin `avgHRVDaily`. + let mainNightRefused = mainGroup.contains { s in + s.avgHRV == nil && SleepStager.sessionHrvOverCounted(start: s.start, end: s.end, rr: rr) + } + let hrvPool = mainNightRefused + ? physiologySessions.filter { p in mainGroup.contains { $0.start == p.start && $0.end == p.end } } + : physiologySessions + let pairs = hrvPool.compactMap { s -> (Double, Double)? in s.avgHRV.map { ($0, Double(s.end - s.start)) } } guard !pairs.isEmpty else { return nil } diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift index 8ee34755e1..796fb48d62 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStager.swift @@ -2968,6 +2968,16 @@ public enum SleepStager { // Classified over the SAME beats the value was built from, windowed [start, end] exactly as // `sessionHrvWindows` does, so the verdict cannot describe a different set of beats than the number // it is gating. + guard !sessionHrvOverCounted(start: start, end: end, rr: rr) else { return nil } + return vals.reduce(0, +) / Double(vals.count) + } + + /// Whether the #1118 coverage gate refuses a session's HRV: its own R-R, windowed [start, end] exactly + /// as `sessionAvgHRV` windows it, banks more beat-time than the wall clock allows. Pure. The ONE + /// definition of "refused": `sessionAvgHRV` gates on it, and `AnalyticsEngine` asks it whether a main + /// night's missing HRV was refused rather than never measured, so the two cannot disagree about which + /// nights were refused. Byte-parity twin of Kotlin `SleepStager.sessionHrvOverCounted`. + static func sessionHrvOverCounted(start: Int, end: Int, rr: [RRInterval]) -> Bool { let seg = rr.filter { $0.ts >= start && $0.ts <= end } let segTs = seg.map { $0.ts } let segMs = seg.map { Double($0.rrMs) } @@ -2981,8 +2991,7 @@ public enum SleepStager { // buying a distinction the caller discards would hand that back. `rrCoverage` is a single O(n) // pass. If a future gate ever needs the two over-count cases apart, compute it then. let verdict = HRVAnalyzer.classifyCoverage(coverage: coverage, collapsed: coverage) - guard HRVAnalyzer.successiveDiffIsTrustworthy(verdict) else { return nil } - return vals.reduce(0, +) / Double(vals.count) + return !HRVAnalyzer.successiveDiffIsTrustworthy(verdict) } /// Per-5-min-window RMSSD across a session, each window tagged with the sleep stage at its CENTER diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineRefusedMainNightHrvTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineRefusedMainNightHrvTests.swift new file mode 100644 index 0000000000..cdc98e6845 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/AnalyticsEngineRefusedMainNightHrvTests.swift @@ -0,0 +1,83 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +/// A main night whose HRV the #1118 over-count gate REFUSED must not be replaced by the day's naps. +/// +/// Field case: a ring night read 1.31 coverage, the gate made its HRV nil, and the day's HRV became the +/// mean of two 26-minute naps, which the baseline then folded as a full night. The three cases pin the +/// rule and its boundary: refused ⇒ naps excluded; merely unmeasured (no R-R at all) ⇒ #1884's fill-in +/// from the other sessions is unchanged; clean ⇒ pooled as before. +/// Byte-parity twin of Kotlin `AnalyticsEngineRefusedMainNightHrvTest`: same fixtures, same expected values. +final class AnalyticsEngineRefusedMainNightHrvTests: XCTestCase { + private let profile = UserProfile(weightKg: 75, heightCm: 178, age: 30, sex: "male") + private let day = "2026-07-27" + + private var dayStart: Int { AnalyticsEngine.dayStartUtcSeconds(day) } + private var nightStart: Int { dayStart - 4 * 3600 } // 20:00 the evening before + private var nightEnd: Int { nightStart + 600 * 60 } // 06:00 on `day` + private var napStart: Int { dayStart + 13 * 3600 } // 13:00 on `day` + private var napEnd: Int { napStart + 40 * 60 } // 13:40 + + private func hr(_ start: Int, _ end: Int) -> [HRSample] { + stride(from: start, to: end, by: 30).map { HRSample(ts: $0, bpm: 52 + ($0 / 300) % 4) } + } + + /// One beat per second, alternating around 1000 ms by `swing`: coverage ~1.0, RMSSD = 2 * swing. + /// The night uses 20 (RMSSD 40) and the nap 30 (RMSSD 60), so a pooled day and a nap-only day read + /// differently. + private func cleanRR(_ start: Int, _ end: Int, swing: Int) -> [RRInterval] { + (start.. [RRInterval] { + (start.. [SleepSession] { + [SleepSession(start: nightStart, end: nightEnd, efficiency: 0.9, + stages: [StageSegment(start: nightStart, end: nightEnd, stage: "light")], + restingHR: nil, avgHRV: nil), + SleepSession(start: napStart, end: napEnd, efficiency: 0.9, + stages: [StageSegment(start: napStart, end: napEnd, stage: "light")], + restingHR: nil, avgHRV: nil)] + } + + private func analyze(nightRR: [RRInterval]) -> AnalyticsEngine.DayResult { + AnalyticsEngine.analyzeDay(day: day, hr: hr(nightStart, nightEnd) + hr(napStart, napEnd), + rr: nightRR + cleanRR(napStart, napEnd, swing: 30), profile: profile, + providedSleep: sessions()) + } + + func testARefusedMainNightIsNotReplacedByTheNap() { + let night = overCountedRR(nightStart, nightEnd) + XCTAssertTrue(SleepStager.sessionHrvOverCounted(start: nightStart, end: nightEnd, rr: night), + "precondition: the gate refuses this night") + XCTAssertNotNil(SleepStager.sessionAvgHRV(start: napStart, end: napEnd, rr: cleanRR(napStart, napEnd, swing: 30)), + "precondition: the nap on its own measures an HRV") + XCTAssertNil(analyze(nightRR: night).daily.avgHrv, + "the day holds rather than reporting the nap as the night") + } + + func testAMainNightWithNoRrStillFallsBackToTheNap() { + let res = analyze(nightRR: []) + XCTAssertEqual(res.daily.avgHrv ?? -1, 60, accuracy: 1e-9, + "not refused, only unmeasured: #1884's fill-in from the nap is unchanged") + } + + func testACleanMainNightIsPooledWithTheNapAsBefore() { + let res = analyze(nightRR: cleanRR(nightStart, nightEnd, swing: 20)) + // In-bed-weighted: (40 * 600 min + 60 * 40 min) / 640 min. + XCTAssertEqual(res.daily.avgHrv ?? -1, 41.25, accuracy: 1e-9) + } + + /// The helper is the gate: whatever it says, `sessionAvgHRV` does, on the same beats. + func testTheHelperAgreesWithTheValueGate() { + for rr in [overCountedRR(nightStart, nightEnd), cleanRR(nightStart, nightEnd, swing: 20)] { + XCTAssertEqual(SleepStager.sessionHrvOverCounted(start: nightStart, end: nightEnd, rr: rr), + SleepStager.sessionAvgHRV(start: nightStart, end: nightEnd, rr: rr) == nil) + } + } +} 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 30917ecbb0..9ac452de03 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt @@ -599,7 +599,23 @@ object AnalyticsEngine { } if (deep.isEmpty()) null else deep.sum() / deep.size } else run { - val pairs = physiologySessions.mapNotNull { s -> + // A main night REFUSED by the #1118 over-count gate is not replaced by the day's naps. The + // day-wide pool below rests on "the main overnight dominates" (see physiologySessions), which + // holds only while that night has a value: once the gate makes it null it carries zero weight, + // and a 26-minute nap became 100 % of the day's HRV and was folded into the baseline as a + // night. So when a main-group session's HRV is null BECAUSE the gate refused it, only the main + // group is pooled, and the day holds (null) unless another fragment of that night measured + // cleanly. A main night that simply banked no R-R is not refused, so #1884's fill-in from the + // day's other sessions is unchanged. Byte-parity twin of Swift `avgHRVDaily`. + val mainNightRefused = mainGroup.any { s -> + s.avgHRV == null && SleepStager.sessionHrvOverCounted(s.start, s.end, rr) + } + val hrvPool = if (mainNightRefused) { + physiologySessions.filter { p -> mainGroup.any { it.start == p.start && it.end == p.end } } + } else { + physiologySessions + } + val pairs = hrvPool.mapNotNull { s -> s.avgHRV?.let { it to (s.end - s.start).toDouble() } } if (pairs.isEmpty()) { diff --git a/android/app/src/main/java/com/noop/analytics/SleepStager.kt b/android/app/src/main/java/com/noop/analytics/SleepStager.kt index a5ef9f2a54..775545720c 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepStager.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepStager.kt @@ -3296,6 +3296,18 @@ object SleepStager { // Classified over the SAME beats the value was built from, windowed [start, end] exactly as // `sessionHrvWindows` does, so the verdict cannot describe a different set of beats than the number // it is gating. + if (sessionHrvOverCounted(start, end, rr)) return null + return vals.sum() / vals.size.toDouble() + } + + /** + * Whether the #1118 coverage gate refuses a session's HRV: its own R-R, windowed [start, end] exactly as + * [sessionAvgHRV] windows it, banks more beat-time than the wall clock allows. Pure. The ONE definition + * of "refused": [sessionAvgHRV] gates on it, and AnalyticsEngine asks it whether a main night's missing + * HRV was refused rather than never measured, so the two cannot disagree about which nights were + * refused. Byte-parity twin of Swift `SleepStager.sessionHrvOverCounted`. + */ + internal fun sessionHrvOverCounted(start: Long, end: Long, rr: List): Boolean { val seg = rr.filter { it.ts in start..end } val segTs = seg.map { it.ts } val segMs = seg.map { it.rrMs.toDouble() } @@ -3309,8 +3321,7 @@ object SleepStager { // night to two, and buying a distinction the caller discards would hand that back. `rrCoverage` is // a single O(n) pass. If a future gate ever needs the two over-count cases apart, compute it then. val verdict = HrvAnalyzer.classifyCoverage(coverage, coverage) - if (!HrvAnalyzer.successiveDiffIsTrustworthy(verdict)) return null - return vals.sum() / vals.size.toDouble() + return !HrvAnalyzer.successiveDiffIsTrustworthy(verdict) } /** diff --git a/android/app/src/test/java/com/noop/analytics/AnalyticsEngineRefusedMainNightHrvTest.kt b/android/app/src/test/java/com/noop/analytics/AnalyticsEngineRefusedMainNightHrvTest.kt new file mode 100644 index 0000000000..d393a40c24 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/AnalyticsEngineRefusedMainNightHrvTest.kt @@ -0,0 +1,78 @@ +package com.noop.analytics + +import com.noop.data.HrSample +import com.noop.data.RrInterval +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate +import java.time.ZoneOffset + +/** + * A main night whose HRV the #1118 over-count gate REFUSED must not be replaced by the day's naps. + * + * Field case: a ring night read 1.31 coverage, the gate made its HRV null, and the day's HRV became the mean + * of two 26-minute naps, which the baseline then folded as a full night. The cases pin the rule and its + * boundary: refused ⇒ naps excluded; merely unmeasured (no R-R at all) ⇒ #1884's fill-in from the other + * sessions is unchanged; clean ⇒ pooled as before. + * Byte-parity twin of Swift `AnalyticsEngineRefusedMainNightHrvTests`: same fixtures, same expected values. + */ +class AnalyticsEngineRefusedMainNightHrvTest { + private val profile = UserProfile(weightKg = 75.0, heightCm = 178.0, age = 30.0, sex = "male") + private val day = "2026-07-27" + private val dayStart = LocalDate.parse(day).atStartOfDay(ZoneOffset.UTC).toEpochSecond() + private val nightStart = dayStart - 4 * 3600 // 20:00 the evening before + private val nightEnd = nightStart + 600 * 60 // 06:00 on `day` + private val napStart = dayStart + 13 * 3600 // 13:00 on `day` + private val napEnd = napStart + 40 * 60 // 13:40 + + private fun hr(start: Long, end: Long) = + (start until end step 30).map { HrSample("t", it, 52 + ((it / 300) % 4).toInt()) } + + /** One beat per second, alternating around 1000 ms by [swing]: coverage ~1.0, RMSSD = 2 * swing. */ + private fun cleanRR(start: Long, end: Long, swing: Int) = + (start until end).map { RrInterval("t", it, if ((it - start) % 2 == 0L) 1000 - swing else 1000 + swing) } + + /** Two beats in every second (880 + 960 ms): ~1.84x the wall clock, refused by the gate. */ + private fun overCountedRR(start: Long, end: Long) = + (start until end).flatMap { listOf(RrInterval("t", it, 880), RrInterval("t", it, 960)) } + + private fun sessions() = listOf( + DetectedSleep(nightStart, nightEnd, 0.9, listOf(StageSegment(nightStart, nightEnd, "light")), + restingHR = null, avgHRV = null), + DetectedSleep(napStart, napEnd, 0.9, listOf(StageSegment(napStart, napEnd, "light")), + restingHR = null, avgHRV = null), + ) + + private fun analyze(nightRR: List) = AnalyticsEngine.analyzeDay( + day = day, hr = hr(nightStart, nightEnd) + hr(napStart, napEnd), + rr = nightRR + cleanRR(napStart, napEnd, swing = 30), profile = profile, providedSleep = sessions(), + ) + + @Test fun aRefusedMainNightIsNotReplacedByTheNap() { + val night = overCountedRR(nightStart, nightEnd) + assertTrue("precondition: the gate refuses this night", + SleepStager.sessionHrvOverCounted(nightStart, nightEnd, night)) + assertNotNull("precondition: the nap on its own measures an HRV", + SleepStager.sessionAvgHRV(napStart, napEnd, cleanRR(napStart, napEnd, swing = 30))) + assertNull("the day holds rather than reporting the nap as the night", analyze(night).daily.avgHrv) + } + + @Test fun aMainNightWithNoRrStillFallsBackToTheNap() { + assertEquals(60.0, analyze(emptyList()).daily.avgHrv!!, 1e-9) + } + + @Test fun aCleanMainNightIsPooledWithTheNapAsBefore() { + // In-bed-weighted: (40 * 600 min + 60 * 40 min) / 640 min. + assertEquals(41.25, analyze(cleanRR(nightStart, nightEnd, swing = 20)).daily.avgHrv!!, 1e-9) + } + + @Test fun theHelperAgreesWithTheValueGate() { + for (rr in listOf(overCountedRR(nightStart, nightEnd), cleanRR(nightStart, nightEnd, swing = 20))) { + assertEquals(SleepStager.sessionHrvOverCounted(nightStart, nightEnd, rr), + SleepStager.sessionAvgHRV(nightStart, nightEnd, rr) == null) + } + } +}