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
25 changes: 25 additions & 0 deletions Packages/WhoopStore/Sources/WhoopStore/Whoop5RRReadPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,31 @@ extension WhoopStore {
}
}

/// Whether the strict WHOOP 5 read policy withheld this exact window solely because it contains
/// unlabelled, non-quarantined legacy rows and no verified scoring transport. This is deliberately
/// narrower than "the R-R read was empty": an unworn night, WHOOP 4, another brand, and a labelled
/// but insufficient transport all return false and therefore remain ordinary current-score outcomes.
public func legacyWhoop5RRWithheld(deviceId: String, from: Int, to: Int,
unlabelledAliasOfWhoop5: Bool = false) async throws -> Bool {
try syncRead { db in
guard try Self.isWhoop5RRSource(db: db, deviceId: deviceId,
unlabelledAliasOfWhoop5: unlabelledAliasOfWhoop5) else {
return false
}
return try Bool.fetchOne(db, sql: """
SELECT
EXISTS(SELECT 1 FROM rrInterval
WHERE deviceId = :d AND ts >= :f AND ts <= :t
AND srcChannel IS NULL
AND (tsSuspect IS NULL OR tsSuspect <> 1))
AND NOT EXISTS(SELECT 1 FROM rrInterval
WHERE deviceId = :d AND ts >= :f AND ts <= :t
AND srcChannel IN \(Self.scorableWhoop5Channels)
AND (tsSuspect IS NULL OR tsSuspect <> 1))
""", arguments: ["d": deviceId, "f": from, "t": to]) ?? false
}
}

