fix(protocol): v18 @36 is a flag byte, @106/@107 two u8 channels#861
Conversation
Three field-model errors in the WHOOP 5/MG v18 historical decoder, each established over 18,650 real v18 records (18,602 from a public HCI capture plus 48 from a second phone that cross-validate the structural claims). @36/@37 was read as one u16 `hr_fixed_8_8` with bpm = value/256. Bit 4 of @36 is NEVER set (0/18,650 — a genuine 8.8 fraction sets it ~50% of the time, and it is the only bit never set), 95.02% of values land in 0x80-0x8F over just 40 distinct values, and sd is 26.5 vs 73.9 for a uniform byte. @37 equals heart_rate@22 exactly in 99.575% of records (18,523/18,602), differing only by -6..+2. The "corr 0.989" that justified the old name was circular: the u16 is literally hr@22 plus a flag byte over 256, leaving a flat +0.504 +/- 0.189 residual. Split into `hr_quality_flags` (bit7 = valid; rr_count == 0 in 70.32% of records when clear vs 19.82% when set) and `heart_rate_alt`. @106 was read as a u16 LE. It cannot be one: across 18,599 consecutive-second pairs the HIGH byte changed while the LOW byte stayed frozen in 3,514 (18.89%), and the corpus holds ZERO low-byte wrap events — a u16 cannot step its high byte without a carry. The apparent deltas are exactly 256*d@107 + d@106. Split into two independent u8 channels (corr +0.73, moving in opposite directions in 5.8% of pairs), with 0 — not 128 — as the off-wrist marker. @108/@109 notes corrected: the 128 sentinel is RECORD-level, not per-channel (amp_a == 128 in 757 records and amp_b in 757 — the same 757, never one without the other). The documented rise with heart rate was an averaging artifact of counting the sentinel as a number; excluding it the trend is flat-to-declining (32.45 -> 29.52) and the real monotone trend is with motion (~32.7 still -> 37.4 at 0.05-0.2 g). The sentinel is a usable per-second signal-quality flag: it predicts the band's own beat-detection failure (rr_count == 0) at 79.44% vs 19.40%, a 4.09x lift that survives holding motion constant (4.11x within dyn_acc < 0.009 g). Still not named SpO2/perfusion — the wavelengths are unknown. Swift and Kotlin twins kept in lockstep, both decoder_oracle.json copies updated, plus the linux-capture tool and the field table in BLE_REVERSE_ENGINEERING.md.
ryanbr
left a comment
There was a problem hiding this comment.
Good work, and the evidence is the right kind — structural impossibility rather than correlation.
I had already verified the @36 finding independently from the doc's own worked example: 25997 = 101·256 + 141, so "101.55 bpm" is literally frame[37] + frame[36]/256. The 0.989 correlation was guaranteed by construction. Your +0.504 ± 0.189 residual is the same fact measured properly, and your example has @37=101 against a documented @22=102 — one of the 0.425%, which corroborates rather than undercuts it.
The @106 argument is the strongest of the three. Zero low-byte wraps across 18,599 consecutive pairs while the high byte moves in 18.89% is not a statistical claim, it is arithmetic: a little-endian u16 cannot carry without wrapping. That settles it on its own.
And the sentinel correction is the kind that is easy to miss — the documented "amps rise with HR" reversing from 33.50→57.66 to 32.45→29.52 once the 128s stop being averaged in as numbers. An artifact of including a sentinel in a mean is exactly how a plausible-looking trend gets into a doc.
You also did the three things I asked for on #848: corrected the doc rather than only the decoder, kept the retired reading with why it was circular so nobody re-derives it, and renamed in the same change.
One blocker, and it is a merge-order problem rather than anything wrong here.
#848 is open and mergeable, and it extracts its aux slot by decoder key:
rawU16At36: p["hr_fixed_8_8"]?.intValue // HistoricalStreams.swift:289This PR removes that key. If #861 lands first, that lookup returns nil and the slot silently banks nothing — no compile error, because it is an optional dictionary read, and no visible symptom, because absence is a first-class state in the aux format. Silent data loss in a capture format whose entire purpose is preserving fields before the strap trims them.
To be clear this is not an argument against merging #861 first — it should go first, it is the correction. But #848 then needs:
- the lookup pointed at
hr_quality_flags/heart_rate_alt rawU16At36(width 2) reconsidered, since after this there is no u16 at@36to read — probably two u8 slots
Credit where due: #848 already renamed that slot away from hrFixed88 to the neutral rawU16At36 with a comment explaining the circularity, which was my ask. The lookup key just did not follow.
Happy to merge this as soon as you want; flagging so the two do not land in the wrong order.
…865) Follow-up to #861, proposed on #845. #861 corrected three WRONG readings. The same census pinned a lot about three fields the doc still called unknown — a different kind of change, which is why it was not in that PR. Until now the reference table said "@33 / @38 / @40: carried raw (meaning not pinned from observation)" and "@113: purpose unknown". Both were accurate that morning. Leaving them costs the expensive way: the next person re-derives it, or invents a name for a byte we now have real structure on, which is exactly what #861 spent its length undoing. The shared row splits, because they are no longer one story: @33 bit 0 byte-identical to @81 bit 0 in 18,650/18,650, across two sessions 15 days apart on different hosts — NOT an independent signal. Bits 1-3 never set; bits 4-5 thermometer-coded (bit 4 only ever set with bit 5, state 01 never occurs). High-nibble popcount monotone against P(rr_count == 0): .180/.207/.301/.427/.612 against a .219 base rate. @38 unchanged, still not pinned. @40 a saturating 0-255 confidence score, r = -0.80 with @113. @113 floors at -5.2869 when quality is good; takes P(rr_count == 0) from 18.30% to 78.00%. Two deliberate restraints. Names stay POSITIONAL. cardiac_flags and cardiac_status describe where the bytes sit, not what they are; the census says what they do, not what they measure. Renaming to beat_quality_* would assert an interpretation one night cannot carry, and #861 is the cautionary example. The corpus caveat travels with the table rather than living in an issue thread: single subject, one night, 5 h 10 m, 99.48% band-scored asleep, no ambulation, no workout, no verified off-wrist period. The cardiac-quality fields are well exercised; the activity-side behaviour is not. Without that line a reader takes "monotone against rr_count == 0" as established across wake and exercise, which the data cannot support. Attributed to the #845 census, not presented as this repo own measurement — the corpus is digitalerdude public capture plus a second strap and is not in-tree, the same footing the ~258k figures already sit on. Every figure was diffed against the original comments and matches verbatim; the only derived number is the 4.3x lift, arithmetic on the two values printed beside it. Field names checked against Interpreter.swift post-#861 so the doc matches what the decoder emits. Docs only. Nothing consumes these fields; they remain instrumentation under the #194 rule.
|
Thanks — and the merge-order catch was the valuable half of that review. Agreed on both counts: this should land first, and #848 needed to adapt. #848 is now adapted and safe for you to merge this whenever you like. The audit turned up a second instance of the same bug class, not just the one you spotted. Both retired keys were fused u16s that #848 was banking as single slots, so both had to split rather than merely repoint:
The other 13 slot keys and the The durable fix, which matters more than the repoint: #848 now carries Heads-up so red checks don't surprise you: #848's CI is currently red by exactly those five tripwire tests, because the keys genuinely don't exist yet on One incidental confirmation from the Android run: after #854's aapt2 pin, |
… discarding (#848) The 5/MG v18 decoder already produces a set of per-second channels that the storage funnel then dropped on the floor. This banks them: a 17-slot aux row per v18 second, carried as a blob with a presence bitmap so absence is first-class and never silently 0. GRDB v31 / Room 24->25, additive, gated on hist_version == 18 so a 4.0 second never banks a near-empty row. SLOT NAMING IS A STORAGE CONTRACT. V18AuxSlot ordering is the wire ordering, so a name that implies a meaning the bytes have not earned is expensive to correct later. Two slots were split rather than banked as u16s, both on #861 evidence: @36/@37 were one u16 `hr_fixed_8_8` ("higher-precision HR, bpm = value/256"). #861 retired that: bit 4 of @36 is never set across 18,650 records where a genuine 8.8 fraction would set it ~50% of the time, 95.02% of values land in 0x80-0x8F, and the "corr 0.989 with heart_rate@22" was circular — the u16 is literally the duplicate HR at @37 plus the @36 flag byte over 256. Two bytes, two slots, both raw. @106/@107 were one u16 optical baseline. A u16 is structurally impossible there: across 18,599 consecutive-second pairs the high byte moved while the low byte stayed frozen in 3,514 of them, with zero low-byte wrap events. Names are the decoder's own keys verbatim, because decoderKey is a string lookup into the parsed dictionary: a slot whose key the decoder no longer emits does not fail to compile and does not fail at runtime, it banks silence forever. That is not hypothetical — the first revision of this PR read `hr_fixed_8_8`, which #861 had removed hours earlier. Two tripwires per platform now cover it: testEverySlotDecoderKeyExistsInARealV18Decode, and testEverySlotIsPopulatedFromARealV18Frame for the harder case where decoderKey and the extractor's own literal drift apart. CROSS-PLATFORM WIDTH. Swift Int is 64-bit, Kotlin Int is 32-bit, so a u32 with bit 31 set — which unknown_f32_113 routinely has — decoded to different numbers, with each platform's fixtures asserting its own answer. The wire bytes were identical, so the pinned-hex tests could not see it. V18AuxRow is Long throughout and asU32() masks both u32 slots back into 0..4294967295; verified across the sign boundary (0x80000000, 0xC0A7619D, 0xFFFFFFFF) against the Swift value. Contract verified mechanically across platforms: 17 slots, identical indices 0..16, identical widths, identical decoderKey on every one, retention 604,800 both sides. The f32 path gates isFinite on both platforms and banks Float bits, so the Double round-trip is exact and a non-finite value is absent on both. RETENTION. Capped at 604,800 rolling rows per device (7 x 86,400, a ~50 MB ceiling at ~85 B/row) rather than unbounded — the rawImuSample shape from #423. This re-introduces a bounded version of the loss the migration exists to stop, deliberately: a census needs weeks of records, not years, and the alternative is an invisible table that outgrows everything a user actually reads. Also carries a genuine pre-existing fix: the #547 timestamp heal did not cover ppgWaveformSample / rawImuSample / sleepStateSample, and the write-only instrumentation tables are now documented and counted in the storage readout. VERIFICATION. test (WhoopProtocol) and test (WhoopStore) green, so the codec, the migration and both Swift tripwires are compiled and executed — 15 of 29 files. The other 14 are Kotlin and nothing compiles them, so MIGRATION_24_25 and the Kotlin tripwires have never run. Their assertion was evaluated statically instead: all 17 slot keys are emitted by the Kotlin decoder and read back, and hr_fixed_8_8 survives only in explanatory comments. That is a static substitute, not a run.
Three field-model errors in the WHOOP 5/MG v18 historical decoder, each established over 18,650 real v18 records — 18,602 from digitalerdude's public HCI capture (
whoop5-hci-capture-2026-07-10) plus 48 from a second phone that cross-validate the structural claims on completely different magnitudes.All three are corrections to readings, not new offsets: the bytes were already decoded, just modelled wrongly. No new physiological claims are made, and nothing is renamed to a metric without ground truth.
1.
@36is a flag byte, not a fixed-point HRhr_fixed_8_8read[36:38]as a u16 withbpm = value/256. The data rejects that:@36is never set — 0 of 18,650 records. A genuine 8.8 fraction sets it ~50% of the time. It is also the only bit never set.@36values land in0x80–0x8F(uniform would be 6.25%), across just 40 distinct values; sd 26.5 vs 73.9 for a uniform byte.@37equalsheart_rate@22exactly in 99.575% of records (18,523/18,602), differing only by −6…+2.The
corr 0.989that justified the old name was circular: the u16 is literallyhr@22(at@37) plus a flag byte over 256, so the residual is a flat +0.504 ± 0.189 — that is@36/256, not a sub-bpm fraction.Now emitted as
hr_quality_flags(u8@36) andheart_rate_alt(u8@37). Bit 7 reads as a validity bit: with it clear (n=748)rr_count == 0in 70.32% of records vs 19.82% with it set, and the@108/@109sentinel fires in 69.65% vs 1.32% — the two flags report the same quality condition. On records where bit 7 is clear the old model produced absurd output; one fixture in this repo decoded to a 227 bpm "higher-precision heart rate".2.
@106is two u8 channels, not a u16optical_baseline_106read[106:108]as a u16 LE. Structurally impossible:256·Δ@107 + Δ@106, clustering at 0, ±1, ±255, ±256, ±257, ±513.They are two correlated but independent u8 channels (corr +0.73, moving in opposite directions in 5.8% of pairs where both move), structurally parallel to the
@108/@109pair. Nowoptical_baseline_a/optical_baseline_b.The old "128 = invalid" note was also wrong here:
0, not128, marks off-wrist — both bytes read 0 in exactly the 8 records that also carryHR == 0, while 128 occurs unremarkably while worn (@106in 10 records,@107in 103). Magnitudes are device-specific (102–255 / 119–247 on one strap vs 20–66 / 34–81 on another), so no scale is asserted.3. Two wrong notes on
@108/@109amp_a == 128in 757 records andamp_b == 128in 757 — the same 757, never one without the other.dyn_acc< 0.02 g) → 37.4 at 0.05–0.2 g.Also documented: the sentinel is a usable per-second signal-quality flag. It fires on 4.02% of worn seconds and predicts the band's own beat-detection failure (
rr_count == 0) at 79.44% vs 19.40% — a 4.09× lift that survives holding motion constant (4.11× withindyn_acc< 0.009 g), where shuffled and circular-shift nulls all sit at ~1.0×. Deliberately not described as SpO₂, blood pressure or a perfusion substrate: signal-quality/AGC is the supported reading and the wavelength identity is unknown.Verification
Test-first: each corrected reading got a failing test before the decoder changed, and the new tests pin the model rather than just values —
@107must leave the@106channel untouched (impossible under the u16 read).@37= 97 and 227 againstHR57 and 63.Swift and Kotlin twins kept in lockstep, both
decoder_oracle.jsoncopies updated identically, plustools/linux-capture/whoop_activity.pyand the field table indocs/BLE_REVERSE_ENGINEERING.md(the retired reading is kept as a struck-through row explaining why the correlation was circular, so it does not get re-derived later).swift test(WhoopProtocol): 340 passed, 0 failures./gradlew testFullDebugUnitTest: 3,063 tests, 0 failures attributable to this changetools/linux-capturetests: all passOne pre-existing failure exists on
mainand is unrelated to this PR:com.noop.ui.SyncChipStateTest > lastSyncedAt_takesPriorityOverHistorySyncExperimentalfails on pristinemain(verified at cca23d6 in a clean worktree).shortSyncAgo's< 60 sbranch is the only one callinguiString(R.string…), which cannot resolve in a plain JVM unit test, and that test passesnow - 5. Happy to send a separate PR for it.No BLE/hardware behaviour is touched — this is decode-only, covered by tests that run with no app, no strap and no CoreBluetooth.