From f082b692d894f5a589ee37c03b2ce2be1b62c31c Mon Sep 17 00:00:00 2001 From: prasad Date: Mon, 21 Sep 2026 08:58:48 -0400 Subject: [PATCH 1/5] fix(sleep): recover unmatched off-wrist tails from sustained HR --- .../StrandAnalytics/AnalyticsEngine.swift | 18 ++- .../StrandAnalytics/WristWearRecovery.swift | 33 +++++ .../WristWearRecoveryTests.swift | 102 ++++++++++++++++ Strand/App/AppModel.swift | 2 + Strand/Data/IntelligenceEngine.swift | 83 ++++++++++--- StrandTests/SleepWearHistoryRepairTests.swift | 115 ++++++++++++++++++ .../com/noop/analytics/AnalyticsEngine.kt | 16 ++- .../com/noop/analytics/IntelligenceEngine.kt | 18 ++- .../noop/analytics/IntelligencePersistence.kt | 18 +++ .../com/noop/analytics/WristWearRecovery.kt | 34 ++++++ .../src/main/java/com/noop/ui/AppViewModel.kt | 11 +- .../src/main/java/com/noop/ui/MainActivity.kt | 10 ++ .../analytics/SleepWearHistoryWindowTest.kt | 32 +++++ .../noop/analytics/WristWearRecoveryTest.kt | 87 +++++++++++++ 14 files changed, 545 insertions(+), 34 deletions(-) create mode 100644 Packages/StrandAnalytics/Sources/StrandAnalytics/WristWearRecovery.swift create mode 100644 Packages/StrandAnalytics/Tests/StrandAnalyticsTests/WristWearRecoveryTests.swift create mode 100644 StrandTests/SleepWearHistoryRepairTests.swift create mode 100644 android/app/src/main/java/com/noop/analytics/WristWearRecovery.kt create mode 100644 android/app/src/test/java/com/noop/analytics/SleepWearHistoryWindowTest.kt create mode 100644 android/app/src/test/java/com/noop/analytics/WristWearRecoveryTest.kt diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index a30efa6c1b..56abe4fe63 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 20e3785a9f..4869aa52cb 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -498,6 +498,7 @@ final class AppModel: ObservableObject { // 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. await self.intelligence.runEffortRescoreIfNeeded() + await self.intelligence.runSleepWearRescoreIfNeeded() while !Task.isCancelled { // #547 RE-POLLUTION: a sync since the last tick may have armed a re-heal (its ingest gate // dropped bad-clock records). `runTimestampHealIfNeeded` honours the pending flag even after @@ -715,6 +716,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 f264cedb9b..112a01e3a0 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -673,6 +673,25 @@ final class IntelligenceEngine: ObservableObject { if !computing { UserDefaults.standard.set(true, forKey: Self.effortRescoreFlagKey) } } + /// 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. Pending survives a busy, failed or killed pass. + static let sleepWearRescoreFlagKey = "intelligence.sleepWearRescore.v1.done" + private var sleepWearRescoreRunning = false + + func runSleepWearRescoreIfNeeded(historyDays: Int = 4000) async { + guard !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.sleepWearRescoreFlagKey) + } + } + /// UserDefaults flag guarding the one-shot #547 implausible-timestamp DB heal (below). Set once the /// heal completes so it never re-runs. static let timestampHealFlagKey = "intelligence.timestampHeal.v547.done" @@ -732,7 +751,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 @@ -834,9 +854,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 @@ -863,7 +883,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) + } } } @@ -1334,7 +1358,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 @@ -2516,17 +2540,30 @@ 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 windows = preserveUnscoredHistory + ? Set(persistedDailies.map { $0.day }).sorted().map { ($0, $0) } + : [(oldestDay, newestDay)] + for (from, to) in windows { + try await store.persistComputedScores( + dailyMetrics: preserveUnscoredHistory + ? persistedDailies.filter { $0.day >= from && $0.day <= to } : persistedDailies, + metricPoints: preserveUnscoredHistory + ? restPoints.filter { $0.day >= from && $0.day <= to } : restPoints, + provenance: preserveUnscoredHistory + ? provenanceByCell.values.filter { $0.day >= from && $0.day <= to } : Array(provenanceByCell.values), + deviceId: computedId, from: from, to: to, + replaceMetricKeys: markerKeys, + additionalMetricPoints: preserveUnscoredHistory + ? markerPoints.filter { $0.point.day >= from && $0.point.day <= to } : 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 @@ -2539,7 +2576,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) { @@ -2821,7 +2858,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` @@ -2988,6 +3032,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..cdb04b61be --- /dev/null +++ b/StrandTests/SleepWearHistoryRepairTests.swift @@ -0,0 +1,115 @@ +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.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 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)) + 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)) + 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 6bffbf7359..c6100e486b 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 d1c2cda90d..97ea5d77c4 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -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 = false, ) { 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 @@ -1946,7 +1953,11 @@ object IntelligenceEngine { candidatePriorities, resolvedScoreOwnerByDay, IntelligencePersistence.LegacyScoreClock(nowLocalMidnight, nowSeconds, tzOffsetSeconds), out, ) - repo.replaceComputedScoreWindow(computedWindow) + if (preserveUnscoredHistoryForRun) { + IntelligencePersistence.persistComputedWindow(repo, computedWindow, true) + } else { + repo.replaceComputedScoreWindow(computedWindow) + } persistFitnessVitalityAndSteps( repo = repo, @@ -3061,6 +3072,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 @@ -3106,7 +3118,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..de20549582 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt @@ -32,6 +32,24 @@ 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 = + window.dailies.map { it.day }.distinct().sorted().map { day -> + window.copy(from = day, to = day, + dailies = window.dailies.filter { it.day == day }, + metricRows = window.metricRows.filter { it.day == day }, + provenance = window.provenance.filter { it.day == day }) + } + 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..612a150331 100644 --- a/android/app/src/main/java/com/noop/ui/AppViewModel.kt +++ b/android/app/src/main/java/com/noop/ui/AppViewModel.kt @@ -1146,18 +1146,23 @@ class AppViewModel(app: Application) : AndroidViewModel(app) { }.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. + // the 0–100 axis. Also replays pre-fix sleep after missing WRIST_ON events. Both flags + // are set only after the full pass returns; 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 = { NoopPrefs.effortRescoreDone(appContext) && 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 4a890a7792..87da4d993a 100644 --- a/android/app/src/main/java/com/noop/ui/MainActivity.kt +++ b/android/app/src/main/java/com/noop/ui/MainActivity.kt @@ -1424,6 +1424,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/SleepWearHistoryWindowTest.kt b/android/app/src/test/java/com/noop/analytics/SleepWearHistoryWindowTest.kt new file mode 100644 index 0000000000..6543c657dc --- /dev/null +++ b/android/app/src/test/java/com/noop/analytics/SleepWearHistoryWindowTest.kt @@ -0,0 +1,32 @@ +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 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(",")) + } +} From 7ddf21d19ab1a28139fa3e5fa445f6ed02eee5e4 Mon Sep 17 00:00:00 2001 From: prasad Date: Wed, 23 Sep 2026 09:18:01 -0400 Subject: [PATCH 2/5] fix(sleep): combine history repair passes --- Strand/App/AppModel.swift | 5 +- Strand/Data/IntelligenceEngine.swift | 46 ++++++++++++------- StrandTests/SleepWearHistoryRepairTests.swift | 12 ++++- .../com/noop/analytics/IntelligenceEngine.kt | 8 ++-- .../noop/analytics/IntelligencePersistence.kt | 17 +++++-- .../src/main/java/com/noop/ui/AppViewModel.kt | 16 +++++-- .../analytics/SleepWearHistoryWindowTest.kt | 7 +++ 7 files changed, 76 insertions(+), 35 deletions(-) diff --git a/Strand/App/AppModel.swift b/Strand/App/AppModel.swift index 4869aa52cb..1408eeeb5a 100644 --- a/Strand/App/AppModel.swift +++ b/Strand/App/AppModel.swift @@ -495,10 +495,9 @@ 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() - await self.intelligence.runSleepWearRescoreIfNeeded() while !Task.isCancelled { // #547 RE-POLLUTION: a sync since the last tick may have armed a re-heal (its ingest gate // dropped bad-clock records). `runTimestampHealIfNeeded` honours the pending flag even after diff --git a/Strand/Data/IntelligenceEngine.swift b/Strand/Data/IntelligenceEngine.swift index 112a01e3a0..83f899ac3c 100644 --- a/Strand/Data/IntelligenceEngine.swift +++ b/Strand/Data/IntelligenceEngine.swift @@ -647,8 +647,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 @@ -662,25 +661,33 @@ 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. Pending survives a busy, failed or killed pass. + /// 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 { - guard !UserDefaults.standard.bool(forKey: Self.sleepWearRescoreFlagKey), + 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. @@ -688,6 +695,7 @@ final class IntelligenceEngine: ObservableObject { 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) } } @@ -2543,21 +2551,25 @@ final class IntelligenceEngine: ObservableObject { 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 - ? Set(persistedDailies.map { $0.day }).sorted().map { ($0, $0) } + ? dailiesByDay.keys.sorted().map { ($0, $0) } : [(oldestDay, newestDay)] for (from, to) in windows { try await store.persistComputedScores( dailyMetrics: preserveUnscoredHistory - ? persistedDailies.filter { $0.day >= from && $0.day <= to } : persistedDailies, + ? dailiesByDay[from, default: []] : persistedDailies, metricPoints: preserveUnscoredHistory - ? restPoints.filter { $0.day >= from && $0.day <= to } : restPoints, + ? restPointsByDay[from, default: []] : restPoints, provenance: preserveUnscoredHistory - ? provenanceByCell.values.filter { $0.day >= from && $0.day <= to } : Array(provenanceByCell.values), + ? provenanceByDay[from, default: []] : Array(provenanceByCell.values), deviceId: computedId, from: from, to: to, replaceMetricKeys: markerKeys, additionalMetricPoints: preserveUnscoredHistory - ? markerPoints.filter { $0.point.day >= from && $0.point.day <= to } : markerPoints, + ? markerPointsByDay[from, default: []] : markerPoints, replaceMetricSourceIds: markerSources) } } catch { diff --git a/StrandTests/SleepWearHistoryRepairTests.swift b/StrandTests/SleepWearHistoryRepairTests.swift index cdb04b61be..8e41f20514 100644 --- a/StrandTests/SleepWearHistoryRepairTests.swift +++ b/StrandTests/SleepWearHistoryRepairTests.swift @@ -14,7 +14,8 @@ final class SleepWearHistoryRepairTests: XCTestCase { "profile.stepsCalibrationCoefficient", "profile.stepsCalibrationSampleDays", "profile.stepsCalibrationConfidence", "profile.stepsCalibrationManual", "profile.stepsManualCoefficient", "profile.stepsHasBankedMotion", - IntelligenceEngine.sleepWearRescoreFlagKey, "noop.analyzeWatermark", "analyzeRecent.stepsMotionCache.v1", + IntelligenceEngine.effortRescoreFlagKey, IntelligenceEngine.sleepWearRescoreFlagKey, + "noop.analyzeWatermark", "analyzeRecent.stepsMotionCache.v1", "noop.hrvBaselineEpoch", "noop.recoveryBaselineEpoch", UnitPrefs.hrvWindowKey, RescoreBackgroundScheduler.owedKey, RescoreBackgroundScheduler.owedTokenKey, RescoreBackgroundScheduler.lastPassSecondsKey, DayCycleMode.storageKey, @@ -34,6 +35,13 @@ final class SleepWearHistoryRepairTests: XCTestCase { 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() @@ -90,6 +98,7 @@ final class SleepWearHistoryRepairTests: XCTestCase { 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") } @@ -102,6 +111,7 @@ final class SleepWearHistoryRepairTests: XCTestCase { 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() 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 97ea5d77c4..1cf24d4c13 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() @@ -604,7 +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 = false, + preserveUnscoredHistory: Boolean = true, ) { if (flagGet()) return analyzeRecent( 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 de20549582..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?, @@ -42,13 +45,17 @@ internal object IntelligencePersistence { repo.replaceComputedScoreWindow(this) /** Per-day repair writes leave older, unscorable cached days and their provenance intact. */ - fun byScoredDay(window: ComputedWindow): List = - window.dailies.map { it.day }.distinct().sorted().map { day -> + 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 = window.dailies.filter { it.day == day }, - metricRows = window.metricRows.filter { it.day == day }, - provenance = window.provenance.filter { it.day == day }) + dailies = dailiesByDay.getValue(day), + metricRows = metricsByDay[day].orEmpty(), + provenance = provenanceByDay[day].orEmpty()) } + } suspend fun prepareComputedWindow( repo: WhoopRepository, 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 612a150331..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,17 +1145,22 @@ 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. Also replays pre-fix sleep after missing WRIST_ON events. Both flags - // are set only after the full pass returns; cancellation/failure retries next 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) && NoopPrefs.sleepWearRescoreDone(appContext) }, + flagGet = { + !IntelligencePersistence.historyRepairIsPending( + effortDone = NoopPrefs.effortRescoreDone(appContext), + sleepWearDone = NoopPrefs.sleepWearRescoreDone(appContext), + ) + }, flagSet = { NoopPrefs.setEffortRescoreDone(appContext) NoopPrefs.setSleepWearRescoreDone(appContext) diff --git a/android/app/src/test/java/com/noop/analytics/SleepWearHistoryWindowTest.kt b/android/app/src/test/java/com/noop/analytics/SleepWearHistoryWindowTest.kt index 6543c657dc..8f29dad726 100644 --- a/android/app/src/test/java/com/noop/analytics/SleepWearHistoryWindowTest.kt +++ b/android/app/src/test/java/com/noop/analytics/SleepWearHistoryWindowTest.kt @@ -7,6 +7,13 @@ 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") From c6f896ba94187e4ec770a3014305e753a763be44 Mon Sep 17 00:00:00 2001 From: prasad Date: Wed, 23 Sep 2026 09:40:17 -0400 Subject: [PATCH 3/5] fix(android): keep history repair under bytecode budget --- .../src/main/java/com/noop/analytics/IntelligenceEngine.kt | 6 +----- .../noop/analytics/IntelligenceEngineJacocoBudgetTest.kt | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) 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 1cf24d4c13..e2e787ae54 100644 --- a/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt @@ -1953,11 +1953,7 @@ object IntelligenceEngine { candidatePriorities, resolvedScoreOwnerByDay, IntelligencePersistence.LegacyScoreClock(nowLocalMidnight, nowSeconds, tzOffsetSeconds), out, ) - if (preserveUnscoredHistoryForRun) { - IntelligencePersistence.persistComputedWindow(repo, computedWindow, true) - } else { - repo.replaceComputedScoreWindow(computedWindow) - } + IntelligencePersistence.persistComputedWindow(repo, computedWindow, preserveUnscoredHistoryForRun) persistFitnessVitalityAndSteps( repo = repo, 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) From fc8202ad2a75a832cc4d3f5d781895acd36277c6 Mon Sep 17 00:00:00 2001 From: prasad Date: Mon, 28 Sep 2026 23:22:08 -0400 Subject: [PATCH 4/5] fix(parity): register sleep wear recovery twins --- .../StrandAnalytics/AnalyticsEngine.swift | 1 + .../StrandAnalytics/WristWearRecovery.swift | 1 + Tools/parity_dispositions.json | 63 +++++++++++++++++++ Tools/parity_twin_map.json | 14 ++--- .../com/noop/analytics/AnalyticsEngine.kt | 3 +- .../com/noop/analytics/WristWearRecovery.kt | 1 + 6 files changed, 75 insertions(+), 8 deletions(-) diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift index 56abe4fe63..b887fd41a9 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/AnalyticsEngine.swift @@ -21,6 +21,7 @@ public enum AnalyticsEngine { /// 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. + /// Kotlin twin: `AnalyticsEngine.offWristIntervals`. public static func offWristIntervals(events: [WhoopEvent], windowEnd: Int, hr: [HRSample] = []) -> [(start: Int, end: Int)] { let wear = events diff --git a/Packages/StrandAnalytics/Sources/StrandAnalytics/WristWearRecovery.swift b/Packages/StrandAnalytics/Sources/StrandAnalytics/WristWearRecovery.swift index 1b18afe763..325c0be2a9 100644 --- a/Packages/StrandAnalytics/Sources/StrandAnalytics/WristWearRecovery.swift +++ b/Packages/StrandAnalytics/Sources/StrandAnalytics/WristWearRecovery.swift @@ -10,6 +10,7 @@ public enum WristWearRecovery { public static let confirmationSeconds = 5 * 60 public static let maximumGapSeconds = 5 + /// Kotlin twin: `WristWearRecovery.firstSustainedHR`. 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] = [:] diff --git a/Tools/parity_dispositions.json b/Tools/parity_dispositions.json index 9d3e2e9825..76c82ab661 100644 --- a/Tools/parity_dispositions.json +++ b/Tools/parity_dispositions.json @@ -204,6 +204,69 @@ "identity_sha256": "c81cc7100101d3ac1d81e244330c66815450899d6194627ad8f0cbfeb48dee5f", "platform": "swift", "rationale": "Consumed only by the Apple OuraLiveSource screen-off live-HR suspend (Experimental \"All-day heart rate & HRV\"), to stand the daytime-HR hold down for the learned night instead of for every screen-off. Android's OuraLiveSource.kt has no screen-off suspend (#1546), so there is nothing for a Kotlin twin to gate; per review, Android stays without it until that suspend exists, at which point this becomes a twin." + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::analyzeRecent/27#1", + "identity_sha256": "3280203fe24402b51232335222f1d77bae103a6528be46084bf03b20f28f0213", + "platform": "kotlin", + "rationale": "The added preserveUnscoredHistory argument matches the role of analyzeRecent in the excluded Swift app layer. This records the new Kotlin orchestration signature, not a byte-identity claim.", + "twin_path": "Strand/Data/IntelligenceEngine.swift" + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::readDaySkinAndWristOff/12#1", + "identity_sha256": "165f046d3448f32d70497da9edc0fe6e9f34d683db81cc2c391e08fbe8212ddb", + "platform": "kotlin", + "rationale": "The additional HR input feeds offWristIntervals during Android stream preparation; Swift performs the corresponding read and wear reconciliation inline in the excluded app-layer analyzeRecent.", + "twin_path": "Strand/Data/IntelligenceEngine.swift" + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligenceEngine.kt::runEffortRescoreIfNeeded/9#1", + "identity_sha256": "0e028f551fdbdfa6a818412f13a977aec4f256e21dedc0db9980aba90285a38d", + "platform": "kotlin", + "rationale": "The shared repair delegates to the scorer with cached-only preservation. Swift runEffortRescoreIfNeeded/runHistoryRepairIfNeeded perform that role in the excluded app layer; signatures differ by platform dependencies.", + "twin_path": "Strand/Data/IntelligenceEngine.swift" + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt::byScoredDay/1#1", + "identity_sha256": "d82c9c2042de9c77810f6150fbb89b34cd36c4f4d7fae92cb7ee154b70bbe61a", + "platform": "kotlin", + "rationale": "Swift groups daily metrics, metric points and provenance by day inline in app-layer analyzeRecent. Kotlin extracts that persistence preparation as a test seam; this is a role twin, not a function parity claim.", + "twin_path": "Strand/Data/IntelligenceEngine.swift" + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt::historyRepairIsPending/2#1", + "identity_sha256": "b553cdaa03042d3839107a0526e2b89a6af987e0bd38e01cb883d961d004b3fd", + "platform": "kotlin", + "rationale": "Swift IntelligenceEngine.historyRepairIsPending has the same repair-flag gate in the excluded app layer; both have direct truth-table coverage.", + "twin_path": "Strand/Data/IntelligenceEngine.swift" + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt::persistComputedWindow/3#1", + "identity_sha256": "2985c2276b70812d00a4f21b31ecc7cc0f96091445a64d26e26a85bdef5bf600", + "platform": "kotlin", + "rationale": "Swift app-layer analyzeRecent performs the conditional per-day persistence loop inline. Kotlin extracts it to keep the instrumented scoring coroutine below the bytecode budget.", + "twin_path": "Strand/Data/IntelligenceEngine.swift" + }, + { + "type": "out_of_scope_twin", + "kind": "add-unpaired-function", + "identity": "kotlin\u0000android/app/src/main/java/com/noop/analytics/IntelligencePersistence.kt::persistInto/1#1", + "identity_sha256": "58d500dc560ff882ce71c532bdc52c92e2652ea6b08e1351e678f9b8b1c52b08", + "platform": "kotlin", + "rationale": "This Kotlin extension dispatches a computed window to repository persistence. The corresponding persistComputedScores dispatch is inline in the excluded Swift app-layer analyzeRecent; this records its architectural role.", + "twin_path": "Strand/Data/IntelligenceEngine.swift" } ] } diff --git a/Tools/parity_twin_map.json b/Tools/parity_twin_map.json index 42ba0a5d73..16383617ac 100644 --- a/Tools/parity_twin_map.json +++ b/Tools/parity_twin_map.json @@ -18,16 +18,16 @@ ] }, "authority": { - "files": {"count": 505, "sha256": "581ec0dcab0d8486ce113646cb5cbad71769353eb3aef75f45486e03591733a0"}, - "functions": {"count": 4509, "sha256": "ea93147013013ce1c99232e5a77093b38d321f231073456cb25c9cfe393de567"}, + "files": {"count": 507, "sha256": "efc6950759de76273adccde8b6457f5e8577bdaf6ed9c6538cde79b0eaf0c612"}, + "functions": {"count": 4515, "sha256": "7cf5850d7d94678a525c8fb936742af843106f728c7be12e1dfa7ba414ef1ae9"}, "properties": {"count": 465, "sha256": "64fcd18c85bf33a4738d154d45ec86967fdc974e78a448d16e9504d9b13609eb"}, - "constants": {"count": 1969, "sha256": "0cfd548d65ff82bdaa5860c8c63a0f8ac5bacbabc9a5abeb83773c1522147652"}, - "file_pairs": {"count": 76, "sha256": "453da3e71534978be500547a44b9add326631fa1920c8871b83e11457f336ade"}, - "function_pairs": {"count": 207, "sha256": "8fb4bad334ea308f0a6bf7e9b94b6001afbe7e2e7cc08b7bce7cf6edaf3c5cb0"}, + "constants": {"count": 1973, "sha256": "a6a79a02c8d0db218443a6b01a614ddcaaf078af54ee9e8ee3222bc31624edf7"}, + "file_pairs": {"count": 77, "sha256": "76fcf94bbfaa9af56adc8cbc4ec9cc301c08651d209ae8c910362f2f78fa7828"}, + "function_pairs": {"count": 209, "sha256": "8204f3a739a97a347467547557fa164eab5d43ba41b50e1a71bee3a93f86bf3d"}, "property_pairs": {"count": 150, "sha256": "be7168d309c8a9cf5f3f25438c028cc60ab64202bb6e9c09a6c553e3699ad0e2"}, - "constant_pairs": {"count": 685, "sha256": "a949b8f3ffc009b8054d7bc56f4131f7baf134b6f09823f323853d5cfb52c246"}, + "constant_pairs": {"count": 687, "sha256": "15266c829ddd529604cbe1c736f6c75857cba62499273159adfd2bca2d6304f6"}, "unpaired_files": {"count": 375, "sha256": "ba14f7aa2a3967ebc2aa21311917ae3c0c3db218186fac0a6d297f8969580f1a"}, - "unpaired_functions": {"count": 4108, "sha256": "7e83a1360a1a8dfe35ee8fefa988bf3ad5a8def29b94782dbff31d8a7d24cdc9"}, + "unpaired_functions": {"count": 4110, "sha256": "4978cf3cacf94a5e73e1d8b02840ecc19ece3f952bddeba204f349aa959fefbe"}, "unpaired_properties": {"count": 165, "sha256": "fe609dd25384cda6de1616d36753fed68431b1a1f9a73b6fd664c7ff35089955"}, "unpaired_constants": {"count": 599, "sha256": "b7467c65623ecc296604558a639d62eee2e00874c56d21acc7ec6d3b6264fdc7"} } 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 c6100e486b..e435d7c14e 100644 --- a/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt +++ b/android/app/src/main/java/com/noop/analytics/AnalyticsEngine.kt @@ -81,7 +81,8 @@ object AnalyticsEngine { * 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. 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. + * "WRIST_OFF(10)"), matched by prefix. Repeated OFFs/ONs without a partner are coalesced. + * Swift twin: `AnalyticsEngine.offWristIntervals`. */ fun offWristIntervals(events: List, windowEnd: Long, hr: List = emptyList()): List> { diff --git a/android/app/src/main/java/com/noop/analytics/WristWearRecovery.kt b/android/app/src/main/java/com/noop/analytics/WristWearRecovery.kt index 6503d28546..5f1d142ca4 100644 --- a/android/app/src/main/java/com/noop/analytics/WristWearRecovery.kt +++ b/android/app/src/main/java/com/noop/analytics/WristWearRecovery.kt @@ -11,6 +11,7 @@ object WristWearRecovery { const val confirmationSeconds = 5L * 60 const val maximumGapSeconds = 5L + /** Swift twin: `WristWearRecovery.firstSustainedHR`. */ fun firstSustainedHR(hr: List, after: Long, before: Long): Long? { // Invalid wins conflicting duplicate timestamps, independent of input order. val validByTimestamp = HashMap() From 39c9a82d29ce17031c3f774a06c924eb5b97b453 Mon Sep 17 00:00:00 2001 From: Fanboynz Date: Tue, 29 Sep 2026 18:24:21 +1300 Subject: [PATCH 5/5] test(parity): repin the R-R preservation contract to windowed persistence The contract pinned `dailyMetrics: persistedDailies` as a literal. Per-day windowing for preserveUnscoredHistory replaced that argument with a ternary, so the literal went missing and the test went red while the invariant it exists to protect was intact: both branches still read only the snapshot-protected copy. Pin both branches instead. That dailiesByDay groups from persistedDailies covers the per-day path, and the ternary's else covers the wide path. Mutation-checked in both directions: grouping from raw dailies, and writing raw dailies in the wide branch, each turn the test red. That is wider than the literal it replaces, which only ever pinned the wide branch. The assertNotIn over the derivations is untouched. That is the assertion keeping a legacy snapshot from masquerading as a value recalculated against today's baselines, and it never stopped passing. --- Tools/tests/test_rr_legacy_preservation_contract.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Tools/tests/test_rr_legacy_preservation_contract.py b/Tools/tests/test_rr_legacy_preservation_contract.py index 8e8be1d6a4..c45c836871 100644 --- a/Tools/tests/test_rr_legacy_preservation_contract.py +++ b/Tools/tests/test_rr_legacy_preservation_contract.py @@ -29,7 +29,10 @@ def test_swift_protects_only_the_persisted_and_displayed_copy(self) -> None: self.assertIn("respRateBpm: snapshot.respRateBpm", persistence) self.assertIn("avgSdnn: snapshot.avgSdnn", persistence) self.assertIn("for daily in persistedDailies", persistence) - self.assertIn("dailyMetrics: persistedDailies", persistence) + # Persistence may window the protected copy per day (preserveUnscoredHistory) or write it + # whole. Pin that BOTH branches still read only persistedDailies, not the raw `dailies`. + self.assertIn("let dailiesByDay = Dictionary(grouping: persistedDailies, by: \\.day)", persistence) + self.assertIn("? dailiesByDay[from, default: []] : persistedDailies", persistence) self.assertNotIn("dailies[index] = fresh.with(avgHrv:", persistence) derivations = source[persistence_end:]