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..1e4b331 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 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..fac0b2d 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 d75a91f..6bab7f2 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 a80189b..2c5e1e0 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 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)) + } +}