From e8a7afa8845e259f812d04c1a29a703c362f940f Mon Sep 17 00:00:00 2001 From: Bucci <149576997+BucciMobile@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:30:53 +0100 Subject: [PATCH 01/13] .coderabbit.yaml aktualisieren --- .coderabbit.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 770ba8d19..849ea7d60 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -25,3 +25,5 @@ reviews: # and they are the least likely place for a shipped bug. Re-include them # for a PR that is mostly test work. - "!test/**" + auto_review: + drafts: true \ No newline at end of file From 4c0660360c12f8dfd0b7c3387ed5a21c211f5f0a Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 5 Nov 2026 21:25:45 +0000 Subject: [PATCH 02/13] docs: add WHOOP SpO2 evidence-review proposal (staged, self-critical) Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 110 +++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/proposals/whoop-spo2.md diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md new file mode 100644 index 000000000..d79f895d2 --- /dev/null +++ b/docs/proposals/whoop-spo2.md @@ -0,0 +1,110 @@ +# Proposal: WHOOP SpO2 (blood oxygen) — evidence review and staged plan + +Status: proposal only, no code yet. Written so other contributors can build on +the current state of `protocol` and `edge` without re-doing this research. + +## Process (what was done, openly) + +1. Reviewed `christianmeurer/whoop-reader` (MIT) end to end: 96-byte realtime + packet on the data characteristic, SpO2 claimed at byte 5 (range gate 50–100), + CRC-32 (poly 0x04C11DB7, xor-out 0xF43F44AC) at bytes 92–95. +2. Cross-checked against the community sources it cites + (jogolden/whoomp, bWanShiTong/reverse-engineering-whoop) and against current + open-source projects: ryanbr/noop ecosystem (issues #103, #2814, PRs #73, #11), + LeonardSEO/wearable5.0, rusheelraj.com/blog/whoop, Gadgetbridge issue #5731. +3. Compared with what our own repos already know: `protocol` (`live.dart`, + `records.dart`, `gen5_records.dart`), `whoop-rs` (`whoop-protocol/src/live.rs`, + `spo2_from_paired`, v18 `spo2_pct`), `edge` (`ble_engine.dart`, + `adapters/whoop_gen4.dart`, `adapters/signals.dart`). + +What was NOT done: no real-device captures, no ground-truth comparison against a +reference oximeter, no CRC verification against our own captures. Everything +below that is not marked "verified in our repos" is community knowledge with +the confidence level stated. + +## Findings by generation + +### WHOOP 4.0 — no secured SpO2 + +- whoop-reader decodes byte 5 of the 96-byte realtime packet as SpO2 %. + Single source, no ground truth, only a 50–100 plausibility gate. +- The original reverse-engineering author states blood oxygen on the 4.0 is + "only measured once a day, during sleep" — so byte 5 is expected to carry a + value only inside sleep windows and 0/invalid otherwise. +- Our `protocol` repo verified that `spo2RedRaw`/`spo2IrRaw` (inner 64/66) move + in lockstep with a fixed offset: a red/IR ratio from them tracks channel drift, + not oxygenation (`records.dart` explicitly forbids building SpO2 on them). + noop documents the same fields as "raw ADC; SpO2 % computed server-side". + whoop-rs therefore only computes a ratio-of-ratios estimate on paired v24 + windows and never a calibrated %. +- Open discrepancy: whoop-reader maps the data stream to characteristic + `61080004-…`, while whoop-rs maps `GEN4_DATA` to `61080005-…` and `…0004` to + EVENTS (`whoop-client/src/uuids.rs`). One mapping is off by one; must be + resolved against our own captures before any wiring. +- Constraint from edge AGENTS.md invariant 14: live high-rate streams are + RAM-only, never persisted. A live SpO2 could therefore only ever be an + ephemeral display value, never a day metric. + +### WHOOP 5.0 / MG — community cross-validated, sleep-only + +- v18 historical records carry the strap's own computed SpO2 % at frame-abs + byte 82 (= `inner[74]`), which our `protocol` already exposes as + `spo2CandidateRaw` (`gen5_records.dart`) but deliberately does not publish as + a percentage. +- Measurement schedule, confirmed independently by noop#2814, noop#73 and + wearable5.0 (FINDINGS §13): 30 s measurement bursts every 1,200 s, only while + the band declares sleep. 5/MG never sends red/IR raw channels (noop#11). +- Validation against the vendor app (noop#73): nightly means 93.4–96.7 vs + WHOOP's own 93.1–96.5 over the same weeks (period means 94.4 vs 94.8). No + night had both readings, so this stays an estimate, not a validation. +- Offline derivation of SpO2 from raw PPG is physically not possible on 5.0 + (26 time-multiplexed optical channels; red/IR never sampled simultaneously). + The byte-82 device value is the only usable source. +- Encoding not fully pinned: values above 128 decompose as `128 + flag`, bit 7 + looks like a status flag. This is why `spo2Candidate` is deprecated. + +## Self-critical assessment + +- "Matches the WHOOP app" is agreement, not validation; only a reference + oximeter (or PSG-grade device) validates. We state this explicitly, following + the precedent in `vendor-staged-sleep.md`. +- The gen4 byte-5 claim is single-source and contradicted by the sleep-only + measurement reports; we treat it as unverified. +- No project anywhere publishes a live (daytime) SpO2 for any WHOOP + generation. The realistic product surface is a nightly sleep SpO2 for + 5/MG, plus at most a raw/diagnostic byte for gen4. +- All sources agree the raw ADC channels cannot yield SpO2; any PR deriving % + from red/IR on 4.0 contradicts our own verified findings and should be + rejected. + +## Staged plan (proposed) + +1. **protocol** — raw, gated decode only: + - Keep `spo2CandidateRaw` as-is; add a pinned, documented interpretation + (bit 7 flag, value = low 7 bits) behind a plausibility gate, still + labeled candidate until validated. + - Add a decoder for the 96-byte realtime packet exposing byte 5 as **raw + + CRC flag only** — no percentage publishing. Resolve the characteristic + UUID discrepancy from real captures first. + - Tests from real frames; extend parity fixtures where applicable. +2. **whoop-rs** — mirror the decoders in `whoop-protocol` (live 96-byte packet, + v18 byte 82 interpretation), expose raw values via `whoop-ffi`; keep the + existing `spo2_pct` v18 and `spo2_from_paired` paths unchanged. +3. **edge** — user-facing only the validated-shape path: + - Wire `InputSignal.deviceSpo2` for gen5/5.0/MG as a nightly mean over the + sleep bursts (per-burst median, flag values dropped), labeled as a device + estimate — same labeling discipline as `vendor_staged`. + - Gen4 byte 5 stays diagnostic/raw in logs, never user-facing, until + ground-truth validated. + - Respect invariants: live streams RAM-only (inv. 14), never fabricate a + metric (inv. 3), bump `kAlgoVersion` when any output changes (inv. 4). +4. **Validation workstream** (prerequisite for any user-facing 4.0 value): + captures against a reference oximeter, byte-82 encoding pin-down, UUID + discrepancy resolution. + +## Out of scope + +- Any medical claim or diagnosis (terms.html: wellness only, not a medical + device). +- Deriving SpO2 from red/IR ADC counts. +- Persisting live-stream data. From f9f368ddda0367b3440f95ec69feaaf9c99fb2a2 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 6 Nov 2026 00:56:36 +0000 Subject: [PATCH 03/13] =?UTF-8?q?docs:=20address=20review=20=E2=80=94=20'a?= =?UTF-8?q?pp=20agreement'=20wording,=20analytics=20owns=20aggregation,=20?= =?UTF-8?q?whoop-rs=20deferred?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index d79f895d2..15be1d77f 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -45,7 +45,7 @@ the confidence level stated. RAM-only, never persisted. A live SpO2 could therefore only ever be an ephemeral display value, never a day metric. -### WHOOP 5.0 / MG — community cross-validated, sleep-only +### WHOOP 5.0 / MG — community-reported app agreement, sleep-only - v18 historical records carry the strap's own computed SpO2 % at frame-abs byte 82 (= `inner[74]`), which our `protocol` already exposes as @@ -87,13 +87,15 @@ the confidence level stated. CRC flag only** — no percentage publishing. Resolve the characteristic UUID discrepancy from real captures first. - Tests from real frames; extend parity fixtures where applicable. -2. **whoop-rs** — mirror the decoders in `whoop-protocol` (live 96-byte packet, +2. **whoop-rs** — DEFERRED: current scope is `protocol` + `edge` only. When + picked up: mirror the decoders in `whoop-protocol` (live 96-byte packet, v18 byte 82 interpretation), expose raw values via `whoop-ffi`; keep the existing `spo2_pct` v18 and `spo2_from_paired` paths unchanged. -3. **edge** — user-facing only the validated-shape path: - - Wire `InputSignal.deviceSpo2` for gen5/5.0/MG as a nightly mean over the - sleep bursts (per-burst median, flag values dropped), labeled as a device - estimate — same labeling discipline as `vendor_staged`. +3. **edge** — pass through the band-reported `deviceSpo2` samples for + gen5/5.0/MG; the per-burst medians and the nightly mean are computed in + `OpenStrap/analytics` (a metric, not signal wiring — AGENTS.md assigns + metrics there), labeled as a device estimate, same labeling discipline as + `vendor_staged`. - Gen4 byte 5 stays diagnostic/raw in logs, never user-facing, until ground-truth validated. - Respect invariants: live streams RAM-only (inv. 14), never fabricate a From feeb71a8926a74928a3ce76349c7cc3903ca80de Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 6 Nov 2026 00:59:46 +0000 Subject: [PATCH 04/13] docs: gate any user-facing SpO2 display on the validation workstream (both generations) Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index 15be1d77f..a48f01f74 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -96,13 +96,16 @@ the confidence level stated. `OpenStrap/analytics` (a metric, not signal wiring — AGENTS.md assigns metrics there), labeled as a device estimate, same labeling discipline as `vendor_staged`. - - Gen4 byte 5 stays diagnostic/raw in logs, never user-facing, until + - NO user-facing display on either generation until the validation + workstream lands: gen5 byte 82 stays raw/logged until its encoding + (bit-7 flag) is pinned and correlated against a reference device; gen4 + byte 5 stays diagnostic/raw in logs, never user-facing, until ground-truth validated. - Respect invariants: live streams RAM-only (inv. 14), never fabricate a metric (inv. 3), bump `kAlgoVersion` when any output changes (inv. 4). -4. **Validation workstream** (prerequisite for any user-facing 4.0 value): - captures against a reference oximeter, byte-82 encoding pin-down, UUID - discrepancy resolution. +4. **Validation workstream** (prerequisite for ANY user-facing SpO2 value, + both generations): captures against a reference oximeter, byte-82 encoding + pin-down, UUID discrepancy resolution. ## Out of scope From 7c20915dde77aaa113ff38ba1b7864b9e86eb10b Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 6 Nov 2026 08:43:40 +0000 Subject: [PATCH 05/13] docs: RAM-only diagnostics (CWE-532) + explicit accuracy/bias gate thresholds Display gate requires MAE <= 2 pp, |bias| <= 1 pp, 95% LoA within +/-4 pp vs a reference oximeter (>=10 nights / 30 pairs); correlation reported separately, never as accuracy proof. Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index a48f01f74..390741568 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -97,15 +97,21 @@ the confidence level stated. metrics there), labeled as a device estimate, same labeling discipline as `vendor_staged`. - NO user-facing display on either generation until the validation - workstream lands: gen5 byte 82 stays raw/logged until its encoding - (bit-7 flag) is pinned and correlated against a reference device; gen4 - byte 5 stays diagnostic/raw in logs, never user-facing, until - ground-truth validated. + workstream lands: gen5 byte 82 stays raw until its encoding (bit-7 flag) + is pinned and validated against a reference device; gen4 byte 5 stays + diagnostic, never user-facing, until ground-truth validated. + - Diagnostics stay RAM-ONLY, never in `_log`/FileLog: FileLog writes to + Android external storage (`lib/sync/file_log.dart`), so a timestamped + candidate/sleep line there is readable by other apps (CWE-532). - Respect invariants: live streams RAM-only (inv. 14), never fabricate a metric (inv. 3), bump `kAlgoVersion` when any output changes (inv. 4). 4. **Validation workstream** (prerequisite for ANY user-facing SpO2 value, - both generations): captures against a reference oximeter, byte-82 encoding - pin-down, UUID discrepancy resolution. + both generations): paired measurements against a reference oximeter over + at least 10 nights / 30 paired samples; the display gate opens only when + accuracy criteria are met — MAE <= 2 pp AND |mean bias| <= 1 pp AND + Bland-Altman 95% limits of agreement within +/-4 pp. Correlation + (Pearson/Spearman) is reported separately and is NEVER treated as proof of + accuracy. Plus: byte-82 encoding pin-down, UUID discrepancy resolution. ## Out of scope From 69feaa9bbd99b2c239fb1ffad3226e64dc8a3bee Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 6 Nov 2026 09:48:50 +0000 Subject: [PATCH 06/13] docs: gate must validate the nightly aggregate, not only burst-level pairs Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index 390741568..3ba174268 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -111,7 +111,12 @@ the confidence level stated. accuracy criteria are met — MAE <= 2 pp AND |mean bias| <= 1 pp AND Bland-Altman 95% limits of agreement within +/-4 pp. Correlation (Pearson/Spearman) is reported separately and is NEVER treated as proof of - accuracy. Plus: byte-82 encoding pin-down, UUID discrepancy resolution. + accuracy. Burst-level pairs are NOT sufficient: the gate must also compare + the actual NIGHTLY OUTPUT (our aggregation over the band-declared sleep + window, burst weighting included) against the reference aggregated over the + same night, because burst-level agreement can pass while the nightly + calculation uses the wrong window or weights. Plus: byte-82 encoding + pin-down, UUID discrepancy resolution. ## Out of scope From 6150e492d2892cfef5dbfa3ed487a9daa044abb3 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 6 Nov 2026 12:09:00 +0000 Subject: [PATCH 07/13] docs: gen4 live display gate uses time-aligned live reference, not the nightly gate Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index 3ba174268..50414fcd1 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -115,8 +115,12 @@ the confidence level stated. the actual NIGHTLY OUTPUT (our aggregation over the band-declared sleep window, burst weighting included) against the reference aggregated over the same night, because burst-level agreement can pass while the nightly - calculation uses the wrong window or weights. Plus: byte-82 encoding - pin-down, UUID discrepancy resolution. + calculation uses the wrong window or weights. Gen4 has NO nightly SpO2 + output — its byte 5 is a live-stream byte only — so any Gen4 display gate + must instead use time-aligned LIVE reference measurements (same accuracy + thresholds: MAE <= 2 pp, |bias| <= 1 pp, 95% LoA within +/-4 pp), never the + nightly gate. Plus: byte-82 encoding pin-down, UUID discrepancy + resolution. ## Out of scope From 752671ddeb93cd54e23ffabcdc9072d106ec5669 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sat, 7 Nov 2026 21:58:19 +0000 Subject: [PATCH 08/13] =?UTF-8?q?docs:=20address=20upstream=20review=20?= =?UTF-8?q?=E2=80=94=20saturation-range=20coverage=20gate,=20correct=20PPG?= =?UTF-8?q?=20rationale,=20RAM-only=20nuance,=20encoding=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index 50414fcd1..7431f4355 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -42,8 +42,11 @@ the confidence level stated. EVENTS (`whoop-client/src/uuids.rs`). One mapping is off by one; must be resolved against our own captures before any wiring. - Constraint from edge AGENTS.md invariant 14: live high-rate streams are - RAM-only, never persisted. A live SpO2 could therefore only ever be an - ephemeral display value, never a day metric. + RAM-only, never persisted. That rules out persisting the stream, NOT a + day metric: an in-memory aggregate over a validated sleep window can still + produce a nightly value without persisting anything. Whether Gen4 ever gets + a nightly metric is decided by validation + coverage, not by invariant 14 + alone. ### WHOOP 5.0 / MG — community-reported app agreement, sleep-only @@ -57,11 +60,15 @@ the confidence level stated. - Validation against the vendor app (noop#73): nightly means 93.4–96.7 vs WHOOP's own 93.1–96.5 over the same weeks (period means 94.4 vs 94.8). No night had both readings, so this stays an estimate, not a validation. -- Offline derivation of SpO2 from raw PPG is physically not possible on 5.0 - (26 time-multiplexed optical channels; red/IR never sampled simultaneously). - The byte-82 device value is the only usable source. -- Encoding not fully pinned: values above 128 decompose as `128 + flag`, bit 7 - looks like a status flag. This is why `spo2Candidate` is deprecated. +- No usable offline derivation from raw PPG on 5.0: time multiplexing alone + would NOT make SpO2 impossible (pulse oximeters alternate red/IR), but the + decoded Gen5 data has no identified red/IR channel pair and the optical + channel bursts are widely separated in time — so no derivation path is + available from what we can decode. The byte-82 device value is the only + usable source. +- Encoding not fully pinned: values above 128 appear to decompose as + `128 + value` with bit 7 acting as a flag/status bit. This is why + `spo2Candidate` is deprecated. ## Self-critical assessment @@ -115,7 +122,13 @@ the confidence level stated. the actual NIGHTLY OUTPUT (our aggregation over the band-declared sleep window, burst weighting included) against the reference aggregated over the same night, because burst-level agreement can pass while the nightly - calculation uses the wrong window or weights. Gen4 has NO nightly SpO2 + calculation uses the wrong window or weights. COVERAGE REQUIREMENT (else a + constant estimate passes by construction): reference samples must span the + saturation range (e.g. stratified bins across 85–100%) and participants must + be representative (FDA oximeter guidance explicitly calls out skin + pigmentation); accuracy is assessed per range/group, and a degenerate + constant estimate (all reference samples ~97%) must NOT open the gate. + Gen4 has NO nightly SpO2 output — its byte 5 is a live-stream byte only — so any Gen4 display gate must instead use time-aligned LIVE reference measurements (same accuracy thresholds: MAE <= 2 pp, |bias| <= 1 pp, 95% LoA within +/-4 pp), never the From 9a7fb418c4f974beec20141b8f99013c17dae4e9 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sat, 7 Nov 2026 21:58:32 +0000 Subject: [PATCH 09/13] docs: fix line wrap in gen4 gate sentence Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index 7431f4355..87959afd5 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -128,9 +128,9 @@ the confidence level stated. be representative (FDA oximeter guidance explicitly calls out skin pigmentation); accuracy is assessed per range/group, and a degenerate constant estimate (all reference samples ~97%) must NOT open the gate. - Gen4 has NO nightly SpO2 - output — its byte 5 is a live-stream byte only — so any Gen4 display gate - must instead use time-aligned LIVE reference measurements (same accuracy + Gen4 has NO nightly SpO2 output — its byte 5 is a live-stream byte only — + so any Gen4 display gate must instead use time-aligned LIVE reference + measurements (same accuracy thresholds: MAE <= 2 pp, |bias| <= 1 pp, 95% LoA within +/-4 pp), never the nightly gate. Plus: byte-82 encoding pin-down, UUID discrepancy resolution. From 41d79afce6b854cdec75652a4736c3c7e0d4c4fb Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 8 Nov 2026 00:06:08 +0000 Subject: [PATCH 10/13] docs: require per-burst time-aligned reference coverage; validate any future Gen4 nightly output like Gen5 Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index 87959afd5..4c983c694 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -128,12 +128,18 @@ the confidence level stated. be representative (FDA oximeter guidance explicitly calls out skin pigmentation); accuracy is assessed per range/group, and a degenerate constant estimate (all reference samples ~97%) must NOT open the gate. - Gen4 has NO nightly SpO2 output — its byte 5 is a live-stream byte only — - so any Gen4 display gate must instead use time-aligned LIVE reference - measurements (same accuracy + BURST COVERAGE: the reference must provide time-aligned readings for EVERY + burst used in the nightly aggregate, and both aggregates must use the SAME + weights — sparse reference samples can match the nightly device output while + missing errors during most of the night. Gen4 has NO nightly SpO2 output — + its byte 5 is a live-stream byte only — so any Gen4 display gate must + instead use time-aligned LIVE reference measurements (same accuracy thresholds: MAE <= 2 pp, |bias| <= 1 pp, 95% LoA within +/-4 pp), never the - nightly gate. Plus: byte-82 encoding pin-down, UUID discrepancy - resolution. + nightly gate. If Gen4 LATER gains a nightly metric (in-memory aggregate over + a validated sleep window), that nightly output must be validated exactly + like the Gen5 one — against the reference aggregated over the same window — + because accurate live samples alone cannot detect wrong window selection or + weighting. Plus: byte-82 encoding pin-down, UUID discrepancy resolution. ## Out of scope From 60754bf61137490ab17f3b92e3e883bf123b83a8 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 8 Nov 2026 00:21:29 +0000 Subject: [PATCH 11/13] =?UTF-8?q?docs:=20address=20upstream=20review=20rou?= =?UTF-8?q?nd=202=20=E2=80=94=20independent=20window/weights/coverage=20ch?= =?UTF-8?q?ecks,=20FileLog=20rationale,=20gen4=20evidence=20wording,=20ana?= =?UTF-8?q?lytics=20in=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index 4c983c694..3d3756703 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -75,8 +75,10 @@ the confidence level stated. - "Matches the WHOOP app" is agreement, not validation; only a reference oximeter (or PSG-grade device) validates. We state this explicitly, following the precedent in `vendor-staged-sleep.md`. -- The gen4 byte-5 claim is single-source and contradicted by the sleep-only - measurement reports; we treat it as unverified. +- The gen4 byte-5 claim is single-source; the sleep-only measurement reports + do NOT contradict it (a realtime packet can carry SpO2 only during sleep, + which is consistent with the claim). It stays unverified because it has one + source and no ground-truth validation. - No project anywhere publishes a live (daytime) SpO2 for any WHOOP generation. The realistic product surface is a nightly sleep SpO2 for 5/MG, plus at most a raw/diagnostic byte for gen4. @@ -94,8 +96,9 @@ the confidence level stated. CRC flag only** — no percentage publishing. Resolve the characteristic UUID discrepancy from real captures first. - Tests from real frames; extend parity fixtures where applicable. -2. **whoop-rs** — DEFERRED: current scope is `protocol` + `edge` only. When - picked up: mirror the decoders in `whoop-protocol` (live 96-byte packet, +2. **whoop-rs** — DEFERRED. Current scope is `protocol` + `edge` + `analytics` + (analytics owns the nightly metric, see step 3); only whoop-rs is out of + scope for now. When picked up: mirror the decoders in `whoop-protocol` (live 96-byte packet, v18 byte 82 interpretation), expose raw values via `whoop-ffi`; keep the existing `spo2_pct` v18 and `spo2_from_paired` paths unchanged. 3. **edge** — pass through the band-reported `deviceSpo2` samples for @@ -107,9 +110,12 @@ the confidence level stated. workstream lands: gen5 byte 82 stays raw until its encoding (bit-7 flag) is pinned and validated against a reference device; gen4 byte 5 stays diagnostic, never user-facing, until ground-truth validated. - - Diagnostics stay RAM-ONLY, never in `_log`/FileLog: FileLog writes to - Android external storage (`lib/sync/file_log.dart`), so a timestamped - candidate/sleep line there is readable by other apps (CWE-532). + - Diagnostics stay RAM-ONLY, never in `_log`/FileLog. FileLog persists to + Android external storage (`lib/sync/file_log.dart`): on Android ≤10 other + apps with storage permission can read it (CWE-532), and on 11+ the + app-specific dir is no longer cross-app readable — but the data would + still be PERSISTED, which is the primary reason health diagnostics stay + in RAM. - Respect invariants: live streams RAM-only (inv. 14), never fabricate a metric (inv. 3), bump `kAlgoVersion` when any output changes (inv. 4). 4. **Validation workstream** (prerequisite for ANY user-facing SpO2 value, @@ -129,9 +135,19 @@ the confidence level stated. pigmentation); accuracy is assessed per range/group, and a degenerate constant estimate (all reference samples ~97%) must NOT open the gate. BURST COVERAGE: the reference must provide time-aligned readings for EVERY - burst used in the nightly aggregate, and both aggregates must use the SAME - weights — sparse reference samples can match the nightly device output while - missing errors during most of the night. Gen4 has NO nightly SpO2 output — + burst used in the nightly aggregate — sparse reference samples can match the + nightly device output while missing errors during most of the night — and + MINIMUM SLEEP-WINDOW COVERAGE must be defined with an independent check for + omitted bursts before approving a nightly estimate (data loss leaving only a + few bursts must not pass the gate). WINDOW MEMBERSHIP: included/excluded + bursts are checked independently against the band-declared sleep boundaries, + and the reference aggregate is constructed from the correct window — two + aggregates sharing the same WRONG window can match each other. WEIGHTS: the + expected burst weights are defined independently and the implementation is + checked against them; the reference aggregate must NOT copy the device's + weights (e.g. bursts of 90/90/99 with weights 1/1/8 yield 97.2 instead of + the equal-weight mean 93 — a weighting defect the copied weights would + conceal). Gen4 has NO nightly SpO2 output — its byte 5 is a live-stream byte only — so any Gen4 display gate must instead use time-aligned LIVE reference measurements (same accuracy thresholds: MAE <= 2 pp, |bias| <= 1 pp, 95% LoA within +/-4 pp), never the From 4d8a5e1dbbb5bfe6627576b1186722031b3c2f61 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 9 Nov 2026 15:41:08 +0000 Subject: [PATCH 12/13] =?UTF-8?q?docs:=20relate=20to=20merged=20#472=20?= =?UTF-8?q?=E2=80=94=20spo2=20MetricSpec/provenance=20owned=20by=20import?= =?UTF-8?q?=20surface;=20spo2CandidateRaw=20feeds=20Spo2Data=20only=20afte?= =?UTF-8?q?r=20the=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index 3d3756703..c0bf75e9e 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -85,6 +85,13 @@ the confidence level stated. - All sources agree the raw ADC channels cannot yield SpO2; any PR deriving % from red/IR on 4.0 contradicts our own verified findings and should be rejected. +- Relation to merged PR #472: its import surface already owns the `spo2` + MetricSpec (suppressed row, vendor-import provenance, not charted). Live + band-derived SpO2 must NOT create a second `spo2` row or chart key — + provenance (vendor import vs band-derived) stays separate. Our raw capture + (`Sample.spo2CandidateRaw`) is deliberately unread until the gate passes; + the single publication path afterwards is `Spo2Data` in + `lib/models/payloads.dart`. ## Staged plan (proposed) @@ -106,6 +113,9 @@ the confidence level stated. `OpenStrap/analytics` (a metric, not signal wiring — AGENTS.md assigns metrics there), labeled as a device estimate, same labeling discipline as `vendor_staged`. + - After the gate passes: feed `Sample.spo2CandidateRaw` into `Spo2Data` + (`lib/models/payloads.dart`) as the single publication path — no separate + display path, no second `spo2` metric row (see #472 note above). - NO user-facing display on either generation until the validation workstream lands: gen5 byte 82 stays raw until its encoding (bit-7 flag) is pinned and validated against a reference device; gen4 byte 5 stays From 2c755856d18b9de1641fb18d6a25264bf82258c1 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 9 Nov 2026 20:06:54 +0000 Subject: [PATCH 13/13] =?UTF-8?q?docs:=20explicit=20post-gate=20publicatio?= =?UTF-8?q?n=20path=20=E2=80=94=20validation-only=20read,=20Spo2Data=20pct?= =?UTF-8?q?=20field/accessor/getter,=20UI=20via=20existing=20spo2=20series?= =?UTF-8?q?=20with=20provenance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index c0bf75e9e..fb33deb52 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -167,6 +167,29 @@ the confidence level stated. because accurate live samples alone cannot detect wrong window selection or weighting. Plus: byte-82 encoding pin-down, UUID discrepancy resolution. +## Publication path after the gate (explicit) + +The gate and the publication path are separate concerns; both are specified +here so the gate is evaluable without shipping a display: + +- **Validation-only read is allowed BEFORE the gate passes.** Evaluating the + gate requires the actual nightly output, so the same nightly aggregation + intended for publication is computed and compared against the reference — + internally only. User-facing output stays withheld until the gate passes; + `Sample.spo2CandidateRaw` remains unread by the UI in the meantime. +- **Payload changes (edge `payloads.dart`):** `Spo2Data` currently has no + percentage accessor and `TodayData.spo2` returns null while `odi_per_hour` + is null. Publication therefore adds: a `spo2Pct` field on `Spo2Data`, a + `double? get spo2Pct` accessor, and a getter condition on `TodayData.spo2` + that accepts the payload once validation has passed (flagged by the + validation record, not by the mere presence of a byte). +- **UI integration:** publishing through `Spo2Data` alone does NOT display + anything — the chart reads `metric_series` and the Explore row stays hidden + without stored `spo2` history. The integration step therefore writes the + validated nightly value into the existing `spo2` series with + band-derived provenance, reusing the #472 import row's provenance axis — + NO second `spo2` row, no second chart key. + ## Out of scope - Any medical claim or diagnosis (terms.html: wellness only, not a medical