From 649f694dfaaf9328ed2514f57ddf1152534bc1e1 Mon Sep 17 00:00:00 2001 From: ak710 Date: Sun, 2 Aug 2026 14:19:43 -0400 Subject: [PATCH 1/3] Stop dropping REM sleep, and let the coach say so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Colmi big-data timeline (stage 0x04) and the YCBT timeline (tag 3) both report REM, and both decoders have always stored it as a SleepStageBlock. It just never got any further: SleepSummary carried light/deep/awake only, so REM never reached the sleep score, the Sleep tab, or the coach — which was still hard-coded to tell the model "light/deep/awake only, no REM" on every ring. Carry remMinutes through SleepSummary, collapseByDay and averageStages, and report it as remPct. Nil rather than zero throughout when the ring reported no REM stage at all: a jring's 0x11 timeline genuinely has none, and "absent" and "you slept no REM" are different claims. This also fixes a scoring side-effect. hasAwakeSignal falls back to asking whether the stage timeline accounted for essentially the whole night, and REM was excluded from that sum — so a fully described REM night looked only ~80% covered, failed the 0.95 gate, and had its awake reading thrown away as "no signal", costing it 45% of the awake sub-score. REM now counts toward coverage. REM is measured but still not scored: the light band (ideal 50-60%) is calibrated for a no-REM decoder that lumps REM into light, so re-weighting the score would move every stored night and needs the versioned recompute readiness does. Noted in SleepScore.calculate for the sleep-score rework. The coach's caveat is now chosen per night from that night's own blocks rather than from the connected ring's capabilities — stored nights outlive the ring that recorded them, so switching rings must not retro-actively disclaim last week's REM. Co-Authored-By: Claude Opus 5 --- .../Coach/Context/CoachContextBuilder.swift | 4 +- .../Coach/Context/CoachContextPacket.swift | 3 + .../Coach/Context/CoachPromptBuilder.swift | 2 +- .../Coach/Context/DataQualityAnalyzer.swift | 27 +++- .../CoachSummaryContextBuilder.swift | 6 +- PulseLoop/Coach/Tools/RetrievalTools.swift | 22 ++- PulseLoop/DesignSystem/Components.swift | 23 ++- PulseLoop/Persistence/SeedData.swift | 6 +- PulseLoop/Services/DerivedSummaries.swift | 13 ++ PulseLoop/Services/PulseServices.swift | 2 + PulseLoop/Services/SleepInsights.swift | 53 ++++++- PulseLoop/Views/SleepView.swift | 6 +- PulseLoopTests/SleepRemStageTests.swift | 140 ++++++++++++++++++ 13 files changed, 284 insertions(+), 23 deletions(-) create mode 100644 PulseLoopTests/SleepRemStageTests.swift diff --git a/PulseLoop/Coach/Context/CoachContextBuilder.swift b/PulseLoop/Coach/Context/CoachContextBuilder.swift index 9b7a42af..74f9733b 100644 --- a/PulseLoop/Coach/Context/CoachContextBuilder.swift +++ b/PulseLoop/Coach/Context/CoachContextBuilder.swift @@ -93,9 +93,10 @@ enum CoachContextBuilder { deepMin: s.deepMinutes, lightMin: s.lightMinutes, awakeMin: s.awakeMinutes, + remMin: s.hasRemSignal ? s.remMinutes : nil, score: s.session.score, confidence: "medium", - decoderNote: DataQualityAnalyzer.sleepDecoderNote + decoderNote: DataQualityAnalyzer.sleepDecoderNote(hasREM: s.hasRemSignal) ) } @@ -104,6 +105,7 @@ enum CoachContextBuilder { profileCompleteness: completeness, daysAvailable: daysAvailable, hasSleep: sleep != nil, + sleepHasREM: summary.sleep?.hasRemSignal ?? false, lastSyncAt: device?.lastSyncAt, isDemo: summary.isDemo ), diff --git a/PulseLoop/Coach/Context/CoachContextPacket.swift b/PulseLoop/Coach/Context/CoachContextPacket.swift index 5bfebc84..a7f12854 100644 --- a/PulseLoop/Coach/Context/CoachContextPacket.swift +++ b/PulseLoop/Coach/Context/CoachContextPacket.swift @@ -95,6 +95,9 @@ struct CoachContextPacket: Encodable { var deepMin: Int var lightMin: Int var awakeMin: Int + /// Omitted entirely when the ring that recorded this night reported no REM stage, so the + /// model sees "this field is absent" rather than "REM was zero minutes". + var remMin: Int? var score: Int? var confidence: String var decoderNote: String diff --git a/PulseLoop/Coach/Context/CoachPromptBuilder.swift b/PulseLoop/Coach/Context/CoachPromptBuilder.swift index 7aad1fbc..81cdcea0 100644 --- a/PulseLoop/Coach/Context/CoachPromptBuilder.swift +++ b/PulseLoop/Coach/Context/CoachPromptBuilder.swift @@ -37,7 +37,7 @@ enum CoachPromptBuilder { Data limitations: - The app may currently have only a few days of real data. - - Sleep stage decoding is experimental and may only contain light/deep/awake, not REM; awake time may read as zero. + - Sleep stages come from the ring's firmware, not a validated classifier. Which stages exist depends on the ring: some report REM, others only light/deep/awake. Trust the stage fields actually present in the data rather than assuming REM is missing; awake time may read as zero. - If there is no age/profile, do not calculate personalized HR zones. If no weight, do not calculate BMI or weight-loss calorie targets. - Some readings are wellness-grade, not medical-grade. diff --git a/PulseLoop/Coach/Context/DataQualityAnalyzer.swift b/PulseLoop/Coach/Context/DataQualityAnalyzer.swift index c7c34a9f..f9cc0f44 100644 --- a/PulseLoop/Coach/Context/DataQualityAnalyzer.swift +++ b/PulseLoop/Coach/Context/DataQualityAnalyzer.swift @@ -3,13 +3,34 @@ import Foundation /// Builds the first-class data-quality warnings that ride in the context packet, /// keeping the spirit of the web app's warnings so the coach never over-claims. enum DataQualityAnalyzer { - static let sleepDecoderNote = - "Sleep stage decoding is experimental — light/deep/awake only, no REM; awake time may read as zero." + /// The caveat for a night whose ring reported **no** REM stage — jring's `0x11` timeline is + /// light/deep/awake only. + static let sleepDecoderNoteWithoutREM = + "Sleep stage decoding is experimental — this ring reports light/deep/awake only, with no REM; " + + "awake time may read as zero." + + /// The caveat for a night that **does** carry REM (Colmi big-data stage `0x04`, YCBT tag `3`). + /// Still hedged — the staging is the ring firmware's, not a validated sleep-lab classifier — but it + /// no longer denies data the app actually has. + static let sleepDecoderNoteWithREM = + "Sleep stages come from the ring's own firmware, not a validated classifier — treat the split as " + + "approximate; awake time may read as zero." + + /// Picks the caveat that matches what this night actually contains. + /// + /// Keyed off the night's own stage blocks rather than the connected ring's capabilities: stored + /// nights outlive the ring that recorded them, so a user who switches rings must not have older + /// REM data disclaimed away (or newer REM data denied) by whatever happens to be paired today. + static func sleepDecoderNote(hasREM: Bool) -> String { + hasREM ? sleepDecoderNoteWithREM : sleepDecoderNoteWithoutREM + } struct Inputs { var profileCompleteness: String // empty | partial | complete var daysAvailable: Int var hasSleep: Bool + /// Whether the night behind `hasSleep` carried a REM stage. Ignored when `hasSleep` is false. + var sleepHasREM: Bool = false var lastSyncAt: Date? var isDemo: Bool } @@ -36,7 +57,7 @@ enum DataQualityAnalyzer { } if input.hasSleep { - out.append(sleepDecoderNote) + out.append(sleepDecoderNote(hasREM: input.sleepHasREM)) } if !input.isDemo { diff --git a/PulseLoop/Coach/Summaries/CoachSummaryContextBuilder.swift b/PulseLoop/Coach/Summaries/CoachSummaryContextBuilder.swift index 3b8e8883..1e4b3317 100644 --- a/PulseLoop/Coach/Summaries/CoachSummaryContextBuilder.swift +++ b/PulseLoop/Coach/Summaries/CoachSummaryContextBuilder.swift @@ -103,13 +103,17 @@ enum CoachSummaryContextBuilder { struct Packet: Encodable { let range: String, nightsTracked: Int, expectedNights: Int let avgTotalMin: Int?, avgScore: Int? - let avgDeepMin: Int?, avgLightMin: Int?, avgAwakeMin: Int?, goalMin: Int? + let avgDeepMin: Int?, avgLightMin: Int?, avgAwakeMin: Int? + /// Absent when no night in the range reported REM — see `SleepInsights.AverageStages.rem`. + let avgRemMin: Int? + let goalMin: Int? let memories: [CoachContextPacket.MemoryContext] } let p = Packet( range: range.rawValue, nightsTracked: valid.count, expectedNights: summary.expectedNights, avgTotalMin: avgMin, avgScore: avgScore, avgDeepMin: stages?.deep, avgLightMin: stages?.light, avgAwakeMin: stages?.awake, + avgRemMin: stages?.rem, goalMin: goalMin, memories: memories ) let sig = signature([ diff --git a/PulseLoop/Coach/Tools/RetrievalTools.swift b/PulseLoop/Coach/Tools/RetrievalTools.swift index 5aa97f0b..65c782a6 100644 --- a/PulseLoop/Coach/Tools/RetrievalTools.swift +++ b/PulseLoop/Coach/Tools/RetrievalTools.swift @@ -65,10 +65,18 @@ enum RetrievalTools { result["hr"] = encodeStats(CoachDataAccess.stats(hr)) result["spo2"] = encodeStats(CoachDataAccess.stats(spo2)) if let sleep { - result["sleep"] = [ + // Resolve the stage split so the note reflects what this night actually holds + // instead of asserting REM is missing on rings that do report it. + let staged = SleepService.summary(for: sleep, context: ctx.modelContext) + var payload: [String: Any] = [ "total_min": sleep.totalMinutes, "score": sleep.score as Any, - "confidence": "medium", "note": "experimental decoder (no REM)", + "deep_min": staged.deepMinutes, "light_min": staged.lightMinutes, + "awake_min": staged.awakeMinutes, + "confidence": "medium", + "note": DataQualityAnalyzer.sleepDecoderNote(hasREM: staged.hasRemSignal), ] + if staged.hasRemSignal { payload["rem_min"] = staged.remMinutes } + result["sleep"] = payload } return .object(result) } @@ -286,8 +294,14 @@ enum RetrievalTools { "nights_tracked": valid.count, "avg_total_min": SleepInsights.averageDuration(valid) as Any, "avg_score": SleepInsights.averageScore(valid) as Any, - "avg_stages_min": stages.map { ["deep": $0.deep, "light": $0.light, "awake": $0.awake] } as Any, - "note": DataQualityAnalyzer.sleepDecoderNote, + "avg_stages_min": stages.map { s -> [String: Int] in + var out = ["deep": s.deep, "light": s.light, "awake": s.awake] + // Present only when some night in the range actually reported REM, so an absent + // key means "this ring can't see REM", never "you slept none". + if let rem = s.rem { out["rem"] = rem } + return out + } as Any, + "note": DataQualityAnalyzer.sleepDecoderNote(hasREM: stages?.rem != nil), ]) } } diff --git a/PulseLoop/DesignSystem/Components.swift b/PulseLoop/DesignSystem/Components.swift index 37adecdb..f06bc5ef 100644 --- a/PulseLoop/DesignSystem/Components.swift +++ b/PulseLoop/DesignSystem/Components.swift @@ -418,12 +418,27 @@ struct SleepStageSummaryCardsView: View { let deep: String let light: String let awake: String + /// REM, when the ring behind this night reported the stage at all. `nil` omits the card + /// entirely rather than showing a dash — a jring genuinely has no REM stage, and an empty + /// fourth card reads as missing data instead of an absent sensor. + var rem: String? var body: some View { - HStack(spacing: 12) { - stat("\(prefix)Deep", deep, SleepStageColors.deep) - stat("\(prefix)Light", light, SleepStageColors.light) - stat("\(prefix)Awake", awake, SleepStageColors.awake) + // Four cards across is too cramped on a small phone, so REM promotes the row to a 2×2 + // grid; without it the original three-across row is unchanged. + if let rem { + LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible(), spacing: 12)], spacing: 12) { + stat("\(prefix)Deep", deep, SleepStageColors.deep) + stat("\(prefix)REM", rem, SleepStageColors.rem) + stat("\(prefix)Light", light, SleepStageColors.light) + stat("\(prefix)Awake", awake, SleepStageColors.awake) + } + } else { + HStack(spacing: 12) { + stat("\(prefix)Deep", deep, SleepStageColors.deep) + stat("\(prefix)Light", light, SleepStageColors.light) + stat("\(prefix)Awake", awake, SleepStageColors.awake) + } } } diff --git a/PulseLoop/Persistence/SeedData.swift b/PulseLoop/Persistence/SeedData.swift index 145cfd46..1050227a 100644 --- a/PulseLoop/Persistence/SeedData.swift +++ b/PulseLoop/Persistence/SeedData.swift @@ -91,9 +91,10 @@ enum SeedData { let light = blocks.filter { $0.stage == .light }.reduce(0) { $0 + $1.durationMinutes } let deep = blocks.filter { $0.stage == .deep }.reduce(0) { $0 + $1.durationMinutes } let awake = blocks.filter { $0.stage == .awake }.reduce(0) { $0 + $1.durationMinutes } + let rem = blocks.filter { $0.stage == .rem }.reduce(0) { $0 + $1.durationMinutes } let summary = SleepSummary( session: SleepSession(date: dayDate, startAt: startAt, endAt: wake, totalMinutes: totalMin), - lightMinutes: light, deepMinutes: deep, awakeMinutes: awake, blocks: blocks + lightMinutes: light, deepMinutes: deep, awakeMinutes: awake, remMinutes: rem, blocks: blocks ) let score = SleepScore.calculate(summary) let session = SleepSession(date: dayDate, startAt: startAt, endAt: wake, totalMinutes: totalMin, score: score.score, syncedAt: wake) @@ -111,9 +112,10 @@ enum SeedData { let napLight = napBlocks.filter { $0.stage == .light }.reduce(0) { $0 + $1.durationMinutes } let napDeep = napBlocks.filter { $0.stage == .deep }.reduce(0) { $0 + $1.durationMinutes } let napAwake = napBlocks.filter { $0.stage == .awake }.reduce(0) { $0 + $1.durationMinutes } + let napRem = napBlocks.filter { $0.stage == .rem }.reduce(0) { $0 + $1.durationMinutes } let napSummary = SleepSummary( session: SleepSession(date: dayDate, startAt: napStart, endAt: napEnd, totalMinutes: nap.minutes), - lightMinutes: napLight, deepMinutes: napDeep, awakeMinutes: napAwake, blocks: napBlocks + lightMinutes: napLight, deepMinutes: napDeep, awakeMinutes: napAwake, remMinutes: napRem, blocks: napBlocks ) let napScore = SleepScore.calculate(napSummary) let napSession = SleepSession(date: dayDate, startAt: napStart, endAt: napEnd, totalMinutes: nap.minutes, score: napScore.score, syncedAt: napEnd) diff --git a/PulseLoop/Services/DerivedSummaries.swift b/PulseLoop/Services/DerivedSummaries.swift index 754926cb..6b54b0ef 100644 --- a/PulseLoop/Services/DerivedSummaries.swift +++ b/PulseLoop/Services/DerivedSummaries.swift @@ -135,7 +135,20 @@ struct SleepSummary { let lightMinutes: Int let deepMinutes: Int let awakeMinutes: Int + /// Minutes the ring tagged as REM. Zero on rings whose firmware has no REM stage (jring's + /// `0x11` timeline is light/deep/awake only), so a zero here is genuinely ambiguous between + /// "no REM slept" and "this ring can't see REM" — use `hasRemSignal` to tell them apart. + let remMinutes: Int let blocks: [SleepStageBlock] + + /// Whether this night's own stage timeline carries REM at all. + /// + /// Deliberately derived from the night's blocks rather than the *connected* ring's + /// capabilities: stored nights outlive the ring that recorded them, so a user who switches + /// from a Colmi to a jring must not have last week's REM retro-actively disclaimed away. + var hasRemSignal: Bool { + remMinutes > 0 || blocks.contains { $0.stage == .rem } + } } struct SleepRangeSummary { diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index 56d685bc..fac0b2d5 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -632,11 +632,13 @@ enum SleepService { let light = blocks.filter { $0.stage == .light }.reduce(0) { $0 + $1.durationMinutes } let deep = blocks.filter { $0.stage == .deep }.reduce(0) { $0 + $1.durationMinutes } let awake = blocks.filter { $0.stage == .awake }.reduce(0) { $0 + $1.durationMinutes } + let rem = blocks.filter { $0.stage == .rem }.reduce(0) { $0 + $1.durationMinutes } return SleepSummary( session: session, lightMinutes: light, deepMinutes: deep, awakeMinutes: awake, + remMinutes: rem, blocks: includeStages ? blocks : [] ) } diff --git a/PulseLoop/Services/SleepInsights.swift b/PulseLoop/Services/SleepInsights.swift index d75a91f7..6bab7f24 100644 --- a/PulseLoop/Services/SleepInsights.swift +++ b/PulseLoop/Services/SleepInsights.swift @@ -23,6 +23,9 @@ struct SleepScoreResult { let lightPct: Int /// nil when there is no usable awake signal. let awakePct: Int? + /// nil on a night whose ring reported no REM stage at all — distinct from `0`, which would + /// claim the user slept no REM. Not yet a scoring contributor (see `calculate`). + let remPct: Int? } enum SleepScore { @@ -65,15 +68,32 @@ enum SleepScore { return .needsWork } + /// Scores a night out of 100 from duration (35), deep % (30), light % (20) and awake % (15). + /// + /// **REM is measured but not yet scored.** `remPct` is reported so the coach and the UI can + /// show it, but no points ride on it, and the light-sleep band (ideal 50–60%) still carries + /// the weight of a no-REM decoder — where REM minutes land in the light bucket. On a + /// REM-capable ring those minutes are tagged separately, so light % reads roughly 20 points + /// lower for the same night and the band scores it slightly harsher than it should. + /// + /// Re-weighting the score is deliberately out of scope here: changing the bands changes every + /// stored night's score, which needs the versioned recompute that `ReadinessDaily` does for + /// readiness. Tracked as the sleep-score v2 rework; this pass only stops REM being dropped on + /// the floor entirely. static func calculate(_ sleep: SleepSummary) -> SleepScoreResult { let total = sleep.session.totalMinutes > 0 ? Double(sleep.session.totalMinutes) : 0 let deep = Double(max(0, sleep.deepMinutes)) let light = Double(max(0, sleep.lightMinutes)) let awake = Double(max(0, sleep.awakeMinutes)) + // REM belongs in the coverage sum. This clause asks "did the timeline account for + // essentially the whole night?", and on a REM-capable ring (Colmi big-data stage `0x04`, + // YCBT tag `3`) REM is typically 20–25% of it — omitting it made a fully-described night + // look 75% covered, which failed the 0.95 gate below and cost the night its awake + // sub-score. jring rings, whose `0x11` timeline has no REM stage, are unaffected. let coveredStageMin = sleep.blocks.reduce(0.0) { sum, block in switch block.stage { - case .deep, .light, .awake: return sum + Double(max(0, block.durationMinutes)) - default: return sum + case .deep, .light, .awake, .rem: return sum + Double(max(0, block.durationMinutes)) + case .unknown: return sum } } let hasAwakeSignal = @@ -85,6 +105,9 @@ enum SleepScore { let deepPct = total > 0 ? (deep / total) * 100 : 0 let lightPct = total > 0 ? (light / total) * 100 : 0 let awakePct: Double? = (total > 0 && hasAwakeSignal) ? (awake / total) * 100 : nil + let remPct: Double? = (total > 0 && sleep.hasRemSignal) + ? (Double(max(0, sleep.remMinutes)) / total) * 100 + : nil let duration = bandScore(totalHours, idealLow: 7.5, idealHigh: 8.5, softLow: 6, softHigh: 9.5, hardLow: 3, hardHigh: 12, points: 35) let deepScore = bandScore(deepPct, idealLow: 13, idealHigh: 23, softLow: 5, softHigh: 35, hardLow: 0, hardHigh: 45, points: 30) @@ -97,7 +120,8 @@ enum SleepScore { label: qualityLabel(score), deepPct: Int(deepPct.rounded()), lightPct: Int(lightPct.rounded()), - awakePct: awakePct.map { Int($0.rounded()) } + awakePct: awakePct.map { Int($0.rounded()) }, + remPct: remPct.map { Int($0.rounded()) } ) } } @@ -177,6 +201,7 @@ enum SleepInsights { let lightMinutes = daySessions.reduce(0) { $0 + $1.lightMinutes } let deepMinutes = daySessions.reduce(0) { $0 + $1.deepMinutes } let awakeMinutes = daySessions.reduce(0) { $0 + $1.awakeMinutes } + let remMinutes = daySessions.reduce(0) { $0 + $1.remMinutes } let blocks = daySessions.flatMap { $0.blocks }.sorted { $0.startAt < $1.startAt } let totalMinutes = daySessions.reduce(0) { $0 + $1.session.totalMinutes } @@ -213,6 +238,7 @@ enum SleepInsights { lightMinutes: lightMinutes, deepMinutes: deepMinutes, awakeMinutes: awakeMinutes, + remMinutes: remMinutes, blocks: blocks )) } @@ -233,13 +259,30 @@ enum SleepInsights { return Int((Double(total) / Double(valid.count)).rounded()) } - static func averageStages(_ valid: [SleepSummary]) -> (deep: Int, light: Int, awake: Int)? { + /// Mean minutes per stage across the valid nights of a range. + /// + /// A struct rather than a tuple because adding REM makes it four members, which trips + /// SwiftLint's `large_tuple` — the same refactor the rest of this codebase already made. + struct AverageStages: Equatable { + let deep: Int + let light: Int + let awake: Int + /// nil when **no** night in the range carried a REM stage, so the caller can omit the field + /// rather than report an average of zero the ring never measured. Nights that do report REM + /// are averaged over the whole range, matching how the other three stages are treated. + let rem: Int? + } + + static func averageStages(_ valid: [SleepSummary]) -> AverageStages? { let valid = collapseByDay(valid) guard !valid.isEmpty else { return nil } let deep = valid.reduce(0) { $0 + $1.deepMinutes } / valid.count let light = valid.reduce(0) { $0 + $1.lightMinutes } / valid.count let awake = valid.reduce(0) { $0 + $1.awakeMinutes } / valid.count - return (deep, light, awake) + let rem = valid.contains { $0.hasRemSignal } + ? valid.reduce(0) { $0 + $1.remMinutes } / valid.count + : nil + return AverageStages(deep: deep, light: light, awake: awake, rem: rem) } /// Population standard deviation of nightly durations (minutes). diff --git a/PulseLoop/Views/SleepView.swift b/PulseLoop/Views/SleepView.swift index a80189b2..2c5e1e02 100644 --- a/PulseLoop/Views/SleepView.swift +++ b/PulseLoop/Views/SleepView.swift @@ -150,7 +150,8 @@ struct SleepView: View { SleepStageSummaryCardsView( deep: SleepFormat.duration(s.deepMinutes), light: SleepFormat.duration(s.lightMinutes), - awake: SleepFormat.duration(s.awakeMinutes) + awake: SleepFormat.duration(s.awakeMinutes), + rem: s.hasRemSignal ? SleepFormat.duration(s.remMinutes) : nil ) } @@ -440,7 +441,8 @@ struct SleepView: View { prefix: "Avg ", deep: stageAvg.map { SleepFormat.duration($0.deep) } ?? "—", light: stageAvg.map { SleepFormat.duration($0.light) } ?? "—", - awake: stageAvg.map { SleepFormat.duration($0.awake) } ?? "—" + awake: stageAvg.map { SleepFormat.duration($0.awake) } ?? "—", + rem: stageAvg?.rem.map { SleepFormat.duration($0) } ) summaryCard(rangeSummary(range), fallback: coach) } diff --git a/PulseLoopTests/SleepRemStageTests.swift b/PulseLoopTests/SleepRemStageTests.swift new file mode 100644 index 00000000..fbfe294d --- /dev/null +++ b/PulseLoopTests/SleepRemStageTests.swift @@ -0,0 +1,140 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// REM was decoded off the wire by the Colmi (big-data stage `0x04`) and YCBT (tag `3`) drivers and +/// stored as `SleepStageBlock`s, but never reached `SleepSummary` — so the score, the Sleep tab and +/// the coach all behaved as though no ring could see it. These lock the plumbing and the one scoring +/// side-effect it fixes. +@MainActor +final class SleepRemStageTests: XCTestCase { + private func night(_ dayOffset: Int) -> Date { + let base = TestSupport.day(dayOffset) + return Calendar.current.date(bySettingHour: 23, minute: 0, second: 0, of: base) ?? base + } + + /// Per-minute stage array: a REM-capable ring's night. + private func remNight() -> [SleepStage] { + Array(repeating: SleepStage.light, count: 60) + + Array(repeating: .deep, count: 20) + + Array(repeating: .rem, count: 20) + } + + /// The same night as a jring would report it — its `0x11` timeline has no REM stage, so those + /// minutes simply arrive tagged light. + private func noRemNight() -> [SleepStage] { + Array(repeating: SleepStage.light, count: 80) + Array(repeating: .deep, count: 20) + } + + // MARK: - Summary plumbing + + func testSummaryCarriesRemMinutes() throws { + let context = try TestSupport.makeContext() + let session = TestSupport.insertSleep(nightStart: night(0), stages: remNight(), into: context) + let summary = SleepService.summary(for: session, context: context) + + XCTAssertEqual(summary.remMinutes, 20) + XCTAssertEqual(summary.lightMinutes, 60) + XCTAssertEqual(summary.deepMinutes, 20) + XCTAssertTrue(summary.hasRemSignal) + } + + func testRingWithoutRemReportsNoRemSignal() throws { + let context = try TestSupport.makeContext() + let session = TestSupport.insertSleep(nightStart: night(0), stages: noRemNight(), into: context) + let summary = SleepService.summary(for: session, context: context) + + XCTAssertEqual(summary.remMinutes, 0) + XCTAssertFalse(summary.hasRemSignal, "zero REM minutes with no REM block is an absent sensor, not a zero reading") + XCTAssertNil(SleepScore.calculate(summary).remPct, "REM % must be absent, never 0%, when the ring can't see REM") + } + + func testRemPercentIsReportedAgainstTotalSleep() throws { + let context = try TestSupport.makeContext() + let session = TestSupport.insertSleep(nightStart: night(0), stages: remNight(), into: context) + let score = SleepScore.calculate(SleepService.summary(for: session, context: context)) + + // 20 REM minutes of a 100-minute night. + XCTAssertEqual(score.remPct, 20) + } + + // MARK: - The scoring side-effect + + /// The regression this fixes: `hasAwakeSignal`'s fallback asks whether the stage timeline + /// accounted for essentially the whole night. REM was excluded from that sum, so a fully + /// described REM night looked only 80% covered, failed the 0.95 gate, and had its awake reading + /// discarded as "no signal" — costing it 45% of the 15-point awake sub-score despite the ring + /// having described every minute. + func testFullyDescribedRemNightKeepsItsAwakeSignal() throws { + let context = try TestSupport.makeContext() + let session = TestSupport.insertSleep(nightStart: night(0), stages: remNight(), into: context) + let score = SleepScore.calculate(SleepService.summary(for: session, context: context)) + + XCTAssertEqual(score.awakePct, 0, "a night the ring fully described has a real zero-awake reading") + } + + /// The complement: a night that genuinely is under-described still withholds the awake signal, + /// so the coverage fix didn't just make the gate unconditionally true. + func testPartiallyDescribedNightStillWithholdsAwakeSignal() throws { + let context = try TestSupport.makeContext() + // 40 minutes of a 100-minute session are untagged, so coverage is 60% — under the 0.95 gate. + let stages = Array(repeating: SleepStage.light, count: 40) + Array(repeating: .unknown, count: 60) + let session = TestSupport.insertSleep(nightStart: night(0), stages: stages, into: context) + let score = SleepScore.calculate(SleepService.summary(for: session, context: context)) + + XCTAssertNil(score.awakePct) + } + + // MARK: - Range averages + + func testAverageStagesOmitsRemWhenNoNightHasIt() throws { + let context = try TestSupport.makeContext() + _ = TestSupport.insertSleep(nightStart: night(0), stages: noRemNight(), into: context) + _ = TestSupport.insertSleep(nightStart: night(-1), stages: noRemNight(), into: context) + + let valid = SleepInsights.validSessions(SleepService.sleepRange(.week, context: context).sessions) + XCTAssertNil(SleepInsights.averageStages(valid)?.rem) + } + + func testAverageStagesReportsRemWhenPresent() throws { + let context = try TestSupport.makeContext() + _ = TestSupport.insertSleep(nightStart: night(0), stages: remNight(), into: context) + _ = TestSupport.insertSleep(nightStart: night(-1), stages: remNight(), into: context) + + let valid = SleepInsights.validSessions(SleepService.sleepRange(.week, context: context).sessions) + XCTAssertEqual(SleepInsights.averageStages(valid)?.rem, 20) + } + + func testCollapsedDaySumsRemAcrossNightAndNap() throws { + let context = try TestSupport.makeContext() + let start = night(0) + _ = TestSupport.insertSleep(nightStart: start, stages: remNight(), into: context) + // A nap the same waking day, well past the 60-minute segmentation gap. + let napStart = Calendar.current.date(byAdding: .hour, value: 10, to: start) ?? start + _ = TestSupport.insertSleep(nightStart: napStart, stages: Array(repeating: .rem, count: 15), into: context) + + let valid = SleepInsights.validSessions(SleepService.sleepRange(.week, context: context).sessions) + let collapsed = SleepInsights.collapseByDay(valid) + XCTAssertEqual(collapsed.count, 1, "night + nap collapse onto one waking day") + XCTAssertEqual(collapsed.first?.remMinutes, 35) + } + + // MARK: - The coach's caveat + + func testDecoderNoteStopsDenyingRemWhenThePresentNightHasIt() { + let withREM = DataQualityAnalyzer.sleepDecoderNote(hasREM: true) + let withoutREM = DataQualityAnalyzer.sleepDecoderNote(hasREM: false) + + XCTAssertFalse(withREM.contains("no REM"), "a night with REM must not be described as having none") + XCTAssertTrue(withoutREM.contains("no REM"), "a jring night is still honestly disclaimed") + XCTAssertNotEqual(withREM, withoutREM) + } + + func testWarningsCarryTheMatchingCaveat() { + let inputs = DataQualityAnalyzer.Inputs( + profileCompleteness: "complete", daysAvailable: 30, + hasSleep: true, sleepHasREM: true, lastSyncAt: Date(), isDemo: false + ) + XCTAssertTrue(DataQualityAnalyzer.warnings(inputs).contains(DataQualityAnalyzer.sleepDecoderNoteWithREM)) + } +} From 3ebc5d9cc33608ba33d819458c1fb9ab1ee9194e Mon Sep 17 00:00:00 2001 From: ak710 Date: Sun, 2 Aug 2026 15:10:40 -0400 Subject: [PATCH 2/3] Rework the sleep score around five contributors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 scored duration (35), deep % (30), light % (20) and awake % (15). Three of those four had problems. Duration scored SleepSession.totalMinutes, which SleepSegmentation sets from end - start — time in bed, not time asleep. A night with 8h in bed and 90 min awake was credited as 8h of sleep. Awake minutes now come off the top. Light % was scored against a band (ideal 50-60%) calibrated for a no-REM decoder that lumps REM minutes into light. On a ring that tags REM separately, the same night reads ~20 points lower and was scored harshly for a split that was correct. Light is now the residual of deep and REM, so it is reported and not scored — scoring it counted the same night twice. Awake % was scored at 55% of its points when the ring gave no usable wake signal, which quietly docked every jring night for a sensor it never had. Missing signals now leave the denominator instead, exactly as readiness does: a jring is scored out of 80, a first week out of 90, and coverage is reported alongside the number. That is what makes one score comparable across hardware — an ideal night now scores the same on both. REM joins as a contributor (20 pts, ideal 20-25%), and bedtime consistency as a new one (10 pts), computed from the user's own median bedtime over the previous 14 nights on an axis wrapped around midnight, so 23:40 and 00:20 average to midnight rather than noon. The night being scored is excluded from its own baseline — including it would drag the median toward it and forgive exactly the drift the contributor exists to notice. Sleep efficiency is deliberately absent: with totalMinutes being time in bed it works out to exactly 1 - awake %, so it would restate restfulness while looking like an independent sixth signal. No migration needed. The production sync path stores every SleepSession with a nil score and every screen computes live from the stage blocks, so v2 took effect immediately; only demo and imported data carry a stored score. algorithmVersion is stamped anyway, for a future change that does need one. Full contributor table, thresholds and rationale in docs/project/sleep-score.md. Co-Authored-By: Claude Opus 5 --- .../CoachSummaryContextBuilder.swift | 23 +- PulseLoop/Services/PulseServices.swift | 26 ++ PulseLoop/Services/SleepInsights.swift | 266 +++++++++++++++--- PulseLoop/Views/SleepView.swift | 24 +- PulseLoopTests/SleepScoreV2Tests.swift | 198 +++++++++++++ docs/project/sleep-score.md | 149 ++++++++++ mkdocs.yml | 1 + 7 files changed, 635 insertions(+), 52 deletions(-) create mode 100644 PulseLoopTests/SleepScoreV2Tests.swift create mode 100644 docs/project/sleep-score.md diff --git a/PulseLoop/Coach/Summaries/CoachSummaryContextBuilder.swift b/PulseLoop/Coach/Summaries/CoachSummaryContextBuilder.swift index 1e4b3317..499a74e0 100644 --- a/PulseLoop/Coach/Summaries/CoachSummaryContextBuilder.swift +++ b/PulseLoop/Coach/Summaries/CoachSummaryContextBuilder.swift @@ -57,22 +57,33 @@ enum CoachSummaryContextBuilder { environment: CoachContextPacket.EnvironmentContext? = nil) -> Built? { let range = SleepService.sleepRange(.day, context: context, now: now) guard let night = SleepInsights.validSessions(range.sessions).last else { return nil } - let score = SleepScore.calculate(night) + let score = SleepScore.calculate( + night, bedtimeBaseline: SleepService.bedtimeBaseline(before: night.session.date, context: context) + ) let activitySteps = MetricsRepository.latestActivity(context: context)?.steps let memories = CoachContextBuilder.build(context: context, now: now).memories struct Packet: Encodable { - let date: String, totalMin: Int, deepMin: Int, lightMin: Int, awakeMin: Int - let score: Int, scoreLabel: String, awakePct: Int?, deepPct: Int, activitySteps: Int? + let date: String, timeInBedMin: Int, asleepMin: Int + let deepMin: Int, lightMin: Int, awakeMin: Int + /// Absent when the ring reported no REM stage — see `SleepSummary.hasRemSignal`. + let remMin: Int? + let score: Int, scoreLabel: String, awakePct: Int?, deepPct: Int, remPct: Int? + /// What fraction of the 100-point score was actually measurable on this ring, so the + /// model can hedge a score built on a partial picture instead of stating it flatly. + let scoreCoverage: Double + let activitySteps: Int? let memories: [CoachContextPacket.MemoryContext] let environment: CoachContextPacket.EnvironmentContext? } let p = Packet( date: CoachDataAccess.localDateString(night.session.date), - totalMin: night.session.totalMinutes, deepMin: night.deepMinutes, - lightMin: night.lightMinutes, awakeMin: night.awakeMinutes, + timeInBedMin: night.session.totalMinutes, asleepMin: score.asleepMinutes, + deepMin: night.deepMinutes, lightMin: night.lightMinutes, awakeMin: night.awakeMinutes, + remMin: night.hasRemSignal ? night.remMinutes : nil, score: score.score, scoreLabel: score.label.rawValue, awakePct: score.awakePct, - deepPct: score.deepPct, activitySteps: activitySteps, memories: memories, + deepPct: score.deepPct, remPct: score.remPct, scoreCoverage: score.coverage, + activitySteps: activitySteps, memories: memories, environment: environment ) let sig = signature([ diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index fac0b2d5..3223e873 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -626,6 +626,32 @@ enum SleepService { static func summary(for session: SleepSession, context: ModelContext) -> SleepSummary { summary(for: session, includeStages: true, context: context) } + + /// How many days back to look for the bedtime baseline. Wider than the 14 nights actually used + /// so a fortnight with a few unworn nights still reaches the 7-night floor. + static let bedtimeBaselineLookbackDays = 30 + + /// The user's usual bedtime as of `night`, or nil when there isn't a week of prior nights. + /// + /// A windowed predicate fetch rather than `SleepRepository.sessions`, which reads the whole + /// table. Stage blocks aren't loaded — only `startAt` matters here. + static func bedtimeBaseline(before night: Date, context: ModelContext) -> BedtimeBaseline? { + let start = Calendar.current.date(byAdding: .day, value: -bedtimeBaselineLookbackDays, to: night) ?? night + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.date >= start && $0.date < night }, + sortBy: [SortDescriptor(\.date, order: .reverse)] + ) + let sessions = ((try? context.fetch(descriptor)) ?? []).map { + SleepSummary(session: $0, lightMinutes: 0, deepMinutes: 0, awakeMinutes: 0, remMinutes: 0, blocks: []) + } + // Reuse the collapse so a day's nap can't contribute a second "bedtime"; `for:` needs a + // night to compare against, and every fetched session is already strictly before `night`. + let anchor = SleepSummary( + session: SleepSession(date: night, startAt: night, endAt: night, totalMinutes: 0), + lightMinutes: 0, deepMinutes: 0, awakeMinutes: 0, remMinutes: 0, blocks: [] + ) + return SleepInsights.bedtimeBaseline(for: anchor, among: sessions) + } private static func summary(for session: SleepSession, includeStages: Bool, context: ModelContext) -> SleepSummary { let blocks = SleepRepository.blocks(sessionId: session.id, context: context) diff --git a/PulseLoop/Services/SleepInsights.swift b/PulseLoop/Services/SleepInsights.swift index 6bab7f24..4e1b1dd9 100644 --- a/PulseLoop/Services/SleepInsights.swift +++ b/PulseLoop/Services/SleepInsights.swift @@ -16,6 +16,43 @@ enum SleepQualityLabel: String { case needsWork = "Needs work" } +/// One scored signal within a night's sleep score, in the shape `ReadinessContributor` uses: what it +/// earned, what it could have earned, and a sentence explaining the number. +struct SleepContributor: Equatable { + enum Kind: String, CaseIterable { + case duration + case deep + case rem + case restfulness + case timing + + var title: String { + switch self { + case .duration: return "Duration" + case .deep: return "Deep sleep" + case .rem: return "REM sleep" + case .restfulness: return "Restfulness" + case .timing: return "Bedtime consistency" + } + } + + var maxPoints: Double { + switch self { + case .duration: return 30 + case .deep: return 25 + case .rem: return 20 + case .restfulness: return 15 + case .timing: return 10 + } + } + } + + let kind: Kind + let earned: Double + let maxPoints: Double + let detail: String +} + struct SleepScoreResult { let score: Int let label: SleepQualityLabel @@ -23,9 +60,50 @@ struct SleepScoreResult { let lightPct: Int /// nil when there is no usable awake signal. let awakePct: Int? - /// nil on a night whose ring reported no REM stage at all — distinct from `0`, which would - /// claim the user slept no REM. Not yet a scoring contributor (see `calculate`). + /// nil on a night whose ring reported no REM stage at all — distinct from `0`, which would claim + /// the user slept no REM. let remPct: Int? + /// Total sleep time: time in bed minus the minutes tagged awake. + let asleepMinutes: Int + /// The scored signals, in display order. Only those the night actually had. + let contributors: [SleepContributor] + /// Fraction of the full 100-point picture this score was based on. A 78 from a partial night is + /// never silently presented as equivalent to a 78 from a complete one. + let coverage: Double + let algorithmVersion: Int +} + +/// The user's own recent bedtime, for the consistency contributor. +/// +/// A separate type so `SleepScore.calculate` stays a pure function: the caller resolves the history, +/// the scorer just reads it. +struct BedtimeBaseline: Equatable { + /// Median minutes-past-midnight of recent bedtimes, on a −12…+12 h axis centred on midnight so + /// a 23:40 and a 00:20 bedtime average to midnight rather than to noon. + let medianMinutesFromMidnight: Double + let nights: Int + + /// Enough history to call a bedtime "usual". Matches the 7-night floor `BaselineStats` uses. + static let minNights = 7 + var isEstablished: Bool { nights >= Self.minNights } + + /// Minutes past midnight on the wrapped axis: 23:00 → −60, 01:00 → +60. + static func minutesFromMidnight(_ date: Date, calendar: Calendar = .current) -> Double { + let parts = calendar.dateComponents([.hour, .minute], from: date) + let raw = Double((parts.hour ?? 0) * 60 + (parts.minute ?? 0)) + return raw > 12 * 60 ? raw - 24 * 60 : raw + } + + /// Builds from prior nights' start times. Returns nil when there are none. + static func compute(bedtimes: [Date], calendar: Calendar = .current) -> BedtimeBaseline? { + guard !bedtimes.isEmpty else { return nil } + let values = bedtimes.map { minutesFromMidnight($0, calendar: calendar) }.sorted() + let mid = values.count / 2 + let median = values.count.isMultiple(of: 2) + ? (values[mid - 1] + values[mid]) / 2 + : values[mid] + return BedtimeBaseline(medianMinutesFromMidnight: median, nights: values.count) + } } enum SleepScore { @@ -54,8 +132,10 @@ enum SleepScore { return points * 0.65 * clamp((hardHigh - value) / (hardHigh - softHigh), 0, 1) } - private static func awakeScore(_ awakePct: Double?, points: Double) -> Double { - guard let awakePct, awakePct.isFinite else { return points * 0.55 } + /// Awake share → points. Non-optional now: a night with no usable wake signal drops the + /// contributor entirely rather than scoring it at a fraction, so there is no absent case here. + static func awakeScore(_ awakePct: Double, points: Double) -> Double { + guard awakePct.isFinite else { return 0 } if awakePct <= 10 { return points } if awakePct <= 20 { return points * (1 - 0.65 * ((awakePct - 10) / 10)) } return points * 0.35 * clamp((35 - awakePct) / 15, 0, 1) @@ -68,28 +148,45 @@ enum SleepScore { return .needsWork } - /// Scores a night out of 100 from duration (35), deep % (30), light % (20) and awake % (15). + /// Bumped whenever a threshold or weight changes, so a stored score is never reinterpreted under + /// a different algorithm than the one that produced it. + static let algorithmVersion = 2 + + /// A score is only produced when at least this many points were available. + static let minAvailablePoints: Double = 50 + + /// Scores a night out of 100 across five contributors — duration (30), deep % (25), REM % (20), + /// restfulness (15) and bedtime consistency (10). + /// + /// **Missing signals leave the denominator, they are never scored as zero.** A jring reports no + /// REM stage at all, so its nights are scored out of 80 rather than penalised 20; the same + /// applies to bedtime consistency until a week of history exists. `coverage` reports what + /// fraction of the full picture the number rests on. This is the rule readiness already follows, + /// and it is what makes one score comparable across a jring and a Colmi. /// - /// **REM is measured but not yet scored.** `remPct` is reported so the coach and the UI can - /// show it, but no points ride on it, and the light-sleep band (ideal 50–60%) still carries - /// the weight of a no-REM decoder — where REM minutes land in the light bucket. On a - /// REM-capable ring those minutes are tagged separately, so light % reads roughly 20 points - /// lower for the same night and the band scores it slightly harsher than it should. + /// Two deliberate departures from v1: /// - /// Re-weighting the score is deliberately out of scope here: changing the bands changes every - /// stored night's score, which needs the versioned recompute that `ReadinessDaily` does for - /// readiness. Tracked as the sleep-score v2 rework; this pass only stops REM being dropped on - /// the floor entirely. - static func calculate(_ sleep: SleepSummary) -> SleepScoreResult { - let total = sleep.session.totalMinutes > 0 ? Double(sleep.session.totalMinutes) : 0 + /// - **Duration is now total sleep time, not time in bed.** `SleepSession.totalMinutes` is the + /// wall-clock span (`SleepSegmentation` sets it from `end − start`), so v1 credited a night + /// with 8 h in bed and 90 min awake as 8 h of sleep. Awake minutes now come off the top. + /// - **Light % is reported but no longer scored.** Once deep and REM are both scored, light is + /// their residual — scoring it too would count the same night twice, and its v1 band (ideal + /// 50–60 %) was calibrated for a no-REM decoder that lumped REM into light. + /// + /// Sleep *efficiency* is deliberately absent for the same reason: with `totalMinutes` being time + /// in bed, efficiency is exactly `1 − awake %`, so it would restate restfulness rather than add + /// a signal. + static func calculate(_ sleep: SleepSummary, bedtimeBaseline: BedtimeBaseline? = nil) -> SleepScoreResult { + let timeInBed = sleep.session.totalMinutes > 0 ? Double(sleep.session.totalMinutes) : 0 let deep = Double(max(0, sleep.deepMinutes)) let light = Double(max(0, sleep.lightMinutes)) let awake = Double(max(0, sleep.awakeMinutes)) - // REM belongs in the coverage sum. This clause asks "did the timeline account for - // essentially the whole night?", and on a REM-capable ring (Colmi big-data stage `0x04`, - // YCBT tag `3`) REM is typically 20–25% of it — omitting it made a fully-described night - // look 75% covered, which failed the 0.95 gate below and cost the night its awake - // sub-score. jring rings, whose `0x11` timeline has no REM stage, are unaffected. + let rem = Double(max(0, sleep.remMinutes)) + + // "Did the timeline account for essentially the whole night?" REM belongs in this sum: on a + // REM-capable ring (Colmi big-data stage `0x04`, YCBT tag `3`) it is typically 20–25 % of the + // night, so omitting it made a fully-described night look 75 % covered and cost it its awake + // reading. jring rings, whose `0x11` timeline has no REM stage, are unaffected. let coveredStageMin = sleep.blocks.reduce(0.0) { sum, block in switch block.stage { case .deep, .light, .awake, .rem: return sum + Double(max(0, block.durationMinutes)) @@ -99,21 +196,82 @@ enum SleepScore { let hasAwakeSignal = sleep.blocks.contains { $0.stage == .awake } || awake > 0 || - (total > 0 && coveredStageMin >= total * 0.95) - - let totalHours = total / 60 - let deepPct = total > 0 ? (deep / total) * 100 : 0 - let lightPct = total > 0 ? (light / total) * 100 : 0 - let awakePct: Double? = (total > 0 && hasAwakeSignal) ? (awake / total) * 100 : nil - let remPct: Double? = (total > 0 && sleep.hasRemSignal) - ? (Double(max(0, sleep.remMinutes)) / total) * 100 - : nil + (timeInBed > 0 && coveredStageMin >= timeInBed * 0.95) + + // Total sleep time. Without an awake signal the best available answer is the whole span — + // stated here rather than left implicit, because it makes duration read slightly generous on + // a ring that can't see wake. + let asleep = hasAwakeSignal ? max(0, timeInBed - awake) : timeInBed + let deepPct = timeInBed > 0 ? (deep / timeInBed) * 100 : 0 + let lightPct = timeInBed > 0 ? (light / timeInBed) * 100 : 0 + let awakePct: Double? = (timeInBed > 0 && hasAwakeSignal) ? (awake / timeInBed) * 100 : nil + let remPct: Double? = (timeInBed > 0 && sleep.hasRemSignal) ? (rem / timeInBed) * 100 : nil + + var contributors: [SleepContributor] = [] + + // Duration — 7–9 h ideal, the adult range every major guideline agrees on. + contributors.append(SleepContributor( + kind: .duration, + earned: bandScore(asleep / 60, idealLow: 7, idealHigh: 9, softLow: 6, softHigh: 9.5, + hardLow: 4, hardHigh: 12, points: SleepContributor.Kind.duration.maxPoints), + maxPoints: SleepContributor.Kind.duration.maxPoints, + detail: "\(SleepFormat.duration(Int(asleep.rounded()))) asleep" + )) + + // Deep — 13–23 % of the night, carried over from v1 unchanged. + if timeInBed > 0 { + contributors.append(SleepContributor( + kind: .deep, + earned: bandScore(deepPct, idealLow: 13, idealHigh: 23, softLow: 5, softHigh: 35, + hardLow: 0, hardHigh: 45, points: SleepContributor.Kind.deep.maxPoints), + maxPoints: SleepContributor.Kind.deep.maxPoints, + detail: "\(Int(deepPct.rounded()))% of the night" + )) + } + + // REM — 20–25 % is the usual adult share. Absent entirely on a ring with no REM stage. + if let remPct { + contributors.append(SleepContributor( + kind: .rem, + earned: bandScore(remPct, idealLow: 20, idealHigh: 25, softLow: 15, softHigh: 30, + hardLow: 5, hardHigh: 40, points: SleepContributor.Kind.rem.maxPoints), + maxPoints: SleepContributor.Kind.rem.maxPoints, + detail: "\(Int(remPct.rounded()))% of the night" + )) + } + + // Restfulness — how much of the night was spent awake. Withheld, not guessed, when the ring + // gave no usable wake signal (v1 scored it at 55 % of the points in that case, which quietly + // penalised every jring night). + if let awakePct { + contributors.append(SleepContributor( + kind: .restfulness, + earned: awakeScore(awakePct, points: SleepContributor.Kind.restfulness.maxPoints), + maxPoints: SleepContributor.Kind.restfulness.maxPoints, + detail: "\(Int(awakePct.rounded()))% awake" + )) + } + + // Bedtime consistency — how far this night's bedtime sat from the user's own recent median. + if let baseline = bedtimeBaseline, baseline.isEstablished { + let drift = abs(BedtimeBaseline.minutesFromMidnight(sleep.session.startAt) - baseline.medianMinutesFromMidnight) + contributors.append(SleepContributor( + kind: .timing, + earned: timingScore(driftMinutes: drift, points: SleepContributor.Kind.timing.maxPoints), + maxPoints: SleepContributor.Kind.timing.maxPoints, + detail: drift < 15 + ? "In line with your usual bedtime" + : "\(Int(drift.rounded())) min from your usual bedtime" + )) + } - let duration = bandScore(totalHours, idealLow: 7.5, idealHigh: 8.5, softLow: 6, softHigh: 9.5, hardLow: 3, hardHigh: 12, points: 35) - let deepScore = bandScore(deepPct, idealLow: 13, idealHigh: 23, softLow: 5, softHigh: 35, hardLow: 0, hardHigh: 45, points: 30) - let lightScore = bandScore(lightPct, idealLow: 50, idealHigh: 60, softLow: 35, softHigh: 75, hardLow: 20, hardHigh: 90, points: 20) - let awakeSub = awakeScore(awakePct, points: 15) - let score = Int(clamp((duration + deepScore + lightScore + awakeSub).rounded(), 0, 100)) + let available = contributors.reduce(0) { $0 + $1.maxPoints } + let earned = contributors.reduce(0) { $0 + $1.earned } + // Below the floor there isn't enough of a night to describe; score 0 rather than inflate a + // fragment into a full-looking number. + let score = available >= minAvailablePoints + ? Int(clamp((earned / available * 100).rounded(), 0, 100)) + : 0 return SleepScoreResult( score: score, @@ -121,9 +279,27 @@ enum SleepScore { deepPct: Int(deepPct.rounded()), lightPct: Int(lightPct.rounded()), awakePct: awakePct.map { Int($0.rounded()) }, - remPct: remPct.map { Int($0.rounded()) } + remPct: remPct.map { Int($0.rounded()) }, + asleepMinutes: Int(asleep.rounded()), + contributors: contributors, + coverage: available / 100, + algorithmVersion: algorithmVersion ) } + + /// Bedtime drift → points. Full marks within 30 minutes of your usual, 55 % at an hour, nothing + /// at two hours or more. + /// + /// The 30-minute knot is where circadian-regularity research stops calling a schedule regular; + /// the two-hour floor is roughly a timezone, by which point the night is a different night. + static func timingScore(driftMinutes: Double, points: Double) -> Double { + guard driftMinutes.isFinite else { return 0 } + if driftMinutes <= 30 { return points } + if driftMinutes <= 60 { + return points * (1 - 0.45 * ((driftMinutes - 30) / 30)) + } + return points * 0.55 * clamp((120 - driftMinutes) / 60, 0, 1) + } } // MARK: - Formatting @@ -246,6 +422,24 @@ enum SleepInsights { return collapsed.sorted { $0.session.date < $1.session.date } } + /// The user's usual bedtime as of a given night, from the nights *before* it. + /// + /// Days are collapsed first so each contributes one bedtime: a collapsed day's `startAt` is the + /// earliest of its sessions, which is the night itself — an afternoon nap starts later in the + /// same waking day and so never displaces it. + /// + /// Strictly prior nights only. Including the night being scored would drag the median toward it + /// and quietly forgive exactly the drift the contributor exists to notice. + static func bedtimeBaseline( + for night: SleepSummary, among sessions: [SleepSummary], window: Int = 14 + ) -> BedtimeBaseline? { + let prior = collapseByDay(sessions) + .filter { $0.session.date < night.session.date } + .sorted { $0.session.date > $1.session.date } + .prefix(window) + return BedtimeBaseline.compute(bedtimes: prior.map { $0.session.startAt }) + } + static func averageDuration(_ valid: [SleepSummary]) -> Int? { let valid = collapseByDay(valid) guard !valid.isEmpty else { return nil } diff --git a/PulseLoop/Views/SleepView.swift b/PulseLoop/Views/SleepView.swift index 2c5e1e02..2077f3e1 100644 --- a/PulseLoop/Views/SleepView.swift +++ b/PulseLoop/Views/SleepView.swift @@ -50,7 +50,10 @@ struct SleepView: View { if range == .day { dayNavHeader(shownDay: SleepService.dayReferenceNight(now: effectiveNow)) - dayView(summary: summary, activitySteps: activitySteps, isToday: dayOffset == 0) + dayView(summary: summary, activitySteps: activitySteps, isToday: dayOffset == 0, + bedtimeBaseline: SleepService.bedtimeBaseline( + before: SleepService.dayReferenceNight(now: effectiveNow), context: modelContext + )) .id(dayOffset) .transition(reduceMotion ? .opacity : .push(from: dayNavEdge)) } else { @@ -102,7 +105,8 @@ struct SleepView: View { // MARK: Day @ViewBuilder - private func dayView(summary: SleepRangeSummary, activitySteps: Int?, isToday: Bool) -> some View { + private func dayView(summary: SleepRangeSummary, activitySteps: Int?, isToday: Bool, + bedtimeBaseline: BedtimeBaseline?) -> some View { let sessions = SleepInsights.validSessions(summary.sessions).sorted { $0.session.startAt < $1.session.startAt } if sessions.isEmpty { let noData = SleepInsights.noDataState(.day) @@ -118,14 +122,14 @@ struct SleepView: View { } else { // The primary (longest) session drives the day-level coach fallback. let primary = sessions.max { $0.session.totalMinutes < $1.session.totalMinutes } ?? sessions[0] - let primaryScore = SleepScore.calculate(primary) + let primaryScore = SleepScore.calculate(primary, bedtimeBaseline: bedtimeBaseline) let dayFallback = SleepInsights.dayCoach(primary, score: primaryScore.score, awakePct: primaryScore.awakePct, deepPct: primaryScore.deepPct, activitySteps: activitySteps) if sessions.count == 1 { // Single session: render exactly as before, no carousel chrome. - sessionPage(sessions[0]) + sessionPage(sessions[0], bedtimeBaseline: bedtimeBaseline) } else { - sleepCarousel(sessions: sessions) + sleepCarousel(sessions: sessions, bedtimeBaseline: bedtimeBaseline) } // The LLM day summary describes last night only; on a past day fall back to the // scripted coach (deterministically computed from that day's own primary session). @@ -135,11 +139,11 @@ struct SleepView: View { /// One session's stack: Hero + hypnogram VisualizationCard + stage cards. @ViewBuilder - private func sessionPage(_ s: SleepSummary) -> some View { - let score = SleepScore.calculate(s) + private func sessionPage(_ s: SleepSummary, bedtimeBaseline: BedtimeBaseline?) -> some View { + let score = SleepScore.calculate(s, bedtimeBaseline: bedtimeBaseline) SleepHeroCardView( label: SleepInsights.rangeHeroLabel[.day] ?? "Last Sleep", - value: SleepFormat.duration(s.session.totalMinutes), + value: SleepFormat.duration(score.asleepMinutes), support: "\(SleepFormat.clockTime(s.session.startAt)) to \(SleepFormat.clockTime(s.session.endAt))", score: score.score, scoreLabel: score.label.rawValue @@ -158,7 +162,7 @@ struct SleepView: View { /// Horizontal paged carousel across multiple sleep sessions in one day. /// Sizes to the tallest visible page (no fixed height) and shows a dot row. @ViewBuilder - private func sleepCarousel(sessions: [SleepSummary]) -> some View { + private func sleepCarousel(sessions: [SleepSummary], bedtimeBaseline: BedtimeBaseline?) -> some View { ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: 0) { ForEach(Array(sessions.enumerated()), id: \.element.session.id) { idx, s in @@ -169,7 +173,7 @@ struct SleepView: View { .textCase(.uppercase) .kerning(0.5) .frame(maxWidth: .infinity, alignment: .leading) - sessionPage(s) + sessionPage(s, bedtimeBaseline: bedtimeBaseline) } .containerRelativeFrame(.horizontal) .id(idx) diff --git a/PulseLoopTests/SleepScoreV2Tests.swift b/PulseLoopTests/SleepScoreV2Tests.swift new file mode 100644 index 00000000..14789501 --- /dev/null +++ b/PulseLoopTests/SleepScoreV2Tests.swift @@ -0,0 +1,198 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// Sleep score v2: five contributors, missing signals leaving the denominator rather than scoring +/// zero, and duration measured as time *asleep* rather than time in bed. Every number here is locked +/// against `docs/project/sleep-score.md`. +@MainActor +final class SleepScoreV2Tests: XCTestCase { + + /// Builds a night directly, so a test states the stage split it means rather than deriving it. + private func night( + inBed: Int, deep: Int, light: Int, awake: Int, rem: Int, + startAt: Date = Date(timeIntervalSince1970: 1_760_000_000) + ) -> SleepSummary { + // One block per stage, enough to satisfy the awake-signal and REM-signal probes. + var blocks: [SleepStageBlock] = [] + var minute = 0 + for (stage, minutes) in [(SleepStage.deep, deep), (.light, light), (.rem, rem), (.awake, awake)] + where minutes > 0 { + blocks.append(SleepStageBlock(sessionId: UUID(), startAt: startAt.addingTimeInterval(Double(minute) * 60), + startMinute: minute, durationMinutes: minutes, stage: stage)) + minute += minutes + } + return SleepSummary( + session: SleepSession(date: Calendar.current.startOfDay(for: startAt), startAt: startAt, + endAt: startAt.addingTimeInterval(Double(inBed) * 60), totalMinutes: inBed), + lightMinutes: light, deepMinutes: deep, awakeMinutes: awake, remMinutes: rem, blocks: blocks + ) + } + + private func contributor(_ kind: SleepContributor.Kind, in result: SleepScoreResult) -> SleepContributor? { + result.contributors.first { $0.kind == kind } + } + + // MARK: - Duration is time asleep, not time in bed + + /// v1 scored `totalMinutes`, which `SleepSegmentation` sets from `end − start` — so 8 h in bed + /// with 90 min awake was credited as 8 h of sleep. + func testDurationExcludesAwakeTime() { + let result = SleepScore.calculate(night(inBed: 480, deep: 90, light: 200, awake: 90, rem: 100)) + XCTAssertEqual(result.asleepMinutes, 390, "480 in bed minus 90 awake") + } + + /// Without a usable wake signal there is nothing to subtract, so the whole span stands in. + func testDurationFallsBackToTimeInBedWithoutAWakeSignal() { + // Blocks cover only 60% of the span, so the 0.95 coverage fallback can't infer zero awake. + let partial = SleepSummary( + session: SleepSession(date: Date(), startAt: Date(), endAt: Date().addingTimeInterval(480 * 60), + totalMinutes: 480), + lightMinutes: 200, deepMinutes: 90, awakeMinutes: 0, remMinutes: 0, + blocks: [SleepStageBlock(sessionId: UUID(), startAt: Date(), startMinute: 0, + durationMinutes: 290, stage: .light)] + ) + let result = SleepScore.calculate(partial) + XCTAssertEqual(result.asleepMinutes, 480) + XCTAssertNil(result.awakePct) + } + + // MARK: - Contributors present and absent + + func testFullNightScoresAllFiveContributors() { + let baseline = BedtimeBaseline(medianMinutesFromMidnight: -60, nights: 14) + let result = SleepScore.calculate(night(inBed: 480, deep: 90, light: 220, awake: 30, rem: 140), + bedtimeBaseline: baseline) + XCTAssertEqual(Set(result.contributors.map(\.kind)), + [.duration, .deep, .rem, .restfulness, .timing]) + XCTAssertEqual(result.coverage, 1.0, accuracy: 0.001) + } + + /// A jring has no REM stage. Those 20 points leave the denominator — the night is scored out of + /// 80, not penalised 20. + func testRingWithoutRemIsScoredOutOfEighty() { + let baseline = BedtimeBaseline(medianMinutesFromMidnight: -60, nights: 14) + let result = SleepScore.calculate(night(inBed: 480, deep: 90, light: 360, awake: 30, rem: 0), + bedtimeBaseline: baseline) + XCTAssertNil(contributor(.rem, in: result)) + XCTAssertEqual(result.coverage, 0.8, accuracy: 0.001, "100 points less REM's 20") + XCTAssertNil(result.remPct, "absent, never 0%") + } + + /// The same stage split scores the same whether or not the ring can see REM, once REM is at its + /// ideal share — which is the property that makes one number comparable across hardware. + func testAnIdealNightScoresTheSameWithAndWithoutRemCoverage() { + let withREM = SleepScore.calculate(night(inBed: 480, deep: 86, light: 268, awake: 22, rem: 104)) + let withoutREM = SleepScore.calculate(night(inBed: 480, deep: 86, light: 372, awake: 22, rem: 0)) + XCTAssertEqual(withREM.score, withoutREM.score, accuracy: 1) + } + + /// Bedtime consistency needs a week of prior nights; until then its 10 points leave the + /// denominator rather than being scored as a miss. + func testTimingIsWithheldUntilTheBaselineIsEstablished() { + let thin = BedtimeBaseline(medianMinutesFromMidnight: -60, nights: 6) + let result = SleepScore.calculate(night(inBed: 480, deep: 90, light: 220, awake: 30, rem: 140), + bedtimeBaseline: thin) + XCTAssertNil(contributor(.timing, in: result)) + XCTAssertEqual(result.coverage, 0.9, accuracy: 0.001) + } + + /// Light is reported for display but never scored — once deep and REM are both scored it is + /// their residual, so scoring it would count the same night twice. + func testLightIsReportedButNotScored() { + let result = SleepScore.calculate(night(inBed: 480, deep: 90, light: 220, awake: 30, rem: 140)) + XCTAssertEqual(result.lightPct, 46) + XCTAssertFalse(result.contributors.contains { $0.kind.title.lowercased().contains("light") }) + } + + // MARK: - Thresholds + + func testTimingScoreKnots() { + XCTAssertEqual(SleepScore.timingScore(driftMinutes: 0, points: 10), 10, accuracy: 0.001) + XCTAssertEqual(SleepScore.timingScore(driftMinutes: 30, points: 10), 10, accuracy: 0.001, + "the 30-minute knot is inclusive") + XCTAssertEqual(SleepScore.timingScore(driftMinutes: 60, points: 10), 5.5, accuracy: 0.001) + XCTAssertEqual(SleepScore.timingScore(driftMinutes: 120, points: 10), 0, accuracy: 0.001) + XCTAssertEqual(SleepScore.timingScore(driftMinutes: 300, points: 10), 0, accuracy: 0.001, + "clamped, never negative") + } + + func testAwakeScoreKnots() { + XCTAssertEqual(SleepScore.awakeScore(10, points: 15), 15, accuracy: 0.001) + XCTAssertEqual(SleepScore.awakeScore(20, points: 15), 5.25, accuracy: 0.001) + XCTAssertEqual(SleepScore.awakeScore(35, points: 15), 0, accuracy: 0.001) + } + + // MARK: - Bedtime baseline maths + + /// Bedtimes straddle midnight, so they are averaged on a wrapped axis — otherwise 23:40 and + /// 00:20 would average to noon instead of to midnight. + func testBedtimeMedianWrapsAroundMidnight() { + let calendar = Calendar.current + let base = calendar.startOfDay(for: Date()) + let before = calendar.date(byAdding: .minute, value: -20, to: base)! // 23:40 + let after = calendar.date(byAdding: .minute, value: 20, to: base)! // 00:20 + + XCTAssertEqual(BedtimeBaseline.minutesFromMidnight(before), -20, accuracy: 0.001) + XCTAssertEqual(BedtimeBaseline.minutesFromMidnight(after), 20, accuracy: 0.001) + + let baseline = BedtimeBaseline.compute(bedtimes: [before, after]) + XCTAssertEqual(baseline?.medianMinutesFromMidnight ?? .nan, 0, accuracy: 0.001) + } + + func testBaselineNeedsSevenNights() { + let bedtimes = (0..<6).map { Date().addingTimeInterval(Double($0) * -86_400) } + XCTAssertEqual(BedtimeBaseline.compute(bedtimes: bedtimes)?.isEstablished, false) + XCTAssertEqual(BedtimeBaseline.compute(bedtimes: bedtimes + [Date()])?.isEstablished, true) + XCTAssertNil(BedtimeBaseline.compute(bedtimes: [])) + } + + /// The baseline must exclude the night being scored — including it would drag the median toward + /// that night and forgive exactly the drift the contributor exists to catch. + func testBaselineExcludesTheNightBeingScored() throws { + let context = try TestSupport.makeContext() + let calendar = Calendar.current + + // Ten prior nights, all starting at 23:00. + for offset in 1...10 { + let day = TestSupport.day(-offset) + let bedtime = calendar.date(bySettingHour: 23, minute: 0, second: 0, of: day) ?? day + _ = TestSupport.insertSleep(nightStart: bedtime, stages: Array(repeating: .light, count: 420), into: context) + } + // Tonight, three hours late. + let tonight = calendar.date(bySettingHour: 2, minute: 0, second: 0, of: TestSupport.day(0)) ?? TestSupport.day(0) + _ = TestSupport.insertSleep(nightStart: tonight, stages: Array(repeating: .light, count: 300), into: context) + + let baseline = try XCTUnwrap(SleepService.bedtimeBaseline(before: TestSupport.day(0), context: context)) + XCTAssertTrue(baseline.isEstablished) + XCTAssertEqual(baseline.medianMinutesFromMidnight, -60, accuracy: 1, + "23:00 on the wrapped axis, unmoved by tonight's 02:00") + } + + // MARK: - Guard rails + + /// A fragment of a night — nothing but a short duration reading — can't be dressed up as a + /// score out of 30. + func testTooLittleCoverageScoresZero() { + let sparse = SleepSummary( + session: SleepSession(date: Date(), startAt: Date(), endAt: Date().addingTimeInterval(3600), + totalMinutes: 0), + lightMinutes: 0, deepMinutes: 0, awakeMinutes: 0, remMinutes: 0, blocks: [] + ) + let result = SleepScore.calculate(sparse) + XCTAssertEqual(result.score, 0) + XCTAssertLessThan(result.coverage, 0.5) + } + + func testVersionIsStamped() { + XCTAssertEqual(SleepScore.calculate(night(inBed: 480, deep: 90, light: 220, awake: 30, rem: 140)).algorithmVersion, + SleepScore.algorithmVersion) + XCTAssertEqual(SleepScore.algorithmVersion, 2) + } + + /// Every contributor's points must sum to exactly 100, or `coverage` stops meaning "fraction of + /// the full picture". + func testContributorWeightsSumToOneHundred() { + XCTAssertEqual(SleepContributor.Kind.allCases.reduce(0) { $0 + $1.maxPoints }, 100, accuracy: 0.001) + } +} diff --git a/docs/project/sleep-score.md b/docs/project/sleep-score.md new file mode 100644 index 00000000..73cbcf83 --- /dev/null +++ b/docs/project/sleep-score.md @@ -0,0 +1,149 @@ +--- +title: Sleep score +description: How PulseLoop scores a night — every contributor, weight, and threshold, documented. +--- + +# Sleep score + +The sleep score answers one question: **how good was last night?** A single number from 0 to 100, +computed on your device from what your ring actually recorded. + +This page documents the whole algorithm. PulseLoop's principles commit to "documented metrics and +an auditable coach, no black boxes", and a sleep score you can't inspect is exactly the thing +competitors charge a subscription for. + +The implementation is +[`SleepInsights.swift`](https://github.com/saksham2001/PulseLoopiOS/blob/main/PulseLoop/Services/SleepInsights.swift), +covered by unit tests that lock every number here. **Algorithm version: 2.** + +## Bands + +| Score | Label | +|---|---| +| 85–100 | Excellent | +| 70–84 | Good | +| 55–69 | Fair | +| 0–54 | Needs work | + +## Contributors + +Five signals, worth 100 points between them. + +| Contributor | Points | What's measured | +|---|---|---| +| Duration | 30 | Total sleep time — time in bed minus minutes tagged awake | +| Deep sleep | 25 | Deep as a share of the night | +| REM sleep | 20 | REM as a share of the night | +| Restfulness | 15 | Share of the night spent awake | +| Bedtime consistency | 10 | How far tonight's bedtime sat from your own recent median | + +### Thresholds + +Each contributor earns full points inside its **ideal** band, 65% of its points at the **soft** +knot, and zero at or beyond **hard**, interpolating linearly between. + +| Contributor | ideal | soft | hard | +|---|---|---|---| +| Duration | 7–9 h asleep | 6 h / 9.5 h | 4 h / 12 h | +| Deep sleep | 13–23% | 5% / 35% | 0% / 45% | +| REM sleep | 20–25% | 15% / 30% | 5% / 40% | + +Restfulness and bedtime consistency use their own curves: + +| Awake share | Points | +|---|---| +| ≤ 10% | full | +| 20% | 35% of full | +| ≥ 35% | zero | + +| Bedtime drift | Points | +|---|---| +| ≤ 30 min | full | +| 60 min | 55% of full | +| ≥ 120 min | zero | + +The 30-minute knot is where circadian-regularity research stops calling a schedule regular. Two +hours is roughly a timezone — by then it's a different night, not a late one. + +## Missing data is never scored as zero + +This is the most important rule in the algorithm. + +If your ring can't produce a signal, that signal is **removed from the denominator** rather than +scored as zero: + +``` +score = 100 × (points earned) ÷ (points available) +``` + +The score also reports its **coverage** — what fraction of the full 100-point picture it rested on. + +What that means per ring: + +| Situation | Available | Result | +|---|---|---| +| REM-capable ring, a week of history | 100 | Full score | +| jring (no REM stage in its `0x11` timeline) | 80 | Scored out of 80 | +| First week of use (no bedtime baseline yet) | 90 | Scored out of 90 | +| jring, first week | 70 | Scored out of 70 | +| No usable wake signal | −15 | Restfulness withheld | + +A score is only produced when **at least 50 points** were available; below that there isn't enough +of a night to describe. + +This is what makes one number comparable across hardware: an ideal night scores the same on a jring +as on a Colmi, rather than the jring being permanently docked 20 points for a sensor it never had. + +## What changed in v2 + +### Duration is now time asleep, not time in bed + +`SleepSession.totalMinutes` is the wall-clock span — `SleepSegmentation` sets it from `end − start`. +v1 scored that directly, so a night with 8 hours in bed and 90 minutes awake was credited as 8 hours +of sleep. Awake minutes now come off the top. + +On a ring with no usable wake signal there is nothing to subtract, so the whole span stands in — which +makes duration read slightly generous on that hardware. Stated here rather than left implicit. + +### REM is scored + +Both the Colmi big-data timeline (stage `0x04`) and the YCBT timeline (tag `3`) report REM, and both +decoders always stored it — but v1 never scored it, and told the coach no ring could see it. + +### Light sleep is reported but no longer scored + +Once deep and REM are both scored, light is their residual — scoring it too counts the same night +twice. Its v1 band (ideal 50–60%) was calibrated for a no-REM decoder that lumped REM minutes into +light, so on a REM-capable ring it was scoring the same night harshly for a split that was correct. + +`lightPct` is still reported for display. + +### Sleep efficiency is deliberately absent + +Efficiency is total sleep time ÷ time in bed. Since `totalMinutes` **is** time in bed, that works out +to exactly `1 − awake %` — the restfulness contributor restated. Adding it would double-weight the +same signal while looking like a sixth independent one. + +Oura can score both because it separates "total sleep time" from "time in bed" using data these rings +don't provide. + +### Bedtime consistency is new + +Computed from your own median bedtime over the previous 14 nights (needing at least 7), on a wrapped +axis centred on midnight — so a 23:40 and a 00:20 bedtime average to midnight rather than to noon. + +Days are collapsed first so each contributes one bedtime: a collapsed day's start is the earliest of +its sessions, which is the night itself, since an afternoon nap starts later in the same waking day. + +**The night being scored is excluded from its own baseline.** Including it would drag the median +toward it and forgive exactly the drift the contributor exists to notice. + +## No migration was needed + +The sleep score is not stored: the production sync path +(`PulseEventBus`, `SleepSegmentation`) creates every `SleepSession` with a nil score, and every +screen calls `SleepScore.calculate` live from the stage blocks. Only demo data and imported archives +carry a stored score. + +So v2 took effect everywhere the moment it shipped, with no recompute pass. `algorithmVersion` is +still stamped on every result, so a future change that *does* need one has the hook already. diff --git a/mkdocs.yml b/mkdocs.yml index 949efe1d..49630429 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,6 +120,7 @@ nav: - Project: - Roadmap: project/roadmap.md - Architecture: project/architecture.md + - Sleep score: project/sleep-score.md - Contributing: project/contributing.md - Contributors: project/contributors.md - Privacy: project/privacy.md From d400b6c62649d07de613ddb18fa47bc85caa01d3 Mon Sep 17 00:00:00 2001 From: ak710 Date: Mon, 3 Aug 2026 01:23:09 -0400 Subject: [PATCH 3/3] Time the day around the user's own sleep schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four windows on the Sleep tab: get light by, last coffee by, finish eating by, wind down from. Ultrahuman's equivalents are one of the most-cited reasons people pick it over Oura. Anchored on the user's own bedtime and wake rather than on sunrise. Ultrahuman derives its windows from solar times, which needs location; this doesn't, for two reasons. The project's principles keep location optional, and the guidance these windows encode is already phrased relative to your own sleep — "get bright light within an hour or two of waking", not "at sunrise". A night-shift schedule (asleep 09:00, awake 17:00) produces a coherent set of windows for exactly that reason, where a sunrise-based one would produce nonsense. The cost is stated plainly in the docs and in the source: the light window cannot say whether it is actually light outside. It says when the body clock is most responsive, not when the sun is up. The schedule is a median bedtime and wake over the last 14 nights, needing seven. Days are collapsed so each contributes one of each, and only sessions of three hours or more count — a 20-minute nap's start and end are not a schedule and would drag both medians. Times are averaged on an axis wrapped around midnight, reusing the placement BedtimeBaseline already defines. Both offsets are placed against the midnight *ending* today, so a −60 (23:00) bedtime lands tonight and a +30 (00:30) bedtime lands tomorrow — both being tonight's sleep. An earlier version special-cased the two signs and put the caffeine cutoff for a 00:30 sleeper at 16:30 yesterday; the midnight test caught it, and the uniform expression is what makes the two sides agree. Nothing renders until the schedule is established, and each window's reason is disclosed on tap — four bare times with no explanation would be instructions rather than guidance, and these are general rules of thumb applied to a schedule, not findings from the user's data. Offsets and rationale in docs/project/circadian-windows.md. Co-Authored-By: Claude Opus 5 --- .../DesignSystem/CircadianWindowsCard.swift | 75 ++++++++ PulseLoop/Services/CircadianWindows.swift | 166 ++++++++++++++++++ PulseLoop/Services/PulseServices.swift | 28 +++ PulseLoop/Views/SleepView.swift | 8 + PulseLoopTests/CircadianWindowsTests.swift | 165 +++++++++++++++++ docs/project/circadian-windows.md | 78 ++++++++ mkdocs.yml | 1 + 7 files changed, 521 insertions(+) create mode 100644 PulseLoop/DesignSystem/CircadianWindowsCard.swift create mode 100644 PulseLoop/Services/CircadianWindows.swift create mode 100644 PulseLoopTests/CircadianWindowsTests.swift create mode 100644 docs/project/circadian-windows.md diff --git a/PulseLoop/DesignSystem/CircadianWindowsCard.swift b/PulseLoop/DesignSystem/CircadianWindowsCard.swift new file mode 100644 index 00000000..202953cc --- /dev/null +++ b/PulseLoop/DesignSystem/CircadianWindowsCard.swift @@ -0,0 +1,75 @@ +import SwiftUI + +/// Timing guidance hung off the user's own sleep schedule. +/// +/// Lives on the **Sleep** tab: every window is derived from bedtime and wake, so it belongs beside +/// the nights it was learned from rather than on an already-dense Today grid. The reasons are +/// disclosed on tap — four times with no explanation would be instructions, not guidance. +struct CircadianWindowsCard: View { + let windows: CircadianWindows + + @State private var expandedKind: CircadianWindows.Kind? + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text("YOUR DAY, TIMED") + .font(PulseFont.caption2.weight(.semibold)).tracking(1.0) + .foregroundStyle(PulseColors.textMuted) + Text("Built from your usual \(SleepFormat.clockTime(windows.usualBedtime)) bedtime") + .font(PulseFont.caption.weight(.regular)) + .foregroundStyle(PulseColors.textSecondary) + } + + VStack(spacing: 0) { + ForEach(windows.entries()) { entry in + row(entry) + if entry.kind != CircadianWindows.Kind.allCases.last { + Divider().overlay(PulseColors.borderSubtle) + } + } + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .pulseGlass(RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) + } + + @ViewBuilder + private func row(_ entry: CircadianWindows.Entry) -> some View { + let isExpanded = expandedKind == entry.kind + Button { + withAnimation(.snappy) { expandedKind = isExpanded ? nil : entry.kind } + } label: { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 12) { + Image(systemName: entry.kind.symbol) + .font(PulseFont.footnote) + .foregroundStyle(PulseColors.accent) + .frame(width: 22) + Text(entry.kind.title) + .font(PulseFont.caption.weight(.semibold)) + .foregroundStyle(PulseColors.textPrimary) + Spacer(minLength: 8) + Text(SleepFormat.clockTime(entry.time)) + .font(PulseFont.subheadline.weight(.semibold).monospacedDigit()) + .foregroundStyle(PulseColors.textPrimary) + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(PulseFont.caption2.weight(.semibold)) + .foregroundStyle(PulseColors.textMuted) + } + if isExpanded { + Text(entry.kind.reason) + .font(PulseFont.caption2.weight(.regular)) + .foregroundStyle(PulseColors.textMuted) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, 34) + } + } + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("\(entry.kind.title) \(SleepFormat.clockTime(entry.time)). \(entry.kind.reason)") + } +} diff --git a/PulseLoop/Services/CircadianWindows.swift b/PulseLoop/Services/CircadianWindows.swift new file mode 100644 index 00000000..b828a35e --- /dev/null +++ b/PulseLoop/Services/CircadianWindows.swift @@ -0,0 +1,166 @@ +import Foundation + +/// The user's own sleep schedule, learned from recent nights — the anchor every window hangs off. +struct CircadianBaseline: Equatable { + /// Median bedtime, minutes on a −12…+12 h axis centred on midnight. + let bedtimeMinutesFromMidnight: Double + /// Median wake time, on the same axis (so an 07:00 wake is +420). + let wakeMinutesFromMidnight: Double + let nights: Int + + /// Enough history to call a schedule "usual" — the same seven-night floor the bedtime-consistency + /// contributor uses. + static let minNights = 7 + var isEstablished: Bool { nights >= Self.minNights } + + /// Minutes past midnight on a wrapped axis: 23:00 → −60, 01:00 → +60. Shared with + /// `BedtimeBaseline` so bedtimes are placed identically wherever they are read. + static func minutesFromMidnight(_ date: Date, calendar: Calendar = .current) -> Double { + BedtimeBaseline.minutesFromMidnight(date, calendar: calendar) + } + + static func compute(bedtimes: [Date], wakeTimes: [Date], calendar: Calendar = .current) -> CircadianBaseline? { + guard !bedtimes.isEmpty, bedtimes.count == wakeTimes.count else { return nil } + func median(_ dates: [Date]) -> Double { + let values = dates.map { minutesFromMidnight($0, calendar: calendar) }.sorted() + let mid = values.count / 2 + return values.count.isMultiple(of: 2) ? (values[mid - 1] + values[mid]) / 2 : values[mid] + } + return CircadianBaseline( + bedtimeMinutesFromMidnight: median(bedtimes), + wakeMinutesFromMidnight: median(wakeTimes), + nights: bedtimes.count + ) + } +} + +/// Timing guidance hung off the user's own schedule: when to stop caffeine, when to stop eating, +/// when to start winding down, and when to get light. +/// +/// **Anchored on sleep timing, not on sunrise.** Ultrahuman's equivalent windows use location to +/// derive solar times; PulseLoop doesn't, for two reasons. The project's principles keep data on the +/// device and location optional, and the circadian advice these windows encode is *already* phrased +/// relative to your own sleep in the literature — "get bright light within an hour or two of waking", +/// not "at sunrise". A night-shift worker's morning is their morning. +/// +/// The one thing this therefore cannot do is tell you whether it is actually light outside. The +/// light window says when your body clock is most responsive to light, not when the sun is up. +struct CircadianWindows: Equatable { + /// Stop caffeine by this time. + let caffeineCutoff: Date + /// Aim to finish eating by this time. + let lastMealBy: Date + /// Start winding down from this time. + let windDownFrom: Date + /// Get bright light before this time, counted from your usual wake. + let morningLightBy: Date + /// Your usual bedtime, for context. + let usualBedtime: Date + /// Your usual wake time, for context. + let usualWake: Date + + /// Caffeine's half-life is roughly 5–6 hours, so a cup eight hours before bed still leaves about + /// a quarter of it circulating at lights-out. Eight hours is the common recommendation and the + /// one Ultrahuman's adenosine window is built on. + static let caffeineCutoffHoursBeforeBed: Double = 8 + /// Late meals raise core temperature and delay sleep onset; three hours is the usual advice. + static let lastMealHoursBeforeBed: Double = 3 + /// Long enough to matter, short enough that people will actually do it. + static let windDownHoursBeforeBed: Double = 1 + /// Light lands hardest on the clock soon after waking. + static let morningLightHoursAfterWake: Double = 2 + + /// Builds the windows for the day containing `now`. + /// + /// Bedtime is placed on **tonight**: a baseline bedtime of 23:00 means tonight's 23:00, and one of + /// 00:30 means tomorrow's. Everything is then counted back from there, so the caffeine cutoff for + /// someone who sleeps at 00:30 lands at 16:30 today rather than yesterday afternoon. + static func build( + from baseline: CircadianBaseline, now: Date = Date(), calendar: Calendar = .current + ) -> CircadianWindows? { + guard baseline.isEstablished else { return nil } + let startOfToday = calendar.startOfDay(for: now) + + // Both offsets are measured against the midnight *ending* today, so both are placed the same + // way: `startOfToday + 24 h + offset`. + // + // That single expression is what makes the two sides of midnight agree. A −60 (23:00) + // bedtime lands on tonight at 23:00; a +30 (00:30) bedtime lands on tomorrow at 00:30 — + // which is still *tonight's* sleep. Placing a positive offset on today instead would put the + // caffeine cutoff for a 00:30 sleeper at 16:30 yesterday. + func placedTonight(_ minutesFromMidnight: Double) -> Date { + startOfToday.addingTimeInterval((24 * 60 + minutesFromMidnight) * 60) + } + + let bedtime = placedTonight(baseline.bedtimeMinutesFromMidnight) + // Wake follows the bedtime it belongs to, so push it a day on if it landed before. + let wakeCandidate = placedTonight(baseline.wakeMinutesFromMidnight) + let usualWake = wakeCandidate > bedtime + ? wakeCandidate + : wakeCandidate.addingTimeInterval(24 * 3600) + + return CircadianWindows( + caffeineCutoff: bedtime.addingTimeInterval(-caffeineCutoffHoursBeforeBed * 3600), + lastMealBy: bedtime.addingTimeInterval(-lastMealHoursBeforeBed * 3600), + windDownFrom: bedtime.addingTimeInterval(-windDownHoursBeforeBed * 3600), + // Counted from *this* morning's wake, which is a day before tonight's bedtime. + morningLightBy: usualWake.addingTimeInterval((morningLightHoursAfterWake - 24) * 3600), + usualBedtime: bedtime, + usualWake: usualWake.addingTimeInterval(-24 * 3600) + ) + } + + /// The windows in display order, with the label and a one-line reason for each. + func entries() -> [Entry] { + [ + Entry(kind: .morningLight, time: morningLightBy), + Entry(kind: .caffeine, time: caffeineCutoff), + Entry(kind: .lastMeal, time: lastMealBy), + Entry(kind: .windDown, time: windDownFrom), + ] + } + + struct Entry: Equatable, Identifiable { + let kind: Kind + let time: Date + var id: String { kind.rawValue } + } + + enum Kind: String, CaseIterable { + case morningLight + case caffeine + case lastMeal + case windDown + + var title: String { + switch self { + case .morningLight: return "Get light by" + case .caffeine: return "Last coffee by" + case .lastMeal: return "Finish eating by" + case .windDown: return "Wind down from" + } + } + + var reason: String { + switch self { + case .morningLight: + return "Light within a couple of hours of waking is what anchors your body clock to the day." + case .caffeine: + return "Caffeine's half-life is about 5–6 hours, so a late cup is still working at lights-out." + case .lastMeal: + return "Eating late raises core temperature, which pushes sleep onset back." + case .windDown: + return "An hour of lower light and lower stimulation before bed shortens how long you take to drop off." + } + } + + var symbol: String { + switch self { + case .morningLight: return "sun.max" + case .caffeine: return "cup.and.saucer" + case .lastMeal: return "fork.knife" + case .windDown: return "moon.stars" + } + } + } +} diff --git a/PulseLoop/Services/PulseServices.swift b/PulseLoop/Services/PulseServices.swift index 3223e873..f4895514 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -631,6 +631,34 @@ enum SleepService { /// so a fortnight with a few unworn nights still reaches the 7-night floor. static let bedtimeBaselineLookbackDays = 30 + /// The user's usual sleep schedule — bedtime *and* wake time — over the recent window, or nil + /// when there isn't a week of nights to learn from. + /// + /// Days are collapsed first so each contributes one bedtime and one wake, and only sessions long + /// enough to be a night are used: a 20-minute nap's start and end are not a schedule. + static func circadianBaseline(now: Date = Date(), context: ModelContext) -> CircadianBaseline? { + let start = Calendar.current.date(byAdding: .day, value: -bedtimeBaselineLookbackDays, to: now) ?? now + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.startAt >= start && $0.startAt <= now }, + sortBy: [SortDescriptor(\.startAt, order: .reverse)] + ) + let sessions = ((try? context.fetch(descriptor)) ?? []).map { + SleepSummary(session: $0, lightMinutes: 0, deepMinutes: 0, awakeMinutes: 0, remMinutes: 0, blocks: []) + } + let nights = SleepInsights.collapseByDay(sessions) + .filter { $0.session.totalMinutes >= minimumNightMinutes } + .sorted { $0.session.date > $1.session.date } + .prefix(14) + + return CircadianBaseline.compute( + bedtimes: nights.map { $0.session.startAt }, + wakeTimes: nights.map { $0.session.endAt } + ) + } + + /// Shortest session that counts as a night rather than a nap, for schedule learning. + static let minimumNightMinutes = 180 + /// The user's usual bedtime as of `night`, or nil when there isn't a week of prior nights. /// /// A windowed predicate fetch rather than `SleepRepository.sessions`, which reads the whole diff --git a/PulseLoop/Views/SleepView.swift b/PulseLoop/Views/SleepView.swift index 2077f3e1..afa6714b 100644 --- a/PulseLoop/Views/SleepView.swift +++ b/PulseLoop/Views/SleepView.swift @@ -131,6 +131,14 @@ struct SleepView: View { } else { sleepCarousel(sessions: sessions, bedtimeBaseline: bedtimeBaseline) } + // Timing guidance for the day ahead — only meaningful on today, and only once a week of + // nights has taught the app the user's actual schedule. + if isToday, + let baseline = SleepService.circadianBaseline(context: modelContext), + let windows = CircadianWindows.build(from: baseline) { + CircadianWindowsCard(windows: windows) + } + // The LLM day summary describes last night only; on a past day fall back to the // scripted coach (deterministically computed from that day's own primary session). summaryCard(isToday ? daySummary : nil, fallback: dayFallback) diff --git a/PulseLoopTests/CircadianWindowsTests.swift b/PulseLoopTests/CircadianWindowsTests.swift new file mode 100644 index 00000000..6cc0305a --- /dev/null +++ b/PulseLoopTests/CircadianWindowsTests.swift @@ -0,0 +1,165 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// Timing windows hung off the user's own schedule. Most of the risk here is date arithmetic around +/// midnight, so that is where the tests concentrate. +@MainActor +final class CircadianWindowsTests: XCTestCase { + + private let calendar = Calendar.current + + private func today(_ hour: Int, _ minute: Int = 0) -> Date { + calendar.date(bySettingHour: hour, minute: minute, second: 0, of: calendar.startOfDay(for: Date())) + ?? Date() + } + + private func clock(_ date: Date) -> String { + let parts = calendar.dateComponents([.hour, .minute], from: date) + return String(format: "%02d:%02d", parts.hour ?? 0, parts.minute ?? 0) + } + + /// An evening bedtime (23:00) and a morning wake (07:00). + private var eveningSleeper: CircadianBaseline { + CircadianBaseline(bedtimeMinutesFromMidnight: -60, wakeMinutesFromMidnight: 420, nights: 14) + } + + /// A past-midnight bedtime (00:30) and a later wake (08:30). + private var lateSleeper: CircadianBaseline { + CircadianBaseline(bedtimeMinutesFromMidnight: 30, wakeMinutesFromMidnight: 510, nights: 14) + } + + // MARK: - Offsets + + func testWindowsHangOffBedtime() throws { + let windows = try XCTUnwrap(CircadianWindows.build(from: eveningSleeper, now: today(12))) + + XCTAssertEqual(clock(windows.usualBedtime), "23:00") + XCTAssertEqual(clock(windows.caffeineCutoff), "15:00", "8 hours before bed") + XCTAssertEqual(clock(windows.lastMealBy), "20:00", "3 hours before bed") + XCTAssertEqual(clock(windows.windDownFrom), "22:00", "1 hour before bed") + XCTAssertEqual(clock(windows.morningLightBy), "09:00", "2 hours after wake") + } + + /// **The midnight case.** Someone who sleeps at 00:30 must get a 16:30 caffeine cutoff *today*, + /// not 16:30 yesterday — counting back eight hours from a bedtime placed on the wrong day is the + /// obvious way to get this wrong. + func testAPastMidnightBedtimeCountsBackIntoToday() throws { + let now = today(12) + let windows = try XCTUnwrap(CircadianWindows.build(from: lateSleeper, now: now)) + + XCTAssertEqual(clock(windows.usualBedtime), "00:30") + XCTAssertEqual(clock(windows.caffeineCutoff), "16:30") + XCTAssertTrue(windows.caffeineCutoff > now, "the cutoff is still ahead of a midday 'now'") + XCTAssertTrue(windows.usualBedtime > now, "tonight's bedtime, not last night's") + } + + /// Every window must sit before the bedtime it was derived from, whichever side of midnight the + /// bedtime falls on. + func testEveryEveningWindowPrecedesBedtime() throws { + for baseline in [eveningSleeper, lateSleeper] { + let windows = try XCTUnwrap(CircadianWindows.build(from: baseline, now: today(12))) + XCTAssertLessThan(windows.caffeineCutoff, windows.usualBedtime) + XCTAssertLessThan(windows.lastMealBy, windows.usualBedtime) + XCTAssertLessThan(windows.windDownFrom, windows.usualBedtime) + XCTAssertLessThan(windows.morningLightBy, windows.usualBedtime) + } + } + + /// A night-shift schedule — asleep at 09:00, up at 17:00 — must still produce a coherent day. + /// The windows are relative to *their* sleep, which is the whole reason this isn't built on + /// sunrise. + func testANightShiftScheduleStillWorks() throws { + let nightShift = CircadianBaseline( + bedtimeMinutesFromMidnight: 540, wakeMinutesFromMidnight: 1020 - 24 * 60, nights: 10 + ) + let windows = try XCTUnwrap(CircadianWindows.build(from: nightShift, now: today(12))) + XCTAssertEqual(clock(windows.usualBedtime), "09:00") + XCTAssertEqual(clock(windows.caffeineCutoff), "01:00") + XCTAssertLessThan(windows.caffeineCutoff, windows.usualBedtime) + } + + // MARK: - Establishment + + func testWindowsNeedAnEstablishedBaseline() { + let thin = CircadianBaseline(bedtimeMinutesFromMidnight: -60, wakeMinutesFromMidnight: 420, nights: 6) + XCTAssertFalse(thin.isEstablished) + XCTAssertNil(CircadianWindows.build(from: thin)) + } + + func testBaselineNeedsMatchingBedtimesAndWakes() { + XCTAssertNil(CircadianBaseline.compute(bedtimes: [Date()], wakeTimes: [])) + XCTAssertNil(CircadianBaseline.compute(bedtimes: [], wakeTimes: [])) + } + + func testBaselineMediansWrapAroundMidnight() throws { + let before = today(23, 40) + let after = today(0, 20) + let baseline = try XCTUnwrap( + CircadianBaseline.compute(bedtimes: [before, after], wakeTimes: [today(7), today(7)]) + ) + XCTAssertEqual(baseline.bedtimeMinutesFromMidnight, 0, accuracy: 0.001, + "23:40 and 00:20 average to midnight, not noon") + XCTAssertEqual(baseline.wakeMinutesFromMidnight, 420, accuracy: 0.001) + } + + // MARK: - Learning the schedule from the store + + func testScheduleIsLearnedFromRecentNights() throws { + let context = try TestSupport.makeContext() + // Ten nights, 23:00 to 07:00. + for offset in 1...10 { + let day = TestSupport.day(-offset) + let bedtime = calendar.date(bySettingHour: 23, minute: 0, second: 0, of: day) ?? day + _ = TestSupport.insertSleep(nightStart: bedtime, stages: Array(repeating: .light, count: 480), into: context) + } + let baseline = try XCTUnwrap(SleepService.circadianBaseline(context: context)) + + XCTAssertTrue(baseline.isEstablished) + XCTAssertEqual(baseline.bedtimeMinutesFromMidnight, -60, accuracy: 1) + XCTAssertEqual(baseline.wakeMinutesFromMidnight, 420, accuracy: 1) + } + + /// Naps are not a schedule. A 20-minute afternoon sleep's start and end must not be read as a + /// bedtime and a wake. + func testNapsAreExcludedFromTheSchedule() throws { + let context = try TestSupport.makeContext() + for offset in 1...10 { + let day = TestSupport.day(-offset) + let bedtime = calendar.date(bySettingHour: 23, minute: 0, second: 0, of: day) ?? day + _ = TestSupport.insertSleep(nightStart: bedtime, stages: Array(repeating: .light, count: 480), into: context) + // A short nap the same waking day, well past the segmentation gap. + let nap = calendar.date(bySettingHour: 14, minute: 0, second: 0, of: day) ?? day + _ = TestSupport.insertSleep(nightStart: nap, stages: Array(repeating: .light, count: 20), into: context) + } + let baseline = try XCTUnwrap(SleepService.circadianBaseline(context: context)) + XCTAssertEqual(baseline.bedtimeMinutesFromMidnight, -60, accuracy: 1, + "the 14:00 nap doesn't drag the bedtime median") + } + + func testNoScheduleWithoutAWeekOfNights() throws { + let context = try TestSupport.makeContext() + for offset in 1...4 { + let day = TestSupport.day(-offset) + let bedtime = calendar.date(bySettingHour: 23, minute: 0, second: 0, of: day) ?? day + _ = TestSupport.insertSleep(nightStart: bedtime, stages: Array(repeating: .light, count: 480), into: context) + } + XCTAssertEqual(SleepService.circadianBaseline(context: context)?.isEstablished, false) + } + + // MARK: - Copy + + /// Four times with no explanation would be instructions rather than guidance. + func testEveryWindowCarriesAReason() { + for kind in CircadianWindows.Kind.allCases { + XCTAssertFalse(kind.title.isEmpty) + XCTAssertFalse(kind.reason.isEmpty) + XCTAssertFalse(kind.symbol.isEmpty) + } + } + + func testEntriesCoverEveryWindow() throws { + let windows = try XCTUnwrap(CircadianWindows.build(from: eveningSleeper, now: today(12))) + XCTAssertEqual(Set(windows.entries().map(\.kind)), Set(CircadianWindows.Kind.allCases)) + } +} diff --git a/docs/project/circadian-windows.md b/docs/project/circadian-windows.md new file mode 100644 index 00000000..284b24d2 --- /dev/null +++ b/docs/project/circadian-windows.md @@ -0,0 +1,78 @@ +--- +title: Circadian windows +description: When to stop caffeine, finish eating, wind down and get light — every offset and where it comes from. +--- + +# Circadian windows + +Four times on the Sleep tab, hung off your own sleep schedule: when to stop caffeine, when to finish +eating, when to start winding down, and when to get light. + +Ultrahuman's equivalent windows are one of the most-cited reasons people pick it over Oura. This is +the same idea, computed on your device from nights you already recorded. + +Implementation: +[`CircadianWindows.swift`](https://github.com/saksham2001/PulseLoopiOS/blob/main/PulseLoop/Services/CircadianWindows.swift). +Unit tests lock every offset and every midnight case. + +## The windows + +| Window | Offset | Why | +|---|---|---| +| Get light by | wake **+ 2 h** | Light lands hardest on the body clock soon after waking. | +| Last coffee by | bedtime **− 8 h** | Caffeine's half-life is ~5–6 h, so a cup 8 h before bed still leaves roughly a quarter circulating at lights-out. | +| Finish eating by | bedtime **− 3 h** | Late meals raise core temperature, which pushes sleep onset back. | +| Wind down from | bedtime **− 1 h** | Long enough to matter, short enough that people actually do it. | + +## Anchored on your sleep, not on sunrise + +Ultrahuman derives its windows from solar times, which needs your location. PulseLoop doesn't, for +two reasons: + +1. **Privacy.** The project's principles keep data on the device and location optional. Requiring + coordinates to show four times would be a poor trade. +2. **It's what the advice actually says.** The circadian guidance these windows encode is already + phrased relative to your own sleep — "get bright light within an hour or two of waking", not "at + sunrise". Someone who wakes at 13:00 still benefits from light at 13:00. + +The consequence, stated plainly: **the light window cannot tell you whether it is actually light +outside.** It says when your body clock is most responsive to light, not when the sun is up. On a +dark winter morning that means a lamp, and the app can't know the difference. + +A night-shift schedule — asleep at 09:00, awake at 17:00 — produces a coherent set of windows for +exactly this reason, where a sunrise-based one would produce nonsense. + +## Learning your schedule + +The anchor is a **median bedtime and wake time over your last 14 nights**, needing at least 7. + +- Days are collapsed first so each contributes one bedtime and one wake. +- Only sessions of **3 hours or more** count. A 20-minute afternoon nap's start and end are not a + schedule, and would otherwise drag both medians. +- Medians, not means, so one very late night doesn't move your usual. +- Times are averaged on an axis **wrapped around midnight**, so a 23:40 and a 00:20 bedtime average + to midnight rather than to noon. + +Nothing renders until the schedule is established — no window is shown built on three nights. + +## The midnight case + +Both offsets are measured against the midnight *ending* today, so both are placed identically: + +``` +time = startOfToday + 24 h + offsetMinutes +``` + +That one expression is what makes the two sides of midnight agree. A −60 (23:00) bedtime lands on +tonight at 23:00; a +30 (00:30) bedtime lands on tomorrow at 00:30 — which is still *tonight's* +sleep. Placing a positive offset on today instead would put the caffeine cutoff for a 00:30 sleeper +at 16:30 **yesterday**, which is the bug the tests were written to catch (and did). + +## Where it appears + +The **Sleep** tab, under the night it was learned from — not Today, which is already a dense grid. +Only on today's view: yesterday's page shows the night, not tomorrow's plan. + +Each window's reason is disclosed on tap. Four bare times with no explanation would be instructions +rather than guidance, and these are guidance — general rules of thumb applied to your schedule, not +findings from your data. diff --git a/mkdocs.yml b/mkdocs.yml index 49630429..bfadc789 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -121,6 +121,7 @@ nav: - Roadmap: project/roadmap.md - Architecture: project/architecture.md - Sleep score: project/sleep-score.md + - Circadian windows: project/circadian-windows.md - Contributing: project/contributing.md - Contributors: project/contributors.md - Privacy: project/privacy.md