From 648c23e8a13230efd8ed73c9ed9c80a0299b1d6c Mon Sep 17 00:00:00 2001 From: ak710 Date: Sun, 2 Aug 2026 14:25:34 -0400 Subject: [PATCH 1/2] Fire the resting-HR drift alert that was only ever declared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restingHRDrift has been a CoachAnomalyKind since the proactive-alert path landed, with a comment explaining why nothing raised it: the detector reads a 12-hour context packet, and drift is only meaningful against a multi-day baseline. But the baseline already exists — RestingHRBaselineService learns and persists it — so the packet just needed to carry it in. The night and the baseline are both measured as the interpolated 10th percentile of heart rate, sharing RestingHRBaselineService's own percentile helper. Comparing a night's mean against a 30-day percentile would have produced a difference that was mostly an artefact of the two formulas. The night is bounded by the sleep session rather than a fixed clock window, so a late night or a shift schedule is measured over the hours actually slept. Fires at +5 bpm and only upward — a resting HR below baseline is usually good news and not worth an unprompted alert. Gated on an established baseline, at least 10 overnight samples (a YCBT ring floors its interval at 30 minutes, so a full night is only ~14 there against ~84 on a 5-minute Colmi), and a night no more than two days old. Ordered last of the three detectors: a short night usually raises resting HR too, so when both trip the sleep alert names the cause while drift would only restate its consequence. Every threshold is documented in docs/project/anomaly-alerts.md, which also covers the two detectors that were already shipping but undocumented. Co-Authored-By: Claude Opus 5 --- .../Notifications/CoachAnomalyDetector.swift | 62 ++++++- .../CoachNotificationGenerator.swift | 5 +- .../CoachNotificationService.swift | 2 +- .../NotificationContextBuilder.swift | 56 +++++- .../Services/RestingHRBaselineService.swift | 9 +- PulseLoopTests/RestingHRDriftTests.swift | 175 ++++++++++++++++++ docs/project/anomaly-alerts.md | 126 +++++++++++++ mkdocs.yml | 1 + 8 files changed, 427 insertions(+), 9 deletions(-) create mode 100644 PulseLoopTests/RestingHRDriftTests.swift create mode 100644 docs/project/anomaly-alerts.md diff --git a/PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift b/PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift index d0813b38..b7ec7bcc 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 8190c477..4ecd030a 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 58eec150..d5ced7fc 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 83ad54a8..98f32e78 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 cbfc85b4..5a18498b 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 00000000..ae433dd9 --- /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 00000000..86e396f9 --- /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 949efe1d..dc0d6738 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 From 9536de7546de04363d67014706fd7cbc01f245c9 Mon Sep 17 00:00:00 2001 From: ak710 Date: Mon, 3 Aug 2026 01:17:25 -0400 Subject: [PATCH 2/2] Watch five overnight signals for signs of strain together MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gap analysis put illness early-warning as the single biggest thing the premium apps have that PulseLoop didn't — Oura's Symptom Radar, Ultrahuman's Sleep Screener. Every input already existed; nothing read them together. HealthWatch judges five overnight signals against their own 30-day baselines: skin temperature, resting heart rate, HRV, breathing rate and blood oxygen. Only the strain direction of each counts — a resting HR below baseline or an HRV above it is good news, and flagging it would turn that into an alert. The scoring exists to make it wait for agreement. Each signal scores 0, 1 or 2; a total of 2 is minor and 3 is major. A single notable signal therefore never counts on its own — one reading past its knot is a warm duvet or one restless hour. A single strong signal reaches minor but not major: a full degree of overnight temperature rise isn't noise, but it isn't enough to interrupt for unaccompanied either. Only major fires an alert. Minor still reaches the Today card and the coach, but two signals nudging past their knots happens after a glass of wine often enough that alerting on it would train people to dismiss the ones that matter. Baselines are the median of preceding nights' figures, not a mean of every sample. One night the ring recorded four times as often as usual would otherwise dominate, and a single feverish night would drag the very baseline it needs to be judged against. Heart rate uses the night's 10th percentile so it is the same quantity as the resting-HR baseline it's compared with; the rest use the mean. Nights are bounded by the sleep session, not a clock window. Signals without a baseline are simply not judged, so a ring with fewer sensors still gets a useful answer from the three it has — and fewer than two judgeable signals returns clear rather than a warning built on one reading. Ordered above restingHRDrift, because drift is one of its own signals: when both trip, the multi-signal result is the better-corroborated message about the same night. Drift still fires alone when nothing corroborated it, or when it was the only signal with a baseline at all. The Today card is conditional — a clear night renders nothing. A permanent "all clear" tile would be clutter, and would train people to stop reading it. Nothing here names a condition, and the copy names alcohol, heat and yesterday's session before anything else; a test asserts it. Thresholds and rationale in docs/project/anomaly-alerts.md, which also now documents the two detectors that were already shipping undocumented. Co-Authored-By: Claude Opus 5 --- .../Notifications/CoachAnomalyDetector.swift | 30 +- .../CoachNotificationGenerator.swift | 5 + .../NotificationContextBuilder.swift | 43 ++- PulseLoop/DesignSystem/HealthWatchCard.swift | 67 +++++ PulseLoop/Services/HealthWatch.swift | 183 ++++++++++++ PulseLoop/Services/HealthWatchService.swift | 97 +++++++ PulseLoop/ViewModels/TodayStore.swift | 5 + PulseLoop/Views/TodayView.swift | 6 + PulseLoopTests/HealthWatchTests.swift | 260 ++++++++++++++++++ docs/project/anomaly-alerts.md | 95 ++++++- 10 files changed, 774 insertions(+), 17 deletions(-) create mode 100644 PulseLoop/DesignSystem/HealthWatchCard.swift create mode 100644 PulseLoop/Services/HealthWatch.swift create mode 100644 PulseLoop/Services/HealthWatchService.swift create mode 100644 PulseLoopTests/HealthWatchTests.swift diff --git a/PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift b/PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift index b7ec7bcc..8e27ef50 100644 --- a/PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift +++ b/PulseLoop/Coach/Notifications/CoachAnomalyDetector.swift @@ -6,6 +6,8 @@ enum CoachAnomalyKind: String, Codable, Equatable { case poorSleep /// Last night's resting HR sitting well above the learned 30-day baseline. case restingHRDrift + /// Several overnight signals departing from their own baselines together. + case healthWatch } struct CoachAnomaly: Equatable { @@ -58,15 +60,35 @@ 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. + // 3. Health Watch — several overnight signals departing together. Outranks resting-HR drift + // below because drift is *one of its own signals*: when both trip, the multi-signal + // result is strictly the better-corroborated message about the same night, and firing + // the single-signal one instead would understate what was actually seen. + if let watch = healthWatch(packet) { return watch } + + // 4. Resting-HR drift on its own — the case where resting HR moved but nothing corroborated + // it, or where it was the only signal with a baseline at all (a jring with a week of wear + // can reach this while Health Watch is still short of two judgeable signals). if let drift = restingHRDrift(packet, now: now) { return drift } return nil } + // MARK: - Health Watch + + /// Fires on a `major` result only. + /// + /// `minor` is deliberately silent: it means two signals nudged past their notable knots, which + /// happens after a glass of wine or a warm room often enough that alerting on it would train the + /// user to dismiss the ones that matter. The minor result still reaches the Today card and the + /// coach — it just doesn't interrupt. + private static func healthWatch(_ packet: NotificationContextPacket) -> CoachAnomaly? { + guard let watch = packet.healthWatch, + watch.status == HealthWatch.Status.major.rawValue, + !watch.flagged.isEmpty else { return nil } + return CoachAnomaly(kind: .healthWatch, facts: watch.facts) + } + // MARK: - Resting-HR drift /// Fires when last night's resting HR sits `driftBpm` or more above the learned baseline. diff --git a/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift b/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift index 4ecd030a..1614af85 100644 --- a/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift +++ b/PulseLoop/Coach/Notifications/CoachNotificationGenerator.swift @@ -74,6 +74,11 @@ enum CoachNotificationGenerator { body: anomaly.facts, tip: "Go easy today and aim for an earlier wind-down.", followUp: "Want tips for a better night tonight?") + case .healthWatch: + return CoachNotification(title: "Worth taking it easy today", + body: anomaly.facts, + tip: "Nothing here names a cause — rest, fluids, and a lighter day cover most of them.", + followUp: "Want to look at what's been different this week?") case .restingHRDrift: return CoachNotification(title: "Resting heart rate is up", body: anomaly.facts, diff --git a/PulseLoop/Coach/Notifications/NotificationContextBuilder.swift b/PulseLoop/Coach/Notifications/NotificationContextBuilder.swift index 98f32e78..e0233d3a 100644 --- a/PulseLoop/Coach/Notifications/NotificationContextBuilder.swift +++ b/PulseLoop/Coach/Notifications/NotificationContextBuilder.swift @@ -33,6 +33,27 @@ struct NotificationContextPacket: Encodable { /// 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? + /// Last night's overnight signals against their own 30-day baselines. Present only when at + /// least two of them had a baseline to be judged against. + var healthWatch: HealthWatchContext? + + struct HealthWatchContext: Encodable { + var status: String + var signalsAvailable: Int + /// Only the signals that departed far enough to count, worst first. + var flagged: [HealthWatchSignal] + /// The already-grounded sentence, so the model restates rather than re-derives it. + var facts: String + } + + /// One departed signal. A sibling of `HealthWatchContext` rather than nested inside it, to stay + /// within SwiftLint's one-level nesting rule. + struct HealthWatchSignal: Encodable { + var signal: String + var value: Double + var baseline: Double + var detail: String + } struct RestingHRContext: Encodable { /// The learned 10th-percentile resting HR over 30 days. Non-nil implies established — @@ -82,7 +103,27 @@ enum NotificationContextBuilder { dataQualityWarnings: packet.dataQualityWarnings, environment: environment, nutrition: packet.nutrition, - restingHR: restingHR(context: context, now: now) + restingHR: restingHR(context: context, now: now), + healthWatch: healthWatch(context: context, now: now) + ) + } + + /// Last night's overnight signals against their own baselines, or nil when fewer than two could + /// be judged — see `HealthWatch.minSignals`. + static func healthWatch( + context: ModelContext, now: Date = Date() + ) -> NotificationContextPacket.HealthWatchContext? { + guard let result = HealthWatchService.evaluate(now: now, context: context), + result.signalsAvailable >= HealthWatch.minSignals else { return nil } + + return .init( + status: result.status.rawValue, + signalsAvailable: result.signalsAvailable, + flagged: result.flagged.map { + .init(signal: $0.signal.title, value: ($0.value * 10).rounded() / 10, + baseline: ($0.baseline * 10).rounded() / 10, detail: $0.detail) + }, + facts: HealthWatch.facts(result) ) } diff --git a/PulseLoop/DesignSystem/HealthWatchCard.swift b/PulseLoop/DesignSystem/HealthWatchCard.swift new file mode 100644 index 00000000..5d32884a --- /dev/null +++ b/PulseLoop/DesignSystem/HealthWatchCard.swift @@ -0,0 +1,67 @@ +import SwiftUI + +/// Overnight signs of strain, shown on Today **only when there are any**. +/// +/// This is the one card allowed onto an already-dense Today grid, because it is conditional rather +/// than permanent: a clear night renders nothing at all. A permanent "all clear" tile would be +/// exactly the clutter the rest of this work avoids — and would also train people to stop reading it. +struct HealthWatchCard: View { + let result: HealthWatch.Result + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Image(systemName: "waveform.path.ecg.rectangle") + .font(PulseFont.headline) + .foregroundStyle(accent) + Text(result.status.rawValue) + .font(PulseFont.subheadline.weight(.semibold)) + .foregroundStyle(PulseColors.textPrimary) + Spacer(minLength: 4) + } + + VStack(spacing: 8) { + ForEach(result.flagged, id: \.signal) { reading in + HStack(spacing: 10) { + Circle().fill(accent).frame(width: 6, height: 6) + Text(reading.signal.title) + .font(PulseFont.caption.weight(.semibold)) + .foregroundStyle(PulseColors.textPrimary) + Spacer(minLength: 8) + Text(reading.detail) + .font(PulseFont.caption.monospacedDigit()) + .foregroundStyle(PulseColors.textSecondary) + .lineLimit(1) + } + } + } + + Text(disclaimer) + .font(PulseFont.caption2.weight(.regular)) + .foregroundStyle(PulseColors.textMuted) + .fixedSize(horizontal: false, vertical: true) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(accent.opacity(0.10), in: RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: PulseRadius.card, style: .continuous) + .stroke(accent.opacity(0.3), lineWidth: 1) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(result.status.rawValue). \(HealthWatch.facts(result))") + } + + private var accent: Color { + result.status == .major ? PulseColors.warning : PulseColors.textSecondary + } + + /// Non-negotiable copy. The card must never read as a diagnosis, and must name the mundane + /// explanations before the worrying one. + private var disclaimer: String { + "Signals compared with your own recent nights, from \(result.signalsAvailable) " + + "measurement\(result.signalsAvailable == 1 ? "" : "s") your ring records. " + + "This isn't a diagnosis — alcohol, a warm room, and a hard session the day before all " + + "look like this." + } +} diff --git a/PulseLoop/Services/HealthWatch.swift b/PulseLoop/Services/HealthWatch.swift new file mode 100644 index 00000000..f3d1f478 --- /dev/null +++ b/PulseLoop/Services/HealthWatch.swift @@ -0,0 +1,183 @@ +import Foundation + +/// Overnight signs that the body is working harder than usual — PulseLoop's equivalent of Oura's +/// Symptom Radar or Ultrahuman's Sleep Screener. +/// +/// **This is not a diagnosis and never names a condition.** It reports that several overnight signals +/// moved away from your own baselines together, which is a pattern that often precedes feeling +/// unwell by about a day — and equally often follows alcohol, heat, a hard session, or a bad night. +/// The copy says so every time. +/// +/// Pure maths; `HealthWatchService` reads the store. +enum HealthWatch { + + /// One signal's departure from its own baseline. + enum Level: Int, Comparable { + case normal = 0 + case notable = 1 + case strong = 2 + + static func < (lhs: Level, rhs: Level) -> Bool { lhs.rawValue < rhs.rawValue } + } + + /// The signals this reads. Each has a direction: only the side that indicates strain counts. + /// + /// A resting HR *below* baseline, an HRV *above* it, or a cooler night are not warning signs, and + /// flagging them would turn good news into an alert — the same asymmetry the resting-HR drift + /// detector applies. + enum Signal: String, CaseIterable { + case skinTemperature + case restingHeartRate + case hrv + case respiratoryRate + case bloodOxygen + + var title: String { + switch self { + case .skinTemperature: return "Skin temperature" + case .restingHeartRate: return "Resting heart rate" + case .hrv: return "HRV" + case .respiratoryRate: return "Breathing rate" + case .bloodOxygen: return "Blood oxygen" + } + } + + /// `(notable, strong)` departures from baseline, in the direction that indicates strain. + /// + /// Temperature and resting HR come first because they are the two that move earliest and + /// most reliably on consumer optical hardware. HRV's knots are proportional rather than + /// absolute because HRV spans an order of magnitude across healthy adults. + var thresholds: (notable: Double, strong: Double) { + switch self { + case .skinTemperature: return (0.5, 1.0) // °C above baseline + case .restingHeartRate: return (5, 10) // bpm above baseline + case .hrv: return (0.15, 0.30) // fraction below baseline + case .respiratoryRate: return (2, 4) // brpm above baseline + case .bloodOxygen: return (2, 4) // percentage points below baseline + } + } + + /// Whether a departure is measured as a fraction of baseline rather than an absolute step. + var isProportional: Bool { self == .hrv } + + /// Whether strain shows as a value *below* baseline. + var strainIsBelowBaseline: Bool { self == .hrv || self == .bloodOxygen } + + var unit: String { + switch self { + case .skinTemperature: return "°C" + case .restingHeartRate: return "bpm" + case .hrv: return "ms" + case .respiratoryRate: return "brpm" + case .bloodOxygen: return "%" + } + } + } + + /// One signal's reading against its baseline. + struct Reading: Equatable { + let signal: Signal + let value: Double + let baseline: Double + let level: Level + + /// Signed departure in the strain direction: positive means "further into strain". + var strainDelta: Double { + let raw = signal.strainIsBelowBaseline ? baseline - value : value - baseline + return signal.isProportional && baseline != 0 ? raw / baseline : raw + } + + var detail: String { + if signal.isProportional { + return "\(Int((strainDelta * 100).rounded()))% below your usual" + } + let magnitude = abs(strainDelta) + let formatted = signal == .skinTemperature + ? String(format: "%.1f", magnitude) + : "\(Int(magnitude.rounded()))" + let direction = strainDelta >= 0 ? "above" : "below" + return "\(formatted) \(signal.unit) \(direction) your usual" + } + } + + enum Status: String { + /// Nothing unusual, or not enough signals to say. + case clear = "No signs of strain" + case minor = "Minor signs of strain" + case major = "Major signs of strain" + } + + struct Result: Equatable { + let status: Status + /// Every signal that had a baseline to be judged against, in descending order of departure. + let readings: [Reading] + /// How many signals could be judged at all. + var signalsAvailable: Int { readings.count } + /// The ones that departed far enough to count. + var flagged: [Reading] { readings.filter { $0.level > .normal } } + } + + /// Fewest judgeable signals before this says anything. One signal moving is noise; the whole + /// value of a multi-signal detector is that it waits for agreement. + static let minSignals = 2 + + /// Total departure score at which the result turns `major`. Levels are 0/1/2 per signal, so 3 is + /// "one strong plus one notable", or "one strong and something else stirring". + static let majorScore = 3 + + /// Score at which it turns `minor` — two notable signals, or a single strong one. + static let minorScore = 2 + + static func level(for signal: Signal, value: Double, baseline: Double) -> Level { + let reading = Reading(signal: signal, value: value, baseline: baseline, level: .normal) + let delta = reading.strainDelta + guard delta.isFinite, delta > 0 else { return .normal } + let thresholds = signal.thresholds + if delta >= thresholds.strong { return .strong } + if delta >= thresholds.notable { return .notable } + return .normal + } + + /// Judges a night. `values` and `baselines` need only overlap — a signal missing from either is + /// simply not judged, which is how a jring (no temperature sensor, no breathing rate) still gets + /// a useful answer from the three it does have. + static func evaluate(values: [Signal: Double], baselines: [Signal: Double]) -> Result { + var readings: [Reading] = [] + for signal in Signal.allCases { + guard let value = values[signal], let baseline = baselines[signal], + value.isFinite, baseline.isFinite, baseline != 0 else { continue } + readings.append(Reading(signal: signal, value: value, baseline: baseline, + level: level(for: signal, value: value, baseline: baseline))) + } + readings.sort { $0.strainDelta > $1.strainDelta } + + // Below the floor there is nothing to corroborate, so the honest answer is "clear" rather + // than a warning built on one reading. + guard readings.count >= minSignals else { + return Result(status: .clear, readings: readings) + } + + let score = readings.reduce(0) { $0 + $1.level.rawValue } + let status: Status + switch score { + case majorScore...: status = .major + case minorScore...: status = .minor + default: status = .clear // a single notable signal on its own is noise + } + + return Result(status: status, readings: readings) + } + + /// One grounded sentence naming what moved, for the alert body and the coach. + static func facts(_ result: Result) -> String { + let flagged = result.flagged + guard !flagged.isEmpty else { return "Overnight signals all sat close to your usual ranges." } + let parts = flagged.map { "\($0.signal.title.lowercased()) \($0.detail)" } + let list = parts.count == 1 + ? parts[0] + : parts.dropLast().joined(separator: ", ") + " and " + (parts.last ?? "") + return "Last night \(list). Several signals moving together like this often shows up a day " + + "before you feel run down — though alcohol, heat, and a hard session the day before do " + + "the same thing." + } +} diff --git a/PulseLoop/Services/HealthWatchService.swift b/PulseLoop/Services/HealthWatchService.swift new file mode 100644 index 00000000..bb27106b --- /dev/null +++ b/PulseLoop/Services/HealthWatchService.swift @@ -0,0 +1,97 @@ +import Foundation +import SwiftData + +/// Resolves last night's values and their 30-day baselines, and hands them to `HealthWatch`. +/// +/// Every signal is measured **over the night itself**, bounded by the sleep session rather than a +/// fixed clock window — the same choice the resting-HR drift detector makes, and for the same +/// reason: a shift worker's night is still their night. +@MainActor +enum HealthWatchService { + + /// How far back the per-signal baselines look. + static let baselineWindowDays = 30 + + /// Fewest nights a baseline needs before it is trusted. Matches `BaselineStats.isEstablished`'s + /// week-of-wear floor, counted in nights rather than samples because these are nightly figures. + static let minBaselineNights = 7 + + /// Fewest readings within one night for that night's figure to stand. A YCBT ring floors its + /// all-day interval at 30 minutes, so a full night is only ~14 samples there. + static let minNightSamples = 6 + + /// Last night judged against your own baselines, or nil when there is no recent night to judge. + static func evaluate(now: Date = Date(), context: ModelContext) -> HealthWatch.Result? { + guard let night = SleepService.latestSleep(context: context) else { return nil } + let start = night.session.startAt + let end = night.session.endAt + + var values: [HealthWatch.Signal: Double] = [:] + var baselines: [HealthWatch.Signal: Double] = [:] + + for signal in HealthWatch.Signal.allCases { + guard let value = nightValue(signal, start: start, end: end, context: context) else { continue } + guard let baseline = baseline(signal, before: start, context: context) else { continue } + values[signal] = value + baselines[signal] = baseline + } + + return HealthWatch.evaluate(values: values, baselines: baselines) + } + + /// One signal's figure for a single night. + /// + /// Heart rate uses the 10th percentile — its resting figure, matching how + /// `RestingHRBaselineService` builds the long-run baseline it is compared against. Everything + /// else uses the mean, since none of them have a "resting" notion distinct from their average. + static func nightValue( + _ signal: HealthWatch.Signal, start: Date, end: Date, context: ModelContext + ) -> Double? { + let samples = MetricsRepository.measurements( + kind: measurementKind(signal), start: start, end: end, limit: 2000, context: context + ).map(\.value).filter { $0 > 0 } + guard samples.count >= minNightSamples else { return nil } + + if signal == .restingHeartRate { + return RestingHRBaselineService.percentile(samples.sorted(), RestingHRBaselineService.restingPercentile) + } + return samples.reduce(0, +) / Double(samples.count) + } + + /// The signal's own baseline: the median of the preceding nights' figures. + /// + /// **Median of per-night figures, not a mean of every sample.** One night the ring recorded four + /// times as often as usual would otherwise dominate a raw sample mean, and a single feverish + /// night would drag the very baseline it needs to be judged against. + static func baseline( + _ signal: HealthWatch.Signal, before night: Date, context: ModelContext, calendar: Calendar = .current + ) -> Double? { + let windowStart = calendar.date(byAdding: .day, value: -baselineWindowDays, to: night) ?? night + // Windowed predicate fetch — the whole-table `SleepRepository.sessions` would read every + // night ever recorded to answer a 30-day question. + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.startAt >= windowStart && $0.startAt < night }, + sortBy: [SortDescriptor(\.startAt, order: .reverse)] + ) + let sessions = (try? context.fetch(descriptor)) ?? [] + + let nightly = sessions.compactMap { + nightValue(signal, start: $0.startAt, end: $0.endAt, context: context) + } + guard nightly.count >= minBaselineNights else { return nil } + + let sorted = nightly.sorted() + let mid = sorted.count / 2 + return sorted.count.isMultiple(of: 2) ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid] + } + + private static func measurementKind(_ signal: HealthWatch.Signal) -> MeasurementKind { + switch signal { + case .skinTemperature: return .temperature + case .restingHeartRate: return .heartRate + case .hrv: return .hrv + case .respiratoryRate: return .respiratoryRate + case .bloodOxygen: return .spo2 + } + } +} diff --git a/PulseLoop/ViewModels/TodayStore.swift b/PulseLoop/ViewModels/TodayStore.swift index 97ba4189..06438596 100644 --- a/PulseLoop/ViewModels/TodayStore.swift +++ b/PulseLoop/ViewModels/TodayStore.swift @@ -37,6 +37,10 @@ final class TodayStore { /// `body`, which meant a full pass over the samples on every re-render (including every frame of /// a card drag). private(set) var hrvBaseline: BaselineStats? + /// Last night's overnight signals against their own baselines. Rebuilt here rather than in + /// `body` because it walks 30 nights of sessions and their readings — far too heavy per render. + /// nil, or a `.clear` result, renders nothing at all. + private(set) var healthWatch: HealthWatch.Result? /// Bumped whenever `cards` and the sample series are rebuilt. The reorder grid keys cell equality /// on this so dragging a card doesn't re-render every Swift Charts tile — see `ReorderCell`. private(set) var revision: Int = 0 @@ -87,6 +91,7 @@ final class TodayStore { hero = TodayInsights.deriveHero(built) capabilities = MetricsService.deviceCapabilities(modelContext) visibleMetrics = Self.computeVisible(context: modelContext) + healthWatch = HealthWatchService.evaluate(context: modelContext) rebuildCards() signature = sig } diff --git a/PulseLoop/Views/TodayView.swift b/PulseLoop/Views/TodayView.swift index c6390cf8..826f1042 100644 --- a/PulseLoop/Views/TodayView.swift +++ b/PulseLoop/Views/TodayView.swift @@ -97,6 +97,12 @@ struct TodayView: View { HeroInsightCardView(title: hero.title, summary: hero.summary, chips: hero.chips) } + // Conditional by design: a clear night renders nothing, so this never becomes a + // permanent "all clear" tile that people learn to stop reading. + if let watch = activeStore.healthWatch, watch.status != .clear { + HealthWatchCard(result: watch) + } + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { tiles(activeStore) } diff --git a/PulseLoopTests/HealthWatchTests.swift b/PulseLoopTests/HealthWatchTests.swift new file mode 100644 index 00000000..a2eda8b6 --- /dev/null +++ b/PulseLoopTests/HealthWatchTests.swift @@ -0,0 +1,260 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// The multi-signal overnight strain detector. Its whole value is that it waits for *agreement* +/// between signals, so most of these pin the cases where it deliberately stays quiet. +@MainActor +final class HealthWatchTests: XCTestCase { + + /// Baselines a healthy adult might carry, for the signals to be judged against. + private let baselines: [HealthWatch.Signal: Double] = [ + .skinTemperature: 34.0, + .restingHeartRate: 54, + .hrv: 60, + .respiratoryRate: 14, + .bloodOxygen: 97, + ] + + // MARK: - Direction + + /// Only the strain side of each signal counts. A resting HR *below* baseline, an HRV *above* + /// it, or a cooler night are not warning signs — flagging them would turn good news into alerts. + func testOnlyTheStrainDirectionCounts() { + XCTAssertEqual(HealthWatch.level(for: .restingHeartRate, value: 44, baseline: 54), .normal) + XCTAssertEqual(HealthWatch.level(for: .restingHeartRate, value: 64, baseline: 54), .strong) + + XCTAssertEqual(HealthWatch.level(for: .hrv, value: 90, baseline: 60), .normal, "higher HRV is good news") + XCTAssertEqual(HealthWatch.level(for: .hrv, value: 40, baseline: 60), .strong) + + XCTAssertEqual(HealthWatch.level(for: .skinTemperature, value: 32.5, baseline: 34), .normal) + XCTAssertEqual(HealthWatch.level(for: .skinTemperature, value: 35.2, baseline: 34), .strong) + + XCTAssertEqual(HealthWatch.level(for: .bloodOxygen, value: 99, baseline: 97), .normal) + XCTAssertEqual(HealthWatch.level(for: .bloodOxygen, value: 93, baseline: 97), .strong) + } + + func testThresholdKnots() { + XCTAssertEqual(HealthWatch.level(for: .skinTemperature, value: 34.4, baseline: 34), .normal) + XCTAssertEqual(HealthWatch.level(for: .skinTemperature, value: 34.5, baseline: 34), .notable) + XCTAssertEqual(HealthWatch.level(for: .skinTemperature, value: 35.0, baseline: 34), .strong) + + XCTAssertEqual(HealthWatch.level(for: .restingHeartRate, value: 58.9, baseline: 54), .normal) + XCTAssertEqual(HealthWatch.level(for: .restingHeartRate, value: 59, baseline: 54), .notable) + + // HRV's knots are proportional, since it spans an order of magnitude across healthy adults. + XCTAssertEqual(HealthWatch.level(for: .hrv, value: 51, baseline: 60), .notable, "15% below") + XCTAssertEqual(HealthWatch.level(for: .hrv, value: 42, baseline: 60), .strong, "30% below") + } + + // MARK: - Agreement + + func testANormalNightIsClear() { + let result = HealthWatch.evaluate( + values: [.skinTemperature: 34.1, .restingHeartRate: 53, .hrv: 61, .bloodOxygen: 97], + baselines: baselines + ) + XCTAssertEqual(result.status, .clear) + XCTAssertTrue(result.flagged.isEmpty) + } + + /// **The false-alarm guard.** One signal nudging past its knot is noise — a warm duvet, one + /// restless hour. Nothing fires until something corroborates it. + func testASingleNotableSignalStaysQuiet() { + let result = HealthWatch.evaluate( + values: [.skinTemperature: 34.6, .restingHeartRate: 53, .hrv: 61, .bloodOxygen: 97], + baselines: baselines + ) + XCTAssertEqual(result.status, .clear) + XCTAssertEqual(result.flagged.count, 1, "it is still reported, just not acted on") + } + + func testTwoNotableSignalsAreMinor() { + let result = HealthWatch.evaluate( + values: [.skinTemperature: 34.6, .restingHeartRate: 60, .hrv: 61, .bloodOxygen: 97], + baselines: baselines + ) + XCTAssertEqual(result.status, .minor) + } + + /// A single strong signal is worth a minor flag on its own — a full degree of overnight + /// temperature rise is not noise, even unaccompanied. + func testOneStrongSignalAloneIsMinor() { + let result = HealthWatch.evaluate( + values: [.skinTemperature: 35.2, .restingHeartRate: 53, .hrv: 61, .bloodOxygen: 97], + baselines: baselines + ) + XCTAssertEqual(result.status, .minor) + } + + func testAStrongPlusANotableIsMajor() { + let result = HealthWatch.evaluate( + values: [.skinTemperature: 35.2, .restingHeartRate: 60, .hrv: 61, .bloodOxygen: 97], + baselines: baselines + ) + XCTAssertEqual(result.status, .major) + XCTAssertEqual(result.flagged.count, 2) + } + + /// The classic pattern: warmer, working harder, less variable. + func testTheIllnessPatternIsMajor() { + let result = HealthWatch.evaluate( + values: [.skinTemperature: 35.3, .restingHeartRate: 66, .hrv: 38, .bloodOxygen: 96], + baselines: baselines + ) + XCTAssertEqual(result.status, .major) + XCTAssertGreaterThanOrEqual(result.flagged.count, 3) + } + + // MARK: - Availability + + /// A jring has no temperature sensor and no breathing rate. It still gets a usable answer from + /// the three signals it does have. + func testARingWithFewerSensorsStillWorks() { + let result = HealthWatch.evaluate( + values: [.restingHeartRate: 66, .hrv: 38, .bloodOxygen: 97], + baselines: baselines + ) + XCTAssertEqual(result.signalsAvailable, 3) + XCTAssertEqual(result.status, .major) + } + + /// One judgeable signal can't corroborate anything, so the honest answer is "clear" rather than + /// a warning built on a single reading. + func testOneJudgeableSignalNeverFlags() { + let result = HealthWatch.evaluate(values: [.restingHeartRate: 80], baselines: baselines) + XCTAssertEqual(result.signalsAvailable, 1) + XCTAssertEqual(result.status, .clear) + } + + func testSignalsWithoutABaselineAreNotJudged() { + let result = HealthWatch.evaluate( + values: [.skinTemperature: 36, .restingHeartRate: 70, .hrv: 30], + baselines: [.restingHeartRate: 54] // only one baseline established + ) + XCTAssertEqual(result.signalsAvailable, 1) + XCTAssertEqual(result.status, .clear) + } + + func testAZeroBaselineIsNotJudged() { + let result = HealthWatch.evaluate( + values: [.restingHeartRate: 70, .hrv: 30], + baselines: [.restingHeartRate: 0, .hrv: 0] + ) + XCTAssertEqual(result.signalsAvailable, 0) + } + + // MARK: - Copy + + /// Non-negotiable: the facts sentence names the mundane explanations, and never a condition. + func testFactsNameTheMundaneExplanationsAndNoDiagnosis() { + let result = HealthWatch.evaluate( + values: [.skinTemperature: 35.3, .restingHeartRate: 66, .hrv: 38], + baselines: baselines + ) + let facts = HealthWatch.facts(result) + XCTAssertTrue(facts.contains("alcohol")) + XCTAssertTrue(facts.contains("hard session")) + for word in ["infection", "illness", "fever", "sick", "virus", "flu"] { + XCTAssertFalse(facts.lowercased().contains(word), "must not name a condition: \(word)") + } + } + + func testFactsOnAClearNight() { + let result = HealthWatch.evaluate(values: [.restingHeartRate: 53, .hrv: 61], baselines: baselines) + XCTAssertTrue(HealthWatch.facts(result).contains("close to your usual")) + } + + // MARK: - Alerting + + /// Only `major` interrupts. `minor` means two signals nudged past their knots, which happens + /// after a glass of wine often enough that alerting on it would train people to dismiss the + /// ones that matter — it still reaches the card and the coach. + func testOnlyMajorFiresAnAlert() { + func packet(status: HealthWatch.Status, flagged: Int) -> NotificationContextPacket { + var p = NotificationContextPacket( + slot: "morning", generatedAt: "", timezone: "UTC", profileName: nil, + goals: .init(stepsDaily: 10000, activeMinutesDaily: 45, sleepHours: 8, exerciseDaysWeekly: 4), + today: .init(localDate: "2026-06-05", 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: [] + ) + p.healthWatch = .init( + status: status.rawValue, signalsAvailable: 4, + flagged: (0..