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
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,21 @@ public enum AnalyzeRecentDayCache {
/// both a 4.0 and a 5/MG): when a day's resolved owner flips between straps, keying on the owner makes
/// the reuse invalidate **explicitly**, rather than relying on two different devices never producing an
/// identical `count`+`maxTs` for the same window.
/// - `streams`: the opaque per-day witness of every OTHER scored stream — PPG-derived HR, R-R,
/// respiration, SpO2, gravity, steps, skin temp and events (`WhoopStore.dayStreamFingerprint`).
/// #29: a history offload does not commit its channels together, and an offloaded HR row duplicating
/// a live one is dropped by `ON CONFLICT DO NOTHING`, so a night can be scored from HR alone and then
/// gain its R-R with `hrCount`/`hrMaxTs` completely unmoved. Keyed on HR alone this said "reuse", and
/// the HRV-less scan was re-served for the rest of the session — a force refresh included, since
/// `force` only bypasses the whole-pass watermark gate. The whole-pass gate had already been widened
/// to every stream (`analysisFingerprint`, v2); this is the same widening at day granularity, which is
/// where the reuse decision is actually made.
///
/// Inputs that feed `analyzeDay` but are pass-global rather than per-day (profile, baselines1, sleep
/// need / consistency, habitual midsleep, tz, stager toggles) are NOT in this key — the engine drops the
/// whole cache when its pass config signature changes, which covers them.
public static func cacheKey(owner: String, hrCount: Int, hrMaxTs: Int, skinAnchorRaw: Double?,
streams: String,
// #1575: whether this day is the one that emits the PER-WINDOW HRV detail
// (`dayStart == nowLocalMidnight`). Now that an active trace no longer
// disables reuse, this has to invalidate: the night cached as "today" with
Expand All @@ -47,6 +57,6 @@ public enum AnalyzeRecentDayCache {
// a trace mode is on.
hrvWindowDetail: Bool) -> String {
let anchor = skinAnchorRaw.map { String($0.bitPattern) } ?? "nil"
return "\(owner)|\(hrCount):\(hrMaxTs):\(anchor):\(hrvWindowDetail ? "d" : "s")"
return "\(owner)|\(hrCount):\(hrMaxTs):\(anchor):\(hrvWindowDetail ? "d" : "s")|\(streams)"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import Foundation
import XCTest
import WhoopProtocol
import WhoopStore
@testable import StrandAnalytics

/// The per-day reuse key must witness every stream `analyzeDay` scores, not HR alone (#29).
///
/// A WHOOP history offload does not commit its channels together: HR can land first and R-R (or
/// respiration, or SpO2) minutes later, and an offloaded HR row duplicating a live one is dropped by
/// `ON CONFLICT DO NOTHING`. So a banked night can be scored once from HR alone and then have its R-R
/// arrive with the night's HR `(count, maxTs)` completely unmoved. The whole-pass gate already sees that —
/// `WhoopStore.analysisFingerprint` covers every raw stream — so the pass runs; it was the PER-DAY key that
/// said "reuse", re-serving the HRV-less scan for the rest of the session, force refreshes included.
///
/// `AnalyzeRecentDayCacheTests` pins the key's contract on literal witnesses. This one drives the two real
/// halves against each other — `analyzeDay` for what the night actually scores, a real store for what the
/// witness actually reports — so neither half can drift into agreeing with a broken other half.
/// Swift-only: it drives a real `WhoopStore`, and Kotlin pins the same contract from the key side in
/// `AnalyzeRecentDayCacheTest`.
final class AnalyzeRecentDayCacheStreamWitnessTests: XCTestCase {

private let profile = UserProfile(weightKg: 75, heightCm: 178, age: 30, sex: "male")
private let day = "2026-07-27"
private let owner = "whoop-A"

/// 16 h awake around 74 +/- 11 bpm then 8 h asleep around 64 +/- 5, anchored so the night runs
/// 00:00-08:00 on `day` — the same field-shaped generator `AnalyticsEngineHrOnlyDayTests` uses. No
/// gravity, which is the shape of the banked night in the report: a strap that streamed HR and R-R.
private func streams() -> ([HRSample], [RRInterval]) {
let dayStart = AnalyticsEngine.dayStartUtcSeconds(day)
let t0 = dayStart - 16 * 3600
var hr: [HRSample] = []
var rr: [RRInterval] = []
for i in 0..<(16 * 3600) {
let bpm = 74 + Int(sin(Double(i) / 500.0) * 11)
hr.append(HRSample(ts: t0 + i, bpm: bpm))
rr.append(RRInterval(ts: t0 + i, rrMs: 60000 / bpm))
}
for j in 0..<(8 * 3600) {
let bpm = 64 + Int(sin(Double(j) / 900.0) * 5)
hr.append(HRSample(ts: dayStart + j, bpm: bpm))
rr.append(RRInterval(ts: dayStart + j, rrMs: 60000 / bpm))
}
return (hr, rr)
}

private func scan(hr: [HRSample], rr: [RRInterval]) -> AnalyticsEngine.DayResult {
AnalyticsEngine.analyzeDay(day: day, hr: hr, rr: rr, profile: profile,
providedSleep: SleepStager.hrOnlySessions(hr: hr, rr: rr, resp: []))
}

/// R-R landing after HR changes what the night scores, so it must change the reuse key.
func testLateRrChangesTheNightAndInvalidatesTheKey() async throws {
let (hr, rr) = streams()
let from = hr.map(\.ts).min() ?? 0
let to = hr.map(\.ts).max() ?? 0

// What the night scores. Pass 1: HR is committed, R-R has not landed. Pass 2: same HR, plus R-R.
let hrOnly = scan(hr: hr, rr: [])
let withRr = scan(hr: hr, rr: rr)
XCTAssertNil(hrOnly.daily.avgHrv, "an R-R-less night has no nightly HRV to bank")
XCTAssertNotNil(withRr.daily.avgHrv, "once R-R lands the night scores an HRV — the cached scan is stale")

// What the store reports, over the SAME two commits.
let store = try await WhoopStore.inMemory()
try await store.upsertDevice(id: owner, mac: nil, name: nil)
_ = try await store.insert(Streams(hr: hr), deviceId: owner)
let beforeFp = try await store.hrFingerprint(deviceId: owner, from: from, to: to)
let beforeStreams = try await store.dayStreamFingerprint(deviceId: owner, from: from, to: to)

_ = try await store.insert(Streams(rr: rr), deviceId: owner)
let afterFp = try await store.hrFingerprint(deviceId: owner, from: from, to: to)
let afterStreams = try await store.dayStreamFingerprint(deviceId: owner, from: from, to: to)

// The HR witness alone cannot see this commit — that is the whole defect.
XCTAssertEqual(beforeFp.count, afterFp.count)
XCTAssertEqual(beforeFp.maxTs, afterFp.maxTs)
XCTAssertNotEqual(beforeStreams, afterStreams, "the R-R commit must move the other-stream witness")

let before = AnalyzeRecentDayCache.cacheKey(
owner: owner, hrCount: beforeFp.count, hrMaxTs: beforeFp.maxTs, skinAnchorRaw: nil,
streams: beforeStreams, hrvWindowDetail: false)
let after = AnalyzeRecentDayCache.cacheKey(
owner: owner, hrCount: afterFp.count, hrMaxTs: afterFp.maxTs, skinAnchorRaw: nil,
streams: afterStreams, hrvWindowDetail: false)
XCTAssertNotEqual(before, after,
"the night's R-R arrived — reusing the HRV-less scan is the #29 defect")

// And a pass that commits nothing must still reuse: the fix must not re-score every night forever.
let idleFp = try await store.hrFingerprint(deviceId: owner, from: from, to: to)
let idleStreams = try await store.dayStreamFingerprint(deviceId: owner, from: from, to: to)
XCTAssertEqual(after, AnalyzeRecentDayCache.cacheKey(
owner: owner, hrCount: idleFp.count, hrMaxTs: idleFp.maxTs, skinAnchorRaw: nil,
streams: idleStreams, hrvWindowDetail: false))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,40 @@ import XCTest
/// (the exact key STRING may differ across platforms — the cache is in-memory and per-platform — but the
/// set of changes that must / must not invalidate a reused day is a shared contract).
final class AnalyzeRecentDayCacheTests: XCTestCase {
/// A settled night's other-stream witness (`WhoopStore.dayStreamFingerprint`) — opaque to the key, so
/// these tests only need two values that differ.
private let baseStreams = "s1|p0:0|r120000:1700000000|x0:0|o0:0|g86400:1700000000|z0:0|t0:0|e0:0"

// Unchanged inputs → identical key → the day is reused.
func testStableInputsReuse() {
let a = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false)
let b = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false)
let a = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false)
let b = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false)
XCTAssertEqual(a, b)
}

// A new HR row (count moves) OR a later newest-ts must invalidate — the day changed.
func testHrChangeInvalidates() {
let base = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false)
let moreRows = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_001, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false)
let laterTs = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_060, skinAnchorRaw: 1290, hrvWindowDetail: false)
let base = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false)
let moreRows = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_001, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false)
let laterTs = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_060, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false)
XCTAssertNotEqual(base, moreRows)
XCTAssertNotEqual(base, laterTs)
}

