From 648c23e8a13230efd8ed73c9ed9c80a0299b1d6c Mon Sep 17 00:00:00 2001 From: ak710 Date: Sun, 2 Aug 2026 14:25:34 -0400 Subject: [PATCH] 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