/// Shared by RR reads and consumers whose cached/union reads must obey the same owner policy.
public func isWhoop5RRSource(deviceId: String, unlabelledAliasOfWhoop5: Bool = false) async throws -> Bool {
try syncRead { try Self.isWhoop5RRSource(db: $0, deviceId: deviceId,
Expand Down
37 changes: 37 additions & 0 deletions Packages/WhoopStore/Tests/WhoopStoreTests/Whoop5RRStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ final class Whoop5RRStoreTests: XCTestCase {
let guarded = try await store.rrIntervals(deviceId: "my-whoop", from: 0, to: 1000, limit: 100,
unlabelledAliasOfWhoop5: true)
XCTAssertTrue(guarded.isEmpty)
let withheld = try await store.legacyWhoop5RRWithheld(
deviceId: "my-whoop", from: 0, to: 1000, unlabelledAliasOfWhoop5: true)
XCTAssertTrue(withheld, "the canonical alias must expose the same strict-window status as its read")
try registry(store, model: "4.0")
let confirmedFour = try await store.isWhoop5RRSource(deviceId: "my-whoop", unlabelledAliasOfWhoop5: true)
XCTAssertFalse(confirmedFour)
Expand Down Expand Up @@ -128,6 +131,40 @@ final class Whoop5RRStoreTests: XCTestCase {
XCTAssertEqual(stored.count, 2, "source policy never rewrites or removes legacy intervals")
}

func testLegacyWithheldStatusUsesTheExactScoringWindowAndSourcePolicy() async throws {
let s = try await WhoopStore.inMemory()
try registry(s, model: "5.0 MG")
_ = try await s.insert(Streams(rr: [RRInterval(ts: 100, rrMs: 1000)]), deviceId: id)
let withheld = try await s.legacyWhoop5RRWithheld(deviceId: id, from: 100, to: 200)
XCTAssertTrue(withheld)
let outside = try await s.legacyWhoop5RRWithheld(deviceId: id, from: 101, to: 200)
XCTAssertFalse(outside,
"legacy rows outside the exact read window cannot protect a score")
try await s.dbWriter.write { db in
try db.execute(sql: "UPDATE rrInterval SET tsSuspect = 1 WHERE deviceId = ? AND ts = 100",
arguments: [self.id])
}
let quarantined = try await s.legacyWhoop5RRWithheld(deviceId: id, from: 100, to: 200)
XCTAssertFalse(quarantined, "quarantined rows are excluded exactly like the scoring read")
try await s.dbWriter.write { db in
try db.execute(sql: "UPDATE rrInterval SET tsSuspect = NULL WHERE deviceId = ? AND ts = 100",
arguments: [self.id])
}

_ = try await s.insert(Streams(rr: [RRInterval(ts: 150, rrMs: 990,
srcChannel: .whoop5Standard)]), deviceId: id)
let labelled = try await s.legacyWhoop5RRWithheld(deviceId: id, from: 100, to: 200)
XCTAssertFalse(labelled,
"any scorable labelled transport ends legacy protection")

try registry(s, model: "4.0")
let whoop4 = try await s.legacyWhoop5RRWithheld(deviceId: id, from: 100, to: 149)
XCTAssertFalse(whoop4)
try registry(s, model: "5.0 MG", brand: "Oura")
let otherBrand = try await s.legacyWhoop5RRWithheld(deviceId: id, from: 100, to: 149)
XCTAssertFalse(otherBrand)
}

func testSourceSelectionPrecedesLimitAndSharesBoundsAndQuarantine() async throws {
let s = try await WhoopStore.inMemory()
try registry(s, model: "5.0 MG")
Expand Down
110 changes: 100 additions & 10 deletions Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,13 @@ final class IntelligenceEngine: ObservableObject {
let primarySessionRHRCoverage: PrimarySessionRestingHR.Coverage?
}

/// Exact pre-upgrade score cells retained only while an unlabelled WHOOP 5 R-R window is withheld.
private struct LegacyScoreSnapshot {
let avgHrv: Double
let recovery: Double?
let recoverySource: String?
}

struct Computed: Identifiable {
let day: String
let recovery: Double?
Expand All @@ -272,6 +279,11 @@ final class IntelligenceEngine: ObservableObject {
/// "+0.3 C vs your normal" with a relative tier tag; never a fake clinical absolute.
var skinTempRel: SkinTempRelative? = nil
var id: String { day }

func withLegacyScore(hrv: Double, recovery: Double?) -> Computed {
Computed(day: day, recovery: recovery, strain: strain, sleepMin: sleepMin, hrv: hrv, rhr: rhr,
source: source, confidence: confidence, drivers: drivers, skinTempRel: skinTempRel)
}
}

/// Optional sink for the per-day scoring diagnostic, fed line-by-line into the SAME shareable strap
Expand Down Expand Up @@ -2016,6 +2028,16 @@ final class IntelligenceEngine: ObservableObject {
// the auto path produced NO trace at all (the "mode was on but produced NO trace" report), so an
// "auto workout appeared then vanished" could not be explained from an export. Diagnostic only.
let workoutsTraceActive = TestCentre.active(.workouts)
let oldestDay = AnalyticsEngine.dayString(nowLocalMidnight - (maxDays - 1) * 86_400,
offsetSec: tzOffset)
let newestDay = AnalyticsEngine.dayString(nowLocalMidnight, offsetSec: tzOffset)
let strictCanonicalAlias = (try? await store.isWhoop5RRSource(deviceId: regActiveId)) ?? true
let legacySnapshots = await Self.legacyScoreSnapshots(
store: store, computedId: computedId, from: oldestDay, to: newestDay,
fresh: scoredNights.map { $0.daily }, ownerByDay: resolvedScoreOwnerByDay,
nowLocalMidnight: nowLocalMidnight, now: now, offsetSec: tzOffset,
maxDays: maxDays, strictCanonicalAlias: strictCanonicalAlias)
var appliedLegacySnapshots: [String: LegacyScoreSnapshot] = [:]
for night in scoredNights {
// #299: scope the edits to THIS day before folding. A userEdited row / hand-logged nap belongs
// to exactly ONE day — the day its night ENDS on, matching the daily's end-day bucket. `endTs`
Expand Down Expand Up @@ -2263,10 +2285,6 @@ final class IntelligenceEngine: ObservableObject {
// only , imported "my-whoop" rows are never touched (a BLE-only WHOOP 4.0 user has no import
// fallback). Rows older than the window keep their old keys (cosmetic off-by-one, acceptable).
// yyyy-MM-dd sorts chronologically, so the string range IS a date range.
let oldestDay = AnalyticsEngine.dayString(nowLocalMidnight - (maxDays - 1) * 86_400,
offsetSec: tzOffset)
let newestDay = AnalyticsEngine.dayString(nowLocalMidnight, offsetSec: tzOffset)

// ── Source-only Charge/Rest fold for wearable imports (Oura / Fitbit / Garmin / Health Connect) ──
// Same honesty gap the watch fold above closes (#823), extended to the other import-only sources: a
// user who ONLY imports an Oura/Fitbit/Garmin export (or Health Connect) has DAILY aggregates (HRV +
Expand Down Expand Up @@ -2302,6 +2320,22 @@ final class IntelligenceEngine: ObservableObject {
}
}

// Apply the exact pair only after current-score traces and derived series were produced from the
// current inputs. A legacy snapshot must not masquerade as a value recalculated against today's
// baselines; it only protects the two persisted/displayed cells from a destructive nil overwrite.
var persistedDailies = dailies
for index in persistedDailies.indices {
let fresh = persistedDailies[index]
guard let snapshot = legacySnapshots[fresh.day], fresh.avgHrv == nil,
(fresh.totalSleepMin ?? 0) > 0 else { continue }
persistedDailies[index] = fresh.with(avgHrv: snapshot.avgHrv, recovery: snapshot.recovery)
appliedLegacySnapshots[fresh.day] = snapshot
}
for index in out.indices {
guard let snapshot = appliedLegacySnapshots[out[index].day] else { continue }
out[index] = out[index].withLegacyScore(hrv: snapshot.avgHrv, recovery: snapshot.recovery)
}

// Persist the computed scores under a dedicated "-noop" source so the WHOLE dashboard
// (Today / Recovery / Strain / Sleep / Trends), not just this screen, reads them. The
// Repository merges these UNDER any imported "my-whoop" rows, so a real WHOOP import
Expand All @@ -2316,11 +2350,18 @@ final class IntelligenceEngine: ObservableObject {
// resolver override). Persist scores + provenance atomically so a failed write can never label an
// older score with a newer provider. The last row for a duplicate day wins, matching the upsert.
var provenanceByCell: [String: ScoreInputProvenanceRow] = [:]
for daily in dailies {
for daily in persistedDailies {
guard let source = resolvedScoreOwnerByDay[daily.day] else { continue }
if daily.recovery != nil {
provenanceByCell["\(daily.day)\u{1F}recovery"] =
ScoreInputProvenanceRow(day: daily.day, key: "recovery", sourceId: source)
if let snapshot = appliedLegacySnapshots[daily.day] {
if let oldSource = snapshot.recoverySource {
provenanceByCell["\(daily.day)\u{1F}recovery"] =
ScoreInputProvenanceRow(day: daily.day, key: "recovery", sourceId: oldSource)
}
} else {
provenanceByCell["\(daily.day)\u{1F}recovery"] =
ScoreInputProvenanceRow(day: daily.day, key: "recovery", sourceId: source)
}
}
if daily.strain != nil {
provenanceByCell["\(daily.day)\u{1F}strain"] =
Expand All @@ -2346,7 +2387,7 @@ final class IntelligenceEngine: ObservableObject {
markerSources = sourceIds
}
try? await store.persistComputedScores(
dailyMetrics: dailies,
dailyMetrics: persistedDailies,
metricPoints: restPoints,
provenance: Array(provenanceByCell.values),
deviceId: computedId,
Expand All @@ -2368,8 +2409,8 @@ final class IntelligenceEngine: ObservableObject {
// covers the window, so eviction runs exactly as before; `persistComputedScores` is guarded the
// same way, so an empty pass leaves the persisted window untouched. Twin of the Android
// WhoopDao.replaceComputedScoreWindow empty guard.
if !dailies.isEmpty {
let freshKeys = Set(dailies.map { $0.day })
if !persistedDailies.isEmpty {
let freshKeys = Set(persistedDailies.map { $0.day })
let existingWindow = (try? await store.dailyMetrics(deviceId: computedId, from: oldestDay, to: newestDay)) ?? []
for stale in existingWindow where !freshKeys.contains(stale.day) {
_ = try? await store.deleteDailyMetrics(deviceId: computedId, from: stale.day, to: stale.day)
Expand Down Expand Up @@ -2995,6 +3036,44 @@ final class IntelligenceEngine: ObservableObject {
skinTempC: nightlySkinTempC)
}

/// Resolve conservative legacy snapshots before the computed-window upsert can replace them. The date
/// loop reuses the scorer's own day/window arithmetic instead of parsing calendar dates, keeping both
/// DST behaviour and today's shortened forward window identical to the scoring read.
private static func legacyScoreSnapshots(
store: WhoopStore, computedId: String, from: String, to: String,
fresh: [DailyMetric], ownerByDay: [String: String],
nowLocalMidnight: Int, now: Int, offsetSec: Int, maxDays: Int,
strictCanonicalAlias: Bool
) async -> [String: LegacyScoreSnapshot] {
let existing = (try? await store.dailyMetrics(deviceId: computedId, from: from, to: to)) ?? []
let existingByDay = Dictionary(existing.map { ($0.day, $0) }, uniquingKeysWith: { a, _ in a })
var snapshots: [String: LegacyScoreSnapshot] = [:]
for day in fresh {
guard day.avgHrv == nil, let old = existingByDay[day.day], let oldHrv = old.avgHrv,
let owner = ownerByDay[day.day] else { continue }
var dayStart: Int?
for offset in 0..<maxDays {
let candidate = nowLocalMidnight - offset * 86_400
if AnalyticsEngine.dayString(candidate, offsetSec: offsetSec) == day.day {
dayStart = candidate
break
}
}
guard let dayStart else { continue }
let readFrom = dayStart - StreamReadCap.lookbackSeconds
let readTo = sleepReadWindowEnd(dayStart: dayStart, nowLocalMidnight: nowLocalMidnight, now: now)
let alias = strictCanonicalAlias && owner == Repository.whoopSource
guard (try? await store.legacyWhoop5RRWithheld(
deviceId: owner, from: readFrom, to: readTo,
unlabelledAliasOfWhoop5: alias)) == true else { continue }
snapshots[day.day] = LegacyScoreSnapshot(
avgHrv: oldHrv, recovery: old.recovery,
recoverySource: try? await store.scoreInputSource(
deviceId: computedId, day: day.day, key: "recovery"))
}
return snapshots
}

/// Re-score ONLY the recovery composite for a day against a (re-seeded) baseline. Every other field
/// in `daily` is baseline-independent and already final from pass 1. Returns nil until the HRV
/// baseline is usable (RecoveryScorer gates on `hrvBaseline.usable`, i.e. ≥ minNightsSeed valid
Expand Down Expand Up @@ -3275,6 +3354,17 @@ final class IntelligenceEngine: ObservableObject {
// is most easily dropped at (they respell every field by name), so StrandTests asserts them directly
// rather than through a copy that could drift. Nothing outside this module can see them either way.
extension DailyMetric {
/// Rebuild with the exact legacy HRV/Charge pair while keeping every other freshly-scored cell.
func with(avgHrv hrv: Double, recovery r: Double?) -> DailyMetric {
DailyMetric(day: day, totalSleepMin: totalSleepMin, efficiency: efficiency, deepMin: deepMin,
remMin: remMin, lightMin: lightMin, disturbances: disturbances, restingHr: restingHr,
avgHrv: hrv, recovery: r, strain: strain, exerciseCount: exerciseCount,
spo2Pct: spo2Pct, skinTempDevC: skinTempDevC, respRateBpm: respRateBpm,
steps: steps, activeKcalEst: activeKcalEst,
spo2Red: spo2Red, spo2Ir: spo2Ir, avgSdnn: avgSdnn, skinTempC: skinTempC,
sleepHrOnly: sleepHrOnly)
}

/// Rebuild the immutable DailyMetric with a substituted recovery + skin-temp deviation
/// (the struct has no `copy()`). (#78)
func with(recovery r: Double?, skinTempDevC sd: Double?, skinTempC sa: Double?) -> DailyMetric {
Expand Down
Loading
Loading