Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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..<nightEnd).flatMap { [RRInterval(ts: $0, rrMs: 880), RRInterval(ts: $0, rrMs: 960)] }
let nap = (napStart..<napEnd).map { RRInterval(ts: $0, rrMs: ($0 - napStart) % 2 == 0 ? 970 : 1_030) }
let hr = stride(from: nightStart, to: napEnd, by: 30).map { HRSample(ts: $0, bpm: 55) }
let sessions = [
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),
]
let rr = night + nap
return (AnalyticsEngine.analyzeDay(day: day, hr: hr, rr: rr, profile: profile, providedSleep: sessions), rr)
}

func testTheDayResultCarriesTheMainNightGroupScoringUsed() {
let (res, _) = dayResult()
XCTAssertEqual(res.mainNightBlocks.map(\.start), [nightStart])
XCTAssertEqual(res.mainNightBlocks.map(\.end), [nightEnd])
}

func testTheDiagnosticVerdictAgreesWithTheGateOnARefusedNight() {
let (res, rr) = dayResult()
let fallback = res.cachedSleep.map { SleepStageTotals.NightBlock(start: $0.startTs, end: $0.endTs) }
let rows = AnalyticsEngine.hrvDiagnosticRows(rr, mainNight: res.mainNightBlocks, fallback: fallback)
XCTAssertEqual(rows.count, 2 * 600 * 60, "the night's beats only, the nap is not pooled in")
XCTAssertTrue(SleepStager.sessionHrvOverCounted(start: nightStart, end: nightEnd, rr: rr),
"precondition: the gate refuses this night")
XCTAssertFalse(HRVAnalyzer.successiveDiffIsTrustworthy(verdict(rows)),
"the line must call it an over-count, as the gate did")
// The old selection, every session pooled over the 7-hour gap: 1.08, `plausible`. Pinned so the
// test shows what it replaced, and fails if the fixture stops reproducing the disagreement.
let pooled = AnalyticsEngine.hrvDiagnosticRows(rr, mainNight: [], fallback: fallback)
XCTAssertEqual(verdict(pooled), .plausible)
}

func testADayWithNoMainNightKeepsThePooledHalfOpenSet() {
let rr = [RRInterval(ts: 100, rrMs: 1_000), RRInterval(ts: 150, rrMs: 1_000), RRInterval(ts: 200, rrMs: 1_000)]
let rows = AnalyticsEngine.hrvDiagnosticRows(
rr, mainNight: [], fallback: [SleepStageTotals.NightBlock(start: 100, end: 200)])
XCTAssertEqual(rows.map(\.ts), [100, 150], "fallback keeps the old half-open [start, end) window")
let main = AnalyticsEngine.hrvDiagnosticRows(
rr, mainNight: [SleepStageTotals.NightBlock(start: 100, end: 200)], fallback: [])
XCTAssertEqual(main.map(\.ts), [100, 150, 200], "main night uses the gate's inclusive [start, end]")
}
}
7 changes: 6 additions & 1 deletion Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1473,7 +1473,12 @@ final class IntelligenceEngine: ObservableObject {
// shipped windowed avgHrv. Built here (loop 1) where `rr` is in scope, but EMITTED in the
// main-actor replay loop below (diagnosticSink is main-actor isolated), carried on `hrvDiag`.
// Byte-identical to the Kotlin line.
let sleepRrRows = rr.filter { r in res.cachedSleep.contains { r.ts >= $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)
Expand Down
26 changes: 26 additions & 0 deletions android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<RrInterval>,
mainNight: List<SleepStageTotals.NightBlock>,
fallback: List<SleepStageTotals.NightBlock>,
): List<RrInterval> =
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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SleepStageTotals.NightBlock> = emptyList(),
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RrInterval>): 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<DayResult, List<RrInterval>> {
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 })
}
}
Loading