Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion PulseLoop/Coach/Context/CoachContextBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}

Expand All @@ -104,6 +105,7 @@ enum CoachContextBuilder {
profileCompleteness: completeness,
daysAvailable: daysAvailable,
hasSleep: sleep != nil,
sleepHasREM: summary.sleep?.hasRemSignal ?? false,
lastSyncAt: device?.lastSyncAt,
isDemo: summary.isDemo
),
Expand Down
3 changes: 3 additions & 0 deletions PulseLoop/Coach/Context/CoachContextPacket.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion PulseLoop/Coach/Context/CoachPromptBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
27 changes: 24 additions & 3 deletions PulseLoop/Coach/Context/DataQualityAnalyzer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -36,7 +57,7 @@ enum DataQualityAnalyzer {
}

if input.hasSleep {
out.append(sleepDecoderNote)
out.append(sleepDecoderNote(hasREM: input.sleepHasREM))
}

if !input.isDemo {
Expand Down
29 changes: 22 additions & 7 deletions PulseLoop/Coach/Summaries/CoachSummaryContextBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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([
Expand Down
22 changes: 18 additions & 4 deletions PulseLoop/Coach/Tools/RetrievalTools.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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),
])
}
}
Expand Down
23 changes: 19 additions & 4 deletions PulseLoop/DesignSystem/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

Expand Down
6 changes: 4 additions & 2 deletions PulseLoop/Persistence/SeedData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions PulseLoop/Services/DerivedSummaries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
28 changes: 28 additions & 0 deletions PulseLoop/Services/PulseServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<SleepSession>(
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 : []
)
}
Expand Down
Loading