From 649f694dfaaf9328ed2514f57ddf1152534bc1e1 Mon Sep 17 00:00:00 2001 From: ak710 Date: Sun, 2 Aug 2026 14:19:43 -0400 Subject: [PATCH] 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)) + } +}