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
7 changes: 7 additions & 0 deletions Packages/WhoopStore/Sources/WhoopStore/Database.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1096,6 +1096,13 @@ extension WhoopStore {
try db.create(index: "idx_liftSet_session_ord", on: "liftSet",
columns: ["sessionId", "ord"], options: [.ifNotExists])
}
// v47 (#2371): mark the WHOOP 5 500 ms fill beats already stored, with the rule `insert` applies to
// every new batch (`WhoopStore.whoop5RrFillFlagSQL`). Data only, no schema change, and a MARK in
// the v35 form, never a delete: the rows stay on disk. Twin of Room
// MIGRATION_40_41.
migrator.registerMigration("v47-rr-whoop5-fill") { db in
try db.execute(sql: WhoopStore.whoop5RrFillMigrationSQL)
}
return migrator
}
}
11 changes: 11 additions & 0 deletions Packages/WhoopStore/Sources/WhoopStore/StreamStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,17 @@ extension WhoopStore {
"device": deviceId, "ts": r.ts, "rr": r.rrMs, "seq": seq])
}
}
// #2371: mark the strap's 500 ms fill beats in this batch's window. The batch's heart rate
// was written above, so the same-second rate the rule reads is already on disk. Only a batch
// that carries a 500 ms WHOOP 5 beat pays for the statement. Android runs the same statement
// from `WhoopRepository.insertWithinTransaction`.
let fillTs = streams.rr.filter {
$0.rrMs == 500 && ($0.srcChannel == .whoop5Historical || $0.srcChannel == .whoop5Standard)
}.map(\.ts)
if let fromTs = fillTs.min(), let toTs = fillTs.max() {
try db.execute(sql: WhoopStore.whoop5RrFillFlagSQL,
arguments: ["deviceId": deviceId, "fromTs": fromTs, "toTs": toTs])
}
}
if !streams.events.isEmpty {
let stmt = try db.cachedStatement(sql: """
Expand Down
20 changes: 20 additions & 0 deletions Packages/WhoopStore/Sources/WhoopStore/Whoop5RRReadPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,26 @@ extension WhoopStore {
/// standard BLE (7) already covers beat for beat.
static let scorableWhoop5Channels = "(5, 7)"

/// #2371: marks the strap's 500 ms fill beats in one device's ts window. `insert` runs it after a batch
/// that carries a 500 ms WHOOP 5 beat, once that batch's heart rate is on disk.
///
/// A WHOOP 5/MG emits an exact 500 ms interval (120 bpm) as a filler at rest, on both scored
/// transports: v18 history (5) and standard BLE (7). On one 5.0's backup (443,896 beats, 28 Sep 2026),
/// split by the strap's own heart rate in the same second, 500 ms occurred 33-37 times as often as its
/// neighbours (495-499, 501-505 ms) at 80-94 bpm, 3 times at 95-99, and no more often than them from
/// 100 bpm up (1.7x on four beats at 100-104, none at 105-109, 1.0x at 110-124). Below 100 bpm a
/// 500 ms beat would be at least 17% shorter than the mean interval of its second.
///
/// The row is MARKED `tsSuspect = 1`, the flag every scoring read already filters (#1073), never
/// deleted: it stays on disk, so the fill stays inspectable. A beat whose second has no heart rate is
/// left alone, since nothing then says it is a fill. One literal, not a composition, so the parity
/// ledger can compare it with the Kotlin twin: `WHOOP5_RR_FILL_FLAG_SQL`.
static let whoop5RrFillFlagSQL = "UPDATE rrInterval SET tsSuspect = 1 WHERE deviceId = :deviceId AND ts >= :fromTs AND ts <= :toTs AND rrMs = 500 AND srcChannel IN (5, 7) AND tsSuspect IS NULL AND EXISTS (SELECT 1 FROM hrSample h WHERE h.deviceId = rrInterval.deviceId AND h.ts = rrInterval.ts AND h.bpm < 100)"

/// Marks every stored fill beat once, in `v47-rr-whoop5-fill`: the condition of `whoop5RrFillFlagSQL`
/// over the whole table. Kotlin twin: `WHOOP5_RR_FILL_MIGRATION_SQL`.
static let whoop5RrFillMigrationSQL = "UPDATE rrInterval SET tsSuspect = 1 WHERE rrMs = 500 AND srcChannel IN (5, 7) AND tsSuspect IS NULL AND EXISTS (SELECT 1 FROM hrSample h WHERE h.deviceId = rrInterval.deviceId AND h.ts = rrInterval.ts AND h.bpm < 100)"

/// The earliest beat this device has banked that the unit policy can actually score, or nil when it
/// has none at all.
///
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"_readme": "SHARED Room<->GRDB SCHEMA ORACLE (#775). Two byte-identical copies: Packages/WhoopStore/Tests/WhoopStoreTests/Resources/schema_oracle.json and android/app/src/test/resources/schema_oracle.json. SchemaOracleTests.swift compares GRDB's PRAGMA table_info/index_list against it; SchemaOracleTest.kt compares Room's exported schema JSON against it. `columns` is the iOS/GRDB shape in GRDB column order (macOS is the reference implementation); every way Android differs is spelled out in an `android` / `iosAbsent` / `androidColumnOrder` override naming a key in `divergenceReasons`. Adding a column, reordering one, or changing a type/nullability on one platform only fails both suites until it is either fixed or written down here.",
"roomVersion": 40,
"roomVersion": 41,
"grdbMigrations": [
"v1",
"v2",
Expand Down Expand Up @@ -47,7 +47,8 @@
"v43-coach-messages",
"v44-ppg-waveform-base-code",
"v45-rr-source-index",
"v46-lift-log"
"v46-lift-log",
"v47-rr-whoop5-fill"
],
"divergenceReasons": {
"android-orphan-synced-column": "REAL COLUMN DRIFT: `synced` exists on Android only. GRDB's v10 note is explicit that stepSample gets no `synced` column ('unused; see StreamStore'), and v12's ppgHrSample never had one either; the Room entities copied the flag from the older per-second entities. Nothing reads it on Android \u2014 the per-row upload flag belongs to an upload path that does not exist there \u2014 so it is dead width, not divergent data. Removing it needs a Room table rebuild.",
Expand Down
117 changes: 117 additions & 0 deletions Packages/WhoopStore/Tests/WhoopStoreTests/Whoop5RrFillTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import XCTest
import GRDB
import WhoopProtocol
@testable import WhoopStore

/// #2371: a WHOOP 5/MG sends an exact 500 ms R-R interval as a filler at rest, on v18 history (5) and
/// standard BLE (7). A stored one is MARKED `tsSuspect = 1` when the strap's own heart rate in that second
/// is under 100 bpm: at ingest by `insert`, and for rows already on disk by `v47-rr-whoop5-fill`. Marked,
/// never deleted, so scoring skips it and the raw row stays. Mirrors the Android `Whoop5RrFillTest`.
final class Whoop5RrFillTests: XCTestCase {

private let device = "my-whoop"
private let t0 = 1_790_000_000

/// The statement text is the cross-platform contract: Android runs the same strings, pinned by the
/// same literals in `Whoop5RrFillTest`, so the two stores mark exactly the same rows.
func testTheFillStatementsArePinned() {
let condition = "rrMs = 500 AND srcChannel IN (5, 7) AND tsSuspect IS NULL AND EXISTS (SELECT 1 FROM "
+ "hrSample h WHERE h.deviceId = rrInterval.deviceId AND h.ts = rrInterval.ts AND h.bpm < 100)"
XCTAssertEqual(WhoopStore.whoop5RrFillFlagSQL, "UPDATE rrInterval SET tsSuspect = 1 WHERE deviceId = "
+ ":deviceId AND ts >= :fromTs AND ts <= :toTs AND " + condition)
XCTAssertEqual(WhoopStore.whoop5RrFillMigrationSQL, "UPDATE rrInterval SET tsSuspect = 1 WHERE " + condition)
}

/// The whole rule at ingest, one second per case. Only the 500 ms beats on a scored WHOOP 5 transport,
/// in a second whose strap heart rate is under 100, are marked; each neighbour on the other side of
/// one condition is not.
func testIngestMarksOnlyAFillAtRest() async throws {
let store = try await WhoopStore.inMemory()
let hr = [HRSample(ts: t0, bpm: 80), HRSample(ts: t0 + 1, bpm: 99), HRSample(ts: t0 + 2, bpm: 100),
HRSample(ts: t0 + 3, bpm: 80), HRSample(ts: t0 + 4, bpm: 80), HRSample(ts: t0 + 5, bpm: 80),
HRSample(ts: t0 + 6, bpm: 80), HRSample(ts: t0 + 7, bpm: 80)]
let rr = [
RRInterval(ts: t0, rrMs: 500, srcChannel: .whoop5Historical), // fill: marked
RRInterval(ts: t0, rrMs: 820, srcChannel: .whoop5Historical), // real beat, same second
RRInterval(ts: t0 + 1, rrMs: 500, srcChannel: .whoop5Standard), // fill at 99 bpm: marked
RRInterval(ts: t0 + 2, rrMs: 500, srcChannel: .whoop5Standard), // 100 bpm: a beat, kept
RRInterval(ts: t0 + 3, rrMs: 501, srcChannel: .whoop5Historical), // not 500
RRInterval(ts: t0 + 4, rrMs: 500, srcChannel: .whoop5Realtime), // channel 6 is never scored
RRInterval(ts: t0 + 5, rrMs: 500, srcChannel: .whoop4Historical), // a WHOOP 4.0
RRInterval(ts: t0 + 6, rrMs: 500), // no transport label
RRInterval(ts: t0 + 8, rrMs: 500, srcChannel: .whoop5Historical), // no heart rate that second
]
_ = try await store.insert(Streams(hr: hr, rr: rr), deviceId: device)

let rows = try await store.fillMarksInTest(deviceId: device)
XCTAssertEqual(rows.count, rr.count, "nothing deleted")
XCTAssertEqual(rows.filter { $0.tsSuspect == 1 }.map { [$0.ts - t0, $0.rrMs] }, [[0, 500], [1, 500]])
}

/// A marked fill drops out of the scoring read like any suspect beat, and stays on disk.
func testAMarkedFillIsNotScoredButStaysOnDisk() async throws {
let store = try await WhoopStore.inMemory()
let hr = (0..<3).map { HRSample(ts: t0 + $0, bpm: 75) }
let rr = [RRInterval(ts: t0, rrMs: 800, srcChannel: .whoop5Historical),
RRInterval(ts: t0 + 1, rrMs: 500, srcChannel: .whoop5Historical),
RRInterval(ts: t0 + 2, rrMs: 790, srcChannel: .whoop5Historical)]
_ = try await store.insert(Streams(hr: hr, rr: rr), deviceId: device)

let scored = try await store.rrIntervals(deviceId: device, from: t0 - 10, to: t0 + 10, limit: 100)
XCTAssertEqual(scored.map(\.rrMs), [800, 790])
let raw = try await store.fillMarksInTest(deviceId: device)
XCTAssertEqual(raw.map(\.rrMs), [800, 500, 790])
}

/// The same beat arriving again (a re-sync) inserts nothing and leaves the mark where it is.
func testAResyncKeepsTheMark() async throws {
let store = try await WhoopStore.inMemory()
let batch = Streams(hr: [HRSample(ts: t0, bpm: 70)],
rr: [RRInterval(ts: t0, rrMs: 500, srcChannel: .whoop5Historical)])
_ = try await store.insert(batch, deviceId: device)
let again = try await store.insert(batch, deviceId: device)
XCTAssertEqual(again.rr, 0)
let marks = try await store.fillMarksInTest(deviceId: device)
XCTAssertEqual(marks.map(\.tsSuspect), [1])
}

/// Rows banked before v47 carry no mark. The migration marks exactly the rows the ingest rule would
/// have, and leaves a future-stamped mark (v35) and every other row alone.
func testV47MarksTheFillsAlreadyStored() async throws {
let t0 = self.t0
let dbQueue = try DatabaseQueue()
try WhoopStore.makeMigrator().migrate(dbQueue, upTo: "v46-lift-log")
try await dbQueue.write { db in
for (ts, bpm) in [(0, 80), (1, 120), (2, 80), (3, 80)] {
try db.execute(sql: "INSERT INTO hrSample (deviceId, ts, bpm) VALUES ('my-whoop', ?, ?)",
arguments: [t0 + ts, bpm])
}
for (ts, rrMs, channel, suspect) in [(0, 500, 5, nil), (1, 500, 7, nil), (2, 500, 7, nil),
(2, 760, 7, nil), (3, 500, 8, nil), (9, 500, 5, 1)] as [(Int, Int, Int, Int?)] {
try db.execute(sql: """
INSERT INTO rrInterval (deviceId, ts, rrMs, seq, ord, srcChannel, tsSuspect)
VALUES ('my-whoop', ?, ?, 0, 0, ?, ?)
""", arguments: [t0 + ts, rrMs, channel, suspect])
}
}

try WhoopStore.makeMigrator().migrate(dbQueue)

let marks = try await dbQueue.read { db in
try Row.fetchAll(db, sql: "SELECT ts, rrMs, tsSuspect FROM rrInterval ORDER BY ts, rrMs")
.map { [($0["ts"] as Int) - t0, $0["rrMs"] as Int, ($0["tsSuspect"] as Int?) ?? 0] }
}
XCTAssertEqual(marks, [[0, 500, 1], [1, 500, 0], [2, 500, 1], [2, 760, 0], [3, 500, 0], [9, 500, 1]])
}
}

extension WhoopStore {
/// Every stored R-R row for a device with its mark, bypassing the scoring read's filters. Test-only.
func fillMarksInTest(deviceId: String) throws -> [(ts: Int, rrMs: Int, tsSuspect: Int?)] {
try syncRead { db in
try Row.fetchAll(db, sql: """
SELECT ts, rrMs, tsSuspect FROM rrInterval WHERE deviceId = ? ORDER BY ts, ord, rrMs, seq
""", arguments: [deviceId]).map { (ts: $0["ts"], rrMs: $0["rrMs"], tsSuspect: $0["tsSuspect"]) }
}
}
}
16 changes: 16 additions & 0 deletions Tools/parity_dispositions.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,22 @@
"platform": "kotlin",
"rationale": "Room @Query DAO method. The Swift store issues the equivalent SQL inline in WhoopStore Reads.swift / StreamStore.swift rather than through a DAO, so there is no Swift function to pair with; its WHOOP 5 siblings whoop5RrIntervals, hasWhoop5RrSource and promoteWhoop5RrSource are one-sided for the same architectural reason."
},
{
"type": "platform_specific",
"kind": "add-unpaired-function",
"identity": "kotlin\u0000android/app/src/main/java/com/noop/data/WhoopDao.kt::flagWhoop5RrFill/3#1",
"identity_sha256": "3714e95a3d89ac54d74060d037e14dc39a0c6d2219ecb01065471573d7db12bc",
"platform": "kotlin",
"rationale": "Room @Query DAO method (#2371). The Swift store runs the same statement inline in WhoopStore StreamStore.swift rather than through a DAO, so there is no Swift function to pair with; the SQL itself is paired, WHOOP5_RR_FILL_FLAG_SQL with WhoopStore.whoop5RrFillFlagSQL, and pinned by the same literal on both platforms."
},
{
"type": "platform_specific",
"kind": "add-unpaired-function",
"identity": "kotlin\u0000android/app/src/main/java/com/noop/data/WhoopDatabase.kt::migrate/1#39",
"identity_sha256": "81b9152e7189625064e51189ec7be31e1d1f57662438fe54df4c1f779a6bbe48",
"platform": "kotlin",
"rationale": "Room Migration.migrate override of MIGRATION_40_41 (#2371). GRDB registers each migration as a closure (`v47-rr-whoop5-fill`), so there is no Swift function to pair with; the two are paired by the migration identifiers pinned in schema_oracle.json and by the SQL constant pair WHOOP5_RR_FILL_MIGRATION_SQL / WhoopStore.whoop5RrFillMigrationSQL."
},
{
"type": "platform_specific",
"kind": "add-unpaired-constant",
Expand Down
8 changes: 4 additions & 4 deletions Tools/parity_twin_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@
},
"authority": {
"files": {"count": 505, "sha256": "581ec0dcab0d8486ce113646cb5cbad71769353eb3aef75f45486e03591733a0"},
"functions": {"count": 4507, "sha256": "ce9326a53287c00561b1c642cfea7bca0132f8f89bbb9d45711f4c294518d532"},
"functions": {"count": 4509, "sha256": "828aa64d6abfd21f2dbb94bb552099409555b564dad5d04dbab88d8555466486"},
"properties": {"count": 465, "sha256": "64fcd18c85bf33a4738d154d45ec86967fdc974e78a448d16e9504d9b13609eb"},
"constants": {"count": 1965, "sha256": "bc257e17e354a48ebbade1e5035aaa41caf21784a119a83488e17270573724da"},
"constants": {"count": 1969, "sha256": "0cfd548d65ff82bdaa5860c8c63a0f8ac5bacbabc9a5abeb83773c1522147652"},
"file_pairs": {"count": 75, "sha256": "d581f60241ec523c595a7e5ff123f7987caee501d8be4ebf6df5ba105e26fe1f"},
"function_pairs": {"count": 206, "sha256": "5b2e4542dfea5e6e75ae3132e018c66bc12fd77092d0e2c996699c32df20ce0b"},
"property_pairs": {"count": 150, "sha256": "be7168d309c8a9cf5f3f25438c028cc60ab64202bb6e9c09a6c553e3699ad0e2"},
"constant_pairs": {"count": 683, "sha256": "c3452cb90df3bda98100154f7f2d109b4b473f6358d6ec1d8476ea9fc183eaa3"},
"constant_pairs": {"count": 685, "sha256": "a949b8f3ffc009b8054d7bc56f4131f7baf134b6f09823f323853d5cfb52c246"},
"unpaired_files": {"count": 377, "sha256": "14e70f77d7e11b4eae3b8412cc493c1aa1bf24dfe8fbc5eca71f81b5daf5253f"},
"unpaired_functions": {"count": 4108, "sha256": "0aad0d7db52de523a86a5e7438ed4e48bc7a4b54aa1300eb3b56ce907c4616ac"},
"unpaired_functions": {"count": 4110, "sha256": "16b46b58f2be94f5653bc25ea4099ed8d220a418b0cf193e0d408722c4910708"},
"unpaired_properties": {"count": 165, "sha256": "fe609dd25384cda6de1616d36753fed68431b1a1f9a73b6fd664c7ff35089955"},
"unpaired_constants": {"count": 599, "sha256": "b7467c65623ecc296604558a639d62eee2e00874c56d21acc7ec6d3b6264fdc7"}
}
Expand Down
20 changes: 20 additions & 0 deletions android/app/src/main/java/com/noop/data/WhoopDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,22 @@ internal const val PROMOTE_WHOOP5_RR_SOURCE_SQL =
"AND ((:source = 5 AND (srcChannel IS NULL OR srcChannel IN (6, 7))) " +
"OR (:source = 7 AND (srcChannel IS NULL OR srcChannel = 6)))"

/**
* #2371: marks the strap's 500 ms fill beats in one device's ts window, after a batch that carries a 500 ms
* WHOOP 5 beat. A WHOOP 5/MG emits an exact 500 ms interval as a filler at rest on v18 history (5) and
* standard BLE (7); split by the strap's own heart rate in the same second, it is 33-37 times as common as
* its neighbours at 80-94 bpm and no more common from 100 bpm up. The row is MARKED `tsSuspect = 1` (every
* scoring read filters it, #1073), never deleted. One literal, so the parity ledger can compare it with the
* Swift twin `WhoopStore.whoop5RrFillFlagSQL`, which carries the full evidence.
*/
internal const val WHOOP5_RR_FILL_FLAG_SQL = "UPDATE rrInterval SET tsSuspect = 1 WHERE deviceId = :deviceId AND ts >= :fromTs AND ts <= :toTs AND rrMs = 500 AND srcChannel IN (5, 7) AND tsSuspect IS NULL AND EXISTS (SELECT 1 FROM hrSample h WHERE h.deviceId = rrInterval.deviceId AND h.ts = rrInterval.ts AND h.bpm < 100)"

/**
* Marks every stored fill beat once, in MIGRATION_40_41: the condition of [WHOOP5_RR_FILL_FLAG_SQL] over the
* whole table. Swift twin: `WhoopStore.whoop5RrFillMigrationSQL`.
*/
internal const val WHOOP5_RR_FILL_MIGRATION_SQL = "UPDATE rrInterval SET tsSuspect = 1 WHERE rrMs = 500 AND srcChannel IN (5, 7) AND tsSuspect IS NULL AND EXISTS (SELECT 1 FROM hrSample h WHERE h.deviceId = rrInterval.deviceId AND h.ts = rrInterval.ts AND h.bpm < 100)"

internal const val PROMOTE_WHOOP4_HISTORY_SQL =
"UPDATE rrInterval SET srcChannel = 8, ord = :ord " +
"WHERE deviceId = :deviceId AND ts = :ts AND rrMs = :rrMs AND seq = :seq AND srcChannel IS NULL"
Expand Down Expand Up @@ -703,6 +719,10 @@ interface WhoopDao : DeviceRegistryDao {
@Query(PROMOTE_WHOOP4_HISTORY_SQL)
suspend fun promoteWhoop4HistoricalRr(deviceId: String, ts: Long, rrMs: Int, seq: Int, ord: Int)

/** #2371: mark the WHOOP 5 500 ms fill beats in one device's window ([WHOOP5_RR_FILL_FLAG_SQL]). */
@Query(WHOOP5_RR_FILL_FLAG_SQL)
suspend fun flagWhoop5RrFill(deviceId: String, fromTs: Long, toTs: Long)

@Query(
"SELECT * FROM event WHERE deviceId = :deviceId AND ts >= :from AND ts <= :to " +
"ORDER BY ts ASC, kind ASC LIMIT :limit"
Expand Down
Loading
Loading