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,49 @@
import Foundation

/// How much of an `analyzeRecent` pass goes into its per-day PROBE queries, as calls and milliseconds.
///
/// The blind spot this closes: with the steps-calibration fold now reused rather than recomputed, a warm
/// pass still reported ~3.3 s in the steps phase with `stepsMotion reused=60/60` beside it, so the fold was
/// provably not the cost and nothing said what was. The phase is bracketed as one number and sits outside
/// the `#1538` day-loop tally, so the remaining candidates — the sixty per-day probe queries, the one
/// `appleDaily` read, and the in-memory calibration fit — were indistinguishable from each other.
///
/// Two probes are worth counting because each has exactly ONE call site, so the counts attribute themselves
/// with no bookkeeping at the call site and no way to drift:
/// - `ownerHr`: `hasHrInWindow`, the day-owner resolver's per-candidate presence probe. Runs in BOTH the
/// scoring loop and the sixty-day steps loop, and is skipped entirely on a default single-strap install
/// (#970), so a two-strap library is the only one that pays it.
/// - `gravityFp`: `gravityFingerprint`, the steps loop's per-day motion witness. Steps-only.
///
/// Read with the `stepsMotion reused=N/M` line, this is decisive rather than suggestive: a warm pass that
/// folded nothing and still spent its time here means the round trips ARE the cost and batching them into a
/// grouped query is worth building; one that does not means the cost is the `appleDaily` read or the fit,
/// and batching would have bought nothing. Measured, not guessed — the same reason the day-loop split and
/// the day-cache duration are measured rather than reasoned about.
///
/// Instrumentation only: nothing reads these counts but the log line.
public enum StoreProbeTally {
/// Render ordered `(name, calls, seconds)` probes as one line.
///
/// `calls` is printed beside the time because the two answer different questions: the time says whether
/// this is where the pass went, and the count says whether the loop issued the number of round trips it
/// was believed to (sixty per steps pass, one per day). A count that is right with a time that is large
/// is a slow query; a count that is wrong is a different bug entirely, and one line should not be able
/// to hide either. Byte-identical to the Kotlin `StoreProbeTally.logLine`.
public static func logLine(_ probes: [(name: String, calls: Int, seconds: Double)]) -> String {
let parts = probes.map { "\($0.name)=\($0.calls)/\(millis($0.seconds))ms" }
let total = probes.reduce(0.0) { $0 + $1.seconds }
return "analyzeRecent storeProbes total=\(millis(total))ms "
+ (parts.isEmpty ? "(no probes)" : parts.joined(separator: " "))
}

/// Seconds to whole milliseconds, floored at zero. The clamp is the parity contract, not defensive
/// habit: a negative or non-finite input is where Swift's round-half-away-from-zero and Kotlin's
/// round-half-up disagree, at exactly -0.5 ms, and both sides render into one shared strap log. The
/// callers here cannot produce one (they count monotonic nanoseconds), which makes this a guarantee
/// about the renderer rather than a repair of its inputs.
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,46 @@
import XCTest
@testable import StrandAnalytics

/// The per-day probe readout. Mirrored by the Kotlin `StoreProbeTallyTest`, which pins the SAME literals:
/// the line is read off one shared strap log, so a one-sided change to the format would make two platforms'
/// logs incomparable exactly when they are being compared.
final class StoreProbeTallyTests: XCTestCase {

/// A warm two-strap pass: the scoring loop's 21 owner probes plus the steps loop's 60, and one gravity
/// witness per steps day. This is the shape the line exists to make readable.
func testRendersCallsAndMillisInOrder() {
let line = StoreProbeTally.logLine([
(name: "ownerHr", calls: 81, seconds: 1.24),
(name: "gravityFp", calls: 60, seconds: 1.89),
])
XCTAssertEqual(line, "analyzeRecent storeProbes total=3130ms ownerHr=81/1240ms gravityFp=60/1890ms")
}

/// A default SINGLE-strap install skips the owner probe entirely (#970), so zero calls must render as
/// zero rather than being omitted: "not measured" and "measured, and it was free" are different
/// findings, and only one of them means the batching idea is pointless for that user.
func testZeroCallsStillRender() {
let line = StoreProbeTally.logLine([
(name: "ownerHr", calls: 0, seconds: 0),
(name: "gravityFp", calls: 60, seconds: 1.89),
])
XCTAssertEqual(line, "analyzeRecent storeProbes total=1890ms ownerHr=0/0ms gravityFp=60/1890ms")
}

func testNoProbesSaysSoRatherThanRenderingAnEmptyTail() {
XCTAssertEqual(StoreProbeTally.logLine([]), "analyzeRecent storeProbes total=0ms (no probes)")
}

/// The renderer clamps, and that is a contract about the RENDERER rather than a claim about the probes:
/// both sides now count monotonic nanoseconds, so neither can hand it a negative. It is pinned anyway
/// because a negative or non-finite seconds value is the one input where Swift's round-half-away-from-
/// zero and Kotlin's round-half-up disagree, at exactly -0.5 ms, and the two platforms render into one
/// shared strap log. A probe cannot take less than no time, so nothing true is lost by flooring it.
func testNegativeAndNonFiniteSecondsClampToZeroOnBothSides() {
let line = StoreProbeTally.logLine([
(name: "ownerHr", calls: 3, seconds: -0.0005),
(name: "gravityFp", calls: 1, seconds: .nan),
])
XCTAssertEqual(line, "analyzeRecent storeProbes total=0ms ownerHr=3/0ms gravityFp=1/0ms")
}
}
14 changes: 12 additions & 2 deletions Packages/WhoopStore/Sources/WhoopStore/Reads.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Dispatch
import Foundation
import GRDB
import WhoopProtocol
Expand Down Expand Up @@ -112,7 +113,13 @@ extension WhoopStore {
/// "hrSample non-empty OR ppgHrSample non-empty". OR short-circuits, so the common case is one probe.
/// Twin of Kotlin's `WhoopDao.hasHrInWindow`.
public func hasHrInWindow(deviceId: String, from: Int, to: Int) async throws -> Bool {
try syncRead { db in
// Counted HERE rather than at the call site, mirroring the Kotlin repository: the resolver this
// serves is `nonisolated static`, so it has nowhere to accumulate, and the steps loop that drives
// most of these runs inside a detached task. One call site each makes the count unambiguous.
// See `StoreProbeTally` (StrandAnalytics). Instrumentation only.
let probeStarted = DispatchTime.now().uptimeNanoseconds
defer { probeCounts.ownerHr.record(nanos: DispatchTime.now().uptimeNanoseconds &- probeStarted) }
return try syncRead { db in
try Bool.fetchOne(db, sql: """
SELECT EXISTS(SELECT 1 FROM hrSample WHERE deviceId = ? AND ts >= ? AND ts <= ?)
OR EXISTS(SELECT 1 FROM ppgHrSample WHERE deviceId = ? AND ts >= ? AND ts <= ?)
Expand All @@ -129,7 +136,10 @@ extension WhoopStore {
/// 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
// Counted for the same reason as `hasHrInWindow` above; see `StoreProbeTally`.
let probeStarted = DispatchTime.now().uptimeNanoseconds
defer { probeCounts.gravityFp.record(nanos: DispatchTime.now().uptimeNanoseconds &- probeStarted) }
return 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 <= ?
Expand Down
63 changes: 63 additions & 0 deletions Packages/WhoopStore/Sources/WhoopStore/StoreProbeCounts.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Dispatch
import Foundation

/// Per-pass call/time counters for the two per-day PROBE reads, rendered by
/// `StrandAnalytics.StoreProbeTally`. Twin of the Kotlin `StoreProbeTally` counters.
///
/// Lives on the store because that is where each probe has exactly ONE call site, which is what makes the
/// counts attribute themselves: `hasHrInWindow` is only ever the day-owner resolver's presence probe and
/// `gravityFingerprint` only ever the steps loop's per-day motion witness. Counting at the call sites
/// instead would mean threading an accumulator through a `nonisolated static` resolver and a detached task,
/// and on Android through a method with no ratchet margin to spend.
///
/// Actor-isolated by living on `WhoopStore`, so no lock and no `Sendable` gymnastics. Instrumentation only:
/// nothing reads these but the diagnostic line.
public struct StoreProbeCounts: Sendable, Equatable {
/// One probe's calls and accumulated time.
public struct Probe: Sendable, Equatable {
public private(set) var calls = 0

/// Accumulated INTEGER nanoseconds, divided to seconds once when read rather than per call.
///
/// The Kotlin twin sums `System.nanoTime()` deltas into an `AtomicLong` and divides once at render.
/// Converting each call to `Double` seconds instead would spend sixty to eighty roundings where the
/// twin spends none, and the two would then disagree in the last bits — which survives into the
/// rendered line whenever the true total sits near a half-millisecond boundary, the exact boundary
/// `StoreProbeTallyTests` pins. Integer accumulation makes the two provably the same arithmetic.
public private(set) var nanos: UInt64 = 0

/// Accumulated seconds, for the renderer. One division, matching the twin.
public var seconds: Double { Double(nanos) / 1_000_000_000 }

/// Add one call of `nanos` monotonic nanoseconds.
///
/// Nanoseconds from a MONOTONIC source, not a `Date()` difference, and that is the whole point of
/// the type. These counters exist to decide whether the per-day round trips are worth batching, so
/// a number that a clock correction can move is worse than no number: `Date()` steps in BOTH
/// directions, and while a backwards step is clamped away, a forward NTP jump mid-probe would
/// silently add itself to that probe's time and inflate the very total the decision reads. The
/// Kotlin twin counts `System.nanoTime()` for the same reason, so the two measure the same thing.
mutating func record(nanos elapsed: UInt64) {
calls += 1
nanos &+= elapsed
}
}

/// `hasHrInWindow` — the day-owner resolver's per-candidate presence probe, in BOTH the scoring loop
/// and the sixty-day steps loop. Skipped entirely on a default single-strap install (#970).
public var ownerHr = Probe()
/// `gravityFingerprint` — the steps loop's per-day motion witness. Steps-only.
public var gravityFp = Probe()

public init() {}
}

public extension WhoopStore {
/// Read the counters and zero them, so a line describes ONE pass and never accumulates across the
/// back-to-back passes an offload storm is made of.
func takeProbeCounts() -> StoreProbeCounts {
let counts = probeCounts
probeCounts = StoreProbeCounts()
return counts
}
}
4 changes: 4 additions & 0 deletions Packages/WhoopStore/Sources/WhoopStore/WhoopStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ private actor StoreOpenGate {
/// data or the query results.
public actor WhoopStore {

/// Per-pass call/time counters for the two per-day PROBE reads, drained by `takeProbeCounts()`.
/// Instrumentation only — nothing reads them but a diagnostic line. See `StoreProbeCounts`.
var probeCounts = StoreProbeCounts()

/// PPG waveform rows banked since its retention sweep last ran, PER DEVICE, for the same reason as
/// `v18AuxRowsSincePrune` below: the sweep is per device, so a shared counter would let one strap
/// spend another's budget. Separate counter from the v18 aux one because the two caps differ and a
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import XCTest
@testable import WhoopStore

/// The per-day probe counters' ARITHMETIC, pinned against the Kotlin twin's.
///
/// The twin sums `System.nanoTime()` deltas into an `AtomicLong` and divides once at render. This side has
/// to do the same thing, not merely a similar thing: dividing each call to `Double` seconds and summing
/// those would spend sixty to eighty roundings where the twin spends none, and the difference survives into
/// the rendered millisecond whenever the true total sits near a half-millisecond boundary. Both platforms
/// render into ONE shared strap log, so a divergence there is read as two devices behaving differently.
final class StoreProbeCountsTests: XCTestCase {

/// Sixty calls of exactly 20 ms. Integer accumulation makes this exactly 1.2 s, so it renders 1200ms;
/// per-call `Double` division would land a hair either side. The literal matches the Kotlin
/// `StoreProbeTallyTest.recordAccumulatesIntegerNanosLikeTheSwiftTwin`.
func testAccumulatesIntegerNanosRatherThanSummingDoubles() {
var probe = StoreProbeCounts.Probe()
for _ in 0..<60 { probe.record(nanos: 20_000_000) }
XCTAssertEqual(probe.calls, 60)
XCTAssertEqual(probe.nanos, 1_200_000_000)
XCTAssertEqual(probe.seconds, 1.2, accuracy: 0)
}

/// A probe that was never called contributes nothing and says so, which is what makes `ownerHr=0/0ms`
/// on a single-strap install (#970) a finding rather than a missing measurement.
func testUncalledProbeIsZeroNotAbsent() {
let probe = StoreProbeCounts.Probe()
XCTAssertEqual(probe.calls, 0)
XCTAssertEqual(probe.seconds, 0)
}

/// `takeProbeCounts()` DRAINS. A line has to describe one pass, and the passes this runs under are the
/// back-to-back ones an offload storm is made of, so a read that left the counters standing would make
/// every pass after the first report its predecessors' work as its own.
func testTakeDrainsSoAPassCannotInheritTheLastOne() async throws {
let store = try await WhoopStore.inMemory()
_ = try? await store.hasHrInWindow(deviceId: "d", from: 0, to: 1)
let first = await store.takeProbeCounts()
XCTAssertEqual(first.ownerHr.calls, 1)
let second = await store.takeProbeCounts()
XCTAssertEqual(second.ownerHr.calls, 0)
XCTAssertEqual(second.ownerHr.seconds, 0)
}
}
11 changes: 11 additions & 0 deletions Strand/Data/IntelligenceEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,10 @@ final class IntelligenceEngine: ObservableObject {
let effortMethodGlobal = PuffinExperiment.effortMethod
let dayCycleMode = DayCycleMode.persisted(UserDefaults.standard.string(forKey: DayCycleMode.storageKey))

// Zero the per-day probe counters so the line emitted after the steps phase describes THIS pass
// and never accumulates across the back-to-back passes an offload storm is made of. Must precede
// the day loop below, which is the scoring half of the owner probes. See `StoreProbeTally`.
_ = await store.takeProbeCounts()
// ── #1005 BATTERY: per-day reuse cache setup (see `dayScanCache`) ────────────────────────────
// The stager toggles are read per-day inside the loop below, but they are global (same value every
// day); read them ONCE here too so the config signature can fold them without reaching into the
Expand Down Expand Up @@ -2477,6 +2481,13 @@ final class IntelligenceEngine: ObservableObject {
UserDefaults.standard.set(stepsMotionPayload, forKey: Self.stepsMotionCacheDefaultsKey)
}
diagnosticSink?(stepsMotionLogLine, nil)
// What the pass spent on its per-day probe queries, beside the `stepsMotion reused=N/M` line above.
// Together they say whether a warm pass that folded nothing still went into the round trips.
let probeCounts = await store.takeProbeCounts()
diagnosticSink?(StoreProbeTally.logLine([
(name: "ownerHr", calls: probeCounts.ownerHr.calls, seconds: probeCounts.ownerHr.seconds),
(name: "gravityFp", calls: probeCounts.gravityFp.calls, seconds: probeCounts.gravityFp.seconds),
]), nil)
// #1816: persist whether the strap has banked ANY motion in the calibration scan window, so the
// Today tile can distinguish "Need N more phone-step days" (motion exists, phone half missing)
// from "No motion synced yet" (the motion half is the blocker, and no number of phone-step days
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,10 @@ object IntelligenceEngine {
// relaunch is a repeat the process boundary hid. Nothing pass-global feeds the fold, so a
// payload written by a previous launch is as good as one written by the previous pass; see
// [StepsMotionCache]. Inside the lock because it writes the gate-guarded cache.
// Zero the per-day probe counters so the line below describes THIS pass and never accumulates
// across the back-to-back passes an offload storm is made of. Reset and emit both live in this
// wrapper, never in `analyzeRecentOnCpu`, whose ratchet margin has no room for either.
StoreProbeTally.reset()
if (!stepsMotionCacheLoaded && stepsMotionCacheGet != null) {
stepsMotionCacheLoaded = true
val raw = stepsMotionCacheGet()
Expand Down Expand Up @@ -529,6 +533,9 @@ object IntelligenceEngine {
stepsMotionCacheSet(payload)
}
}
// What the pass spent on its per-day probe queries, beside the `stepsMotion reused=N/M` line.
// Together they say whether a warm pass that folded nothing still went into the round trips.
diag(StoreProbeTally.line())
result
}
diag("re-score: done — scored ${scored.size} night(s) in ${(System.nanoTime() - reScoreStart) / 1_000_000} ms (#1005)")
Expand Down
Loading