Skip to content

feat(protocol): bank the v18 per-second fields the storage funnel was discarding#848

Open
vishk23 wants to merge 2 commits into
ryanbr:mainfrom
vishk23:instrument/v18-deep-capture
Open

feat(protocol): bank the v18 per-second fields the storage funnel was discarding#848
vishk23 wants to merge 2 commits into
ryanbr:mainfrom
vishk23:instrument/v18-deep-capture

Conversation

@vishk23

@vishk23 vishk23 commented Jul 27, 2026

Copy link
Copy Markdown

extractHistoricalStreams is a narrow funnel: anything it doesn't name is decoded and dropped one line later — and the strap trims its banked history the moment NOOP acks the offload, so an unbanked field is gone permanently and can never be censused or validated. #845 inventories 23 such fields.

This lands every remaining 5/MG v18 per-second field as durable capture.

Four named channels ride existing per-second rows (all nullable, no DEFAULT):

Column Table Field
dynAccel REAL gravitySample dynamic_acceleration@41
rawByte INTEGER sleepStateSample the whole @81 byte (state untouched = (rawByte>>4)&3)
aux1Raw / aux2Raw INTEGER skinTempSample temp_aux_1_raw@69 / temp_aux_2_raw@71

dynamic_acceleration goes beside the gravity vector deliberately: NOOP's motion spine derives stillness from 1 Hz gravity deltas — orientation change, not acceleration — so storing the strap's own gravity-removed magnitude next to the proxy is what makes a later comparison possible at all.

The other fifteen fields go in one narrow side table v18AuxSample(deviceId, ts, fields BLOB) rather than fifteen columns, for three reasons: (1) no existing per-second table is guaranteed presentgravitySample needs gravity_x, skinTempSample needs @73 past its thermal gate, hrSample skips bpm = 0 — so a v18 record can carry aux fields while all of those gate out, and a hung-off-column design would silently drop records; (2) it keeps fifteen unpinned bytes out of tables analytics read; (3) a new slot is a bitmap bit, not a migration. Wire format: 1 version byte + u16 presence bitmap + only the present slots, LE, ~30 B full. Byte-identical Swift/Kotlin codecs, and both suites pin the same fixture hex, so a one-sided edit fails a test rather than writing blobs the other platform can't read from a .noopbak.

