feat(protocol): bank the v18 per-second fields the storage funnel was discarding#848
feat(protocol): bank the v18 per-second fields the storage funnel was discarding#848vishk23 wants to merge 2 commits into
Conversation
… 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
left a comment
There was a problem hiding this comment.
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/256Your #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 contract — V18AuxSlot.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-level — amp_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.
|
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 But the two 4-byte slots decode to different NUMBERS on the two platforms, and your parity tests bake it in. Swift Your own fixtures use exactly such a value, and each suite asserts its own platform's answer: unknownF32Bits: 0xC0A7_619D // Swift: positiveassertEquals(0xC0A7619D.toInt(), a.unknownF32Bits) // Kotlin: negativeBoth 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 It is not an edge case for 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 Combined with the |
extractHistoricalStreamsis 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):
dynAccelREALgravitySampledynamic_acceleration@41rawByteINTEGERsleepStateSample@81byte (stateuntouched =(rawByte>>4)&3)aux1Raw/aux2RawINTEGERskinTempSampletemp_aux_1_raw@69/temp_aux_2_raw@71dynamic_accelerationgoes 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 present —gravitySampleneedsgravity_x,skinTempSampleneeds@73past its thermal gate,hrSampleskips 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 gatedspo2_candidate_82untouched, nothing promoted tospo2Pct),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@63is the same byte asactivity_classunder a second name, andspo2_candidate_82is 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_countis 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+ RoomMIGRATION_24_25(db 24→25), additive ALTERs only, nothing renamed or reordered. Migration tests both sides; the Kotlin one asserts the hand-written SQL isADD COLUMNwith noNOT NULL, noDEFAULT, and no DROP/DELETE/UPDATE/INSERT.Storage: ~75 B per v18 strap-second ≈ 2 MB per 8 h night. For scale,
ppgWaveformSamplealready 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)) documentsppgWaveformSample'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,sleepStateSampleandv18AuxSamplewere all missing from the #547 timestamp heal despite sharing the sametsand ingest path, and all four were missing fromstorageStats()— so unbounded unpruned tables were invisible in the only space readout users have.Tests:
WhoopProtocol348 (11 new),WhoopStore311 (14 new),StrandAnalytics1150,NoopLocalAccess9 — all 0 failures. AndroidtestFullDebugUnitTest3084 passing, 30 new; one pre-existing unrelated failure (SyncChipStateTest) reproduced on pristineupstream/main. macOSStrandBUILD SUCCEEDED.