From f9eed52a4bc95ea1685909cf27fb8058f5ddfd8e Mon Sep 17 00:00:00 2001 From: ak710 Date: Fri, 31 Jul 2026 20:00:51 -0400 Subject: [PATCH] Add readiness score: algorithm, baselines, and storage First of several PRs implementing the readiness/recovery score from the roadmap's "Metrics you can trust" section (#103). This one lands the engine and its storage; the Today tile, detail screen, coach tool, and widget follow separately. A daily 0-100 score from five contributors, weighted 30/25/30/10/5: overnight HRV, resting heart rate, sleep, skin temperature, and yesterday's training load. Four are judged against the user's own baseline; sleep is absolute because SleepScore already encodes population-normal ranges. Three rules shape the design: - Missing signals leave the denominator rather than scoring zero. A night without a temperature reading is scored out of 90 points, not penalised 10, and the result reports its coverage. This mirrors the doctrine at the top of SleepInsights.swift. - An unestablished baseline counts as missing, not as "at baseline". Scoring a deviation against three days of data would look authoritative while being noise. - Every contributor carries its own explanation ("HRV 12% below your baseline"), so the score is never surfaced as a bare number. The full algorithm - every weight and threshold - is documented in docs/project/readiness.md. Reuses the existing baseline machinery rather than building a parallel one: BaselineStats for HRV and temperature, and UserProfile.hrRestingBaseline, which RestingHRBaselineService already learns and throttles. ReadinessService is shaped after that service. Overnight signals are read from the sleep session's own span, falling back to 22:00-08:00 when sleep wasn't decoded, so daytime readings can't masquerade as recovery data. Scores persist as ReadinessDaily with their breakdown, since recomputing an old morning against today's baseline would give a different and wrong answer. Rows carry an algorithmVersion that invalidates them on a weight change instead of silently reinterpreting them. Archive format version goes to 2. readinessDailies is Optional because PulseArchive uses the synthesized decoder, which has no notion of property defaults - a non-optional array would make every existing v1 backup unimportable. Covered by a test that strips the key from a real export. 39 new tests. Demo seed data produces 10 scored days across multiple bands, so the feature is reviewable without a ring. Co-Authored-By: Claude Opus 5 --- PulseLoop/Models/PulseModels.swift | 87 ++++ .../Persistence/DataArchive+Readiness.swift | 49 ++ PulseLoop/Persistence/DataArchive.swift | 10 +- .../Persistence/DataArchiveService.swift | 22 +- .../Persistence/ModelContainerFactory.swift | 1 + PulseLoop/Persistence/SeedData.swift | 6 + PulseLoop/PulseLoopApp.swift | 7 + PulseLoop/Services/ReadinessScore.swift | 449 ++++++++++++++++++ PulseLoop/Services/ReadinessService.swift | 277 +++++++++++ PulseLoop/Services/Repositories.swift | 37 ++ PulseLoop/Settings/ReadinessPrefsStore.swift | 70 +++ PulseLoopTests/DataArchiveTests.swift | 42 +- PulseLoopTests/ReadinessScoreTests.swift | 359 ++++++++++++++ PulseLoopTests/ReadinessServiceTests.swift | 375 +++++++++++++++ docs/project/readiness.md | 158 ++++++ docs/project/roadmap.md | 3 +- mkdocs.yml | 1 + 17 files changed, 1945 insertions(+), 8 deletions(-) create mode 100644 PulseLoop/Persistence/DataArchive+Readiness.swift create mode 100644 PulseLoop/Services/ReadinessScore.swift create mode 100644 PulseLoop/Services/ReadinessService.swift create mode 100644 PulseLoop/Settings/ReadinessPrefsStore.swift create mode 100644 PulseLoopTests/ReadinessScoreTests.swift create mode 100644 PulseLoopTests/ReadinessServiceTests.swift create mode 100644 docs/project/readiness.md diff --git a/PulseLoop/Models/PulseModels.swift b/PulseLoop/Models/PulseModels.swift index 76aa3450..467e2a3a 100644 --- a/PulseLoop/Models/PulseModels.swift +++ b/PulseLoop/Models/PulseModels.swift @@ -325,6 +325,93 @@ final class SleepStageBlock { var stage: SleepStage { SleepStage(rawValue: stageRaw) ?? .unknown } } +/// One morning's readiness score, persisted with the contributor breakdown that produced it. +/// +/// Stored rather than recomputed for three reasons: the trend chart wants 30–90 days and the +/// `TodayStore` signature architecture exists to keep that work off the render path; a score keeps +/// its *why* only if the breakdown is stored alongside it; and recomputing an old morning against +/// today's 30-day baseline would silently produce a different, wrong answer. +/// +/// `algorithmVersion` is what makes that safe — `ReadinessService` recomputes any row whose version +/// no longer matches `ReadinessScore.algorithmVersion` instead of reinterpreting old numbers under +/// new weights. +@Model +final class ReadinessDaily { + @Attribute(.unique) var id: UUID + /// Start-of-day of the morning this score describes. + var date: Date + var score: Int + var bandRaw: String + /// The denominator the score was taken over — how much of the 100-point picture was available. + var availablePoints: Double + /// JSON-encoded `[ReadinessContributorRecord]`: the breakdown behind `score`. + var contributorsJSON: String + var algorithmVersion: Int + var computedAt: Date + var createdAt: Date + var updatedAt: Date + + init( + id: UUID = UUID(), + date: Date, + score: Int, + band: ReadinessBand, + availablePoints: Double, + contributorsJSON: String, + algorithmVersion: Int = ReadinessScore.algorithmVersion, + computedAt: Date = Date() + ) { + self.id = id + self.date = Calendar.current.startOfDay(for: date) + self.score = score + self.bandRaw = band.rawValue + self.availablePoints = availablePoints + self.contributorsJSON = contributorsJSON + self.algorithmVersion = algorithmVersion + self.computedAt = computedAt + self.createdAt = Date() + self.updatedAt = Date() + } + + var band: ReadinessBand { ReadinessBand(rawValue: bandRaw) ?? .moderate } + + var coverage: Double { availablePoints > 0 ? availablePoints / 100 : 0 } + + /// Decoded breakdown. Returns `[]` rather than throwing — a readiness row with unreadable + /// contributors is still a usable score, and the detail screen degrades to "breakdown + /// unavailable" instead of the whole tile failing. + var contributors: [ReadinessContributorRecord] { + guard let data = contributorsJSON.data(using: .utf8) else { return [] } + return (try? JSONDecoder().decode([ReadinessContributorRecord].self, from: data)) ?? [] + } +} + +/// Codable mirror of `ReadinessContributor` for storage and export. Kept separate from the scoring +/// value type so `ReadinessScore` stays free of persistence concerns, and so the on-disk shape is +/// explicit and versioned by `ReadinessDaily.algorithmVersion`. +struct ReadinessContributorRecord: Codable, Equatable, Sendable { + var kindRaw: String + var earned: Double + var maxPoints: Double + var value: Double + var baseline: Double? + var deviation: Double? + var detail: String + + var kind: ReadinessContributor.Kind? { ReadinessContributor.Kind(rawValue: kindRaw) } + var drag: Double { maxPoints - earned } + + init(_ contributor: ReadinessContributor) { + kindRaw = contributor.kind.rawValue + earned = contributor.earned + maxPoints = contributor.maxPoints + value = contributor.value + baseline = contributor.baseline + deviation = contributor.deviation + detail = contributor.detail + } +} + @Model final class RawPacketRow { @Attribute(.unique) var id: UUID diff --git a/PulseLoop/Persistence/DataArchive+Readiness.swift b/PulseLoop/Persistence/DataArchive+Readiness.swift new file mode 100644 index 00000000..b127a745 --- /dev/null +++ b/PulseLoop/Persistence/DataArchive+Readiness.swift @@ -0,0 +1,49 @@ +import Foundation +import SwiftData + +// Readiness rows in the portable archive. Split out of `DataArchive.swift` purely to keep that file +// under SwiftLint's `file_length` error threshold — the DTO contract is identical to the others. + +nonisolated struct ArchiveReadinessDaily: Codable, Sendable { + var id: UUID + var date: Date + var score: Int + var bandRaw: String + var availablePoints: Double + var contributorsJSON: String + var algorithmVersion: Int + var computedAt: Date + var createdAt: Date + var updatedAt: Date + + @MainActor init(_ m: ReadinessDaily) { + id = m.id + date = m.date + score = m.score + bandRaw = m.bandRaw + availablePoints = m.availablePoints + contributorsJSON = m.contributorsJSON + algorithmVersion = m.algorithmVersion + computedAt = m.computedAt + createdAt = m.createdAt + updatedAt = m.updatedAt + } + + @MainActor func insert(into context: ModelContext) { + let m = ReadinessDaily( + date: date, + score: score, + band: ReadinessBand(rawValue: bandRaw) ?? .moderate, + availablePoints: availablePoints, + contributorsJSON: contributorsJSON, + algorithmVersion: algorithmVersion, + computedAt: computedAt + ) + m.id = id + m.date = date // init re-derives startOfDay in the local timezone; restore the exact value + m.bandRaw = bandRaw // preserve an unknown band verbatim rather than collapsing it + m.createdAt = createdAt + m.updatedAt = updatedAt + context.insert(m) + } +} diff --git a/PulseLoop/Persistence/DataArchive.swift b/PulseLoop/Persistence/DataArchive.swift index 8dddac34..12103ae1 100644 --- a/PulseLoop/Persistence/DataArchive.swift +++ b/PulseLoop/Persistence/DataArchive.swift @@ -21,7 +21,10 @@ import SwiftData // MARK: - Envelope nonisolated struct PulseArchive: Codable, Sendable { - static let currentFormatVersion = 1 + /// Bumped to 2 when readiness scores joined the archive. `importArchive` refuses anything + /// newer than this, which is the honest behaviour: an older build genuinely cannot restore a + /// table it has no model for. + static let currentFormatVersion = 2 var formatVersion: Int var exportedAt: Date @@ -35,6 +38,11 @@ nonisolated struct PulseArchive: Codable, Sendable { var batterySamples: [ArchiveBatterySample] var sleepSessions: [ArchiveSleepSession] var sleepStageBlocks: [ArchiveSleepStageBlock] + /// Added in format version 2. **Optional on purpose**: `PulseArchive` uses the synthesized + /// decoder, which has no notion of property defaults, so a non-optional array here would make + /// every existing v1 archive fail to decode. Read it as `?? []`; a new export always writes it. + /// Any future table added to this struct should follow the same pattern. + var readinessDailies: [ArchiveReadinessDaily]? var rawPackets: [ArchiveRawPacket] var derivedUpdates: [ArchiveDerivedUpdate] var userProfiles: [ArchiveUserProfile] diff --git a/PulseLoop/Persistence/DataArchiveService.swift b/PulseLoop/Persistence/DataArchiveService.swift index e53ce010..e8eddbe0 100644 --- a/PulseLoop/Persistence/DataArchiveService.swift +++ b/PulseLoop/Persistence/DataArchiveService.swift @@ -40,7 +40,8 @@ enum DataArchiveService { "pulseloop.workoutprefs.v1", "pulseloop.calibration.v1", "pulseloop.coach.settings.v1", - "pulseloop.applehealth.prefs.v1" + "pulseloop.applehealth.prefs.v1", + ReadinessPrefsStore.prefsKey ] /// Rows processed between `Task.yield()`s while mapping models to DTOs during export. @@ -100,6 +101,7 @@ enum DataArchiveService { let batterySamples = try await collect(BatterySample.self, context) { ArchiveBatterySample($0) } let sleepSessions = try await collect(SleepSession.self, context) { ArchiveSleepSession($0) } let sleepStageBlocks = try await collect(SleepStageBlock.self, context) { ArchiveSleepStageBlock($0) } + let readinessDailies = try await collect(ReadinessDaily.self, context) { ArchiveReadinessDaily($0) } let rawPackets = try await collect(RawPacketRow.self, context) { ArchiveRawPacket($0) } let derivedUpdates = try await collect(DerivedUpdateRow.self, context) { ArchiveDerivedUpdate($0) } let userProfiles = try await collect(UserProfile.self, context) { ArchiveUserProfile($0) } @@ -133,6 +135,7 @@ enum DataArchiveService { "batterySamples": batterySamples.count, "sleepSessions": sleepSessions.count, "sleepStageBlocks": sleepStageBlocks.count, + "readinessDailies": readinessDailies.count, "rawPackets": rawPackets.count, "derivedUpdates": derivedUpdates.count, "userProfiles": userProfiles.count, @@ -166,6 +169,7 @@ enum DataArchiveService { batterySamples: batterySamples, sleepSessions: sleepSessions, sleepStageBlocks: sleepStageBlocks, + readinessDailies: readinessDailies, rawPackets: rawPackets, derivedUpdates: derivedUpdates, userProfiles: userProfiles, @@ -286,9 +290,14 @@ enum DataArchiveService { if refreshStores { refreshSharedStores() } + + // 6. Self-heal readiness history. A v1 archive predates the table entirely, and a v2 one may + // carry rows scored by an older algorithm version. Both cases recompute from the + // measurements we just restored; rows already at the current version are skipped. + ReadinessService.backfill(days: 90, context: context) } - /// Whether any of the 24 model tables has at least one row — gates the destructive + /// Whether any of the 25 model tables has at least one row — gates the destructive /// "Replace all data?" confirmation. static func hasAnyData(context: ModelContext) -> Bool { func has(_ type: T.Type) -> Bool { @@ -296,6 +305,7 @@ enum DataArchiveService { } return has(Device.self) || has(ActivityDaily.self) || has(PulseLoop.Measurement.self) || has(BatterySample.self) || has(SleepSession.self) || has(SleepStageBlock.self) + || has(ReadinessDaily.self) || has(RawPacketRow.self) || has(DerivedUpdateRow.self) || has(UserProfile.self) || has(UserGoal.self) || has(DeviceMeasurementConfig.self) || has(ActivitySession.self) || has(ActivitySample.self) || has(ActivityBucketSample.self) || has(ActivityGpsPoint.self) @@ -304,7 +314,7 @@ enum DataArchiveService { || has(CoachNotificationRecord.self) || has(CoachSummary.self) || has(WearableLog.self) } - /// Deletes every row of every model in the schema — all 24 types, unlike `SeedData.clearAll` + /// Deletes every row of every model in the schema — all 25 types, unlike `SeedData.clearAll` /// (which predates six of them). Tracked deletes, no save, and deliberately synchronous — see /// the atomicity note in `importArchive`. static func wipeAllData(context: ModelContext) throws { @@ -314,6 +324,7 @@ enum DataArchiveService { try deleteAll(BatterySample.self, context) try deleteAll(SleepSession.self, context) try deleteAll(SleepStageBlock.self, context) + try deleteAll(ReadinessDaily.self, context) try deleteAll(RawPacketRow.self, context) try deleteAll(DerivedUpdateRow.self, context) try deleteAll(UserProfile.self, context) @@ -347,6 +358,7 @@ enum DataArchiveService { insert(archive.batterySamples, context) insert(archive.sleepSessions, context) insert(archive.sleepStageBlocks, context) + insert(archive.readinessDailies ?? [], context) insert(archive.rawPackets, context) insert(archive.derivedUpdates, context) insert(archive.userProfiles, context) @@ -382,6 +394,7 @@ enum DataArchiveService { try requireUnique(archive.batterySamples.map(\.id), entity: "battery sample") try requireUnique(archive.sleepSessions.map(\.id), entity: "sleep session") try requireUnique(archive.sleepStageBlocks.map(\.id), entity: "sleep stage") + try requireUnique((archive.readinessDailies ?? []).map(\.id), entity: "readiness score") try requireUnique(archive.rawPackets.map(\.id), entity: "raw packet") try requireUnique(archive.derivedUpdates.map(\.id), entity: "derived update") try requireUnique(archive.userProfiles.map(\.id), entity: "profile") @@ -473,7 +486,7 @@ enum DataArchiveService { } } -/// Shared shape of the 24 DTOs' model-restoring side, so `insertAll` can chunk generically. +/// Shared shape of the 25 DTOs' model-restoring side, so `insertAll` can chunk generically. @MainActor protocol ArchiveInsertable { func insert(into context: ModelContext) @@ -485,6 +498,7 @@ extension ArchiveMeasurement: ArchiveInsertable {} extension ArchiveBatterySample: ArchiveInsertable {} extension ArchiveSleepSession: ArchiveInsertable {} extension ArchiveSleepStageBlock: ArchiveInsertable {} +extension ArchiveReadinessDaily: ArchiveInsertable {} extension ArchiveRawPacket: ArchiveInsertable {} extension ArchiveDerivedUpdate: ArchiveInsertable {} extension ArchiveUserProfile: ArchiveInsertable {} diff --git a/PulseLoop/Persistence/ModelContainerFactory.swift b/PulseLoop/Persistence/ModelContainerFactory.swift index 6eef7a21..17aa9ffc 100644 --- a/PulseLoop/Persistence/ModelContainerFactory.swift +++ b/PulseLoop/Persistence/ModelContainerFactory.swift @@ -9,6 +9,7 @@ enum ModelContainerFactory { BatterySample.self, SleepSession.self, SleepStageBlock.self, + ReadinessDaily.self, RawPacketRow.self, DerivedUpdateRow.self, UserProfile.self, diff --git a/PulseLoop/Persistence/SeedData.swift b/PulseLoop/Persistence/SeedData.swift index 145cfd46..4efb0bbe 100644 --- a/PulseLoop/Persistence/SeedData.swift +++ b/PulseLoop/Persistence/SeedData.swift @@ -181,6 +181,11 @@ enum SeedData { context.insert(DerivedUpdateRow(kind: "seed", entityType: "database", entityId: "demo", payloadJSON: #"{"source":"SeedData"}"#)) try? context.save() + + // Score the demo history now that its measurements, sleep and workouts exist, so the + // readiness tile and its trend chart have something to show without waiting for a real + // week of wear. Runs last, and reads only what was just seeded. + ReadinessService.backfill(days: 30, context: context) } /// One demo meal to insert. @@ -333,6 +338,7 @@ enum SeedData { deleteAll(Measurement.self, context) deleteAll(SleepSession.self, context) deleteAll(SleepStageBlock.self, context) + deleteAll(ReadinessDaily.self, context) deleteAll(RawPacketRow.self, context) deleteAll(DerivedUpdateRow.self, context) deleteAll(UserProfile.self, context) diff --git a/PulseLoop/PulseLoopApp.swift b/PulseLoop/PulseLoopApp.swift index 34153eda..ec7fefe9 100644 --- a/PulseLoop/PulseLoopApp.swift +++ b/PulseLoop/PulseLoopApp.swift @@ -114,6 +114,11 @@ struct PulseLoopApp: App { // internally, so this is a cheap no-op most launches). RestingHRBaselineService.refreshIfStale(context: container.mainContext) + // Score this morning's readiness. Must run AFTER the resting-HR refresh above — readiness + // reads `UserProfile.hrRestingBaseline` as one of its five contributors. Throttled to 3h + // internally, so this is a cheap no-op most launches. + ReadinessService.refreshIfStale(context: container.mainContext) + // Start persistence + coordinator draining the bus; auto-reconnect happens when // CoreBluetooth reports poweredOn (see RingBLEClient.centralManagerDidUpdateState). subscriber.start() @@ -160,6 +165,8 @@ struct PulseLoopApp: App { // Refresh the learned resting-HR baseline on foreground (6h-throttled no-op usually). if !Self.isRunningUnitTests { RestingHRBaselineService.refreshIfStale(context: container.mainContext) + // Same ordering constraint as in `init`: readiness consumes the baseline above. + ReadinessService.refreshIfStale(context: container.mainContext) } // Foreground reconnect: the OS can silently tear down the BLE link while suspended without // delivering a disconnect, leaving us "connected" but dead. On every resume, re-link the diff --git a/PulseLoop/Services/ReadinessScore.swift b/PulseLoop/Services/ReadinessScore.swift new file mode 100644 index 00000000..c27eb226 --- /dev/null +++ b/PulseLoop/Services/ReadinessScore.swift @@ -0,0 +1,449 @@ +import Foundation + +/// Daily readiness scoring — how recovered the user is this morning, on 0–100. +/// +/// Pure and storage-free, in the same spirit as `SleepInsights.swift`: this file consumes a +/// plain-value `ReadinessInputs` and never touches SwiftData. `ReadinessService` owns the fetching. +/// +/// Three rules govern everything here, and every one of them exists because the alternative +/// silently invents data: +/// +/// 1. **A missing signal is excluded from the denominator, never scored as zero.** A night where +/// the ring dropped its temperature reading is a night scored out of 90 points, not a night +/// that lost 10. Mirrors the doctrine at the top of `SleepInsights.swift`. +/// 2. **A baseline that isn't established yet counts as missing, not as "at baseline".** Scoring a +/// deviation against three days of data would read as authoritative while being noise. +/// 3. **Every contributor carries its own explanation.** The project's stated principle is +/// documented metrics and no black boxes, so a score is never surfaced without the ability to +/// say which signal dragged it down and by how much. +/// +/// Contributor weights, band knots, and the reasoning behind them are documented in +/// `docs/project/readiness.md`. Changing any of them requires bumping `algorithmVersion`, which +/// invalidates stored rows rather than silently reinterpreting old scores under new weights. + +// MARK: - Inputs + +/// One morning's raw signals plus the personal baselines to judge them against. Every field is +/// optional: callers pass what the ring actually captured, and scoring adapts. +struct ReadinessInputs: Equatable { + /// Mean HRV across the overnight window, in ms. + var hrv: Double? + /// 30-day baseline of overnight HRV, excluding the night being scored. + var hrvBaseline: BaselineStats? + + /// 10th percentile of heart rate across the overnight window, in bpm. + var restingHeartRate: Double? + /// The learned resting-HR baseline (`UserProfile.hrRestingBaseline`), in bpm. + var restingHeartRateBaseline: Double? + + /// `SleepScore.calculate(_:).score` for last night, 0–100. Scored absolutely — `SleepScore` + /// already encodes population-normal ranges, so a second personal baseline would double-count. + var sleepScore: Int? + + /// Mean skin temperature across the overnight window, in °C. + var skinTemperature: Double? + /// 30-day baseline of overnight skin temperature, excluding the night being scored. + var skinTemperatureBaseline: BaselineStats? + + /// Yesterday's training load in minutes. + var priorDayLoadMinutes: Double? + /// Trailing 7-day mean load in minutes, excluding yesterday. + var loadBaselineMinutes: Double? + + init( + hrv: Double? = nil, + hrvBaseline: BaselineStats? = nil, + restingHeartRate: Double? = nil, + restingHeartRateBaseline: Double? = nil, + sleepScore: Int? = nil, + skinTemperature: Double? = nil, + skinTemperatureBaseline: BaselineStats? = nil, + priorDayLoadMinutes: Double? = nil, + loadBaselineMinutes: Double? = nil + ) { + self.hrv = hrv + self.hrvBaseline = hrvBaseline + self.restingHeartRate = restingHeartRate + self.restingHeartRateBaseline = restingHeartRateBaseline + self.sleepScore = sleepScore + self.skinTemperature = skinTemperature + self.skinTemperatureBaseline = skinTemperatureBaseline + self.priorDayLoadMinutes = priorDayLoadMinutes + self.loadBaselineMinutes = loadBaselineMinutes + } +} + +// MARK: - Output + +/// One scored signal, carrying both its arithmetic and its explanation. +struct ReadinessContributor: Equatable { + enum Kind: String, CaseIterable { + case hrv + case restingHeartRate + case sleep + case skinTemperature + case trainingLoad + + var title: String { + switch self { + case .hrv: return "HRV" + case .restingHeartRate: return "Resting HR" + case .sleep: return "Sleep" + case .skinTemperature: return "Skin temperature" + case .trainingLoad: return "Training load" + } + } + + /// Points this signal is worth when present. Documented in `docs/project/readiness.md`. + var maxPoints: Double { + switch self { + case .hrv: return 30 + case .restingHeartRate: return 25 + case .sleep: return 30 + case .skinTemperature: return 10 + case .trainingLoad: return 5 + } + } + } + + let kind: Kind + let earned: Double + let maxPoints: Double + /// The measured value, in the contributor's own unit (ms, bpm, 0–100, °C, ratio). + let value: Double + /// The personal baseline it was judged against. nil for `sleep`, which is absolute. + let baseline: Double? + /// Signed deviation from baseline, in the contributor's reporting unit (% for HRV, bpm for + /// resting HR, °C for temperature, a ratio for load). nil for `sleep`. + let deviation: Double? + /// Plain-language explanation, e.g. "HRV 12% below your baseline". Never mentions a value that + /// wasn't measured. + let detail: String + + /// Points this signal cost. Sorting by this surfaces what actually held the score back. + var drag: Double { maxPoints - earned } +} + +enum ReadinessBand: String, CaseIterable { + case primed = "Primed" + case ready = "Ready" + case moderate = "Moderate" + case restNeeded = "Rest needed" +} + +/// Why a morning couldn't be scored. The distinction matters to the UI: "we're still learning your +/// baseline, day 6 of 14" is a useful empty state, "wear your ring overnight" is a call to action, +/// and conflating them produces a tile that looks broken. +enum ReadinessUnavailableReason: String { + /// Nothing usable was captured overnight. + case noSignals + /// Signals arrived, but the personal baselines they'd be judged against aren't established yet. + case baselineLearning + /// Baselines are fine; too little of the night was captured to be worth a number. + case insufficientCoverage +} + +struct ReadinessResult: Equatable { + let score: Int + let band: ReadinessBand + /// Scored contributors, biggest drag first. + let contributors: [ReadinessContributor] + /// What couldn't be scored, in canonical order. + let missing: [ReadinessContributor.Kind] + /// Points actually available this morning — the denominator the score was taken over. + let availablePoints: Double + + /// How much of the full 100-point picture this score is based on. Surfaced so a 78 from a + /// partial night is never presented as equivalent to a 78 from a complete one. + var coverage: Double { availablePoints / 100 } +} + +enum ReadinessOutcome: Equatable { + case scored(ReadinessResult) + case unavailable(ReadinessUnavailableReason) +} + +// MARK: - Scoring + +enum ReadinessScore { + /// Bumping this invalidates stored `ReadinessDaily` rows so they recompute, rather than letting + /// old scores be reinterpreted under new weights. Changing weights or knots REQUIRES a bump, + /// and an update to `docs/project/readiness.md`. + static let algorithmVersion = 1 + + /// Below this many available points a score would be more suggestion than measurement. + static let minAvailablePoints: Double = 50 + + /// Where the "soft" knot sits as a fraction of a contributor's points. Deliberately harsher + /// than `SleepScore.bandScore`'s 0.65: a recovery score that never drops below 65 tells you + /// nothing on the days you most need it to. + static let softFraction: Double = 0.55 + + static func band(_ score: Int) -> ReadinessBand { + if score >= 85 { return .primed } + if score >= 70 { return .ready } + if score >= 55 { return .moderate } + return .restNeeded + } + + static func evaluate(_ inputs: ReadinessInputs) -> ReadinessOutcome { + var contributors: [ReadinessContributor] = [] + var missing: [ReadinessContributor.Kind] = [] + /// Did any signal arrive but get dropped purely because its baseline wasn't ready? That is + /// "still learning", which is a different — and recoverable — story from "no data". + var awaitingBaseline = false + /// Did anything usable arrive at all? Distinguishes "ring not worn" from "ring worn, thin night". + var sawAnySignal = false + + func admit(_ contributor: ReadinessContributor?, kind: ReadinessContributor.Kind) { + if let contributor { + contributors.append(contributor) + } else { + missing.append(kind) + } + } + + // HRV — relative to the user's own 30-day median, in percent. + if let value = inputs.hrv, value.isFinite, value > 0 { + sawAnySignal = true + if let baseline = usableBaseline(inputs.hrvBaseline) { + let deviation = ((value - baseline) / baseline) * 100 + admit( + ReadinessContributor( + kind: .hrv, + earned: lowerIsWorse(deviation, ideal: 0, soft: -15, hard: -40, + points: ReadinessContributor.Kind.hrv.maxPoints), + maxPoints: ReadinessContributor.Kind.hrv.maxPoints, + value: value, + baseline: baseline, + deviation: deviation, + detail: relativeDetail("HRV", deviation, unit: .percent) + ), + kind: .hrv + ) + } else { + awaitingBaseline = true + missing.append(.hrv) + } + } else { + missing.append(.hrv) + } + + // Resting HR — bpm above the learned baseline. Below baseline is never penalized. + if let value = inputs.restingHeartRate, value.isFinite, value > 0 { + sawAnySignal = true + if let baseline = inputs.restingHeartRateBaseline, baseline.isFinite, baseline > 0 { + let deviation = value - baseline + admit( + ReadinessContributor( + kind: .restingHeartRate, + earned: higherIsWorse(deviation, ideal: 0, soft: 5, hard: 12, + points: ReadinessContributor.Kind.restingHeartRate.maxPoints), + maxPoints: ReadinessContributor.Kind.restingHeartRate.maxPoints, + value: value, + baseline: baseline, + deviation: deviation, + detail: relativeDetail("Resting HR", deviation, unit: .bpm) + ), + kind: .restingHeartRate + ) + } else { + awaitingBaseline = true + missing.append(.restingHeartRate) + } + } else { + missing.append(.restingHeartRate) + } + + // Sleep — absolute, since `SleepScore` already encodes population-normal ranges. + if let sleepScore = inputs.sleepScore, sleepScore > 0 { + sawAnySignal = true + let value = Double(sleepScore) + contributors.append( + ReadinessContributor( + kind: .sleep, + earned: lowerIsWorse(value, ideal: 88, soft: 65, hard: 30, + points: ReadinessContributor.Kind.sleep.maxPoints), + maxPoints: ReadinessContributor.Kind.sleep.maxPoints, + value: value, + baseline: nil, + deviation: nil, + detail: "Sleep score \(sleepScore)" + ) + ) + } else { + missing.append(.sleep) + } + + // Skin temperature — symmetric: a deviation in either direction is a signal. + if let value = inputs.skinTemperature, value.isFinite, value > 0 { + sawAnySignal = true + if let baseline = usableBaseline(inputs.skinTemperatureBaseline) { + let deviation = value - baseline + admit( + ReadinessContributor( + kind: .skinTemperature, + earned: higherIsWorse(abs(deviation), ideal: 0.2, soft: 0.6, hard: 1.2, + points: ReadinessContributor.Kind.skinTemperature.maxPoints), + maxPoints: ReadinessContributor.Kind.skinTemperature.maxPoints, + value: value, + baseline: baseline, + deviation: deviation, + detail: relativeDetail("Skin temperature", deviation, unit: .celsius) + ), + kind: .skinTemperature + ) + } else { + awaitingBaseline = true + missing.append(.skinTemperature) + } + } else { + missing.append(.skinTemperature) + } + + // Training load — yesterday's minutes as a ratio of the trailing week. + if let value = inputs.priorDayLoadMinutes, value.isFinite, value >= 0 { + sawAnySignal = true + if let baseline = inputs.loadBaselineMinutes, baseline.isFinite, baseline > 0 { + let ratio = value / baseline + admit( + ReadinessContributor( + kind: .trainingLoad, + earned: higherIsWorse(ratio, ideal: 1.2, soft: 1.8, hard: 3.0, + points: ReadinessContributor.Kind.trainingLoad.maxPoints), + maxPoints: ReadinessContributor.Kind.trainingLoad.maxPoints, + value: value, + baseline: baseline, + deviation: ratio, + detail: loadDetail(ratio) + ), + kind: .trainingLoad + ) + } else { + awaitingBaseline = true + missing.append(.trainingLoad) + } + } else { + missing.append(.trainingLoad) + } + + guard sawAnySignal else { return .unavailable(.noSignals) } + + let available = contributors.reduce(0) { $0 + $1.maxPoints } + // HRV and sleep are the two signals that actually describe recovery. Resting HR and + // temperature qualify them; load and temperature alone would be a fitness score, not a + // readiness one. + let hasCoreSignal = contributors.contains { $0.kind == .hrv || $0.kind == .sleep } + + guard available >= minAvailablePoints, hasCoreSignal else { + return .unavailable(awaitingBaseline ? .baselineLearning : .insufficientCoverage) + } + + let earned = contributors.reduce(0) { $0 + $1.earned } + let score = Int(clamp(((earned / available) * 100).rounded(), 0, 100)) + + // Biggest drag first, falling back to declaration order so equal drags stay deterministic. + let ordering = Dictionary( + uniqueKeysWithValues: ReadinessContributor.Kind.allCases.enumerated().map { ($1, $0) } + ) + let ranked = contributors.sorted { + $0.drag == $1.drag + ? (ordering[$0.kind] ?? 0) < (ordering[$1.kind] ?? 0) + : $0.drag > $1.drag + } + + return .scored( + ReadinessResult( + score: score, + band: band(score), + contributors: ranked, + missing: ReadinessContributor.Kind.allCases.filter { missing.contains($0) }, + availablePoints: available + ) + ) + } + + // MARK: - Band shaping + + /// Full points at or above `ideal`, `softFraction` of them at `soft`, zero at or below `hard`, + /// linear between the knots. Requires `ideal > soft > hard`. + /// + /// Deliberately not a reuse of `SleepScore.bandScore`, which is two-sided and absolute — every + /// readiness contributor except sleep is a one-sided deviation from a personal baseline. + private static func lowerIsWorse( + _ value: Double, ideal: Double, soft: Double, hard: Double, + points: Double, softFraction: Double = ReadinessScore.softFraction + ) -> Double { + guard value.isFinite, ideal > soft, soft > hard else { return 0 } + if value >= ideal { return points } + if value <= hard { return 0 } + let softPoints = points * softFraction + if value >= soft { + // Between soft and ideal: softPoints → points. + return softPoints + (points - softPoints) * ((value - soft) / (ideal - soft)) + } + // Between hard and soft: 0 → softPoints. + return softPoints * ((value - hard) / (soft - hard)) + } + + /// Mirror of `lowerIsWorse` for signals where a rise is the bad direction. Requires + /// `ideal < soft < hard`. Implemented by negation so the two curves cannot drift apart. + private static func higherIsWorse( + _ value: Double, ideal: Double, soft: Double, hard: Double, + points: Double, softFraction: Double = ReadinessScore.softFraction + ) -> Double { + lowerIsWorse(-value, ideal: -ideal, soft: -soft, hard: -hard, + points: points, softFraction: softFraction) + } + + // MARK: - Helpers + + /// A baseline is usable only once `BaselineStats` considers it established (roughly a week of + /// wear, ≥20 samples) and its median is a positive number we can divide by. + private static func usableBaseline(_ stats: BaselineStats?) -> Double? { + guard let stats, stats.isEstablished else { return nil } + let median = stats.median + guard median.isFinite, median > 0 else { return nil } + return median + } + + private enum DeviationUnit { + case percent, bpm, celsius + + /// Deviations smaller than this read as "at baseline" rather than as a rounded-to-zero + /// delta — "HRV 0% below your baseline" is noise dressed up as a finding. + var epsilon: Double { + switch self { + case .percent: return 0.5 + case .bpm: return 0.5 + case .celsius: return 0.05 + } + } + + func format(_ magnitude: Double) -> String { + switch self { + case .percent: return "\(Int(magnitude.rounded()))%" + case .bpm: return "\(Int(magnitude.rounded())) bpm" + case .celsius: return String(format: "%.1f °C", magnitude) + } + } + } + + private static func relativeDetail(_ label: String, _ deviation: Double, unit: DeviationUnit) -> String { + guard deviation.isFinite, abs(deviation) >= unit.epsilon else { + return "\(label) at your baseline" + } + let direction = deviation < 0 ? "below" : "above" + return "\(label) \(unit.format(abs(deviation))) \(direction) your baseline" + } + + private static func loadDetail(_ ratio: Double) -> String { + guard ratio.isFinite else { return "Training load unknown" } + if ratio <= 1.2 { return "Yesterday's load in your usual range" } + return String(format: "Yesterday's load %.1f× your usual", ratio) + } + + private static func clamp(_ value: Double, _ lo: Double, _ hi: Double) -> Double { + min(hi, max(lo, value)) + } +} diff --git a/PulseLoop/Services/ReadinessService.swift b/PulseLoop/Services/ReadinessService.swift new file mode 100644 index 00000000..efebb79a --- /dev/null +++ b/PulseLoop/Services/ReadinessService.swift @@ -0,0 +1,277 @@ +import Foundation +import SwiftData + +/// Assembles readiness inputs from the store, scores them, and persists the result. +/// +/// The storage half of the readiness feature — `ReadinessScore` holds the (pure) maths. Shaped +/// after `RestingHRBaselineService`: a throttled `refreshIfStale` entry point, a bounded fetch, and +/// writes only when something actually changed. +/// +/// The window that matters here is **the night**, not the calendar day. Daytime HRV and heart rate +/// reflect what you were doing, not how you recovered, so every overnight signal is read from the +/// sleep session's own span and daytime samples are excluded outright. +enum ReadinessService { + /// Readiness changes when a night's data lands, not continuously. Three hours is frequent + /// enough to pick up a morning sync and cheap enough to call on every foreground. + static let refreshInterval: TimeInterval = 3 * 3600 + static let baselineWindowDays = 30 + static let loadBaselineDays = 7 + /// 30 days of continuous overnight sampling is a few thousand rows; cap defensively, matching + /// `RestingHRBaselineService.fetchLimit`. + static let fetchLimit = 5000 + /// Below this many usable days the trailing-load mean is noise, so load is left unscored. + static let minLoadBaselineDays = 4 + + /// Fallback overnight window when no sleep session was decoded: 22:00 the previous evening + /// through 08:00. Deliberately generous — a ring that captured HRV and HR overnight but failed + /// the sleep decode should still produce a score. + static let fallbackWindowStartHour = -2 + static let fallbackWindowEndHour = 8 + + // MARK: - Entry points + + /// Recompute today's readiness if the stored row is stale or was written by an older algorithm. + /// Cheap to call on every launch and foreground. + @MainActor + static func refreshIfStale(context: ModelContext, now: Date = Date()) { + guard ReadinessPrefsStore.shared.prefs.masterEnabled else { return } + let today = Calendar.current.startOfDay(for: now) + if let existing = ReadinessRepository.row(on: today, context: context), + existing.algorithmVersion == ReadinessScore.algorithmVersion, + now.timeIntervalSince(existing.computedAt) < refreshInterval { + return + } + refresh(day: today, context: context, now: now) + } + + /// Compute and upsert the row for `day`. + /// + /// Deletes any existing row when the outcome becomes unavailable, so a night whose sleep session + /// was corrected or deleted doesn't strand yesterday's score on screen. + @MainActor + @discardableResult + static func refresh(day: Date, context: ModelContext, now: Date = Date()) -> ReadinessOutcome { + let startOfDay = Calendar.current.startOfDay(for: day) + let outcome = ReadinessScore.evaluate(inputs(for: startOfDay, context: context)) + let existing = ReadinessRepository.row(on: startOfDay, context: context) + + switch outcome { + case .unavailable: + if let existing { + context.delete(existing) + try? context.save() + } + case .scored(let result): + let json = encodeContributors(result.contributors) + if let existing { + existing.score = result.score + existing.bandRaw = result.band.rawValue + existing.availablePoints = result.availablePoints + existing.contributorsJSON = json + existing.algorithmVersion = ReadinessScore.algorithmVersion + existing.computedAt = now + existing.updatedAt = now + } else { + context.insert( + ReadinessDaily( + date: startOfDay, + score: result.score, + band: result.band, + availablePoints: result.availablePoints, + contributorsJSON: json, + computedAt: now + ) + ) + } + try? context.save() + } + return outcome + } + + /// Fill in history. Idempotent: days already scored at the current algorithm version are + /// skipped, so this is safe to call after an import or a demo reseed. + @MainActor + static func backfill(days: Int = 30, context: ModelContext, now: Date = Date()) { + guard ReadinessPrefsStore.shared.prefs.masterEnabled else { return } + let calendar = Calendar.current + let today = calendar.startOfDay(for: now) + for offset in 0.. ReadinessInputs { + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: day) + let window = overnightWindow(for: startOfDay, context: context) + + // Baselines end where the scored night begins, so a night can actually deviate from its own + // baseline rather than being averaged into it. + let baselineStart = window.start.addingTimeInterval(-Double(baselineWindowDays) * 86_400) + + func overnightValues(_ kind: MeasurementKind) -> [Double] { + MetricsRepository + .measurements(kind: kind, start: window.start, end: window.end, + limit: fetchLimit, context: context) + .map(\.value) + .filter { $0 > 0 } + } + + func baselineSamples(_ kind: MeasurementKind) -> BaselineStats? { + let rows = MetricsRepository.measurements( + kind: kind, start: baselineStart, end: window.start, + limit: fetchLimit, context: context + ) + // Only overnight readings belong in an overnight baseline; a 3pm HRV reading describes + // a different physiological state entirely. + let nightly = rows + .filter { isOvernight($0.timestamp, calendar: calendar) } + .map { MetricSample(timestamp: $0.timestamp, value: $0.value) } + return BaselineStats.compute(nightly) + } + + let hrvValues = overnightValues(.hrv) + let hrValues = overnightValues(.heartRate) + let tempValues = overnightValues(.temperature) + + var inputs = ReadinessInputs() + + if !hrvValues.isEmpty { + inputs.hrv = mean(hrvValues) + inputs.hrvBaseline = baselineSamples(.hrv) + } + + if !hrValues.isEmpty { + // The night's floor, not its average — the same statistic (p10) the learned baseline it + // is compared against uses, so the two are like for like. + inputs.restingHeartRate = percentile(hrValues.sorted(), 0.10) + inputs.restingHeartRateBaseline = ProfileRepository.profile(context: context)?.hrRestingBaseline + } + + if !tempValues.isEmpty { + inputs.skinTemperature = mean(tempValues) + inputs.skinTemperatureBaseline = baselineSamples(.temperature) + } + + if let sleep = SleepService.sleepForDate(startOfDay, context: context), sleep.session.totalMinutes > 0 { + inputs.sleepScore = SleepScore.calculate(sleep).score + } + + if let priorDay = calendar.date(byAdding: .day, value: -1, to: startOfDay) { + inputs.priorDayLoadMinutes = loadMinutes(on: priorDay, context: context) + inputs.loadBaselineMinutes = loadBaseline(before: priorDay, context: context) + } + + return inputs + } + + // MARK: - Overnight window + + /// The span to read overnight signals from. Prefers the night's own sleep session — which + /// `SleepService.sleepForDate` already resolves to the day's *longest* session, i.e. the night + /// rather than a nap — and falls back to a fixed 22:00–08:00 window when sleep wasn't decoded. + @MainActor + static func overnightWindow(for day: Date, context: ModelContext) -> (start: Date, end: Date) { + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: day) + if let sleep = SleepService.sleepForDate(startOfDay, context: context), + sleep.session.endAt > sleep.session.startAt { + return (sleep.session.startAt, sleep.session.endAt) + } + let start = calendar.date(byAdding: .hour, value: fallbackWindowStartHour, to: startOfDay) ?? startOfDay + let end = calendar.date(byAdding: .hour, value: fallbackWindowEndHour, to: startOfDay) ?? startOfDay + return (start, end) + } + + /// Whether a timestamp falls in the overnight band used for baselines (22:00–08:00 local). + private static func isOvernight(_ date: Date, calendar: Calendar) -> Bool { + let hour = calendar.component(.hour, from: date) + return hour >= 22 || hour < 8 + } + + // MARK: - Training load + + /// Yesterday's load in minutes: `max` of the day's active minutes and its recorded workout + /// time, never the sum — a tracked run usually also generates active minutes, and adding them + /// would double-count the same hour of effort. + @MainActor + static func loadMinutes(on day: Date, context: ModelContext) -> Double? { + let calendar = Calendar.current + let startOfDay = calendar.startOfDay(for: day) + guard let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay) else { return nil } + + let daily = MetricsRepository.activity(on: startOfDay, context: context) + let activeMinutes = daily.map { Double(max(0, $0.activeMinutes)) } + + let sessions = ActivityRepository.sessions(context: context).filter { session in + guard session.status == .finished, let ended = session.endedAt else { return false } + return ended >= startOfDay && ended < endOfDay + } + let workoutMinutes: Double? = sessions.isEmpty ? nil : sessions.reduce(0.0) { total, session in + guard let ended = session.endedAt else { return total } + let elapsed = ended.timeIntervalSince(session.startedAt) - session.totalPauseSeconds + return total + max(0, elapsed) / 60 + } + + switch (activeMinutes, workoutMinutes) { + case (nil, nil): return nil + case (let a?, nil): return a + case (nil, let w?): return w + case (let a?, let w?): return max(a, w) + } + } + + /// Trailing mean daily load over the `loadBaselineDays` before `day`, excluding `day` itself. + /// Returns nil below `minLoadBaselineDays` of usable history — a ratio against one or two days + /// would swing wildly for no real reason. + @MainActor + static func loadBaseline(before day: Date, context: ModelContext) -> Double? { + let calendar = Calendar.current + var values: [Double] = [] + for offset in 1...loadBaselineDays { + guard let past = calendar.date(byAdding: .day, value: -offset, to: day) else { continue } + if let minutes = loadMinutes(on: past, context: context) { + values.append(minutes) + } + } + guard values.count >= minLoadBaselineDays else { return nil } + let average = mean(values) + return average > 0 ? average : nil + } + + // MARK: - Helpers + + private static func encodeContributors(_ contributors: [ReadinessContributor]) -> String { + let records = contributors.map(ReadinessContributorRecord.init) + guard let data = try? JSONEncoder().encode(records), + let json = String(data: data, encoding: .utf8) else { return "[]" } + return json + } + + private static func mean(_ values: [Double]) -> Double { + guard !values.isEmpty else { return 0 } + return values.reduce(0, +) / Double(values.count) + } + + /// Interpolated percentile — same formula as `BaselineStats.compute` and + /// `RestingHRBaselineService`, so every resting-HR number in the app is derived identically. + private 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) + let lower = Int(rank.rounded(.down)) + let upper = Int(rank.rounded(.up)) + let weight = rank - Double(lower) + return sorted[lower] * (1 - weight) + sorted[upper] * weight + } +} diff --git a/PulseLoop/Services/Repositories.swift b/PulseLoop/Services/Repositories.swift index 07d7b1bd..cc8b604b 100644 --- a/PulseLoop/Services/Repositories.swift +++ b/PulseLoop/Services/Repositories.swift @@ -264,6 +264,43 @@ enum ProfileRepository { } } +/// Stored daily readiness scores. `ReadinessService` is the only writer. +enum ReadinessRepository { + /// The row for one morning, or nil if that day was never scored (or scored then invalidated). + @MainActor + static func row(on date: Date, context: ModelContext) -> ReadinessDaily? { + let start = Calendar.current.startOfDay(for: date) + guard let end = Calendar.current.date(byAdding: .day, value: 1, to: start) else { return nil } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.date >= start && $0.date < end }, + sortBy: [SortDescriptor(\.date, order: .reverse)] + ) + descriptor.fetchLimit = 1 + return try? context.fetch(descriptor).first + } + + /// The most recently scored morning. `fetchLimit: 1` — one row, not the whole table. + @MainActor + static func latest(context: ModelContext) -> ReadinessDaily? { + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.date, order: .reverse)] + ) + descriptor.fetchLimit = 1 + return try? context.fetch(descriptor).first + } + + /// Scored mornings within `[from, to]`, oldest-first for a left-to-right chart axis. + @MainActor + static func rows(from: Date, to: Date, limit: Int = 400, context: ModelContext) -> [ReadinessDaily] { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.date >= from && $0.date <= to }, + sortBy: [SortDescriptor(\.date, order: .forward)] + ) + descriptor.fetchLimit = limit + return (try? context.fetch(descriptor)) ?? [] + } +} + /// Per-device measurement configuration (HR interval + all-day vital toggles), keyed by `Device.id`. enum MeasurementConfigRepository { @MainActor diff --git a/PulseLoop/Settings/ReadinessPrefsStore.swift b/PulseLoop/Settings/ReadinessPrefsStore.swift new file mode 100644 index 00000000..07ede503 --- /dev/null +++ b/PulseLoop/Settings/ReadinessPrefsStore.swift @@ -0,0 +1,70 @@ +import Foundation + +/// User-tunable readiness preferences, persisted as JSON in `UserDefaults`. +/// +/// Unlike `NutritionPrefs`, `masterEnabled` defaults to **true**. Nutrition defaults off because it +/// is manual data entry that can ship meal photos to a third-party LLM — a genuinely new privacy +/// surface. Readiness is derived entirely from data the ring already collects locally: it adds no +/// permission, no network egress, and stores nothing the user didn't already have. It is also +/// self-gating, since the tile can't appear without HRV-or-sleep capability and can't score until +/// personal baselines establish, so defaulting it on can't produce a misleading empty tile. +/// +/// Mirrors the `NutritionPrefsStore` pattern — no SwiftData, no migration — with tolerant decode so +/// adding a future key never wipes an existing user's blob. +struct ReadinessPrefs: Codable, Equatable { + /// Master opt-in. While off there is no tile, no coach context, and no computation. + var masterEnabled = true + /// Show the readiness tile on the Today dashboard and in the widget snapshot. + var showOnToday = true + /// Include the score and its contributor breakdown in the coach's context packet and tools. + var shareWithCoach = true + /// Mention readiness in daily check-in notifications (only when `shareWithCoach` is also on). + var includeInNotifications = true + + static let `default` = ReadinessPrefs() + + init() {} + + /// Tolerant decode: any missing key falls back to its default, so a stored blob written by an + /// older build (lacking a newer key) is never discarded. + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let d = ReadinessPrefs.default + masterEnabled = try c.decodeIfPresent(Bool.self, forKey: .masterEnabled) ?? d.masterEnabled + showOnToday = try c.decodeIfPresent(Bool.self, forKey: .showOnToday) ?? d.showOnToday + shareWithCoach = try c.decodeIfPresent(Bool.self, forKey: .shareWithCoach) ?? d.shareWithCoach + includeInNotifications = try c.decodeIfPresent(Bool.self, forKey: .includeInNotifications) ?? d.includeInNotifications + } +} + +/// Observable, `UserDefaults`-backed store for readiness preferences. +/// Follows the `NutritionPrefsStore` pattern; persists on `didSet`, reads at use-time. +@MainActor +@Observable +final class ReadinessPrefsStore { + nonisolated deinit {} // skip the main-actor isolated-deinit hop (crashes on older sim runtimes) + + static let shared = ReadinessPrefsStore() + + static let prefsKey = "pulseloop.readiness.prefs.v1" + private let defaults: UserDefaults + + var prefs: ReadinessPrefs { + didSet { persist(prefs, forKey: Self.prefsKey) } + } + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + self.prefs = Self.load(ReadinessPrefs.self, forKey: Self.prefsKey, from: defaults) ?? .default + } + + private static func load(_ type: T.Type, forKey key: String, from defaults: UserDefaults) -> T? { + guard let data = defaults.data(forKey: key) else { return nil } + return try? JSONDecoder().decode(T.self, from: data) + } + + private func persist(_ value: T, forKey key: String) { + guard let data = try? JSONEncoder().encode(value) else { return } + defaults.set(data, forKey: key) + } +} diff --git a/PulseLoopTests/DataArchiveTests.swift b/PulseLoopTests/DataArchiveTests.swift index a9b7ad31..5285fa0a 100644 --- a/PulseLoopTests/DataArchiveTests.swift +++ b/PulseLoopTests/DataArchiveTests.swift @@ -2,7 +2,7 @@ import XCTest import SwiftData @testable import PulseLoop -/// Locks down the full-app export/import archive: a complete round trip over all 24 models, +/// Locks down the full-app export/import archive: a complete round trip over all 25 models, /// wipe completeness, version/corruption rejection (without data loss), and the settings + /// attachment side channels. Hermetic — in-memory SwiftData, suite-scoped UserDefaults, temp dirs. @MainActor @@ -14,7 +14,7 @@ final class DataArchiveTests: XCTestCase { (try? context.fetchCount(FetchDescriptor())) ?? -1 } - /// One row of every model type `SeedData.seedDemo` does NOT create, so seed + these = all 24. + /// One row of every model type `SeedData.seedDemo` does NOT create, so seed + these = all 25. private func insertModelsMissingFromSeed(_ context: ModelContext, deviceId: UUID) { context.insert(BatterySample(percent: 57, timestamp: Date(timeIntervalSince1970: 1_750_000_000))) context.insert(DeviceMeasurementConfig(deviceId: deviceId)) @@ -34,6 +34,15 @@ final class DataArchiveTests: XCTestCase { context.insert(CoachNotificationRecord(slotRaw: "morning", dateKey: "2026-07-25", title: "Hi", body: "Check in")) context.insert(CoachSummary(kind: "today", scopeKey: "2026-07-25", title: "Today", body: "Solid", dataSignature: "sig1")) context.insert(WearableLog(category: .sync, level: .info, message: "sync done", metadataJSON: #"{"n":1}"#)) + // Inserted explicitly rather than relying on the seed's readiness backfill, which only + // scores days that happen to have a full enough night. + context.insert(ReadinessDaily( + date: Date(timeIntervalSince1970: 1_750_000_000), + score: 78, + band: .ready, + availablePoints: 85, + contributorsJSON: #"[{"kindRaw":"hrv","earned":22.5,"maxPoints":30,"value":44,"baseline":50,"deviation":-12,"detail":"HRV 12% below your baseline"}]"# + )) try? context.save() } @@ -50,6 +59,7 @@ final class DataArchiveTests: XCTestCase { check(ActivityEvent.self); check(ActivitySensorPollEvent.self); check(CoachConversation.self) check(CoachMessage.self); check(CoachMemory.self); check(CoachToolCall.self) check(CoachNotificationRecord.self); check(CoachSummary.self); check(WearableLog.self) + check(ReadinessDaily.self) } private func makeSuiteDefaults(_ name: String) -> UserDefaults { @@ -169,6 +179,34 @@ final class DataArchiveTests: XCTestCase { XCTAssertEqual(count(PulseLoop.Measurement.self, context), before, "a rejected file must not touch existing data") } + /// Format version 2 added `readinessDailies`. Archives exported before it have no such key, and + /// `PulseArchive` decodes via the synthesized decoder — which has no notion of property + /// defaults — so the field must stay Optional or every older backup becomes unimportable. + /// This test builds a genuine v1 file by stripping the key from a real export. + func testV1ArchiveWithoutReadinessStillImports() async throws { + let source = try TestSupport.makeContext() + SeedData.seedDemo(source) + TestSupport.insertMeasurement(kind: .heartRate, value: 71, timestamp: Date(), into: source) + let measurementCount = count(PulseLoop.Measurement.self, source) + XCTAssertGreaterThan(measurementCount, 0) + + let data = try await DataArchiveService.exportArchive(context: source) + var json = try XCTUnwrap( + JSONSerialization.jsonObject(with: data) as? [String: Any], + "export should be a JSON object" + ) + XCTAssertNotNil(json["readinessDailies"], "a v2 export must write the key") + json.removeValue(forKey: "readinessDailies") + json["formatVersion"] = 1 + let v1Data = try JSONSerialization.data(withJSONObject: json) + + let destination = try TestSupport.makeContext() + try await DataArchiveService.importArchive(v1Data, context: destination, refreshStores: false) + + XCTAssertEqual(count(PulseLoop.Measurement.self, destination), measurementCount, + "a v1 archive must restore everything it does contain") + } + func testImportRejectsCorruptJSONWithoutDataLoss() async throws { let context = try TestSupport.makeContext() TestSupport.insertMeasurement(kind: .heartRate, value: 70, timestamp: Date(), into: context) diff --git a/PulseLoopTests/ReadinessScoreTests.swift b/PulseLoopTests/ReadinessScoreTests.swift new file mode 100644 index 00000000..e6ff645f --- /dev/null +++ b/PulseLoopTests/ReadinessScoreTests.swift @@ -0,0 +1,359 @@ +import XCTest +@testable import PulseLoop + +/// Locks the readiness algorithm: band knots, the missing-signal contract, and the exact wording of +/// the contributor explanations. Pure logic — no store, no hardware, no dates. +/// +/// The most important test here is `testMissingContributorIsNeverScoredAsZero`. Everything else is +/// arithmetic; that one encodes the design rule the whole feature rests on. +@MainActor +final class ReadinessScoreTests: XCTestCase { + + // MARK: - Fixtures + + /// An established baseline: `isEstablished` needs ≥7 span days and ≥20 samples. + private func baseline(_ median: Double, established: Bool = true) -> BaselineStats { + BaselineStats( + mean: median, + median: median, + standardDeviation: median * 0.1, + p25: median * 0.9, + p75: median * 1.1, + sampleCount: established ? 40 : 5, + spanDays: established ? 30 : 3 + ) + } + + /// Every contributor present and exactly at baseline. + private func perfectInputs() -> ReadinessInputs { + ReadinessInputs( + hrv: 50, hrvBaseline: baseline(50), + restingHeartRate: 55, restingHeartRateBaseline: 55, + sleepScore: 90, + skinTemperature: 36.1, skinTemperatureBaseline: baseline(36.0), + priorDayLoadMinutes: 45, loadBaselineMinutes: 45 + ) + } + + /// HRV at a given percentage deviation from a 50 ms baseline, paired with sleep so the + /// 50-point coverage gate is satisfied and the outcome is scoreable. + private func hrvInputs(deviationPercent: Double, sleepScore: Int = 88) -> ReadinessInputs { + ReadinessInputs( + hrv: 50 * (1 + deviationPercent / 100), hrvBaseline: baseline(50), + sleepScore: sleepScore + ) + } + + private func scored(_ inputs: ReadinessInputs, + file: StaticString = #filePath, line: UInt = #line) throws -> ReadinessResult { + guard case .scored(let result) = ReadinessScore.evaluate(inputs) else { + XCTFail("expected a scored outcome, got \(ReadinessScore.evaluate(inputs))", file: file, line: line) + throw XCTSkip("not scored") + } + return result + } + + private func contributor(_ kind: ReadinessContributor.Kind, + in result: ReadinessResult, + file: StaticString = #filePath, line: UInt = #line) throws -> ReadinessContributor { + let match = result.contributors.first { $0.kind == kind } + return try XCTUnwrap(match, "expected a \(kind.rawValue) contributor", file: file, line: line) + } + + private func earned(_ kind: ReadinessContributor.Kind, _ inputs: ReadinessInputs) throws -> Double { + try contributor(kind, in: try scored(inputs)).earned + } + + // MARK: - Composition + + func testAllContributorsAtBaselineScores100() throws { + let result = try scored(perfectInputs()) + XCTAssertEqual(result.score, 100) + XCTAssertEqual(result.band, .primed) + XCTAssertEqual(result.availablePoints, 100) + XCTAssertEqual(result.coverage, 1.0) + XCTAssertTrue(result.missing.isEmpty) + XCTAssertEqual(result.contributors.count, 5) + } + + /// The core invariant. A signal the ring didn't capture must leave the denominator, not drag + /// the score down. If this ever fails, readiness is punishing users for hardware gaps. + func testMissingContributorIsNeverScoredAsZero() throws { + // Sleep (30) + resting HR (25) = 55 available, both perfect. + let partial = ReadinessInputs( + restingHeartRate: 55, restingHeartRateBaseline: 55, + sleepScore: 90 + ) + let partialResult = try scored(partial) + XCTAssertEqual(partialResult.score, 100, "a night missing HRV is scored out of 55, not out of 100") + XCTAssertEqual(partialResult.availablePoints, 55) + XCTAssertEqual(partialResult.coverage, 0.55, accuracy: 0.0001) + XCTAssertTrue(partialResult.missing.contains(.hrv)) + + // The same night, but HRV was captured and is genuinely poor — now it must bite. + var withPoorHrv = partial + withPoorHrv.hrv = 30 + withPoorHrv.hrvBaseline = baseline(50) + let poorResult = try scored(withPoorHrv) + XCTAssertLessThan(poorResult.score, partialResult.score) + XCTAssertEqual(poorResult.availablePoints, 85) + } + + func testCoverageGateReturnsUnavailable() { + // Sleep alone is 30 points — below the 50-point floor. + let outcome = ReadinessScore.evaluate(ReadinessInputs(sleepScore: 90)) + XCTAssertEqual(outcome, .unavailable(.insufficientCoverage)) + } + + func testNoSignalsAtAllIsDistinctFromThinCoverage() { + XCTAssertEqual(ReadinessScore.evaluate(ReadinessInputs()), .unavailable(.noSignals)) + } + + /// Resting HR and temperature qualify recovery; they don't describe it. Without HRV or sleep + /// there is nothing to qualify. + func testWithoutCoreSignalIsUnavailableEvenWithEnoughPoints() { + let outcome = ReadinessScore.evaluate(ReadinessInputs( + restingHeartRate: 55, restingHeartRateBaseline: 55, + skinTemperature: 36.0, skinTemperatureBaseline: baseline(36.0), + priorDayLoadMinutes: 45, loadBaselineMinutes: 45 + )) + XCTAssertEqual(outcome, .unavailable(.insufficientCoverage)) + } + + /// A signal whose baseline isn't established is missing, not "at baseline" — and it reports the + /// recoverable reason so the tile can say "still learning" instead of "no data". + func testUnestablishedBaselineIsTreatedAsMissing() throws { + let inputs = ReadinessInputs( + hrv: 50, hrvBaseline: baseline(50, established: false), + sleepScore: 90 + ) + XCTAssertEqual(ReadinessScore.evaluate(inputs), .unavailable(.baselineLearning)) + + // With enough other coverage to score, HRV still stays out of the maths entirely. + var withRhr = inputs + withRhr.restingHeartRate = 55 + withRhr.restingHeartRateBaseline = 55 + let result = try scored(withRhr) + XCTAssertEqual(result.availablePoints, 55, "an unestablished baseline contributes no points") + XCTAssertTrue(result.missing.contains(.hrv)) + XCTAssertFalse(result.contributors.contains { $0.kind == .hrv }) + } + + // MARK: - Band knots + + func testHrvBandKnots() throws { + XCTAssertEqual(try earned(.hrv, hrvInputs(deviationPercent: 0)), 30.0, accuracy: 0.001) + XCTAssertEqual(try earned(.hrv, hrvInputs(deviationPercent: -15)), 16.5, accuracy: 0.001) + XCTAssertEqual(try earned(.hrv, hrvInputs(deviationPercent: -40)), 0.0, accuracy: 0.001) + XCTAssertEqual(try earned(.hrv, hrvInputs(deviationPercent: -60)), 0.0, accuracy: 0.001) + } + + func testHighHrvIsNotPenalized() throws { + XCTAssertEqual(try earned(.hrv, hrvInputs(deviationPercent: 40)), 30.0, accuracy: 0.001) + } + + func testRestingHeartRateBandKnots() throws { + func rhr(_ delta: Double) -> ReadinessInputs { + ReadinessInputs(restingHeartRate: 55 + delta, restingHeartRateBaseline: 55, sleepScore: 88) + } + XCTAssertEqual(try earned(.restingHeartRate, rhr(0)), 25.0, accuracy: 0.001) + XCTAssertEqual(try earned(.restingHeartRate, rhr(5)), 13.75, accuracy: 0.001) + XCTAssertEqual(try earned(.restingHeartRate, rhr(12)), 0.0, accuracy: 0.001) + XCTAssertEqual(try earned(.restingHeartRate, rhr(-6)), 25.0, accuracy: 0.001, + "a resting HR below baseline is a good sign, never a penalty") + } + + func testSleepBandKnots() throws { + func sleep(_ score: Int) -> ReadinessInputs { + ReadinessInputs(restingHeartRate: 55, restingHeartRateBaseline: 55, sleepScore: score) + } + XCTAssertEqual(try earned(.sleep, sleep(88)), 30.0, accuracy: 0.001) + XCTAssertEqual(try earned(.sleep, sleep(65)), 16.5, accuracy: 0.001) + XCTAssertEqual(try earned(.sleep, sleep(30)), 0.0, accuracy: 0.001) + } + + func testSkinTemperatureIsSymmetric() throws { + // Resting HR is carried at baseline purely to clear the 50-point coverage gate; it earns + // full marks, so it never moves the temperature contributor being measured. + func temp(_ delta: Double) -> ReadinessInputs { + ReadinessInputs( + restingHeartRate: 55, restingHeartRateBaseline: 55, + sleepScore: 88, + skinTemperature: 36.0 + delta, skinTemperatureBaseline: baseline(36.0) + ) + } + let above = try contributor(.skinTemperature, in: try scored(temp(0.9))) + let below = try contributor(.skinTemperature, in: try scored(temp(-0.9))) + XCTAssertEqual(above.earned, below.earned, "a deviation is a deviation in either direction") + XCTAssertEqual(above.detail, "Skin temperature 0.9 °C above your baseline") + XCTAssertEqual(below.detail, "Skin temperature 0.9 °C below your baseline") + + XCTAssertEqual(try earned(.skinTemperature, temp(0.2)), 10.0, accuracy: 0.001) + XCTAssertEqual(try earned(.skinTemperature, temp(0.6)), 5.5, accuracy: 0.001) + XCTAssertEqual(try earned(.skinTemperature, temp(1.2)), 0.0, accuracy: 0.001) + } + + func testTrainingLoadBandKnots() throws { + // Resting HR at baseline clears the coverage gate without affecting the load contributor. + func load(_ ratio: Double) -> ReadinessInputs { + ReadinessInputs( + restingHeartRate: 55, restingHeartRateBaseline: 55, + sleepScore: 88, + priorDayLoadMinutes: 40 * ratio, loadBaselineMinutes: 40 + ) + } + XCTAssertEqual(try earned(.trainingLoad, load(1.0)), 5.0, accuracy: 0.001) + XCTAssertEqual(try earned(.trainingLoad, load(1.2)), 5.0, accuracy: 0.001) + XCTAssertEqual(try earned(.trainingLoad, load(1.8)), 2.75, accuracy: 0.001) + XCTAssertEqual(try earned(.trainingLoad, load(3.0)), 0.0, accuracy: 0.001) + XCTAssertEqual(try earned(.trainingLoad, load(0.2)), 5.0, accuracy: 0.001, + "a rest day is never penalized") + } + + func testBandCutoffs() { + XCTAssertEqual(ReadinessScore.band(100), .primed) + XCTAssertEqual(ReadinessScore.band(85), .primed) + XCTAssertEqual(ReadinessScore.band(84), .ready) + XCTAssertEqual(ReadinessScore.band(70), .ready) + XCTAssertEqual(ReadinessScore.band(69), .moderate) + XCTAssertEqual(ReadinessScore.band(55), .moderate) + XCTAssertEqual(ReadinessScore.band(54), .restNeeded) + XCTAssertEqual(ReadinessScore.band(0), .restNeeded) + } + + // MARK: - Shape + + /// No cliffs, no inversions, no out-of-range scores anywhere across the HRV domain. + func testScoreIsMonotonicInHrv() throws { + var previous = Int.max + for step in stride(from: 20.0, through: -60.0, by: -1.0) { + let result = try scored(hrvInputs(deviationPercent: step)) + XCTAssertTrue((0...100).contains(result.score), "score \(result.score) out of range at \(step)%") + XCTAssertLessThanOrEqual(result.score, previous, "score rose as HRV fell, at \(step)%") + previous = result.score + } + } + + func testContributorsSortedByDragDescending() throws { + let inputs = ReadinessInputs( + hrv: 30, hrvBaseline: baseline(50), // −40%, full 30-point drag + restingHeartRate: 57, restingHeartRateBaseline: 55, // +2 bpm, small drag + sleepScore: 90, // no drag + skinTemperature: 36.0, skinTemperatureBaseline: baseline(36.0), + priorDayLoadMinutes: 45, loadBaselineMinutes: 45 + ) + let result = try scored(inputs) + let drags = result.contributors.map(\.drag) + XCTAssertEqual(drags, drags.sorted(by: >)) + XCTAssertEqual(result.contributors.first?.kind, .hrv) + } + + func testMissingIsReportedInCanonicalOrder() throws { + let result = try scored(ReadinessInputs( + hrv: 50, hrvBaseline: baseline(50), + sleepScore: 90 + )) + XCTAssertEqual(result.missing, [.restingHeartRate, .skinTemperature, .trainingLoad]) + } + + // MARK: - Explanations + + func testDetailStringsAreDataHonest() throws { + let result = try scored(ReadinessInputs( + hrv: 44, hrvBaseline: baseline(50), // −12% + restingHeartRate: 59, restingHeartRateBaseline: 55, // +4 bpm + sleepScore: 82 + )) + XCTAssertEqual(try contributor(.hrv, in: result).detail, "HRV 12% below your baseline") + XCTAssertEqual(try contributor(.restingHeartRate, in: result).detail, "Resting HR 4 bpm above your baseline") + XCTAssertEqual(try contributor(.sleep, in: result).detail, "Sleep score 82") + + // Nothing describes a signal that wasn't measured. + XCTAssertFalse(result.contributors.contains { $0.kind == .skinTemperature }) + XCTAssertFalse(result.contributors.contains { $0.detail.localizedCaseInsensitiveContains("temperature") }) + XCTAssertFalse(result.contributors.contains { $0.detail.localizedCaseInsensitiveContains("load") }) + } + + /// A deviation that rounds to zero is reported as "at baseline", not as a finding of zero. + func testNegligibleDeviationReadsAsAtBaseline() throws { + let result = try scored(ReadinessInputs( + hrv: 50.1, hrvBaseline: baseline(50), + restingHeartRate: 55.1, restingHeartRateBaseline: 55, + sleepScore: 88 + )) + XCTAssertEqual(try contributor(.hrv, in: result).detail, "HRV at your baseline") + XCTAssertEqual(try contributor(.restingHeartRate, in: result).detail, "Resting HR at your baseline") + } + + func testTrainingLoadDetailOnlyCallsOutRealSpikes() throws { + func detail(_ ratio: Double) throws -> String { + try contributor(.trainingLoad, in: try scored( + ReadinessInputs( + restingHeartRate: 55, restingHeartRateBaseline: 55, + sleepScore: 88, + priorDayLoadMinutes: 40 * ratio, loadBaselineMinutes: 40 + ) + )).detail + } + XCTAssertEqual(try detail(1.0), "Yesterday's load in your usual range") + XCTAssertEqual(try detail(2.1), "Yesterday's load 2.1× your usual") + } + + // MARK: - Robustness + + /// Garbage in must not produce a crash, a NaN, or a confidently wrong number. The service layer + /// filters its samples, but scoring must not depend on that. + func testDegenerateInputsAreSafe() { + let hostile: [ReadinessInputs] = [ + ReadinessInputs(hrv: .nan, hrvBaseline: baseline(50), sleepScore: 90), + ReadinessInputs(hrv: .infinity, hrvBaseline: baseline(50), sleepScore: 90), + ReadinessInputs(hrv: -10, hrvBaseline: baseline(50), sleepScore: 90), + ReadinessInputs(hrv: 50, hrvBaseline: baseline(0), sleepScore: 90), + ReadinessInputs(restingHeartRate: 55, restingHeartRateBaseline: 0, sleepScore: 90), + ReadinessInputs(sleepScore: 0, priorDayLoadMinutes: 45, loadBaselineMinutes: 0), + ReadinessInputs(sleepScore: -5), + ReadinessInputs( + hrv: 50, hrvBaseline: baseline(50), + sleepScore: 90, + skinTemperature: .nan, skinTemperatureBaseline: baseline(36.0), + priorDayLoadMinutes: .infinity, loadBaselineMinutes: 45 + ) + ] + + for inputs in hostile { + switch ReadinessScore.evaluate(inputs) { + case .unavailable: + continue + case .scored(let result): + XCTAssertTrue((0...100).contains(result.score), "score \(result.score) out of range") + XCTAssertGreaterThan(result.availablePoints, 0) + for c in result.contributors { + XCTAssertTrue(c.earned.isFinite, "\(c.kind.rawValue) earned a non-finite score") + XCTAssertTrue((0...c.maxPoints).contains(c.earned)) + XCTAssertFalse(c.detail.isEmpty) + } + } + } + } + + /// Bumping the version is what invalidates stored rows. Changing weights or knots without + /// bumping it would leave old scores silently reinterpreted — so this pin is deliberate. + /// If you changed the algorithm: bump `algorithmVersion`, update `docs/project/readiness.md`, + /// then update this test. + func testAlgorithmVersionIsPinned() { + XCTAssertEqual(ReadinessScore.algorithmVersion, 1) + XCTAssertEqual(ReadinessScore.minAvailablePoints, 50) + XCTAssertEqual(ReadinessScore.softFraction, 0.55) + } + + /// The weights are the contract agreed in the issue thread; they are not incidental. + func testContributorWeightsSumTo100() { + let total = ReadinessContributor.Kind.allCases.reduce(0) { $0 + $1.maxPoints } + XCTAssertEqual(total, 100) + XCTAssertEqual(ReadinessContributor.Kind.hrv.maxPoints, 30) + XCTAssertEqual(ReadinessContributor.Kind.restingHeartRate.maxPoints, 25) + XCTAssertEqual(ReadinessContributor.Kind.sleep.maxPoints, 30) + XCTAssertEqual(ReadinessContributor.Kind.skinTemperature.maxPoints, 10) + XCTAssertEqual(ReadinessContributor.Kind.trainingLoad.maxPoints, 5) + } +} diff --git a/PulseLoopTests/ReadinessServiceTests.swift b/PulseLoopTests/ReadinessServiceTests.swift new file mode 100644 index 00000000..d0376967 --- /dev/null +++ b/PulseLoopTests/ReadinessServiceTests.swift @@ -0,0 +1,375 @@ +import XCTest +import SwiftData +@testable import PulseLoop + +/// Storage-side readiness tests: overnight windowing, baseline windows, upsert/throttle behaviour, +/// and backfill. The scoring maths itself is covered by `ReadinessScoreTests`. +/// +/// Anchored to a fixed reference date rather than `Date()` so a run at 23:59 can't straddle +/// midnight and produce a different day's window than a run at noon. +@MainActor +final class ReadinessServiceTests: XCTestCase { + + /// 2026-03-15 12:00 local — midday, so every ±hours offset stays inside its intended day. + private let reference: Date = { + var components = DateComponents() + components.year = 2026 + components.month = 3 + components.day = 15 + components.hour = 12 + return Calendar.current.date(from: components) ?? Date() + }() + + private var calendar: Calendar { Calendar.current } + private var savedPrefs: ReadinessPrefs? + + override func setUp() async throws { + try await super.setUp() + // `refreshIfStale` and `backfill` are gated on the master toggle, which lives in a shared + // UserDefaults-backed singleton. Pin it on, and restore whatever was there afterwards. + savedPrefs = ReadinessPrefsStore.shared.prefs + var prefs = ReadinessPrefs.default + prefs.masterEnabled = true + ReadinessPrefsStore.shared.prefs = prefs + } + + override func tearDown() async throws { + if let savedPrefs { ReadinessPrefsStore.shared.prefs = savedPrefs } + try await super.tearDown() + } + + // MARK: - Fixtures + + private func day(_ offset: Int) -> Date { + calendar.date(byAdding: .day, value: offset, to: calendar.startOfDay(for: reference)) + ?? reference + } + + /// A time on the morning of `day`, or the evening before when `hour` is negative. + private func at(_ hour: Int, _ dayOffset: Int, minute: Int = 0) -> Date { + let base = day(dayOffset) + return calendar.date(byAdding: .minute, value: hour * 60 + minute, to: base) ?? base + } + + /// A night belonging to the morning of `dayOffset`: 23:00 the previous evening → +`minutes`. + /// Stage blocks are aggregated (one per stage) rather than per-minute, so a 30-day backfill + /// doesn't insert fifteen thousand rows. + @discardableResult + private func insertNight( + _ dayOffset: Int, + minutes: Int = 450, + deep: Int = 90, + light: Int = 320, + awake: Int = 40, + into context: ModelContext + ) -> SleepSession { + let start = at(-1, dayOffset) // 23:00 the evening before + let end = calendar.date(byAdding: .minute, value: minutes, to: start) ?? start + let session = SleepSession( + date: day(dayOffset), startAt: start, endAt: end, + totalMinutes: minutes, syncedAt: start + ) + context.insert(session) + var cursor = 0 + for (stage, duration) in [(SleepStage.deep, deep), (.light, light), (.awake, awake)] where duration > 0 { + let blockStart = calendar.date(byAdding: .minute, value: cursor, to: start) ?? start + context.insert(SleepStageBlock( + sessionId: session.id, startAt: blockStart, + startMinute: cursor, durationMinutes: duration, stage: stage + )) + cursor += duration + } + try? context.save() + return session + } + + /// Heart-rate readings spread across the night of `dayOffset`. + private func insertOvernightHR(_ dayOffset: Int, values: [Double], into context: ModelContext) { + for (index, value) in values.enumerated() { + let ts = calendar.date(byAdding: .minute, value: index * 30, to: at(-1, dayOffset)) ?? reference + TestSupport.insertMeasurement(kind: .heartRate, value: value, timestamp: ts, into: context) + } + } + + private func setRestingBaseline(_ bpm: Double, into context: ModelContext) { + let profile = UserProfile() + profile.hrRestingBaseline = bpm + context.insert(profile) + try? context.save() + } + + /// The cheapest fully scoreable morning: sleep (30 pts) + resting HR (25 pts) = 55 available, + /// which clears `minAvailablePoints` without needing a 30-day HRV baseline. + private func seedScoreableDay(_ dayOffset: Int, into context: ModelContext) { + insertNight(dayOffset, into: context) + insertOvernightHR(dayOffset, values: [58, 56, 55, 57, 59], into: context) + } + + // MARK: - Overnight window + + func testOvernightWindowFollowsTheSleepSession() throws { + let context = try TestSupport.makeContext() + let session = insertNight(0, minutes: 450, into: context) + + let window = ReadinessService.overnightWindow(for: day(0), context: context) + XCTAssertEqual(window.start, session.startAt) + XCTAssertEqual(window.end, session.endAt) + } + + func testFallbackWindowIsUsedWhenSleepWasNotDecoded() throws { + let context = try TestSupport.makeContext() + // No sleep session at all — a ring that captured vitals but failed the sleep decode. + let window = ReadinessService.overnightWindow(for: day(0), context: context) + XCTAssertEqual(window.start, at(-2, 0), "fallback should open at 22:00 the evening before") + XCTAssertEqual(window.end, at(8, 0), "fallback should close at 08:00") + + insertOvernightHR(0, values: [58, 56, 55], into: context) + setRestingBaseline(55, into: context) + let inputs = ReadinessService.inputs(for: day(0), context: context) + XCTAssertNotNil(inputs.restingHeartRate, "overnight HR must survive a missing sleep session") + } + + /// Daytime readings describe what you were doing, not how you recovered. A low afternoon heart + /// rate must not be allowed to masquerade as a good resting HR. + func testDaytimeSamplesAreExcludedFromTheOvernightWindow() throws { + let context = try TestSupport.makeContext() + insertNight(0, into: context) + insertOvernightHR(0, values: [58, 56, 55, 57, 59], into: context) + // A much lower reading at 14:00, well outside the night's window. + TestSupport.insertMeasurement(kind: .heartRate, value: 40, timestamp: at(14, 0), into: context) + setRestingBaseline(55, into: context) + + let inputs = ReadinessService.inputs(for: day(0), context: context) + let resting = try XCTUnwrap(inputs.restingHeartRate) + XCTAssertGreaterThan(resting, 50, "the 40 bpm afternoon reading leaked into the overnight p10") + XCTAssertLessThan(resting, 60) + } + + // MARK: - Baselines + + func testHrvBaselineExcludesTheNightBeingScored() throws { + let context = try TestSupport.makeContext() + // 30 prior nights at a steady 50 ms — enough span and samples for `isEstablished`. + for offset in 1...30 { + insertNight(-offset, into: context) + for index in 0..<3 { + let ts = calendar.date(byAdding: .hour, value: index, to: at(-1, -offset)) ?? reference + TestSupport.insertMeasurement(kind: .hrv, value: 50, timestamp: ts, into: context) + } + } + // Tonight is wildly different; it must not pull its own baseline toward itself. + insertNight(0, into: context) + for index in 0..<3 { + let ts = calendar.date(byAdding: .hour, value: index, to: at(-1, 0)) ?? reference + TestSupport.insertMeasurement(kind: .hrv, value: 20, timestamp: ts, into: context) + } + + let inputs = ReadinessService.inputs(for: day(0), context: context) + XCTAssertEqual(try XCTUnwrap(inputs.hrv), 20, accuracy: 0.001) + let baseline = try XCTUnwrap(inputs.hrvBaseline) + XCTAssertTrue(baseline.isEstablished) + XCTAssertEqual(baseline.median, 50, accuracy: 0.001, "tonight's 20 ms leaked into its own baseline") + } + + func testUnestablishedBaselineWritesNoRow() throws { + let context = try TestSupport.makeContext() + // Only three nights of HRV — far short of the establishment gate, and no resting baseline. + for offset in 0...2 { + insertNight(-offset, into: context) + TestSupport.insertMeasurement(kind: .hrv, value: 50, timestamp: at(-1, -offset), into: context) + } + let outcome = ReadinessService.refresh(day: day(0), context: context, now: reference) + XCTAssertEqual(outcome, .unavailable(.baselineLearning)) + XCTAssertNil(ReadinessRepository.row(on: day(0), context: context)) + } + + // MARK: - Persistence + + func testRefreshUpsertsASingleRow() throws { + let context = try TestSupport.makeContext() + seedScoreableDay(0, into: context) + setRestingBaseline(55, into: context) + + ReadinessService.refresh(day: day(0), context: context, now: reference) + ReadinessService.refresh(day: day(0), context: context, now: reference.addingTimeInterval(60)) + + let rows = try context.fetch(FetchDescriptor()) + XCTAssertEqual(rows.count, 1, "a second refresh must update the row, not insert another") + XCTAssertEqual(rows.first?.algorithmVersion, ReadinessScore.algorithmVersion) + XCTAssertFalse(rows.first?.contributors.isEmpty ?? true, "the breakdown must round-trip") + } + + func testStoredContributorsRoundTrip() throws { + let context = try TestSupport.makeContext() + seedScoreableDay(0, into: context) + setRestingBaseline(55, into: context) + ReadinessService.refresh(day: day(0), context: context, now: reference) + + let row = try XCTUnwrap(ReadinessRepository.row(on: day(0), context: context)) + let kinds = Set(row.contributors.compactMap(\.kind)) + XCTAssertEqual(kinds, [.sleep, .restingHeartRate]) + XCTAssertEqual(row.availablePoints, 55) + XCTAssertEqual(row.coverage, 0.55, accuracy: 0.0001) + for record in row.contributors { + XCTAssertFalse(record.detail.isEmpty) + } + } + + func testThrottleSkipsAFreshRow() throws { + let context = try TestSupport.makeContext() + seedScoreableDay(0, into: context) + setRestingBaseline(55, into: context) + ReadinessService.refresh(day: day(0), context: context, now: reference) + let computedAt = try XCTUnwrap(ReadinessRepository.row(on: day(0), context: context)).computedAt + + // One hour later — inside the 3h throttle. + ReadinessService.refreshIfStale(context: context, now: reference.addingTimeInterval(3600)) + XCTAssertEqual(try XCTUnwrap(ReadinessRepository.row(on: day(0), context: context)).computedAt, computedAt) + + // Four hours later — past it. + ReadinessService.refreshIfStale(context: context, now: reference.addingTimeInterval(4 * 3600)) + XCTAssertNotEqual(try XCTUnwrap(ReadinessRepository.row(on: day(0), context: context)).computedAt, computedAt) + } + + /// A version bump must beat the throttle, or old scores would linger under new weights. + func testAlgorithmVersionMismatchForcesRecomputeInsideTheThrottle() throws { + let context = try TestSupport.makeContext() + seedScoreableDay(0, into: context) + setRestingBaseline(55, into: context) + ReadinessService.refresh(day: day(0), context: context, now: reference) + + let row = try XCTUnwrap(ReadinessRepository.row(on: day(0), context: context)) + row.algorithmVersion = ReadinessScore.algorithmVersion - 1 + try? context.save() + + ReadinessService.refreshIfStale(context: context, now: reference.addingTimeInterval(60)) + let refreshed = try XCTUnwrap(ReadinessRepository.row(on: day(0), context: context)) + XCTAssertEqual(refreshed.algorithmVersion, ReadinessScore.algorithmVersion) + } + + /// If a night's sleep is corrected away, yesterday's score must not stay on screen. + func testRefreshDeletesTheRowWhenTheOutcomeBecomesUnavailable() throws { + let context = try TestSupport.makeContext() + let session = insertNight(0, into: context) + insertOvernightHR(0, values: [58, 56, 55], into: context) + setRestingBaseline(55, into: context) + ReadinessService.refresh(day: day(0), context: context, now: reference) + XCTAssertNotNil(ReadinessRepository.row(on: day(0), context: context)) + + // Delete the night and every overnight reading — nothing left to score. + context.delete(session) + for row in try context.fetch(FetchDescriptor()) { + context.delete(row) + } + try? context.save() + + let outcome = ReadinessService.refresh(day: day(0), context: context, now: reference) + XCTAssertEqual(outcome, .unavailable(.noSignals)) + XCTAssertNil(ReadinessRepository.row(on: day(0), context: context), + "a stale score must be cleared, not left behind") + } + + // MARK: - Backfill + + func testBackfillScoresOnlyDaysWithDataAndIsIdempotent() throws { + let context = try TestSupport.makeContext() + setRestingBaseline(55, into: context) + for offset in [0, -1, -3] { + seedScoreableDay(offset, into: context) + } + + ReadinessService.backfill(days: 7, context: context, now: reference) + let first = try context.fetch(FetchDescriptor()) + XCTAssertEqual(first.count, 3, "days with no night must not get a row") + let stamps = first.map(\.computedAt) + + ReadinessService.backfill(days: 7, context: context, now: reference.addingTimeInterval(600)) + let second = try context.fetch(FetchDescriptor()) + XCTAssertEqual(second.count, 3, "a second backfill must not duplicate rows") + XCTAssertEqual(second.map(\.computedAt).sorted(), stamps.sorted(), + "rows already at the current version must be skipped, not rewritten") + } + + func testMasterToggleOffSkipsComputationEntirely() throws { + let context = try TestSupport.makeContext() + seedScoreableDay(0, into: context) + setRestingBaseline(55, into: context) + + var prefs = ReadinessPrefs.default + prefs.masterEnabled = false + ReadinessPrefsStore.shared.prefs = prefs + + ReadinessService.refreshIfStale(context: context, now: reference) + ReadinessService.backfill(days: 7, context: context, now: reference) + XCTAssertTrue(try context.fetch(FetchDescriptor()).isEmpty) + } + + // MARK: - Training load + + /// A tracked run usually also generates active minutes. Summing them would double-count the + /// same hour of effort, so the day's load is the larger of the two, never their sum. + func testLoadMinutesTakesTheMaxNotTheSum() throws { + let context = try TestSupport.makeContext() + TestSupport.insertActivity(date: day(-1), activeMinutes: 45, into: context) + let session = ActivitySession(type: "run", status: .finished, startedAt: at(9, -1)) + session.endedAt = calendar.date(byAdding: .minute, value: 30, to: at(9, -1)) + context.insert(session) + try? context.save() + + let load = try XCTUnwrap(ReadinessService.loadMinutes(on: day(-1), context: context)) + XCTAssertEqual(load, 45, accuracy: 0.001, "expected max(45, 30), not 75") + } + + func testLoadMinutesSubtractsPausedTime() throws { + let context = try TestSupport.makeContext() + let session = ActivitySession(type: "run", status: .finished, startedAt: at(9, -1)) + session.endedAt = calendar.date(byAdding: .minute, value: 60, to: at(9, -1)) + session.totalPauseSeconds = 600 // 10 minutes paused + context.insert(session) + try? context.save() + + let load = try XCTUnwrap(ReadinessService.loadMinutes(on: day(-1), context: context)) + XCTAssertEqual(load, 50, accuracy: 0.001) + } + + func testLoadBaselineNeedsEnoughDaysBeforeItIsTrusted() throws { + let context = try TestSupport.makeContext() + // Three days of history — below `minLoadBaselineDays`. + for offset in 2...4 { + TestSupport.insertActivity(date: day(-offset), activeMinutes: 40, into: context) + } + XCTAssertNil(ReadinessService.loadBaseline(before: day(-1), context: context)) + + TestSupport.insertActivity(date: day(-5), activeMinutes: 40, into: context) + let baseline = try XCTUnwrap(ReadinessService.loadBaseline(before: day(-1), context: context)) + XCTAssertEqual(baseline, 40, accuracy: 0.001) + } + + func testLoadBaselineExcludesTheDayBeingJudged() throws { + let context = try TestSupport.makeContext() + // A huge spike on the day itself, steady history before it. + TestSupport.insertActivity(date: day(-1), activeMinutes: 300, into: context) + for offset in 2...6 { + TestSupport.insertActivity(date: day(-offset), activeMinutes: 40, into: context) + } + let baseline = try XCTUnwrap(ReadinessService.loadBaseline(before: day(-1), context: context)) + XCTAssertEqual(baseline, 40, accuracy: 0.001, "the spike day leaked into its own baseline") + } + + // MARK: - Demo data + + /// The seeded demo store must produce a real readiness history, or the tile and its trend chart + /// are empty for anyone evaluating the app without a ring (`-seedDemo YES`). + func testDemoSeedProducesReadinessHistory() throws { + let context = try TestSupport.makeContext() + SeedData.seedDemo(context) + let rows = try context.fetch(FetchDescriptor()) + XCTAssertGreaterThan(rows.count, 5, "demo data produced too little readiness history to chart") + // A spread, not a flat line — the demo store should exercise more than one band. + XCTAssertGreaterThan(Set(rows.map(\.band)).count, 1) + for row in rows { + XCTAssertTrue((0...100).contains(row.score)) + XCTAssertFalse(row.contributors.isEmpty, "a seeded score must carry its breakdown") + } + } +} diff --git a/docs/project/readiness.md b/docs/project/readiness.md new file mode 100644 index 00000000..eec7d862 --- /dev/null +++ b/docs/project/readiness.md @@ -0,0 +1,158 @@ +--- +title: Readiness score +description: How PulseLoop computes your daily readiness score — every contributor, weight, and threshold, documented. +--- + +# Readiness score + +Readiness answers one question each morning: **how recovered are you today?** It is a single +number from 0 to 100, computed entirely on your device from data your ring already collects. + +This page documents the whole algorithm. That is deliberate — PulseLoop's principles commit to +"documented metrics and an auditable coach, no black boxes", and a recovery score you can't +inspect is exactly the thing competitors charge a subscription for. + +The implementation lives in [`ReadinessScore.swift`](https://github.com/saksham2001/PulseLoopiOS/blob/main/PulseLoop/Services/ReadinessScore.swift) +(pure maths) and [`ReadinessService.swift`](https://github.com/saksham2001/PulseLoopiOS/blob/main/PulseLoop/Services/ReadinessService.swift) +(reading your data). Both are covered by unit tests that lock every number on this page. + +## Bands + +| Score | Band | +|---|---| +| 85–100 | Primed | +| 70–84 | Ready | +| 55–69 | Moderate | +| 0–54 | Rest needed | + +## Contributors + +Five signals, worth 100 points between them. Four are judged against **your own baseline**, not +against a population average — what counts as a good HRV for you is not what counts as a good HRV +for anyone else. + +| Contributor | Points | What's measured | Compared against | +|---|---|---|---| +| HRV | 30 | Mean HRV across the night (ms) | Your 30-day overnight median | +| Resting heart rate | 25 | The night's 10th-percentile HR (bpm) | Your learned resting-HR baseline | +| Sleep | 30 | Your sleep score for that night (0–100) | Absolute | +| Skin temperature | 10 | Mean skin temperature across the night (°C) | Your 30-day overnight median | +| Training load | 5 | Yesterday's active/workout minutes | Your trailing 7-day average | + +Sleep is the one absolute contributor, because the [sleep score](https://github.com/saksham2001/PulseLoopiOS/blob/main/PulseLoop/Services/SleepInsights.swift) +already encodes population-normal ranges for duration and stage balance. Scoring it against a +personal baseline as well would double-count the same normalisation. + +### Thresholds + +Each contributor earns full points at or better than **ideal**, 55% of its points at **soft**, and +zero at or beyond **hard**, interpolating linearly between those knots. + +| Contributor | ideal | soft | hard | +|---|---|---|---| +| HRV | at or above baseline | 15% below | 40% below | +| Resting heart rate | at or below baseline | 5 bpm above | 12 bpm above | +| Sleep | score ≥ 88 | score 65 | score 30 | +| Skin temperature | within 0.2 °C | 0.6 °C off | 1.2 °C off | +| Training load | ≤ 1.2× your usual | 1.8× | 3.0× | + +Two deliberate asymmetries: + +- **HRV above your baseline and resting HR below it are never penalised.** Some recovery models + treat an unusually high HRV as a warning sign (parasympathetic overshoot). That isn't + falsifiable from a consumer ring, so PulseLoop doesn't guess. +- **Skin temperature is symmetric.** A deviation in either direction is a signal, so the score + uses the absolute difference. + +The 55% knee is harsher than the sleep score's 65%. A recovery score that never drops below 65 +tells you nothing on the days you most need it to. + +## Missing data is never scored as zero + +This is the most important rule in the algorithm. + +If your ring didn't capture a signal last night, that signal is **removed from the denominator** +rather than scored as zero: + +``` +score = 100 × (points earned) ÷ (points available) +``` + +So a night where skin temperature is missing is scored out of 90 points, not penalised 10. The +score also reports its **coverage** — what fraction of the full 100-point picture it was based on — +so a 78 from a partial night is never silently presented as equivalent to a 78 from a complete one. + +A score is only produced when **at least 50 points are available** *and* at least one of HRV or +sleep is present. Resting heart rate and temperature qualify recovery; they don't describe it. + +What that means per device: + +| Situation | Available | Result | +|---|---|---| +| Colmi, all baselines established | 100 | Full-fidelity score | +| Colmi, no temperature reading that night | 90 | Scored, coverage 0.90 | +| Ring with sleep + HR but no HRV | 55 | Scored, coverage 0.55 | +| Any ring in its first week | < 50 | "Learning your baseline" | +| Ring with neither HRV nor sleep | — | No readiness tile at all | + +## Baselines + +A deviation is only meaningful once there's something to deviate from. PulseLoop reuses the +existing `BaselineStats` machinery, which considers a baseline established after roughly **a week +of wear with at least 20 samples**. + +Until then the contributor is treated as **missing, not as "at baseline"** — scoring a deviation +against three days of data would look authoritative while being noise. + +| Baseline | Window | Notes | +|---|---|---| +| HRV | 30 days of overnight readings | Excludes the night being scored | +| Skin temperature | 30 days of overnight readings | Excludes the night being scored | +| Resting heart rate | Learned separately, 30-day 10th percentile | Shared with the auto heart-rate zones | +| Training load | Trailing 7 days | Needs ≥ 4 days of history; excludes the day being judged | + +Every baseline window **excludes the day it is judging**. Otherwise a night would be partly +averaged into its own baseline and could never deviate from it. + +## The overnight window + +Daytime readings describe what you were doing, not how you recovered, so every overnight signal is +read from the night itself: + +- **Normally**: the span of that night's sleep session — which is resolved to the *longest* session + of the day, so a nap is never mistaken for the night. +- **If sleep wasn't decoded**: a fixed 22:00–08:00 window. A ring that captured HRV and heart rate + overnight but failed the sleep decode should still produce a score. + +Resting heart rate uses the night's 10th percentile rather than its mean — the same statistic the +baseline it's compared against uses, so the two are like for like. + +## Training load + +Yesterday's load is the **larger** of the day's active minutes and its recorded workout time — +never their sum. A tracked run usually also generates active minutes, and adding them would +double-count the same hour of effort. Workout time excludes any paused periods. + +## Storage and versioning + +Each morning's score is stored with its full contributor breakdown, so history keeps its *why* and +the trend chart doesn't recompute months of data on every render. Recomputing an old morning +against today's baseline would produce a different — and wrong — answer. + +Every stored row records the `algorithmVersion` that produced it. Changing any weight or threshold +on this page requires bumping that version, which invalidates stored rows so they recompute, +rather than silently reinterpreting old scores under new rules. + +Readiness scores are included in the full-data JSON export (format version 2 and later). + +## Known limitations + +Stated plainly, because the point of this page is that you can judge the number for yourself: + +- **The resting-HR baseline is an all-day 10th percentile**, not an overnight-only one. It's + dominated by sleep values in practice — your lowest heart rate of the day *is* during sleep — and + reusing it avoids a second baseline pipeline. An overnight-only variant is a candidate refinement. +- **The weights are informed judgement, not a validated clinical model.** They're documented here + precisely so they can be argued with and improved. +- **Training load is a blunt instrument** at 5 points: minutes only, with no notion of intensity. + A proper training-load model is separate roadmap work. diff --git a/docs/project/roadmap.md b/docs/project/roadmap.md index 2f2c0b31..9679df7f 100644 --- a/docs/project/roadmap.md +++ b/docs/project/roadmap.md @@ -50,7 +50,8 @@ These guide what we build and what we say no to. ### Metrics you can trust - **Performance & recovery**: readiness, training/cardio load, HRV and resting-HR - trends, VO₂max. + trends, VO₂max. The [readiness score](readiness.md) is documented in full — every + contributor, weight, and threshold. - **Health signals**: illness early-warning from shifts in skin temperature, resting heart rate, and respiration. - **Cycle tracking**: menstrual cycle and BBT from skin temperature, computed on-device. diff --git a/mkdocs.yml b/mkdocs.yml index 949efe1d..48f06c6b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,6 +120,7 @@ nav: - Project: - Roadmap: project/roadmap.md - Architecture: project/architecture.md + - Readiness score: project/readiness.md - Contributing: project/contributing.md - Contributors: project/contributors.md - Privacy: project/privacy.md