From a693ccc830de465adfc7b1a61c38d8493c48eaf3 Mon Sep 17 00:00:00 2001 From: kavemang <66108693+kavemang@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:10:59 -0500 Subject: [PATCH 1/2] fix(day-cycle): accept early bedtimes through canonical sleep selection Refs #2626 --- .../StrandAnalytics/PhysiologicalSteps.swift | 7 +- ...hysiologicalStepsClassificationTests.swift | 81 +++++++++++++++++ .../com/noop/analytics/PhysiologicalSteps.kt | 10 +-- .../PhysiologicalStepsClassificationTest.kt | 88 +++++++++++++++++++ .../noop/analytics/PhysiologicalStepsTest.kt | 7 +- 5 files changed, 180 insertions(+), 13 deletions(-) create mode 100644 Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PhysiologicalStepsClassificationTests.swift create mode 100644 android/app/src/test/java/com/noop/analytics/PhysiologicalStepsClassificationTest.kt diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/PhysiologicalSteps.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/PhysiologicalSteps.swift index 3527481319..41ef786d8a 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/PhysiologicalSteps.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/PhysiologicalSteps.swift @@ -56,10 +56,9 @@ public enum PhysiologicalSteps { let eligible = SleepStageTotals.bridgedNightGroups(nightBlocks, offsetSec: offsetSec) .filter { group in let total = group.indices.reduce(0) { $0 + max(0, nightBlocks[$1].durationS) } - let onset = group.indices.map { nightBlocks[$0].start }.min() - return total >= minMainSleepSeconds && onset.map { - SleepStageTotals.isOvernightOnset($0, offsetSec: offsetSec) - } == true + // #2626: rank early-bedtime and shift sleeps with the canonical selector below. + // Its timing bonus is a preference, not an onset-band eligibility gate. + return total >= minMainSleepSeconds } .flatMap { $0.indices } let candidates = eligible.map { index in diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PhysiologicalStepsClassificationTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PhysiologicalStepsClassificationTests.swift new file mode 100644 index 0000000000..eb03cea806 --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PhysiologicalStepsClassificationTests.swift @@ -0,0 +1,81 @@ +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:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCCCCCCCCCCCCCCCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN + 0:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCCCCCCCCCCCCCCCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN + 46800:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCCCCCCCCCCCCCCCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN + early1945:M:1799100 + early1900:M:1796400 + overnight:M:1807200 + shift:M:1771200 + 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]) + } + + 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/PhysiologicalSteps.kt b/android/app/src/main/java/com/noop/analytics/PhysiologicalSteps.kt index e62c2ded73..3d25546fa3 100644 --- a/android/app/src/main/java/com/noop/analytics/PhysiologicalSteps.kt +++ b/android/app/src/main/java/com/noop/analytics/PhysiologicalSteps.kt @@ -40,7 +40,7 @@ object PhysiologicalSteps { /** - * Apply the same user-visible main-vs-nap shape used by the sleep surfaces: only the canonical overnight + * Apply the same user-visible main-vs-nap shape used by the sleep surfaces: only the canonical main * group can be MAIN, it must total at least three hours, and every other/twinless-explicit block is NAP. */ fun classifyForCycle( @@ -58,14 +58,12 @@ object PhysiologicalSteps { val b = blocks[it] SleepStageTotals.NightBlock(b.effectiveOnset, b.end) } - // Eliminate nap-shaped GROUPS before choosing a winner. Otherwise a six-hour afternoon nap can - // win the generic duration scorer, fail the daytime guard, and hide a valid shorter night. + // Keep the cycle duration floor, then rank early-bedtime and shift sleeps with the canonical + // selector. Its timing bonus is a preference, not an onset-band eligibility gate (#2626). val eligible = SleepStageTotals.bridgedNightGroups(selectableNightBlocks, tzOffsetSeconds) .filter { group -> val total = group.indices.sumOf { i -> selectableNightBlocks[i].durationS.coerceAtLeast(0L) } - val onset = group.indices.minOfOrNull { selectableNightBlocks[it].start } - total >= MIN_MAIN_SLEEP_SECONDS && onset != null && - SleepStageTotals.isOvernightOnset(onset, tzOffsetSeconds) + total >= MIN_MAIN_SLEEP_SECONDS } .flatMap { it.indices } .distinct() 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..262275a7d2 --- /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:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCCCCCCCCCCCCCCCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN + 0:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCCCCCCCCCCCCCCCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN + 46800:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCCCCCCCCCCCCCCCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN + early1945:M:1799100 + early1900:M:1796400 + overnight:M:1807200 + shift:M:1771200 + 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") + } +} diff --git a/android/app/src/test/java/com/noop/analytics/PhysiologicalStepsTest.kt b/android/app/src/test/java/com/noop/analytics/PhysiologicalStepsTest.kt index 23525c11c6..f0a95dc1f1 100644 --- a/android/app/src/test/java/com/noop/analytics/PhysiologicalStepsTest.kt +++ b/android/app/src/test/java/com/noop/analytics/PhysiologicalStepsTest.kt @@ -55,7 +55,7 @@ class PhysiologicalStepsTest { ) } - @Test fun productionClassificationRejectsShortOrDaytimeNapShapes() { + @Test fun productionClassificationSelectsCanonicalNightAndRejectsShortGroups() { fun utc(hour: Int) = LocalDateTime.of(2026, 8, 21, hour, 0).toEpochSecond(ZoneOffset.UTC) val classified = PhysiologicalSteps.classifyForCycle( listOf( @@ -74,13 +74,14 @@ class PhysiologicalStepsTest { assertEquals(PhysiologicalSteps.SleepKind.NAP, classified["daytime"]) } - @Test fun longDaytimeNapCannotHideAShorterValidMainNight() { + @Test fun explicitlyMarkedLongDaytimeNapCannotHideAShorterMainNight() { fun utc(day: Int, hour: Int, minute: Int = 0) = LocalDateTime.of(2026, 8, day, hour, minute).toEpochSecond(ZoneOffset.UTC) val classified = PhysiologicalSteps.classifyForCycle( listOf( PhysiologicalSteps.SleepBlock(utc(21, 1), utc(21, 4, 30), id = "night"), - PhysiologicalSteps.SleepBlock(utc(21, 13), utc(21, 19), id = "nap"), + PhysiologicalSteps.SleepBlock(utc(21, 13), utc(21, 19), id = "nap", + kind = PhysiologicalSteps.SleepKind.NAP), ), 0, null, From b4e05d1470d05215bdf3c8a77860c369bf78fd52 Mon Sep 17 00:00:00 2001 From: kavemang <66108693+kavemang@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:14:17 -0500 Subject: [PATCH 2/2] fix(day-cycle): admit 19:00 bedtimes while retaining the nap gate --- .../StrandAnalytics/PhysiologicalSteps.swift | 7 ++++--- .../StrandAnalytics/SleepStageTotals.swift | 20 +++++++++---------- ...hysiologicalStepsClassificationTests.swift | 18 +++++++++++++---- .../com/noop/analytics/PhysiologicalSteps.kt | 10 ++++++---- .../com/noop/analytics/SleepStageTotals.kt | 15 +++++++------- .../PhysiologicalStepsClassificationTest.kt | 8 ++++---- .../noop/analytics/PhysiologicalStepsTest.kt | 7 +++---- 7 files changed, 47 insertions(+), 38 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/PhysiologicalSteps.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/PhysiologicalSteps.swift index 41ef786d8a..3527481319 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/PhysiologicalSteps.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/PhysiologicalSteps.swift @@ -56,9 +56,10 @@ public enum PhysiologicalSteps { let eligible = SleepStageTotals.bridgedNightGroups(nightBlocks, offsetSec: offsetSec) .filter { group in let total = group.indices.reduce(0) { $0 + max(0, nightBlocks[$1].durationS) } - // #2626: rank early-bedtime and shift sleeps with the canonical selector below. - // Its timing bonus is a preference, not an onset-band eligibility gate. - return total >= minMainSleepSeconds + let onset = group.indices.map { nightBlocks[$0].start }.min() + return total >= minMainSleepSeconds && onset.map { + SleepStageTotals.isOvernightOnset($0, offsetSec: offsetSec) + } == true } .flatMap { $0.indices } let candidates = eligible.map { index in 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 index eb03cea806..5afb034504 100644 --- a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PhysiologicalStepsClassificationTests.swift +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/PhysiologicalStepsClassificationTests.swift @@ -5,13 +5,13 @@ final class PhysiologicalStepsClassificationTests: XCTestCase { // Expected output comes from the standalone optimized Swift oracle, also pinned on Kotlin. func testClassificationOracle() { XCTAssertEqual(classificationOracle(), """ - -18000:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCCCCCCCCCCCCCCCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN - 0:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCCCCCCCCCCCCCCCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN - 46800:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCCCCCCCCCCCCCCCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN + -18000:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN + 0:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN + 46800:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN early1945:M:1799100 early1900:M:1796400 overnight:M:1807200 - shift:M:1771200 + shift:N:- short:N:- floor:M:1796400 nap:N:- @@ -37,6 +37,16 @@ final class PhysiologicalStepsClassificationTests: XCTestCase { 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] = [] diff --git a/android/app/src/main/java/com/noop/analytics/PhysiologicalSteps.kt b/android/app/src/main/java/com/noop/analytics/PhysiologicalSteps.kt index 3d25546fa3..e62c2ded73 100644 --- a/android/app/src/main/java/com/noop/analytics/PhysiologicalSteps.kt +++ b/android/app/src/main/java/com/noop/analytics/PhysiologicalSteps.kt @@ -40,7 +40,7 @@ object PhysiologicalSteps { /** - * Apply the same user-visible main-vs-nap shape used by the sleep surfaces: only the canonical main + * Apply the same user-visible main-vs-nap shape used by the sleep surfaces: only the canonical overnight * group can be MAIN, it must total at least three hours, and every other/twinless-explicit block is NAP. */ fun classifyForCycle( @@ -58,12 +58,14 @@ object PhysiologicalSteps { val b = blocks[it] SleepStageTotals.NightBlock(b.effectiveOnset, b.end) } - // Keep the cycle duration floor, then rank early-bedtime and shift sleeps with the canonical - // selector. Its timing bonus is a preference, not an onset-band eligibility gate (#2626). + // Eliminate nap-shaped GROUPS before choosing a winner. Otherwise a six-hour afternoon nap can + // win the generic duration scorer, fail the daytime guard, and hide a valid shorter night. val eligible = SleepStageTotals.bridgedNightGroups(selectableNightBlocks, tzOffsetSeconds) .filter { group -> val total = group.indices.sumOf { i -> selectableNightBlocks[i].durationS.coerceAtLeast(0L) } - total >= MIN_MAIN_SLEEP_SECONDS + val onset = group.indices.minOfOrNull { selectableNightBlocks[it].start } + total >= MIN_MAIN_SLEEP_SECONDS && onset != null && + SleepStageTotals.isOvernightOnset(onset, tzOffsetSeconds) } .flatMap { it.indices } .distinct() 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 index 262275a7d2..031f0efb24 100644 --- a/android/app/src/test/java/com/noop/analytics/PhysiologicalStepsClassificationTest.kt +++ b/android/app/src/test/java/com/noop/analytics/PhysiologicalStepsClassificationTest.kt @@ -7,13 +7,13 @@ class PhysiologicalStepsClassificationTest { // Verbatim stdout from swiftc -O over the production Swift classifier and SleepStageTotals. @Test fun classificationMatchesSwiftOracle() { assertEquals(""" - -18000:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCCCCCCCCCCCCCCCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN - 0:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCCCCCCCCCCCCCCCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN - 46800:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCCCCCCCCCCCCCCCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN + -18000:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN + 0:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN + 46800:NNNNNNNNNNNNNNNNNNNNNCNCNCNCNCNCNNNNNNNNNNNNNCCCCCNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNNN early1945:M:1799100 early1900:M:1796400 overnight:M:1807200 - shift:M:1771200 + shift:N:- short:N:- floor:M:1796400 nap:N:- diff --git a/android/app/src/test/java/com/noop/analytics/PhysiologicalStepsTest.kt b/android/app/src/test/java/com/noop/analytics/PhysiologicalStepsTest.kt index f0a95dc1f1..23525c11c6 100644 --- a/android/app/src/test/java/com/noop/analytics/PhysiologicalStepsTest.kt +++ b/android/app/src/test/java/com/noop/analytics/PhysiologicalStepsTest.kt @@ -55,7 +55,7 @@ class PhysiologicalStepsTest { ) } - @Test fun productionClassificationSelectsCanonicalNightAndRejectsShortGroups() { + @Test fun productionClassificationRejectsShortOrDaytimeNapShapes() { fun utc(hour: Int) = LocalDateTime.of(2026, 8, 21, hour, 0).toEpochSecond(ZoneOffset.UTC) val classified = PhysiologicalSteps.classifyForCycle( listOf( @@ -74,14 +74,13 @@ class PhysiologicalStepsTest { assertEquals(PhysiologicalSteps.SleepKind.NAP, classified["daytime"]) } - @Test fun explicitlyMarkedLongDaytimeNapCannotHideAShorterMainNight() { + @Test fun longDaytimeNapCannotHideAShorterValidMainNight() { fun utc(day: Int, hour: Int, minute: Int = 0) = LocalDateTime.of(2026, 8, day, hour, minute).toEpochSecond(ZoneOffset.UTC) val classified = PhysiologicalSteps.classifyForCycle( listOf( PhysiologicalSteps.SleepBlock(utc(21, 1), utc(21, 4, 30), id = "night"), - PhysiologicalSteps.SleepBlock(utc(21, 13), utc(21, 19), id = "nap", - kind = PhysiologicalSteps.SleepKind.NAP), + PhysiologicalSteps.SleepBlock(utc(21, 13), utc(21, 19), id = "nap"), ), 0, null,