// A shifted window-wide skin anchor (another night's skin changed the 4.0 median) must invalidate even
// when this night's HR fingerprint is identical — the skin conversion changed.
func testAnchorShiftInvalidates() {
let a = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false)
let b = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290.5, hrvWindowDetail: false)
let a = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false)
let b = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290.5, streams: baseStreams, hrvWindowDetail: false)
XCTAssertNotEqual(a, b)
}

// A 5/MG night (no anchor) is a distinct, self-consistent key: nil reuses nil, and nil ≠ a real anchor
// (so a 4.0 and a 5/MG night with the same fingerprint never alias to each other's scan).
func testNilAnchorDistinctButStable() {
let nilA = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 5_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: nil, hrvWindowDetail: false)
let nilB = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 5_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: nil, hrvWindowDetail: false)
let real = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 5_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 0, hrvWindowDetail: false)
let nilA = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 5_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: nil, streams: baseStreams, hrvWindowDetail: false)
let nilB = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 5_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: nil, streams: baseStreams, hrvWindowDetail: false)
let real = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 5_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 0, streams: baseStreams, hrvWindowDetail: false)
XCTAssertEqual(nilA, nilB)
XCTAssertNotEqual(nilA, real)
}
Expand All @@ -45,8 +49,8 @@ final class AnalyzeRecentDayCacheTests: XCTestCase {
// count+maxTs for the same window. The owner id is part of the key, so it never falsely reuses one
// strap's scan for the other.
func testDifferentOwnerInvalidates() {
let whoop4 = AnalyzeRecentDayCache.cacheKey(owner: "whoop4-A", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false)
let whoop5 = AnalyzeRecentDayCache.cacheKey(owner: "whoop5-B", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, hrvWindowDetail: false)
let whoop4 = AnalyzeRecentDayCache.cacheKey(owner: "whoop4-A", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false)
let whoop5 = AnalyzeRecentDayCache.cacheKey(owner: "whoop5-B", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290, streams: baseStreams, hrvWindowDetail: false)
XCTAssertNotEqual(whoop4, whoop5)
}

Expand All @@ -62,10 +66,42 @@ final class AnalyzeRecentDayCacheTests: XCTestCase {
func testTheHrvDetailDayIsNotReusedAsAnOrdinaryNight() {
let detail = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000,
hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290,
hrvWindowDetail: true)
streams: baseStreams, hrvWindowDetail: true)
let summary = AnalyzeRecentDayCache.cacheKey(owner: "dev1", hrCount: 178_000,
hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290,
hrvWindowDetail: false)
streams: baseStreams, hrvWindowDetail: false)
XCTAssertNotEqual(detail, summary, "the detail day must invalidate when it stops being today")
}

