diff --git a/Packages/WhoopProtocol/Sources/WhoopProtocol/Streams.swift b/Packages/WhoopProtocol/Sources/WhoopProtocol/Streams.swift index 4a8a30701d..6b83a63e41 100644 --- a/Packages/WhoopProtocol/Sources/WhoopProtocol/Streams.swift +++ b/Packages/WhoopProtocol/Sources/WhoopProtocol/Streams.swift @@ -57,8 +57,30 @@ public struct RRInterval: Equatable, Codable { /// that second, so the over-count is accumulation across offloads. WHOOP's wire format has no /// channel field, so `srcChannel` can never answer this for a strap — `ord` is what is left. public let ord: Int? - public init(ts: Int, rrMs: Int, srcChannel: RRSourceChannel? = nil, ord: Int? = nil) { - self.ts = ts; self.rrMs = rrMs; self.srcChannel = srcChannel; self.ord = ord + /// Storage identity for equal beats in the same second. Defaults to zero so wire decoders and + /// callers that predate the widened database key remain source- and behaviour-compatible. + public let seq: Int + public init(ts: Int, rrMs: Int, srcChannel: RRSourceChannel? = nil, ord: Int? = nil, seq: Int = 0) { + self.ts = ts; self.rrMs = rrMs; self.srcChannel = srcChannel; self.ord = ord; self.seq = seq + } + + private enum CodingKeys: String, CodingKey { case ts, rrMs, srcChannel, ord, seq } + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + ts = try c.decode(Int.self, forKey: .ts) + rrMs = try c.decode(Int.self, forKey: .rrMs) + srcChannel = try c.decodeIfPresent(RRSourceChannel.self, forKey: .srcChannel) + ord = try c.decodeIfPresent(Int.self, forKey: .ord) + seq = try c.decodeIfPresent(Int.self, forKey: .seq) ?? 0 + } + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(ts, forKey: .ts) + try c.encode(rrMs, forKey: .rrMs) + try c.encodeIfPresent(srcChannel, forKey: .srcChannel) + try c.encodeIfPresent(ord, forKey: .ord) + // Preserve the legacy encoded shape for the overwhelmingly common seq=0 row. + if seq != 0 { try c.encode(seq, forKey: .seq) } } } diff --git a/Packages/WhoopStore/Sources/WhoopStore/Reads.swift b/Packages/WhoopStore/Sources/WhoopStore/Reads.swift index 73a3971387..68341f9b08 100644 --- a/Packages/WhoopStore/Sources/WhoopStore/Reads.swift +++ b/Packages/WhoopStore/Sources/WhoopStore/Reads.swift @@ -216,7 +216,7 @@ extension WhoopStore { public func rrIntervals(deviceId: String, from: Int, to: Int, limit: Int) async throws -> [RRInterval] { try syncRead { db in try Row.fetchAll(db, sql: """ - SELECT ts, rrMs, srcChannel, ord FROM rrInterval + SELECT ts, rrMs, srcChannel, ord, seq FROM rrInterval WHERE deviceId = ? AND ts >= ? AND ts <= ? AND (srcChannel IS NULL OR srcChannel <> ?) AND (tsSuspect IS NULL OR tsSuspect <> 1) -- #1073: exclude future-stamped beats @@ -225,7 +225,7 @@ extension WhoopStore { .map { row in RRInterval(ts: row["ts"], rrMs: row["rrMs"], srcChannel: (row["srcChannel"] as Int?).flatMap(RRSourceChannel.init(rawValue:)), - ord: row["ord"] as Int?) + ord: row["ord"] as Int?, seq: row["seq"]) } } } diff --git a/Strand/AI/AICoach.swift b/Strand/AI/AICoach.swift index 0677802d8d..afc490242e 100644 --- a/Strand/AI/AICoach.swift +++ b/Strand/AI/AICoach.swift @@ -553,7 +553,7 @@ final class AICoachEngine: ObservableObject { } /// One derived stress line for the coach context: the Baevsky Stress Index over TODAY's R-R, read - /// via the store exactly as `StressView` does (`storeHandle()` → `rrIntervals(deviceId:from:to:)`), + /// via the same device-aware repository R-R union as `StressView`, /// then summarised to a single number with `StressIndex.stressIndex(rr:)`. Returns nil when the /// store is unavailable or there are too few clean beats (the histogram needs >= 20), so the line is /// simply absent, never a fabricated value. Summary-only: the raw R-R never leaves the device. @@ -561,9 +561,7 @@ final class AICoachEngine: ObservableObject { let cal = Calendar.current let from = Int(cal.startOfDay(for: Date()).timeIntervalSince1970) let to = Int(Date().timeIntervalSince1970) - guard let store = await repo.storeHandle() else { return nil } - let rr = (try? await store.rrIntervals( - deviceId: repo.deviceId, from: from, to: to, limit: 200_000)) ?? [] + let rr = await repo.rrIntervals(from: from, to: to, limit: 200_000) guard let si = StressIndex.stressIndex(rr: rr) else { return nil } return Self.stressIndexSummary(si: si) } diff --git a/Strand/Data/Repository.swift b/Strand/Data/Repository.swift index 5bd913a6b3..07076c60fd 100644 --- a/Strand/Data/Repository.swift +++ b/Strand/Data/Repository.swift @@ -266,6 +266,34 @@ final class Repository: ObservableObject { // MARK: - Union reads (active strap + canonical) // + /// Raw physiological streams are a worn timeline, not a property of whichever strap is active now. + /// Resolve every registered physical WHOOP, active first, and keep the canonical import namespace as + /// the final fallback. Archived devices intentionally remain: archive means "stop connecting, keep + /// data", and historical timelines must not orphan their retained samples. The active id remains first + /// even for a non-WHOOP provider, preserving the pre-multi-strap cross-provider path. + private func rawPhysiologyReadIds(store: WhoopStore) -> [String] { + let paired = (try? DeviceRegistryStore(dbQueue: store.registryWriter).all()) ?? [] + let registeredWhoops = paired.filter { + $0.brand.caseInsensitiveCompare("WHOOP") == .orderedSame + }.map(\.id) + return Self.rawWhoopSourceIds(activeDeviceId: deviceId, registeredWhoopIds: registeredWhoops) + } + + /// Pure ordering contract shared with Android's parity guard: current active source first, every other + /// registered WHOOP in stable registry order, canonical history last; duplicates collapse. + nonisolated static func rawWhoopSourceIds(activeDeviceId: String, + registeredWhoopIds: [String]) -> [String] { + let canonical = Self.whoopSource + let middle = registeredWhoopIds.filter { $0 != activeDeviceId && $0 != canonical } + return ([activeDeviceId] + middle + [canonical]).reduce(into: [String]()) { + if !$0.contains($1) { $0.append($1) } + } + } + + private func rawComputedReadIds(store: WhoopStore) -> [String] { + rawPhysiologyReadIds(store: store).map { $0.hasSuffix("-noop") ? $0 : $0 + "-noop" } + } + // Each helper reads the SAME store query across `importedReadIds` (active strap + canonical "my-whoop") // and concatenates the rows, ACTIVE STRAP FIRST. The callers feed the concatenated rows into the SAME // per-day dedup they already used (mergeDaily/mergeSleep key by day; the daily/series facades dedup @@ -298,7 +326,7 @@ final class Repository: ObservableObject { func gravitySamplesUnion(from: Int, to: Int, limit: Int = 200_000) async -> [GravitySample] { guard let store = await storeHandle() else { return [] } var lists: [[GravitySample]] = [] - for id in importedReadIds { // active strap FIRST → it wins any shared timestamp + for id in rawPhysiologyReadIds(store: store) { // active strap FIRST → it wins any shared timestamp lists.append((try? await store.gravitySamples(deviceId: id, from: from, to: to, limit: limit)) ?? []) } return Self.mergeGravityByTs(lists) @@ -319,6 +347,27 @@ final class Repository: ObservableObject { return byTs.values.sorted { $0.ts < $1.ts } } + /// Merge R-R reads without treating a timestamp as a beat identity. Several real beats can share a + /// one-second timestamp; only the complete storage identity `(ts, rrMs, seq)` is a duplicate across + /// namespaces. The first list is the active strap and therefore wins exact duplicates. + nonisolated static func mergeRRByIdentity(_ lists: [[RRInterval]]) -> [RRInterval] { + if lists.count == 1 { return lists[0] } + struct BeatKey: Hashable { let ts: Int; let rrMs: Int; let seq: Int } + var seen = Set() + var out: [RRInterval] = [] + for list in lists { + for beat in list where seen.insert(BeatKey(ts: beat.ts, rrMs: beat.rrMs, seq: beat.seq)).inserted { + out.append(beat) + } + } + return out.sorted { + if $0.ts != $1.ts { return $0.ts < $1.ts } + if $0.seq != $1.seq { return $0.seq < $1.seq } + if $0.rrMs != $1.rrMs { return $0.rrMs < $1.rrMs } + return ($0.ord ?? Int.min) < ($1.ord ?? Int.min) + } + } + /// One day held by two source ids in the SAME bucket, folded into `winner`'s row: `winner` keeps every /// column it carries (NON-nil — a measured zero is a reading) and `filler` supplies only the ones it /// left nil. Whole-row first-wins let a hollow row (steps and nothing else) discard a complete one, and @@ -370,11 +419,11 @@ final class Repository: ObservableObject { return byDay.values.sorted { $0.day < $1.day } } - /// Sleep sessions across the imported union for a ts range, keeping ALL sessions per day (a nap + a main + /// Sleep sessions across every registered WHOOP source for a ts range, keeping ALL sessions per day (a nap + a main /// night both survive) and dropping only EXACT-duplicate blocks (same start+end) recorded under both /// union ids. The downstream `mergeSleep`/`userEditedDays` do the per-day collapse, exactly as before. private func unionSleepSessions(store: WhoopStore, from: Int, to: Int, limit: Int = 4000) async -> [CachedSleepSession] { - Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: importedReadIds, from: from, to: to, limit: limit)) + Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: rawPhysiologyReadIds(store: store), from: from, to: to, limit: limit)) } /// Computed ("-noop") daily-metric rows across the computed union, DEDUPED per day (active strap's @@ -389,10 +438,10 @@ final class Repository: ObservableObject { return byDay.values.sorted { $0.day < $1.day } } - /// Computed ("-noop") sleep sessions across the computed union, keeping ALL sessions per day and dropping + /// Computed ("-noop") sleep sessions across every registered WHOOP source, keeping ALL sessions per day and dropping /// only EXACT-duplicate blocks recorded under both computed siblings. private func unionComputedSleepSessions(store: WhoopStore, from: Int, to: Int, limit: Int = 4000) async -> [CachedSleepSession] { - Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: computedReadIds, from: from, to: to, limit: limit)) + Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: rawComputedReadIds(store: store), from: from, to: to, limit: limit)) } /// ALL sleep blocks across `ids` for a ts range, concatenated (NOT collapsed to one per day, used by @@ -1060,11 +1109,12 @@ final class Repository: ObservableObject { // UNION the active strap + canonical so the HR trend renders whether the landed day's raw sits under // the re-added strap (live) or the canonical history. Deduped by ts (active strap wins) so an overlap // never double-counts; sorted ascending. Single-device install reads one id (byte-identical). - guard deviceId != canonicalDeviceId else { + let ids = rawPhysiologyReadIds(store: store) + guard ids.count != 1 else { return (try? await store.hrSamples(deviceId: deviceId, from: from, to: to, limit: limit)) ?? [] } var byTs: [Int: HRSample] = [:] - for id in importedReadIds { + for id in ids { for s in (try? await store.hrSamples(deviceId: id, from: from, to: to, limit: limit)) ?? [] where byTs[s.ts] == nil { byTs[s.ts] = s } @@ -1072,6 +1122,21 @@ final class Repository: ObservableObject { return byTs.values.sorted { $0.ts < $1.ts } } + /// R-R beats across the active physical WHOOP and canonical history. Exact duplicates are removed + /// active-first, while same-timestamp distinct beats remain intact. + func rrIntervals(from: Int, to: Int, limit: Int = 8000) async -> [RRInterval] { + guard let store = await ensureStore() else { return [] } + let ids = rawPhysiologyReadIds(store: store) + guard ids.count != 1 else { + return (try? await store.rrIntervals(deviceId: deviceId, from: from, to: to, limit: limit)) ?? [] + } + var lists: [[RRInterval]] = [] + for id in ids { + lists.append((try? await store.rrIntervals(deviceId: id, from: from, to: to, limit: limit)) ?? []) + } + return Self.mergeRRByIdentity(lists) + } + /// Logical day-start of the most recent day the active device has HR data for, or nil when the store is /// empty. Lets the Deep Timeline open on a day that actually has data instead of a possibly-empty today /// right after a history sync , the #597 root cause (the timeline was today-only with no way back). @@ -1103,19 +1168,11 @@ final class Repository: ObservableObject { func hrBuckets(from: Int, to: Int, bucketSeconds: Int = 300) async -> [HRBucket] { guard let store = await ensureStore() else { return [] } - // UNION the active strap + canonical for the trend chart. Per bucket-start the active strap wins; a + // UNION all registered WHOOP straps + canonical for the trend chart. Per bucket-start the active strap wins; a // raw HR window almost never overlaps between the two namespaces (different time periods), so the // per-bucket mean stays faithful. Single-device install reads one id (byte-identical). - guard deviceId != canonicalDeviceId else { - return (try? await store.hrBuckets(deviceId: deviceId, from: from, to: to, bucketSeconds: bucketSeconds)) ?? [] - } - var byStart: [Int: HRBucket] = [:] - for id in importedReadIds { - for b in (try? await store.hrBuckets(deviceId: id, from: from, to: to, bucketSeconds: bucketSeconds)) ?? [] where byStart[b.ts] == nil { - byStart[b.ts] = b - } - } - return byStart.values.sorted { $0.ts < $1.ts } + return await hrBuckets(deviceIds: rawPhysiologyReadIds(store: store), from: from, to: to, + bucketSeconds: bucketSeconds) } /// The latest (greatest-ts) non-nil @63 activity class over `[from, to]`, read across the active strap + @@ -1199,11 +1256,13 @@ final class Repository: ObservableObject { guard let store = await ensureStore() else { return [] } let now = Int(Date().timeIntervalSince1970) let lo = now - days * 86_400, hi = now + 86_400 + let rawIds = rawPhysiologyReadIds(store: store) + let rawComputedIds = rawComputedReadIds(store: store) // UNION the active strap + canonical (imported) and their computed siblings, keeping ALL blocks (not // one per day, this view expands split sleeps), but dropping any block that appears under BOTH union // ids (same start+end key) so a day present in both namespaces isn't double-listed. - let imported = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: importedReadIds, from: lo, to: hi)) - let computed = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: computedReadIds, from: lo, to: hi)) + let imported = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: rawIds, from: lo, to: hi)) + let computed = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: rawComputedIds, from: lo, to: hi)) let cal = Calendar.current func endDay(_ s: CachedSleepSession) -> Date { cal.startOfDay(for: Date(timeIntervalSince1970: TimeInterval(s.endTs))) @@ -1221,12 +1280,36 @@ final class Repository: ObservableObject { /// already chosen the main-night GROUP (the 6.1.1 bridged group) and passes those blocks' starts; we /// only fetch each one's stored series so the Sleep tab can lay them along the hypnogram's timeline. /// A start with no stored series is omitted from the result (its key is absent). - func sessionMotions(starts: [Int]) async -> [Int: [Double]] { - guard !starts.isEmpty, let store = await ensureStore() else { return [:] } - // One batched read keyed by startTs, not a single-row SELECT per session start. The store's - // batched accessor keeps the exact contract of the old loop: starts with no (or an empty) series - // are omitted from the result. - return (try? await store.sessionMotions(deviceId: computedDeviceId, sessionStarts: starts)) ?? [:] + func sessionMotions(sessions: [CachedSleepSession]) async -> [Int: [Double]] { + guard !sessions.isEmpty, let store = await ensureStore() else { return [:] } + let rawIds = rawPhysiologyReadIds(store: store) + let computedIds = rawComputedReadIds(store: store) + var out: [Int: [Double]] = [:] + for session in sessions where out[session.startTs] == nil { + // CachedSleepSession has no provenance field. Re-resolve the exact visible block using the + // same imported-wins order as allSleepSessions, then probe its computed owner first. + var owner: String? + for id in rawIds + computedIds { + let rows = (try? await store.sleepSessions(deviceId: id, from: session.startTs, + to: session.startTs, limit: 4)) ?? [] + if rows.contains(where: { $0.startTs == session.startTs && $0.endTs == session.endTs }) { + owner = id + break + } + } + let ownerComputed = owner.map { $0.hasSuffix("-noop") ? $0 : $0 + "-noop" } + let sources = ([ownerComputed].compactMap { $0 } + computedIds).reduce(into: [String]()) { + if !$0.contains($1) { $0.append($1) } + } + for id in sources { + if let motion = (try? await store.sessionMotion(deviceId: id, sessionStart: session.startTs)) ?? nil, + !motion.isEmpty { + out[session.startTs] = motion + break + } + } + } + return out } /// The user's learned habitual midsleep (local time-of-day seconds), or nil under @@ -1243,10 +1326,12 @@ final class Repository: ObservableObject { guard let store = await ensureStore() else { return nil } let now = Int(Date().timeIntervalSince1970) let lo = now - days * 86_400, hi = now + 86_400 - // UNION active strap + canonical (imported) and their computed siblings, de-duplicating identical - // blocks recorded under both ids so a day present in both namespaces doesn't double-weight the learner. - let imported = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: importedReadIds, from: lo, to: hi)) - let computed = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: computedReadIds, from: lo, to: hi)) + // Use the SAME all-registered-WHOOP source set as allSleepSessions. Otherwise a removed/replaced + // strap's visible retained nights would not teach the selector that chooses their main block. + let rawIds = rawPhysiologyReadIds(store: store) + let rawComputedIds = rawComputedReadIds(store: store) + let imported = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: rawIds, from: lo, to: hi)) + let computed = Self.dedupBlocks(await unionRawSleepBlocks(store: store, ids: rawComputedIds, from: lo, to: hi)) let offsetSec = TimeZone.current.secondsFromGMT() let blocks = (imported + computed).compactMap { s -> SleepStageTotals.HistoryBlock? in let start = s.effectiveStartTs, end = s.endTs @@ -1720,11 +1805,9 @@ final class Repository: ObservableObject { func timelineSeries(metric: TimelineMetric, from: Int, to: Int, targetPoints: Int = 600, source: String? = nil) async -> TimelineSeries { guard to > from, let store = await ensureStore() else { return .empty } - // Default (no explicit source) → the user's own strap, UNIONed with the canonical "my-whoop" so the - // Deep Timeline renders whether the landed day's raw sits under the re-added strap or the canonical - // history. An explicit source (a per-source page) reads that source verbatim. `unionIds` is one id - // on a single-device install (byte-identical) and dedups raw by ts (active strap wins). - let unionIds: [String] = source.map { [$0] } ?? importedReadIds + // Default (no explicit source) → the complete worn WHOOP timeline: active, every registered prior + // strap, then canonical history. An explicit per-source page still reads that source verbatim. + let unionIds: [String] = source.map { [$0] } ?? rawPhysiologyReadIds(store: store) let bucket = Self.timelineBucketSeconds(spanSeconds: to - from, targetPoints: targetPoints) let isRaw = bucket <= 1 diff --git a/Strand/Liquid/LiquidTodayView.swift b/Strand/Liquid/LiquidTodayView.swift index 855f678e66..c8607dd3df 100644 --- a/Strand/Liquid/LiquidTodayView.swift +++ b/Strand/Liquid/LiquidTodayView.swift @@ -1593,7 +1593,7 @@ struct LiquidTodayView: View { if HostedCardPrefs.decodeEnabled(hostedCardsRaw).contains(where: { $0.origin == sleepOrigin }) { let hostedSessions = await repo.allSleepSessions() let hostedHabitual = await repo.habitualMidsleepSec() - let hostedMotion = await repo.sessionMotions(starts: hostedSessions.map { $0.startTs }) + let hostedMotion = await repo.sessionMotions(sessions: hostedSessions) hostedSleepModel = SleepModel.build(SleepModelInputs( days: repo.days, sleeps: repo.sleeps, diff --git a/Strand/Screens/SleepView.swift b/Strand/Screens/SleepView.swift index 6d24b89938..3fbbab98b1 100644 --- a/Strand/Screens/SleepView.swift +++ b/Strand/Screens/SleepView.swift @@ -75,7 +75,7 @@ struct SleepView: View { @State private var habitualMidsleepSec: Int? = nil /// Persisted per-epoch MOTION series keyed by each session's detected `startTs` (#407). Loaded in the - /// same `.task` as `allSessions` from `repo.sessionMotions(starts:)`, then laid along the hypnogram for + /// same `.task` as `allSessions` from `repo.sessionMotions(sessions:)`, then laid along the hypnogram for /// the SAME main-night GROUP blocks the hero resolved (mergeDay's group) — we do NOT re-resolve the /// night, only read the already-chosen group's stored motion. A block with no stored series stays absent /// (honest empty state for older rows whose `motionJSON` is NULL). Refreshed with `allSessions`. @@ -208,7 +208,7 @@ struct SleepView: View { habitualMidsleepSec = await repo.habitualMidsleepSec() // Per-epoch motion for every block (#407), keyed by detected start. mergeDay reads only the // already-resolved group's entries — this just pre-fetches them all so the model build is sync. - motionByStart = await repo.sessionMotions(starts: allSessions.map { $0.startTs }) + motionByStart = await repo.sessionMotions(sessions: allSessions) nightOffset = 0 navNight = nil modelKey = dataKey diff --git a/Strand/Screens/StressView.swift b/Strand/Screens/StressView.swift index 88413ec444..803abbbf41 100644 --- a/Strand/Screens/StressView.swift +++ b/Strand/Screens/StressView.swift @@ -113,13 +113,11 @@ struct StressView: View { freqHRV = nil return } - let rr = (try? await repo.storeHandle()?.rrIntervals( - deviceId: repo.deviceId, from: from, to: to, limit: 200_000)) ?? [] + let rr = await repo.rrIntervals(from: from, to: to, limit: 200_000) // Wrist accelerometer for the motion gate: an ambulatory hour is EXERTION, not stress, so it // is masked rather than scored (DaytimeStress). Same store read as R-R; empty on hardware or // imports with no gravity, which is exactly the "no masking, prior behaviour" degradation. - let gravity = (try? await repo.storeHandle()?.gravitySamples( - deviceId: repo.deviceId, from: from, to: to, limit: 200_000)) ?? [] + let gravity = await repo.gravitySamplesUnion(from: from, to: to, limit: 200_000) // Score today's hours against the PERSONAL cross-day daytime baseline ONLY when the user has // opted in (Settings → Experimental) AND enough worn history exists (Oura-style @@ -173,8 +171,7 @@ struct StressView: View { let dayTz = TimeZone.current.secondsFromGMT(for: dayStart) let dayHR = await repo.hrSamples(from: from, to: to, limit: 200_000) guard !dayHR.isEmpty else { continue } // unworn day — no floor to learn, skip the R-R read - let dayRR = (try? await repo.storeHandle()?.rrIntervals( - deviceId: repo.deviceId, from: from, to: to, limit: 200_000)) ?? [] + let dayRR = await repo.rrIntervals(from: from, to: to, limit: 200_000) days.append(.init(hr: dayHR, rr: dayRR, tzOffsetSeconds: dayTz)) } return DaytimeStress.scoringMode(history: days) diff --git a/Strand/Screens/TodayView.swift b/Strand/Screens/TodayView.swift index cb19affc93..f801a14f63 100644 --- a/Strand/Screens/TodayView.swift +++ b/Strand/Screens/TodayView.swift @@ -4295,7 +4295,7 @@ struct TodayView: View { } let hostedSessions = await repo.allSleepSessions() let hostedHabitual = await repo.habitualMidsleepSec() - let hostedMotion = await repo.sessionMotions(starts: hostedSessions.map { $0.startTs }) + let hostedMotion = await repo.sessionMotions(sessions: hostedSessions) hostedSleepModel = SleepModel.build(SleepModelInputs( days: repo.days, sleeps: repo.sleeps, diff --git a/Strand/Screens/V5PillarHosts.swift b/Strand/Screens/V5PillarHosts.swift index d96312f653..a049b4193e 100644 --- a/Strand/Screens/V5PillarHosts.swift +++ b/Strand/Screens/V5PillarHosts.swift @@ -78,23 +78,15 @@ struct RhythmHost: View { /// Read the most recent banked sleep session, pull its R-R + gravity, split into ~5-minute windows, /// gate each on stillness + resting rate, and screen it. Descriptive stats only — never a verdict. private func load() async { - guard let store = await repo.storeHandle(), - let lastSleep = (await repo.allSleepSessions(days: 14)).last else { return } + guard let lastSleep = (await repo.allSleepSessions(days: 14)).last else { return } let lo = lastSleep.effectiveStartTs let hi = lastSleep.endTs guard hi > lo else { return } - let rr = (try? await store.rrIntervals(deviceId: repo.deviceId, from: lo, to: hi, limit: 200_000)) ?? [] - // BLE-only users have their night under the computed source; fall back to it when the imported - // device yields nothing. Gravity MUST be read from the SAME source id as the R-R (#1360): otherwise - // a fall-back night reads its stillness from the wrong device and every window fails the motion - // gate, which the empty-state diagnosis below would then misread as "no stillness signal at all". - let usedFallback = rr.isEmpty - let sourceId = usedFallback ? repo.deviceId + "-noop" : repo.deviceId - let rrRows = usedFallback - ? ((try? await store.rrIntervals(deviceId: sourceId, from: lo, to: hi, limit: 200_000)) ?? []) - : rr + // The selected night can belong to a previous/archived strap. Resolve the same all-WHOOP worn + // timeline used by Sleep rather than pinning its raw physiology to whichever strap is active now. + let rrRows = await repo.rrIntervals(from: lo, to: hi, limit: 200_000) guard !rrRows.isEmpty else { return } - let grav = (try? await store.gravitySamples(deviceId: sourceId, from: lo, to: hi, limit: 200_000)) ?? [] + let grav = await repo.gravitySamplesUnion(from: lo, to: hi, limit: 200_000) // Window the night into 5-minute slices; a slice is "still" when its gravity variance is small. let windowSec = 5 * 60 diff --git a/StrandTests/DeviceRawSourceParityTests.swift b/StrandTests/DeviceRawSourceParityTests.swift new file mode 100644 index 0000000000..1230b0761a --- /dev/null +++ b/StrandTests/DeviceRawSourceParityTests.swift @@ -0,0 +1,118 @@ +import Foundation +import XCTest +@testable import Strand +import WhoopStore +import WhoopProtocol + +/// Apple half of the shared Android/Apple raw-source oracle. This tests the repository contract rather +/// than duplicating expected values in Swift, so source precedence and R-R identity cannot drift quietly. +final class DeviceRawSourceParityTests: XCTestCase { + private struct Fixture: Decodable { + struct SourceCase: Decodable { + let name: String + let activeDeviceId: String + let registeredWhoopIds: [String] + let imported: [String] + let computed: [String] + } + struct Beat: Decodable { + let source: String? + let ts: Int + let rrMs: Int + let seq: Int + let ord: Int? + } + struct BeatSource: Decodable { let id: String; let beats: [Beat] } + struct RRCase: Decodable { let name: String; let sources: [BeatSource]; let expected: [Beat] } + let sourceCases: [SourceCase] + let rrCases: [RRCase] + } + + private func fixture() throws -> Fixture { + let testFile = URL(fileURLWithPath: #filePath) + let url = testFile.deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("Tools/parity_cases/device_raw_sources.json") + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path), + "Shared device-source parity fixture is missing; parity must fail closed") + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + } + + private func production(_ path: String) throws -> String { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + let url = root.appendingPathComponent(path) + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path), + "Production source missing: \(path); consumer wiring guard must fail closed") + return try String(contentsOf: url, encoding: .utf8) + } + + func testSourceResolutionMatchesSharedOracle() throws { + for vector in try fixture().sourceCases { + let actual = Repository.rawWhoopSourceIds(activeDeviceId: vector.activeDeviceId, + registeredWhoopIds: vector.registeredWhoopIds) + XCTAssertEqual(actual, vector.imported, vector.name) + XCTAssertEqual(actual.map { $0 + "-noop" }, vector.computed, vector.name) + } + } + + func testRRIdentityMergeMatchesSharedOracle() throws { + for vector in try fixture().rrCases { + let inputs = vector.sources.map { source in + source.beats.map { RRInterval(ts: $0.ts, rrMs: $0.rrMs, ord: $0.ord, seq: $0.seq) } + } + let actual = Repository.mergeRRByIdentity(inputs) + XCTAssertEqual(actual.map(\.ts), vector.expected.map(\.ts), vector.name) + XCTAssertEqual(actual.map(\.rrMs), vector.expected.map(\.rrMs), vector.name) + XCTAssertEqual(actual.map(\.seq), vector.expected.map(\.seq), vector.name) + XCTAssertEqual(actual.map(\.ord), vector.expected.map(\.ord), vector.name) + } + } + + func testStressCannotPinRawReadsToCanonicalNamespace() throws { + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + let source = try String(contentsOf: root.appendingPathComponent("Strand/Screens/StressView.swift"), + encoding: .utf8) + let withoutComments = source.replacingOccurrences(of: #"/\*.*?\*/"#, with: "", + options: .regularExpression) + .split(separator: "\n", omittingEmptySubsequences: false) + .map { $0.split(separator: "//", maxSplits: 1).first.map(String.init) ?? "" } + .joined(separator: "\n") + let directCanonicalRaw = #"(?s)(hrSamples|rrIntervals|gravitySamples)\s*\([^)]*(\"my-whoop\"|Repository\.whoopSource)"# + XCTAssertNil(withoutComments.range(of: directCanonicalRaw, options: .regularExpression), + "Stress raw physiology must resolve via the device-aware repository API") + for call in ["repo.hrSamples(from:", "repo.rrIntervals(from:", "repo.gravitySamplesUnion(from:"] { + XCTAssertTrue(withoutComments.contains(call), "Stress must use device-aware \(call)") + } + } + + func testCoachTimelineAndRhythmUseDeviceAwareRawFacades() throws { + let coach = try production("Strand/AI/AICoach.swift") + XCTAssertTrue(coach.contains("repo.rrIntervals(from:"), + "AI Coach stress context must use Repository's all-source R-R facade") + XCTAssertFalse(coach.contains("store.rrIntervals(\n deviceId: repo.deviceId"), + "AI Coach must not pin R-R to the currently active device") + + let repository = try production("Strand/Data/Repository.swift") + guard let timelineStart = repository.range(of: "func timelineSeries(")?.lowerBound else { + return XCTFail("Repository.timelineSeries is missing") + } + let timeline = String(repository[timelineStart...].prefix(12_000)) + XCTAssertTrue(timeline.contains("rawPhysiologyReadIds(store: store)"), + "Default Full Day timelines must resolve every registered raw source") + XCTAssertFalse(timeline.contains("source.map { [$0] } ?? importedReadIds"), + "Timeline must not fall back to active-plus-canonical only") + XCTAssertTrue(repository.contains("hrBuckets(deviceIds: rawPhysiologyReadIds(store: store)"), + "Coarse HR reads must include every registered WHOOP strap") + XCTAssertTrue(repository.contains("ids: rawPhysiologyReadIds(store: store)"), + "Primary sleep reads must include every registered WHOOP strap") + XCTAssertTrue(repository.contains("ids: rawComputedReadIds(store: store)"), + "Computed sleep reads must include every registered WHOOP strap") + + let pillars = try production("Strand/Screens/V5PillarHosts.swift") + XCTAssertTrue(pillars.contains("repo.rrIntervals(from:"), + "Rhythm must use Repository's all-source R-R facade") + XCTAssertTrue(pillars.contains("repo.gravitySamplesUnion(from:"), + "Rhythm must use Repository's all-source gravity facade") + XCTAssertFalse(pillars.contains("store.rrIntervals(deviceId: repo.deviceId"), + "Rhythm must not pin R-R to the currently active device") + } +} diff --git a/StrandTests/RawPhysiologyUnionTests.swift b/StrandTests/RawPhysiologyUnionTests.swift new file mode 100644 index 0000000000..7188981392 --- /dev/null +++ b/StrandTests/RawPhysiologyUnionTests.swift @@ -0,0 +1,123 @@ +import XCTest +import WhoopProtocol +import WhoopStore +import StrandAnalytics +@testable import Strand + +final class RawPhysiologyUnionTests: XCTestCase { + func testRRMergePreservesDistinctSameTimestampBeatsAndDedupesExactIdentityActiveFirst() { + let active = [RRInterval(ts: 100, rrMs: 800, ord: 8, seq: 0), + RRInterval(ts: 100, rrMs: 800, ord: 9, seq: 1)] + let canonical = [RRInterval(ts: 100, rrMs: 800, ord: 1, seq: 0), + RRInterval(ts: 101, rrMs: 810, ord: 2, seq: 0)] + let merged = Repository.mergeRRByIdentity([active, canonical]) + XCTAssertEqual(merged.map { "\($0.ts):\($0.rrMs):\($0.seq)" }, + ["100:800:0", "100:800:1", "101:810:0"]) + XCTAssertEqual(merged.first?.ord, 8, "active source wins only the exact duplicate") + } + + func testSingleSourceRRIsReturnedUnchanged() { + let rows = [RRInterval(ts: 101, rrMs: 810, ord: 4, seq: 2), + RRInterval(ts: 100, rrMs: 800, ord: 3, seq: 1)] + XCTAssertEqual(Repository.mergeRRByIdentity([rows]), rows) + } + + func testRRUnionSortsByTimestampThenSequenceBeforeValueAndOrder() { + let rows = Repository.mergeRRByIdentity([ + [RRInterval(ts: 100, rrMs: 700, ord: 0, seq: 1)], + [RRInterval(ts: 100, rrMs: 900, ord: 9, seq: 0), + RRInterval(ts: 100, rrMs: 800, ord: nil, seq: 0)], + ]) + XCTAssertEqual(rows.map { "\($0.seq):\($0.rrMs):\($0.ord.map(String.init) ?? "nil")" }, + ["0:800:nil", "0:900:9", "1:700:0"]) + } + + @MainActor + func testRRFacadeUnionsReAddedStrapAndCanonical() async throws { + let store = try await WhoopStore.inMemory() + let repo = Repository(deviceId: "my-whoop") + repo.setStoreForTesting(store) + _ = try await store.insert(Streams(rr: [RRInterval(ts: 100, rrMs: 800)]), deviceId: "my-whoop") + _ = try await store.insert(Streams(rr: [RRInterval(ts: 101, rrMs: 810)]), deviceId: "whoop-new") + repo.adoptActiveDeviceId("whoop-new") + let rows = await repo.rrIntervals(from: 90, to: 110) + XCTAssertEqual(rows.map(\.ts), [100, 101]) + } + + @MainActor + func testRawFacadeIncludesAnotherRegisteredWhoopNotCurrentlyActive() async throws { + let store = try await WhoopStore.inMemory() + let registry = DeviceRegistryStore(dbQueue: store.registryWriter) + try registry.add(PairedDevice(id: "whoop-a", brand: "WHOOP", model: "5.0", + sourceKind: .liveBLE, capabilities: [.hr], status: .archived, addedAt: 1, lastSeenAt: 1)) + try registry.add(PairedDevice(id: "whoop-b", brand: "WHOOP", model: "5.0", + sourceKind: .liveBLE, capabilities: [.hr], status: .active, addedAt: 2, lastSeenAt: 2)) + _ = try await store.insert(Streams(hr: [HRSample(ts: 100, bpm: 61)]), deviceId: "whoop-a") + _ = try await store.insert(Streams(hr: [HRSample(ts: 200, bpm: 72)]), deviceId: "whoop-b") + let repo = Repository(deviceId: "whoop-b") + repo.setStoreForTesting(store) + let rows = await repo.hrSamples(from: 90, to: 210) + XCTAssertEqual(rows.map(\.bpm), [61, 72]) + + let timeline = await repo.timelineSeries(metric: .hr, from: 90, to: 210, targetPoints: 600) + XCTAssertEqual(timeline.points.map { Int($0.value) }, [61, 72], + "default Deep Timeline must use the same all-WHOOP raw resolver") + } + + @MainActor + func testSessionMotionUsesHistoricalOwnerBeforeCurrentActiveComputedSource() async throws { + let store = try await WhoopStore.inMemory() + let session = CachedSleepSession(startTs: 1_000, endTs: 5_000, efficiency: 0.9, + restingHr: 52, avgHrv: 60, stagesJSON: nil) + _ = try await store.upsertSleepSessions([session], deviceId: "my-whoop") + _ = try await store.upsertSleepSessions([session], deviceId: "my-whoop-noop") + _ = try await store.persistSessionMotion(deviceId: "my-whoop-noop", sessionStart: 1_000, + motionEpochs: [0.1, 0.2]) + _ = try await store.upsertSleepSessions([session], deviceId: "whoop-new-noop") + _ = try await store.persistSessionMotion(deviceId: "whoop-new-noop", sessionStart: 1_000, + motionEpochs: [9.0]) + let repo = Repository(deviceId: "whoop-new") + repo.setStoreForTesting(store) + let motion = await repo.sessionMotions(sessions: [session]) + XCTAssertEqual(motion[1_000] ?? [], [0.1, 0.2]) + } + + @MainActor + func testArchivedStrapNightsTeachHabitualMidsleep() async throws { + let store = try await WhoopStore.inMemory() + let registry = DeviceRegistryStore(dbQueue: store.registryWriter) + try registry.add(PairedDevice(id: "whoop-old", brand: "WHOOP", model: "4.0", + sourceKind: .liveBLE, capabilities: [.sleep], status: .archived, addedAt: 1, lastSeenAt: 1)) + try registry.add(PairedDevice(id: "whoop-new", brand: "WHOOP", model: "5.0", + sourceKind: .liveBLE, capabilities: [.sleep], status: .active, addedAt: 2, lastSeenAt: 2)) + let firstStart = Int(Date().timeIntervalSince1970) - 20 * 86_400 + let nights = (0.. nightHi` guard handles — a 5 pm run still shows up the // same day. val dayGrav = AnalyticsEngine.daySliceFromNight(grav, from, to, dayMidnight, dayEnd) { it.ts } - ?: repo.gravitySamples(owner, dayMidnight, dayEnd, STREAM_LIMIT) + ?: repo.gravitySamplesForDevice(owner, dayMidnight, dayEnd, STREAM_LIMIT) // CONSUME (#531 / #175): the strap's OWN band sleep_state for the night window as (ts, state) // samples, so the H7 morning-stillness guard can confirm a borderline re-onset against the strap's @@ -958,7 +958,7 @@ object IntelligenceEngine { // import namespace are untouched; analyzeDay still lets a DETECTED session win where they overlap. val providedSleep: List = if (owner != importedDeviceId && grav.size < 2) { - repo.sleepSessions(owner, from, to, 4000) + repo.sleepSessionsForDevice(owner, from, to, 4000) .mapNotNull { AnalyticsEngine.sleepSessionFromProvided(it) } } else { emptyList() @@ -1862,7 +1862,7 @@ object IntelligenceEngine { } val healDropped = ArrayList() for (healId in healDeviceIds) { - val storedSessions = repo.sleepSessions(healId, windowStart, nowSeconds, 4000) + val storedSessions = repo.sleepSessionsForDevice(healId, windowStart, nowSeconds, 4000) val healable = storedSessions.filter { AnalyticsEngine.dayString(it.endTs, tzOffsetSeconds) in oldestDay..newestDay } @@ -2016,7 +2016,7 @@ object IntelligenceEngine { val dayEnd = dayMid + SECONDS_PER_DAY - 1 val dayKey = AnalyticsEngine.dayString(dayMid, tzOffsetSeconds) val owner = resolveDayOwner(repo, ownerSource, candidatePriorities, dayKey, dayMid, dayEnd, importedDeviceId) - val grav = repo.gravitySamples(owner, dayMid, dayEnd, STREAM_LIMIT) + val grav = repo.gravitySamplesForDevice(owner, dayMid, dayEnd, STREAM_LIMIT) val m = StepsEstimateEngine.dayMotionIntensity(grav) if (m > 0) motionByDay[dayKey] = m } @@ -2125,7 +2125,7 @@ object IntelligenceEngine { // merged-workout case, where kcal is the SUM of inputs so it never looks under-scored yet // Effort stays blank forever). improves() then accepts a strain-only gain for the latter. if (!ManualWorkoutRescore.looksUnderScored(row.energyKcal) && row.strain != null) continue - val samples = runCatching { repo.hrSamples(deviceId, row.startTs, row.endTs, 20_000) } + val samples = runCatching { repo.hrSamplesForDevice(deviceId, row.startTs, row.endTs, 20_000) } .getOrNull() ?: continue val s = ManualWorkoutRescore.scored( samples, profile, hrMax, restingHR, effortMethod) ?: continue @@ -2291,7 +2291,7 @@ object IntelligenceEngine { // re-banked copy of the night would otherwise feed "asleep" epochs at the OLD times into the H7 // re-onset guard, letting the stale block keep confirming itself. Read-side only (no bank-recency // witness here); the store itself is healed post-upsert in analyzeRecentOnCpu. Mirrors Swift. - val sessions = SleepSessionDedup.dedupe(repo.sleepSessions(computedId, from, to, 4000)).kept + val sessions = SleepSessionDedup.dedupe(repo.sleepSessionsForDevice(computedId, from, to, 4000)).kept val samples = ArrayList>() for (s in sessions) { val states = repo.sessionSleepState(computedId, s.startTs) ?: continue @@ -2319,8 +2319,8 @@ object IntelligenceEngine { windowEnd: Long, offsetSec: Long, ): Pair> { - val imported = repo.sleepSessions(importedId, windowStart, windowEnd, 4000) - val computed = repo.sleepSessions(computedId, windowStart, windowEnd, 4000) + val imported = repo.sleepSessionsForDevice(importedId, windowStart, windowEnd, 4000) + val computed = repo.sleepSessionsForDevice(computedId, windowStart, windowEnd, 4000) // #899: collapse overlapping timebase-shifted duplicates BEFORE the learner sees the history. // A stale re-banked copy of a night lands on a DIFFERENT day key, so the per-day longest-block // de-dup below never caught it and the learned midsleep drifted toward the stale timing, which @@ -2599,7 +2599,7 @@ object IntelligenceEngine { val candidates = candidatePriorities.map { (id, priority) -> // Cheap presence check: a single HR row for this device in the night window marks it a // candidate. (LIMIT 1 , not the full pull the caller does once an owner is chosen.) - val hasData = repo.hrSamples(id, from, to, 1).isNotEmpty() + val hasData = repo.hrSamplesForDevice(id, from, to, 1).isNotEmpty() DayOwnerResolver.Candidate(deviceId = id, priority = priority, hasData = hasData) } return DayOwnerResolver.resolve(day, lockedOwner = null, candidates = candidates) ?: importedDeviceId @@ -2657,13 +2657,13 @@ object IntelligenceEngine { * extraction next door exists to protect. */ private fun hrReadWindow(repo: com.noop.data.WhoopRepository) = SlidingStreamWindow({ it.ts }, STREAM_LIMIT) { o, f, t -> - repo.hrSamples(o, f, t, STREAM_LIMIT) + repo.hrSamplesForDevice(o, f, t, STREAM_LIMIT) } /** The pass-1 R-R sliding read window. Same reason as [hrReadWindow] for living out here. */ private fun rrReadWindow(repo: com.noop.data.WhoopRepository) = SlidingStreamWindow({ it.ts }, STREAM_LIMIT) { o, f, t -> - repo.rrIntervals(o, f, t, STREAM_LIMIT) + repo.rrIntervalsForDevice(o, f, t, STREAM_LIMIT) } diff --git a/android/app/src/main/java/com/noop/analytics/SleepStageHealer.kt b/android/app/src/main/java/com/noop/analytics/SleepStageHealer.kt index b7a5698517..188e52415e 100644 --- a/android/app/src/main/java/com/noop/analytics/SleepStageHealer.kt +++ b/android/app/src/main/java/com/noop/analytics/SleepStageHealer.kt @@ -69,11 +69,11 @@ object SleepStageHealer { ): String? { val lo = start - 3_600L val hi = end + 3_600L - val grav = repo.gravitySamples(deviceId, lo, hi, IntelligenceEngine.STREAM_LIMIT) + val grav = repo.gravitySamplesForDevice(deviceId, lo, hi, IntelligenceEngine.STREAM_LIMIT) // Cheap density gate FIRST (count only) so a sparse imported night skips the three further reads. if (!isDense(grav, start, end)) return null - val hr = repo.hrSamples(deviceId, lo, hi, IntelligenceEngine.STREAM_LIMIT) - val rr = repo.rrIntervals(deviceId, lo, hi, IntelligenceEngine.STREAM_LIMIT) + val hr = repo.hrSamplesForDevice(deviceId, lo, hi, IntelligenceEngine.STREAM_LIMIT) + val rr = repo.rrIntervalsForDevice(deviceId, lo, hi, IntelligenceEngine.STREAM_LIMIT) // Same provenance refusal as the nightly scan: an Oura ring's respiration rows are its own // per-window RATE stored as instrumentation, not the ~1 Hz raw ADC waveform this stager reads, // so they never reach a re-stage either. See `OuraRespScale.forScoring`. Mirrors Swift. @@ -161,7 +161,7 @@ object SleepStageHealer { useMotionAwareWake: Boolean = false, ): List { suspend fun editedRows(): List = - repo.sleepSessions(computedDeviceId, windowStart, windowEnd).filter { it.userEdited } + repo.sleepSessionsForDevice(computedDeviceId, windowStart, windowEnd).filter { it.userEdited } val edited = editedRows() if (edited.isEmpty()) return emptyList() diff --git a/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt b/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt index f56d4de32b..a70db1897f 100644 --- a/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt +++ b/android/app/src/main/java/com/noop/ble/SourceCoordinator.kt @@ -531,7 +531,7 @@ class SourceCoordinator( runCatching { val from = s.startTs - 16 * 3600 - 3600 val to = s.endTs + 3600 - repo.sleepSessions(deviceId, from, to, 64) + repo.sleepSessionsForDevice(deviceId, from, to, 64) .filter { it.startTs != s.startTs && com.noop.analytics.SleepSessionDedup.isDuplicate(session, it) } .forEach { e -> straplog("Oura: dup-gen(#1284) persist ${dupGenShape(s.startTs, s.endTs, s.stagesJson)} duplicates stored ${dupGenShape(e.startTs, e.endTs, e.stagesJSON)} startDelta=${s.startTs - e.startTs}s (end-anchor drift) - cross-connection DB read") @@ -544,7 +544,7 @@ class SourceCoordinator( // candidate (safe default). Mirrors the Swift twin's structure + FAILED log. val from = s.startTs - 16 * 3600 - 3600 val to = s.endTs + 3600 - val nearby = runCatching { repo.sleepSessions(deviceId, from, to, 64) }.getOrDefault(emptyList()) + val nearby = runCatching { repo.sleepSessionsForDevice(deviceId, from, to, 64) }.getOrDefault(emptyList()) val plan = com.noop.analytics.SleepSessionDedup.planBank(session, nearby) if (plan.bank) { // Bank the survivor FIRST, retire what it supersedes only after it lands — so a diff --git a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt index 08e42424f6..f08ac7e84b 100644 --- a/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt +++ b/android/app/src/main/java/com/noop/ble/WhoopBleClient.kt @@ -3687,7 +3687,7 @@ class WhoopBleClient( try { val nowSec = System.currentTimeMillis() / 1000L val from = nowSec - INACTIVITY_LOOKBACK_S - val grav = repository.gravitySamples(deviceId, from, nowSec) + val grav = repository.gravitySamplesForDevice(deviceId, from, nowSec) if (grav.isEmpty()) return@launch val decision = SedentaryDetector.evaluate( @@ -3738,7 +3738,7 @@ class WhoopBleClient( // gravity window, the same primitive SedentaryDetector reuses. Null when there's no // recent gravity — the engine then leans on the resting-HR band gate (spec Q3). val from = nowSec - INACTIVITY_LOOKBACK_S - val grav = runCatching { repository.gravitySamples(deviceId, from, nowSec) }.getOrDefault(emptyList()) + val grav = runCatching { repository.gravitySamplesForDevice(deviceId, from, nowSec) }.getOrDefault(emptyList()) val recentMotionG = WorkoutDetector.activitySeries(grav).lastOrNull()?.intensity val live = _state.value @@ -3792,9 +3792,9 @@ class WhoopBleClient( // Look back over the freshly-offloaded daytime window (the same lookback the inactivity / // stress hooks read), so a brief afternoon nap that just landed gets judged. val from = nowSec - INACTIVITY_LOOKBACK_S - val grav = runCatching { repository.gravitySamples(deviceId, from, nowSec) }.getOrDefault(emptyList()) + val grav = runCatching { repository.gravitySamplesForDevice(deviceId, from, nowSec) }.getOrDefault(emptyList()) if (grav.isEmpty()) return@launch - val hr = runCatching { repository.hrSamples(deviceId, from, nowSec) }.getOrDefault(emptyList()) + val hr = runCatching { repository.hrSamplesForDevice(deviceId, from, nowSec) }.getOrDefault(emptyList()) // Honest resting band: the newest daily metric's resting HR, or null (the engine then // leans on motion alone at lower confidence — it never fabricates a band). val restingHr = runCatching { diff --git a/android/app/src/main/java/com/noop/data/WhoopRepository.kt b/android/app/src/main/java/com/noop/data/WhoopRepository.kt index dc062368d5..0d7ae4dea2 100644 --- a/android/app/src/main/java/com/noop/data/WhoopRepository.kt +++ b/android/app/src/main/java/com/noop/data/WhoopRepository.kt @@ -886,19 +886,31 @@ class WhoopRepository( suspend fun sessionMotion(deviceId: String, sessionStart: Long): List? = dao.sessionMotionJson(deviceId, sessionStart)?.let { decodeDoubleArray(it) } - /** Per-epoch MOTION series for each of [starts] (detected session start keys), keyed by start (#407). - * Motion is written ONLY under the computed ("-noop") source by the engine, so we read there; an - * imported-only night (no computed twin) has no motion (absent stays absent , an honest empty state, - * never a fabricated zero array). Does NOT resolve the night: the caller has already chosen the - * main-night GROUP and passes those blocks' starts. A start with no stored series is omitted. Mirrors - * iOS Repository.sessionMotions. */ - suspend fun sessionMotions(strapDeviceId: String, starts: List): Map> { - if (starts.isEmpty()) return emptyMap() - val computedId = computedDeviceId(strapDeviceId) + /** Per-epoch motion for the already-resolved [sessions], keyed by detected start (#407). + * + * The engine writes motion under a computed ("-noop") namespace. A selected session can belong to + * an older strap, however, while [activeStrapId] names the strap worn now. Resolve each start from its + * owning computed namespace first, then every registered WHOOP's computed namespace (active first, + * canonical last). This keeps old nights attached to their source across repeated strap switches and + * still finds a computed twin for an imported canonical session. A missing series remains absent. + */ + suspend fun sessionMotions( + activeStrapId: String, + sessions: List, + ): Map> { + if (sessions.isEmpty()) return emptyMap() + val fallbackIds = rawWhoopSourceIds(activeStrapId).map { "$it-noop" } val out = HashMap>() - for (start in starts) { - val m = dao.sessionMotionJson(computedId, start)?.let { decodeDoubleArray(it) } - if (!m.isNullOrEmpty()) out[start] = m + for (session in sessions) { + if (out.containsKey(session.startTs)) continue + val ownerComputed = if (session.deviceId.endsWith("-noop")) session.deviceId else "${session.deviceId}-noop" + for (sourceId in (listOf(ownerComputed) + fallbackIds).distinct()) { + val motion = dao.sessionMotionJson(sourceId, session.startTs)?.let { decodeDoubleArray(it) } + if (!motion.isNullOrEmpty()) { + out[session.startTs] = motion + break + } + } } return out } @@ -947,7 +959,7 @@ class WhoopRepository( // MARK: - Reads - suspend fun hrSamples(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = + suspend fun hrSamplesForDevice(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = dao.hrSamples(deviceId, from, to, limit) /** #856: HR samples over an EXPLICIT id list, deduped by ts with earlier ids winning — the sample @@ -959,9 +971,8 @@ class WhoopRepository( else mergeHrByTs(deviceIds.map { dao.hrSamples(it, from, to, limit) }) /** - * HR samples over the read-side UNION of the active strap id AND the canonical "my-whoop" (SPINE / - * #814 + HIGH-2), deduped by ts with the active strap winning. This is the Kotlin twin of the Swift - * [com.noop] Repository.hrSamples(from:to:) union overload. + * HR samples over every registered WHOOP plus canonical "my-whoop", deduped by timestamp with the + * active strap winning and archived straps retained for historical windows. * * #908: a strap re-added through the in-app device manager banks its LIVE raw under its OWN fresh id * (e.g. "whoop-"), NOT "my-whoop". A Today-curve / live-Effort read pinned to the hardcoded @@ -970,7 +981,7 @@ class WhoopRepository( * A single-WHOOP install resolves [activeDeviceId] to "my-whoop" ⇒ ONE id ⇒ byte-identical read. */ suspend fun hrSamplesUnion(activeDeviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT): - List = mergeHrByTs(importedSourceIds(activeDeviceId).map { dao.hrSamples(it, from, to, limit) }) + List = mergeHrByTs(rawWhoopSourceIds(activeDeviceId).map { dao.hrSamples(it, from, to, limit) }) /** Raw measured HR only (no v26 PPG-derived union) for the raw-sensor diagnostic export. */ suspend fun rawHrSamples(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = @@ -1022,7 +1033,7 @@ class WhoopRepository( .map { it.ts to StreamPersistence.unpackImuColumns(it.samples) } /** Downsampled HR (mean bpm per [bucketSeconds]) for the strap, for the Today 24h trend chart. */ - suspend fun hrBuckets(deviceId: String, from: Long, to: Long, bucketSeconds: Long = 300L) = + suspend fun hrBucketsForDevice(deviceId: String, from: Long, to: Long, bucketSeconds: Long = 300L) = dao.hrBuckets(deviceId, from, to, bucketSeconds) /** #856: the same dedup over an EXPLICIT id list, so a workout can read its OWN recording strap @@ -1034,15 +1045,13 @@ class WhoopRepository( else mergeHrBucketsByStart(deviceIds.map { dao.hrBuckets(it, from, to, bucketSeconds) }) /** - * Downsampled HR buckets over the read-side UNION of the active strap id AND the canonical "my-whoop" - * (SPINE / #814 + HIGH-2), deduped by bucket start with the active strap winning. Kotlin twin of the - * Swift Repository.hrBuckets(from:to:bucketSeconds:) union overload. #908: keeps the Today HR curve - * pointed at whichever id the re-added strap actually banks under. Single-WHOOP install ⇒ one id ⇒ - * byte-identical read. + * Downsampled HR buckets over every registered WHOOP (active first, archived included, canonical + * last), deduped by bucket start with the active source winning. Kotlin twin of the raw-sample union. + * A legacy canonical-only install remains a single byte-identical DAO read. */ suspend fun hrBucketsUnion(activeDeviceId: String, from: Long, to: Long, bucketSeconds: Long = 300L): List = mergeHrBucketsByStart( - importedSourceIds(activeDeviceId).map { dao.hrBuckets(it, from, to, bucketSeconds) }, + rawWhoopSourceIds(activeDeviceId).map { dao.hrBuckets(it, from, to, bucketSeconds) }, ) /** @@ -1147,9 +1156,20 @@ class WhoopRepository( } } - suspend fun rrIntervals(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = + suspend fun rrIntervalsForDevice(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = dao.rrIntervals(deviceId, from, to, limit) + /** R-R beats over active strap + canonical history. Exact duplicate beats are removed with the + * active source winning; distinct beats sharing a timestamp remain intact. */ + suspend fun rrIntervalsUnion( + activeDeviceId: String, + from: Long, + to: Long, + limit: Int = DEFAULT_LIMIT, + ): List = mergeRrByIdentity( + rawWhoopSourceIds(activeDeviceId).map { dao.rrIntervals(it, from, to, limit) }, + ) + suspend fun events(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = dao.events(deviceId, from, to, limit) @@ -1327,7 +1347,7 @@ class WhoopRepository( suspend fun respSamples(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = dao.respSamples(deviceId, from, to, limit) - suspend fun gravitySamples(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = + suspend fun gravitySamplesForDevice(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = dao.gravitySamples(deviceId, from, to, limit) /** @@ -1343,10 +1363,20 @@ class WhoopRepository( to: Long, limit: Int = DEFAULT_LIMIT, ): List = mergeGravityByTs( - importedSourceIds(activeDeviceId).map { dao.gravitySamples(it, from, to, limit) }, + rawWhoopSourceIds(activeDeviceId).map { dao.gravitySamples(it, from, to, limit) }, ) - suspend fun sleepSessions(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = + /** Resolve raw WHOOP telemetry across every registered WHOOP, including archived straps. Registry + * order is stable (addedAt ASC); the currently worn strap wins overlaps and canonical imports lose + * overlaps. The explicitly active source is preserved even for another brand; unrelated registered + * devices from other brands are deliberately excluded from WHOOP-specific fallback reads. */ + private suspend fun rawWhoopSourceIds(activeDeviceId: String): List = + rawWhoopSourceIdsFor( + activeDeviceId, + dao.pairedDevices().filter { it.brand.equals("WHOOP", ignoreCase = true) }.map { it.id }, + ) + + suspend fun sleepSessionsForDevice(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT) = dao.sleepSessions(deviceId, from, to, limit) /** @@ -1365,13 +1395,15 @@ class WhoopRepository( val now = System.currentTimeMillis() / 1000L val lo = now - days * 86_400L val hi = now + 86_400L - // UNION active strap + canonical "my-whoop" (imported) and their computed siblings (#814/#1008), + // UNION every registered WHOOP (active first, including archived, canonical last) and their + // computed siblings, // de-duplicating identical (startTs, endTs) blocks recorded under both ids so a night present in // both namespaces doesn't double-weight the learner. Reading one id narrowed the night set vs iOS // after a strap re-add (the learner could cold-start to null where iOS returned a learned value). // Mirrors Swift Repository.habitualMidsleepSec (importedReadIds/computedReadIds + dedupBlocks). - val imported = dedupSleepBlocks(importedSourceIds(deviceId).flatMap { dao.sleepSessions(it, lo, hi, 4000) }) - val computed = dedupSleepBlocks(computedSourceIds(deviceId).flatMap { dao.sleepSessions(it, lo, hi, 4000) }) + val rawIds = rawWhoopSourceIds(deviceId) + val imported = dedupSleepBlocks(rawIds.flatMap { dao.sleepSessions(it, lo, hi, 4000) }) + val computed = dedupSleepBlocks(rawIds.map { "$it-noop" }.flatMap { dao.sleepSessions(it, lo, hi, 4000) }) val offsetSec = (java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 1000).toLong() val blocks = (imported + computed).mapNotNull { s -> val start = s.effectiveStartTs @@ -1716,7 +1748,8 @@ class WhoopRepository( computed = computedSourceIds(deviceId).reversed().flatMap { dao.sleepSessions(it, from, to, limit) }, ) - /** ALL imported sleep BLOCKS across the active∪canonical union (#814/#1008), keeping every session + /** ALL imported sleep BLOCKS across every registered WHOOP (active first, archived included, + * canonical last), keeping every session * per day (a nap + a main night both survive) and dropping only EXACT-duplicate (startTs, endTs) * blocks recorded under both union ids , active strap FIRST so it keeps the surviving copy. The * Sleep tab's chevron walk reads this instead of the single canonical id, so a night recorded under @@ -1724,14 +1757,16 @@ class WhoopRepository( * caller's, exactly as before). Mirrors Swift Repository.unionSleepSessions. */ suspend fun sleepSessionsUnion(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT): List = - dedupSleepBlocks(importedSourceIds(deviceId).flatMap { dao.sleepSessions(it, from, to, limit) }) + dedupSleepBlocks(rawWhoopSourceIds(deviceId).flatMap { dao.sleepSessions(it, from, to, limit) }) /** The COMPUTED ("-noop") twin of [sleepSessionsUnion]: all computed sleep blocks across the computed * union ids, exact-duplicate blocks dropped (active's computed sibling first). Mirrors Swift * Repository.unionComputedSleepSessions. */ suspend fun computedSleepSessionsUnion(deviceId: String, from: Long, to: Long, limit: Int = DEFAULT_LIMIT): - List = - dedupSleepBlocks(computedSourceIds(deviceId).flatMap { dao.sleepSessions(it, from, to, limit) }) + List { + val ids = rawWhoopSourceIds(deviceId).map { "$it-noop" } + return dedupSleepBlocks(ids.flatMap { dao.sleepSessions(it, from, to, limit) }) + } /** Workouts over the read-side UNION of the active strap id AND the canonical "my-whoop" (#814 twin of * [hrSamplesUnion] / [sleepSessionsUnion]): a re-added / newly-paired strap owns "whoop-" while @@ -2038,10 +2073,10 @@ class WhoopRepository( * The IMPORTED daily-source ids to read for an [activeDeviceId]: the UNION of the active strap id * AND the canonical legacy "my-whoop", active FIRST (so a per-day pick takes the active/live row). * SPINE / #814 + HIGH-2. A re-added strap writes live data under its fresh id while the WHOOP-export - * import path ([com.noop.ingest.WhoopCsvImporter]) keeps writing under the canonical "my-whoop" - * (never drifting), so reading only the active id orphans the import. A single-WHOOP install resolves - * to "my-whoop" only ⇒ one id, byte-identical reads. Companion form so [com.noop.ui.FusionDayAdapter] - * (an object) and the instance reads share ONE definition. + * import path ([com.noop.ingest.WhoopCsvImporter]) keeps writing under the canonical "my-whoop" + * (never drifting), so reading only the active id orphans the import. A single-WHOOP install resolves + * to "my-whoop" only ⇒ one id, byte-identical reads. Companion form so [com.noop.ui.FusionDayAdapter] + * (an object) and the instance reads share ONE definition. */ fun importedSourceIdsFor(activeDeviceId: String): List = if (activeDeviceId == WHOOP_SOURCE) listOf(WHOOP_SOURCE) @@ -2052,6 +2087,19 @@ class WhoopRepository( fun computedSourceIdsFor(activeDeviceId: String): List = importedSourceIdsFor(activeDeviceId).map { "$it-noop" } + /** Pure ordering contract shared with Swift through Tools/parity_cases/device_raw_sources.json. */ + fun rawWhoopSourceIdsFor(activeDeviceId: String, registeredWhoopIds: List): List = + (listOf(activeDeviceId) + + registeredWhoopIds.filter { it != activeDeviceId && it != WHOOP_SOURCE } + + WHOOP_SOURCE).distinct() + + /** Computed namespaces to probe for a session's motion, owner first and then the current + * active + canonical union. [sessionOwnerId] may already be a computed id. */ + fun motionSourceIdsFor(activeDeviceId: String, sessionOwnerId: String): List { + val ownerComputed = if (sessionOwnerId.endsWith("-noop")) sessionOwnerId else "$sessionOwnerId-noop" + return (listOf(ownerComputed) + computedSourceIdsFor(activeDeviceId)).distinct() + } + /** Pick the winner among per-source LATEST rows ([computedSourceIdsFor] order, active-strap * first): the strictly newest day wins; a shared newest day keeps the FIRST seen (the active * strap) — byte-identical to what `mergeComputedSeriesUnion(...).lastOrNull()` yields on the @@ -2372,6 +2420,24 @@ class WhoopRepository( return byTs.values.sortedBy { it.ts } } + /** Merge R-R lists with source priority matching [mergeHrByTs]. R-R has multiple legitimate + * beats at one timestamp, so its identity is the storage key excluding deviceId, not timestamp + * alone. The first (active) list wins only an exact duplicate. */ + internal fun mergeRrByIdentity(lists: List>): List { + if (lists.size == 1) return lists[0] + data class BeatKey(val ts: Long, val rrMs: Int, val seq: Int) + val byBeat = LinkedHashMap() + for (list in lists) for (beat in list) { + byBeat.putIfAbsent(BeatKey(beat.ts, beat.rrMs, beat.seq), beat) + } + return byBeat.values.sortedWith( + compareBy { it.ts } + .thenBy { it.seq } + .thenBy { it.rrMs } + .thenBy { it.ord }, + ) + } + /** * Merge gravity sample lists into one time-ordered stream, deduped by timestamp with the FIRST * list (the active strap) winning on a tie. A single-id read is returned unchanged, matching the diff --git a/android/app/src/main/java/com/noop/ingest/HealthConnectWriter.kt b/android/app/src/main/java/com/noop/ingest/HealthConnectWriter.kt index c1c6cbef98..9cf0cd7c72 100644 --- a/android/app/src/main/java/com/noop/ingest/HealthConnectWriter.kt +++ b/android/app/src/main/java/com/noop/ingest/HealthConnectWriter.kt @@ -228,14 +228,14 @@ object HealthConnectWriter { val floor = now - WINDOW_DAYS * 86_400 val frontier = maxOf(NoopPrefs.hcHrFrontier(context), floor) - val samples = repo.hrSamples(deviceId, from = frontier + 1, to = now, limit = 200_000) + val samples = repo.hrSamplesForDevice(deviceId, from = frontier + 1, to = now, limit = 200_000) .map { HealthExportPlan.HrPoint(it.ts, it.bpm) } if (samples.isEmpty()) return 0 // Workout + sleep windows where the full-resolution series matters; everything else decimates. val windows = buildList { repo.workouts(deviceId, frontier, now).forEach { add(HealthExportPlan.Window(it.startTs, it.endTs)) } - repo.sleepSessions(deviceId, frontier, now).forEach { add(HealthExportPlan.Window(it.startTs, it.endTs)) } + repo.sleepSessionsForDevice(deviceId, frontier, now).forEach { add(HealthExportPlan.Window(it.startTs, it.endTs)) } } val plan = HealthExportPlan.heartRate(samples, windows, frontier) diff --git a/android/app/src/main/java/com/noop/ingest/RawSensorExport.kt b/android/app/src/main/java/com/noop/ingest/RawSensorExport.kt index 3e9028bc89..df5881b4f6 100644 --- a/android/app/src/main/java/com/noop/ingest/RawSensorExport.kt +++ b/android/app/src/main/java/com/noop/ingest/RawSensorExport.kt @@ -75,11 +75,11 @@ object RawSensorExport { counts["hr"] = hr.size for (s in hr) rows += LineRow(s.ts, line("hr", s.ts, 0 to n(s.bpm))) - val rr = repo.rrIntervals(deviceId, from, to, limit) + val rr = repo.rrIntervalsForDevice(deviceId, from, to, limit) counts["rr"] = rr.size for (s in rr) rows += LineRow(s.ts, line("rr", s.ts, 1 to n(s.rrMs))) - val grav = repo.gravitySamples(deviceId, from, to, limit) + val grav = repo.gravitySamplesForDevice(deviceId, from, to, limit) counts["gravity"] = grav.size for (s in grav) rows += LineRow(s.ts, line("gravity", s.ts, 2 to n(s.x), 3 to n(s.y), 4 to n(s.z))) diff --git a/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt b/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt index c1edf42b07..e93b0fced0 100644 --- a/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt +++ b/android/app/src/main/java/com/noop/testcentre/AndroidDiagnostics.kt @@ -178,9 +178,9 @@ object AndroidDiagnostics { if (sk.isNotEmpty()) { session = s; skin = sk; break } } } - val grav = repo.gravitySamples(id, session.startTs, session.endTs, Int.MAX_VALUE) - val hr = repo.hrSamples(id, session.startTs, session.endTs, Int.MAX_VALUE) - val rr = repo.rrIntervals(id, session.startTs, session.endTs, Int.MAX_VALUE) + val grav = repo.gravitySamplesForDevice(id, session.startTs, session.endTs, Int.MAX_VALUE) + val hr = repo.hrSamplesForDevice(id, session.startTs, session.endTs, Int.MAX_VALUE) + val rr = repo.rrIntervalsForDevice(id, session.startTs, session.endTs, Int.MAX_VALUE) val resp = repo.respSamples(id, session.startTs, session.endTs, Int.MAX_VALUE) add("Night ${dayStamp(session.startTs)}: grav=${grav.size} hr=${hr.size} rr=${rr.size} resp=${resp.size} skin=${skin.size}") if (grav.isEmpty() && hr.isEmpty()) { diff --git a/android/app/src/main/java/com/noop/ui/FullDayChartScreen.kt b/android/app/src/main/java/com/noop/ui/FullDayChartScreen.kt index dd3e7d3f34..9b3395361d 100644 --- a/android/app/src/main/java/com/noop/ui/FullDayChartScreen.kt +++ b/android/app/src/main/java/com/noop/ui/FullDayChartScreen.kt @@ -392,7 +392,9 @@ private suspend fun readTimeline( // the chart at day scale (the #575 point-count risk downsampleTimeline handles for the others). // #1036 (ryanbr): stepSec closes this Android-only day-scale flood gap. val hrvWindow = HrvAnalyzer.DEFAULT_ROLLING_WINDOW_SEC - return@withContext runCatching { repo.rrIntervals(deviceId, from, to, 200_000) }.getOrDefault(emptyList()) + return@withContext runCatching { + repo.rrIntervalsUnion(deviceId, from, to, 200_000) + }.getOrDefault(emptyList()) .let { HrvAnalyzer.rollingRmssd(it, windowSec = hrvWindow, stepSec = maxOf(1, hrvWindow / 8)) } .map { (ts, v) -> TimelinePoint(ts, v) } } @@ -419,7 +421,7 @@ private suspend fun readTimeline( runCatching { repo.respSamples(deviceId, from, to, 200_000) }.getOrDefault(emptyList()) .map { TimelinePoint(it.ts, OuraRespScale.displayValue(it.raw, deviceId)) } TimelineMetric.Motion -> - runCatching { repo.gravitySamples(deviceId, from, to, 200_000) }.getOrDefault(emptyList()) + runCatching { repo.gravitySamplesUnion(deviceId, from, to, 200_000) }.getOrDefault(emptyList()) .map { TimelinePoint(it.ts, kotlin.math.sqrt(it.x * it.x + it.y * it.y + it.z * it.z)) } TimelineMetric.BandSleepState -> // #175: the strap's OWN band sleep_state (0 wake/1 still/2 asleep/3 up) as a stepped track. Read diff --git a/android/app/src/main/java/com/noop/ui/SleepScreen.kt b/android/app/src/main/java/com/noop/ui/SleepScreen.kt index 3994e2df6a..6bd3b5ce94 100644 --- a/android/app/src/main/java/com/noop/ui/SleepScreen.kt +++ b/android/app/src/main/java/com/noop/ui/SleepScreen.kt @@ -230,9 +230,9 @@ fun SleepScreen( // and lays them along the hypnogram's timeline. A block with no stored series stays absent (honest empty // state for older rows whose motionJSON is NULL). Mirrors iOS SleepView.motionByStart. var motionByStart by remember { mutableStateOf>>(emptyMap()) } - LaunchedEffect(sleeps) { + LaunchedEffect(sleeps, vm.activeStrapId) { motionByStart = runCatching { - vm.repo.sessionMotions("my-whoop", sleeps.map { it.startTs }) + vm.repo.sessionMotions(vm.activeStrapId, sleeps) }.getOrDefault(emptyMap()) } @@ -1988,4 +1988,3 @@ private fun MotionStrip(epochs: List) { // MARK: - Sleep metric cards, debt ledger, stages, trends + chart helpers live in SleepMetricCardsUi.kt // MARK: - Sleep metric detail sheet UI lives in SleepMetricDetailSheet.kt - diff --git a/android/app/src/main/java/com/noop/ui/StressScreen.kt b/android/app/src/main/java/com/noop/ui/StressScreen.kt index df5e4a4d5f..7ca4467382 100644 --- a/android/app/src/main/java/com/noop/ui/StressScreen.kt +++ b/android/app/src/main/java/com/noop/ui/StressScreen.kt @@ -124,7 +124,7 @@ fun StressScreen(vm: AppViewModel, onBreathe: () -> Unit = {}) { // span/beat gate is not met. Faithful twin of the iOS StressView readouts. var stressIndex by remember { mutableStateOf(null) } var freqHrv by remember { mutableStateOf(null) } - androidx.compose.runtime.LaunchedEffect(Unit) { + androidx.compose.runtime.LaunchedEffect(vm.activeStrapId) { val read = runCatching { loadDaytimeStress(vm, NoopPrefs.stressPersonalBaseline(context)) } .getOrDefault(DaytimeReadout(DaytimeStress.Result.EMPTY, null, null)) daytime = read.daytime @@ -181,15 +181,15 @@ private suspend fun loadDaytimeStress(vm: AppViewModel, personalBaseline: Boolea val todayWindow = stressLocalDayWindowContaining(nowSeconds, zone) val from = todayWindow.fromEpochSecond val tzOffsetSeconds = zone.rules.getOffset(Instant.ofEpochSecond(nowSeconds)).totalSeconds.toLong() - val hr = vm.repo.hrSamples("my-whoop", from, nowSeconds, limit = 200_000) + val hr = vm.repo.hrSamplesUnion(vm.activeStrapId, from, nowSeconds, limit = 200_000) if (hr.size < DaytimeStress.minHourHrSamples) { return DaytimeReadout(DaytimeStress.Result.EMPTY, null, null) } - val rr = vm.repo.rrIntervals("my-whoop", from, nowSeconds, limit = 200_000) + val rr = vm.repo.rrIntervalsUnion(vm.activeStrapId, from, nowSeconds, limit = 200_000) // Wrist accelerometer for the motion gate: an ambulatory hour is EXERTION, not stress, so it is // masked rather than scored (DaytimeStress). Same repo read as R-R; empty on hardware or imports // with no gravity, which is exactly the "no masking, prior behaviour" degradation. - val gravity = vm.repo.gravitySamples("my-whoop", from, nowSeconds, limit = 200_000) + val gravity = vm.repo.gravitySamplesUnion(vm.activeStrapId, from, nowSeconds, limit = 200_000) // Score against the PERSONAL cross-day baseline only when the user opted in (#463) AND enough worn // history exists (DaytimeBaselines.scoringMode is the degradation gate); else the day's own calm // hours (DayRelative, the default). The trailing-history reads happen only past the HR-count guard @@ -227,15 +227,15 @@ private suspend fun daytimeScoringMode( // Oldest → newest so the EWMA fold replays the history in order. for (back in baselineHistoryDays downTo 1) { val window = stressLocalDayWindow(todayLocalDay.minusDays(back.toLong()), zone) - val dayHr = vm.repo.hrSamples( - "my-whoop", + val dayHr = vm.repo.hrSamplesUnion( + vm.activeStrapId, window.fromEpochSecond, window.toEpochSecondInclusive, limit = 200_000, ) if (dayHr.isEmpty()) continue // unworn day — no floor to learn, skip the R-R read - val dayRr = vm.repo.rrIntervals( - "my-whoop", + val dayRr = vm.repo.rrIntervalsUnion( + vm.activeStrapId, window.fromEpochSecond, window.toEpochSecondInclusive, limit = 200_000, diff --git a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt index 77e9480a5a..846e760fc8 100644 --- a/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt +++ b/android/app/src/main/java/com/noop/ui/TestCentreScreen.kt @@ -407,10 +407,10 @@ private fun TestCentreLiveReadoutPanel( val now = System.currentTimeMillis() / 1_000 val from = now - 60 * 60 hrSamples = runCatching { - vm.repo.hrSamples(activeStrapId, from, now, limit = 10_000) + vm.repo.hrSamplesForDevice(activeStrapId, from, now, limit = 10_000) }.getOrDefault(emptyList()) gravitySamples = runCatching { - vm.repo.gravitySamples(activeStrapId, from, now, limit = 10_000) + vm.repo.gravitySamplesForDevice(activeStrapId, from, now, limit = 10_000) }.getOrDefault(emptyList()) } 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 a7e9502570..c8c2c733ea 100644 --- a/android/app/src/test/java/com/noop/analytics/IntelligenceEngineJacocoBudgetTest.kt +++ b/android/app/src/test/java/com/noop/analytics/IntelligenceEngineJacocoBudgetTest.kt @@ -89,7 +89,7 @@ class IntelligenceEngineJacocoBudgetTest { "Vitality upsert" to Regex("""\brepo\s*\.\s*upsertMetricSeries\s*\(\s*listOf\s*\("""), "Apple Health read" to Regex("""\brepo\s*\.\s*appleDaily\s*\(\s*WhoopRepository\s*\.\s*APPLE_HEALTH_SOURCE\b"""), "Health Connect read" to Regex("""\brepo\s*\.\s*appleDaily\s*\(\s*WhoopRepository\s*\.\s*HEALTH_CONNECT_SOURCE\b"""), - "gravity samples" to Regex("""\brepo\s*\.\s*gravitySamples\s*\("""), + "gravity samples" to Regex("""\brepo\s*\.\s*gravitySamplesForDevice\s*\("""), "calibration" to Regex("""\bStepsEstimateEngine\s*\.\s*calibrate\s*\("""), "step upsert" to Regex("""\brepo\s*\.\s*upsertMetricSeries\s*\(\s*estRows\s*\)"""), "calibration persistence" to Regex("""\bpersistStepsCalibration\s*\("""), diff --git a/android/app/src/test/java/com/noop/data/DeviceRawSourceParityTest.kt b/android/app/src/test/java/com/noop/data/DeviceRawSourceParityTest.kt new file mode 100644 index 0000000000..20cf93fc18 --- /dev/null +++ b/android/app/src/test/java/com/noop/data/DeviceRawSourceParityTest.kt @@ -0,0 +1,107 @@ +package com.noop.data + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * Android half of the cross-platform raw-source contract. The same JSON oracle is consumed by + * `DeviceRawSourceParityTests.swift`; adding a strap-resolution rule on one platform without its twin + * therefore breaks one of the two native suites instead of silently changing Stress semantics. + */ +class DeviceRawSourceParityTest { + private fun repositoryRoot(): File { + val cwd = File(System.getProperty("user.dir") ?: ".") + return listOf(cwd.parentFile, cwd, cwd.parentFile?.parentFile) + .filterNotNull().firstOrNull { File(it, "android/app/src/main/java").isDirectory } + ?: error("repository root not found from $cwd; consumer wiring guard must fail closed") + } + + private fun production(path: String): String = File(repositoryRoot(), path).also { + assertTrue("production source missing: $path", it.isFile) + }.readText() + + private fun fixture(): JSONObject { + val cwd = File(System.getProperty("user.dir") ?: ".") + val file = listOf( + File(cwd, "../Tools/parity_cases/device_raw_sources.json"), + File(cwd, "Tools/parity_cases/device_raw_sources.json"), + File(cwd, "../../Tools/parity_cases/device_raw_sources.json"), + ).firstOrNull(File::isFile) + assertNotNull("device_raw_sources.json not found from $cwd; parity must fail closed", file) + return JSONObject(file!!.readText()) + } + + @Test + fun sourceResolutionMatchesSharedOracle() { + val cases = fixture().getJSONArray("sourceCases") + repeat(cases.length()) { index -> + val case = cases.getJSONObject(index) + val active = case.getString("activeDeviceId") + val registered = case.getJSONArray("registeredWhoopIds").let { xs -> + List(xs.length()) { xs.getString(it) } + } + val imported = case.getJSONArray("imported").let { xs -> + List(xs.length()) { xs.getString(it) } + } + val computed = case.getJSONArray("computed").let { xs -> + List(xs.length()) { xs.getString(it) } + } + val actual = WhoopRepository.rawWhoopSourceIdsFor(active, registered) + assertEquals(case.getString("name"), imported, actual) + assertEquals(case.getString("name"), computed, actual.map { "$it-noop" }) + } + } + + @Test + fun rrIdentityMergeMatchesSharedOracle() { + val cases = fixture().getJSONArray("rrCases") + repeat(cases.length()) { index -> + val case = cases.getJSONObject(index) + val sources = case.getJSONArray("sources") + val inputs = List(sources.length()) { sourceIndex -> + val source = sources.getJSONObject(sourceIndex) + val id = source.getString("id") + val beats = source.getJSONArray("beats") + List(beats.length()) { beatIndex -> + val beat = beats.getJSONObject(beatIndex) + RrInterval( + id, beat.getLong("ts"), beat.getInt("rrMs"), seq = beat.getInt("seq"), + ord = beat.optInt("ord").takeIf { beat.has("ord") }, + ) + } + } + val actual = WhoopRepository.mergeRrByIdentity(inputs).map { + listOf(it.ts, it.rrMs, it.seq, it.ord) + } + val expectedJson = case.getJSONArray("expected") + val expected = List(expectedJson.length()) { + expectedJson.getJSONObject(it).let { beat -> + listOf( + beat.getLong("ts"), beat.getInt("rrMs"), beat.getInt("seq"), + beat.optInt("ord").takeIf { beat.has("ord") }, + ) + } + } + assertEquals(case.getString("name"), expected, actual) + } + } + + @Test + fun aiCoachAndFullDayTimelineUseAllSourceRawFacades() { + val coach = production("android/app/src/main/java/com/noop/ai/AiCoach.kt") + assertTrue("AI Coach stress context must union R-R across the worn timeline", coach.contains("repo.rrIntervalsUnion(")) + assertFalse("AI Coach must not select one R-R owner", coach.contains("repo.rrIntervalsForDevice(activeStrapId()")) + + val timeline = production("android/app/src/main/java/com/noop/ui/FullDayChartScreen.kt") + for (call in listOf("repo.hrBucketsUnion(", "repo.rrIntervalsUnion(", "repo.gravitySamplesUnion(")) { + assertTrue("Full Day Chart must use $call", timeline.contains(call)) + } + assertFalse("Full Day HRV must not pin R-R to the selected device", timeline.contains("repo.rrIntervalsForDevice(deviceId,")) + assertFalse("Full Day motion must not pin gravity to the selected device", timeline.contains("repo.gravitySamplesForDevice(deviceId,")) + } +} diff --git a/android/app/src/test/java/com/noop/data/RawTimelineRepositoryDaoTest.kt b/android/app/src/test/java/com/noop/data/RawTimelineRepositoryDaoTest.kt new file mode 100644 index 0000000000..e1e4dda3e6 --- /dev/null +++ b/android/app/src/test/java/com/noop/data/RawTimelineRepositoryDaoTest.kt @@ -0,0 +1,250 @@ +package com.noop.data + +import java.lang.reflect.Proxy +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +/** DAO-backed contract tests for the public device-aware repository reads. */ +class RawTimelineRepositoryDaoTest { + private fun rr(source: String, ts: Long, ms: Int) = + RrInterval(deviceId = source, ts = ts, rrMs = ms) + + private fun device(id: String, status: String, addedAt: Long, brand: String = "WHOOP") = + PairedDeviceRow(id, brand, "test", null, null, "whoop", "hr", status, addedAt, addedAt) + + private val allWhoops = listOf( + device("whoop-old", "archived", 1), + device("my-whoop", "paired", 2), + device("polar-ignored", "paired", 3, brand = "Polar"), + device("whoop-new", "active", 4), + ) + + @Test + fun rrIntervalsUnionQueriesActiveThenCanonicalAndMergesTheirRows() = runBlocking { + val queried = mutableListOf() + val active = listOf(rr("whoop-new", 101, 810), rr("whoop-new", 103, 830)) + val canonical = listOf(rr("my-whoop", 100, 800), rr("my-whoop", 101, 810)) + val repo = WhoopRepository(proxyDao { method, args -> + when (method) { + "pairedDevices" -> listOf( + device("my-whoop", "paired", 1), + device("whoop-new", "active", 2), + ) + "rrIntervals" -> { + val id = args[0] as String + queried += id + if (id == "whoop-new") active else canonical + } + else -> throw UnsupportedOperationException(method) + } + }) + + val rows = repo.rrIntervalsUnion("whoop-new", 10, 20, limit = 50) + + assertEquals(listOf("whoop-new", "my-whoop"), queried) + assertEquals(listOf(100L, 101L, 103L), rows.map { it.ts }) + assertEquals("whoop-new", rows.first { it.ts == 101L }.deviceId) + } + + @Test + fun rrIntervalsUnionCanonicalActivePerformsOneLegacyReadUnchanged() = runBlocking { + val queried = mutableListOf() + val canonical = listOf(rr("my-whoop", 100, 800), rr("my-whoop", 101, 810)) + val repo = WhoopRepository(proxyDao { method, args -> + when (method) { + "pairedDevices" -> listOf(device("my-whoop", "active", 1)) + "rrIntervals" -> { + queried += args[0] as String + canonical + } + else -> throw UnsupportedOperationException(method) + } + }) + + val rows = repo.rrIntervalsUnion("my-whoop", 10, 20, limit = 50) + + assertEquals(listOf("my-whoop"), queried) + assertSame("single-source legacy result must pass through unchanged", canonical, rows) + } + + @Test + fun rawUnionsQueryActiveThenEveryHistoricalWhoopThenCanonical() = runBlocking { + val queried = mutableListOf>() + val repo = WhoopRepository(proxyDao { method, args -> + when (method) { + "pairedDevices" -> allWhoops + "hrSamples" -> { queried += "hr" to (args[0] as String); emptyList() } + "hrBuckets" -> { queried += "hrBucket" to (args[0] as String); emptyList() } + "rrIntervals" -> { queried += "rr" to (args[0] as String); emptyList() } + "gravitySamples" -> { queried += "gravity" to (args[0] as String); emptyList() } + else -> throw UnsupportedOperationException(method) + } + }) + + repo.hrSamplesUnion("whoop-new", 10, 20) + repo.hrBucketsUnion("whoop-new", 10, 20) + repo.rrIntervalsUnion("whoop-new", 10, 20) + repo.gravitySamplesUnion("whoop-new", 10, 20) + + val expected = listOf("whoop-new", "whoop-old", "my-whoop") + assertEquals(expected, queried.filter { it.first == "hr" }.map { it.second }) + assertEquals(expected, queried.filter { it.first == "hrBucket" }.map { it.second }) + assertEquals(expected, queried.filter { it.first == "rr" }.map { it.second }) + assertEquals(expected, queried.filter { it.first == "gravity" }.map { it.second }) + } + + @Test + fun rawResolverKeepsActiveNonWhoopButExcludesUnrelatedNonWhoopRegistryRows() = runBlocking { + val queried = mutableListOf() + val repo = WhoopRepository(proxyDao { method, args -> + when (method) { + "pairedDevices" -> listOf( + device("whoop-old", "archived", 1), + device("polar-other", "paired", 2, brand = "Polar"), + device("polar-active", "active", 3, brand = "Polar"), + device("my-whoop", "paired", 4), + ) + "hrSamples" -> { queried += args[0] as String; emptyList() } + else -> throw UnsupportedOperationException(method) + } + }) + + repo.hrSamplesUnion("polar-active", 10, 20) + + assertEquals(listOf("polar-active", "whoop-old", "my-whoop"), queried) + assertEquals( + listOf("polar-active", "whoop-old", "my-whoop"), + WhoopRepository.rawWhoopSourceIdsFor( + activeDeviceId = "polar-active", + registeredWhoopIds = listOf("whoop-old", "my-whoop"), + ), + ) + } + + @Test + fun sessionMotionFallbackIncludesArchivedWhoopButNotOtherBrands() = runBlocking { + val queried = mutableListOf() + val repo = WhoopRepository(proxyDao { method, args -> + when (method) { + "pairedDevices" -> allWhoops + "sessionMotionJson" -> { + val id = args[0] as String + queried += id + if (id == "whoop-old-noop") "[3,4]" else null + } + else -> throw UnsupportedOperationException(method) + } + }) + + val result = repo.sessionMotions( + "whoop-new", + listOf(SleepSession(deviceId = "unknown-owner", startTs = 1_000, endTs = 2_000)), + ) + + assertEquals(listOf("unknown-owner-noop", "whoop-new-noop", "whoop-old-noop"), queried) + assertEquals(listOf(3.0, 4.0), result[1_000L]) + } + + @Test + fun sleepUnionsIncludeArchivedRawAndComputedNights() = runBlocking { + val queried = mutableListOf() + val rows = mapOf( + "whoop-new" to listOf(SleepSession("whoop-new", 3_000, 4_000)), + "whoop-old" to listOf(SleepSession("whoop-old", 1_000, 2_000)), + "whoop-new-noop" to listOf(SleepSession("whoop-new-noop", 7_000, 8_000)), + "whoop-old-noop" to listOf(SleepSession("whoop-old-noop", 5_000, 6_000)), + ) + val repo = WhoopRepository(proxyDao { method, args -> + when (method) { + "pairedDevices" -> allWhoops + "sleepSessions" -> { + val id = args[0] as String + queried += id + rows[id].orEmpty() + } + else -> throw UnsupportedOperationException(method) + } + }) + + val raw = repo.sleepSessionsUnion("whoop-new", 0, 10_000) + val computed = repo.computedSleepSessionsUnion("whoop-new", 0, 10_000) + + assertEquals( + listOf("whoop-new", "whoop-old", "my-whoop", "whoop-new-noop", "whoop-old-noop", "my-whoop-noop"), + queried, + ) + assertEquals(listOf("whoop-new", "whoop-old"), raw.map { it.deviceId }) + assertEquals(listOf("whoop-new-noop", "whoop-old-noop"), computed.map { it.deviceId }) + } + + @Test + fun habitualMidsleepLearnsAcrossActiveAndArchivedStrapHistory() = runBlocking { + val queried = mutableListOf() + val now = System.currentTimeMillis() / 1_000L + fun nights(id: String, dayOffset: IntRange) = dayOffset.map { day -> + val start = now - day * 86_400L + SleepSession(id, start, start + 8 * 3_600L) + } + val rows = mapOf( + "whoop-new" to nights("whoop-new", 1..7), + "whoop-old-noop" to nights("whoop-old-noop", 8..14), + ) + val repo = WhoopRepository(proxyDao { method, args -> + when (method) { + "pairedDevices" -> allWhoops + "sleepSessions" -> { + val id = args[0] as String + queried += id + rows[id].orEmpty() + } + else -> throw UnsupportedOperationException(method) + } + }) + + val habitual = repo.habitualMidsleepSec("whoop-new", days = 30) + + assertEquals( + listOf("whoop-new", "whoop-old", "my-whoop", "whoop-new-noop", "whoop-old-noop", "my-whoop-noop"), + queried, + ) + org.junit.Assert.assertNotNull(habitual) + } + + @Test + fun sessionMotionsPrefersOldSessionOwnerOverSameStartFallbacks() = runBlocking { + val queried = mutableListOf>() + val start = 1_000L + val values = mapOf( + "whoop-old-noop" to "[1,2]", + "whoop-new-noop" to "[8,8]", + "my-whoop-noop" to "[9,9]", + ) + val repo = WhoopRepository(proxyDao { method, args -> + when (method) { + "pairedDevices" -> allWhoops + "sessionMotionJson" -> { + val id = args[0] as String + queried += id to (args[1] as Long) + values[id] + } + else -> throw UnsupportedOperationException(method) + } + }) + + val result = repo.sessionMotions( + activeStrapId = "whoop-new", + sessions = listOf(SleepSession(deviceId = "whoop-old", startTs = start, endTs = start + 3600)), + ) + + assertEquals(listOf("whoop-old-noop" to start), queried) + assertEquals(listOf(1.0, 2.0), result[start]) + } + + private fun proxyDao(call: (String, Array) -> Any?): WhoopDao = + Proxy.newProxyInstance( + WhoopDao::class.java.classLoader, + arrayOf(WhoopDao::class.java), + ) { _, method, args -> call(method.name, args ?: emptyArray()) } as WhoopDao +} diff --git a/android/app/src/test/java/com/noop/data/RawTimelineUnionTest.kt b/android/app/src/test/java/com/noop/data/RawTimelineUnionTest.kt new file mode 100644 index 0000000000..a5332a9a16 --- /dev/null +++ b/android/app/src/test/java/com/noop/data/RawTimelineUnionTest.kt @@ -0,0 +1,51 @@ +package com.noop.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class RawTimelineUnionTest { + private fun rr(source: String, ts: Long, ms: Int, seq: Int = 0) = + RrInterval(deviceId = source, ts = ts, rrMs = ms, seq = seq) + + @Test + fun rrUnionIsOrderedAndActiveWinsExactBeatTie() { + val active = listOf(rr("whoop-new", 102, 810), rr("whoop-new", 100, 800)) + val canonical = listOf(rr("my-whoop", 99, 790), rr("my-whoop", 100, 800), rr("my-whoop", 101, 805)) + + val merged = WhoopRepository.mergeRrByIdentity(listOf(active, canonical)) + + assertEquals(listOf(99L, 100L, 101L, 102L), merged.map { it.ts }) + assertEquals("whoop-new", merged.first { it.ts == 100L }.deviceId) + } + + @Test + fun rrUnionKeepsDistinctBeatsAtSameTimestamp() { + val merged = WhoopRepository.mergeRrByIdentity( + listOf( + listOf(rr("whoop-new", 100, 800, seq = 0), rr("whoop-new", 100, 810, seq = 1)), + listOf(rr("my-whoop", 100, 800, seq = 0)), + ), + ) + + assertEquals(listOf(800, 810), merged.map { it.rrMs }) + } + + @Test + fun rrSingleCanonicalSourceIsUnchanged() { + val only = listOf(rr("my-whoop", 100, 800)) + assertSame(only, WhoopRepository.mergeRrByIdentity(listOf(only))) + } + + @Test + fun motionSourcesPreferSessionOwnerThenActiveAndCanonicalComputed() { + assertEquals( + listOf("whoop-old-noop", "whoop-new-noop", "my-whoop-noop"), + WhoopRepository.motionSourceIdsFor("whoop-new", "whoop-old-noop"), + ) + assertEquals( + listOf("my-whoop-noop"), + WhoopRepository.motionSourceIdsFor("my-whoop", "my-whoop"), + ) + } +} diff --git a/android/app/src/test/java/com/noop/ui/RawSensorDeviceScopeAuditTest.kt b/android/app/src/test/java/com/noop/ui/RawSensorDeviceScopeAuditTest.kt new file mode 100644 index 0000000000..dd7777403d --- /dev/null +++ b/android/app/src/test/java/com/noop/ui/RawSensorDeviceScopeAuditTest.kt @@ -0,0 +1,208 @@ +package com.noop.ui + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * Architecture guard for the split between the canonical WHOOP history namespace and physical + * sensor ownership. + * + * `"my-whoop"` is a valid, stable source for imported/daily series. It is not, however, the id of + * every physical strap: a re-paired or second strap banks HR, R-R and accelerometer samples under + * `whoop-`. A production UI call that passes the canonical literal to a raw-sensor API therefore + * silently drops those samples. Stress exposed this failure as a missing intraday chart. + * + * This deliberately narrow source audit bans only that invalid combination. Dynamic owner reads + * (session detail and diagnostics) remain legal, as do canonical `metricSeries` and import uses. + * Device-aware repository methods are also legal because their first argument is an active/owning + * device id and the repository performs the canonical union. + */ +class RawSensorDeviceScopeAuditTest { + + private fun uiSourceDir(): File { + val userDir = File(System.getProperty("user.dir") ?: ".") + val found = listOf( + File(userDir, "src/main/java/com/noop/ui"), + File(userDir, "app/src/main/java/com/noop/ui"), + File(userDir, "android/app/src/main/java/com/noop/ui"), + ).firstOrNull { it.isDirectory } + assertNotNull( + "com/noop/ui not found from user.dir=$userDir; a source guard must fail closed", + found, + ) + return found!! + } + + private fun repositorySource(): String { + val javaRoot = uiSourceDir().parentFile + val repository = File(javaRoot, "data/WhoopRepository.kt") + assertTrue("WhoopRepository.kt not found", repository.isFile) + return stripComments(repository.readText()) + } + + /** Removes comments so examples and migration notes do not count as production calls. */ + private fun stripComments(source: String): String { + val withoutBlocks = Regex("/\\*.*?\\*/", RegexOption.DOT_MATCHES_ALL).replace(source, "") + return withoutBlocks.lineSequence().joinToString("\n") { line -> + var quoted = false + var escaped = false + var cut = -1 + var index = 0 + while (index < line.length - 1) { + val char = line[index] + if (char == '"' && !escaped) quoted = !quoted + if (!quoted && char == '/' && line[index + 1] == '/') { + cut = index + break + } + escaped = char == '\\' && !escaped + if (char != '\\') escaped = false + index++ + } + if (cut >= 0) line.substring(0, cut) else line + } + } + + private val directRawRead = Regex("""\b(hrSamples(?:ForDevice)?|rrIntervals(?:ForDevice)?|gravitySamples(?:ForDevice)?)\s*\(""") + + private data class RawCall(val method: String, val args: String, val offset: Int) + + /** Extract raw calls with balanced parentheses so named arguments can appear in any order. */ + private fun rawCalls(source: String): List { + val clean = stripComments(source) + val calls = mutableListOf() + directRawRead.findAll(clean).forEach { match -> + val open = clean.indexOf('(', match.range.first) + var depth = 1 + var quoted = false + var escaped = false + var end = open + 1 + while (end < clean.length && depth > 0) { + val char = clean[end] + if (char == '"' && !escaped) quoted = !quoted + if (!quoted) { + if (char == '(') depth++ + if (char == ')') depth-- + } + escaped = char == '\\' && !escaped + if (char != '\\') escaped = false + end++ + } + if (depth == 0) { + calls += RawCall( + method = match.groupValues[1], + args = clean.substring(open + 1, end - 1), + offset = match.range.first, + ) + } + } + return calls + } + + private fun offenders(source: String): List = rawCalls(source).filter { call -> + call.args.trimStart().startsWith("\"my-whoop\"") || + Regex("""\bdeviceId\s*=\s*"my-whoop"""").containsMatchIn(call.args) + } + + @Test + fun scannerRejectsCanonicalRawReadsButAllowsCanonicalMetricsAndDeviceAwareReads() { + assertTrue(offenders("repo.hrSamples(\"my-whoop\", from, to)").isNotEmpty()) + assertTrue(offenders("repo.rrIntervals(\n deviceId = \"my-whoop\", from, to)").isNotEmpty()) + assertTrue(offenders("repo.gravitySamples( \"my-whoop\", from, to)").isNotEmpty()) + assertTrue(offenders("repo.hrSamplesForDevice(\"my-whoop\", from, to)").isNotEmpty()) + assertTrue( + offenders( + "repo.hrSamples(from = from, to = to, limit = 5, deviceId = \"my-whoop\")", + ).isNotEmpty(), + ) + + assertTrue(offenders("repo.metricSeries(\"my-whoop\", \"stress\", from, to)").isEmpty()) + assertTrue(offenders("repo.hrSamplesUnion(activeStrapId, from, to)").isEmpty()) + assertTrue(offenders("repo.rrIntervals(session.deviceId, from, to)").isEmpty()) + assertTrue(offenders("// repo.hrSamples(\"my-whoop\", from, to)").isEmpty()) + assertTrue(offenders("/* repo.gravitySamples(\"my-whoop\", from, to) */").isEmpty()) + } + + @Test + fun repositoryHasNoAmbiguousDeviceScopedRawReaderNames() { + val source = repositorySource() + for (oldName in listOf("hrSamples", "hrBuckets", "rrIntervals", "gravitySamples", "sleepSessions")) { + assertFalse( + "Device-scoped repository reads must say ForDevice; ambiguous $oldName can be misused by UI", + Regex("""suspend\s+fun\s+$oldName\s*\(""").containsMatchIn(source), + ) + } + for (explicitName in listOf( + "hrSamplesForDevice", "hrBucketsForDevice", "rrIntervalsForDevice", + "gravitySamplesForDevice", "sleepSessionsForDevice", + )) { + assertTrue("Missing explicit repository API $explicitName", source.contains("fun $explicitName(")) + } + } + + @Test + fun productionUiNeverPinsRawSensorReadsToCanonicalWhoop() { + val violations = mutableListOf() + uiSourceDir().walkTopDown() + .filter { it.isFile && it.extension == "kt" } + .forEach { file -> + val source = file.readText() + offenders(source).forEach { call -> + val line = stripComments(source).take(call.offset).count { it == '\n' } + 1 + violations += "${file.name}:$line: ${call.method}(${call.args.trim().take(80)})" + } + } + + assertFalse( + "Raw HR/R-R/gravity UI reads must use an owning device or a device-aware repository API; " + + "`my-whoop` is only the canonical history/import namespace:\n" + + violations.joinToString("\n"), + violations.isNotEmpty(), + ) + } + + @Test + fun stressUsesAllThreeDeviceAwareRawTimelines() { + val stress = File(uiSourceDir(), "StressScreen.kt") + assertTrue("StressScreen.kt not found", stress.isFile) + val source = stripComments(stress.readText()) + + val bypasses = directRawRead.findAll(source).map { it.value }.toList() + assertTrue( + "Stress must not select a raw device id itself; use the repository unions: $bypasses", + bypasses.isEmpty(), + ) + for (api in listOf("hrSamplesUnion(", "rrIntervalsUnion(", "gravitySamplesUnion(")) { + assertTrue("Stress must read through $api", source.contains(api)) + } + } + + @Test + fun coachStressAndFullDayChartUseDeviceAwareRawTimelines() { + val javaRoot = uiSourceDir().parentFile + val coach = File(javaRoot, "ai/AiCoach.kt") + val chart = File(uiSourceDir(), "FullDayChartScreen.kt") + assertTrue("AiCoach.kt not found", coach.isFile) + assertTrue("FullDayChartScreen.kt not found", chart.isFile) + + val coachSource = stripComments(coach.readText()) + assertTrue( + "AI Coach derived stress must include archived strap R-R history", + coachSource.contains("repo.rrIntervalsUnion(activeStrapId()"), + ) + assertFalse( + "AI Coach derived stress must not bypass the device-aware R-R resolver", + coachSource.contains("repo.rrIntervalsForDevice(activeStrapId()"), + ) + + val chartSource = stripComments(chart.readText()) + for (api in listOf("hrSamplesUnion(", "hrBucketsUnion(", "rrIntervalsUnion(", "gravitySamplesUnion(")) { + assertTrue("Full-day chart must read through $api", chartSource.contains(api)) + } + assertFalse(chartSource.contains("repo.rrIntervalsForDevice(deviceId")) + assertFalse(chartSource.contains("repo.gravitySamplesForDevice(deviceId")) + } +} diff --git a/android/app/src/test/java/com/noop/ui/StressRawReadScopeTest.kt b/android/app/src/test/java/com/noop/ui/StressRawReadScopeTest.kt new file mode 100644 index 0000000000..6005342488 --- /dev/null +++ b/android/app/src/test/java/com/noop/ui/StressRawReadScopeTest.kt @@ -0,0 +1,33 @@ +package com.noop.ui + +import java.io.File +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StressRawReadScopeTest { + private fun source(): String { + var dir: File? = File(System.getProperty("user.dir")) + repeat(5) { + val current = dir ?: return@repeat + val candidate = File(current, "app/src/main/java/com/noop/ui/StressScreen.kt") + if (candidate.isFile) return candidate.readText() + dir = current.parentFile + } + error("StressScreen.kt not found from ${System.getProperty("user.dir")}") + } + + @Test + fun intradayAndBaselineUseDeviceAwareRawReads() { + val text = source().replace(Regex("\\s+"), " ") + assertTrue(text.contains("hrSamplesUnion(vm.activeStrapId")) + assertTrue(text.contains("rrIntervalsUnion(vm.activeStrapId")) + assertTrue(text.contains("gravitySamplesUnion(vm.activeStrapId")) + assertFalse(Regex("(hrSamples|rrIntervals|gravitySamples)\\(\\s*\"my-whoop\"").containsMatchIn(text)) + } + + @Test + fun storedDailyStressRemainsCanonical() { + assertTrue(source().contains("metricSeries(\"my-whoop\", \"stress\"")) + } +}