Skip to content

Measure how a duplicated second's two copies compare (#1505) - #1510

Merged
ryanbr merged 2 commits into
mainfrom
diag/rr-duplicate-pair-ratio-1505
Aug 21, 2026
Merged

Measure how a duplicated second's two copies compare (#1505)#1510
ryanbr merged 2 commits into
mainfrom
diag/rr-duplicate-pair-ratio-1505

Conversation

@ryanbr

@ryanbr ryanbr commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Instrumentation for #1505. No behaviour change, no storage change, nothing scored differently.

What it measures

#1505 asks whether WHOOP 5 v18 historical R-R is in 1/1024-second ticks rather than milliseconds. The
strap emits the beat train twice — live over 0x2A37, which the BLE spec fixes at 1/1024 s and we convert
on the way in, and again inside its v18 record, which we store as read. If those are the same beat in two
units, a second written by two deliveries holds two values 1024/1000 apart. If they're genuinely two
beats, the ratios scatter.

deliveryHistogram already counts how many deliveries wrote each second. It has never looked at what
they wrote
— which is the measurement the question turns on.

duplicatePairRatios reports the distribution of those ratios and takes no view on the answer:

rr dupPairs n=14 same=0 tick=12 other=2 medPPT=1024 spread=1019-1043

Restricted to the unambiguous case — seconds carrying exactly two rows, both ord 0. ord restarts
per delivery, so that's two deliveries each contributing their first beat, not two consecutive beats from
one record's array (which read 0 then 1).

Why a measurement rather than the fix

One pair can't settle it. The field pair in #1451 is -1s[872#0, 893#0], and 893 × 1000/1024 = 872.07 — an
exact hit. But 21 ms is also an entirely ordinary difference between consecutive beats, so both readings
explain it
. A population separates them: a tight cluster at 1.024 is a unit mismatch, a broad spread is
physiology.

That's the discipline the PPG→HR estimate failed in #194, where one matching night looked like validation.
A 2.4% shift to stored R-R moves HRV and the RSA-derived respiratory rate on both platforms; it should
follow evidence, not precede it.

Parity

