Skip to content

fix(protocol): v18 @36 is a flag byte, @106/@107 two u8 channels#861

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
vishk23:fix/v18-decode-model
Jul 27, 2026
Merged

fix(protocol): v18 @36 is a flag byte, @106/@107 two u8 channels#861
ryanbr merged 1 commit into
ryanbr:mainfrom
vishk23:fix/v18-decode-model

Conversation

@vishk23

@vishk23 vishk23 commented Jul 27, 2026

Copy link
Copy Markdown

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. @36 is a flag byte, not a fixed-point HR

hr_fixed_8_8 read [36:38] as a u16 with bpm = value/256. The data rejects that:

  • Bit 4 of @36 is 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.
  • 95.02% of @36 values land in 0x80–0x8F (uniform would be 6.25%), across just 40 distinct values; sd 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 (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) and heart_rate_alt (u8 @37). Bit 7 reads as a validity bit: with it clear (n=748) rr_count == 0 in 70.32% of records vs 19.82% with it set, and the @108/@109 sentinel 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. @106 is two u8 channels, not a u16

optical_baseline_106 read [106:108] as a u16 LE. Structurally impossible:

  • Across 18,599 consecutive-second pairs, the high byte changed while the low byte stayed frozen in 3,514 (18.89%).
  • There are zero low-byte wrap events in the entire corpus. A real u16 cannot step its high byte without a carry.
  • The apparent u16 deltas are exactly 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/@109 pair. Now optical_baseline_a / optical_baseline_b.

The old "128 = invalid" note was also wrong here: 0, not 128, marks off-wrist — both bytes read 0 in exactly the 8 records that also carry HR == 0, while 128 occurs unremarkably while worn (@106 in 10 records, @107 in 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/@109

  • The 128 sentinel is record-level, not per-channel. amp_a == 128 in 757 records and amp_b == 128 in 757 — the same 757, never one without the other.
  • They do not rise with heart rate. The documented climb (~34 at HR 40–49 → ~58 at 80–89) was an averaging artifact of counting the 128 sentinel as a number. Including sentinels: 33.50 → 57.66. Excluding them: 32.45 → 29.52 — flat to slightly declining. The real monotone trend is with motion: ~32.7 while still (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× within dyn_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 —

  • Mutating @107 must leave the @106 channel untouched (impossible under the u16 read).
  • The two second-device fixtures, both bit-7-clear, carry @37 = 97 and 227 against HR 57 and 63.
  • The 128 sentinel must fire on both amp bytes or neither, across all four real fixtures.

Swift and Kotlin twins kept in lockstep, both decoder_oracle.json copies updated identically, plus tools/linux-capture/whoop_activity.py and the field table in docs/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 change
  • tools/linux-capture tests: all pass

One pre-existing failure exists on main and is unrelated to this PR: com.noop.ui.SyncChipStateTest > lastSyncedAt_takesPriorityOverHistorySyncExperimental fails on pristine main (verified at cca23d6 in a clean worktree). shortSyncAgo's < 60 s branch is the only one calling uiString(R.string…), which cannot resolve in a plain JVM unit test, and that test passes now - 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.

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 ryanbr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:289

This 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 @36 to 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.

@ryanbr
ryanbr merged commit 6a60c53 into ryanbr:main Jul 27, 2026
10 checks passed
ryanbr added a commit that referenced this pull request Jul 27, 2026
…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.
@vishk23

vishk23 commented Jul 27, 2026

Copy link
Copy Markdown
Author

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:

retired here #848 now reads
hr_fixed_8_8 (u16 @36) hr_quality_flags (u8) + heart_rate_alt (u8)
optical_baseline_106 (u16 @106) optical_baseline_a (u8) + optical_baseline_b (u8)

The other 13 slot keys and the hist_version gate are untouched by this PR. Side effect: that removes #848's one deliberate slot-name exception — every slot is now the decoder's own name verbatim.

The durable fix, which matters more than the repoint: #848 now carries everySlotDecoderKeyExistsInARealV18Decode and everySlotIsPopulatedFromARealV18Frame on both platforms. The first pins that every slot's decoderKey resolves in a real v18 decode; the second pins that the value actually reaches the sample, since the key string and the extractor literal are separate strings and only the second one is load-bearing. A future key rename now fails a test instead of silently banking nothing — which is the failure mode you identified.

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 main. That's the guard doing its job, and it makes an out-of-order merge mechanically impossible rather than merely discouraged. I verified fix/v18-decode-model merges into #848 with zero conflicts, and post-merge it's green: WhoopProtocol 363/0, WhoopStore 319/0, Android 3092/1 (the one being the pre-existing unrelated SyncChipStateTest), macOS build succeeded. So merging this turns #848 green with no further action from either of us.

One incidental confirmation from the Android run: after #854's aapt2 pin, ./gradlew testFullDebugUnitTest now works on macOS without --dependency-verification=off. Thanks for that one — it removes a step that had been quietly weakening local verification.

ryanbr pushed a commit that referenced this pull request Jul 27, 2026
… 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.
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.

2 participants