Skip to content

feat(spo2): surface WHOOP 5.0 spo2_candidate_82 behind experimental toggle (#103) - #1185

Merged
ryanbr merged 1 commit into
mainfrom
feat/spo2-candidate-1180
Aug 9, 2026
Merged

ryanbr merged 1 commit into
mainfrom
feat/spo2-candidate-1180

Conversation

@ryanbr

@ryanbr ryanbr commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Rebase of #1180 by @evoveotech onto current main, with the two blockers fixed so it lands clean.

What it does

WHOOP 5.0/MG doesn't write spo2Sample (ratio-of-ratios is 4.0-only); it emits a strap-computed candidate at byte @82 of the v18 record. This surfaces that nightly spo2_candidate_82 mean in the Blood Oxygen tile as a "strap estimate (unverified)" fallback when no calibrated spo2Pct exists, behind a default-off experimental toggle (Settings → Diagnostics), with a "toggle ON · no @82 data" diagnostic when the stream is empty.

Rule-compliant (the reason this is mergeable where #1173's SpO₂ half wasn't)

  • Default OFF; display-only — writes to metricSeries["spo2_candidate"] under the -noop computed ID, never spo2Pct, and never feeds recovery/illness scoring.
  • Labelled unverified; the split cross-device evidence (+0.99 over 8 nights, but 2 nights opposite — device/firmware variance unresolved) is disclosed. This is opt-in instrumentation per the CLAUDE.md derived-biosignal rule.
  • The candidate value comes from the pre-existing, parity-tested AnalyticsEngine.nightlySpo2CandidateMean; the new metricSeries persistence mirrors byte-identically on both platforms.

Fixes I made to land it

  1. Android compile error — HealthScreen.kt called NoopPrefs.spo2CandidateDisplay(LocalContext.current) inside a remember {} calculation lambda (LocalContext.current is a @Composable read → hard compile error; this is why the PR's build-and-test was red). Hoisted it into the composable body and keyed remember on the resolved value.
  2. i18n gate — baselined the experimental toggle's title + blurb (both platforms) in i18n_audit_baseline.json. Deferred localization is standard for experimental diagnostic copy that may not survive the experiment.

Verification

  • ./gradlew compileFullDebugKotlin ✅ (fixes the red build)
  • i18n_audit.py --ci main → exit 0 ✅
  • app-build running for the 6 app-target Swift files (IntelligenceEngine/TodayView/VitalSignsSummary/HealthView/SettingsView/PuffinExperiment) — no default CI covers those.

Credit @evoveotech (#1180). Closes #103.

…oggle (#103)

WHOOP 5.0/MG does not write spo2Sample (the ratio-of-ratios SpO₂ path is 4.0-only);
it emits a strap-computed candidate at byte @82 of the v18 historical record. This
surfaces that nightly `spo2_candidate_82` mean in the Blood Oxygen tile as a
"strap estimate (unverified)" fallback when no calibrated spo2Pct exists, behind a
DEFAULT-OFF experimental toggle (Settings → Diagnostics).

Display-only and rule-compliant (CLAUDE.md derived-biosignal): the value is written
to metricSeries as "spo2_candidate" under the "-noop" computed ID, NEVER to spo2Pct,
and never feeds recovery or illness scoring. The @82 candidate has SPLIT cross-device
evidence (+0.99 over 8 nights, but 2 nights opposite on the original device — device/
firmware variance unresolved), so it ships off-by-default and labelled unverified.
Uses the pre-existing, parity-tested AnalyticsEngine.nightlySpo2CandidateMean helper.

Rebased onto current main + fixed to land cleanly:
- HealthScreen.kt: hoisted `NoopPrefs.spo2CandidateDisplay(LocalContext.current)` out of
  the `remember {}` calculation lambda — a @composable read there is a hard compile error
  (the reason the PR's Android build was red).
- Baselined the experimental toggle's title + blurb on both platforms in
  i18n_audit_baseline.json (deferred localization for experimental copy).

Co-authored-by: evoveotech <evoveotech@users.noreply.github.com>
@ryanbr
ryanbr merged commit ac4f246 into main Aug 9, 2026
5 checks passed
@ryanbr
ryanbr deleted the feat/spo2-candidate-1180 branch August 9, 2026 07:30
ryanbr added a commit that referenced this pull request Aug 9, 2026
…#103 follow-up) (#1186)

Follow-up to #1185. Two Android nits found on re-review:

- The Blood Oxygen tile's candidate captions in HealthVitalsLogic were RAW string
  literals ("Estimate (unverified)" / "toggle ON · no @82 data") — hardcoded English
  that the i18n audit doesn't catch (it misses literals returned from logic-file
  functions, not just @composable Text). Extracted both to strings.xml via uiString
  and translated across the shipped locales (de/es/fr/pt-PT/zh), matching iOS which
  already used String(localized:).
- Wording parity: Android said "Estimate (unverified)"; iOS says "strap estimate
  (unverified)". Aligned Android to the iOS wording so the tile reads the same.

Also collect spo2CandidateByDay reactively in HealthScreen (collectAsStateWithLifecycle
instead of .value), matching VitalSignsScreen, so the Latest-readings card recomposes
on its own when the candidate map updates rather than only incidentally via `days`.

Android-only, display-only, no behavior/scoring change. compileFullDebugKotlin +
i18n_audit --ci both green.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…oggle (ryanbr#103) (ryanbr#1185)

WHOOP 5.0/MG does not write spo2Sample (the ratio-of-ratios SpO₂ path is 4.0-only);
it emits a strap-computed candidate at byte @82 of the v18 historical record. This
surfaces that nightly `spo2_candidate_82` mean in the Blood Oxygen tile as a
"strap estimate (unverified)" fallback when no calibrated spo2Pct exists, behind a
DEFAULT-OFF experimental toggle (Settings → Diagnostics).

Display-only and rule-compliant (CLAUDE.md derived-biosignal): the value is written
to metricSeries as "spo2_candidate" under the "-noop" computed ID, NEVER to spo2Pct,
and never feeds recovery or illness scoring. The @82 candidate has SPLIT cross-device
evidence (+0.99 over 8 nights, but 2 nights opposite on the original device — device/
firmware variance unresolved), so it ships off-by-default and labelled unverified.
Uses the pre-existing, parity-tested AnalyticsEngine.nightlySpo2CandidateMean helper.

Rebased onto current main + fixed to land cleanly:
- HealthScreen.kt: hoisted `NoopPrefs.spo2CandidateDisplay(LocalContext.current)` out of
  the `remember {}` calculation lambda — a @composable read there is a hard compile error
  (the reason the PR's Android build was red).
- Baselined the experimental toggle's title + blurb on both platforms in
  i18n_audit_baseline.json (deferred localization for experimental copy).

Co-authored-by: evoveotech <evoveotech@users.noreply.github.com>
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ryanbr#103 follow-up) (ryanbr#1186)

Follow-up to ryanbr#1185. Two Android nits found on re-review:

- The Blood Oxygen tile's candidate captions in HealthVitalsLogic were RAW string
  literals ("Estimate (unverified)" / "toggle ON · no @82 data") — hardcoded English
  that the i18n audit doesn't catch (it misses literals returned from logic-file
  functions, not just @composable Text). Extracted both to strings.xml via uiString
  and translated across the shipped locales (de/es/fr/pt-PT/zh), matching iOS which
  already used String(localized:).
- Wording parity: Android said "Estimate (unverified)"; iOS says "strap estimate
  (unverified)". Aligned Android to the iOS wording so the tile reads the same.

Also collect spo2CandidateByDay reactively in HealthScreen (collectAsStateWithLifecycle
instead of .value), matching VitalSignsScreen, so the Latest-readings card recomposes
on its own when the candidate map updates rather than only incidentally via `days`.

Android-only, display-only, no behavior/scoring change. compileFullDebugKotlin +
i18n_audit --ci both green.
DX23876 added a commit to DX23876/noop that referenced this pull request Sep 26, 2026
The previous commit said SpO2 comes from neither strap and the v18 byte-82
value is never shown. That is wrong: since ryanbr#1185 (2026-08-09) the
default-off Settings toggle "Experimental · Blood Oxygen" surfaces its
nightly mean in the Blood Oxygen tile as "strap estimate (unverified)".
A 4.0 has no such byte. It still never feeds scoring, and the NOOP
Limitations grid's "No" predates the toggle by a day.

Analysis migration required: no (documentation only).
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.

WHOOP 5.0 raw HCI capture — where to submit? (docs reference #174, which doesn't exist)

2 participants