From 4dee9161b4f32538a83902a5a04920f08539a17a Mon Sep 17 00:00:00 2001 From: Pipiche Date: Wed, 23 Sep 2026 20:59:45 +0200 Subject: [PATCH] log(hrv): describe the main night the gate judged in the hrv diag line The always-on `hrv diag` line pooled every sleep session of the day and divided their beat-time by first-to-last beat, so the hours between a night and a nap entered the denominator. On a real day whose main night the #1118 gate refused at 1.31 coverage, the line printed `coverage=0.48 rrIntegrity=underCovered`: it called a night refused for an over-count "not an over-count". `hrvOverCounted` (the HRV card's unverified flag) and the RSA resp gate read the same rows, so they sided with the log against the gate. `analyzeDay` now exposes the main-night group it already scores from (`DayResult.mainNightBlocks`, the same `mainGroup`), and a pure `AnalyticsEngine.hrvDiagnosticRows` selects that group's beats over the gate's own inclusive [start, end] window. On a one-block night that is exactly the set `SleepStager.sessionHrvOverCounted` judged, so the over-count half of the verdict cannot disagree with the gate. A day with no main night keeps the old pooled, half-open set. The line's format is unchanged; its fields now describe the main night. Stacked on the refused-main-night fix, which introduces `sessionHrvOverCounted`. Tests: HrvDiagnosticRowsTests (Swift) and its Kotlin twin, same fixtures and expected values: the DayResult carries the scoring main group; on a refused night the selected rows are the night's only and classify as an over-count, agreeing with the gate, while the old pooled selection reads `plausible`; no main night keeps the half-open fallback. StrandAnalytics 2056/0; Kotlin twin 3/0; full Android suite shows the same 8 pre-existing failures as upstream/main. macOS `Strand` and iOS `NOOPiOS` both build. doc_comment_lint OK. Refs #2425, #1118 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AvMtCweTWqqM9iyDBz88XX --- .../StrandAnalytics/AnalyticsEngine.swift | 34 ++++++++- .../HrvDiagnosticRowsTests.swift | 71 +++++++++++++++++ Strand/Data/IntelligenceEngine.swift | 7 +- .../com/noop/analytics/AnalyticsEngine.kt | 26 +++++++ .../com/noop/analytics/AnalyticsModels.kt | 7 ++ .../com/noop/analytics/IntelligenceEngine.kt | 8 +- .../noop/analytics/HrvDiagnosticRowsTest.kt | 76 +++++++++++++++++++ 7 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HrvDiagnosticRowsTests.swift create mode 100644 android/app/src/test/java/com/noop/analytics/HrvDiagnosticRowsTest.kt diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index d68ae3fe96..1c6f4660ff 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -110,6 +110,10 @@ public enum AnalyticsEngine { /// so the caller persists NULL there rather than a fabricated array. Feeds the H7 re-onset CONFIRM /// guard on the NEXT pass; never overrides the derived hypnogram. Empty on a WHOOP 4.0. (#175) public let sessionSleepStateByStart: [Int: [Int]] + /// The bounds of the day's MAIN-night group, the SAME `mainGroup` the sleep aggregates and the + /// refused-main-night HRV rule use; empty when the day has no main night. Exposed so the `hrv diag` + /// line can describe the night the #1118 gate actually judged instead of re-deriving it (#2425). + public let mainNightBlocks: [SleepStageTotals.NightBlock] public init(daily: DailyMetric, sleepSessions: [SleepSession], cachedSleep: [CachedSleepSession], workouts: [ExerciseSession], @@ -122,8 +126,10 @@ public enum AnalyticsEngine { sessionSleepStateByStart: [Int: [Int]] = [:], chargeDrivers: [ChargeDriver] = [], skinTempRelative: SkinTempRelative? = nil, - detectionFunnel: WorkoutDetector.DetectionFunnel? = nil) { + detectionFunnel: WorkoutDetector.DetectionFunnel? = nil, + mainNightBlocks: [SleepStageTotals.NightBlock] = []) { self.daily = daily; self.sleepSessions = sleepSessions + self.mainNightBlocks = mainNightBlocks self.cachedSleep = cachedSleep; self.workouts = workouts self.detectionFunnel = detectionFunnel self.recovery = recovery; self.strain = strain @@ -1091,7 +1097,31 @@ public enum AnalyticsEngine { sessionSleepStateByStart: sessionSleepStateByStart, chargeDrivers: chargeDrivers, skinTempRelative: skinTempRelative, - detectionFunnel: detectionFunnel) + detectionFunnel: detectionFunnel, + mainNightBlocks: mainGroup.map { SleepStageTotals.NightBlock(start: $0.start, end: $0.end) }) + } + + /// The R-R rows the always-on `hrv diag` line describes (#2425). Pure. Byte-parity twin of Kotlin + /// `AnalyticsEngine.hrvDiagnosticRows`. + /// + /// The line used to pool every sleep session of the day and divide their beat-time by first-to-last + /// beat, so the hours BETWEEN a night and a nap entered the denominator. On a real day whose main night + /// the #1118 gate refused at 1.31 coverage, the pooled line printed `coverage=0.48 + /// rrIntegrity=underCovered`: the log said "not an over-count" about a night refused for one, and the + /// HRV card's over-count flag, read from the same verdict, agreed with the log rather than the gate. + /// + /// So the line now reads the MAIN-night group (`DayResult.mainNightBlocks`, the same group scoring uses) + /// over the gate's own inclusive `[start, end]` window. On a one-block night that is exactly the set of + /// beats `SleepStager.sessionHrvOverCounted` judged, so the over-count half of the verdict cannot + /// disagree with the gate. A bridged multi-fragment night still spans its short bridge gap, which can + /// only pull coverage DOWN. A day with no main night keeps the old pooled set (`fallback`), since there + /// is no night to describe. + public static func hrvDiagnosticRows(_ rr: [RRInterval], mainNight: [SleepStageTotals.NightBlock], + fallback: [SleepStageTotals.NightBlock]) -> [RRInterval] { + if mainNight.isEmpty { + return rr.filter { r in fallback.contains { r.ts >= $0.start && r.ts < $0.end } } + } + return rr.filter { r in mainNight.contains { r.ts >= $0.start && r.ts <= $0.end } } } // MARK: - Rest composite (Charge/Effort/Rest) diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HrvDiagnosticRowsTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HrvDiagnosticRowsTests.swift new file mode 100644 index 0000000000..568d711686 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/HrvDiagnosticRowsTests.swift @@ -0,0 +1,71 @@ +import XCTest +@testable import StrandAnalytics +import WhoopProtocol + +/// #2425: the `hrv diag` line must describe the night the #1118 gate judged. It used to pool every session +/// of the day over first-to-last beat, gaps included, and on a refused night printed a verdict that was not +/// an over-count. Asserted as an AGREEMENT with the gate's own helper, not against a fixed label. +/// Byte-parity twin of Kotlin `HrvDiagnosticRowsTest`: same fixtures, same expected values. +final class HrvDiagnosticRowsTests: 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 } + private var nightEnd: Int { nightStart + 600 * 60 } + private var napStart: Int { dayStart + 13 * 3600 } + private var napEnd: Int { napStart + 40 * 60 } + + private func verdict(_ rows: [RRInterval]) -> HRVAnalyzer.RrCoverageVerdict { + let ts = rows.map(\.ts), ms = rows.map { Double($0.rrMs) } + return HRVAnalyzer.classifyCoverage(coverage: HRVAnalyzer.rrCoverage(tsSec: ts, rrMs: ms), + collapsed: HRVAnalyzer.collapsedCoverage(tsSec: ts, rrMs: ms)) + } + + /// Night: two beats every second (refused). Nap: one per second (clean). + private func dayResult() -> (AnalyticsEngine.DayResult, [RRInterval]) { + let night = (nightStart..= $0.startTs && r.ts < $0.endTs } } + // #2425: the MAIN night the #1118 gate judged, not every session of the day pooled over the + // gaps between them; see `AnalyticsEngine.hrvDiagnosticRows`. `hrvOverCounted` and the RSA + // resp gate below read the same rows, so they now agree with the gate too. + let sleepRrRows = AnalyticsEngine.hrvDiagnosticRows( + rr, mainNight: res.mainNightBlocks, + fallback: res.cachedSleep.map { SleepStageTotals.NightBlock(start: $0.startTs, end: $0.endTs) }) let sleepRr = sleepRrRows.map { Double($0.rrMs) } let hrvDiag: String? let hrvOverCounted: Bool? // #1118: nil = no in-sleep R-R (no HRV to caveat) 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 9ac452de03..2f08417ad9 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt @@ -1023,9 +1023,35 @@ object AnalyticsEngine { sessionSleepStateByStart = sessionSleepStateByStart, gravitySparse = gravitySparse, detectionFunnel = detectionFunnel, + mainNightBlocks = mainGroup.map { SleepStageTotals.NightBlock(it.start, it.end) }, ) } + /** + * The R-R rows the always-on `hrv diag` line describes (#2425). Pure. Byte-parity twin of Swift + * `AnalyticsEngine.hrvDiagnosticRows`. + * + * The line used to pool every sleep session of the day and divide their beat-time by first-to-last beat, + * so the hours BETWEEN a night and a nap entered the denominator. On a real day whose main night the + * #1118 gate refused at 1.31 coverage, the pooled line printed `coverage=0.48 rrIntegrity=underCovered`: + * the log said "not an over-count" about a night refused for one, and the HRV card's over-count flag, + * read from the same verdict, agreed with the log rather than the gate. + * + * So the line now reads the MAIN-night group ([DayResult.mainNightBlocks], the same group scoring uses) + * over the gate's own inclusive `[start, end]` window. On a one-block night that is exactly the set of + * beats [SleepStager.sessionHrvOverCounted] judged, so the over-count half of the verdict cannot disagree + * with the gate. A bridged multi-fragment night still spans its short bridge gap, which can only pull + * coverage DOWN. A day with no main night keeps the old pooled set ([fallback]), since there is no night + * to describe. + */ + fun hrvDiagnosticRows( + rr: List, + mainNight: List, + fallback: List, + ): List = + if (mainNight.isEmpty()) rr.filter { r -> fallback.any { r.ts >= it.start && r.ts < it.end } } + else rr.filter { r -> mainNight.any { r.ts >= it.start && r.ts <= it.end } } + /** Round to 2 decimal places (matches the imported/demo skin-temp deviation precision). (PR #85) */ private fun round2(v: Double): Double = kotlin.math.round(v * 100.0) / 100.0 diff --git a/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt b/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt index db7e9ca727..6480c88a17 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsModels.kt @@ -328,4 +328,11 @@ data class DayResult( * the counts exist to explain. Trailing + defaulted so every existing construction site is unchanged. */ val detectionFunnel: WorkoutDetector.DetectionFunnel? = null, + /** + * The bounds of the day's MAIN-night group, the SAME `mainGroup` the sleep aggregates and the + * refused-main-night HRV rule use; empty when the day has no main night. Exposed so the `hrv diag` line + * can describe the night the #1118 gate actually judged instead of re-deriving it (#2425). Trailing + + * defaulted so every existing construction site is unchanged. Mirrors Swift `DayResult.mainNightBlocks`. + */ + val mainNightBlocks: List = emptyList(), ) diff --git a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index 06ef62225c..82501e07a7 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -1211,7 +1211,13 @@ object IntelligenceEngine { // `nInput` is set before the min-beats gate, so a sparse night still shows its count with // rmssd=nil). A SEPARATE analyzeRaw pass over the in-sleep R-R — does NOT touch the shipped // windowed avgHrv. Emitted here where `rr` is in scope; byte-identical to the Swift line. - val sleepRrRows = rr.filter { r -> res.sleepSessions.any { r.ts >= it.start && r.ts < it.end } } + // #2425: the MAIN night the #1118 gate judged, not every session of the day pooled over the gaps + // between them; see AnalyticsEngine.hrvDiagnosticRows. hrvOverCounted and the RSA resp gate read + // the same rows, so they now agree with the gate too. + val sleepRrRows = AnalyticsEngine.hrvDiagnosticRows( + rr, res.mainNightBlocks, + res.sleepSessions.map { SleepStageTotals.NightBlock(it.start, it.end) }, + ) val sleepRr = sleepRrRows.map { it.rrMs.toDouble() } // #1331: the RSA gate's inputs, carried to the resp diagnostic below. Declared out here because // the HRV block is one scope deeper; a night with no sleep R-R leaves them null and the resp diff --git a/android/app/src/test/java/com/noop/analytics/HrvDiagnosticRowsTest.kt b/android/app/src/test/java/com/noop/analytics/HrvDiagnosticRowsTest.kt new file mode 100644 index 0000000000..61f932d2a2 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/HrvDiagnosticRowsTest.kt @@ -0,0 +1,76 @@ +package com.noop.analytics + +import com.noop.data.HrSample +import com.noop.data.RrInterval +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.LocalDate +import java.time.ZoneOffset + +/** + * #2425: the `hrv diag` line must describe the night the #1118 gate judged. It used to pool every session of + * the day over first-to-last beat, gaps included, and on a refused night printed a verdict that was not an + * over-count. Asserted as an AGREEMENT with the gate's own helper, not against a fixed label. + * Byte-parity twin of Swift `HrvDiagnosticRowsTests`: same fixtures, same expected values. + */ +class HrvDiagnosticRowsTest { + 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 + private val nightEnd = nightStart + 600 * 60 + private val napStart = dayStart + 13 * 3600 + private val napEnd = napStart + 40 * 60 + + private fun verdict(rows: List): HrvAnalyzer.RrCoverageVerdict { + val ts = rows.map { it.ts } + val ms = rows.map { it.rrMs.toDouble() } + return HrvAnalyzer.classifyCoverage(HrvAnalyzer.rrCoverage(ts, ms), HrvAnalyzer.collapsedCoverage(ts, ms)) + } + + /** Night: two beats every second (refused). Nap: one per second (clean). */ + private fun dayResult(): Pair> { + val night = (nightStart until nightEnd).flatMap { listOf(RrInterval("t", it, 880), RrInterval("t", it, 960)) } + val nap = (napStart until napEnd).map { RrInterval("t", it, if ((it - napStart) % 2 == 0L) 970 else 1030) } + val hr = (nightStart until napEnd step 30).map { HrSample("t", it, 55) } + val 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), + ) + val rr = night + nap + return AnalyticsEngine.analyzeDay(day = day, hr = hr, rr = rr, profile = profile, + providedSleep = sessions) to rr + } + + @Test fun theDayResultCarriesTheMainNightGroupScoringUsed() { + val (res, _) = dayResult() + assertEquals(listOf(nightStart), res.mainNightBlocks.map { it.start }) + assertEquals(listOf(nightEnd), res.mainNightBlocks.map { it.end }) + } + + @Test fun theDiagnosticVerdictAgreesWithTheGateOnARefusedNight() { + val (res, rr) = dayResult() + val fallback = res.sleepSessions.map { SleepStageTotals.NightBlock(it.start, it.end) } + val rows = AnalyticsEngine.hrvDiagnosticRows(rr, res.mainNightBlocks, fallback) + assertEquals("the night's beats only, the nap is not pooled in", 2 * 600 * 60, rows.size) + assertTrue("precondition: the gate refuses this night", + SleepStager.sessionHrvOverCounted(nightStart, nightEnd, rr)) + assertFalse("the line must call it an over-count, as the gate did", + HrvAnalyzer.successiveDiffIsTrustworthy(verdict(rows))) + // The old selection, every session pooled over the 7-hour gap: 1.08, PLAUSIBLE. + val pooled = AnalyticsEngine.hrvDiagnosticRows(rr, emptyList(), fallback) + assertEquals(HrvAnalyzer.RrCoverageVerdict.PLAUSIBLE, verdict(pooled)) + } + + @Test fun aDayWithNoMainNightKeepsThePooledHalfOpenSet() { + val rr = listOf(RrInterval("t", 100, 1000), RrInterval("t", 150, 1000), RrInterval("t", 200, 1000)) + val rows = AnalyticsEngine.hrvDiagnosticRows(rr, emptyList(), listOf(SleepStageTotals.NightBlock(100, 200))) + assertEquals("fallback keeps the old half-open [start, end) window", listOf(100L, 150L), rows.map { it.ts }) + val main = AnalyticsEngine.hrvDiagnosticRows(rr, listOf(SleepStageTotals.NightBlock(100, 200)), emptyList()) + assertEquals("main night uses the gate's inclusive [start, end]", listOf(100L, 150L, 200L), main.map { it.ts }) + } +}