diff --git a/PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift b/PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift index d0813b3..b7ec7bc 100644 --- a/PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift +++ b/PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift @@ -4,7 +4,7 @@ import Foundation enum CoachAnomalyKind: String, Codable, Equatable { case lowSpO2 case poorSleep - /// Reserved for a future baseline-aware detector (needs multi-day history). + /// Last night's resting HR sitting well above the learned 30-day baseline. case restingHRDrift } @@ -21,10 +21,23 @@ struct CoachAnomaly: Equatable { /// Pure, conservative anomaly detection over the notification context packet. /// Thresholds are intentionally cautious — a missed alert is far better than a /// false alarm on health data. Returns at most one anomaly, highest-priority -/// first. (Resting-HR drift is deferred — it needs a multi-day baseline the -/// 12-hour packet doesn't carry.) +/// first. enum CoachAnomalyDetector { - static func detect(_ packet: NotificationContextPacket) -> CoachAnomaly? { + /// How far last night's resting HR must sit above the learned baseline before it's worth + /// interrupting for. + /// + /// Five bpm is the usual consumer-wearable threshold for "your body is working harder than + /// usual at rest" — the signal that moves first with infection, alcohol, heat and + /// under-recovery, typically a day before the user notices anything. Below that the day-to-day + /// noise in an optical ring's overnight sampling swamps it. + static let driftBpm: Double = 5 + + /// …and the night must be recent. The baseline is a 30-day figure, so an old night compared + /// against it says nothing about today; `SleepService.latestSleep` already withholds stale + /// sessions, and this is the belt-and-braces check on the packet's own date string. + static let driftMaxNightAgeDays = 2 + + static func detect(_ packet: NotificationContextPacket, now: Date = Date()) -> CoachAnomaly? { // 1. Low SpO₂ — most clinically meaningful. Require a few readings so a // single noisy sample doesn't trigger an alert. if packet.spo2Last12h.count >= 3, let lowest = packet.spo2Last12h.min, lowest < 90 { @@ -45,6 +58,47 @@ enum CoachAnomalyDetector { ) } + // 3. Resting-HR drift. Ordered last of the three deliberately: a short or broken night + // usually raises resting HR too, so when both trip, the sleep alert names the cause and + // this one would only restate its consequence. `detect` returns at most one anomaly, so + // this is a precedence choice between two messages about the same night. + if let drift = restingHRDrift(packet, now: now) { return drift } + return nil } + + // MARK: - Resting-HR drift + + /// Fires when last night's resting HR sits `driftBpm` or more above the learned baseline. + /// + /// Only the elevated direction is reported. A resting HR *below* baseline is usually good news + /// (fitness, a genuinely restful night) and is not something to push an unprompted alert about — + /// the same asymmetry the readiness score applies to HRV. + private static func restingHRDrift( + _ packet: NotificationContextPacket, now: Date + ) -> CoachAnomaly? { + guard let resting = packet.restingHR else { return nil } + + let drift = resting.lastNightBpm - resting.baselineBpm + guard drift >= driftBpm else { return nil } + guard isRecentNight(resting.nightOf, now: now) else { return nil } + + let night = Int(resting.lastNightBpm.rounded()) + let base = Int(resting.baselineBpm.rounded()) + let delta = Int(drift.rounded()) + return CoachAnomaly( + kind: .restingHRDrift, + facts: "Resting heart rate overnight was \(night) bpm, \(delta) bpm above the usual \(base) bpm " + + "learned over the last 30 days. An elevated resting heart rate often shows up a day before " + + "you feel run down, and also follows alcohol, heat, or a hard session the day before." + ) + } + + /// Whether the packet's night date is recent enough to say anything about today. + private static func isRecentNight(_ nightOf: String, now: Date, calendar: Calendar = .current) -> Bool { + guard let date = CoachDataAccess.parseLocalDate(nightOf) else { return false } + let days = calendar.dateComponents([.day], from: calendar.startOfDay(for: date), + to: calendar.startOfDay(for: now)).day ?? .max + return days <= driftMaxNightAgeDays + } } diff --git a/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift b/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift index 8190c47..4ecd030 100644 --- a/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift +++ b/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift @@ -75,7 +75,10 @@ enum CoachNotificationGenerator { tip: "Go easy today and aim for an earlier wind-down.", followUp: "Want tips for a better night tonight?") case .restingHRDrift: - return CoachNotification(title: "A quick heads-up", body: anomaly.facts) + return CoachNotification(title: "Resting heart rate is up", + body: anomaly.facts, + tip: "Worth an easier day and some extra fluids; it usually settles in a night or two.", + followUp: "Want to look at what's been different this week?") } } diff --git a/PulseLoop/Coach/Notifications/CoachNotificationService.swift b/PulseLoop/Coach/Notifications/CoachNotificationService.swift index 58eec15..d5ced7f 100644 --- a/PulseLoop/Coach/Notifications/CoachNotificationService.swift +++ b/PulseLoop/Coach/Notifications/CoachNotificationService.swift @@ -162,7 +162,7 @@ final class CoachNotificationService { let slot = forcedSlot(now: now) // only for building the context packet let environment = await CoachEnvironmentContextService.shared.snapshot(now: now) let packet = NotificationContextBuilder.build(slot: slot, context: modelContext, now: now, environment: environment) - guard let anomaly = CoachAnomalyDetector.detect(packet) else { return .noAnomaly } + guard let anomaly = CoachAnomalyDetector.detect(packet, now: now) else { return .noAnomaly } if !force, isAnomalyDuplicate(anomaly, now: now) { return .noAnomaly } diff --git a/PulseLoop/Coach/Notifications/NotificationContextBuilder.swift b/PulseLoop/Coach/Notifications/NotificationContextBuilder.swift index 83ad54a..98f32e7 100644 --- a/PulseLoop/Coach/Notifications/NotificationContextBuilder.swift +++ b/PulseLoop/Coach/Notifications/NotificationContextBuilder.swift @@ -26,6 +26,25 @@ struct NotificationContextPacket: Encodable { /// Present only when nutrition tracking is on, shared with the coach, AND the /// check-in sub-toggle allows it. var nutrition: CoachContextPacket.NutritionContext? + /// Last night measured against the learned resting-HR baseline. + /// + /// This is the one block the 12-hour window can't supply on its own: drift is only meaningful + /// against a multi-day baseline, which is why `restingHRDrift` sat declared-but-unfired. The + /// baseline is already learned and persisted by `RestingHRBaselineService`, so the packet just + /// carries it in alongside the single night to compare it to. + var restingHR: RestingHRContext? + + struct RestingHRContext: Encodable { + /// The learned 10th-percentile resting HR over 30 days. Non-nil implies established — + /// `RestingHRBaselineService` stores nil until it has ≥20 samples spanning ≥7 days. + var baselineBpm: Double + /// Last night's resting HR, measured the same way over the night's own HR samples. + var lastNightBpm: Double + /// How many HR samples that night figure came from, so the model can weigh it. + var sampleCount: Int + /// Local date of the night, so a stale night is visible rather than implied to be recent. + var nightOf: String + } } @MainActor @@ -62,7 +81,42 @@ enum NotificationContextBuilder { memories: packet.memories, dataQualityWarnings: packet.dataQualityWarnings, environment: environment, - nutrition: packet.nutrition + nutrition: packet.nutrition, + restingHR: restingHR(context: context, now: now) ) } + + /// Last night's resting HR beside the learned baseline, or nil when either is unavailable. + /// + /// The night is bounded by the sleep session itself rather than a fixed clock window, so a shift + /// worker or a late night is measured over the hours they actually slept. `SleepService.latestSleep` + /// already withholds stale sessions, so a ring that hasn't synced in days yields nil here rather + /// than comparing against an old night. + static func restingHR( + context: ModelContext, now: Date = Date() + ) -> NotificationContextPacket.RestingHRContext? { + guard let baseline = ProfileRepository.profile(context: context)?.hrRestingBaseline, + let night = SleepService.latestSleep(context: context) else { return nil } + + let samples = MetricsRepository.measurements( + kind: .heartRate, start: night.session.startAt, end: night.session.endAt, context: context + ).map(\.value).filter { $0 > 0 } + + // A YCBT ring floors its all-day interval at 30 minutes, so a full night is only ~14 samples + // there against ~84 on a 5-minute Colmi. Ten keeps both usable while still refusing to call a + // handful of readings a resting heart rate. + guard samples.count >= minNightSamples else { return nil } + + return .init( + baselineBpm: (baseline * 10).rounded() / 10, + lastNightBpm: (RestingHRBaselineService.percentile( + samples.sorted(), RestingHRBaselineService.restingPercentile + ) * 10).rounded() / 10, + sampleCount: samples.count, + nightOf: CoachDataAccess.localDateString(night.session.date) + ) + } + + /// Fewest overnight HR samples that can stand in for a night's resting heart rate. + static let minNightSamples = 10 } diff --git a/PulseLoop/Services/RestingHRBaselineService.swift b/PulseLoop/Services/RestingHRBaselineService.swift index cbfc85b..5a18498 100644 --- a/PulseLoop/Services/RestingHRBaselineService.swift +++ b/PulseLoop/Services/RestingHRBaselineService.swift @@ -43,7 +43,7 @@ enum RestingHRBaselineService { } let established = values.count >= minSamples && spanDays >= minSpanDays - let newBaseline = established ? percentile(values.sorted(), 0.10) : nil + let newBaseline = established ? percentile(values.sorted(), restingPercentile) : nil // Stamp the refresh time even when not established, so we don't rescan on every foreground. profile.hrRestingBaselineUpdatedAt = now @@ -55,8 +55,13 @@ enum RestingHRBaselineService { try? context.save() } + /// The percentile this service treats as "resting" — the 10th. Shared so anything comparing a + /// single night against `hrRestingBaseline` measures that night the same way the baseline was + /// built, rather than inventing a second definition of resting HR. + static let restingPercentile = 0.10 + /// Interpolated percentile (same formula as `BaselineStats.compute`). - private static func percentile(_ sorted: [Double], _ fraction: Double) -> Double { + static func percentile(_ sorted: [Double], _ fraction: Double) -> Double { guard !sorted.isEmpty else { return 0 } guard sorted.count > 1 else { return sorted[0] } let rank = fraction * Double(sorted.count - 1) diff --git a/PulseLoopTests/RestingHRDriftTests.swift b/PulseLoopTests/RestingHRDriftTests.swift new file mode 100644 index 0000000..ae433dd --- /dev/null +++ b/PulseLoopTests/RestingHRDriftTests.swift @@ -0,0 +1,175 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// `restingHRDrift` shipped as a declared-but-unfired `CoachAnomalyKind` — the detector reads a +/// 12-hour packet and drift only means anything against a multi-day baseline. These lock both halves +/// of the fix: the baseline now rides in the packet, and the detector's gates. +@MainActor +final class RestingHRDriftTests: XCTestCase { + + /// Builds a packet carrying only what the drift detector reads; everything else is inert so a + /// higher-priority anomaly can't mask the case under test. + private func packet( + baseline: Double?, lastNight: Double?, nightOf: String, samples: Int = 40 + ) -> NotificationContextPacket { + var p = NotificationContextPacket( + slot: "morning", generatedAt: "", timezone: "UTC", profileName: "Sam", + goals: .init(stepsDaily: 10000, activeMinutesDaily: 45, sleepHours: 8, exerciseDaysWeekly: 4), + today: .init(localDate: nightOf, steps: 0, calories: nil, distanceKm: nil, + activeMinutes: nil, dataConfidence: "high"), + latestSleep: nil, + latestVitals: .init(latestHr: nil, latestHrAt: nil, latestSpo2: nil, latestSpo2At: nil, + restingHrEstimate: nil, peakHrToday: nil), + hrLast12h: .init(count: 0, avg: nil, min: nil, max: nil), + spo2Last12h: .init(count: 0, avg: nil, min: nil, max: nil), + recentWorkouts: [], memories: [], dataQualityWarnings: [] + ) + if let baseline, let lastNight { + p.restingHR = .init(baselineBpm: baseline, lastNightBpm: lastNight, + sampleCount: samples, nightOf: nightOf) + } + return p + } + + private func today(_ now: Date = Date()) -> String { + CoachDataAccess.localDateString(now) + } + + // MARK: - Threshold + + func testFiresAtTheDriftThreshold() { + let now = Date() + let anomaly = CoachAnomalyDetector.detect( + packet(baseline: 54, lastNight: 59, nightOf: today(now)), now: now + ) + XCTAssertEqual(anomaly?.kind, .restingHRDrift) + XCTAssertTrue(anomaly?.facts.contains("59 bpm") == true) + XCTAssertTrue(anomaly?.facts.contains("5 bpm above") == true) + } + + func testSilentJustBelowTheThreshold() { + let now = Date() + XCTAssertNil(CoachAnomalyDetector.detect( + packet(baseline: 54, lastNight: 58.9, nightOf: today(now)), now: now + )) + } + + /// A resting HR *below* baseline is usually good news, and never an unprompted alert. + func testSilentWhenRestingHRIsBelowBaseline() { + let now = Date() + XCTAssertNil(CoachAnomalyDetector.detect( + packet(baseline: 60, lastNight: 50, nightOf: today(now)), now: now + )) + } + + // MARK: - Gates + + func testSilentWithoutAnEstablishedBaseline() { + let now = Date() + XCTAssertNil(CoachAnomalyDetector.detect( + packet(baseline: nil, lastNight: nil, nightOf: today(now)), now: now + )) + } + + func testSilentOnAStaleNight() { + let now = Date() + let old = Calendar.current.date(byAdding: .day, value: -5, to: now) ?? now + XCTAssertNil( + CoachAnomalyDetector.detect( + packet(baseline: 54, lastNight: 70, nightOf: CoachDataAccess.localDateString(old)), now: now + ), + "a five-day-old night says nothing about today, however elevated" + ) + } + + // MARK: - Precedence + + /// A short night usually raises resting HR too. When both trip, the sleep alert names the cause + /// and drift would only restate its consequence — `detect` returns one anomaly, so sleep wins. + func testShortSleepOutranksDrift() { + let now = Date() + var p = packet(baseline: 54, lastNight: 70, nightOf: today(now)) + p.latestSleep = .init(date: today(now), totalMin: 240, deepMin: 40, lightMin: 180, + awakeMin: 20, score: 40, confidence: "medium", decoderNote: "") + XCTAssertEqual(CoachAnomalyDetector.detect(p, now: now)?.kind, .poorSleep) + } + + func testLowSpO2OutranksDrift() { + let now = Date() + var p = packet(baseline: 54, lastNight: 70, nightOf: today(now)) + p.spo2Last12h = .init(count: 4, avg: 93, min: 88, max: 97) + XCTAssertEqual(CoachAnomalyDetector.detect(p, now: now)?.kind, .lowSpO2) + } + + // MARK: - Copy + + func testScriptedAlertIsActionable() { + let now = Date() + guard let anomaly = CoachAnomalyDetector.detect( + packet(baseline: 54, lastNight: 62, nightOf: today(now)), now: now + ) else { return XCTFail("expected a drift anomaly") } + + let notification = CoachNotificationGenerator.scriptedAnomaly(anomaly) + XCTAssertFalse(notification.title.isEmpty) + XCTAssertNotNil(notification.tip, "the offline fallback should still suggest something") + XCTAssertEqual(anomaly.dedupeKey, "anomaly:restingHRDrift") + } + + // MARK: - Builder + + /// The packet block is only built once there is both a learned baseline and a recent night with + /// enough overnight samples to stand in for a resting figure. + func testBuilderWithholdsBlockWhenBaselineUnlearned() throws { + let context = try TestSupport.makeContext() + let profile = UserProfile(name: "Sam") + context.insert(profile) + try? context.save() + + XCTAssertNil(NotificationContextBuilder.restingHR(context: context)) + } + + func testBuilderMeasuresTheNightAtTheSamePercentileAsTheBaseline() throws { + let context = try TestSupport.makeContext() + let profile = UserProfile(name: "Sam") + profile.hrRestingBaseline = 54 + context.insert(profile) + + // A night of sleep, with HR samples spread across it. + let start = Calendar.current.date(bySettingHour: 23, minute: 0, second: 0, of: TestSupport.day(-1)) + ?? TestSupport.day(-1) + _ = TestSupport.insertSleep(nightStart: start, stages: Array(repeating: .light, count: 400), into: context) + + // 40 readings from 60 to 99 bpm: the 10th percentile lands at 63.9. + for i in 0..<40 { + let ts = Calendar.current.date(byAdding: .minute, value: i * 10, to: start) ?? start + context.insert(Measurement(kind: .heartRate, value: Double(60 + i), unit: "bpm", timestamp: ts)) + } + try? context.save() + + let resting = NotificationContextBuilder.restingHR(context: context) + XCTAssertEqual(resting?.sampleCount, 40) + XCTAssertEqual(resting?.baselineBpm, 54) + XCTAssertEqual(resting?.lastNightBpm ?? 0, 63.9, accuracy: 0.05) + } + + func testBuilderWithholdsBlockOnTooFewOvernightSamples() throws { + let context = try TestSupport.makeContext() + let profile = UserProfile(name: "Sam") + profile.hrRestingBaseline = 54 + context.insert(profile) + + let start = Calendar.current.date(bySettingHour: 23, minute: 0, second: 0, of: TestSupport.day(-1)) + ?? TestSupport.day(-1) + _ = TestSupport.insertSleep(nightStart: start, stages: Array(repeating: .light, count: 400), into: context) + + // One under the floor — a handful of readings is not a resting heart rate. + for i in 0..<(NotificationContextBuilder.minNightSamples - 1) { + let ts = Calendar.current.date(byAdding: .minute, value: i * 10, to: start) ?? start + context.insert(Measurement(kind: .heartRate, value: 70, unit: "bpm", timestamp: ts)) + } + try? context.save() + + XCTAssertNil(NotificationContextBuilder.restingHR(context: context)) + } +} diff --git a/docs/project/anomaly-alerts.md b/docs/project/anomaly-alerts.md new file mode 100644 index 0000000..86e396f --- /dev/null +++ b/docs/project/anomaly-alerts.md @@ -0,0 +1,126 @@ +--- +title: Proactive anomaly alerts +description: Every pattern PulseLoop will interrupt you for — the exact signal, threshold, and gates behind each one. +--- + +# Proactive anomaly alerts + +Most of what PulseLoop tells you, you asked for. Anomaly alerts are the exception: they arrive +unprompted, so the bar for firing one is deliberately high. + +This page documents every detector — the signal, the threshold, and every gate. PulseLoop's +principles commit to "documented metrics and an auditable coach, no black boxes", and an alert you +can't inspect is indistinguishable from a guess. + +!!! info "Off by default" + Proactive alerts are opt-in (**Settings → Notifications**) and only run when the coach is set to + Apple's on-device model, so an alert never triggers a paid cloud call on a background data event. + +The implementation is [`CoachAnomalyDetector.swift`](https://github.com/saksham2001/PulseLoopiOS/blob/main/PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift), +covered by unit tests that lock every number on this page. + +## Rules that apply to all alerts + +- **At most one alert per detection pass.** When several patterns trip at once, the highest-priority + one wins — see [Precedence](#precedence). +- **At most one alert per kind per day**, deduped on `anomaly:` in `CoachNotificationRecord`. +- **A missed alert beats a false alarm.** Every threshold below is set so that noise stays silent, + accepting that some genuine events go unremarked. +- **No alert diagnoses anything.** Copy describes what was measured and offers a benign next step. + +## The detectors + +### 1. Low blood oxygen — `lowSpO2` + +| | | +|---|---| +| **Signal** | Lowest SpO₂ reading in the last 12 hours | +| **Fires when** | `min(SpO₂) < 90%` | +| **Gates** | At least 3 readings in the window | +| **Rings** | Any with an SpO₂ sensor | + +The multi-reading gate exists because a single low sample is far more often a finger moving against +the sensor than genuine desaturation. + +### 2. Short sleep — `poorSleep` + +| | | +|---|---| +| **Signal** | Last night's total sleep | +| **Fires when** | `0 < totalMinutes < 300` (under 5 hours) | +| **Gates** | A sleep session exists for the night | +| **Rings** | Any with sleep tracking | + +The 5-hour cut is absolute rather than relative to your sleep goal: the message is about a night +short enough to matter physiologically, not about missing a target you set. + +### 3. Resting heart-rate drift — `restingHRDrift` + +| | | +|---|---| +| **Signal** | Last night's resting HR vs. your learned 30-day baseline | +| **Fires when** | `lastNight − baseline ≥ 5 bpm` | +| **Gates** | Baseline established · ≥ 10 overnight samples · night no more than 2 days old | +| **Rings** | Any with heart rate — every supported family | + +#### How both numbers are computed + +Both sides use the **10th percentile** of heart-rate samples, interpolated. Using one definition for +both is the whole point: comparing a night's *mean* against a 30-day *percentile* would produce a +difference that is mostly an artefact of the two formulas. + +``` +baseline = p10( HR samples over the last 30 days ) # RestingHRBaselineService +lastNight = p10( HR samples between sleep start and sleep end ) +drift = lastNight − baseline +``` + +The night is bounded by the **sleep session itself**, not a fixed clock window, so a late night or a +shift worker's schedule is measured over the hours actually slept. + +#### Why 5 bpm + +An elevated resting heart rate is the signal that moves first under infection, alcohol, heat, and +under-recovery — typically about a day before you notice anything. Five bpm is the usual +consumer-wearable threshold: below it, the night-to-night noise in an optical ring's overnight +sampling swamps the effect. + +#### Why only upward + +A resting HR *below* baseline is usually good news — improving fitness, or a genuinely restful +night — and is not something to interrupt anyone about. + +#### Why the sample floor is 10 + +A YCBT-family ring floors its all-day measurement interval at 30 minutes, so a full night yields +roughly 14 samples; a Colmi at the default 5-minute cadence yields roughly 84. Ten keeps the +detector usable on both while still refusing to call a handful of readings a resting heart rate. + +#### Why the baseline can be missing + +`RestingHRBaselineService` stores `nil` until it has **≥ 20 samples spanning ≥ 7 days**. Until then +there is nothing trustworthy to compare against and this detector stays silent — it does not fall +back to a population average. + +## Precedence + +`detect` returns at most one anomaly, checked in this order: + +1. `lowSpO2` — the most clinically meaningful of the three. +2. `poorSleep` — fires right after a sleep download, when it is most actionable. +3. `restingHRDrift`. + +Drift is last **by design**. A short or broken night usually raises resting HR as well, so when both +trip, the sleep alert names the cause while drift would only restate its consequence. This is a +choice between two messages about the same night, not a suppressed alert. + +## What is deliberately not a detector + +- **Temperature deviation.** Ring skin temperature is a strong illness signal, but not every + supported ring has the sensor, and a single-signal temperature alert produces too many false + alarms from a warm room or a duvet. It belongs in a multi-signal detector, not on its own. +- **HRV drops.** HRV is noisy enough night-to-night that a single-night drop is usually not a + signal, and it moves for the same reasons resting HR does — so an HRV alert would mostly + double-report drift. +- **Anything resembling a diagnosis.** No detector names a condition, and none ever will on + wellness-grade optical hardware. diff --git a/mkdocs.yml b/mkdocs.yml index 949efe1..dc0d673 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,6 +120,7 @@ nav: - Project: - Roadmap: project/roadmap.md - Architecture: project/architecture.md + - Proactive alerts: project/anomaly-alerts.md - Contributing: project/contributing.md - Contributors: project/contributors.md - Privacy: project/privacy.md