Skip to content
Open
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 @@ -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

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
}
15 changes: 7 additions & 8 deletions android/app/src/main/java/com/noop/analytics/SleepStageTotals.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>()
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")
}
}
Loading