Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions Packages/WhoopProtocol/Sources/WhoopProtocol/Streams.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}

Expand Down
4 changes: 2 additions & 2 deletions Packages/WhoopStore/Sources/WhoopStore/Reads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"])
}
}
}
Expand Down
6 changes: 2 additions & 4 deletions Strand/AI/AICoach.swift
Original file line number Diff line number Diff line change
Expand Up @@ -553,17 +553,15 @@ 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.
func stressIndexLine() async -> String? {
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)
}
Expand Down
153 changes: 118 additions & 35 deletions Strand/Data/Repository.swift

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Strand/Liquid/LiquidTodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions Strand/Screens/SleepView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down
9 changes: 3 additions & 6 deletions Strand/Screens/StressView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Strand/Screens/TodayView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 5 additions & 13 deletions Strand/Screens/V5PillarHosts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
118 changes: 118 additions & 0 deletions StrandTests/DeviceRawSourceParityTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading