diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStageTotals.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStageTotals.swift index 28cda1fca7..f90e730a77 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStageTotals.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/SleepStageTotals.swift @@ -132,15 +132,12 @@ public enum SleepStageTotals { // MARK: - Canonical main-night selection (#525 / #547 — learned-timing scored pick) - /// Broad overnight band used ONLY for the cold-start alignment bonus (NOT a gate). A block whose - /// midpoint lands near this band's center earns the timing credit when we have no learned habitual - /// midsleep yet. The band is [`overnightStartHour`, `overnightEndHour`) local, reconciled with the - /// detector's `SleepStager.isOvernightOnset` window [20:00, 11:00) so the selector and detector agree - /// (this removes the old [10:00, 11:00) off-by-one where the detector kept a ~10:30 onset as "night" - /// but the selector demoted it to a nap). (#547) - public static let overnightStartHour = 20 - /// Local hour (exclusive) that closes the cold-start overnight band. Now 11 (was 10) to match the - /// detector's [20:00, 11:00) onset window. A block onset in [`overnightEndHour`, `overnightStartHour`) + /// Broad overnight band for the cold-start timing bonus and physiological-day eligibility. + /// The 19:00 edge admits early bedtimes (#2626); the 11:00 closing edge retains late-morning onsets. + /// The raw detector has its own onset band, which is unchanged by this selection policy. + public static let overnightStartHour = 19 + /// Local hour (exclusive) that closes the cold-start overnight band at 11, matching the + /// detector's late-morning edge. A block onset in [`overnightEndHour`, `overnightStartHour`) /// is daytime; everything else is overnight. public static let overnightEndHour = 11 @@ -234,8 +231,9 @@ public enum SleepStageTotals { /// True when a block's onset falls in the cold-start overnight band (≥ `overnightStartHour` or /// < `overnightEndHour`, local). Retained for callers/tests that still ask the binary question, but - /// the scored selector no longer GATES on it — it only feeds the cold-start alignment bonus. - /// Mirrors `SleepStager.isOvernightOnset`. `offsetSec` is seconds EAST of UTC. (#525 / #547) + /// the scored selector no longer GATES on it. Physiological-day classification still uses this band + /// to exclude unclassified daytime naps; the early edge admits 19:00 bedtimes (#2626). + /// `offsetSec` is seconds EAST of UTC. public static func isOvernightOnset(_ ts: Int, offsetSec: Int) -> Bool { let local = ts + offsetSec let secOfDay = ((local % secondsPerDay) + secondsPerDay) % secondsPerDay diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PhysiologicalStepsClassificationTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PhysiologicalStepsClassificationTests.swift new file mode 100644 index 0000000000..5afb034504 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PhysiologicalStepsClassificationTests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import StrandAnalytics + +final class PhysiologicalStepsClassificationTests: XCTestCase { + // Expected output comes from the standalone optimized Swift oracle, also pinned on Kotlin. + func testClassificationOracle() { + XCTAssertEqual(classificationOracle(), """ + -18000:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN + 0:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN + 46800:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN + early1945:M:1799100 + early1900:M:1796400 + overnight:M:1807200 + shift:N:- + short:N:- + floor:M:1796400 + nap:N:- + explicit:M:1771200 + edited:M:1796400 + bridge:MM:1796400 + """) + } + + func testConsecutiveEarlyBedtimesCreateDailyBoundaries() { + var boundaries: [PhysiologicalSteps.CycleBoundary] = [] + for day in 20...22 { + let onset = day * 86_400 + 19 * 3_600 + let classified = PhysiologicalSteps.classifyForCycle([ + .init(onset: onset, end: onset + 8 * 3_600, id: String(day)) + ], offsetSec: 0, habitualMidsleepSec: nil) + for block in classified where block.kind == .mainSleep { + boundaries.append(.init(sleepId: block.id, onset: block.effectiveOnset)) + } + } + let windows = PhysiologicalSteps.cycleWindows(boundaries, now: 23 * 86_400 + 19 * 3_600) + XCTAssertEqual(windows.count, 3) + XCTAssertEqual(windows.map { $0.endExclusive - $0.onset }, [86_400, 86_400, 86_400]) + } + + func testLongDaytimeNapCannotHideAShorterValidMainNight() { + let base = 20 * 86_400 + let classified = PhysiologicalSteps.classifyForCycle([ + .init(onset: base + 3_600, end: base + 4 * 3_600 + 30 * 60, id: "night"), + .init(onset: base + 13 * 3_600, end: base + 19 * 3_600, id: "nap") + ], offsetSec: 0, habitualMidsleepSec: nil) + XCTAssertEqual(classified.first { $0.id == "night" }?.kind, .mainSleep) + XCTAssertEqual(classified.first { $0.id == "nap" }?.kind, .nap) + } + + private func classificationOracle() -> String { + typealias Block = PhysiologicalSteps.SleepBlock + var lines: [String] = [] + let base = 20 * 86_400 + for offset in [-18_000, 0, 46_800] { + var signature = "" + for minute in stride(from: 0, to: 1_440, by: 15) { + let blocks = [ + Block(onset: base + 3_600 - offset, end: base + 5 * 3_600 - offset, id: "N"), + Block(onset: base + minute * 60 - offset, + end: base + minute * 60 + 3 * 3_600 - offset, id: "C") + ] + let result = PhysiologicalSteps.classifyForCycle(blocks, offsetSec: offset, + habitualMidsleepSec: 14 * 3_600) + signature += result.filter { $0.kind == .mainSleep }.map(\.id).joined() + } + lines.append("\(offset):\(signature)") + } + let cases: [(String, [Block], Int?)] = [ + ("early1945", [Block(onset: base + 19 * 3_600 + 45 * 60, end: base + 28 * 3_600 + 15 * 60)], nil), + ("early1900", [Block(onset: base + 19 * 3_600, end: base + 27 * 3_600 + 45 * 60)], nil), + ("overnight", [Block(onset: base + 22 * 3_600, end: base + 30 * 3_600)], nil), + ("shift", [Block(onset: base + 12 * 3_600, end: base + 20 * 3_600)], 16 * 3_600), + ("short", [Block(onset: base + 19 * 3_600, end: base + 22 * 3_600 - 1)], nil), + ("floor", [Block(onset: base + 19 * 3_600, end: base + 22 * 3_600)], nil), + ("nap", [Block(onset: base + 19 * 3_600, end: base + 28 * 3_600, kind: .nap)], nil), + ("explicit", [Block(onset: base + 12 * 3_600, end: base + 13 * 3_600, kind: .mainSleep)], nil), + ("edited", [Block(onset: base + 20 * 3_600, end: base + 28 * 3_600, + editedOnset: base + 19 * 3_600)], nil), + ("bridge", [Block(onset: base + 19 * 3_600, end: base + 21 * 3_600), + Block(onset: base + 21 * 3_600 + 30 * 60, end: base + 23 * 3_600)], nil) + ] + for (name, blocks, habitual) in cases { + let result = PhysiologicalSteps.classifyForCycle(blocks, offsetSec: 0, + habitualMidsleepSec: habitual) + let kinds = result.map { $0.kind == .mainSleep ? "M" : "N" }.joined() + let onset = result.filter { $0.kind == .mainSleep }.map(\.effectiveOnset).min() + lines.append("\(name):\(kinds):\(onset.map(String.init) ?? "-")") + } + return lines.joined(separator: "\n") + } +} diff --git a/android/app/src/main/java/com/noop/analytics/SleepStageTotals.kt b/android/app/src/main/java/com/noop/analytics/SleepStageTotals.kt index 234dd7c75b..04463bc451 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepStageTotals.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepStageTotals.kt @@ -119,14 +119,13 @@ object SleepStageTotals { // ── Canonical main-night selection (#525 / #547 — learned-timing scored pick) ──────────────────── - /** Broad overnight band used ONLY for the cold-start alignment bonus (NOT a gate). The band is - * [OVERNIGHT_START_HOUR, OVERNIGHT_END_HOUR) local, reconciled with the detector's - * `SleepStager.isOvernightOnset` window [20:00, 11:00) so the selector and detector agree (removes the - * old [10:00, 11:00) off-by-one). Mirrors Swift. (#547) */ - const val OVERNIGHT_START_HOUR = 20 + /** Broad overnight band for the cold-start timing bonus and physiological-day eligibility. + * The 19:00 edge admits early bedtimes (#2626); the 11:00 closing edge retains late-morning onsets. + * The raw detector has its own onset band, unchanged by this selection policy. */ + const val OVERNIGHT_START_HOUR = 19 /** Local hour (exclusive) that closes the cold-start overnight band. Now 11 (was 10) to match the - * detector's [20:00, 11:00) onset window. A block onset in [OVERNIGHT_END_HOUR, OVERNIGHT_START_HOUR) + * detector's late-morning edge. A block onset in [OVERNIGHT_END_HOUR, OVERNIGHT_START_HOUR) * is daytime; everything else is overnight. */ const val OVERNIGHT_END_HOUR = 11 @@ -178,8 +177,8 @@ object SleepStageTotals { /** True when a block's onset falls in the cold-start overnight band (>= [OVERNIGHT_START_HOUR] or * < [OVERNIGHT_END_HOUR], local). Retained for callers/tests that still ask the binary question, but - * the scored selector no longer GATES on it — it only feeds the cold-start alignment bonus. Mirrors - * `SleepStager.isOvernightOnset`. [offsetSec] is seconds EAST of UTC. (#525 / #547) */ + * the scored selector no longer GATES on it. Physiological-day classification still uses this + * band to exclude unclassified daytime naps; its early edge admits 19:00 bedtimes (#2626). [offsetSec] is seconds EAST of UTC. (#525 / #547) */ fun isOvernightOnset(ts: Long, offsetSec: Long): Boolean { val local = ts + offsetSec val secOfDay = ((local % SECONDS_PER_DAY) + SECONDS_PER_DAY) % SECONDS_PER_DAY diff --git a/android/app/src/test/java/com/noop/analytics/PhysiologicalStepsClassificationTest.kt b/android/app/src/test/java/com/noop/analytics/PhysiologicalStepsClassificationTest.kt new file mode 100644 index 0000000000..031f0efb24 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/PhysiologicalStepsClassificationTest.kt @@ -0,0 +1,88 @@ +package com.noop.analytics + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PhysiologicalStepsClassificationTest { + // Verbatim stdout from swiftc -O over the production Swift classifier and SleepStageTotals. + @Test fun classificationMatchesSwiftOracle() { + assertEquals(""" + -18000:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN + 0:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN + 46800:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN + early1945:M:1799100 + early1900:M:1796400 + overnight:M:1807200 + shift:N:- + short:N:- + floor:M:1796400 + nap:N:- + explicit:M:1771200 + edited:M:1796400 + bridge:MM:1796400 + """.trimIndent(), classificationOracle()) + } + + @Test fun consecutiveEarlyBedtimesCreateDailyBoundaries() { + val boundaries = (20L..22L).flatMap { day -> + val onset = day * 86_400 + 19 * 3_600 + PhysiologicalSteps.classifyForCycle( + listOf(PhysiologicalSteps.SleepBlock(onset, onset + 8 * 3_600, id = day.toString())), + 0, null, + ).filter { it.kind == PhysiologicalSteps.SleepKind.MAIN_SLEEP } + .map { PhysiologicalSteps.CycleBoundary(it.id, it.effectiveOnset) } + } + val windows = PhysiologicalSteps.cycleWindows(boundaries, now = 23 * 86_400L + 19 * 3_600) + assertEquals(3, windows.size) + assertEquals(listOf(86_400L, 86_400L, 86_400L), windows.map { it.endExclusive - it.onset }) + } + + private fun classificationOracle(): String { + val lines = mutableListOf() + val base = 20 * 86_400L + for (offset in listOf(-18_000L, 0L, 46_800L)) { + val signature = StringBuilder() + for (minute in 0 until 1_440 step 15) { + val blocks = listOf( + PhysiologicalSteps.SleepBlock(base + 3_600 - offset, base + 5 * 3_600 - offset, id = "N"), + PhysiologicalSteps.SleepBlock(base + minute * 60 - offset, + base + minute * 60 + 3 * 3_600 - offset, id = "C"), + ) + val result = PhysiologicalSteps.classifyForCycle(blocks, offset, 14 * 3_600L) + signature.append(result.filter { it.kind == PhysiologicalSteps.SleepKind.MAIN_SLEEP } + .joinToString("") { it.id }) + } + lines.add("$offset:$signature") + } + val cases = listOf( + Triple("early1945", listOf(PhysiologicalSteps.SleepBlock(base + 19 * 3_600 + 45 * 60, + base + 28 * 3_600 + 15 * 60)), null), + Triple("early1900", listOf(PhysiologicalSteps.SleepBlock(base + 19 * 3_600, + base + 27 * 3_600 + 45 * 60)), null), + Triple("overnight", listOf(PhysiologicalSteps.SleepBlock(base + 22 * 3_600, + base + 30 * 3_600)), null), + Triple("shift", listOf(PhysiologicalSteps.SleepBlock(base + 12 * 3_600, + base + 20 * 3_600)), 16 * 3_600L), + Triple("short", listOf(PhysiologicalSteps.SleepBlock(base + 19 * 3_600, + base + 22 * 3_600 - 1)), null), + Triple("floor", listOf(PhysiologicalSteps.SleepBlock(base + 19 * 3_600, + base + 22 * 3_600)), null), + Triple("nap", listOf(PhysiologicalSteps.SleepBlock(base + 19 * 3_600, + base + 28 * 3_600, kind = PhysiologicalSteps.SleepKind.NAP)), null), + Triple("explicit", listOf(PhysiologicalSteps.SleepBlock(base + 12 * 3_600, + base + 13 * 3_600, kind = PhysiologicalSteps.SleepKind.MAIN_SLEEP)), null), + Triple("edited", listOf(PhysiologicalSteps.SleepBlock(base + 20 * 3_600, + base + 28 * 3_600, editedOnset = base + 19 * 3_600)), null), + Triple("bridge", listOf(PhysiologicalSteps.SleepBlock(base + 19 * 3_600, base + 21 * 3_600), + PhysiologicalSteps.SleepBlock(base + 21 * 3_600 + 30 * 60, base + 23 * 3_600)), null), + ) + for ((name, blocks, habitual) in cases) { + val result = PhysiologicalSteps.classifyForCycle(blocks, 0, habitual) + val kinds = result.joinToString("") { if (it.kind == PhysiologicalSteps.SleepKind.MAIN_SLEEP) "M" else "N" } + val onset = result.filter { it.kind == PhysiologicalSteps.SleepKind.MAIN_SLEEP } + .minOfOrNull { it.effectiveOnset } + lines.add("$name:$kinds:${onset ?: "-"}") + } + return lines.joinToString("\n") + } +}