/// #29: a stream that is not HR moving must invalidate, even with the HR fingerprint frozen.
///
/// A history offload delivers channels independently and drops offloaded HR rows that duplicate live
/// ones (`ON CONFLICT DO NOTHING`), so a night scored once from HR alone can gain its R-R — or its
/// respiration, SpO2, gravity, steps, skin temp, events, or a PPG-derived HR second — with this night's
/// `hrCount`/`hrMaxTs` completely unmoved. Keyed on HR alone that said "reuse" and re-served the
/// HRV-less scan for the rest of the session, force refreshes included.
///
/// Byte-parity twin of Kotlin `aStreamArrivingAfterHrInvalidatesTheReusedNight`.
func testAStreamArrivingAfterHrInvalidatesTheReusedNight() {
// Same night, same HR rows, same anchor — only the other-stream witness moved.
let hrOnly = AnalyzeRecentDayCache.cacheKey(
owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290,
streams: "s1|p0:0|r0:0|x0:0|o0:0|g0:0|z0:0|t0:0|e0:0", hrvWindowDetail: false)
let rrLanded = AnalyzeRecentDayCache.cacheKey(
owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290,
streams: "s1|p0:0|r120000:1700000000|x0:0|o0:0|g0:0|z0:0|t0:0|e0:0", hrvWindowDetail: false)
XCTAssertNotEqual(hrOnly, rrLanded, "the night's R-R arrived — its cached scan has no HRV in it")
}

/// The other half of the same contract: a witness that did NOT move must still reuse. #29's fix must
/// not turn every pass into a full re-score — that is the drain #1005 closed.
func testUnchangedStreamWitnessStillReuses() {
let a = AnalyzeRecentDayCache.cacheKey(
owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290,
streams: baseStreams, hrvWindowDetail: false)
let b = AnalyzeRecentDayCache.cacheKey(
owner: "dev1", hrCount: 178_000, hrMaxTs: 1_700_000_000, skinAnchorRaw: 1290,
streams: baseStreams, hrvWindowDetail: false)
XCTAssertEqual(a, b)
}
}
Loading