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
@@ -0,0 +1,46 @@
import Foundation

/// Where an `analyzeRecent` pass spends the time AFTER its per-day loop.
///
/// #1538 added a cost line, but it brackets the day loop and is emitted the moment that loop returns. Every
/// phase after it — the baseline folds, pass 2, the persist, the weekly metrics, the steps calibration, the
/// sleep writes and heals, the workout rescore — was outside the tally, so a pass whose time went somewhere
/// after the loop reported a small `prep`/`score` and no explanation for the rest. The steps calibration
/// alone re-folded sixty days of gravity per pass under that blind spot.
///
/// This is the same idea at the other end of the pass: stamp each phase as it completes and print them in
/// order, so the dominant phase names itself instead of being inferred from what the day-loop line does not
/// account for.
public enum AnalysisPhaseTally {
/// Render ordered `(name, seconds)` phases as one line, each in milliseconds.
///
/// Phases print in the order given, which is execution order — reading the line top to bottom is reading
/// the pass front to back. The total is included because the phases deliberately do NOT have to sum to
/// the pass: anything not bracketed shows up as the difference, which is itself the signal that a phase
/// is still unmeasured.
///
/// `scope` names WHAT was bracketed, and it is a parameter rather than a constant because the two
/// platforms can afford to measure different amounts: Android's `analyzeRecentOnCpu` sits under a
/// JaCoCo method-size ratchet that leaves no room for the marks, so it brackets only its persist
/// helper. A line that said `postLoop` while timing part of the post-loop would misreport its own
/// total, which is the one thing a cost line must not do. Byte-identical to the Kotlin
/// `AnalysisPhaseTally.logLine`.
public static func logLine(scope: String, _ phases: [(name: String, seconds: Double)]) -> String {
let parts = phases.map { "\($0.name)=\(millis($0.seconds))ms" }
let total = phases.reduce(0.0) { $0 + $1.seconds }
return "analyzeRecent \(scope) total=\(millis(total))ms "
+ (parts.isEmpty ? "(no phases)" : parts.joined(separator: " "))
}

/// Seconds to whole milliseconds, floored at zero.
///
/// The clamp is the parity contract, not defensive habit. These are `Date()` differences, and a wall
/// clock that steps backwards mid-pass (an NTP correction is enough) yields a negative interval — the
/// one input where Swift's round-half-away-from-zero and Kotlin's round-half-up disagree, at exactly
/// -0.5 ms. Clamping first makes both sides provably identical instead of identical only while the
/// clock behaves. A phase cannot take less than no time, so nothing true is lost.
private static func millis(_ seconds: Double) -> Int {
guard seconds.isFinite, seconds > 0 else { return 0 }
return Int((seconds * 1000).rounded())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Foundation

/// Per-day reuse identity for the steps-calibration motion fold in `IntelligenceEngine.analyzeRecent`.
///
/// The drain this closes: the calibration fits one coefficient from sixty days of strap motion, and it
/// re-folded all sixty on EVERY pass. Each day meant a `gravitySamples` read capped at 200,000 rows, so a
/// worn library paid millions of materialised rows per pass to re-derive numbers that had not moved. The
/// phase does not scale with the days being re-scored — it always reads the same sixty — so a two-day pass
/// could cost more than a twenty-one-day one, which is what made it hard to see in a cost line keyed on the
/// day loop.
///
/// `StepsEstimateEngine.dayMotionIntensity` is a pure fold over one day's gravity stream. Nothing else
/// reaches it: no profile field, no baseline, no toggle, no other stream. So unlike `AnalyzeRecentDayCache`
/// there is no pass-config signature to invalidate against — the value changes exactly when that one day's
/// gravity changes, and the key below is the whole story.
///
/// Like the day-scan cache this is in-memory and per-process. It never persists, never crosses the
/// `.noopbak` boundary, and a relaunch pays the full fold once. That is deliberate: the cost being closed is
/// the repeat within a process, where an offload storm fires passes back to back.
public enum StepsMotionCache {
/// The per-day reuse key. Reuse a cached motion volume iff this string is unchanged.
///
/// - `owner`: the resolved owning device the fold was measured against. A day whose owner flips between
/// straps must re-fold, and the fingerprint below is device-scoped, so this makes that explicit rather
/// than relying on two devices never producing an identical count and newest timestamp for one window.
/// - `gravityCount` / `gravityMaxTs`: the day window's gravity witness. Any gravity row added or removed
/// moves one of the two. Deliberately NOT the wider `dayStreamFingerprint`: that also counts HR, R-R,
/// respiration, SpO2, steps, skin temp and sleep state, so an ordinary HR offload would invalidate a
/// motion volume that cannot have changed by it.
public static func cacheKey(owner: String, gravityCount: Int, gravityMaxTs: Int) -> String {
"\(owner)|\(gravityCount)|\(gravityMaxTs)"
}

/// The pass's one-line reuse readout, beside the phase cost line.
///
/// `reused` and `folded` sum to the days scanned, so the ratio is readable without a second line. A pass
/// reporting `folded=60` every time means the key is moving when it should not, which is the failure
/// this cache can have and the reason the number is reported at all rather than assumed.
public static func logLine(reused: Int, folded: Int, size: Int) -> String {
"analyzeRecent stepsMotion reused=\(reused)/\(reused + folded) size=\(size)"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import XCTest
@testable import StrandAnalytics

/// The post-loop phase line. Mirrored by the Kotlin `AnalysisPhaseTallyTest` against the SAME literals:
/// the two platforms name their own phases (the passes genuinely differ after the day loop), but the
/// rendering — order, rounding, the total, the empty case — is one contract.
final class AnalysisPhaseTallyTests: XCTestCase {

func testPhasesRenderInOrderWithATotal() {
let line = AnalysisPhaseTally.logLine(scope: "postLoop", [
(name: "baselines", seconds: 0.012),
(name: "score2", seconds: 1.1),
(name: "steps", seconds: 28.93),
])
XCTAssertEqual(line, "analyzeRecent postLoop total=30042ms baselines=12ms score2=1100ms steps=28930ms")
}

/// The phases are printed in the order GIVEN, never sorted by cost. Execution order is the point: the
/// line is read front to back as the pass runs, and a sort would hide where in the pass the time sits.
func testOrderIsExecutionOrderNotCost() {
let line = AnalysisPhaseTally.logLine(scope: "postLoop", [
(name: "steps", seconds: 28.93),
(name: "baselines", seconds: 0.012),
])
XCTAssertEqual(line, "analyzeRecent postLoop total=28942ms steps=28930ms baselines=12ms")
}

/// A backwards wall-clock step (an NTP correction mid-pass is enough) yields a negative interval.
/// It floors at zero rather than printing a negative duration — and, more to the point, that is the
/// one input where Swift's round-half-away-from-zero and Kotlin's round-half-up would disagree.
func testNegativeAndNonFinitePhasesFloorAtZero() {
XCTAssertEqual(AnalysisPhaseTally.logLine(scope: "postLoop", [(name: "skew", seconds: -0.0005)]),
"analyzeRecent postLoop total=0ms skew=0ms")
XCTAssertEqual(AnalysisPhaseTally.logLine(scope: "postLoop", [(name: "nan", seconds: Double.nan)]),
"analyzeRecent postLoop total=0ms nan=0ms")
}

/// The total is the sum of what was MEASURED, not of the pass. A phase nobody bracketed shows up as
/// the gap between this number and `re-score: done`, which is how the next blind spot gets found.
func testTotalIsTheSumOfMeasuredPhasesOnly() {
XCTAssertEqual(AnalysisPhaseTally.logLine(scope: "postLoop", [(name: "a", seconds: 0.5), (name: "b", seconds: 0.25)]),
"analyzeRecent postLoop total=750ms a=500ms b=250ms")
}

/// The scope is the caller's, not a constant. Android brackets only its persist helper (the JaCoCo
/// method-size ratchet leaves no room in `analyzeRecentOnCpu`), so its line must not claim a `postLoop`
/// total it never measured. Same phases, different scope, different line.
func testScopeNamesWhatWasActuallyBracketed() {
let phases: [(name: String, seconds: Double)] = [(name: "weekly", seconds: 0.07),
(name: "steps", seconds: 28.93)]
XCTAssertEqual(AnalysisPhaseTally.logLine(scope: "persistSteps", phases),
"analyzeRecent persistSteps total=29000ms weekly=70ms steps=28930ms")
XCTAssertEqual(AnalysisPhaseTally.logLine(scope: "postLoop", phases),
"analyzeRecent postLoop total=29000ms weekly=70ms steps=28930ms")
}

func testNoPhasesSaysSoRatherThanPrintingNothing() {
XCTAssertEqual(AnalysisPhaseTally.logLine(scope: "postLoop", []), "analyzeRecent postLoop total=0ms (no phases)")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import XCTest
@testable import StrandAnalytics

/// The steps-calibration motion cache's key and readout. Mirrored by the Kotlin
/// `StepsMotionCacheTest`, which pins the SAME literals — the key is a cross-platform contract only in
/// the sense that both sides must invalidate on the same facts, and pinning the rendered strings is how
/// a one-sided change to either rule is caught.
final class StepsMotionCacheTests: XCTestCase {

func testKeyIsStableForUnchangedInputs() {
let a = StepsMotionCache.cacheKey(owner: "my-whoop", gravityCount: 8640, gravityMaxTs: 1_757_000_000)
let b = StepsMotionCache.cacheKey(owner: "my-whoop", gravityCount: 8640, gravityMaxTs: 1_757_000_000)
XCTAssertEqual(a, b)
XCTAssertEqual(a, "my-whoop|8640|1757000000")
}

/// The three facts that MUST invalidate a fold, one at a time. A row added moves the count; a row
/// replacing another at a newer timestamp moves the max; a day changing hands moves the owner.
func testEveryInputInvalidates() {
let base = StepsMotionCache.cacheKey(owner: "my-whoop", gravityCount: 8640, gravityMaxTs: 1_757_000_000)
XCTAssertNotEqual(base, StepsMotionCache.cacheKey(owner: "my-whoop", gravityCount: 8641,
gravityMaxTs: 1_757_000_000))
XCTAssertNotEqual(base, StepsMotionCache.cacheKey(owner: "my-whoop", gravityCount: 8640,
gravityMaxTs: 1_757_000_001))
XCTAssertNotEqual(base, StepsMotionCache.cacheKey(owner: "whoop-5mg", gravityCount: 8640,
gravityMaxTs: 1_757_000_000))
}

/// An empty day is a real, cacheable answer — the key for it is well-formed and distinct from a day
/// that has rows. Caching it is what stops an unworn gap re-reading its whole stream every pass.
func testEmptyDayHasItsOwnKey() {
let empty = StepsMotionCache.cacheKey(owner: "my-whoop", gravityCount: 0, gravityMaxTs: 0)
XCTAssertEqual(empty, "my-whoop|0|0")
XCTAssertNotEqual(empty, StepsMotionCache.cacheKey(owner: "my-whoop", gravityCount: 1,
gravityMaxTs: 0))
}

/// The owner is the FIRST field, so two devices cannot collide by arranging their counts: the
/// separator makes `a|1|2` and `a|1|2` the only way to match.
func testOwnerBoundaryCannotBeForgedByCounts() {
XCTAssertNotEqual(StepsMotionCache.cacheKey(owner: "a", gravityCount: 1, gravityMaxTs: 2),
StepsMotionCache.cacheKey(owner: "a|1", gravityCount: 2, gravityMaxTs: 0))
}

func testLogLineReportsTheRatioAndSize() {
XCTAssertEqual(StepsMotionCache.logLine(reused: 58, folded: 2, size: 60),
"analyzeRecent stepsMotion reused=58/60 size=60")
// A cold process: everything folded, nothing reused. This is the line a FIRST pass prints, and
// seeing it on every pass is the symptom that the key is moving when it should not.
XCTAssertEqual(StepsMotionCache.logLine(reused: 0, folded: 60, size: 60),
"analyzeRecent stepsMotion reused=0/60 size=60")
}
}
20 changes: 20 additions & 0 deletions Packages/WhoopStore/Sources/WhoopStore/Reads.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,26 @@ extension WhoopStore {
}
}

/// Per-day GRAVITY fingerprint: `(count, maxTs)` over one device's `gravitySample` rows in a window.
///
/// The witness the steps-calibration motion cache reuses a day's fold against. `dayStreamFingerprint`
/// already computes this pair, but it computes it alongside eight other streams, so a new HR row would
/// invalidate a motion volume that cannot have changed. `StepsEstimateEngine.dayMotionIntensity` is a
/// pure fold over one day's gravity stream and nothing else, so its cache key must move when that
/// stream moves and at no other time. Same COUNT/COALESCE(MAX) shape and the same `(deviceId, ts)`
/// index as `hrFingerprint(deviceId:from:to:)` above, and never a row fetch.
public func gravityFingerprint(deviceId: String, from: Int, to: Int) async throws -> (count: Int, maxTs: Int) {
try syncRead { db in
guard let row = try Row.fetchOne(db, sql: """
SELECT COUNT(*) AS c, COALESCE(MAX(ts), 0) AS m FROM gravitySample
WHERE deviceId = ? AND ts >= ? AND ts <= ?
""", arguments: [deviceId, from, to]) else { return (0, 0) }
let c: Int = row["c"]
let m: Int = row["m"]
return (c, m)
}
}

/// Cross-device raw-HR fingerprint: `(count, maxTs)` over EVERY `hrSample` row, no `deviceId` filter.
/// The `analyzeRecent` re-score gate (#1392) only needs to answer "did the raw stream change AT ALL",
/// so it must see HR that lands under ANY id — an Oura ring, an Apple Watch, or a WHOOP re-added under a
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import XCTest
import WhoopProtocol
@testable import WhoopStore

/// The contract the steps-calibration motion cache rests on.
///
/// `StepsEstimateEngine.dayMotionIntensity` sums the distance between CONSECUTIVE gravity samples, so it
/// depends on the set of x/y/z values in a day AND on their order. The cache re-folds a day only when
/// `gravityFingerprint` moves, which is sound only while a day's gravity cannot change underneath an
/// unchanged `(count, maxTs)`. These pin the parts of that a test can reach; the rest is structural (the
/// reads are `ORDER BY ts ASC`, and nothing anywhere rewrites a `ts` or a vector in place).
final class GravityWitnessTests: XCTestCase {

private func store() async throws -> WhoopStore {
let s = try await WhoopStore.inMemory()
try await s.upsertDevice(id: "dev1", mac: nil, name: nil)
try await s.upsertDevice(id: "other", mac: nil, name: nil)
return s
}

private func grav(_ ts: Int, _ v: Double) -> GravitySample {
GravitySample(ts: ts, x: v, y: v, z: v, dynAccel: nil)
}

/// The load-bearing one. Re-offloading a second that is already banked is absorbed by
/// `ON CONFLICT(deviceId, ts) DO NOTHING`: the stored vector is the FIRST one, not the newest. That is
/// what makes an unchanged witness mean an unchanged fold. Were this ever flipped to a REPLACE, the
/// values under a day would change while its count and newest timestamp held still, and the cache would
/// serve a motion volume for data it no longer describes — silently, with no failing read.
func testReinsertingAnExistingSecondChangesNeitherTheWitnessNorTheValues() async throws {
let s = try await store()
_ = try await s.insert(Streams(gravity: [grav(100, 1), grav(200, 2)]), deviceId: "dev1")
let before = try await s.gravityFingerprint(deviceId: "dev1", from: 0, to: 1000)

_ = try await s.insert(Streams(gravity: [grav(100, 99), grav(200, 99)]), deviceId: "dev1")
let after = try await s.gravityFingerprint(deviceId: "dev1", from: 0, to: 1000)
XCTAssertEqual(before.count, after.count)
XCTAssertEqual(before.maxTs, after.maxTs)

let rows = try await s.gravitySamples(deviceId: "dev1", from: 0, to: 1000, limit: 100)
XCTAssertEqual(rows.map { $0.x }, [1, 2], "conflict-ignore must keep the first vector, not the newest")
}

/// A new second moves both halves, so the day re-folds. Both are asserted: a witness that moved only
/// its count would miss an append that replaced nothing, and one that moved only its newest timestamp
/// would miss a backfilled second older than the day's last.
func testAnAppendMovesTheWitnessAndABackfillMovesTheCount() async throws {
let s = try await store()
_ = try await s.insert(Streams(gravity: [grav(200, 1)]), deviceId: "dev1")
let one = try await s.gravityFingerprint(deviceId: "dev1", from: 0, to: 1000)
XCTAssertEqual(one.count, 1)
XCTAssertEqual(one.maxTs, 200)

_ = try await s.insert(Streams(gravity: [grav(300, 1)]), deviceId: "dev1")
let appended = try await s.gravityFingerprint(deviceId: "dev1", from: 0, to: 1000)
XCTAssertEqual(appended.count, 2)
XCTAssertEqual(appended.maxTs, 300)

// A second that lands BEFORE the day's newest: the timestamp holds, the count is the only witness
// that moves. An offload does not commit its channels in order, so this is the ordinary case.
_ = try await s.insert(Streams(gravity: [grav(100, 1)]), deviceId: "dev1")
let backfilled = try await s.gravityFingerprint(deviceId: "dev1", from: 0, to: 1000)
XCTAssertEqual(backfilled.count, 3)
XCTAssertEqual(backfilled.maxTs, 300, "a backfill leaves the newest timestamp alone")
}

/// Scoped to one device and one window, because the cache key pairs the witness with a resolved owner
/// and a day. Another strap's gravity, or the same strap's gravity on another day, must not move it.
func testTheWitnessIsScopedToOneDeviceAndOneWindow() async throws {
let s = try await store()
_ = try await s.insert(Streams(gravity: [grav(100, 1), grav(200, 1)]), deviceId: "dev1")
let base = try await s.gravityFingerprint(deviceId: "dev1", from: 0, to: 250)

_ = try await s.insert(Streams(gravity: [grav(150, 1)]), deviceId: "other")
_ = try await s.insert(Streams(gravity: [grav(900, 1)]), deviceId: "dev1")
let after = try await s.gravityFingerprint(deviceId: "dev1", from: 0, to: 250)
XCTAssertEqual(base.count, after.count)
XCTAssertEqual(base.maxTs, after.maxTs)
}

/// An empty window is a real answer, not a missing one: `(0, 0)`. The cache stores zero folds under it
/// so an unworn gap stops re-reading its whole stream to rediscover that it is empty.
func testAnEmptyWindowReportsZeroRatherThanFailing() async throws {
let s = try await store()
let fp = try await s.gravityFingerprint(deviceId: "dev1", from: 0, to: 1000)
XCTAssertEqual(fp.count, 0)
XCTAssertEqual(fp.maxTs, 0)
}
}
Loading