diff --git a/PulseLoop/Coach/Context/CoachContextBuilder.swift b/PulseLoop/Coach/Context/CoachContextBuilder.swift index 9b7a42a..74f9733 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 5bfebc8..a7f1285 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 7aad1fb..81cdcea 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 c7c34a9..f9cc0f4 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 3b8e888..499a74e 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([ @@ -103,13 +114,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 5aa97f0..65c782a 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 37adecd..f06bc5e 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 145cfd4..1050227 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 754926c..6b54b0e 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 56d685b..3223e87 100644 --- a/PulseLoop/Services/PulseServices.swift +++ b/PulseLoop/Services/PulseServices.swift @@ -626,17 +626,45 @@ 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) 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 d75a91f..4e1b1dd 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,6 +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. + 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 { @@ -51,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) @@ -65,41 +148,158 @@ enum SleepScore { return .needsWork } - static func calculate(_ sleep: SleepSummary) -> SleepScoreResult { - let total = sleep.session.totalMinutes > 0 ? Double(sleep.session.totalMinutes) : 0 + /// 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. + /// + /// Two deliberate departures from v1: + /// + /// - **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)) + 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: 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 = sleep.blocks.contains { $0.stage == .awake } || awake > 0 || - (total > 0 && coveredStageMin >= total * 0.95) + (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" + )) + } - 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 + // 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" + )) + } - 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)) + // 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 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, 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()) }, + 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 @@ -177,6 +377,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 +414,7 @@ enum SleepInsights { lightMinutes: lightMinutes, deepMinutes: deepMinutes, awakeMinutes: awakeMinutes, + remMinutes: remMinutes, blocks: blocks )) } @@ -220,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 } @@ -233,13 +453,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 a80189b..2077f3e 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 @@ -150,14 +154,15 @@ 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 ) } /// 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 @@ -168,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) @@ -440,7 +445,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 0000000..fbfe294 --- /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)) + } +} diff --git a/PulseLoopTests/SleepScoreV2Tests.swift b/PulseLoopTests/SleepScoreV2Tests.swift new file mode 100644 index 0000000..1478950 --- /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 0000000..73cbcf8 --- /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 949efe1..4963042 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