Slots use the decoder's own names verbatim — several are frankly unpinned bytes and nothing is renamed to imply a meaning it hasn't earned: record_index@11, rr_count@23, cardiac_flags@33, hr_fixed_8_8@36, rr_packed@38, cardiac_status@40, step_cadence@59, status_word@75, status_word_1@77, status_word_2@79, aux_byte_82@82 (raw; upstream's gated spo2_candidate_82 untouched, nothing promoted to spo2Pct), optical_baseline_106@106, optical_amp_a@108, optical_amp_b@109, unknown_f32_113@113 (banked as its raw u32 bit pattern so a future census can reinterpret the bytes).

Two fields deliberately not banked twice: motion_wear_quality@63 is the same byte as activity_class under a second name, and spo2_candidate_82 is derivable from the raw @82. A test enforces that no slot duplicates an already-persisted field.

Instrumentation only. No analytic, score, gate, UI or export reads any of it.

WHOOP 4.0 is byte-identical — the aux gate keys on hist_version == 18, not on which keys happen to be present. That's load-bearing: rr_count is shared with the 4.0 schema, so a presence test would have banked a near-empty row for every 4.0 second.

Migrations: GRDB v31-deep-capture-channels + Room MIGRATION_24_25 (db 24→25), additive ALTERs only, nothing renamed or reordered. Migration tests both sides; the Kotlin one asserts the hand-written SQL is ADD COLUMN with no NOT NULL, no DEFAULT, and no DROP/DELETE/UPDATE/INSERT.

Storage: ~75 B per v18 strap-second ≈ 2 MB per 8 h night. For scale, ppgWaveformSample already banks ~50 B per v26-second. No retention cap — matching every other durable decoded stream — but these tables are added to the storage-stats readout so the growth is visible.

Second commit (fix(store)) documents ppgWaveformSample's intended write-only status at its migration (it's instrumentation under the post-#194 rule, so it shouldn't be "cleaned up"), and fixes two gaps that make write-only data hazardous: ppgWaveformSample, rawImuSample, sleepStateSample and v18AuxSample were all missing from the #547 timestamp heal despite sharing the same ts and ingest path, and all four were missing from storageStats() — so unbounded unpruned tables were invisible in the only space readout users have.

Tests: WhoopProtocol 348 (11 new), WhoopStore 311 (14 new), StrandAnalytics 1150, NoopLocalAccess 9 — all 0 failures. Android testFullDebugUnitTest 3084 passing, 30 new; one pre-existing unrelated failure (SyncChipStateTest) reproduced on pristine upstream/main. macOS Strand BUILD SUCCEEDED.

vishk23 added 2 commits July 26, 2026 20:03
… discarding

`extractHistoricalStreams` is a narrow funnel: a field the Interpreter decodes but
the funnel does not name is computed and dropped one line later. That drop is
permanent — the strap trims its banked history as soon as NOOP acks the offload,
so an unbanked field can never be re-fetched, censused, or validated. This lands
every remaining 5/MG v18 per-second field as durable capture.

Four channels ride the per-second rows they belong beside:

  gravitySample.dynAccel     `dynamic_acceleration@41` (f32 g) — the strap's OWN
                             gravity-removed motion magnitude, computed on-device
                             from the full-rate IMU. NOOP's motion spine instead
                             derives stillness from `gravityDeltas`, the L2
                             distance between consecutive 1 Hz gravity vectors,
                             which measures orientation CHANGE at 1 Hz rather than
                             acceleration. Stored BESIDE that proxy, never instead
                             of it, so the two can be compared on real nights.
  sleepStateSample.rawByte   the WHOLE @81 flag byte. v21 stored only
                             `(byte >> 4) & 3` as `state`; b0-1 `onwrist` and b2-3
                             `wake_quality` were decoded and dropped, and b6-7 have
                             no interpretation at all (0 across every capture held
                             here). `state` is untouched, so #175 is bit-identical.
  skinTempSample.aux1Raw     `temp_aux_1_raw@69` / `temp_aux_2_raw@71` (i16,
  skinTempSample.aux2Raw     °C = value/10 — a different scale from the primary's
                             /100). Two further thermal channels that track the
                             primary closely (corr ~0.92 / ~0.97).

The remaining fifteen slots — `record_index@11`, `rr_count@23`, `cardiac_flags@33`,
`hr_fixed_8_8@36`, `rr_packed@38`, `cardiac_status@40`, `step_cadence@59`,
`status_word@75`, `status_word_1@77`, `status_word_2@79`, `aux_byte_82@82`,
`optical_baseline_106@106`, `optical_amp_a@108`, `optical_amp_b@109` and
`unknown_f32_113@113` — go to a new narrow `v18AuxSample` table as one compact
blob rather than fifteen nullable columns on a hot per-second table. Its own table
because no existing per-second table is guaranteed present: `gravitySample` needs
`gravity_x` to decode, `skinTempSample` needs @73 to clear its thermal gate,
`hrSample` skips bpm=0 — a v18 record can carry aux fields while every one of
those gated out. The blob is a 3-byte header (format version + u16 presence
bitmap) plus only the bytes actually present, ~30 bytes for a full row.

Names are the decoder's own, verbatim. Several of these are frankly unpinned
bytes: `cardiac_flags`/`cardiac_status` are outside-report names that do not
decode consistently across firmwares, and `optical_amp_a`/`_b` are optical channel
amplitudes, not a named physiological quantity. Nothing is renamed to imply a
meaning it has not earned, so a later census decides what they are.

Fields with an existing durable home are not duplicated. `motion_wear_quality@63`
is the same byte as `activity_class` under a second name and the same 0-2 gate;
`spo2_candidate_82` is a gated 70-100 view of the raw @82 byte and is recoverable
from it — neither is banked twice, and no SpO2 value is promoted anywhere.

INSTRUMENTATION ONLY. Nothing reads any of this: no analytic, no score, no gate,
no UI. Every column is nullable with no DEFAULT and the aux gate keys on
`hist_version == 18`, so a WHOOP 4.0 is byte-identical and pre-migration rows read
back absent rather than a fabricated 0 — history already trimmed by the strap
cannot be backfilled, so an absent channel has to stay absent.

Storage: GRDB `v31-deep-capture-channels` (a new identifier registered last; no
existing migration renamed or reordered) and its Room twin `MIGRATION_24_25`
(db version 24 -> 25). Additive ALTERs only, no table rebuild, no row touched, no
key changed. The blob encoding is byte-identical across the two platforms and both
suites pin the same fixture bytes, so a one-sided edit fails a test instead of
writing blobs the other platform cannot read out of a `.noopbak`.

Verified: WhoopProtocol 348 tests, WhoopStore 310 tests, StrandAnalytics 1138
tests all green; Android `testFullDebugUnitTest` 3073 tests with only the
pre-existing `SyncChipStateTest` failure (reproduced on a pristine upstream/main
worktree, unrelated); macOS `Strand` scheme builds.
Audit of `ppgWaveformSample` (the raw v26 optical waveform, migration
v27-ppg-waveform) confirmed the claim that it has a writer and a reader and no
production callers — and found the reader is deader than reported:

  writer  LIVE on both platforms. Swift: BLEManager -> Backfiller ->
          extractHistoricalStreams -> store.insert, plus the RawHistoryArchive
          replay. Android: the same chain plus CaptureImporter.
  reader  Swift `ppgWaveformSamples` has 5 call sites, ALL in
          PpgWaveformSampleTests. Kotlin `WhoopRepository.ppgWaveformSamples`
          has ZERO call sites, including zero tests.
  surface no analytic, UI, export, or diagnostic reads a waveform row. The raw
          CSV export carries ppg_bpm/ppg_conf (the DERIVED estimate, which IS
          fully consumed in production) but no waveform column.

That is the intended shape, not an oversight: the project's own rule is to land
unvalidated sensor work as instrumentation — decode and store, never score — after
the #194 PPG->HR estimate was withdrawn. So the reader is NOT removed and the
table is NOT deleted; the honest fix is to say so where the next reader will look,
and to close the two gaps that make write-only data genuinely hazardous.

1. DOCUMENTED. The v27 migration comment now states the consumer status outright,
   including that the reader has no production callers BY DESIGN, that its rows
   exist so a better estimator / HRV-from-PPG / a waveform viewer can later run
   over the ORIGINAL samples, and that it must not be "cleaned up" as dead code.
   It also draws the line to `ppgHrSample`, the derived stream that IS consumed —
   conflating the two is the main way to get this question wrong, and the
   derivation happens in memory and never reads back from this table, so these
   rows are not on any scoring path at all.

2. HEALED. `ppgWaveformSample`, `rawImuSample`, `sleepStateSample` and
   `v18AuxSample` all landed after the #547 timestamp heal was written and were
   never added to its table list, so a bad-clock strap's garbage-ts rows survived
   in them while every sibling stream was cleaned. They are keyed by the same `ts`
   from the same type-47 ingest path; there was no reason to exempt them. Fixed on
   both platforms (Swift `TimestampHeal.rawTables`, four new Room prune queries
   wired into the Android heal). Legacy rows only in practice — the #547 ingest
   gate now rejects an implausible ts before it is banked — which is why it went
   unnoticed. A plausible row is never touched.

3. MADE VISIBLE. `storageStats()` counted ten decoded tables and omitted
   `ppgWaveformSample`, `rawImuSample`, `sleepStateSample` and `v18AuxSample`, so
   an unbounded, deliberately-unpruned table was also invisible in the only
   readout a user has for what the store is spending space on. Growth that nothing
   reads still has to be growth somebody can SEE. Guarded by a table-exists check
   so a store predating any of these migrations still reports.

No table dropped, no reader removed, no retention cap imposed (no pruning matches
every other durable decoded per-second stream; PrunePolicy's cap governs only
`rawBatch`).

Verified: WhoopStore 311 tests green (1 new, covering the heal across all three
instrumentation streams), NoopLocalAccess 9, WhoopProtocol 348, StrandAnalytics
1150; Android `testFullDebugUnitTest` 3084 with only the pre-existing
`SyncChipStateTest` failure; macOS `Strand` scheme builds.

@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.

Strong PR. The blob-with-presence-bitmap is the right shape, "absence is first-class, never 0" is the correct call for unpinned bytes, both suites pinning the same fixture hex is exactly the parity discipline this needs, and gating the aux row on hist_version == 18 rather than key-presence is a subtle catch — rr_count really would have banked a near-empty row for every 4.0 second. Migration numbering is clean against current main (GRDB v30 / Room 24 after #830, so v31 / 24→25 is right).

One blocker: this banks a field under a name your own census disproved 25 minutes earlier.

Streams.swift:307:

case hrFixed88   // @36  u16 — higher-precision HR: bpm = value/256

Your #845 analysis showed u16@36/256 is (flags << 8 | hr) / 256 — the integer part IS @37, and the "fraction" is the flag byte over 256. I verified it against the doc's own worked example: 25997 = frame[37]=101, frame[36]=141, so 101.55 is literally 101 + 141/256. There is no sub-bpm HR in v18.

The data is preserved either way, since a u16 keeps both bytes. The problem is the name and comment become a storage contractV18AuxSlot.rawValue is the wire ordering, so this is the expensive kind of thing to correct later. It also contradicts the PR's own stated principle that "nothing is renamed to imply a meaning it hasn't earned". Suggest splitting into flagsAt36 (u8) + hrDupAt37 (u8), or keeping the u16 under a neutral name like rawU16At36 — before this ships, not after.

Same class, smaller: opticalAmpA's comment says 128 = invalid sentinel, but your census found it is record-levelamp_a == 128 and amp_b == 128 co-occur 757/757 and never independently. Worth wording as one record marker.

One thing to correct in the description: "No retention cap — matching every other durable decoded stream" is not accurate. rawImuSample caps at 3600 rows (rawImuRetentionRows, #423) — and it is the closest analogue here, raw instrumentation banked as a blob. At ~2 MB/night unbounded this is ~730 MB/year on a device with no server to offload to. Adding these tables to the storage readout is good, but visible-and-unbounded is still unbounded. Given nothing reads these yet, a rolling cap seems easier to relax later than to impose after users have a year of rows.

Not blocking, worth noting: the second commit's timestamp-heal fix for ppgWaveformSample / rawImuSample / sleepStateSample is a genuine pre-existing bug fix that stands on its own. The storageStats half clearly belongs here; the heal half would be easier to review — and to revert — separately.

@ryanbr

ryanbr commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Re-reviewed the codecs properly this time. The Swift unpack is solid — truncation-guarded, no over-read, unknown-version returns empty, trailing bytes ignored so a newer writer stays readable. Kotlin masks with and 0xFF throughout, so no sign-extension bug on individual bytes.

But the two 4-byte slots decode to different NUMBERS on the two platforms, and your parity tests bake it in.

Swift Int is 64-bit; Kotlin Int is 32-bit. For a u32 with bit 31 set:

Swift  Int: 3_232_604_573      Kotlin Int: -1_062_362_723

Your own fixtures use exactly such a value, and each suite asserts its own platform's answer:

unknownF32Bits: 0xC0A7_619D                      // Swift: positive
assertEquals(0xC0A7619D.toInt(), a.unknownF32Bits)   // Kotlin: negative

Both pass. The fixture-hex tests can't catch it either, because the wire bytes are identical — the divergence is in the decoded value, downstream of the bytes you pinned. The unknownF32At113 float accessor is fine on both (same bit pattern → −5.2307), so this only bites anything reading the raw integer.

It is not an edge case for @113: your census says it floors at −5.2869 when quality is good, so bit 31 is set in a large share of records. recordIndex@11 is also u32 — safe at 1 Hz for ~68 years unless the strap's counter does not start near zero.

Suggestion, minimal and wire-compatible:

// V18AuxCodec: widen the decoded domain to match Swift's 64-bit Int.
val values = arrayOfNulls<Long>(SLOTS.size)
var u = 0L
for (b in 0 until slot.width) u = u or ((data[i + b].toLong() and 0xFF) shl (8 * b))

with slotValues: List<Long?>, then assert the same literal on both sides — 3_232_604_573 — so the test proves parity instead of documenting its absence. Blob bytes are unchanged, so no migration and no re-write of existing rows.

Combined with the hrFixed88 naming point above, both are cheap now and expensive once rows exist.

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