Repository navigation
store: mark the WHOOP 5 500 ms R-R fill value as suspect - #2569
Conversation
…green locally; spacing PR waits on the 29 Sep log
|
@UtkuDenizAltiok Reviewed. This is a careful piece of work and the evidence behind it is better than most changes of this shape get. Rather than read it over, I checked the parts that could be wrong in ways reading would not show. The guard is load-bearing, and it holds. Both statements are byte-identical across platforms. I extracted the four literals and diffed them rather than comparing by eye: 277 bytes for the flag statement, 218 for the migration, no differences. Spelling each one out in full on both sides instead of composing it from Every version pin moves together. The stated mechanism is true. The comment claims the batch's heart rate is already on disk when the fill statement reads it, which is the difference between marking the batch and silently missing it. Swift orders Your exported schema matches what KSP generates here. Regenerating on this machine produced no diff against your checked-in Full Android suite on the rebased branch: 6534 tests, 0 failures, 0 errors, across 769 classes, every result file written inside the run window. Ratchet: 0 errors. One thing I changed
I have added a commit naming both causes, and the part that matters downstream: the mark records THAT a beat is excluded, never WHICH cause excluded it. A diagnostic that counted these and called them clock corruption would be asserting more than the column can attribute. The rebaseOnly your parity refresh commit conflicted, on One judgement call, which is mine and not a change requestThe 100 bpm cutoff rests on one strap over four nights. Inside that sample the cliff is convincing, but the band just under it is where your own table is thinnest:
At 95-99 the excess has fallen from 33x to 3x, so roughly a third of what gets marked there is plausibly a real beat, and the "at least 17% shorter than its second's mean" argument is weakest exactly at the boundary. The reader this touches is the one you already flag honestly: What keeps it small is the thing your table actually shows: the rule demands EXACTLY 500 ms, and a real premature beat can land anywhere in that span while the fill is always 500, which is why the ratio is 33x rather than 2x. 873 beats in 443,896 is 0.2%, nothing is deleted, and the mark can be re-derived later. My read is that it ships as written. I am recording the trade rather than asking you to change it. Thank you for the distribution table, for measuring the blast radius on a real backup instead of asserting it, and for switching the fix off to see which tests go red rather than trusting that they would. |
A WHOOP 5/MG sends an exact 500 ms R-R interval as a filler at rest, on v18 history (srcChannel 5) and standard BLE (7), and it was stored as a real beat. Split by the strap's own heart rate in the same second, 500 ms occurs 33-37 times as often as its neighbours at 80-94 bpm, 3 times at 95-99, and no more often from 100 bpm up (one 5.0's backup, 443,896 beats). After each R-R insert, one statement marks tsSuspect = 1 on a 500 ms beat of channel 5 or 7 whose second has a stored heart rate under 100 bpm. The same condition over the whole table runs once as GRDB v47-rr-whoop5-fill and Room MIGRATION_40_41 (version 41). Marked, never deleted: every scoring read already skips tsSuspect rows (ryanbr#1073) and the row stays on disk. The SQL is one literal per platform, paired by the parity ledger and pinned by the same literals in Whoop5RrFillTests (Swift) and Whoop5RrFillTest (Kotlin); Whoop5RRSqliteTest runs the same cases through the production insert. The Room DAO method and migration are recorded as one-sided in parity_dispositions.json, like their siblings. Refs ryanbr#2371 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Output of `parity_ledger.py --refresh-derived --base upstream/main`: the two new SQL constant pairs, and the Room DAO method and migration recorded as one-sided in parity_dispositions.json. Refs ryanbr#2371 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
87ea199 to
a5afb4c
Compare
…answered; new rules (fail-not-crash, warn before failing runs)
#2569 marks the WHOOP 5/MG 500 ms fill beats with `tsSuspect = 1`. Reusing that flag is the right call: every scoring read already tests `<> 1` and wants both kinds of beat gone, and a second value would be read as "not suspect" by all of them. What it leaves behind is a column documented as something it no longer is. `Entities.kt` still said "1 when this beat's ts is in the FUTURE (corrupt ring time)", and `Reads.swift` still annotated the read filter "#1073: exclude future-stamped beats". Both now name both causes. The part worth having in writing is the last clause: the mark records THAT a beat is excluded, never WHICH cause excluded it. A diagnostic that counted these rows and reported them as clock corruption would be asserting more than the column can attribute, and nothing in the schema would contradict it. Documentation only. No declaration is added or removed, so the parity authority does not move. Refs #2371, #1073
), ryanbr#2569 merged, build 769113c; rescore-profile tools; STATE rewritten for a fresh session
What this PR does
A WHOOP 5/MG sends an exact 500 ms R-R interval as a filler when the wearer is at rest, and NOOP stored it as a real heartbeat (#2371). This marks those beats as suspect (
tsSuspect = 1), the same flag every scoring read already skips for future-stamped beats (#1073). Nothing is deleted: the rows stay on disk.The rule: a stored beat of exactly 500 ms, on WHOOP 5 history (
srcChannel5) or standard BLE (7), in a second whose stored heart rate is under 100 bpm. It runs after every R-R insert (only for a batch that carries such a beat), and once over existing rows as GRDBv47-rr-whoop5-fill/ RoomMIGRATION_40_41(Room version 41). Channel 6 (live type-40) is left out because scoring never reads it; a beat with no heart rate in its second is left alone.Why 100 bpm. One WHOOP 5.0's backup (443,896 beats on channels 5 and 7, four nights), each 500 ms beat matched to the strap's own heart rate in the same second, compared with the mean count of its neighbours (495–499 and 501–505 ms):
The filler stops at 100 bpm; above that, a 500 ms beat is as common as its neighbours, so it is a real beat and is kept. Below 100 bpm a real 500 ms beat would be at least 17% shorter than the average interval of its second. Every 500 ms beat in this backup has a heart rate for its second. The issue's own capture (a WHOOP MG, resting ~60 bpm) shows the same thing.
What it changes, measured on that backup (the migration applied to a copy):
cleanRRto reject every filler, but fillers often come in runs (up to 11 in a row), and inside a run the local median is 500 itself: 138 of the 508 channel-5 fillers pass the ectopic filter today (radius 2, 20%). Nightly RMSSD over each of the four sleep sessions (channel 5, with a Python replica ofcleanRRGapAware+rmssdGapAware, not the app's own nightly pipeline) goes from 23.68 → 23.73, 29.54 → 29.64, 27.27 → 27.30 and 30.44 → 30.47 ms (+0.1% to +0.3%).RhythmScreenerrange-filters but keeps ectopy on purpose, so each filler read as an irregular beat;SleepStagerV2's RSA respiration term only clamps to 300–2000 ms.Type of change
How it was tested
WhoopStore, 629 tests pass):Whoop5RrFillTests— the statements pinned by literal; at ingest only a 500 ms beat on channel 5/7 under 100 bpm is marked (99 bpm marked, 100 kept, 501 ms, channel 6, WHOOP 4, unlabelled and no-HR beats all kept, nothing deleted); a marked beat drops out ofrrIntervalsand stays on disk; a re-sync keeps the mark;v47marks rows stored at v46 and leaves a v35 mark alone. With the insert statement switched off 3 tests fail, with the migration's statement removed 1 fails; both restored byte-identical (sha256).SchemaOracleTestspass withv47androomVersion41.Whoop5RrFillTestpins the same literals and checksMIGRATION_40_41runs exactly the migration statement;Whoop5RRSqliteTestruns the same ingest, scoring, re-sync and migration cases as the Swift tests through the productionWhoopRepository.insertand the production SQL on SQLite, with the same expected rows. On the fork's Android CI the app builds and all 6,526 unit tests pass; with the fix switched off (a throwaway branch), exactly these four fail: the three ingest cases and the migration wiring.WHOOP5_RR_FILL_FLAG_SQL/WhoopStore.whoop5RrFillFlagSQLand the migration pair value for value. The Room DAO method and the migration'smigrateare recorded as one-sided inparity_dispositions.json, likepromoteWhoop4HistoricalRrbeside them, and the second commit is the output ofparity_ledger.py --refresh-derived --base upstream/mainonda4526f7.parity_ledger.py: no new findings;parity_ratchet.py: 0 errors.a5afb4c0(onda4526f7), every step passed: WhoopStore 629 · StrandAnalytics 2081 · StrandImport 327 · doc lint · i18n · parity ledger · ratchet · governance (124) · macOSStrandTests2,244 (only the two locale-dependentTodayCarryOverTests) · iOS build.Checklist
swift testinPackages/WhoopStore)android/(fork CI, see above)docs/CONTRIBUTING.mdStrand.xcodeproj/) or any secrets/keystoresRelated issues
Refs #2371, #1073
🤖 Generated with Claude Code