diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index 60235fefde..b3a1a2d644 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -18,23 +18,31 @@ public enum AnalyticsEngine { /// Pair the strap's WRIST_OFF/WRIST_ON events into off-wrist `[start, end)` intervals for the sleep /// detector's fractional wear filter (#500; design credited to j0b-dev's #504). Each WRIST_OFF opens /// an interval that closes at the next WRIST_ON, or at `windowEnd` if the strap is still off at the - /// end of the read window. Events need not be pre-sorted; kinds are formatted "NAME(n)" (e.g. + /// end of the read window. An unmatched tail may end earlier when sustained valid HR resumes; + /// explicit OFF/ON pairs are never shortened. Events need not be pre-sorted; kinds are formatted "NAME(n)" (e.g. /// "WRIST_OFF(10)"), matched by prefix. Repeated OFFs/ONs without a partner are coalesced. - public static func offWristIntervals(events: [WhoopEvent], windowEnd: Int) -> [(start: Int, end: Int)] { + public static func offWristIntervals(events: [WhoopEvent], windowEnd: Int, + hr: [HRSample] = []) -> [(start: Int, end: Int)] { let wear = events .filter { $0.kind.hasPrefix("WRIST_OFF") || $0.kind.hasPrefix("WRIST_ON") } .sorted { $0.ts < $1.ts } var intervals: [(start: Int, end: Int)] = [] var offStart: Int? = nil - for e in wear { + var lastOff: Int? = nil + for e in wear where e.ts <= windowEnd { if e.kind.hasPrefix("WRIST_OFF") { - if offStart == nil { offStart = e.ts } // ignore repeated OFFs + if offStart == nil { offStart = e.ts } + lastOff = e.ts // a repeated OFF invalidates evidence before it } else { // WRIST_ON closes an open off-wrist span if let s = offStart, e.ts > s { intervals.append((start: s, end: e.ts)) } offStart = nil } } - if let s = offStart, windowEnd > s { intervals.append((start: s, end: windowEnd)) } + if let s = offStart, windowEnd > s { + let end = WristWearRecovery.firstSustainedHR(hr, after: lastOff ?? s, before: windowEnd) + ?? windowEnd + if end > s { intervals.append((start: s, end: end)) } + } return intervals } diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/WristWearRecovery.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/WristWearRecovery.swift new file mode 100644 index 0000000000..1b18afe763 --- /dev/null +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WristWearRecovery.swift @@ -0,0 +1,33 @@ +import WhoopProtocol + +/// Reconcile a missing WRIST_ON event without declaring an unobserved tail worn. +/// Only the unpaired OFF tail uses this evidence; explicit OFF/ON intervals stay authoritative. +/// The 30...220 bpm range matches AnalyticsEngine's existing worn-HR gate. Five minutes with +/// no gap over five seconds rejects isolated pulses and sparse streams. The returned boundary is +/// the first observed sample of that confirmed run, never the OFF timestamp or a fabricated event. +/// This is event reconciliation, not a sleep classifier; all sleep and HR-gap gates still run. +public enum WristWearRecovery { + public static let confirmationSeconds = 5 * 60 + public static let maximumGapSeconds = 5 + + public static func firstSustainedHR(_ hr: [HRSample], after: Int, before: Int) -> Int? { + // Collapse duplicate timestamps conservatively: an invalid observation wins a conflict. + var validByTimestamp: [Int: Bool] = [:] + for sample in hr where sample.ts > after && sample.ts < before { + validByTimestamp[sample.ts] = (validByTimestamp[sample.ts] ?? true) + && (30...220).contains(sample.bpm) + } + var start: Int? + var previous: Int? + for ts in validByTimestamp.keys.sorted() { + guard validByTimestamp[ts] == true else { + start = nil; previous = nil + continue + } + if previous == nil || ts - previous! > maximumGapSeconds { start = ts } + previous = ts + if let start, ts - start >= confirmationSeconds { return start } + } + return nil + } +} diff --git a/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WristWearRecoveryTests.swift b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WristWearRecoveryTests.swift new file mode 100644 index 0000000000..7ce2dcb2ad --- /dev/null +++ b/Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WristWearRecoveryTests.swift @@ -0,0 +1,102 @@ +import XCTest +import WhoopProtocol +@testable import StrandAnalytics + +final class WristWearRecoveryTests: XCTestCase { + private func hr(_ from: Int, _ to: Int, step: Int = 1, bpm: Int = 60) -> [HRSample] { + stride(from: from, through: to, by: step).map { HRSample(ts: $0, bpm: bpm) } + } + private func event(_ ts: Int, _ off: Bool) -> WhoopEvent { + WhoopEvent(ts: ts, kind: off ? "WRIST_OFF(10)" : "WRIST_ON(9)", payload: [:]) + } + private func spans(_ events: [WhoopEvent], _ samples: [HRSample], end: Int = 4000) -> [String] { + AnalyticsEngine.offWristIntervals(events: events, windowEnd: end, hr: samples) + .map { "\($0.start):\($0.end)" } + } + + func testMissingOnEndsAtStartOfSustainedHR() { + XCTAssertEqual(spans([event(100, true)], hr(1000, 1600)), ["100:1000"]) + XCTAssertEqual(spans([event(100, true)], []), ["100:4000"]) + XCTAssertEqual(spans([], hr(1000, 1600)), []) + } + + func testPairedEventsStayAuthoritativeEvenWithDenseHR() { + XCTAssertEqual(spans([event(100, true), event(3000, false)], hr(1000, 3500)), ["100:3000"]) + } + + func testRepeatedOffRestartsEvidenceAndPreservesEarlierPairs() { + let events = [event(2500, true), event(100, true), event(2000, true), event(500, false)] + XCTAssertEqual(spans(events, hr(2100, 2900)), ["100:500", "2000:2501"]) + XCTAssertEqual(spans(events, hr(2100, 2700)), ["100:500", "2000:4000"]) + } + + func testFutureSamplesAndEventsCannotCloseCurrentTail() { + XCTAssertEqual(spans([event(100, true), event(5000, false)], hr(4100, 4500)), ["100:4000"]) + XCTAssertEqual(spans([event(5000, true)], hr(1000, 1600)), []) + } + + func testFiveMinuteConfirmationAndFiveSecondGapBoundaries() { + XCTAssertEqual(WristWearRecovery.firstSustainedHR(hr(1000, 1300, step: 5), after: 0, before: 2000), 1000) + XCTAssertNil(WristWearRecovery.firstSustainedHR(hr(1000, 1299), after: 0, before: 2000)) + XCTAssertNil(WristWearRecovery.firstSustainedHR(hr(1000, 1600, step: 6), after: 0, before: 2000)) + XCTAssertNil(WristWearRecovery.firstSustainedHR(hr(1000, 1300), after: 1000, before: 2000)) + XCTAssertNil(WristWearRecovery.firstSustainedHR(hr(1000, 1300), after: 0, before: 1300)) + } + + func testGapsAndInvalidReadingsResetConfirmation() { + XCTAssertEqual(WristWearRecovery.firstSustainedHR(hr(1000, 1150) + hr(1200, 1500), after: 0, before: 2000), 1200) + for invalid in [0, 29, 221, 255] { + let samples = hr(1000, 1199) + [HRSample(ts: 1200, bpm: invalid)] + hr(1201, 1600) + XCTAssertEqual(WristWearRecovery.firstSustainedHR(samples, after: 0, before: 2000), 1201) + } + } + + func testDuplicatesCannotManufactureCoverageAndInvalidWinsConflict() { + XCTAssertNil(WristWearRecovery.firstSustainedHR(Array(repeating: HRSample(ts: 1000, bpm: 60), count: 1000), after: 0, before: 2000)) + let samples = hr(1000, 1600) + [HRSample(ts: 1200, bpm: 0)] + for rows in [samples, Array(samples.reversed())] { + XCTAssertEqual(WristWearRecovery.firstSustainedHR(rows, after: 0, before: 2000), 1201) + } + } + + func testRecoveredNightMatchesControlAndPairedOffStillDropsIt() { + let start = 2 * 3600, end = start + 90 * 60 + let gravity = stride(from: start, through: end, by: 5).map { + GravitySample(ts: $0, x: 0, y: 0, z: 1, unit: "g") + } + let samples = hr(start - 900, end, step: 5, bpm: 50) + let control = SleepStager.detectSleep(hr: samples, gravity: gravity) + XCTAssertEqual(control.count, 1) + let recovered = AnalyticsEngine.offWristIntervals(events: [event(start - 1800, true)], windowEnd: end + 1, hr: samples) + let actual = SleepStager.detectSleep(hr: samples, gravity: gravity, wristOff: recovered) + XCTAssertEqual(actual.map { $0.start }, control.map { $0.start }) + XCTAssertEqual(actual.map { $0.end }, control.map { $0.end }) + let paired = AnalyticsEngine.offWristIntervals(events: [event(start - 1800, true), event(end, false)], windowEnd: end + 1, hr: samples) + XCTAssertTrue(SleepStager.detectSleep(hr: samples, gravity: gravity, wristOff: paired).isEmpty) + } + + func testRecoveryDoesNotDisableSubsequentHRGapGuard() { + let samples = hr(100, 1000) + hr(8000, 9000) + let off = AnalyticsEngine.offWristIntervals(events: [event(0, true)], windowEnd: 10000, hr: samples) + let period = SleepStager.Period(stage: "sleep", start: 2000, end: 7000) + XCTAssertEqual(SleepStager.offWristFraction(period, hr: samples, wristOff: off), 1) + } + + // Swift oracle output is pinned verbatim in the Kotlin twin. Offsets and input cadence vary; + // neither fixtures nor expected values contain a wearer's recorded samples or timestamps. + func testParityOracle() { + var values: [String] = [] + for offset in [0, 86400, 1700000000] { + for step in [1, 5, 6, 60] { + for duration in [299, 300, 600] { + let result = WristWearRecovery.firstSustainedHR( + hr(offset + 100, offset + 100 + duration, step: step), after: offset, before: offset + 1000) + values.append(result.map(String.init) ?? "nil") + } + } + } + let oracle = values.joined(separator: ",") + print("WRIST_ORACLE=\(oracle)") + XCTAssertEqual(oracle, "nil,100,100,nil,100,100,nil,nil,nil,nil,nil,nil,nil,86500,86500,nil,86500,86500,nil,nil,nil,nil,nil,nil,nil,1700000100,1700000100,nil,1700000100,1700000100,nil,nil,nil,nil,nil,nil") + } +} diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 4b2e917772..ed8931cc6e 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -486,8 +486,8 @@ final class AppModel: ObservableObject { // flag → no-op on every subsequent launch; idempotent on a clean DB. await self.intelligence.runTimestampHealIfNeeded() // One-shot on-upgrade Effort rescore (#313): recompute strain from source across the FULL - // history once, so any deep-history rows an older build left on the 0–21 axis regenerate on - // the 0–100 axis. Guarded by a persisted flag, so this is a no-op on every subsequent launch. + // history and repair sleep rejected by unmatched WRIST_OFF in one pass. Both persisted flags + // describe that shared pass; either pending flag triggers it. await self.intelligence.runEffortRescoreIfNeeded() while !Task.isCancelled { // #547 RE-POLLUTION: a sync since the last tick may have armed a re-heal (its ingest gate @@ -705,6 +705,7 @@ final class AppModel: ObservableObject { /// so that it cannot mark unscored data as scored — so gating on the fingerprint here would be asking /// a question whose answer is already known to be "yes, there is work". func runDeferredRescoreIfOwed() async { + await intelligence.runSleepWearRescoreIfNeeded() // A pass already running here holds the owed mark itself and settles it when it finishes; forcing // another would only queue a second full pass behind it. guard RescoreBackgroundScheduler.isRescoreOwed, !intelligence.computing else { return } diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index d49b740c39..fb90c6215c 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -593,8 +593,7 @@ final class IntelligenceEngine: ObservableObject { return !rows.isEmpty } - /// UserDefaults flag guarding the one-shot #313 full-history Effort rescore (below). Set once the - /// pass completes so it never re-runs. + /// Completion flag for the shared full-history Effort and sleep-wear repair pass. static let effortRescoreFlagKey = "intelligence.effortRescore.v313.done" /// One-shot, on-upgrade FULL-history Effort rescore (#313 PART B). The Effort hero gauge + numbers @@ -608,15 +607,43 @@ final class IntelligenceEngine: ObservableObject { /// `analyzeRecent` once with the `maxDays` cap lifted to the full history, then persist a flag so it /// runs exactly once. IMPORTED rows are never rewritten here (the engine only ever writes under the /// "-noop" computed source) , those are handled by re-import. A day already on 0–100 is recomputed - /// from the same raw HR and lands on 0–100 again: UNCHANGED axis (verified by test). + /// from the same raw HR and lands on 0–100 again: UNCHANGED axis (verified by test). This entry point + /// now shares a pass with sleep-wear repair; cached-only days are preserved in both repairs. func runEffortRescoreIfNeeded(historyDays: Int = 4000) async { - guard !UserDefaults.standard.bool(forKey: Self.effortRescoreFlagKey) else { return } - await analyzeRecent(maxDays: historyDays) - // Only mark done if the pass actually completed (wasn't skipped because another tick held the - // `computing` lock). `computing` is false here once analyzeRecent's `defer` has run; a skipped - // call returns with `note` unset by it. Use the lock state: if a concurrent run was in progress - // the flag stays unset so the next launch retries , cheap, and correctness over a one-time cost. - if !computing { UserDefaults.standard.set(true, forKey: Self.effortRescoreFlagKey) } + await runHistoryRepairIfNeeded(historyDays: historyDays) + } + + /// Upgrade repair for nights rejected by an unmatched WRIST_OFF, including history older than + /// the normal 21-day window. Reuses the full-history scoring path, including edited/dismissed + /// sleep protection and dependent daily metrics. Both flags describe this shared pass: either + /// pending flag triggers one run, and both are set only after every required write succeeds. + /// Cached-only days are preserved, changing the old Effort-only repair from broad-window writes to + /// writes only for days that were recomputed. + static let sleepWearRescoreFlagKey = "intelligence.sleepWearRescore.v1.done" + private var sleepWearRescoreRunning = false + + static func historyRepairIsPending(effortDone: Bool, sleepWearDone: Bool) -> Bool { + !effortDone || !sleepWearDone + } + + func runSleepWearRescoreIfNeeded(historyDays: Int = 4000) async { + await runHistoryRepairIfNeeded(historyDays: historyDays) + } + + private func runHistoryRepairIfNeeded(historyDays: Int) async { + guard Self.historyRepairIsPending( + effortDone: UserDefaults.standard.bool(forKey: Self.effortRescoreFlagKey), + sleepWearDone: UserDefaults.standard.bool(forKey: Self.sleepWearRescoreFlagKey)), + !sleepWearRescoreRunning, !computing, !Task.isCancelled, + !RescoreBackgroundScheduler.isBackgrounded else { return } + // Launch and scene activation can overlap while the store handle is being awaited. + sleepWearRescoreRunning = true + defer { sleepWearRescoreRunning = false } + await analyzeRecent(maxDays: historyDays, triggerLabel: "sleep-wear-history-repair", + preserveUnscoredHistory: true) { + UserDefaults.standard.set(true, forKey: Self.effortRescoreFlagKey) + UserDefaults.standard.set(true, forKey: Self.sleepWearRescoreFlagKey) + } } /// UserDefaults flag guarding the one-shot #547 implausible-timestamp DB heal (below). Set once the @@ -678,7 +705,8 @@ final class IntelligenceEngine: ObservableObject { /// Personal baselines (HRV / resting HR) are folded from the imported history, so even the first /// live night can be scored against your norm. func analyzeRecent(maxDays: Int = 21, force: Bool = true, skipIfUnchanged: Bool = false, - triggerLabel: String? = nil) async { + triggerLabel: String? = nil, preserveUnscoredHistory: Bool = false, + onPersisted: (() -> Void)? = nil) async { // #899-A: a concurrent pass already holds the lock. A NON-forced idle tick is safe to drop (the // in-flight pass already covers the same window). But a FORCED call is a real update path (a // post-backfill rescore after a sync) , dropping it would leave a freshly-synced night unscored @@ -780,9 +808,9 @@ final class IntelligenceEngine: ObservableObject { runningPassDays = maxDays // #1538: the pass is now past every gate and will do real work. Mark it started durably, so that a // process killed mid-pass leaves evidence a LATER process can read — the killed process itself gets - // no chance to record anything. Cleared beside the watermark at the end; there is no early return - // between here and there, so "started and never finished" means exactly "killed", never a silent - // internal skip. `RescoreBackgroundPolicy` reads it to stop re-attempting a pass that cannot + // no chance to record anything. Cleared beside the watermark at the end; persistence failures + // also leave this mark outstanding so a later pass can retry. `RescoreBackgroundPolicy` stops + // re-attempting a pass that cannot // finish in the background, which is the livelock in #1538. // #1681: keep the token this debt was stamped with. At the end of the pass it is what tells our // own debt apart from one a LATER trigger recorded while we were running - the latter must @@ -809,7 +837,11 @@ final class IntelligenceEngine: ObservableObject { // Carry THIS pass's window into the re-pass: a heal firing during a wide one-shot pass // must re-score the same width, not the default 21 days (Kotlin re-passes with the same // maxDays; keep the platforms in lockstep). - Task { await self.analyzeRecent(maxDays: maxDays, force: true) } + Task { + await self.analyzeRecent(maxDays: maxDays, force: true, + preserveUnscoredHistory: preserveUnscoredHistory, + onPersisted: onPersisted) + } } } @@ -1280,7 +1312,7 @@ final class IntelligenceEngine: ObservableObject { // short off-wrist tail survives. Pairing needs WRIST_ON too (to bound each interval); a span // still open at the window end closes at `to`. Empty when the strap emitted no wrist events. let wristEvents = (try? await store.events(deviceId: owner, from: from, to: to, limit: 50_000)) ?? [] - let wristOff = AnalyticsEngine.offWristIntervals(events: wristEvents, windowEnd: to) + let wristOff = AnalyticsEngine.offWristIntervals(events: wristEvents, windowEnd: to, hr: hr) // Calendar-day window for the ADDITIVE daily totals (steps + calories). The night window // above is anchored to the current time-of-day and ends at dayStart+12h, so for a PAST @@ -2445,17 +2477,34 @@ final class IntelligenceEngine: ObservableObject { } markerSources = sourceIds } - try? await store.persistComputedScores( - dailyMetrics: persistedDailies, - metricPoints: restPoints, - provenance: Array(provenanceByCell.values), - deviceId: computedId, - from: oldestDay, - to: newestDay, - replaceMetricKeys: markerKeys, - additionalMetricPoints: markerPoints, - replaceMetricSourceIds: markerSources - ) + do { + // Repair only the days actually recomputed. A full-history repair must not erase + // older cached scores/provenance whose raw inputs are no longer retained. + let dailiesByDay = Dictionary(grouping: persistedDailies, by: \.day) + let restPointsByDay = Dictionary(grouping: restPoints, by: \.day) + let provenanceByDay = Dictionary(grouping: provenanceByCell.values, by: \.day) + let markerPointsByDay = Dictionary(grouping: markerPoints, by: { $0.point.day }) + let windows = preserveUnscoredHistory + ? dailiesByDay.keys.sorted().map { ($0, $0) } + : [(oldestDay, newestDay)] + for (from, to) in windows { + try await store.persistComputedScores( + dailyMetrics: preserveUnscoredHistory + ? dailiesByDay[from, default: []] : persistedDailies, + metricPoints: preserveUnscoredHistory + ? restPointsByDay[from, default: []] : restPoints, + provenance: preserveUnscoredHistory + ? provenanceByDay[from, default: []] : Array(provenanceByCell.values), + deviceId: computedId, from: from, to: to, + replaceMetricKeys: markerKeys, + additionalMetricPoints: preserveUnscoredHistory + ? markerPointsByDay[from, default: []] : markerPoints, + replaceMetricSourceIds: markerSources) + } + } catch { + diagnosticSink?("re-score: daily persistence failed; history repair remains pending", nil) + return + } // Now evict only the STALE computed rows in the window , those a prior (e.g. UTC-keyed) run left // behind that the current local-keyed run no longer produces. Read the window, diff against the @@ -2468,7 +2517,7 @@ final class IntelligenceEngine: ObservableObject { // covers the window, so eviction runs exactly as before; `persistComputedScores` is guarded the // same way, so an empty pass leaves the persisted window untouched. Twin of the Android // WhoopDao.replaceComputedScoreWindow empty guard. - if !persistedDailies.isEmpty { + if !preserveUnscoredHistory && !persistedDailies.isEmpty { let freshKeys = Set(persistedDailies.map { $0.day }) let existingWindow = (try? await store.dailyMetrics(deviceId: computedId, from: oldestDay, to: newestDay)) ?? [] for stale in existingWindow where !freshKeys.contains(stale.day) { @@ -2750,7 +2799,14 @@ final class IntelligenceEngine: ObservableObject { let cachedSleepKept = cachedSleep.filter { s in !skipWindows.contains { s.startTs < $0.end && $0.start < s.endTs } // time-overlap test } - if !cachedSleepKept.isEmpty { _ = try? await store.upsertSleepSessions(cachedSleepKept, deviceId: computedId) } + do { + if !cachedSleepKept.isEmpty { + _ = try await store.upsertSleepSessions(cachedSleepKept, deviceId: computedId) + } + } catch { + diagnosticSink?("re-score: sleep persistence failed; history repair remains pending", nil) + return + } // ── Persist per-epoch motion (H8) beside each kept session's stagesJSON ────────────────────────── // The sleepSession rows exist now (just upserted), so the targeted motion UPDATE lands. Persist ONLY // for the sessions actually kept (not edited/dismissed), keyed by the detected start `analyzeDay` @@ -2917,6 +2973,7 @@ final class IntelligenceEngine: ObservableObject { diagnosticSink?("re-score: debt NOT settled — a newer re-score was recorded while this pass " + "was running, so the mark stays and another pass will run (#1681)", nil) } + if !Task.isCancelled && !pendingForcedRescore { onPersisted?() } } /// UserDefaults key for the #836 idle-tick gate: the complete raw-analysis fingerprint the last completed diff --git a/StrandTests/SleepWearHistoryRepairTests.swift b/StrandTests/SleepWearHistoryRepairTests.swift new file mode 100644 index 0000000000..8e41f20514 --- /dev/null +++ b/StrandTests/SleepWearHistoryRepairTests.swift @@ -0,0 +1,125 @@ +import XCTest +import WhoopProtocol +import WhoopStore +import StrandAnalytics +@testable import Strand + +@MainActor +final class SleepWearHistoryRepairTests: XCTestCase { + private func withPreferences(_ body: () async throws -> Void) async throws { + let defaults = UserDefaults.standard + let keys = [ + "profile.dateOfBirth", "profile.age", "profile.sex", "profile.weightKg", + "profile.heightCm", "profile.waistCm", "profile.hrMaxOverride", "profile.stepTicksPerStep", + "profile.stepsCalibrationCoefficient", "profile.stepsCalibrationSampleDays", + "profile.stepsCalibrationConfidence", "profile.stepsCalibrationManual", + "profile.stepsManualCoefficient", "profile.stepsHasBankedMotion", + IntelligenceEngine.effortRescoreFlagKey, IntelligenceEngine.sleepWearRescoreFlagKey, + "noop.analyzeWatermark", "analyzeRecent.stepsMotionCache.v1", + "noop.hrvBaselineEpoch", "noop.recoveryBaselineEpoch", UnitPrefs.hrvWindowKey, + RescoreBackgroundScheduler.owedKey, RescoreBackgroundScheduler.owedTokenKey, + RescoreBackgroundScheduler.lastPassSecondsKey, DayCycleMode.storageKey, + PuffinExperiment.experimentalSleepV2Key, PuffinExperiment.motionAwareWakeKey, + ] + let saved = keys.map { ($0, defaults.object(forKey: $0)) } + defer { + for (key, value) in saved { + if let value { defaults.set(value, forKey: key) } + else { defaults.removeObject(forKey: key) } + } + } + for key in keys { defaults.removeObject(forKey: key) } + defaults.set(DayCycleMode.midnight.rawValue, forKey: DayCycleMode.storageKey) + defaults.set(true, forKey: PuffinExperiment.experimentalSleepV2Key) + defaults.set(false, forKey: PuffinExperiment.motionAwareWakeKey) + try await body() + } + + func testEitherPendingFlagSchedulesExactlyOneSharedRepair() { + XCTAssertTrue(IntelligenceEngine.historyRepairIsPending(effortDone: false, sleepWearDone: false)) + XCTAssertTrue(IntelligenceEngine.historyRepairIsPending(effortDone: true, sleepWearDone: false)) + XCTAssertTrue(IntelligenceEngine.historyRepairIsPending(effortDone: false, sleepWearDone: true)) + XCTAssertFalse(IntelligenceEngine.historyRepairIsPending(effortDone: true, sleepWearDone: true)) + } + + func testRepairsOlderThan21DaysPersistsAndRunsOnlyOnce() async throws { + try await withPreferences { + let store = try await WhoopStore.inMemory() + let source = "my-whoop" + let registry = DeviceRegistryStore(dbQueue: store.registryWriter) + try registry.add(PairedDevice(id: source, brand: "WHOOP", model: "4.0", + sourceKind: .liveBLE, capabilities: [.hr, .hrv], status: .active, addedAt: 1, lastSeenAt: 1)) + let now = Int(Date().timeIntervalSince1970) + let tz = TimeZone.current.secondsFromGMT() + let dayStart = IntelligenceEngine.midnightLocal(now, offsetSec: tz) - 35 * 86400 + let start = dayStart + 2 * 3600, end = start + 2 * 3600 + let hr = stride(from: start - 900, through: end, by: 5).map { HRSample(ts: $0, bpm: 50) } + let grav = stride(from: start, through: end, by: 5).map { GravitySample(ts: $0, x: 0, y: 0, z: 1, unit: "g") } + let events = [WhoopEvent(ts: start - 1800, kind: "WRIST_OFF(10)", payload: [:])] + _ = try await store.insert(Streams(hr: hr, gravity: grav, events: events), deviceId: source) + // A cached day with no retained raw data must survive the wide repair unchanged. + let cachedDay = AnalyticsEngine.dayString(dayStart - 3 * 86400, offsetSec: tz) + let cached = DailyMetric(day: cachedDay, totalSleepMin: 456, efficiency: 0.9, + deepMin: 100, remMin: 100, lightMin: 256, disturbances: 1, restingHr: 55, + avgHrv: 35, recovery: 70, strain: 10, exerciseCount: 0) + _ = try await store.upsertDailyMetrics([cached], deviceId: source + "-noop") + // Hand-edited bounds overlapping a second night's raw data remain authoritative. + let editedStart = start + 86400, editedEnd = end + 86400 + let secondHR = hr.map { HRSample(ts: $0.ts + 86400, bpm: $0.bpm) } + let secondGravity = grav.map { GravitySample(ts: $0.ts + 86400, x: $0.x, y: $0.y, z: $0.z, unit: "g") } + _ = try await store.insert(Streams(hr: secondHR, gravity: secondGravity), deviceId: source) + let edited = CachedSleepSession(startTs: editedStart, endTs: editedEnd - 300, + efficiency: 0.9, restingHr: 50, avgHrv: nil, stagesJSON: "[]", userEdited: true, + startTsAdjusted: editedStart + 300) + _ = try await store.upsertSleepSessions([edited], deviceId: source + "-noop") + let repo = Repository(deviceId: source) + repo.setStoreForTesting(store) + let engine = IntelligenceEngine(repo: repo, profile: ProfileStore(), deviceId: source) + var triggers = 0 + engine.diagnosticSink = { line, _ in + if line.contains("trigger=sleep-wear-history-repair") { triggers += 1 } + } + // The normal recent pass cannot repair this older night. + await engine.analyzeRecent(maxDays: 21) + let before = try await store.sleepSessions(deviceId: source + "-noop", from: dayStart, to: dayStart + 86400, limit: 100) + XCTAssertTrue(before.isEmpty) + await engine.runSleepWearRescoreIfNeeded(historyDays: 40) + let after = try await store.sleepSessions(deviceId: source + "-noop", from: dayStart, to: dayStart + 86400, limit: 100) + XCTAssertEqual(after.count, 1) + let day = AnalyticsEngine.dayString(dayStart, offsetSec: tz) + let dailies = try await store.dailyMetrics(deviceId: source + "-noop", from: day, to: day) + XCTAssertGreaterThan(try XCTUnwrap(dailies.first?.totalSleepMin), 60) + let cachedAfter = try await store.dailyMetrics(deviceId: source + "-noop", from: cachedDay, to: cachedDay) + XCTAssertEqual(cachedAfter.first?.totalSleepMin, cached.totalSleepMin) + XCTAssertEqual(cachedAfter.first?.recovery, cached.recovery) + let editedAfter = try await store.sleepSessions(deviceId: source + "-noop", from: editedStart, to: editedEnd, limit: 100) + XCTAssertEqual(editedAfter.count, 1) + XCTAssertEqual(editedAfter.first?.effectiveStartTs, edited.effectiveStartTs) + XCTAssertEqual(editedAfter.first?.endTs, edited.endTs) + XCTAssertEqual(editedAfter.first?.userEdited, true) + XCTAssertTrue(UserDefaults.standard.bool(forKey: IntelligenceEngine.sleepWearRescoreFlagKey)) + XCTAssertTrue(UserDefaults.standard.bool(forKey: IntelligenceEngine.effortRescoreFlagKey)) + await engine.runSleepWearRescoreIfNeeded(historyDays: 40) + XCTAssertEqual(triggers, 1, "Completed history repair must not run on every launch") + } + } + + func testBusyAndCancelledAttemptsRemainPending() async throws { + try await withPreferences { + let repo = Repository(deviceId: "test-sleep-wear") + let engine = IntelligenceEngine(repo: repo, profile: ProfileStore(), deviceId: "test-sleep-wear") + engine.computing = true + await engine.runSleepWearRescoreIfNeeded(historyDays: 40) + XCTAssertFalse(UserDefaults.standard.bool(forKey: IntelligenceEngine.sleepWearRescoreFlagKey)) + XCTAssertFalse(UserDefaults.standard.bool(forKey: IntelligenceEngine.effortRescoreFlagKey)) + engine.computing = false + let task = Task { @MainActor in + await Task.yield() + await engine.runSleepWearRescoreIfNeeded(historyDays: 40) + } + task.cancel() + await task.value + XCTAssertFalse(UserDefaults.standard.bool(forKey: IntelligenceEngine.sleepWearRescoreFlagKey)) + } + } +} diff --git a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt index 30917ecbb0..a3325426cc 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt @@ -79,18 +79,23 @@ object AnalyticsEngine { * Pair the strap's WRIST_OFF/WRIST_ON events into off-wrist [start, end) intervals for the sleep * detector's fractional wear filter (#500; design credited to j0b-dev's #504). Each WRIST_OFF opens * an interval that closes at the next WRIST_ON, or at [windowEnd] if the strap is still off at the - * end of the read window. Events need not be pre-sorted; kinds are formatted "NAME(n)" (e.g. + * end of the read window. An unmatched tail may end earlier when sustained valid HR resumes; + * explicit OFF/ON pairs are never shortened. Events need not be pre-sorted; kinds are formatted "NAME(n)" (e.g. * "WRIST_OFF(10)"), matched by prefix. Repeated OFFs/ONs without a partner are coalesced. Mirrors Swift. */ - fun offWristIntervals(events: List, windowEnd: Long): List> { + fun offWristIntervals(events: List, windowEnd: Long, + hr: List = emptyList()): List> { val wear = events .filter { it.kind.startsWith("WRIST_OFF") || it.kind.startsWith("WRIST_ON") } .sortedBy { it.ts } val intervals = ArrayList>() var offStart: Long? = null + var lastOff: Long? = null for (e in wear) { + if (e.ts > windowEnd) continue if (e.kind.startsWith("WRIST_OFF")) { - if (offStart == null) offStart = e.ts // ignore repeated OFFs + if (offStart == null) offStart = e.ts + lastOff = e.ts // a repeated OFF invalidates evidence before it } else { // WRIST_ON closes an open off-wrist span val s = offStart if (s != null && e.ts > s) intervals.add(s to e.ts) @@ -98,7 +103,10 @@ object AnalyticsEngine { } } val s = offStart - if (s != null && windowEnd > s) intervals.add(s to windowEnd) + if (s != null && windowEnd > s) { + val end = WristWearRecovery.firstSustainedHR(hr, lastOff ?: s, windowEnd) ?: windowEnd + if (end > s) intervals.add(s to end) + } return intervals } diff --git a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt index 06ef62225c..452cb6d6f2 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -46,18 +46,18 @@ object IntelligenceEngine { /** * Serialises [analyzeRecent] against itself. The pass is launched from four independent coroutines: the * 15-min backstop loop and rescoreAfterEdit (both AppViewModel), the post-offload analyze - * (WhoopBleClient), plus the one-shot Effort rescore ([runEffortRescoreIfNeeded]). These can overlap: + * (WhoopBleClient), plus the shared one-shot Effort/sleep-history repair. These can overlap: * two parallel 21-night passes double the CPU/battery AND race the #899 self-heal, whose concurrent * overlapping-session deletes can pick different survivors. This mirrors the intent of the Swift * `computing` guard, but SERIALISES rather than coalesces on purpose: Android's callers pass - * heterogeneous windows , the Effort rescore uses maxDays=4000, not 21, and can overlap the *independent* + * heterogeneous windows , the history repair uses maxDays=4000, not 21, and can overlap the *independent* * BLE-offload analyze. A drop-guard would skip that full-history rescore while its unconditional flagSet * marks it permanently done, and would re-run the holder's 21-day window in its place. withLock lets * every caller run its OWN pass, queued and never parallel, so nothing is dropped and no window is * silently lost. Suspending (not thread-blocking) and cancellation-cooperative, matching the callers' * #125 CancellationException handling. No re-entrancy: nothing analyzeRecent calls re-enters it * ([runEffortRescoreIfNeeded] delegates to analyzeRecent and does NOT take the lock itself, so the - * Mutex is acquired exactly once per Effort pass, never nested). + * Mutex is acquired exactly once per history repair, never nested). */ private val analyzeGate = Mutex() @@ -122,6 +122,9 @@ object IntelligenceEngine { * config signature. Pruned to the calibration window each pass so it cannot grow without bound. */ private var stepsMotionCache = HashMap>() + // Guarded by analyzeGate; kept as state to avoid another parameter on the bytecode-budgeted pass. + private var preserveUnscoredHistoryForRun = false + /** One reused night: its per-day cache [key], the scored [res], and everything the pass-1 loop otherwise * writes into function-scoped per-day maps that pass 2 reads (owner/hrRows/primary-session RHR/SpO₂ * candidate/HRV over-count), plus the always-on per-day [diagLines] to replay so a reused pass logs the @@ -499,6 +502,7 @@ object IntelligenceEngine { // every existing test relies on. Read/written under [analyzeGate] with the cache they back. stepsMotionCacheGet: (() -> String?)? = null, stepsMotionCacheSet: ((String) -> Unit)? = null, + preserveUnscoredHistory: Boolean = false, ): List = withContext(Dispatchers.Default) { // #1005: time the whole pass so a re-score STORM is visible in the strap log (the trigger lines // record WHY each pass runs; this records how many nights and how long — the CPU cost per run). @@ -516,6 +520,7 @@ object IntelligenceEngine { // across the back-to-back passes an offload storm is made of. Reset and emit both live in this // wrapper, never in `analyzeRecentOnCpu`, whose ratchet margin has no room for either. StoreProbeTally.reset() + preserveUnscoredHistoryForRun = preserveUnscoredHistory if (!stepsMotionCacheLoaded && stepsMotionCacheGet != null) { stepsMotionCacheLoaded = true val raw = stepsMotionCacheGet() @@ -599,6 +604,7 @@ object IntelligenceEngine { // #1567: same reason as the sync path, over a WIDER window — this one rewrites the FULL history // once. Without it every day of that rewrite reads the skin-temp scale as WHOOP5 (see analyzeRecent). ownerSource: DayOwnerSource? = null, + preserveUnscoredHistory: Boolean = true, ) { if (flagGet()) return analyzeRecent( @@ -608,6 +614,7 @@ object IntelligenceEngine { importedDeviceId = importedDeviceId, maxHROverride = maxHROverride, ownerSource = ownerSource, + preserveUnscoredHistory = preserveUnscoredHistory, ) flagSet() } @@ -1036,7 +1043,7 @@ object IntelligenceEngine { val steps = repo.stepSamples(owner, from, to, STREAM_LIMIT) val skinReads = readDaySkinAndWristOff( repo, owner, from, to, ownerSource, skinFamilyByOwner, skinWornToleranceByOwner, - skinAnchorByOwner, skinAnchorResolvedOwners, skinAnchorScanFrom, skinAnchorScanTo, + skinAnchorByOwner, skinAnchorResolvedOwners, skinAnchorScanFrom, skinAnchorScanTo, hr, ) val skin = skinReads.skin val spo2 = skinReads.spo2 @@ -1929,7 +1936,7 @@ object IntelligenceEngine { candidatePriorities, resolvedScoreOwnerByDay, IntelligencePersistence.LegacyScoreClock(nowLocalMidnight, nowSeconds, tzOffsetSeconds), out, ) - repo.replaceComputedScoreWindow(computedWindow) + IntelligencePersistence.persistComputedWindow(repo, computedWindow, preserveUnscoredHistoryForRun) persistFitnessVitalityAndSteps( repo = repo, @@ -3044,6 +3051,7 @@ object IntelligenceEngine { skinAnchorResolvedOwners: HashSet, skinAnchorScanFrom: Long, skinAnchorScanTo: Long, + hr: List, ): DaySkinReads { val skin = repo.skinTempSamples(owner, from, to, StreamReadCap.SKIN) // #93: WHOOP 4.0 raw SpO2 PPG samples for the night; analyzeDay banks the nightly red/IR ADC @@ -3089,7 +3097,7 @@ object IntelligenceEngine { // only when its off-wrist coverage reaches maxOffWristSleepFraction, so a real night with a // short off-wrist tail survives. Pairing needs WRIST_ON too (to bound each interval); a span // still open at the window end closes at `to`. Empty when the strap emitted no wrist events. - val wristOff = AnalyticsEngine.offWristIntervals(repo.events(owner, from, to, STREAM_LIMIT), to) + val wristOff = AnalyticsEngine.offWristIntervals(repo.events(owner, from, to, STREAM_LIMIT), to, hr) return DaySkinReads(skin, spo2, skinFamily, skinWornToleranceSec, skinAnchorRaw, wristOff) } diff --git a/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt b/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt index 25482d0026..52a52e523f 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt @@ -8,6 +8,9 @@ import com.noop.data.WhoopRepository /** Persistence-only helpers kept out of the already large scoring orchestrator. */ internal object IntelligencePersistence { + fun historyRepairIsPending(effortDone: Boolean, sleepWearDone: Boolean): Boolean = + !effortDone || !sleepWearDone + data class LegacyScoreSnapshot( val avgHrv: Double, val recovery: Double?, @@ -32,6 +35,28 @@ internal object IntelligencePersistence { val markerSourceIds: List, ) + /** Keep the per-day repair loop outside the instrumented scoring coroutine's bytecode budget. */ + suspend fun persistComputedWindow(repo: WhoopRepository, window: ComputedWindow, preserveUnscoredHistory: Boolean) { + val windows = if (preserveUnscoredHistory) byScoredDay(window) else listOf(window) + for (part in windows) part.persistInto(repo) + } + + private suspend fun ComputedWindow.persistInto(repo: WhoopRepository) = + repo.replaceComputedScoreWindow(this) + + /** Per-day repair writes leave older, unscorable cached days and their provenance intact. */ + fun byScoredDay(window: ComputedWindow): List { + val dailiesByDay = window.dailies.groupBy { it.day } + val metricsByDay = window.metricRows.groupBy { it.day } + val provenanceByDay = window.provenance.groupBy { it.day } + return dailiesByDay.keys.sorted().map { day -> + window.copy(from = day, to = day, + dailies = dailiesByDay.getValue(day), + metricRows = metricsByDay[day].orEmpty(), + provenance = provenanceByDay[day].orEmpty()) + } + } + suspend fun prepareComputedWindow( repo: WhoopRepository, importedDeviceId: String, diff --git a/android/app/src/main/java/com/noop/analytics/WristWearRecovery.kt b/android/app/src/main/java/com/noop/analytics/WristWearRecovery.kt new file mode 100644 index 0000000000..6503d28546 --- /dev/null +++ b/android/app/src/main/java/com/noop/analytics/WristWearRecovery.kt @@ -0,0 +1,34 @@ +package com.noop.analytics + +import com.noop.data.HrSample + +/** + * Reconcile a missing WRIST_ON event using five minutes of valid HR with no gap over five seconds. + * Only an unpaired OFF tail uses this evidence; explicit OFF/ON pairs and sleep/HR-gap gates remain. + * Returns the first observed sample of the confirmed run. Mirrors Swift WristWearRecovery. + */ +object WristWearRecovery { + const val confirmationSeconds = 5L * 60 + const val maximumGapSeconds = 5L + + fun firstSustainedHR(hr: List, after: Long, before: Long): Long? { + // Invalid wins conflicting duplicate timestamps, independent of input order. + val validByTimestamp = HashMap() + for (sample in hr) { + if (sample.ts <= after || sample.ts >= before) continue + validByTimestamp[sample.ts] = (validByTimestamp[sample.ts] ?: true) && sample.bpm in 30..220 + } + var start: Long? = null + var previous: Long? = null + for (ts in validByTimestamp.keys.sorted()) { + if (validByTimestamp[ts] != true) { + start = null; previous = null + continue + } + if (previous == null || ts - previous > maximumGapSeconds) start = ts + previous = ts + if (start != null && ts - start >= confirmationSeconds) return start + } + return null + } +} diff --git a/android/app/src/main/java/com/noop/ui/AppViewModel.kt b/android/app/src/main/java/com/noop/ui/AppViewModel.kt index ef3792174f..c4e636c545 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -13,6 +13,7 @@ import com.noop.analytics.Baselines import com.noop.analytics.IllnessSignalEngine import com.noop.analytics.IllnessWatch import com.noop.analytics.IntelligenceEngine +import com.noop.analytics.IntelligencePersistence import com.noop.analytics.DayCycleIntelligenceIntegration import com.noop.analytics.CircadianEngine import com.noop.analytics.V5HealthSignals @@ -1144,20 +1145,30 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { NoopPrefs.setTsHealPending(appContext, false) } }.onFailure { if (it is kotlin.coroutines.cancellation.CancellationException) throw it } - // One-shot on-upgrade Effort rescore (#313): recompute strain from source across the FULL - // history once, so any deep-history rows an older build left on the 0–21 axis regenerate on - // the 0–100 axis. Guarded by a persisted flag, so it's a no-op on every subsequent launch. + // One-shot shared Effort and sleep-wear repair: recompute strain from source across the FULL + // history and replay pre-fix sleep once. Either pending flag triggers one pass; both flags + // are set only after persistence returns. Cached-only days are preserved, changing Effort's + // old broad-window write behavior. Cancellation/failure retries next launch. runCatching { IntelligenceEngine.runEffortRescoreIfNeeded( repo = repository, profile = currentProfile(), importedDeviceId = deviceId, maxHROverride = profileStore.hrMaxOverride.takeIf { it > 0 }?.toDouble(), - flagGet = { NoopPrefs.effortRescoreDone(appContext) }, - flagSet = { NoopPrefs.setEffortRescoreDone(appContext) }, + flagGet = { + !IntelligencePersistence.historyRepairIsPending( + effortDone = NoopPrefs.effortRescoreDone(appContext), + sleepWearDone = NoopPrefs.sleepWearRescoreDone(appContext), + ) + }, + flagSet = { + NoopPrefs.setEffortRescoreDone(appContext) + NoopPrefs.setSleepWearRescoreDone(appContext) + }, // #1567: this rewrites the FULL history once, so a missing owner source would bake the // WHOOP5 skin-temp scale into every day of it. ownerSource = RegistryDayOwnerSource(noopApp.deviceRegistry), + preserveUnscoredHistory = true, ) }.onFailure { if (it is kotlin.coroutines.cancellation.CancellationException) throw it } while (isActive) { diff --git a/android/app/src/main/java/com/noop/ui/MainActivity.kt b/android/app/src/main/java/com/noop/ui/MainActivity.kt index ba4d978eff..58b0e5d468 100644 --- a/android/app/src/main/java/com/noop/ui/MainActivity.kt +++ b/android/app/src/main/java/com/noop/ui/MainActivity.kt @@ -1412,6 +1412,16 @@ object NoopPrefs { of(context).edit().putBoolean(KEY_EFFORT_RESCORE_DONE, true).apply() } + /** Full-history sleep wear repair is marked only after the source rescore returns successfully. */ + const val KEY_SLEEP_WEAR_RESCORE_DONE = "intelligence.sleepWearRescore.v1.done" + + fun sleepWearRescoreDone(context: Context): Boolean = + of(context).getBoolean(KEY_SLEEP_WEAR_RESCORE_DONE, false) + + fun setSleepWearRescoreDone(context: Context) { + of(context).edit().putBoolean(KEY_SLEEP_WEAR_RESCORE_DONE, true).apply() + } + /** Whether the one-shot #547 implausible-timestamp heal has run. Set true once it completes so the * on-upgrade purge of bad-strap-clock rows (far-past / future-dated) never re-runs. Re-running is * harmless (the deletes are idempotent), but the flag avoids the work on every launch. */ diff --git a/android/app/src/test/java/com/noop/analytics/IntelligenceEngineJacocoBudgetTest.kt b/android/app/src/test/java/com/noop/analytics/IntelligenceEngineJacocoBudgetTest.kt index 6e74d97fa3..f8d5cc11a6 100644 --- a/android/app/src/test/java/com/noop/analytics/IntelligenceEngineJacocoBudgetTest.kt +++ b/android/app/src/test/java/com/noop/analytics/IntelligenceEngineJacocoBudgetTest.kt @@ -69,7 +69,7 @@ class IntelligenceEngineJacocoBudgetTest { val calls = nameUses.filter { it != nameOffsetInDeclaration } assertEquals("Expected exactly one call to $helperName", 1, calls.size) - val replace = requireExactlyOne(code, Regex("""\brepo\s*\.\s*replaceComputedScoreWindow\s*\(""")) + val replace = requireExactlyOne(code, Regex("""\bIntelligencePersistence\s*\.\s*persistComputedWindow\s*\(""")) val dismissed = requireExactlyOne(code, Regex("""\bDismissedSleepGuard\s*\.\s*keeping\s*\(""")) assertTrue("$helperName must run after replaceComputedScoreWindow", calls.single() > replace) assertTrue("$helperName must run before DismissedSleepGuard.keeping", calls.single() < dismissed) diff --git a/android/app/src/test/java/com/noop/analytics/SleepWearHistoryWindowTest.kt b/android/app/src/test/java/com/noop/analytics/SleepWearHistoryWindowTest.kt new file mode 100644 index 0000000000..8f29dad726 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/SleepWearHistoryWindowTest.kt @@ -0,0 +1,39 @@ +package com.noop.analytics + +import com.noop.data.DailyMetric +import com.noop.data.MetricSeriesRow +import com.noop.data.ScoreInputProvenanceRow +import org.junit.Assert.* +import org.junit.Test + +class SleepWearHistoryWindowTest { + @Test fun eitherPendingFlagSchedulesTheSharedRepairOnce() { + assertTrue(IntelligencePersistence.historyRepairIsPending(effortDone = false, sleepWearDone = false)) + assertTrue(IntelligencePersistence.historyRepairIsPending(effortDone = true, sleepWearDone = false)) + assertTrue(IntelligencePersistence.historyRepairIsPending(effortDone = false, sleepWearDone = true)) + assertFalse(IntelligencePersistence.historyRepairIsPending(effortDone = true, sleepWearDone = true)) + } + + @Test fun repairNeverWidensWritesAcrossUnscoredHistory() { + val source = "test-noop" + val days = listOf("2024-02-01", "2024-04-01") + val window = IntelligencePersistence.ComputedWindow( + deviceId = source, from = "2020-01-01", to = "2026-01-01", + dailies = days.reversed().map { DailyMetric(source, it, totalSleepMin = 400.0) }, + metricRows = (days + "2024-03-01").map { MetricSeriesRow(source, it, "sleep_performance", 80.0) }, + provenance = days.map { ScoreInputProvenanceRow(source, it, "recovery", "test") }, + markerSourceIds = listOf(source, "test"), + ) + val writes = IntelligencePersistence.byScoredDay(window) + assertEquals(days, writes.map { it.from }) + for (write in writes) { + assertEquals(write.from, write.to) + assertTrue(write.dailies.all { it.day == write.from }) + assertTrue(write.metricRows.all { it.day == write.from }) + assertTrue(write.provenance.all { it.day == write.from }) + assertEquals(window.markerSourceIds, write.markerSourceIds) + } + assertTrue(writes.none { "2024-03-01" in it.from..it.to }) + assertTrue(IntelligencePersistence.byScoredDay(window.copy(dailies = emptyList())).isEmpty()) + } +} diff --git a/android/app/src/test/java/com/noop/analytics/WristWearRecoveryTest.kt b/android/app/src/test/java/com/noop/analytics/WristWearRecoveryTest.kt new file mode 100644 index 0000000000..6938cf1895 --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/WristWearRecoveryTest.kt @@ -0,0 +1,87 @@ +package com.noop.analytics + +import com.noop.data.EventRow +import com.noop.data.GravitySample +import com.noop.data.HrSample +import org.junit.Assert.* +import org.junit.Test + +class WristWearRecoveryTest { + private fun hr(from: Long, to: Long, step: Long = 1, bpm: Int = 60) = + (from..to step step).map { HrSample("test", it, bpm) } + private fun event(ts: Long, off: Boolean) = + EventRow("test", ts, if (off) "WRIST_OFF(10)" else "WRIST_ON(9)", "{}") + private fun spans(events: List, samples: List, end: Long = 4000) = + AnalyticsEngine.offWristIntervals(events, end, samples).map { "${it.first}:${it.second}" } + + @Test fun missingOnEndsAtStartOfSustainedHR() { + assertEquals(listOf("100:1000"), spans(listOf(event(100, true)), hr(1000, 1600))) + assertEquals(listOf("100:4000"), spans(listOf(event(100, true)), emptyList())) + assertEquals(emptyList(), spans(emptyList(), hr(1000, 1600))) + } + @Test fun pairedEventsStayAuthoritativeEvenWithDenseHR() { + assertEquals(listOf("100:3000"), spans(listOf(event(100, true), event(3000, false)), hr(1000, 3500))) + } + @Test fun repeatedOffRestartsEvidenceAndPreservesEarlierPairs() { + val events = listOf(event(2500, true), event(100, true), event(2000, true), event(500, false)) + assertEquals(listOf("100:500", "2000:2501"), spans(events, hr(2100, 2900))) + assertEquals(listOf("100:500", "2000:4000"), spans(events, hr(2100, 2700))) + } + @Test fun futureSamplesAndEventsCannotCloseCurrentTail() { + assertEquals(listOf("100:4000"), spans(listOf(event(100, true), event(5000, false)), hr(4100, 4500))) + assertEquals(emptyList(), spans(listOf(event(5000, true)), hr(1000, 1600))) + } + @Test fun fiveMinuteConfirmationAndFiveSecondGapBoundaries() { + assertEquals(1000L, WristWearRecovery.firstSustainedHR(hr(1000, 1300, step = 5), 0, 2000)) + assertNull(WristWearRecovery.firstSustainedHR(hr(1000, 1299), 0, 2000)) + assertNull(WristWearRecovery.firstSustainedHR(hr(1000, 1600, step = 6), 0, 2000)) + assertNull(WristWearRecovery.firstSustainedHR(hr(1000, 1300), 1000, 2000)) + assertNull(WristWearRecovery.firstSustainedHR(hr(1000, 1300), 0, 1300)) + } + @Test fun gapsAndInvalidReadingsResetConfirmation() { + assertEquals(1200L, WristWearRecovery.firstSustainedHR(hr(1000, 1150) + hr(1200, 1500), 0, 2000)) + for (invalid in listOf(0, 29, 221, 255)) { + val samples = hr(1000, 1199) + HrSample("test", 1200, invalid) + hr(1201, 1600) + assertEquals(1201L, WristWearRecovery.firstSustainedHR(samples, 0, 2000)) + } + } + @Test fun duplicatesCannotManufactureCoverageAndInvalidWinsConflict() { + assertNull(WristWearRecovery.firstSustainedHR(List(1000) { HrSample("test", 1000, 60) }, 0, 2000)) + val samples = hr(1000, 1600) + HrSample("test", 1200, 0) + for (rows in listOf(samples, samples.reversed())) { + assertEquals(1201L, WristWearRecovery.firstSustainedHR(rows, 0, 2000)) + } + } + @Test fun recoveredNightMatchesControlAndPairedOffStillDropsIt() { + val start = 2 * 3600L; val end = start + 90 * 60 + val gravity = (start..end step 5).map { GravitySample("test", it, 0.0, 0.0, 1.0) } + val samples = hr(start - 900, end, step = 5, bpm = 50) + val control = SleepStager.detectSleep(hr = samples, gravity = gravity) + assertEquals(1, control.size) + val recovered = AnalyticsEngine.offWristIntervals(listOf(event(start - 1800, true)), end + 1, samples) + val actual = SleepStager.detectSleep(hr = samples, gravity = gravity, wristOff = recovered) + assertEquals(control.map { it.start }, actual.map { it.start }) + assertEquals(control.map { it.end }, actual.map { it.end }) + val paired = AnalyticsEngine.offWristIntervals(listOf(event(start - 1800, true), event(end, false)), end + 1, samples) + assertTrue(SleepStager.detectSleep(hr = samples, gravity = gravity, wristOff = paired).isEmpty()) + } + @Test fun recoveryDoesNotDisableSubsequentHRGapGuard() { + val samples = hr(100, 1000) + hr(8000, 9000) + val off = AnalyticsEngine.offWristIntervals(listOf(event(0, true)), 10000, samples) + val period = SleepStager.Period("sleep", 2000, 7000) + assertEquals(1.0, SleepStager.offWristFraction(period, samples, off), 0.0) + } + @Test fun swiftParityOracle() { + val values = mutableListOf() + for (offset in listOf(0L, 86400L, 1700000000L)) { + for (step in listOf(1L, 5L, 6L, 60L)) { + for (duration in listOf(299L, 300L, 600L)) { + val result = WristWearRecovery.firstSustainedHR(hr(offset + 100, offset + 100 + duration, step), offset, offset + 1000) + values.add(result?.toString() ?: "nil") + } + } + } + // Verbatim output from Swift WristWearRecoveryTests.testParityOracle. + assertEquals("nil,100,100,nil,100,100,nil,nil,nil,nil,nil,nil,nil,86500,86500,nil,86500,86500,nil,nil,nil,nil,nil,nil,nil,1700000100,1700000100,nil,1700000100,1700000100,nil,nil,nil,nil,nil,nil", values.joinToString(",")) + } +}