Parts-per-thousand in integer arithmetic, and the output string is pinned by value in both test suites,
so a capture read on either platform is directly comparable and the twins can't drift. Deliberate: the
rounding of a tie has silently diverged between these two platforms twice before (#1476, #1486).

Verification

9 Kotlin tests and 9 Swift twins — the real field pair, an ordinary beat-to-beat difference that must
not read as a mismatch, exact duplicates, consecutive beats from one delivery excluded, ambiguous

2-row seconds skipped, rows with no ord ignored, and both population shapes.

Full Android suite: 4,182 tests, 0 failures. doc_comment_lint and i18n_audit --ci clean.

The Swift half lives in StrandAnalytics, which doesn't build on Linux (GRDB / sqlite3.h), so it's
validated by swift-packages CI rather than locally.

What this doesn't do

It doesn't convert anything, and it doesn't touch the transport-tagging work @gdorgian has for #1008/#1118
— that stays theirs. It just produces the evidence both of those are waiting on.

ryanbr added 2 commits August 21, 2026 18:58
…here are two (#1505)

#1505 asks whether the WHOOP 5 v18 historical R-R is in 1/1024-second ticks
rather than milliseconds. The strap emits the beat train twice - live over
0x2A37, which the BLE spec fixes at 1/1024 s and we convert on the way in, and
again inside its v18 record, which we store as read. If those are the same beat
in two units, a second written by two deliveries holds two values 1024/1000
apart. If they are genuinely two beats, the ratios scatter.

deliveryHistogram already counts how many deliveries wrote each second. It has
never looked at what they wrote, which is the measurement the question turns on.

duplicatePairRatios reports the distribution of those ratios and takes no view on
the answer. It is restricted to the unambiguous case: seconds carrying EXACTLY
two rows, both ord 0. ord restarts per delivery, so that is two deliveries each
contributing their first beat, not two consecutive beats from one record's array
(which read 0 then 1).

This exists because one pair cannot settle it. The field pair from #1451 is
`-1s[872#0, 893#0]`, and 893 × 1000/1024 = 872.07 - an exact hit, but 21 ms is
also an entirely ordinary difference between consecutive beats. Both readings
explain it. A POPULATION separates them: a tight cluster at 1.024 is a unit
mismatch, a broad spread is physiology. That is the same discipline the PPG→HR
estimate failed in #194, where one matching night looked like validation.

No behaviour change, no storage change, nothing scored differently - a diagnostic
line beside the existing histogram, on the path that already runs for
over-counted nights.

Parts-per-thousand in integer arithmetic, and the output string is pinned by
value in BOTH test suites, so a capture read on either platform is directly
comparable and the twins cannot drift. That is deliberate: the rounding of a tie
has silently diverged between these two platforms twice before.

9 Kotlin tests and 9 Swift twins: the real field pair, an ordinary beat-to-beat
difference that must NOT read as a mismatch, exact duplicates, consecutive beats
from one delivery being excluded, ambiguous >2-row seconds skipped, rows with no
ord ignored, and both population shapes.

Full Android suite 4182 tests 0 failures; doc lint and i18n --ci clean. The Swift
half is StrandAnalytics, which does not build on Linux (GRDB/sqlite3.h), so it is
validated by swift-packages CI rather than locally.
Two defects in the diagnostic from the commit before this.

The parts-per-thousand multiply was `hi * 1000` in each platform's native Int.
Kotlin's is 32-bit and Swift's is 64-bit, so a corrupt row above ~2.15 million ms
wraps on Android and does not on Apple. A v18 u16 cannot reach that, so it is not
reachable through the decoder today - but a diagnostic whose whole purpose is to
be compared across two platforms must not have a width-dependent answer sitting
in it, waiting for a stored value nobody bounded. Both sides now do the multiply
in 64 bits.

The pass also allocated a list per second and built a second dictionary over rows
the histogram beside it had already walked. That histogram carries a comment
explaining it was deliberately reduced to ONE dictionary with no per-second
allocation, because it runs over a night's beats about 21 times per
analyzeRecent, every 15 minutes - the pass #1005/#836 spent a release making
cheaper. Adding a second allocation-heavy walk beside it, in the same file, under
that comment, was not a defensible way to add a diagnostic. It now uses a
PairTally per second, mirroring SecondTally directly above it.

Behaviour is unchanged: both suites still pass, including the pinned output
string that the twins are compared by, which is what makes that safe to assert.

Android 4182 tests 0 failures; doc lint clean.
@ryanbr
ryanbr merged commit 5865c33 into main Aug 21, 2026
14 checks passed
@ryanbr
ryanbr deleted the diag/rr-duplicate-pair-ratio-1505 branch August 21, 2026 07:17
ryanbr added a commit that referenced this pull request Aug 21, 2026
…ity fixes (#1521)

Four changes, all to code introduced during 10.5.1 itself.

Build the R-R ord array once (both platforms). The over-count branch of the
sleep R-R diagnostics calls three analysers, and each was handed its own freshly
mapped copy of the night's ord column -- ~70k elements, on a branch reached ~21
times per analyzeRecent, every 15 minutes. All three expressions were identical,
so two copies were waste. Value-identical by construction. (#1510)

Read the strap model through @AppStorage (iOS). stepsPipelineActive did a
UserDefaults lookup from inside keyMetricTile, once per metric per body pass, on
a view that recomposes with live heart rate. SettingsView already declares the
same key this way. Also makes the value observed, so the tile re-renders on a
strap switch.

Key the Android prompt on the strap model it reads. stepsCalibrationPrompt reads
noop.selectedWhoopModel and returns null for anything but a 4.0, but the
remember around it was keyed only on the three calibration values -- and that key
is rewritten under a composed Today screen, from Settings and from live detection
in WhoopBleClient. Switching straps left the stale answer on screen.

Stop reading an unset strap model as a 4.0 (iOS). #1512 landed this prompt on
both platforms and the halves disagreed about one state from the first commit:
Android bails when the key is unset, Apple fell back to .whoop4 through
WhoopModel.persisted. An import-only user who had never paired was offered
"Need N more days where your phone also counted steps" for a strap nobody had
seen. Optional-chained now; every real 4.0 owner has the key written by
BLEManager.persistSelectedModel, so #1512's intent is untouched.

Left for #1523: the calibration-state terms are still OR-ed outside the family
check, so leftover sample days can activate the pipeline on a non-4.0 where
Android cannot. A design question, not an oversight.

Verified: android compileFullDebugKotlin + testFullDebugUnitTest (4189 tests, 0
failures, --no-build-cache --rerun-tasks); app-build green on both legs at
a16d885 including the Test Strand step, so StrandTests ran; doc lint and i18n
audit clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant