Repository navigation
Persist the Oura ring's SleepNet hypnogram as a stage-timeline night, labelled "Oura" - #773
Merged
Merged
Conversation
… sleep session, shown as "Oura" Decode the ring's whole-night SleepNet phase-code burst, reconstruct its 30 s/code time axis, anchor+refine it with the 0x49 sleep-summary window (BOTH ends), and upsert the night as a CachedSleepSession under the ring's own deviceId — so SleepMerge's imported-over-computed rule surfaces Oura's own staging instead of a computed guess. The sleep surfaces then label that night honestly as "Oura" with a caveat that its stages are the ring's raw on-device classification. Persist (both platforms, byte-identical Swift/Kotlin twins): - OuraProtocol: HypnogramAssembler time-axis reconstruction with the 0x49 window applied at BOTH ends — the end anchored to the sleep-end, and the START clamped to the sleep-onset so the burst's pre-window epochs are clipped (the clip lives in the PURE codesWithTimes(…, sleepStartUnixSeconds:) with a never-empty guard, so a mis-paired 0x49 can never erase the night); 0x49 pairing in OuraHistoryDrain; OuraEvents/Decoders/Framing/OuraDriver additions. - WhoopStore: OuraSleepSessionMapping (anchored per-code hypnogram → stage breakdown), OuraStreamMapping sleepPhase pass-through. - App: OuraLiveSource assembles the burst, anchors + 0x49-refines both ends, calls persistSleepSession; SourceCoordinator wires the persist closure. Provenance UI (both platforms): a persisted Oura night is the ring's OWN SleepNet output, not a NOOP computation, so the surfaces say so instead of the generic "On-device": - DeviceBrandCatalog.isOura(deviceId) — ONE canonical resolver (brand table, no "oura" literal); Swift Repository.activeDeviceIsOura + Kotlin SleepScreen call it. - SleepView / SleepScreen: hero + main-sleep badges read "Oura"; the stage caption reads "raw on-device stages"; a caveat note states the split is the ring's RAW BLE classification (more Awake / less Deep+REM) — NOT the Oura app's cloud-adjusted stages. daySourceBadge gains the Oura brand so the ring-id session (and By-Day) label correctly. Design tokens only; WHOOP/Apple imports still win the provenance order above Oura. Tier discipline: the persisted session is a ring-PROVIDED night, not a NOOP-computed score. Supersedes DRAFT ryanbr#446; docs/OURA_PROTOCOL.md updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… 0x49 start-clamp Unit tests for the feature above, on both platforms (byte-identical Swift/Kotlin fixtures): - HypnogramAssembler: the 0x49 start-clamp in the pure codesWithTimes — drops codes before the onset, inclusive boundary, an onset before the burst keeps all, and the never-empty fallback for a mis-paired 0x49; plus the existing backward-layout cases. - OuraSleepSessionMapping: anchored per-code hypnogram → CachedSleepSession stage breakdown. - OuraSleepWindowPairing: closest-0x49-window selection (overnight vs nap, tolerance, empties). - DeviceBrandCatalog.isOura: registry-id → brand resolution (Oura true; WHOOP/computed/import false). - OuraStreamMapping / Decoder golden / Framing / Driver / HistoryDrain: the twin decode + mapping tests. swift test OuraProtocol + WhoopStore green; the Kotlin twins mirror them (their run is gated only by the unrelated ryanbr#716 test-fake compile gap / PR ryanbr#725). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… (Android) The SleepNet provenance caveat added with the Oura raw-stages note was new UI copy the i18n gate (`Tools/i18n_audit.py --ci`) flagged: on Apple the two literals were absent from their String Catalog; on Android they were new hardcoded literals not in the baseline. - Apple: add both strings — the `Text` caveat and its `accessibilityLabel` — to Strand/Resources/Localizable.xcstrings with translations for all 8 focus locales (de, es, fr, it, pt-PT, ru, zh-Hans, zh-Hant), state `translated`, mirroring the sibling "Low confidence staging" note's terminology (deep/REM, efficiency, stages, "anneau/Ring/anillo/…"). - Android: `SleepScreen.kt` is fully hardcoded (0 stringResource, 6 entries already baselined) and has no low-confidence twin, so the two literals join the accepted backlog via `--update-baseline` — the diff is exactly the two SleepScreen.kt entries, nothing else. (Localizing the Android sleep screen's copy is a separate backlog task; this only closes the NEW-literal regression.) `python3 Tools/i18n_audit.py --ci origin/main` now exits 0 (Android: no new hardcoded literals + focus locales complete; Apple: no new un-extracted literals + all catalogs complete). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
ryanbr
added a commit
to pipiche38/noop
that referenced
this pull request
Jul 24, 2026
…ock conflict ryanbr#773 (SleepNet hypnogram) and this PR both added state-clears to the same resetState blocks. Kept BOTH: loggedProductInfo (this PR) + greenIbiAmp* / recentSleepWindows049 (ryanbr#773). Independent resets, union resolution. All other Oura files auto-merged. App-target Swift - not compiled here; needs a local xcodebuild before merge.
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Jul 27, 2026
…to the scorer (ryanbr#804 Fix A) An Oura-only night decoded and persisted a full SleepNet hypnogram (ryanbr#773) and showed on the Sleep timeline, yet the nightly summary reported `totalSleepMin=nil eff=nil avgHrv=nil source=computed`. Root cause: the scorer (`AnalyticsEngine.analyzeDay`) stages sleep from the motion/gravity vector, and a ring streams NO gravity — so `detectSleep` returned nothing, the sleep window was empty, and avgHrv (computed only over that window) stayed nil despite tens of thousands of R-R intervals present. The ring's persisted hypnogram never reached `analyzeDay`; it only fed the display-path `SleepMerge`. Fix A (general, not Oura-hardcoded): `analyzeDay` gains an optional `providedSleep` of already-staged sessions. When the day owner sends no usable gravity vector (`grav.count < 2` — a ring; a WHOOP always streams gravity) and is not the canonical WHOOP import namespace, IntelligenceEngine reads the owner's persisted hypnogram (its own device namespace) and hands it in. Provided sessions bypass detectSleep + wake refinement; where they overlap a detected session the provided one wins (authoritative device staging), and a non-overlapping detected nap survives. Each provided session's nightly restingHR/avgHRV is re-derived from that day's hr/rr over its window via the SAME helpers detectSleep uses, so RHR/HRV light up even though the stored ring row carried neither. Empty `providedSleep` (every WHOOP / pure-function caller) is the byte-identical motion-only path. Both platforms, byte-identical: `sleepSession(fromProvided:)` / `decodeStages` reconstruct the pure session from the persisted `CachedSleepSession` (a minute-dict import row decodes to nothing and is skipped, so only real stage timelines inject). Tests (no strap): a provided hypnogram over a gravity-free night now yields totalSleepMin / deep / rem / efficiency AND a derived avgHrv + restingHr; empty providedSleep is asserted byte-identical to omitting it (and still nil without gravity — the pre-fix ryanbr#804 state). Swift StrandAnalytics 1173/0; Kotlin AnalyticsEngineProvidedSleepTest green; macOS app builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Jul 27, 2026
…to the scorer (ryanbr#804 Fix A) An Oura-only night decoded and persisted a full SleepNet hypnogram (ryanbr#773) and showed on the Sleep timeline, yet the nightly summary reported `totalSleepMin=nil eff=nil avgHrv=nil source=computed`. Root cause: the scorer (`AnalyticsEngine.analyzeDay`) stages sleep from the motion/gravity vector, and a ring streams NO gravity — so `detectSleep` returned nothing, the sleep window was empty, and avgHrv (computed only over that window) stayed nil despite tens of thousands of R-R intervals present. The ring's persisted hypnogram never reached `analyzeDay`; it only fed the display-path `SleepMerge`. Fix A (general, not Oura-hardcoded): `analyzeDay` gains an optional `providedSleep` of already-staged sessions. When the day owner sends no usable gravity vector (`grav.count < 2` — a ring; a WHOOP always streams gravity) and is not the canonical WHOOP import namespace, IntelligenceEngine reads the owner's persisted hypnogram (its own device namespace) and hands it in. Provided sessions bypass detectSleep + wake refinement; where they overlap a detected session the provided one wins (authoritative device staging), and a non-overlapping detected nap survives. Each provided session's nightly restingHR/avgHRV is re-derived from that day's hr/rr over its window via the SAME helpers detectSleep uses, so RHR/HRV light up even though the stored ring row carried neither. Empty `providedSleep` (every WHOOP / pure-function caller) is the byte-identical motion-only path. Both platforms, byte-identical: `sleepSession(fromProvided:)` / `decodeStages` reconstruct the pure session from the persisted `CachedSleepSession` (a minute-dict import row decodes to nothing and is skipped, so only real stage timelines inject). Tests (no strap): a provided hypnogram over a gravity-free night now yields totalSleepMin / deep / rem / efficiency AND a derived avgHrv + restingHr; empty providedSleep is asserted byte-identical to omitting it (and still nil without gravity — the pre-fix ryanbr#804 state). Swift StrandAnalytics 1173/0; Kotlin AnalyticsEngineProvidedSleepTest green; macOS app builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Jul 27, 2026
…to the scorer (ryanbr#804 Fix A) An Oura-only night decoded and persisted a full SleepNet hypnogram (ryanbr#773) and showed on the Sleep timeline, yet the nightly summary reported `totalSleepMin=nil eff=nil avgHrv=nil source=computed`. Root cause: the scorer (`AnalyticsEngine.analyzeDay`) stages sleep from the motion/gravity vector, and a ring streams NO gravity — so `detectSleep` returned nothing, the sleep window was empty, and avgHrv (computed only over that window) stayed nil despite tens of thousands of R-R intervals present. The ring's persisted hypnogram never reached `analyzeDay`; it only fed the display-path `SleepMerge`. Fix A (general, not Oura-hardcoded): `analyzeDay` gains an optional `providedSleep` of already-staged sessions. When the day owner sends no usable gravity vector (`grav.count < 2` — a ring; a WHOOP always streams gravity) and is not the canonical WHOOP import namespace, IntelligenceEngine reads the owner's persisted hypnogram (its own device namespace) and hands it in. Provided sessions bypass detectSleep + wake refinement; where they overlap a detected session the provided one wins (authoritative device staging), and a non-overlapping detected nap survives. Each provided session's nightly restingHR/avgHRV is re-derived from that day's hr/rr over its window via the SAME helpers detectSleep uses, so RHR/HRV light up even though the stored ring row carried neither. Empty `providedSleep` (every WHOOP / pure-function caller) is the byte-identical motion-only path. Both platforms, byte-identical: `sleepSession(fromProvided:)` / `decodeStages` reconstruct the pure session from the persisted `CachedSleepSession` (a minute-dict import row decodes to nothing and is skipped, so only real stage timelines inject). Tests (no strap): a provided hypnogram over a gravity-free night now yields totalSleepMin / deep / rem / efficiency AND a derived avgHrv + restingHr; empty providedSleep is asserted byte-identical to omitting it (and still nil without gravity — the pre-fix ryanbr#804 state). Swift StrandAnalytics 1173/0; Kotlin AnalyticsEngineProvidedSleepTest green; macOS app builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Jul 30, 2026
…to the scorer (ryanbr#804 Fix A) An Oura-only night decoded and persisted a full SleepNet hypnogram (ryanbr#773) and showed on the Sleep timeline, yet the nightly summary reported `totalSleepMin=nil eff=nil avgHrv=nil source=computed`. Root cause: the scorer (`AnalyticsEngine.analyzeDay`) stages sleep from the motion/gravity vector, and a ring streams NO gravity — so `detectSleep` returned nothing, the sleep window was empty, and avgHrv (computed only over that window) stayed nil despite tens of thousands of R-R intervals present. The ring's persisted hypnogram never reached `analyzeDay`; it only fed the display-path `SleepMerge`. Fix A (general, not Oura-hardcoded): `analyzeDay` gains an optional `providedSleep` of already-staged sessions. When the day owner sends no usable gravity vector (`grav.count < 2` — a ring; a WHOOP always streams gravity) and is not the canonical WHOOP import namespace, IntelligenceEngine reads the owner's persisted hypnogram (its own device namespace) and hands it in. Provided sessions bypass detectSleep + wake refinement; where they overlap a detected session the provided one wins (authoritative device staging), and a non-overlapping detected nap survives. Each provided session's nightly restingHR/avgHRV is re-derived from that day's hr/rr over its window via the SAME helpers detectSleep uses, so RHR/HRV light up even though the stored ring row carried neither. Empty `providedSleep` (every WHOOP / pure-function caller) is the byte-identical motion-only path. Both platforms, byte-identical: `sleepSession(fromProvided:)` / `decodeStages` reconstruct the pure session from the persisted `CachedSleepSession` (a minute-dict import row decodes to nothing and is skipped, so only real stage timelines inject). Tests (no strap): a provided hypnogram over a gravity-free night now yields totalSleepMin / deep / rem / efficiency AND a derived avgHrv + restingHr; empty providedSleep is asserted byte-identical to omitting it (and still nil without gravity — the pre-fix ryanbr#804 state). Swift StrandAnalytics 1173/0; Kotlin AnalyticsEngineProvidedSleepTest green; macOS app builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Jul 31, 2026
…to the scorer (ryanbr#804 Fix A) An Oura-only night decoded and persisted a full SleepNet hypnogram (ryanbr#773) and showed on the Sleep timeline, yet the nightly summary reported `totalSleepMin=nil eff=nil avgHrv=nil source=computed`. Root cause: the scorer (`AnalyticsEngine.analyzeDay`) stages sleep from the motion/gravity vector, and a ring streams NO gravity — so `detectSleep` returned nothing, the sleep window was empty, and avgHrv (computed only over that window) stayed nil despite tens of thousands of R-R intervals present. The ring's persisted hypnogram never reached `analyzeDay`; it only fed the display-path `SleepMerge`. Fix A (general, not Oura-hardcoded): `analyzeDay` gains an optional `providedSleep` of already-staged sessions. When the day owner sends no usable gravity vector (`grav.count < 2` — a ring; a WHOOP always streams gravity) and is not the canonical WHOOP import namespace, IntelligenceEngine reads the owner's persisted hypnogram (its own device namespace) and hands it in. Provided sessions bypass detectSleep + wake refinement; where they overlap a detected session the provided one wins (authoritative device staging), and a non-overlapping detected nap survives. Each provided session's nightly restingHR/avgHRV is re-derived from that day's hr/rr over its window via the SAME helpers detectSleep uses, so RHR/HRV light up even though the stored ring row carried neither. Empty `providedSleep` (every WHOOP / pure-function caller) is the byte-identical motion-only path. Both platforms, byte-identical: `sleepSession(fromProvided:)` / `decodeStages` reconstruct the pure session from the persisted `CachedSleepSession` (a minute-dict import row decodes to nothing and is skipped, so only real stage timelines inject). Tests (no strap): a provided hypnogram over a gravity-free night now yields totalSleepMin / deep / rem / efficiency AND a derived avgHrv + restingHr; empty providedSleep is asserted byte-identical to omitting it (and still nil without gravity — the pre-fix ryanbr#804 state). Swift StrandAnalytics 1173/0; Kotlin AnalyticsEngineProvidedSleepTest green; macOS app builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 2, 2026
…to the scorer (ryanbr#804 Fix A) An Oura-only night decoded and persisted a full SleepNet hypnogram (ryanbr#773) and showed on the Sleep timeline, yet the nightly summary reported `totalSleepMin=nil eff=nil avgHrv=nil source=computed`. Root cause: the scorer (`AnalyticsEngine.analyzeDay`) stages sleep from the motion/gravity vector, and a ring streams NO gravity — so `detectSleep` returned nothing, the sleep window was empty, and avgHrv (computed only over that window) stayed nil despite tens of thousands of R-R intervals present. The ring's persisted hypnogram never reached `analyzeDay`; it only fed the display-path `SleepMerge`. Fix A (general, not Oura-hardcoded): `analyzeDay` gains an optional `providedSleep` of already-staged sessions. When the day owner sends no usable gravity vector (`grav.count < 2` — a ring; a WHOOP always streams gravity) and is not the canonical WHOOP import namespace, IntelligenceEngine reads the owner's persisted hypnogram (its own device namespace) and hands it in. Provided sessions bypass detectSleep + wake refinement; where they overlap a detected session the provided one wins (authoritative device staging), and a non-overlapping detected nap survives. Each provided session's nightly restingHR/avgHRV is re-derived from that day's hr/rr over its window via the SAME helpers detectSleep uses, so RHR/HRV light up even though the stored ring row carried neither. Empty `providedSleep` (every WHOOP / pure-function caller) is the byte-identical motion-only path. Both platforms, byte-identical: `sleepSession(fromProvided:)` / `decodeStages` reconstruct the pure session from the persisted `CachedSleepSession` (a minute-dict import row decodes to nothing and is skipped, so only real stage timelines inject). Tests (no strap): a provided hypnogram over a gravity-free night now yields totalSleepMin / deep / rem / efficiency AND a derived avgHrv + restingHr; empty providedSleep is asserted byte-identical to omitting it (and still nil without gravity — the pre-fix ryanbr#804 state). Swift StrandAnalytics 1173/0; Kotlin AnalyticsEngineProvidedSleepTest green; macOS app builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 5, 2026
…to the scorer (ryanbr#804 Fix A) An Oura-only night decoded and persisted a full SleepNet hypnogram (ryanbr#773) and showed on the Sleep timeline, yet the nightly summary reported `totalSleepMin=nil eff=nil avgHrv=nil source=computed`. Root cause: the scorer (`AnalyticsEngine.analyzeDay`) stages sleep from the motion/gravity vector, and a ring streams NO gravity — so `detectSleep` returned nothing, the sleep window was empty, and avgHrv (computed only over that window) stayed nil despite tens of thousands of R-R intervals present. The ring's persisted hypnogram never reached `analyzeDay`; it only fed the display-path `SleepMerge`. Fix A (general, not Oura-hardcoded): `analyzeDay` gains an optional `providedSleep` of already-staged sessions. When the day owner sends no usable gravity vector (`grav.count < 2` — a ring; a WHOOP always streams gravity) and is not the canonical WHOOP import namespace, IntelligenceEngine reads the owner's persisted hypnogram (its own device namespace) and hands it in. Provided sessions bypass detectSleep + wake refinement; where they overlap a detected session the provided one wins (authoritative device staging), and a non-overlapping detected nap survives. Each provided session's nightly restingHR/avgHRV is re-derived from that day's hr/rr over its window via the SAME helpers detectSleep uses, so RHR/HRV light up even though the stored ring row carried neither. Empty `providedSleep` (every WHOOP / pure-function caller) is the byte-identical motion-only path. Both platforms, byte-identical: `sleepSession(fromProvided:)` / `decodeStages` reconstruct the pure session from the persisted `CachedSleepSession` (a minute-dict import row decodes to nothing and is skipped, so only real stage timelines inject). Tests (no strap): a provided hypnogram over a gravity-free night now yields totalSleepMin / deep / rem / efficiency AND a derived avgHrv + restingHr; empty providedSleep is asserted byte-identical to omitting it (and still nil without gravity — the pre-fix ryanbr#804 state). Swift StrandAnalytics 1173/0; Kotlin AnalyticsEngineProvidedSleepTest green; macOS app builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 5, 2026
…to the scorer (ryanbr#804 Fix A) An Oura-only night decoded and persisted a full SleepNet hypnogram (ryanbr#773) and showed on the Sleep timeline, yet the nightly summary reported `totalSleepMin=nil eff=nil avgHrv=nil source=computed`. Root cause: the scorer (`AnalyticsEngine.analyzeDay`) stages sleep from the motion/gravity vector, and a ring streams NO gravity — so `detectSleep` returned nothing, the sleep window was empty, and avgHrv (computed only over that window) stayed nil despite tens of thousands of R-R intervals present. The ring's persisted hypnogram never reached `analyzeDay`; it only fed the display-path `SleepMerge`. Fix A (general, not Oura-hardcoded): `analyzeDay` gains an optional `providedSleep` of already-staged sessions. When the day owner sends no usable gravity vector (`grav.count < 2` — a ring; a WHOOP always streams gravity) and is not the canonical WHOOP import namespace, IntelligenceEngine reads the owner's persisted hypnogram (its own device namespace) and hands it in. Provided sessions bypass detectSleep + wake refinement; where they overlap a detected session the provided one wins (authoritative device staging), and a non-overlapping detected nap survives. Each provided session's nightly restingHR/avgHRV is re-derived from that day's hr/rr over its window via the SAME helpers detectSleep uses, so RHR/HRV light up even though the stored ring row carried neither. Empty `providedSleep` (every WHOOP / pure-function caller) is the byte-identical motion-only path. Both platforms, byte-identical: `sleepSession(fromProvided:)` / `decodeStages` reconstruct the pure session from the persisted `CachedSleepSession` (a minute-dict import row decodes to nothing and is skipped, so only real stage timelines inject). Tests (no strap): a provided hypnogram over a gravity-free night now yields totalSleepMin / deep / rem / efficiency AND a derived avgHrv + restingHr; empty providedSleep is asserted byte-identical to omitting it (and still nil without gravity — the pre-fix ryanbr#804 state). Swift StrandAnalytics 1173/0; Kotlin AnalyticsEngineProvidedSleepTest green; macOS app builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 6, 2026
…to the scorer (ryanbr#804 Fix A) An Oura-only night decoded and persisted a full SleepNet hypnogram (ryanbr#773) and showed on the Sleep timeline, yet the nightly summary reported `totalSleepMin=nil eff=nil avgHrv=nil source=computed`. Root cause: the scorer (`AnalyticsEngine.analyzeDay`) stages sleep from the motion/gravity vector, and a ring streams NO gravity — so `detectSleep` returned nothing, the sleep window was empty, and avgHrv (computed only over that window) stayed nil despite tens of thousands of R-R intervals present. The ring's persisted hypnogram never reached `analyzeDay`; it only fed the display-path `SleepMerge`. Fix A (general, not Oura-hardcoded): `analyzeDay` gains an optional `providedSleep` of already-staged sessions. When the day owner sends no usable gravity vector (`grav.count < 2` — a ring; a WHOOP always streams gravity) and is not the canonical WHOOP import namespace, IntelligenceEngine reads the owner's persisted hypnogram (its own device namespace) and hands it in. Provided sessions bypass detectSleep + wake refinement; where they overlap a detected session the provided one wins (authoritative device staging), and a non-overlapping detected nap survives. Each provided session's nightly restingHR/avgHRV is re-derived from that day's hr/rr over its window via the SAME helpers detectSleep uses, so RHR/HRV light up even though the stored ring row carried neither. Empty `providedSleep` (every WHOOP / pure-function caller) is the byte-identical motion-only path. Both platforms, byte-identical: `sleepSession(fromProvided:)` / `decodeStages` reconstruct the pure session from the persisted `CachedSleepSession` (a minute-dict import row decodes to nothing and is skipped, so only real stage timelines inject). Tests (no strap): a provided hypnogram over a gravity-free night now yields totalSleepMin / deep / rem / efficiency AND a derived avgHrv + restingHr; empty providedSleep is asserted byte-identical to omitting it (and still nil without gravity — the pre-fix ryanbr#804 state). Swift StrandAnalytics 1173/0; Kotlin AnalyticsEngineProvidedSleepTest green; macOS app builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 6, 2026
…to the scorer (ryanbr#804 Fix A) An Oura-only night decoded and persisted a full SleepNet hypnogram (ryanbr#773) and showed on the Sleep timeline, yet the nightly summary reported `totalSleepMin=nil eff=nil avgHrv=nil source=computed`. Root cause: the scorer (`AnalyticsEngine.analyzeDay`) stages sleep from the motion/gravity vector, and a ring streams NO gravity — so `detectSleep` returned nothing, the sleep window was empty, and avgHrv (computed only over that window) stayed nil despite tens of thousands of R-R intervals present. The ring's persisted hypnogram never reached `analyzeDay`; it only fed the display-path `SleepMerge`. Fix A (general, not Oura-hardcoded): `analyzeDay` gains an optional `providedSleep` of already-staged sessions. When the day owner sends no usable gravity vector (`grav.count < 2` — a ring; a WHOOP always streams gravity) and is not the canonical WHOOP import namespace, IntelligenceEngine reads the owner's persisted hypnogram (its own device namespace) and hands it in. Provided sessions bypass detectSleep + wake refinement; where they overlap a detected session the provided one wins (authoritative device staging), and a non-overlapping detected nap survives. Each provided session's nightly restingHR/avgHRV is re-derived from that day's hr/rr over its window via the SAME helpers detectSleep uses, so RHR/HRV light up even though the stored ring row carried neither. Empty `providedSleep` (every WHOOP / pure-function caller) is the byte-identical motion-only path. Both platforms, byte-identical: `sleepSession(fromProvided:)` / `decodeStages` reconstruct the pure session from the persisted `CachedSleepSession` (a minute-dict import row decodes to nothing and is skipped, so only real stage timelines inject). Tests (no strap): a provided hypnogram over a gravity-free night now yields totalSleepMin / deep / rem / efficiency AND a derived avgHrv + restingHr; empty providedSleep is asserted byte-identical to omitting it (and still nil without gravity — the pre-fix ryanbr#804 state). Swift StrandAnalytics 1173/0; Kotlin AnalyticsEngineProvidedSleepTest green; macOS app builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 6, 2026
…to the scorer (ryanbr#804 Fix A) An Oura-only night decoded and persisted a full SleepNet hypnogram (ryanbr#773) and showed on the Sleep timeline, yet the nightly summary reported `totalSleepMin=nil eff=nil avgHrv=nil source=computed`. Root cause: the scorer (`AnalyticsEngine.analyzeDay`) stages sleep from the motion/gravity vector, and a ring streams NO gravity — so `detectSleep` returned nothing, the sleep window was empty, and avgHrv (computed only over that window) stayed nil despite tens of thousands of R-R intervals present. The ring's persisted hypnogram never reached `analyzeDay`; it only fed the display-path `SleepMerge`. Fix A (general, not Oura-hardcoded): `analyzeDay` gains an optional `providedSleep` of already-staged sessions. When the day owner sends no usable gravity vector (`grav.count < 2` — a ring; a WHOOP always streams gravity) and is not the canonical WHOOP import namespace, IntelligenceEngine reads the owner's persisted hypnogram (its own device namespace) and hands it in. Provided sessions bypass detectSleep + wake refinement; where they overlap a detected session the provided one wins (authoritative device staging), and a non-overlapping detected nap survives. Each provided session's nightly restingHR/avgHRV is re-derived from that day's hr/rr over its window via the SAME helpers detectSleep uses, so RHR/HRV light up even though the stored ring row carried neither. Empty `providedSleep` (every WHOOP / pure-function caller) is the byte-identical motion-only path. Both platforms, byte-identical: `sleepSession(fromProvided:)` / `decodeStages` reconstruct the pure session from the persisted `CachedSleepSession` (a minute-dict import row decodes to nothing and is skipped, so only real stage timelines inject). Tests (no strap): a provided hypnogram over a gravity-free night now yields totalSleepMin / deep / rem / efficiency AND a derived avgHrv + restingHr; empty providedSleep is asserted byte-identical to omitting it (and still nil without gravity — the pre-fix ryanbr#804 state). Swift StrandAnalytics 1173/0; Kotlin AnalyticsEngineProvidedSleepTest green; macOS app builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 7, 2026
…to the scorer (ryanbr#804 Fix A) An Oura-only night decoded and persisted a full SleepNet hypnogram (ryanbr#773) and showed on the Sleep timeline, yet the nightly summary reported `totalSleepMin=nil eff=nil avgHrv=nil source=computed`. Root cause: the scorer (`AnalyticsEngine.analyzeDay`) stages sleep from the motion/gravity vector, and a ring streams NO gravity — so `detectSleep` returned nothing, the sleep window was empty, and avgHrv (computed only over that window) stayed nil despite tens of thousands of R-R intervals present. The ring's persisted hypnogram never reached `analyzeDay`; it only fed the display-path `SleepMerge`. Fix A (general, not Oura-hardcoded): `analyzeDay` gains an optional `providedSleep` of already-staged sessions. When the day owner sends no usable gravity vector (`grav.count < 2` — a ring; a WHOOP always streams gravity) and is not the canonical WHOOP import namespace, IntelligenceEngine reads the owner's persisted hypnogram (its own device namespace) and hands it in. Provided sessions bypass detectSleep + wake refinement; where they overlap a detected session the provided one wins (authoritative device staging), and a non-overlapping detected nap survives. Each provided session's nightly restingHR/avgHRV is re-derived from that day's hr/rr over its window via the SAME helpers detectSleep uses, so RHR/HRV light up even though the stored ring row carried neither. Empty `providedSleep` (every WHOOP / pure-function caller) is the byte-identical motion-only path. Both platforms, byte-identical: `sleepSession(fromProvided:)` / `decodeStages` reconstruct the pure session from the persisted `CachedSleepSession` (a minute-dict import row decodes to nothing and is skipped, so only real stage timelines inject). Tests (no strap): a provided hypnogram over a gravity-free night now yields totalSleepMin / deep / rem / efficiency AND a derived avgHrv + restingHr; empty providedSleep is asserted byte-identical to omitting it (and still nil without gravity — the pre-fix ryanbr#804 state). Swift StrandAnalytics 1173/0; Kotlin AnalyticsEngineProvidedSleepTest green; macOS app builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 8, 2026
…to the scorer (ryanbr#804 Fix A) An Oura-only night decoded and persisted a full SleepNet hypnogram (ryanbr#773) and showed on the Sleep timeline, yet the nightly summary reported `totalSleepMin=nil eff=nil avgHrv=nil source=computed`. Root cause: the scorer (`AnalyticsEngine.analyzeDay`) stages sleep from the motion/gravity vector, and a ring streams NO gravity — so `detectSleep` returned nothing, the sleep window was empty, and avgHrv (computed only over that window) stayed nil despite tens of thousands of R-R intervals present. The ring's persisted hypnogram never reached `analyzeDay`; it only fed the display-path `SleepMerge`. Fix A (general, not Oura-hardcoded): `analyzeDay` gains an optional `providedSleep` of already-staged sessions. When the day owner sends no usable gravity vector (`grav.count < 2` — a ring; a WHOOP always streams gravity) and is not the canonical WHOOP import namespace, IntelligenceEngine reads the owner's persisted hypnogram (its own device namespace) and hands it in. Provided sessions bypass detectSleep + wake refinement; where they overlap a detected session the provided one wins (authoritative device staging), and a non-overlapping detected nap survives. Each provided session's nightly restingHR/avgHRV is re-derived from that day's hr/rr over its window via the SAME helpers detectSleep uses, so RHR/HRV light up even though the stored ring row carried neither. Empty `providedSleep` (every WHOOP / pure-function caller) is the byte-identical motion-only path. Both platforms, byte-identical: `sleepSession(fromProvided:)` / `decodeStages` reconstruct the pure session from the persisted `CachedSleepSession` (a minute-dict import row decodes to nothing and is skipped, so only real stage timelines inject). Tests (no strap): a provided hypnogram over a gravity-free night now yields totalSleepMin / deep / rem / efficiency AND a derived avgHrv + restingHr; empty providedSleep is asserted byte-identical to omitting it (and still nil without gravity — the pre-fix ryanbr#804 state). Swift StrandAnalytics 1173/0; Kotlin AnalyticsEngineProvidedSleepTest green; macOS app builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 9, 2026
…to the scorer (ryanbr#804 Fix A) An Oura-only night decoded and persisted a full SleepNet hypnogram (ryanbr#773) and showed on the Sleep timeline, yet the nightly summary reported `totalSleepMin=nil eff=nil avgHrv=nil source=computed`. Root cause: the scorer (`AnalyticsEngine.analyzeDay`) stages sleep from the motion/gravity vector, and a ring streams NO gravity — so `detectSleep` returned nothing, the sleep window was empty, and avgHrv (computed only over that window) stayed nil despite tens of thousands of R-R intervals present. The ring's persisted hypnogram never reached `analyzeDay`; it only fed the display-path `SleepMerge`. Fix A (general, not Oura-hardcoded): `analyzeDay` gains an optional `providedSleep` of already-staged sessions. When the day owner sends no usable gravity vector (`grav.count < 2` — a ring; a WHOOP always streams gravity) and is not the canonical WHOOP import namespace, IntelligenceEngine reads the owner's persisted hypnogram (its own device namespace) and hands it in. Provided sessions bypass detectSleep + wake refinement; where they overlap a detected session the provided one wins (authoritative device staging), and a non-overlapping detected nap survives. Each provided session's nightly restingHR/avgHRV is re-derived from that day's hr/rr over its window via the SAME helpers detectSleep uses, so RHR/HRV light up even though the stored ring row carried neither. Empty `providedSleep` (every WHOOP / pure-function caller) is the byte-identical motion-only path. Both platforms, byte-identical: `sleepSession(fromProvided:)` / `decodeStages` reconstruct the pure session from the persisted `CachedSleepSession` (a minute-dict import row decodes to nothing and is skipped, so only real stage timelines inject). Tests (no strap): a provided hypnogram over a gravity-free night now yields totalSleepMin / deep / rem / efficiency AND a derived avgHrv + restingHr; empty providedSleep is asserted byte-identical to omitting it (and still nil without gravity — the pre-fix ryanbr#804 state). Swift StrandAnalytics 1173/0; Kotlin AnalyticsEngineProvidedSleepTest green; macOS app builds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6
ryanbr
pushed a commit
that referenced
this pull request
Aug 9, 2026
…to the scorer (#804 Fix A) (#1183) An Oura-only night decoded and persisted a full SleepNet hypnogram (#773) and showed on the Sleep timeline, yet the nightly summary reported `totalSleepMin=nil eff=nil avgHrv=nil source=computed`. Root cause: the scorer (`AnalyticsEngine.analyzeDay`) stages sleep from the motion/gravity vector, and a ring streams NO gravity — so `detectSleep` returned nothing, the sleep window was empty, and avgHrv (computed only over that window) stayed nil despite tens of thousands of R-R intervals present. The ring's persisted hypnogram never reached `analyzeDay`; it only fed the display-path `SleepMerge`. Fix A (general, not Oura-hardcoded): `analyzeDay` gains an optional `providedSleep` of already-staged sessions. When the day owner sends no usable gravity vector (`grav.count < 2` — a ring; a WHOOP always streams gravity) and is not the canonical WHOOP import namespace, IntelligenceEngine reads the owner's persisted hypnogram (its own device namespace) and hands it in. Provided sessions bypass detectSleep + wake refinement; where they overlap a detected session the provided one wins (authoritative device staging), and a non-overlapping detected nap survives. Each provided session's nightly restingHR/avgHRV is re-derived from that day's hr/rr over its window via the SAME helpers detectSleep uses, so RHR/HRV light up even though the stored ring row carried neither. Empty `providedSleep` (every WHOOP / pure-function caller) is the byte-identical motion-only path. Both platforms, byte-identical: `sleepSession(fromProvided:)` / `decodeStages` reconstruct the pure session from the persisted `CachedSleepSession` (a minute-dict import row decodes to nothing and is skipped, so only real stage timelines inject). Tests (no strap): a provided hypnogram over a gravity-free night now yields totalSleepMin / deep / rem / efficiency AND a derived avgHrv + restingHr; empty providedSleep is asserted byte-identical to omitting it (and still nil without gravity — the pre-fix #804 state). Swift StrandAnalytics 1173/0; Kotlin AnalyticsEngineProvidedSleepTest green; macOS app builds. Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
ThatsSoTrieu
pushed a commit
to ThatsSoTrieu/noop
that referenced
this pull request
Aug 13, 2026
…tics (ryanbr#332) * Surface a banner when the strap clock is set in the future (ryanbr#324) A WHOOP 4.0 whose RTC relatched to a future base banks history dated years ahead. The ryanbr#928/ryanbr#1012/ryanbr#773 guards correctly refuse to misfile it, but because the strap DOES bank records the existing 'clock lost / not banking' banner (gated on empty syncs) never fires - so the sync reported clean while importing nothing, with no reason shown (ryanbr#324). Add a pure futureDatedStrapBanner (Swift BLEManager + Kotlin WhoopBleClient, byte-identical string) driven by the existing isFutureDatedNewest predicate, wired into the HISTORY_COMPLETE handler after the banked-nothing branch. One detection is decisive (nothing legitimate banks 48h ahead), so no streak gate. Names the strap-side remedy (full charge + power-cycle to relatch the clock). Twin tests pin the string + the 48h threshold on both platforms. * Fix ryanbr#324 banner: cover the timeout outcome, not just HISTORY_COMPLETE Re-review caught the real gap: a future-dated strap with a deep backlog TIMES OUT before HISTORY_COMPLETE (the reporter's own session ended on timeout). The first cut gated the banner on HISTORY_COMPLETE only, so the reported case would still show the misleading 'strap went quiet' message and the new banner would never fire. Hoist the future-clock banner (computed once from strapNewestTs, which is already populated by the connect-time GET_DATA_RANGE) and apply it to BOTH the HISTORY_COMPLETE branch and the WHOOP-4 timeout branch, on both platforms. The log is now outcome-independent too. * Fix ryanbr#324 banner parity: bankedNothing precedence + single wall-clock read Re-review found a Swift/Kotlin divergence I introduced: replacing Kotlin's 'else null' with 'else futureClockBanner' meant a strap that banked nothing this pass but isn't yet sustained-empty AND is future-dated showed the future banner on Android while Swift (bankedNothing branch wins) showed nil. Restructure the Kotlin lastSyncError to a when{} that matches Swift's else-if precedence exactly: bankedNothing keeps its sustained-empty logic (ryanbr#126/ryanbr#214), future-dated is checked only on the banked-something path. Also hoist the Swift wall-clock read to one value (parity with Kotlin's single nowSec; the banner and its log can't drift across a second boundary). * Add bad-clock strap diagnostics for ryanbr#324 (dev logs 1 + 2) Two log-only additions to make a future-dated strap's cause legible in the strap-log export, extending the existing ryanbr#547 dropped-record seam: 1. RTC-state events (RTC_LOST / BOOT / SET_RTC) that the ryanbr#547 plausibility gate drops for their own implausible timestamp are now CAPTURED before being discarded (Streams.droppedRtcEvents). This is the catch-22 fix: the one event that explains the reset was silently dropped because ITS ts is bad. The Backfiller logs each with the bad epoch the RTC jumped to. 2. The epoch SPAN of the dropped records (oldest -> newest own-timestamp + hours-ahead skew) is captured and logged, so we can tell a whole-range- future strap (safe to fast-forward-discard, the ryanbr#324 follow-up B) from one mixed with real data. Pure capture in WhoopProtocol.extractHistoricalStreams + a pure, testable BadClockDiagnostics formatter (byte-identical Swift/Kotlin), both twinned. No behavior change: the gate still drops exactly what it dropped before. WhoopProtocol 271 swift test + Android compile/tests green.
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 14, 2026
…s instrumentation NOOP received tag 0x6A and dropped it as unknown. It carries the ring's own per-window sleep summary — an average heart rate and a breath rate. This decodes it on both platforms and persists ONE field of it, `breath`, as a respiration row under the ring's own deviceId: stored and shown, never staged, never scored. ## The decode Our §6.12 already had the OFFSETS from [ringverse] but no NAMES, so the tag read as four anonymous metrics; it also typed `average_hr` as `int8` with no `× 0.5`, which reads every value above 63.5 bpm as negative. [open_ring]'s `decode_sleep_period_info_2` supplies the names and the `.rodata` multipliers (clean-room fact citation, no code copied): 0 average_hr u8 ×0.5 · 1 hr_trend s8 ×0.0625 · 2 mzci / 3 dzci u8 ×0.0625 4 breath u8 /8.0 · 5 breath_v u8 /8.0 · 6 motion_count · 7 sleep_state 8-9 cv u16 LE /65536 Verified on four consecutive real Gen 3 overnights (2026-08-05→09, 3,493 records): every body exactly 10 bytes, every record satisfies the source's two declared invariants, and every `breath` is an exact multiple of 0.125 — the `/8.0` fixed point confirmed from the data rather than assumed. The `× 0.5` scale is settled by its falsifier: read as `× 1.0` these records sit +56…+62 bpm above every other HR channel we hold, while `× 0.5` medians 53–54 bpm, matching the banked-IBI channel's 54 (ryanbr#511, WHOOP-validated at RHR 55). Both decoders REJECT a body that violates either declared invariant (motion_count < 121, sleep_state ∈ {0,1,2}) rather than returning numbers built from bytes that mean something else; all 3,493 real records pass. `mzci` / `dzci` keep the source's opaque names — we do not know what they measure. Cadence ≈296 s, emitted only during sleep periods. ## Why `breath` is stored — and which bar actually applies `breath` is computed BY THE RING and read off the wire. It is not a signal NOOP derives from raw sensor data, so the ryanbr#194 rule — written for methods like PPG→HR autocorrelation and RSA-from-R-R, where NOOP invents the number and can manufacture a peak that looks physiological — is not the test. What has to be right here is the DECODE, which the structural verification above establishes. This is the same standing as the ring's own SleepNet hypnogram, which NOOP already persists and scores from (ryanbr#773/ryanbr#877). The strongest cross-check is not against WHOOP at all: these records median 14.75/min against the SAME wearer's 851-night Oura APP export at median 15.250 (IQR 14.875-15.625) — byte 4 checked against Oura's own reported respiratory rate, same quantity, same band, our medians on the low side (~12th pct). That is a distribution check, NOT paired: the export ends 2026-07-07 and these records start 2026-08-05, and a paired test is impossible by construction, since the ring pairs to one app at a time and nothing reaches Oura's cloud while NOOP holds it. Against a WHOOP worn the same nights, the decode reproduces the VENDOR's own offset: over 18 nights of that wearer's history carrying both vendors, Oura's own app reads below WHOOP 18/18 (delta -1.158, sd 0.359) and scores r = +0.680; ours is delta -1.458 sign-stable 6/6, r = +0.599 over 6 paired nights and +0.748 over the 4 well-covered ones. Judging this decode by "does it beat WHOOP" would ask it to beat Oura's own app at reproducing WHOOP — the wrong test for a vendor-computed value, and an impossible one. The date falsifier passed: mapping each night to the wrong date collapses r from +0.591 to -0.151. So the value is stored and shown. Two limits remain, and NEITHER is a doubt about the measurement: - STORED: `breath` -> `respSample.raw` in MILLI-breaths-per-minute. The wire field is a u8 / 8, so raw == wireByte * 125 exactly for all 256 values — no rounding, and therefore no rounding rule for the two platforms to disagree about. Centi would land on x.5 for half the alphabet, and whole bpm would erase the ~0.30 bpm the whole question is about. - KEPT OUT OF THE SLEEP STAGER — a SHAPE refusal, not a trust one. `respSample` otherwise carries a WHOOP 4.0's raw respiration ADC WAVEFORM, which the stager runs a peak detector over; the ring's rows are a per-window RATE, so feeding them in is wrong however good the rate is (the same reason 0x47 motion is never folded into gravitySample, ryanbr#804). `OuraRespScale.forScoring` drops them at every scoring read (nightly scan, re-stage/self-heal, and the offline SleepBench harness). Today's ~296 s cadence would have produced NaN anyway — the tests pin that staging is bit-identical with and without the rows, plus a control proving a dense stream DOES move staging, so the invariance is not vacuous. Refusing by provenance is what keeps that true if the cadence ever changes. - NOT WRITTEN TO `dailyMetric.respRateBpm`, for a reason that lives in NOOP's baseline model rather than in the ring: that slot's 28-day baseline is a single day-keyed series SHARED ACROSS SOURCES (imported values win per day, computed ones fill the rest). A WHOOP-import day (~16.1) beside a ring night (~14.6) is a ~3 sigma illness-ward step against a ~0.52 bpm baseline sd, so a strap switch would read as physiology. Scope the respiration baseline per source and this value is promotable; until then the slot stays the R-R RSA estimate. - SHOWN: the day/Deep-Timeline respiration track scales a ring's rows back to breaths/min (~14-16, not ~14,375). `OuraRespScale` is the single place both the display conversion and the scoring refusal live, because `DeviceFamily` has no Oura case by design (ryanbr#1086) and so cannot carry this distinction. `average_hr` from the same record is refused — it would join the beat-derived HR series at a different cadence and a different provenance — as are `breath_v` / `mzci` / `dzci` / `cv` / `sleep_state`, which stay in the investigation log beside the stored row. RSA-from-banked-R-R stays refused separately, and that one IS a ryanbr#194 failure in the proper sense: NOOP computes it, and shuffling or reversing the night's R-R returns the same 13.3333 bpm. This does not retract the respiratory-rate gate. That work proved RSA is unrecoverable from banked IBI and refusing a fabricated number is right regardless; what 0x6A corrects is that finding's second clause — "the app must use a channel we never receive". We do receive one; we had simply never decoded the tag. Docs: §6.12 rewritten (layout table, the `× 0.5` correction called out as a correction, the four-night verification, the vendor-ceiling measurement and the instrumentation disposition); §9's census row for 0x6a marked SOLVED — its "recurring 0001f8b0 trailer" was never a trailer, it is motion_count=0, sleep_state=1 and the 2-byte cv, recurring because a still sleeper repeats them. No new user-facing strings, so no i18n work. Tests: 7 real-record decode fixtures per platform (same hex both sides), plus new mapping/scale/scoring-exclusion suites. WhoopStore 384/0, StrandAnalytics 1348/0, OuraProtocol 191/0, SleepBench 13/0, Android 3828/0. macOS `Strand` and iOS `NOOPiOS` app targets both compiled locally (CI covers neither). NOT yet validated on hardware: the live-source arm anchors and enqueues 0x6A like its banked siblings and advances the drain resume cursor with them (0x6A is banked-only and never live, so it cannot leap the cursor to "now"), which needs a real overnight drain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FxLiKdWF4iegX5NHK24FwR
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 14, 2026
…iratory rate NOOP received tag 0x6A and dropped it as unknown. It carries the ring's own per-window sleep summary — an average heart rate and a breath rate. This decodes it on both platforms, persists ONE field of it, `breath`, as a respiration row under the ring's own deviceId, shows it, and makes it the night's `dailyMetric.respRateBpm` on a ring night. ## The decode Our §6.12 already had the OFFSETS from [ringverse] but no NAMES, so the tag read as four anonymous metrics; it also typed `average_hr` as `int8` with no `× 0.5`, which reads every value above 63.5 bpm as negative. [open_ring]'s `decode_sleep_period_info_2` supplies the names and the `.rodata` multipliers (clean-room fact citation, no code copied): 0 average_hr u8 ×0.5 · 1 hr_trend s8 ×0.0625 · 2 mzci / 3 dzci u8 ×0.0625 4 breath u8 /8.0 · 5 breath_v u8 /8.0 · 6 motion_count · 7 sleep_state 8-9 cv u16 LE /65536 Verified on four consecutive real Gen 3 overnights (2026-08-05→09, 3,493 records): every body exactly 10 bytes, every record satisfies the source's two declared invariants, and every `breath` is an exact multiple of 0.125 — the `/8.0` fixed point confirmed from the data rather than assumed. The `× 0.5` scale is settled by its falsifier: read as `× 1.0` these records sit +56…+62 bpm above every other HR channel we hold, while `× 0.5` medians 53–54 bpm, matching the banked-IBI channel's 54 (ryanbr#511, WHOOP-validated at RHR 55). Both decoders REJECT a body that violates either declared invariant (motion_count < 121, sleep_state ∈ {0,1,2}) rather than returning numbers built from bytes that mean something else; all 3,493 real records pass. `mzci` / `dzci` keep the source's opaque names — we do not know what they measure. Cadence ≈296 s, emitted only during sleep periods. ## Why `breath` is stored — and which bar actually applies `breath` is computed BY THE RING and read off the wire. It is not a signal NOOP derives from raw sensor data, so the ryanbr#194 rule — written for methods like PPG→HR autocorrelation and RSA-from-R-R, where NOOP invents the number and can manufacture a peak that looks physiological — is not the test. What has to be right here is the DECODE, which the structural verification above establishes. This is the same standing as the ring's own SleepNet hypnogram, which NOOP already persists and scores from (ryanbr#773/ryanbr#877). The strongest cross-check is not against WHOOP at all: these records median 14.75/min against the SAME wearer's 851-night Oura APP export at median 15.250 (IQR 14.875-15.625) — byte 4 checked against Oura's own reported respiratory rate, same quantity, same band, our medians on the low side (~12th pct). That is a distribution check, NOT paired: the export ends 2026-07-07 and these records start 2026-08-05, and a paired test is impossible by construction, since the ring pairs to one app at a time and nothing reaches Oura's cloud while NOOP holds it. Against a WHOOP worn the same nights, the decode reproduces the VENDOR's own offset: over 18 nights of that wearer's history carrying both vendors, Oura's own app reads below WHOOP 18/18 (delta -1.158, sd 0.359) and scores r = +0.680; ours is delta -1.458 sign-stable 6/6, r = +0.599 over 6 paired nights and +0.748 over the 4 well-covered ones. Judging this decode by "does it beat WHOOP" would ask it to beat Oura's own app at reproducing WHOOP — the wrong test for a vendor-computed value, and an impossible one. The date falsifier passed: mapping each night to the wrong date collapses r from +0.591 to -0.151. So the value is stored, shown, and — on a ring night — scored. One limit remains, and it is about SHAPE, not trust: - STORED: `breath` -> `respSample.raw` in MILLI-breaths-per-minute. The wire field is a u8 / 8, so raw == wireByte * 125 exactly for all 256 values — no rounding, and therefore no rounding rule for the two platforms to disagree about. Centi would land on x.5 for half the alphabet, and whole bpm would erase the ~0.30 bpm the whole question is about. - KEPT OUT OF THE SLEEP STAGER — a SHAPE refusal, not a trust one. `respSample` otherwise carries a WHOOP 4.0's raw respiration ADC WAVEFORM, which the stager runs a peak detector over; the ring's rows are a per-window RATE, so feeding them in is wrong however good the rate is (the same reason 0x47 motion is never folded into gravitySample, ryanbr#804). `OuraRespScale.forScoring` drops them at every scoring read (nightly scan, re-stage/self-heal, and the offline SleepBench harness). Today's ~296 s cadence would have produced NaN anyway — the tests pin that staging is bit-identical with and without the rows, plus a control proving a dense stream DOES move staging, so the invariance is not vacuous. Refusing by provenance is what keeps that true if the cadence ever changes. - WRITTEN TO `dailyMetric.respRateBpm` on a ring night, as the MEDIAN of the rows inside a matched in-bed session. On such a night that replaces an RSA-from-banked- R-R estimate which carries no breathing information at all — shuffling or reversing the night returns the same 13.3333 bpm — so this is strictly better, not merely different. Two coverage guards, both about representativeness rather than trust: the in-session rows must SPAN >= 1 h (AnalyticsEngine .vendorRespMinSpanS), and the median must land inside the same 8-25 bpm band the RSA path is clamped to. The span gate is on time, not row count, because the cadence is not constant: the ledger's 36-minute tail holds ~70 rows and is still not a night. A WHOOP night passes no vendor rows and keeps the RSA path verbatim (pinned: an empty list is byte-identical to omitting the parameter). - THE BASELINE THAT VALUE FEEDS IS SCOPED TO THE CURRENT DEVICE ERA. Without this the promotion would manufacture a false signal: respRateBpm's 28-day baseline is a single day-keyed series SHARED ACROSS SOURCES (imported values win per day, computed ones fill the rest), so a WHOOP-import day at ~16.1 beside a ring night at ~14.6 is a ~3 sigma illness-ward step against a ~0.52 bpm spread — a strap switch read as physiology. `Baselines.deviceEraEpoch` (ryanbr#459) already solves exactly this for a brand change and was implemented, unit-tested and NEVER WIRED TO ANYTHING; this wires it for the respiration fold. A single-brand history returns epoch 0.0, so every existing WHOOP user folds byte-identically to before. The HRV and resting-HR baselines ryanbr#459 was written for stay unwired — that is ryanbr#459's own scope. Known gap, pre-existing: the bucket is per BRAND, so it does not separate an imported WHOOP vendor rate from NOOP's own RSA estimate on WHOOP nights; those were already pooled and still are. - SHOWN: the day/Deep-Timeline respiration track scales a ring's rows back to breaths/min (~14-16, not ~14,375). `OuraRespScale` is the single place both the display conversion and the scoring refusal live, because `DeviceFamily` has no Oura case by design (ryanbr#1086) and so cannot carry this distinction. `average_hr` from the same record is refused — it would join the beat-derived HR series at a different cadence and a different provenance — as are `breath_v` / `mzci` / `dzci` / `cv` / `sleep_state`, which stay in the investigation log beside the stored row. RSA-from-banked-R-R stays refused separately, and that one IS a ryanbr#194 failure in the proper sense: NOOP computes it, and shuffling or reversing the night's R-R returns the same 13.3333 bpm. This does not retract the respiratory-rate gate. That work proved RSA is unrecoverable from banked IBI and refusing a fabricated number is right regardless; what 0x6A corrects is that finding's second clause — "the app must use a channel we never receive". We do receive one; we had simply never decoded the tag. Docs: §6.12 rewritten (layout table, the `× 0.5` correction called out as a correction, the four-night verification, the vendor-ceiling measurement and the instrumentation disposition); §9's census row for 0x6a marked SOLVED — its "recurring 0001f8b0 trailer" was never a trailer, it is motion_count=0, sleep_state=1 and the 2-byte cv, recurring because a still sleeper repeats them. No new user-facing strings, so no i18n work. Tests: 7 real-record decode fixtures per platform (same hex both sides), plus new twinned mapping/scale/scoring-exclusion suites and a 9-test vendor-rate suite per platform (median, out-of-session rows ignored, the span gate refusing a dense 36-minute fragment, the plausible-band refusal, analyzeDay preferring the device rate, empty-vendor byte-identity, and the era-scoped fold with a pooled-baseline control showing the same ring night crossing 1 sigma when brands are mixed). WhoopStore 384/0, StrandAnalytics 1357/0, OuraProtocol 191/0, SleepBench 13/0, Android 3837/0. macOS `Strand` and iOS `NOOPiOS` app targets both compiled locally (CI covers neither). NOT yet validated on hardware: the live-source arm anchors and enqueues 0x6A like its banked siblings and advances the drain resume cursor with them (0x6A is banked-only and never live, so it cannot leap the cursor to "now"), which needs a real overnight drain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FxLiKdWF4iegX5NHK24FwR
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 14, 2026
…iratory rate NOOP received tag 0x6A and dropped it as unknown. It carries the ring's own per-window sleep summary — an average heart rate and a breath rate. This decodes it on both platforms, persists ONE field of it, `breath`, as a respiration row under the ring's own deviceId, shows it, and makes it the night's `dailyMetric.respRateBpm` on a ring night. ## The decode Our §6.12 already had the OFFSETS from [ringverse] but no NAMES, so the tag read as four anonymous metrics; it also typed `average_hr` as `int8` with no `× 0.5`, which reads every value above 63.5 bpm as negative. [open_ring]'s `decode_sleep_period_info_2` supplies the names and the `.rodata` multipliers (clean-room fact citation, no code copied): 0 average_hr u8 ×0.5 · 1 hr_trend s8 ×0.0625 · 2 mzci / 3 dzci u8 ×0.0625 4 breath u8 /8.0 · 5 breath_v u8 /8.0 · 6 motion_count · 7 sleep_state 8-9 cv u16 LE /65536 Verified on four consecutive real Gen 3 overnights (2026-08-05→09, 3,493 records): every body exactly 10 bytes, every record satisfies the source's two declared invariants, and every `breath` is an exact multiple of 0.125 — the `/8.0` fixed point confirmed from the data rather than assumed. The `× 0.5` scale is settled by its falsifier: read as `× 1.0` these records sit +56…+62 bpm above every other HR channel we hold, while `× 0.5` medians 53–54 bpm, matching the banked-IBI channel's 54 (ryanbr#511, WHOOP-validated at RHR 55). Both decoders REJECT a body that violates either declared invariant (motion_count < 121, sleep_state ∈ {0,1,2}) rather than returning numbers built from bytes that mean something else; all 3,493 real records pass. `mzci` / `dzci` keep the source's opaque names — we do not know what they measure. Cadence ≈296 s, emitted only during sleep periods. ## Why `breath` is stored — and which bar actually applies `breath` is computed BY THE RING and read off the wire. It is not a signal NOOP derives from raw sensor data, so the ryanbr#194 rule — written for methods like PPG→HR autocorrelation and RSA-from-R-R, where NOOP invents the number and can manufacture a peak that looks physiological — is not the test. What has to be right here is the DECODE, which the structural verification above establishes. This is the same standing as the ring's own SleepNet hypnogram, which NOOP already persists and scores from (ryanbr#773/ryanbr#877). The strongest cross-check is not against WHOOP at all: these records median 14.75/min against the SAME wearer's 851-night Oura APP export at median 15.250 (IQR 14.875-15.625) — byte 4 checked against Oura's own reported respiratory rate, same quantity, same band, our medians on the low side (~12th pct). That is a distribution check, NOT paired: the export ends 2026-07-07 and these records start 2026-08-05, and a paired test is impossible by construction, since the ring pairs to one app at a time and nothing reaches Oura's cloud while NOOP holds it. Against a WHOOP worn the same nights, the decode reproduces the VENDOR's own offset: over 18 nights of that wearer's history carrying both vendors, Oura's own app reads below WHOOP 18/18 (delta -1.158, sd 0.359) and scores r = +0.680; ours is delta -1.458 sign-stable 6/6, r = +0.599 over 6 paired nights and +0.748 over the 4 well-covered ones. Judging this decode by "does it beat WHOOP" would ask it to beat Oura's own app at reproducing WHOOP — the wrong test for a vendor-computed value, and an impossible one. The date falsifier passed: mapping each night to the wrong date collapses r from +0.591 to -0.151. So the value is stored, shown, and — on a ring night — scored. One limit remains, and it is about SHAPE, not trust: - STORED: `breath` -> `respSample.raw` in MILLI-breaths-per-minute. The wire field is a u8 / 8, so raw == wireByte * 125 exactly for all 256 values — no rounding, and therefore no rounding rule for the two platforms to disagree about. Centi would land on x.5 for half the alphabet, and whole bpm would erase the ~0.30 bpm the whole question is about. - KEPT OUT OF THE SLEEP STAGER — a SHAPE refusal, not a trust one. `respSample` otherwise carries a WHOOP 4.0's raw respiration ADC WAVEFORM, which the stager runs a peak detector over; the ring's rows are a per-window RATE, so feeding them in is wrong however good the rate is (the same reason 0x47 motion is never folded into gravitySample, ryanbr#804). `OuraRespScale.forScoring` drops them at every scoring read (nightly scan, re-stage/self-heal, and the offline SleepBench harness). Today's ~296 s cadence would have produced NaN anyway — the tests pin that staging is bit-identical with and without the rows, plus a control proving a dense stream DOES move staging, so the invariance is not vacuous. Refusing by provenance is what keeps that true if the cadence ever changes. - WRITTEN TO `dailyMetric.respRateBpm` on a ring night, as the MEDIAN of the rows inside a matched in-bed session. On such a night that replaces an RSA-from-banked- R-R estimate which carries no breathing information at all — shuffling or reversing the night returns the same 13.3333 bpm — so this is strictly better, not merely different. Two coverage guards, both about representativeness rather than trust: the in-session rows must SPAN >= 1 h (AnalyticsEngine .vendorRespMinSpanS), and the median must land inside the same 8-25 bpm band the RSA path is clamped to. The span gate is on time, not row count, because the cadence is not constant: the ledger's 36-minute tail holds ~70 rows and is still not a night. A WHOOP night passes no vendor rows and keeps the RSA path verbatim (pinned: an empty list is byte-identical to omitting the parameter). - THE BASELINE THAT VALUE FEEDS IS SCOPED TO THE CURRENT DEVICE ERA. Without this the promotion would manufacture a false signal: respRateBpm's 28-day baseline is a single day-keyed series SHARED ACROSS SOURCES (imported values win per day, computed ones fill the rest), so a WHOOP-import day at ~16.1 beside a ring night at ~14.6 is a ~3 sigma illness-ward step against a ~0.52 bpm spread — a strap switch read as physiology. `Baselines.deviceEraEpoch` (ryanbr#459) already solves exactly this for a brand change and was implemented, unit-tested and NEVER WIRED TO ANYTHING; this wires it for the respiration fold. A single-brand history returns epoch 0.0, so every existing WHOOP user folds byte-identically to before. The HRV and resting-HR baselines ryanbr#459 was written for stay unwired — that is ryanbr#459's own scope. Known gap, pre-existing: the bucket is per BRAND, so it does not separate an imported WHOOP vendor rate from NOOP's own RSA estimate on WHOOP nights; those were already pooled and still are. - SHOWN: the day/Deep-Timeline respiration track scales a ring's rows back to breaths/min (~14-16, not ~14,375). `OuraRespScale` is the single place both the display conversion and the scoring refusal live, because `DeviceFamily` has no Oura case by design (ryanbr#1086) and so cannot carry this distinction. `average_hr` from the same record is refused — it would join the beat-derived HR series at a different cadence and a different provenance — as are `breath_v` / `mzci` / `dzci` / `cv` / `sleep_state`, which stay in the investigation log beside the stored row. RSA-from-banked-R-R stays refused separately, and that one IS a ryanbr#194 failure in the proper sense: NOOP computes it, and shuffling or reversing the night's R-R returns the same 13.3333 bpm. This does not retract the respiratory-rate gate. That work proved RSA is unrecoverable from banked IBI and refusing a fabricated number is right regardless; what 0x6A corrects is that finding's second clause — "the app must use a channel we never receive". We do receive one; we had simply never decoded the tag. Docs: §6.12 rewritten (layout table, the `× 0.5` correction called out as a correction, the four-night verification, the vendor-ceiling measurement and the instrumentation disposition); §9's census row for 0x6a marked SOLVED — its "recurring 0001f8b0 trailer" was never a trailer, it is motion_count=0, sleep_state=1 and the 2-byte cv, recurring because a still sleeper repeats them. No new user-facing strings, so no i18n work. Tests: 7 real-record decode fixtures per platform (same hex both sides), plus new twinned mapping/scale/scoring-exclusion suites and a 9-test vendor-rate suite per platform (median, out-of-session rows ignored, the span gate refusing a dense 36-minute fragment, the plausible-band refusal, analyzeDay preferring the device rate, empty-vendor byte-identity, and the era-scoped fold with a pooled-baseline control showing the same ring night crossing 1 sigma when brands are mixed). WhoopStore 384/0, StrandAnalytics 1357/0, OuraProtocol 191/0, SleepBench 13/0, Android 3837/0. macOS `Strand` and iOS `NOOPiOS` app targets both compiled locally (CI covers neither). NOT yet validated on hardware: the live-source arm anchors and enqueues 0x6A like its banked siblings and advances the drain resume cursor with them (0x6A is banked-only and never live, so it cannot leap the cursor to "now"), which needs a real overnight drain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FxLiKdWF4iegX5NHK24FwR
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 14, 2026
…iratory rate NOOP received tag 0x6A and dropped it as unknown. It carries the ring's own per-window sleep summary — an average heart rate and a breath rate. This decodes it on both platforms, persists ONE field of it, `breath`, as a respiration row under the ring's own deviceId, shows it, and makes it the night's `dailyMetric.respRateBpm` on a ring night. Our §6.12 already had the OFFSETS from [ringverse] but no NAMES, so the tag read as four anonymous metrics; it also typed `average_hr` as `int8` with no `× 0.5`, which reads every value above 63.5 bpm as negative. [open_ring]'s `decode_sleep_period_info_2` supplies the names and the `.rodata` multipliers (clean-room fact citation, no code copied): 0 average_hr u8 ×0.5 · 1 hr_trend s8 ×0.0625 · 2 mzci / 3 dzci u8 ×0.0625 4 breath u8 /8.0 · 5 breath_v u8 /8.0 · 6 motion_count · 7 sleep_state 8-9 cv u16 LE /65536 Verified on four consecutive real Gen 3 overnights (2026-08-05→09, 3,493 records): every body exactly 10 bytes, every record satisfies the source's two declared invariants, and every `breath` is an exact multiple of 0.125 — the `/8.0` fixed point confirmed from the data rather than assumed. The `× 0.5` scale is settled by its falsifier: read as `× 1.0` these records sit +56…+62 bpm above every other HR channel we hold, while `× 0.5` medians 53–54 bpm, matching the banked-IBI channel's 54 (ryanbr#511, WHOOP-validated at RHR 55). Both decoders REJECT a body that violates either declared invariant (motion_count < 121, sleep_state ∈ {0,1,2}) rather than returning numbers built from bytes that mean something else; all 3,493 real records pass. `mzci` / `dzci` keep the source's opaque names — we do not know what they measure. Cadence ≈296 s, emitted only during sleep periods. `breath` is computed BY THE RING and read off the wire. It is not a signal NOOP derives from raw sensor data, so the ryanbr#194 rule — written for methods like PPG→HR autocorrelation and RSA-from-R-R, where NOOP invents the number and can manufacture a peak that looks physiological — is not the test. What has to be right here is the DECODE, which the structural verification above establishes. This is the same standing as the ring's own SleepNet hypnogram, which NOOP already persists and scores from (ryanbr#773/ryanbr#877). The strongest cross-check is not against WHOOP at all: these records median 14.75/min against the SAME wearer's 851-night Oura APP export at median 15.250 (IQR 14.875-15.625) — byte 4 checked against Oura's own reported respiratory rate, same quantity, same band, our medians on the low side (~12th pct). That is a distribution check, NOT paired: the export ends 2026-07-07 and these records start 2026-08-05, and a paired test is impossible by construction, since the ring pairs to one app at a time and nothing reaches Oura's cloud while NOOP holds it. Against a WHOOP worn the same nights, the decode reproduces the VENDOR's own offset: over 18 nights of that wearer's history carrying both vendors, Oura's own app reads below WHOOP 18/18 (delta -1.158, sd 0.359) and scores r = +0.680; ours is delta -1.458 sign-stable 6/6, r = +0.599 over 6 paired nights and +0.748 over the 4 well-covered ones. Judging this decode by "does it beat WHOOP" would ask it to beat Oura's own app at reproducing WHOOP — the wrong test for a vendor-computed value, and an impossible one. The date falsifier passed: mapping each night to the wrong date collapses r from +0.591 to -0.151. So the value is stored, shown, and — on a ring night — scored. One limit remains, and it is about SHAPE, not trust: - STORED: `breath` -> `respSample.raw` in MILLI-breaths-per-minute. The wire field is a u8 / 8, so raw == wireByte * 125 exactly for all 256 values — no rounding, and therefore no rounding rule for the two platforms to disagree about. Centi would land on x.5 for half the alphabet, and whole bpm would erase the ~0.30 bpm the whole question is about. - KEPT OUT OF THE SLEEP STAGER — a SHAPE refusal, not a trust one. `respSample` otherwise carries a WHOOP 4.0's raw respiration ADC WAVEFORM, which the stager runs a peak detector over; the ring's rows are a per-window RATE, so feeding them in is wrong however good the rate is (the same reason 0x47 motion is never folded into gravitySample, ryanbr#804). `OuraRespScale.forScoring` drops them at every scoring read (nightly scan, re-stage/self-heal, and the offline SleepBench harness). Today's ~296 s cadence would have produced NaN anyway — the tests pin that staging is bit-identical with and without the rows, plus a control proving a dense stream DOES move staging, so the invariance is not vacuous. Refusing by provenance is what keeps that true if the cadence ever changes. - WRITTEN TO `dailyMetric.respRateBpm` on a ring night, as the MEDIAN of the rows inside a matched in-bed session. On such a night that replaces an RSA-from-banked- R-R estimate which carries no breathing information at all — shuffling or reversing the night returns the same 13.3333 bpm — so this is strictly better, not merely different. Two coverage guards, both about representativeness rather than trust: the in-session rows must SPAN >= 1 h (AnalyticsEngine .vendorRespMinSpanS), and the median must land inside the same 8-25 bpm band the RSA path is clamped to. The span gate is on time, not row count, because the cadence is not constant: the ledger's 36-minute tail holds ~70 rows and is still not a night. A WHOOP night passes no vendor rows and keeps the RSA path verbatim (pinned: an empty list is byte-identical to omitting the parameter). - THE BASELINE THAT VALUE FEEDS IS SCOPED TO THE CURRENT DEVICE ERA. Without this the promotion would manufacture a false signal: respRateBpm's 28-day baseline is a single day-keyed series SHARED ACROSS SOURCES (imported values win per day, computed ones fill the rest), so a WHOOP-import day at ~16.1 beside a ring night at ~14.6 is a ~3 sigma illness-ward step against a ~0.52 bpm spread — a strap switch read as physiology. `Baselines.deviceEraEpoch` (ryanbr#459) already solves exactly this for a brand change and was implemented, unit-tested and NEVER WIRED TO ANYTHING; this wires it for the respiration fold. A single-brand history returns epoch 0.0, so every existing WHOOP user folds byte-identically to before. The HRV and resting-HR baselines ryanbr#459 was written for stay unwired — that is ryanbr#459's own scope. Known gap, pre-existing: the bucket is per BRAND, so it does not separate an imported WHOOP vendor rate from NOOP's own RSA estimate on WHOOP nights; those were already pooled and still are. - SHOWN: the day/Deep-Timeline respiration track scales a ring's rows back to breaths/min (~14-16, not ~14,375). `OuraRespScale` is the single place both the display conversion and the scoring refusal live, because `DeviceFamily` has no Oura case by design (ryanbr#1086) and so cannot carry this distinction. `average_hr` from the same record is refused — it would join the beat-derived HR series at a different cadence and a different provenance — as are `breath_v` / `mzci` / `dzci` / `cv` / `sleep_state`, which stay in the investigation log beside the stored row. RSA-from-banked-R-R stays refused separately, and that one IS a night's R-R returns the same 13.3333 bpm. This does not retract the respiratory-rate gate. That work proved RSA is unrecoverable from banked IBI and refusing a fabricated number is right regardless; what 0x6A corrects is that finding's second clause — "the app must use a channel we never receive". We do receive one; we had simply never decoded the tag. Docs: §6.12 rewritten (layout table, the `× 0.5` correction called out as a correction, the four-night verification, the vendor-ceiling measurement and the instrumentation disposition); §9's census row for 0x6a marked SOLVED — its "recurring 0001f8b0 trailer" was never a trailer, it is motion_count=0, sleep_state=1 and the 2-byte cv, recurring because a still sleeper repeats them. No new user-facing strings, so no i18n work. Tests: 7 real-record decode fixtures per platform (same hex both sides), plus new twinned mapping/scale/scoring-exclusion suites and a 9-test vendor-rate suite per platform (median, out-of-session rows ignored, the span gate refusing a dense 36-minute fragment, the plausible-band refusal, analyzeDay preferring the device rate, empty-vendor byte-identity, and the era-scoped fold with a pooled-baseline control showing the same ring night crossing 1 sigma when brands are mixed). WhoopStore 384/0, StrandAnalytics 1357/0, OuraProtocol 191/0, SleepBench 13/0, Android 3837/0. macOS `Strand` and iOS `NOOPiOS` app targets both compiled locally (CI covers neither). NOT yet validated on hardware: the live-source arm anchors and enqueues 0x6A like its banked siblings and advances the drain resume cursor with them (0x6A is banked-only and never live, so it cannot leap the cursor to "now"), which needs a real overnight drain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FxLiKdWF4iegX5NHK24FwR (cherry picked from commit 0e17cbd)
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 14, 2026
…iratory rate NOOP received tag 0x6A and dropped it as unknown. It carries the ring's own per-window sleep summary — an average heart rate and a breath rate. This decodes it on both platforms, persists ONE field of it, `breath`, as a respiration row under the ring's own deviceId, shows it, and makes it the night's `dailyMetric.respRateBpm` on a ring night. Our §6.12 already had the OFFSETS from [ringverse] but no NAMES, so the tag read as four anonymous metrics; it also typed `average_hr` as `int8` with no `× 0.5`, which reads every value above 63.5 bpm as negative. [open_ring]'s `decode_sleep_period_info_2` supplies the names and the `.rodata` multipliers (clean-room fact citation, no code copied): 0 average_hr u8 ×0.5 · 1 hr_trend s8 ×0.0625 · 2 mzci / 3 dzci u8 ×0.0625 4 breath u8 /8.0 · 5 breath_v u8 /8.0 · 6 motion_count · 7 sleep_state 8-9 cv u16 LE /65536 Verified on four consecutive real Gen 3 overnights (2026-08-05→09, 3,493 records): every body exactly 10 bytes, every record satisfies the source's two declared invariants, and every `breath` is an exact multiple of 0.125 — the `/8.0` fixed point confirmed from the data rather than assumed. The `× 0.5` scale is settled by its falsifier: read as `× 1.0` these records sit +56…+62 bpm above every other HR channel we hold, while `× 0.5` medians 53–54 bpm, matching the banked-IBI channel's 54 (ryanbr#511, WHOOP-validated at RHR 55). Both decoders REJECT a body that violates either declared invariant (motion_count < 121, sleep_state ∈ {0,1,2}) rather than returning numbers built from bytes that mean something else; all 3,493 real records pass. `mzci` / `dzci` keep the source's opaque names — we do not know what they measure. Cadence ≈296 s, emitted only during sleep periods. `breath` is computed BY THE RING and read off the wire. It is not a signal NOOP derives from raw sensor data, so the ryanbr#194 rule — written for methods like PPG→HR autocorrelation and RSA-from-R-R, where NOOP invents the number and can manufacture a peak that looks physiological — is not the test. What has to be right here is the DECODE, which the structural verification above establishes. This is the same standing as the ring's own SleepNet hypnogram, which NOOP already persists and scores from (ryanbr#773/ryanbr#877). The strongest cross-check is not against WHOOP at all: these records median 14.75/min against the SAME wearer's 851-night Oura APP export at median 15.250 (IQR 14.875-15.625) — byte 4 checked against Oura's own reported respiratory rate, same quantity, same band, our medians on the low side (~12th pct). That is a distribution check, NOT paired: the export ends 2026-07-07 and these records start 2026-08-05, and a paired test is impossible by construction, since the ring pairs to one app at a time and nothing reaches Oura's cloud while NOOP holds it. Against a WHOOP worn the same nights, the decode reproduces the VENDOR's own offset: over 18 nights of that wearer's history carrying both vendors, Oura's own app reads below WHOOP 18/18 (delta -1.158, sd 0.359) and scores r = +0.680; ours is delta -1.458 sign-stable 6/6, r = +0.599 over 6 paired nights and +0.748 over the 4 well-covered ones. Judging this decode by "does it beat WHOOP" would ask it to beat Oura's own app at reproducing WHOOP — the wrong test for a vendor-computed value, and an impossible one. The date falsifier passed: mapping each night to the wrong date collapses r from +0.591 to -0.151. So the value is stored, shown, and — on a ring night — scored. One limit remains, and it is about SHAPE, not trust: - STORED: `breath` -> `respSample.raw` in MILLI-breaths-per-minute. The wire field is a u8 / 8, so raw == wireByte * 125 exactly for all 256 values — no rounding, and therefore no rounding rule for the two platforms to disagree about. Centi would land on x.5 for half the alphabet, and whole bpm would erase the ~0.30 bpm the whole question is about. - KEPT OUT OF THE SLEEP STAGER — a SHAPE refusal, not a trust one. `respSample` otherwise carries a WHOOP 4.0's raw respiration ADC WAVEFORM, which the stager runs a peak detector over; the ring's rows are a per-window RATE, so feeding them in is wrong however good the rate is (the same reason 0x47 motion is never folded into gravitySample, ryanbr#804). `OuraRespScale.forScoring` drops them at every scoring read (nightly scan, re-stage/self-heal, and the offline SleepBench harness). Today's ~296 s cadence would have produced NaN anyway — the tests pin that staging is bit-identical with and without the rows, plus a control proving a dense stream DOES move staging, so the invariance is not vacuous. Refusing by provenance is what keeps that true if the cadence ever changes. - WRITTEN TO `dailyMetric.respRateBpm` on a ring night, as the MEDIAN of the rows inside a matched in-bed session. On such a night that replaces an RSA-from-banked- R-R estimate which carries no breathing information at all — shuffling or reversing the night returns the same 13.3333 bpm — so this is strictly better, not merely different. Two coverage guards, both about representativeness rather than trust: the in-session rows must SPAN >= 1 h (AnalyticsEngine .vendorRespMinSpanS), and the median must land inside the same 8-25 bpm band the RSA path is clamped to. The span gate is on time, not row count, because the cadence is not constant: the ledger's 36-minute tail holds ~70 rows and is still not a night. A WHOOP night passes no vendor rows and keeps the RSA path verbatim (pinned: an empty list is byte-identical to omitting the parameter). - THE BASELINE THAT VALUE FEEDS IS SCOPED TO THE CURRENT DEVICE ERA. Without this the promotion would manufacture a false signal: respRateBpm's 28-day baseline is a single day-keyed series SHARED ACROSS SOURCES (imported values win per day, computed ones fill the rest), so a WHOOP-import day at ~16.1 beside a ring night at ~14.6 is a ~3 sigma illness-ward step against a ~0.52 bpm spread — a strap switch read as physiology. `Baselines.deviceEraEpoch` (ryanbr#459) already solves exactly this for a brand change and was implemented, unit-tested and NEVER WIRED TO ANYTHING; this wires it for the respiration fold. A single-brand history returns epoch 0.0, so every existing WHOOP user folds byte-identically to before. The HRV and resting-HR baselines ryanbr#459 was written for stay unwired — that is ryanbr#459's own scope. Known gap, pre-existing: the bucket is per BRAND, so it does not separate an imported WHOOP vendor rate from NOOP's own RSA estimate on WHOOP nights; those were already pooled and still are. - SHOWN: the day/Deep-Timeline respiration track scales a ring's rows back to breaths/min (~14-16, not ~14,375). `OuraRespScale` is the single place both the display conversion and the scoring refusal live, because `DeviceFamily` has no Oura case by design (ryanbr#1086) and so cannot carry this distinction. `average_hr` from the same record is refused — it would join the beat-derived HR series at a different cadence and a different provenance — as are `breath_v` / `mzci` / `dzci` / `cv` / `sleep_state`, which stay in the investigation log beside the stored row. RSA-from-banked-R-R stays refused separately, and that one IS a night's R-R returns the same 13.3333 bpm. This does not retract the respiratory-rate gate. That work proved RSA is unrecoverable from banked IBI and refusing a fabricated number is right regardless; what 0x6A corrects is that finding's second clause — "the app must use a channel we never receive". We do receive one; we had simply never decoded the tag. Docs: §6.12 rewritten (layout table, the `× 0.5` correction called out as a correction, the four-night verification, the vendor-ceiling measurement and the instrumentation disposition); §9's census row for 0x6a marked SOLVED — its "recurring 0001f8b0 trailer" was never a trailer, it is motion_count=0, sleep_state=1 and the 2-byte cv, recurring because a still sleeper repeats them. No new user-facing strings, so no i18n work. Tests: 7 real-record decode fixtures per platform (same hex both sides), plus new twinned mapping/scale/scoring-exclusion suites and a 9-test vendor-rate suite per platform (median, out-of-session rows ignored, the span gate refusing a dense 36-minute fragment, the plausible-band refusal, analyzeDay preferring the device rate, empty-vendor byte-identity, and the era-scoped fold with a pooled-baseline control showing the same ring night crossing 1 sigma when brands are mixed). WhoopStore 384/0, StrandAnalytics 1357/0, OuraProtocol 191/0, SleepBench 13/0, Android 3837/0. macOS `Strand` and iOS `NOOPiOS` app targets both compiled locally (CI covers neither). NOT yet validated on hardware: the live-source arm anchors and enqueues 0x6A like its banked siblings and advances the drain resume cursor with them (0x6A is banked-only and never live, so it cannot leap the cursor to "now"), which needs a real overnight drain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FxLiKdWF4iegX5NHK24FwR (cherry picked from commit 0e17cbd)
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 15, 2026
…iratory rate NOOP received tag 0x6A and dropped it as unknown. It carries the ring's own per-window sleep summary — an average heart rate and a breath rate. This decodes it on both platforms, persists ONE field of it, `breath`, as a respiration row under the ring's own deviceId, shows it, and makes it the night's `dailyMetric.respRateBpm` on a ring night. ## The decode Our §6.12 already had the OFFSETS from [ringverse] but no NAMES, so the tag read as four anonymous metrics; it also typed `average_hr` as `int8` with no `× 0.5`, which reads every value above 63.5 bpm as negative. [open_ring]'s `decode_sleep_period_info_2` supplies the names and the `.rodata` multipliers (clean-room fact citation, no code copied): 0 average_hr u8 ×0.5 · 1 hr_trend s8 ×0.0625 · 2 mzci / 3 dzci u8 ×0.0625 4 breath u8 /8.0 · 5 breath_v u8 /8.0 · 6 motion_count · 7 sleep_state 8-9 cv u16 LE /65536 Verified on four consecutive real Gen 3 overnights (2026-08-05→09, 3,493 records): every body exactly 10 bytes, every record satisfies the source's two declared invariants, and every `breath` is an exact multiple of 0.125 — the `/8.0` fixed point confirmed from the data rather than assumed. The `× 0.5` scale is settled by its falsifier: read as `× 1.0` these records sit +56…+62 bpm above every other HR channel we hold, while `× 0.5` medians 53–54 bpm, matching the banked-IBI channel's 54 (ryanbr#511, WHOOP-validated at RHR 55). Both decoders REJECT a body that violates either declared invariant (motion_count < 121, sleep_state ∈ {0,1,2}) rather than returning numbers built from bytes that mean something else; all 3,493 real records pass. `mzci` / `dzci` keep the source's opaque names — we do not know what they measure. Cadence ≈296 s, emitted only during sleep periods. ## Why `breath` is stored — and which bar actually applies `breath` is computed BY THE RING and read off the wire. It is not a signal NOOP derives from raw sensor data, so the ryanbr#194 rule — written for methods like PPG→HR autocorrelation and RSA-from-R-R, where NOOP invents the number and can manufacture a peak that looks physiological — is not the test. What has to be right here is the DECODE, which the structural verification above establishes. This is the same standing as the ring's own SleepNet hypnogram, which NOOP already persists and scores from (ryanbr#773/ryanbr#877). The strongest cross-check is not against WHOOP at all: these records median 14.75/min against the SAME wearer's 851-night Oura APP export at median 15.250 (IQR 14.875-15.625) — byte 4 checked against Oura's own reported respiratory rate, same quantity, same band, our medians on the low side (~12th pct). That is a distribution check, NOT paired: the export ends 2026-07-07 and these records start 2026-08-05, and a paired test is impossible by construction, since the ring pairs to one app at a time and nothing reaches Oura's cloud while NOOP holds it. Against a WHOOP worn the same nights, the decode reproduces the VENDOR's own offset: over 18 nights of that wearer's history carrying both vendors, Oura's own app reads below WHOOP 18/18 (delta -1.158, sd 0.359) and scores r = +0.680; ours is delta -1.458 sign-stable 6/6, r = +0.599 over 6 paired nights and +0.748 over the 4 well-covered ones. Judging this decode by "does it beat WHOOP" would ask it to beat Oura's own app at reproducing WHOOP — the wrong test for a vendor-computed value, and an impossible one. The date falsifier passed: mapping each night to the wrong date collapses r from +0.591 to -0.151. So the value is stored, shown, and — on a ring night — scored. One limit remains, and it is about SHAPE, not trust: - STORED: `breath` -> `respSample.raw` in MILLI-breaths-per-minute. The wire field is a u8 / 8, so raw == wireByte * 125 exactly for all 256 values — no rounding, and therefore no rounding rule for the two platforms to disagree about. Centi would land on x.5 for half the alphabet, and whole bpm would erase the ~0.30 bpm the whole question is about. - KEPT OUT OF THE SLEEP STAGER — a SHAPE refusal, not a trust one. `respSample` otherwise carries a WHOOP 4.0's raw respiration ADC WAVEFORM, which the stager runs a peak detector over; the ring's rows are a per-window RATE, so feeding them in is wrong however good the rate is (the same reason 0x47 motion is never folded into gravitySample, ryanbr#804). `OuraRespScale.forScoring` drops them at every scoring read (nightly scan, re-stage/self-heal, and the offline SleepBench harness). Today's ~296 s cadence would have produced NaN anyway — the tests pin that staging is bit-identical with and without the rows, plus a control proving a dense stream DOES move staging, so the invariance is not vacuous. Refusing by provenance is what keeps that true if the cadence ever changes. - WRITTEN TO `dailyMetric.respRateBpm` on a ring night, as the MEDIAN of the rows inside a matched in-bed session. On such a night that replaces an RSA-from-banked- R-R estimate which carries no breathing information at all — shuffling or reversing the night returns the same 13.3333 bpm — so this is strictly better, not merely different. Two coverage guards, both about representativeness rather than trust: the in-session rows must SPAN >= 1 h (AnalyticsEngine .vendorRespMinSpanS), and the median must land inside the same 8-25 bpm band the RSA path is clamped to. The span gate is on time, not row count, because the cadence is not constant: the ledger's 36-minute tail holds ~70 rows and is still not a night. A WHOOP night passes no vendor rows and keeps the RSA path verbatim (pinned: an empty list is byte-identical to omitting the parameter). - THE BASELINE THAT VALUE FEEDS IS SCOPED TO THE CURRENT DEVICE ERA. Without this the promotion would manufacture a false signal: respRateBpm's 28-day baseline is a single day-keyed series SHARED ACROSS SOURCES (imported values win per day, computed ones fill the rest), so a WHOOP-import day at ~16.1 beside a ring night at ~14.6 is a ~3 sigma illness-ward step against a ~0.52 bpm spread — a strap switch read as physiology. `Baselines.deviceEraEpoch` (ryanbr#459) already solves exactly this for a brand change and was implemented, unit-tested and NEVER WIRED TO ANYTHING; this wires it for the respiration fold. A single-brand history returns epoch 0.0, so every existing WHOOP user folds byte-identically to before. The HRV and resting-HR baselines ryanbr#459 was written for stay unwired — that is ryanbr#459's own scope. Known gap, pre-existing: the bucket is per BRAND, so it does not separate an imported WHOOP vendor rate from NOOP's own RSA estimate on WHOOP nights; those were already pooled and still are. - SHOWN: the day/Deep-Timeline respiration track scales a ring's rows back to breaths/min (~14-16, not ~14,375). `OuraRespScale` is the single place both the display conversion and the scoring refusal live, because `DeviceFamily` has no Oura case by design (ryanbr#1086) and so cannot carry this distinction. `average_hr` from the same record is refused — it would join the beat-derived HR series at a different cadence and a different provenance — as are `breath_v` / `mzci` / `dzci` / `cv` / `sleep_state`, which stay in the investigation log beside the stored row. RSA-from-banked-R-R stays refused separately, and that one IS a ryanbr#194 failure in the proper sense: NOOP computes it, and shuffling or reversing the night's R-R returns the same 13.3333 bpm. This does not retract the respiratory-rate gate. That work proved RSA is unrecoverable from banked IBI and refusing a fabricated number is right regardless; what 0x6A corrects is that finding's second clause — "the app must use a channel we never receive". We do receive one; we had simply never decoded the tag. Docs: §6.12 rewritten (layout table, the `× 0.5` correction called out as a correction, the four-night verification, the vendor-ceiling measurement and the instrumentation disposition); §9's census row for 0x6a marked SOLVED — its "recurring 0001f8b0 trailer" was never a trailer, it is motion_count=0, sleep_state=1 and the 2-byte cv, recurring because a still sleeper repeats them. No new user-facing strings, so no i18n work. Tests: 7 real-record decode fixtures per platform (same hex both sides), plus new twinned mapping/scale/scoring-exclusion suites and a 9-test vendor-rate suite per platform (median, out-of-session rows ignored, the span gate refusing a dense 36-minute fragment, the plausible-band refusal, analyzeDay preferring the device rate, empty-vendor byte-identity, and the era-scoped fold with a pooled-baseline control showing the same ring night crossing 1 sigma when brands are mixed). WhoopStore 384/0, StrandAnalytics 1357/0, OuraProtocol 191/0, SleepBench 13/0, Android 3837/0. macOS `Strand` and iOS `NOOPiOS` app targets both compiled locally (CI covers neither). NOT yet validated on hardware: the live-source arm anchors and enqueues 0x6A like its banked siblings and advances the drain resume cursor with them (0x6A is banked-only and never live, so it cannot leap the cursor to "now"), which needs a real overnight drain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FxLiKdWF4iegX5NHK24FwR
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 15, 2026
…iratory rate NOOP received tag 0x6A and dropped it as unknown. It carries the ring's own per-window sleep summary — an average heart rate and a breath rate. This decodes it on both platforms, persists ONE field of it, `breath`, as a respiration row under the ring's own deviceId, shows it, and makes it the night's `dailyMetric.respRateBpm` on a ring night. Our §6.12 already had the OFFSETS from [ringverse] but no NAMES, so the tag read as four anonymous metrics; it also typed `average_hr` as `int8` with no `× 0.5`, which reads every value above 63.5 bpm as negative. [open_ring]'s `decode_sleep_period_info_2` supplies the names and the `.rodata` multipliers (clean-room fact citation, no code copied): 0 average_hr u8 ×0.5 · 1 hr_trend s8 ×0.0625 · 2 mzci / 3 dzci u8 ×0.0625 4 breath u8 /8.0 · 5 breath_v u8 /8.0 · 6 motion_count · 7 sleep_state 8-9 cv u16 LE /65536 Verified on four consecutive real Gen 3 overnights (2026-08-05→09, 3,493 records): every body exactly 10 bytes, every record satisfies the source's two declared invariants, and every `breath` is an exact multiple of 0.125 — the `/8.0` fixed point confirmed from the data rather than assumed. The `× 0.5` scale is settled by its falsifier: read as `× 1.0` these records sit +56…+62 bpm above every other HR channel we hold, while `× 0.5` medians 53–54 bpm, matching the banked-IBI channel's 54 (ryanbr#511, WHOOP-validated at RHR 55). Both decoders REJECT a body that violates either declared invariant (motion_count < 121, sleep_state ∈ {0,1,2}) rather than returning numbers built from bytes that mean something else; all 3,493 real records pass. `mzci` / `dzci` keep the source's opaque names — we do not know what they measure. Cadence ≈296 s, emitted only during sleep periods. `breath` is computed BY THE RING and read off the wire. It is not a signal NOOP derives from raw sensor data, so the ryanbr#194 rule — written for methods like PPG→HR autocorrelation and RSA-from-R-R, where NOOP invents the number and can manufacture a peak that looks physiological — is not the test. What has to be right here is the DECODE, which the structural verification above establishes. This is the same standing as the ring's own SleepNet hypnogram, which NOOP already persists and scores from (ryanbr#773/ryanbr#877). The strongest cross-check is not against WHOOP at all: these records median 14.75/min against the SAME wearer's 851-night Oura APP export at median 15.250 (IQR 14.875-15.625) — byte 4 checked against Oura's own reported respiratory rate, same quantity, same band, our medians on the low side (~12th pct). That is a distribution check, NOT paired: the export ends 2026-07-07 and these records start 2026-08-05, and a paired test is impossible by construction, since the ring pairs to one app at a time and nothing reaches Oura's cloud while NOOP holds it. Against a WHOOP worn the same nights, the decode reproduces the VENDOR's own offset: over 18 nights of that wearer's history carrying both vendors, Oura's own app reads below WHOOP 18/18 (delta -1.158, sd 0.359) and scores r = +0.680; ours is delta -1.458 sign-stable 6/6, r = +0.599 over 6 paired nights and +0.748 over the 4 well-covered ones. Judging this decode by "does it beat WHOOP" would ask it to beat Oura's own app at reproducing WHOOP — the wrong test for a vendor-computed value, and an impossible one. The date falsifier passed: mapping each night to the wrong date collapses r from +0.591 to -0.151. So the value is stored, shown, and — on a ring night — scored. One limit remains, and it is about SHAPE, not trust: - STORED: `breath` -> `respSample.raw` in MILLI-breaths-per-minute. The wire field is a u8 / 8, so raw == wireByte * 125 exactly for all 256 values — no rounding, and therefore no rounding rule for the two platforms to disagree about. Centi would land on x.5 for half the alphabet, and whole bpm would erase the ~0.30 bpm the whole question is about. - KEPT OUT OF THE SLEEP STAGER — a SHAPE refusal, not a trust one. `respSample` otherwise carries a WHOOP 4.0's raw respiration ADC WAVEFORM, which the stager runs a peak detector over; the ring's rows are a per-window RATE, so feeding them in is wrong however good the rate is (the same reason 0x47 motion is never folded into gravitySample, ryanbr#804). `OuraRespScale.forScoring` drops them at every scoring read (nightly scan, re-stage/self-heal, and the offline SleepBench harness). Today's ~296 s cadence would have produced NaN anyway — the tests pin that staging is bit-identical with and without the rows, plus a control proving a dense stream DOES move staging, so the invariance is not vacuous. Refusing by provenance is what keeps that true if the cadence ever changes. - WRITTEN TO `dailyMetric.respRateBpm` on a ring night, as the MEDIAN of the rows inside a matched in-bed session. On such a night that replaces an RSA-from-banked- R-R estimate which carries no breathing information at all — shuffling or reversing the night returns the same 13.3333 bpm — so this is strictly better, not merely different. Two coverage guards, both about representativeness rather than trust: the in-session rows must SPAN >= 1 h (AnalyticsEngine .vendorRespMinSpanS), and the median must land inside the same 8-25 bpm band the RSA path is clamped to. The span gate is on time, not row count, because the cadence is not constant: the ledger's 36-minute tail holds ~70 rows and is still not a night. A WHOOP night passes no vendor rows and keeps the RSA path verbatim (pinned: an empty list is byte-identical to omitting the parameter). - THE BASELINE THAT VALUE FEEDS IS SCOPED TO THE CURRENT DEVICE ERA. Without this the promotion would manufacture a false signal: respRateBpm's 28-day baseline is a single day-keyed series SHARED ACROSS SOURCES (imported values win per day, computed ones fill the rest), so a WHOOP-import day at ~16.1 beside a ring night at ~14.6 is a ~3 sigma illness-ward step against a ~0.52 bpm spread — a strap switch read as physiology. `Baselines.deviceEraEpoch` (ryanbr#459) already solves exactly this for a brand change and was implemented, unit-tested and NEVER WIRED TO ANYTHING; this wires it for the respiration fold. A single-brand history returns epoch 0.0, so every existing WHOOP user folds byte-identically to before. The HRV and resting-HR baselines ryanbr#459 was written for stay unwired — that is ryanbr#459's own scope. Known gap, pre-existing: the bucket is per BRAND, so it does not separate an imported WHOOP vendor rate from NOOP's own RSA estimate on WHOOP nights; those were already pooled and still are. - SHOWN: the day/Deep-Timeline respiration track scales a ring's rows back to breaths/min (~14-16, not ~14,375). `OuraRespScale` is the single place both the display conversion and the scoring refusal live, because `DeviceFamily` has no Oura case by design (ryanbr#1086) and so cannot carry this distinction. `average_hr` from the same record is refused — it would join the beat-derived HR series at a different cadence and a different provenance — as are `breath_v` / `mzci` / `dzci` / `cv` / `sleep_state`, which stay in the investigation log beside the stored row. RSA-from-banked-R-R stays refused separately, and that one IS a night's R-R returns the same 13.3333 bpm. This does not retract the respiratory-rate gate. That work proved RSA is unrecoverable from banked IBI and refusing a fabricated number is right regardless; what 0x6A corrects is that finding's second clause — "the app must use a channel we never receive". We do receive one; we had simply never decoded the tag. Docs: §6.12 rewritten (layout table, the `× 0.5` correction called out as a correction, the four-night verification, the vendor-ceiling measurement and the instrumentation disposition); §9's census row for 0x6a marked SOLVED — its "recurring 0001f8b0 trailer" was never a trailer, it is motion_count=0, sleep_state=1 and the 2-byte cv, recurring because a still sleeper repeats them. No new user-facing strings, so no i18n work. Tests: 7 real-record decode fixtures per platform (same hex both sides), plus new twinned mapping/scale/scoring-exclusion suites and a 9-test vendor-rate suite per platform (median, out-of-session rows ignored, the span gate refusing a dense 36-minute fragment, the plausible-band refusal, analyzeDay preferring the device rate, empty-vendor byte-identity, and the era-scoped fold with a pooled-baseline control showing the same ring night crossing 1 sigma when brands are mixed). WhoopStore 384/0, StrandAnalytics 1357/0, OuraProtocol 191/0, SleepBench 13/0, Android 3837/0. macOS `Strand` and iOS `NOOPiOS` app targets both compiled locally (CI covers neither). NOT yet validated on hardware: the live-source arm anchors and enqueues 0x6A like its banked siblings and advances the drain resume cursor with them (0x6A is banked-only and never live, so it cannot leap the cursor to "now"), which needs a real overnight drain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FxLiKdWF4iegX5NHK24FwR (cherry picked from commit 0e17cbd)
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 15, 2026
…iratory rate NOOP received tag 0x6A and dropped it as unknown. It carries the ring's own per-window sleep summary — an average heart rate and a breath rate. This decodes it on both platforms, persists ONE field of it, `breath`, as a respiration row under the ring's own deviceId, shows it, and makes it the night's `dailyMetric.respRateBpm` on a ring night. Our §6.12 already had the OFFSETS from [ringverse] but no NAMES, so the tag read as four anonymous metrics; it also typed `average_hr` as `int8` with no `× 0.5`, which reads every value above 63.5 bpm as negative. [open_ring]'s `decode_sleep_period_info_2` supplies the names and the `.rodata` multipliers (clean-room fact citation, no code copied): 0 average_hr u8 ×0.5 · 1 hr_trend s8 ×0.0625 · 2 mzci / 3 dzci u8 ×0.0625 4 breath u8 /8.0 · 5 breath_v u8 /8.0 · 6 motion_count · 7 sleep_state 8-9 cv u16 LE /65536 Verified on four consecutive real Gen 3 overnights (2026-08-05→09, 3,493 records): every body exactly 10 bytes, every record satisfies the source's two declared invariants, and every `breath` is an exact multiple of 0.125 — the `/8.0` fixed point confirmed from the data rather than assumed. The `× 0.5` scale is settled by its falsifier: read as `× 1.0` these records sit +56…+62 bpm above every other HR channel we hold, while `× 0.5` medians 53–54 bpm, matching the banked-IBI channel's 54 (ryanbr#511, WHOOP-validated at RHR 55). Both decoders REJECT a body that violates either declared invariant (motion_count < 121, sleep_state ∈ {0,1,2}) rather than returning numbers built from bytes that mean something else; all 3,493 real records pass. `mzci` / `dzci` keep the source's opaque names — we do not know what they measure. Cadence ≈296 s, emitted only during sleep periods. `breath` is computed BY THE RING and read off the wire. It is not a signal NOOP derives from raw sensor data, so the ryanbr#194 rule — written for methods like PPG→HR autocorrelation and RSA-from-R-R, where NOOP invents the number and can manufacture a peak that looks physiological — is not the test. What has to be right here is the DECODE, which the structural verification above establishes. This is the same standing as the ring's own SleepNet hypnogram, which NOOP already persists and scores from (ryanbr#773/ryanbr#877). The strongest cross-check is not against WHOOP at all: these records median 14.75/min against the SAME wearer's 851-night Oura APP export at median 15.250 (IQR 14.875-15.625) — byte 4 checked against Oura's own reported respiratory rate, same quantity, same band, our medians on the low side (~12th pct). That is a distribution check, NOT paired: the export ends 2026-07-07 and these records start 2026-08-05, and a paired test is impossible by construction, since the ring pairs to one app at a time and nothing reaches Oura's cloud while NOOP holds it. Against a WHOOP worn the same nights, the decode reproduces the VENDOR's own offset: over 18 nights of that wearer's history carrying both vendors, Oura's own app reads below WHOOP 18/18 (delta -1.158, sd 0.359) and scores r = +0.680; ours is delta -1.458 sign-stable 6/6, r = +0.599 over 6 paired nights and +0.748 over the 4 well-covered ones. Judging this decode by "does it beat WHOOP" would ask it to beat Oura's own app at reproducing WHOOP — the wrong test for a vendor-computed value, and an impossible one. The date falsifier passed: mapping each night to the wrong date collapses r from +0.591 to -0.151. So the value is stored, shown, and — on a ring night — scored. One limit remains, and it is about SHAPE, not trust: - STORED: `breath` -> `respSample.raw` in MILLI-breaths-per-minute. The wire field is a u8 / 8, so raw == wireByte * 125 exactly for all 256 values — no rounding, and therefore no rounding rule for the two platforms to disagree about. Centi would land on x.5 for half the alphabet, and whole bpm would erase the ~0.30 bpm the whole question is about. - KEPT OUT OF THE SLEEP STAGER — a SHAPE refusal, not a trust one. `respSample` otherwise carries a WHOOP 4.0's raw respiration ADC WAVEFORM, which the stager runs a peak detector over; the ring's rows are a per-window RATE, so feeding them in is wrong however good the rate is (the same reason 0x47 motion is never folded into gravitySample, ryanbr#804). `OuraRespScale.forScoring` drops them at every scoring read (nightly scan, re-stage/self-heal, and the offline SleepBench harness). Today's ~296 s cadence would have produced NaN anyway — the tests pin that staging is bit-identical with and without the rows, plus a control proving a dense stream DOES move staging, so the invariance is not vacuous. Refusing by provenance is what keeps that true if the cadence ever changes. - WRITTEN TO `dailyMetric.respRateBpm` on a ring night, as the MEDIAN of the rows inside a matched in-bed session. On such a night that replaces an RSA-from-banked- R-R estimate which carries no breathing information at all — shuffling or reversing the night returns the same 13.3333 bpm — so this is strictly better, not merely different. Two coverage guards, both about representativeness rather than trust: the in-session rows must SPAN >= 1 h (AnalyticsEngine .vendorRespMinSpanS), and the median must land inside the same 8-25 bpm band the RSA path is clamped to. The span gate is on time, not row count, because the cadence is not constant: the ledger's 36-minute tail holds ~70 rows and is still not a night. A WHOOP night passes no vendor rows and keeps the RSA path verbatim (pinned: an empty list is byte-identical to omitting the parameter). - THE BASELINE THAT VALUE FEEDS IS SCOPED TO THE CURRENT DEVICE ERA. Without this the promotion would manufacture a false signal: respRateBpm's 28-day baseline is a single day-keyed series SHARED ACROSS SOURCES (imported values win per day, computed ones fill the rest), so a WHOOP-import day at ~16.1 beside a ring night at ~14.6 is a ~3 sigma illness-ward step against a ~0.52 bpm spread — a strap switch read as physiology. `Baselines.deviceEraEpoch` (ryanbr#459) already solves exactly this for a brand change and was implemented, unit-tested and NEVER WIRED TO ANYTHING; this wires it for the respiration fold. A single-brand history returns epoch 0.0, so every existing WHOOP user folds byte-identically to before. The HRV and resting-HR baselines ryanbr#459 was written for stay unwired — that is ryanbr#459's own scope. Known gap, pre-existing: the bucket is per BRAND, so it does not separate an imported WHOOP vendor rate from NOOP's own RSA estimate on WHOOP nights; those were already pooled and still are. - SHOWN: the day/Deep-Timeline respiration track scales a ring's rows back to breaths/min (~14-16, not ~14,375). `OuraRespScale` is the single place both the display conversion and the scoring refusal live, because `DeviceFamily` has no Oura case by design (ryanbr#1086) and so cannot carry this distinction. `average_hr` from the same record is refused — it would join the beat-derived HR series at a different cadence and a different provenance — as are `breath_v` / `mzci` / `dzci` / `cv` / `sleep_state`, which stay in the investigation log beside the stored row. RSA-from-banked-R-R stays refused separately, and that one IS a night's R-R returns the same 13.3333 bpm. This does not retract the respiratory-rate gate. That work proved RSA is unrecoverable from banked IBI and refusing a fabricated number is right regardless; what 0x6A corrects is that finding's second clause — "the app must use a channel we never receive". We do receive one; we had simply never decoded the tag. Docs: §6.12 rewritten (layout table, the `× 0.5` correction called out as a correction, the four-night verification, the vendor-ceiling measurement and the instrumentation disposition); §9's census row for 0x6a marked SOLVED — its "recurring 0001f8b0 trailer" was never a trailer, it is motion_count=0, sleep_state=1 and the 2-byte cv, recurring because a still sleeper repeats them. No new user-facing strings, so no i18n work. Tests: 7 real-record decode fixtures per platform (same hex both sides), plus new twinned mapping/scale/scoring-exclusion suites and a 9-test vendor-rate suite per platform (median, out-of-session rows ignored, the span gate refusing a dense 36-minute fragment, the plausible-band refusal, analyzeDay preferring the device rate, empty-vendor byte-identity, and the era-scoped fold with a pooled-baseline control showing the same ring night crossing 1 sigma when brands are mixed). WhoopStore 384/0, StrandAnalytics 1357/0, OuraProtocol 191/0, SleepBench 13/0, Android 3837/0. macOS `Strand` and iOS `NOOPiOS` app targets both compiled locally (CI covers neither). NOT yet validated on hardware: the live-source arm anchors and enqueues 0x6A like its banked siblings and advances the drain resume cursor with them (0x6A is banked-only and never live, so it cannot leap the cursor to "now"), which needs a real overnight drain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FxLiKdWF4iegX5NHK24FwR (cherry picked from commit 0e17cbd)
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 16, 2026
…reath rate NOOP received tag 0x6A and dropped it as unknown. It carries the ring's own per-window sleep summary — an average heart rate and a breath rate. This decodes it on both platforms and persists ONE field of it, `breath`, as a respiration row under the ring's own deviceId, shown on the respiration track. It is INSTRUMENTATION: stored and shown beside the incumbent, and nothing scores from it. Our §6.12 already had the OFFSETS from [ringverse] but no NAMES, so the tag read as four anonymous metrics; it also typed `average_hr` as `int8` with no `× 0.5`, which reads every value above 63.5 bpm as negative. [open_ring]'s `decode_sleep_period_info_2` supplies the names and the `.rodata` multipliers (clean-room fact citation, no code copied): 0 average_hr u8 ×0.5 · 1 hr_trend s8 ×0.0625 · 2 mzci / 3 dzci u8 ×0.0625 4 breath u8 /8.0 · 5 breath_v u8 /8.0 · 6 motion_count · 7 sleep_state 8-9 cv u16 LE /65536 Verified on four consecutive real Gen 3 overnights (2026-08-05→09, 3,493 records): every body exactly 10 bytes, every record satisfies the source's two declared invariants, and every `breath` is an exact multiple of 0.125 — the `/8.0` fixed point confirmed from the data rather than assumed. The `× 0.5` scale is settled by its falsifier: read as `× 1.0` these records sit +56…+62 bpm above every other HR channel we hold, while `× 0.5` medians 53–54 bpm, matching the banked-IBI channel's 54 (ryanbr#511, WHOOP-validated at RHR 55). Both decoders REJECT a body that violates either declared invariant (motion_count < 121, sleep_state ∈ {0,1,2}) rather than returning numbers built from bytes that mean something else; all 3,493 real records pass. `mzci` / `dzci` keep the source's opaque names — we do not know what they measure. Cadence ≈296 s, emitted only during sleep periods. `breath` is computed BY THE RING and read off the wire. It is not a signal NOOP derives from raw sensor data, so what has to be right is the DECODE, which the structural verification above establishes. On decode provenance that is the same standing as the ring's own SleepNet hypnogram, which NOOP already persists (ryanbr#773/ryanbr#877). The strongest cross-check is not against WHOOP at all: these records median 14.75/min against the SAME wearer's 851-night Oura APP export at median 15.250 (IQR 14.875-15.625) — byte 4 checked against Oura's own reported respiratory rate, same quantity, same band, our medians on the low side (~12th pct). That is a distribution check, NOT paired: the export ends 2026-07-07 and these records start 2026-08-05, and a paired test is impossible by construction, since the ring pairs to one app at a time and nothing reaches Oura's cloud while NOOP holds it. Against a WHOOP worn the same nights, the decode reproduces the VENDOR's own offset: over 18 nights of that wearer's history carrying both vendors, Oura's own app reads below WHOOP 18/18 (delta -1.158, sd 0.359) and scores r = +0.680; ours is delta -1.458 sign-stable 6/6, r = +0.599 over 6 paired nights and +0.748 over the 4 well-covered ones. Judging this decode by "does it beat WHOOP" would ask it to beat Oura's own app at reproducing WHOOP — the wrong test for a vendor-computed value, and an impossible one. The date falsifier passed: mapping each night to the wrong date collapses r from +0.591 to -0.151. The same numbers put this channel AT the vendor ceiling rather than past it. That is precisely the position CLAUDE.md's ryanbr#194 rule addresses: land it as instrumentation beside the incumbent, never as the default and never feeding a downstream gate. So `dailyMetric.respRateBpm` is untouched — it is the scored slot (recovery's respiration term, and `IllnessSignalEngine` reads it with `illnessUp: true`), and a ring night's value stays whatever the RSA path returns, which on banked R-R is nothing. Two refusals, enforced at the read rather than by convention: - KEPT OUT OF THE SLEEP STAGER — a SHAPE refusal, and it would hold even for a value nobody doubts. `respSample` otherwise carries a WHOOP 4.0's raw respiration ADC WAVEFORM, which the stager runs a peak detector over; the ring's rows are a per-window RATE, so feeding them in is wrong however good the rate is (the same reason 0x47 motion is never folded into gravitySample, ryanbr#804). `OuraRespScale.forScoring` drops them at every scoring read (nightly scan, re-stage/self-heal, and the offline SleepBench harness). Today's ~296 s cadence would have produced NaN anyway — the tests pin that staging is bit-identical with and without the rows, plus a control proving a dense stream DOES move staging, so the invariance is not vacuous. Refusing by PROVENANCE is what keeps that true if the cadence ever changes. - KEPT OUT OF THE DAILY METRIC. `analyzeDay` has no parameter these rows can arrive through; the pin passes them in verbatim as `resp` — the worst case where a caller forgot `forScoring` — and asserts the day comes back byte-identical to the day with no rows at all, and that the night's respRateBpm is never the rows' median. If a future change wants to score them it needs its own evidence and its own review. It would also have to decide the baseline question, which this change deliberately leaves alone: respRateBpm's 28-day baseline is a single day-keyed series SHARED ACROSS SOURCES, so a WHOOP-import day at ~16.1 beside a ring night at ~14.6 would be a ~3 sigma illness-ward step against a ~0.52 bpm spread — a strap switch read as physiology. `Baselines.deviceEraEpoch` (ryanbr#459) is the primitive for that and remains unwired, as it was. What IS user-visible: the day/Deep-Timeline respiration track scales a ring's rows back to breaths/min (~14-16, not ~14,375). `OuraRespScale` is the single place both the display conversion and the scoring refusal live, because `DeviceFamily` has no Oura case by design (ryanbr#1086) and so cannot carry this distinction. Scale: `breath` -> `respSample.raw` in MILLI-breaths-per-minute. The wire field is a u8 / 8, so raw == wireByte * 125 exactly for all 256 values — no rounding, and therefore no rounding rule for the two platforms to disagree about. Centi would land on x.5 for half the alphabet, and whole bpm would erase the ~0.30 bpm the whole question is about. `average_hr` from the same record is refused — it would join the beat-derived HR series at a different cadence and a different provenance — as are `breath_v` / `mzci` / `dzci` / `cv` / `sleep_state`, which stay in the investigation log beside the stored row. RSA-from-banked-R-R stays refused separately, and that one IS a night's R-R returns the same 13.3333 bpm. This does not retract the respiratory-rate gate. That work proved RSA is unrecoverable from banked IBI and refusing a fabricated number is right regardless; what 0x6A corrects is that finding's second clause — "the app must use a channel we never receive". We do receive one; we had simply never decoded the tag. ## What the incumbent actually is (ryanbr#1367) Worth stating plainly, because it is easy to read this change as putting a decoded vendor number beside a strap-MEASURED one. It is not. ryanbr#1367 settled that the respiratory rate NOOP displays for WHOOP is an on-device estimate on BOTH generations: the shown value is always `SleepStager.respRateFromRR`, an RSA estimate off the R-R stream, with no family branch. 5.0/MG's v18 wire carries no respiratory channel at all, and 4.0's v24 `resp_rate_raw` ADC is stored unconverted and never shown. So both sides of this comparison are estimates, and 0x6A is in fact the first respiratory rate NOOP has ever decoded that the DEVICE computed rather than the app. That cuts in a direction the disposition here deliberately does not follow: it is not an argument for scoring these rows. The reason they are not scored is the measurement, not a belief that the incumbent is better-sourced — r = +0.680 is what Oura's OWN app scores against WHOOP, so no Oura-derived rate can beat it, and the one-app-at-a-time pairing makes a same-night Oura-app comparison impossible by construction rather than merely undone. AT the vendor ceiling is where ryanbr#194 says instrumentation belongs. It does mean one framing in the earlier respiration work should not be carried forward: NOOP's WHOOP respiratory number was never the "read off the strap" reference that a decoded ring value had to justify itself against. Neither number is read off a strap. Docs: §6.12 rewritten (layout table, the `× 0.5` correction called out as a correction, the four-night verification, the vendor-ceiling measurement and the instrumentation disposition); §9's census row for 0x6a marked SOLVED — its "recurring 0001f8b0 trailer" was never a trailer, it is motion_count=0, sleep_state=1 and the 2-byte cv, recurring because a still sleeper repeats them. No new user-facing strings, so no i18n work. HARDWARE-VALIDATED, one overnight (2026-08-15/16, iPhone, Gen 3 ring). The live arm anchors and enqueues 0x6A like its banked siblings and advances the drain resume cursor with them; the drain produced 219 `respSample` rows under `oura-2H3B2405003655`, 2026-08-15 14:54:33 → 2026-08-16 06:16:57 local. Checked against the diagnostics sidecar the same build wrote: 219/219 rows match a sidecar record on BOTH timestamp and value, no row lost and none invented, every stored `raw` an exact multiple of 125, and the DB median equals the sidecar median over the span (14.375 bpm). Anchoring holds — the `utc - ringTs/10` offset spreads 3.8 s across the night's records — so the ~5-min windows land where they were measured rather than at drain-arrival. `dailyMetric.respRateBpm` is NULL for that ring night, which is the point. Tests: 7 real-record decode fixtures per platform (same hex both sides), plus twinned mapping/scale suites and a scoring-exclusion suite per platform (the stager refusal, the staging invariance, the dense-stream control that keeps it from being vacuous, and the analyzeDay pin above). WhoopStore 390/0, StrandAnalytics 1365/0, OuraProtocol 191/0, SleepBench 13/0, Android 3860/0 (5 skipped). macOS `Strand` built + `StrandTests` 1120/0 (1 skipped); iOS `NOOPiOS` built. CI covers neither app target. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U8iqGopT1kYet7kHUM5dkM
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 16, 2026
…iratory rate NOOP received tag 0x6A and dropped it as unknown. It carries the ring's own per-window sleep summary — an average heart rate and a breath rate. This decodes it on both platforms, persists ONE field of it, `breath`, as a respiration row under the ring's own deviceId, shows it, and makes it the night's `dailyMetric.respRateBpm` on a ring night. Our §6.12 already had the OFFSETS from [ringverse] but no NAMES, so the tag read as four anonymous metrics; it also typed `average_hr` as `int8` with no `× 0.5`, which reads every value above 63.5 bpm as negative. [open_ring]'s `decode_sleep_period_info_2` supplies the names and the `.rodata` multipliers (clean-room fact citation, no code copied): 0 average_hr u8 ×0.5 · 1 hr_trend s8 ×0.0625 · 2 mzci / 3 dzci u8 ×0.0625 4 breath u8 /8.0 · 5 breath_v u8 /8.0 · 6 motion_count · 7 sleep_state 8-9 cv u16 LE /65536 Verified on four consecutive real Gen 3 overnights (2026-08-05→09, 3,493 records): every body exactly 10 bytes, every record satisfies the source's two declared invariants, and every `breath` is an exact multiple of 0.125 — the `/8.0` fixed point confirmed from the data rather than assumed. The `× 0.5` scale is settled by its falsifier: read as `× 1.0` these records sit +56…+62 bpm above every other HR channel we hold, while `× 0.5` medians 53–54 bpm, matching the banked-IBI channel's 54 (ryanbr#511, WHOOP-validated at RHR 55). Both decoders REJECT a body that violates either declared invariant (motion_count < 121, sleep_state ∈ {0,1,2}) rather than returning numbers built from bytes that mean something else; all 3,493 real records pass. `mzci` / `dzci` keep the source's opaque names — we do not know what they measure. Cadence ≈296 s, emitted only during sleep periods. `breath` is computed BY THE RING and read off the wire. It is not a signal NOOP derives from raw sensor data, so the ryanbr#194 rule — written for methods like PPG→HR autocorrelation and RSA-from-R-R, where NOOP invents the number and can manufacture a peak that looks physiological — is not the test. What has to be right here is the DECODE, which the structural verification above establishes. This is the same standing as the ring's own SleepNet hypnogram, which NOOP already persists and scores from (ryanbr#773/ryanbr#877). The strongest cross-check is not against WHOOP at all: these records median 14.75/min against the SAME wearer's 851-night Oura APP export at median 15.250 (IQR 14.875-15.625) — byte 4 checked against Oura's own reported respiratory rate, same quantity, same band, our medians on the low side (~12th pct). That is a distribution check, NOT paired: the export ends 2026-07-07 and these records start 2026-08-05, and a paired test is impossible by construction, since the ring pairs to one app at a time and nothing reaches Oura's cloud while NOOP holds it. Against a WHOOP worn the same nights, the decode reproduces the VENDOR's own offset: over 18 nights of that wearer's history carrying both vendors, Oura's own app reads below WHOOP 18/18 (delta -1.158, sd 0.359) and scores r = +0.680; ours is delta -1.458 sign-stable 6/6, r = +0.599 over 6 paired nights and +0.748 over the 4 well-covered ones. Judging this decode by "does it beat WHOOP" would ask it to beat Oura's own app at reproducing WHOOP — the wrong test for a vendor-computed value, and an impossible one. The date falsifier passed: mapping each night to the wrong date collapses r from +0.591 to -0.151. So the value is stored, shown, and — on a ring night — scored. One limit remains, and it is about SHAPE, not trust: - STORED: `breath` -> `respSample.raw` in MILLI-breaths-per-minute. The wire field is a u8 / 8, so raw == wireByte * 125 exactly for all 256 values — no rounding, and therefore no rounding rule for the two platforms to disagree about. Centi would land on x.5 for half the alphabet, and whole bpm would erase the ~0.30 bpm the whole question is about. - KEPT OUT OF THE SLEEP STAGER — a SHAPE refusal, not a trust one. `respSample` otherwise carries a WHOOP 4.0's raw respiration ADC WAVEFORM, which the stager runs a peak detector over; the ring's rows are a per-window RATE, so feeding them in is wrong however good the rate is (the same reason 0x47 motion is never folded into gravitySample, ryanbr#804). `OuraRespScale.forScoring` drops them at every scoring read (nightly scan, re-stage/self-heal, and the offline SleepBench harness). Today's ~296 s cadence would have produced NaN anyway — the tests pin that staging is bit-identical with and without the rows, plus a control proving a dense stream DOES move staging, so the invariance is not vacuous. Refusing by provenance is what keeps that true if the cadence ever changes. - WRITTEN TO `dailyMetric.respRateBpm` on a ring night, as the MEDIAN of the rows inside a matched in-bed session. On such a night that replaces an RSA-from-banked- R-R estimate which carries no breathing information at all — shuffling or reversing the night returns the same 13.3333 bpm — so this is strictly better, not merely different. Two coverage guards, both about representativeness rather than trust: the in-session rows must SPAN >= 1 h (AnalyticsEngine .vendorRespMinSpanS), and the median must land inside the same 8-25 bpm band the RSA path is clamped to. The span gate is on time, not row count, because the cadence is not constant: the ledger's 36-minute tail holds ~70 rows and is still not a night. A WHOOP night passes no vendor rows and keeps the RSA path verbatim (pinned: an empty list is byte-identical to omitting the parameter). - THE BASELINE THAT VALUE FEEDS IS SCOPED TO THE CURRENT DEVICE ERA. Without this the promotion would manufacture a false signal: respRateBpm's 28-day baseline is a single day-keyed series SHARED ACROSS SOURCES (imported values win per day, computed ones fill the rest), so a WHOOP-import day at ~16.1 beside a ring night at ~14.6 is a ~3 sigma illness-ward step against a ~0.52 bpm spread — a strap switch read as physiology. `Baselines.deviceEraEpoch` (ryanbr#459) already solves exactly this for a brand change and was implemented, unit-tested and NEVER WIRED TO ANYTHING; this wires it for the respiration fold. A single-brand history returns epoch 0.0, so every existing WHOOP user folds byte-identically to before. The HRV and resting-HR baselines ryanbr#459 was written for stay unwired — that is ryanbr#459's own scope. Known gap, pre-existing: the bucket is per BRAND, so it does not separate an imported WHOOP vendor rate from NOOP's own RSA estimate on WHOOP nights; those were already pooled and still are. - SHOWN: the day/Deep-Timeline respiration track scales a ring's rows back to breaths/min (~14-16, not ~14,375). `OuraRespScale` is the single place both the display conversion and the scoring refusal live, because `DeviceFamily` has no Oura case by design (ryanbr#1086) and so cannot carry this distinction. `average_hr` from the same record is refused — it would join the beat-derived HR series at a different cadence and a different provenance — as are `breath_v` / `mzci` / `dzci` / `cv` / `sleep_state`, which stay in the investigation log beside the stored row. RSA-from-banked-R-R stays refused separately, and that one IS a night's R-R returns the same 13.3333 bpm. This does not retract the respiratory-rate gate. That work proved RSA is unrecoverable from banked IBI and refusing a fabricated number is right regardless; what 0x6A corrects is that finding's second clause — "the app must use a channel we never receive". We do receive one; we had simply never decoded the tag. Docs: §6.12 rewritten (layout table, the `× 0.5` correction called out as a correction, the four-night verification, the vendor-ceiling measurement and the instrumentation disposition); §9's census row for 0x6a marked SOLVED — its "recurring 0001f8b0 trailer" was never a trailer, it is motion_count=0, sleep_state=1 and the 2-byte cv, recurring because a still sleeper repeats them. No new user-facing strings, so no i18n work. Tests: 7 real-record decode fixtures per platform (same hex both sides), plus new twinned mapping/scale/scoring-exclusion suites and a 9-test vendor-rate suite per platform (median, out-of-session rows ignored, the span gate refusing a dense 36-minute fragment, the plausible-band refusal, analyzeDay preferring the device rate, empty-vendor byte-identity, and the era-scoped fold with a pooled-baseline control showing the same ring night crossing 1 sigma when brands are mixed). WhoopStore 384/0, StrandAnalytics 1357/0, OuraProtocol 191/0, SleepBench 13/0, Android 3837/0. macOS `Strand` and iOS `NOOPiOS` app targets both compiled locally (CI covers neither). NOT yet validated on hardware: the live-source arm anchors and enqueues 0x6A like its banked siblings and advances the drain resume cursor with them (0x6A is banked-only and never live, so it cannot leap the cursor to "now"), which needs a real overnight drain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FxLiKdWF4iegX5NHK24FwR (cherry picked from commit 0e17cbd)
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
…tics (ryanbr#332) * Surface a banner when the strap clock is set in the future (ryanbr#324) A WHOOP 4.0 whose RTC relatched to a future base banks history dated years ahead. The ryanbr#928/ryanbr#1012/ryanbr#773 guards correctly refuse to misfile it, but because the strap DOES bank records the existing 'clock lost / not banking' banner (gated on empty syncs) never fires - so the sync reported clean while importing nothing, with no reason shown (ryanbr#324). Add a pure futureDatedStrapBanner (Swift BLEManager + Kotlin WhoopBleClient, byte-identical string) driven by the existing isFutureDatedNewest predicate, wired into the HISTORY_COMPLETE handler after the banked-nothing branch. One detection is decisive (nothing legitimate banks 48h ahead), so no streak gate. Names the strap-side remedy (full charge + power-cycle to relatch the clock). Twin tests pin the string + the 48h threshold on both platforms. * Fix ryanbr#324 banner: cover the timeout outcome, not just HISTORY_COMPLETE Re-review caught the real gap: a future-dated strap with a deep backlog TIMES OUT before HISTORY_COMPLETE (the reporter's own session ended on timeout). The first cut gated the banner on HISTORY_COMPLETE only, so the reported case would still show the misleading 'strap went quiet' message and the new banner would never fire. Hoist the future-clock banner (computed once from strapNewestTs, which is already populated by the connect-time GET_DATA_RANGE) and apply it to BOTH the HISTORY_COMPLETE branch and the WHOOP-4 timeout branch, on both platforms. The log is now outcome-independent too. * Fix ryanbr#324 banner parity: bankedNothing precedence + single wall-clock read Re-review found a Swift/Kotlin divergence I introduced: replacing Kotlin's 'else null' with 'else futureClockBanner' meant a strap that banked nothing this pass but isn't yet sustained-empty AND is future-dated showed the future banner on Android while Swift (bankedNothing branch wins) showed nil. Restructure the Kotlin lastSyncError to a when{} that matches Swift's else-if precedence exactly: bankedNothing keeps its sustained-empty logic (ryanbr#126/ryanbr#214), future-dated is checked only on the banked-something path. Also hoist the Swift wall-clock read to one value (parity with Kotlin's single nowSec; the banner and its log can't drift across a second boundary). * Add bad-clock strap diagnostics for ryanbr#324 (dev logs 1 + 2) Two log-only additions to make a future-dated strap's cause legible in the strap-log export, extending the existing ryanbr#547 dropped-record seam: 1. RTC-state events (RTC_LOST / BOOT / SET_RTC) that the ryanbr#547 plausibility gate drops for their own implausible timestamp are now CAPTURED before being discarded (Streams.droppedRtcEvents). This is the catch-22 fix: the one event that explains the reset was silently dropped because ITS ts is bad. The Backfiller logs each with the bad epoch the RTC jumped to. 2. The epoch SPAN of the dropped records (oldest -> newest own-timestamp + hours-ahead skew) is captured and logged, so we can tell a whole-range- future strap (safe to fast-forward-discard, the ryanbr#324 follow-up B) from one mixed with real data. Pure capture in WhoopProtocol.extractHistoricalStreams + a pure, testable BadClockDiagnostics formatter (byte-identical Swift/Kotlin), both twinned. No behavior change: the gate still drops exactly what it dropped before. WhoopProtocol 271 swift test + Android compile/tests green.
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
… labelled "Oura" (ryanbr#773) * feat(oura): persist the ring's SleepNet hypnogram as a stage-timeline sleep session, shown as "Oura" Decode the ring's whole-night SleepNet phase-code burst, reconstruct its 30 s/code time axis, anchor+refine it with the 0x49 sleep-summary window (BOTH ends), and upsert the night as a CachedSleepSession under the ring's own deviceId — so SleepMerge's imported-over-computed rule surfaces Oura's own staging instead of a computed guess. The sleep surfaces then label that night honestly as "Oura" with a caveat that its stages are the ring's raw on-device classification. Persist (both platforms, byte-identical Swift/Kotlin twins): - OuraProtocol: HypnogramAssembler time-axis reconstruction with the 0x49 window applied at BOTH ends — the end anchored to the sleep-end, and the START clamped to the sleep-onset so the burst's pre-window epochs are clipped (the clip lives in the PURE codesWithTimes(…, sleepStartUnixSeconds:) with a never-empty guard, so a mis-paired 0x49 can never erase the night); 0x49 pairing in OuraHistoryDrain; OuraEvents/Decoders/Framing/OuraDriver additions. - WhoopStore: OuraSleepSessionMapping (anchored per-code hypnogram → stage breakdown), OuraStreamMapping sleepPhase pass-through. - App: OuraLiveSource assembles the burst, anchors + 0x49-refines both ends, calls persistSleepSession; SourceCoordinator wires the persist closure. Provenance UI (both platforms): a persisted Oura night is the ring's OWN SleepNet output, not a NOOP computation, so the surfaces say so instead of the generic "On-device": - DeviceBrandCatalog.isOura(deviceId) — ONE canonical resolver (brand table, no "oura" literal); Swift Repository.activeDeviceIsOura + Kotlin SleepScreen call it. - SleepView / SleepScreen: hero + main-sleep badges read "Oura"; the stage caption reads "raw on-device stages"; a caveat note states the split is the ring's RAW BLE classification (more Awake / less Deep+REM) — NOT the Oura app's cloud-adjusted stages. daySourceBadge gains the Oura brand so the ring-id session (and By-Day) label correctly. Design tokens only; WHOOP/Apple imports still win the provenance order above Oura. Tier discipline: the persisted session is a ring-PROVIDED night, not a NOOP-computed score. Supersedes DRAFT ryanbr#446; docs/OURA_PROTOCOL.md updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(oura): coverage for the SleepNet persist, "Oura" provenance, and 0x49 start-clamp Unit tests for the feature above, on both platforms (byte-identical Swift/Kotlin fixtures): - HypnogramAssembler: the 0x49 start-clamp in the pure codesWithTimes — drops codes before the onset, inclusive boundary, an onset before the burst keeps all, and the never-empty fallback for a mis-paired 0x49; plus the existing backward-layout cases. - OuraSleepSessionMapping: anchored per-code hypnogram → CachedSleepSession stage breakdown. - OuraSleepWindowPairing: closest-0x49-window selection (overnight vs nap, tolerance, empties). - DeviceBrandCatalog.isOura: registry-id → brand resolution (Oura true; WHOOP/computed/import false). - OuraStreamMapping / Decoder golden / Framing / Driver / HistoryDrain: the twin decode + mapping tests. swift test OuraProtocol + WhoopStore green; the Kotlin twins mirror them (their run is gated only by the unrelated ryanbr#716 test-fake compile gap / PR ryanbr#725). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * i18n(oura): localize the Oura raw-stages caveat (Apple) + baseline it (Android) The SleepNet provenance caveat added with the Oura raw-stages note was new UI copy the i18n gate (`Tools/i18n_audit.py --ci`) flagged: on Apple the two literals were absent from their String Catalog; on Android they were new hardcoded literals not in the baseline. - Apple: add both strings — the `Text` caveat and its `accessibilityLabel` — to Strand/Resources/Localizable.xcstrings with translations for all 8 focus locales (de, es, fr, it, pt-PT, ru, zh-Hans, zh-Hant), state `translated`, mirroring the sibling "Low confidence staging" note's terminology (deep/REM, efficiency, stages, "anneau/Ring/anillo/…"). - Android: `SleepScreen.kt` is fully hardcoded (0 stringResource, 6 entries already baselined) and has no low-confidence twin, so the two literals join the accepted backlog via `--update-baseline` — the diff is exactly the two SleepScreen.kt entries, nothing else. (Localizing the Android sleep screen's copy is a separate backlog task; this only closes the NEW-literal regression.) `python3 Tools/i18n_audit.py --ci origin/main` now exits 0 (Android: no new hardcoded literals + focus locales complete; Apple: no new un-extracted literals + all catalogs complete). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
simoncad7
pushed a commit
to simoncad7/noop
that referenced
this pull request
Aug 17, 2026
…to the scorer (ryanbr#804 Fix A) (ryanbr#1183) An Oura-only night decoded and persisted a full SleepNet hypnogram (ryanbr#773) and showed on the Sleep timeline, yet the nightly summary reported `totalSleepMin=nil eff=nil avgHrv=nil source=computed`. Root cause: the scorer (`AnalyticsEngine.analyzeDay`) stages sleep from the motion/gravity vector, and a ring streams NO gravity — so `detectSleep` returned nothing, the sleep window was empty, and avgHrv (computed only over that window) stayed nil despite tens of thousands of R-R intervals present. The ring's persisted hypnogram never reached `analyzeDay`; it only fed the display-path `SleepMerge`. Fix A (general, not Oura-hardcoded): `analyzeDay` gains an optional `providedSleep` of already-staged sessions. When the day owner sends no usable gravity vector (`grav.count < 2` — a ring; a WHOOP always streams gravity) and is not the canonical WHOOP import namespace, IntelligenceEngine reads the owner's persisted hypnogram (its own device namespace) and hands it in. Provided sessions bypass detectSleep + wake refinement; where they overlap a detected session the provided one wins (authoritative device staging), and a non-overlapping detected nap survives. Each provided session's nightly restingHR/avgHRV is re-derived from that day's hr/rr over its window via the SAME helpers detectSleep uses, so RHR/HRV light up even though the stored ring row carried neither. Empty `providedSleep` (every WHOOP / pure-function caller) is the byte-identical motion-only path. Both platforms, byte-identical: `sleepSession(fromProvided:)` / `decodeStages` reconstruct the pure session from the persisted `CachedSleepSession` (a minute-dict import row decodes to nothing and is skipped, so only real stage timelines inject). Tests (no strap): a provided hypnogram over a gravity-free night now yields totalSleepMin / deep / rem / efficiency AND a derived avgHrv + restingHr; empty providedSleep is asserted byte-identical to omitting it (and still nil without gravity — the pre-fix ryanbr#804 state). Swift StrandAnalytics 1173/0; Kotlin AnalyticsEngineProvidedSleepTest green; macOS app builds. Claude-Session: https://claude.ai/code/session_01AtXcBU1t6Xk1qJhaQEeDx6 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 18, 2026
…iratory rate NOOP received tag 0x6A and dropped it as unknown. It carries the ring's own per-window sleep summary — an average heart rate and a breath rate. This decodes it on both platforms, persists ONE field of it, `breath`, as a respiration row under the ring's own deviceId, shows it, and makes it the night's `dailyMetric.respRateBpm` on a ring night. Our §6.12 already had the OFFSETS from [ringverse] but no NAMES, so the tag read as four anonymous metrics; it also typed `average_hr` as `int8` with no `× 0.5`, which reads every value above 63.5 bpm as negative. [open_ring]'s `decode_sleep_period_info_2` supplies the names and the `.rodata` multipliers (clean-room fact citation, no code copied): 0 average_hr u8 ×0.5 · 1 hr_trend s8 ×0.0625 · 2 mzci / 3 dzci u8 ×0.0625 4 breath u8 /8.0 · 5 breath_v u8 /8.0 · 6 motion_count · 7 sleep_state 8-9 cv u16 LE /65536 Verified on four consecutive real Gen 3 overnights (2026-08-05→09, 3,493 records): every body exactly 10 bytes, every record satisfies the source's two declared invariants, and every `breath` is an exact multiple of 0.125 — the `/8.0` fixed point confirmed from the data rather than assumed. The `× 0.5` scale is settled by its falsifier: read as `× 1.0` these records sit +56…+62 bpm above every other HR channel we hold, while `× 0.5` medians 53–54 bpm, matching the banked-IBI channel's 54 (ryanbr#511, WHOOP-validated at RHR 55). Both decoders REJECT a body that violates either declared invariant (motion_count < 121, sleep_state ∈ {0,1,2}) rather than returning numbers built from bytes that mean something else; all 3,493 real records pass. `mzci` / `dzci` keep the source's opaque names — we do not know what they measure. Cadence ≈296 s, emitted only during sleep periods. `breath` is computed BY THE RING and read off the wire. It is not a signal NOOP derives from raw sensor data, so the ryanbr#194 rule — written for methods like PPG→HR autocorrelation and RSA-from-R-R, where NOOP invents the number and can manufacture a peak that looks physiological — is not the test. What has to be right here is the DECODE, which the structural verification above establishes. This is the same standing as the ring's own SleepNet hypnogram, which NOOP already persists and scores from (ryanbr#773/ryanbr#877). The strongest cross-check is not against WHOOP at all: these records median 14.75/min against the SAME wearer's 851-night Oura APP export at median 15.250 (IQR 14.875-15.625) — byte 4 checked against Oura's own reported respiratory rate, same quantity, same band, our medians on the low side (~12th pct). That is a distribution check, NOT paired: the export ends 2026-07-07 and these records start 2026-08-05, and a paired test is impossible by construction, since the ring pairs to one app at a time and nothing reaches Oura's cloud while NOOP holds it. Against a WHOOP worn the same nights, the decode reproduces the VENDOR's own offset: over 18 nights of that wearer's history carrying both vendors, Oura's own app reads below WHOOP 18/18 (delta -1.158, sd 0.359) and scores r = +0.680; ours is delta -1.458 sign-stable 6/6, r = +0.599 over 6 paired nights and +0.748 over the 4 well-covered ones. Judging this decode by "does it beat WHOOP" would ask it to beat Oura's own app at reproducing WHOOP — the wrong test for a vendor-computed value, and an impossible one. The date falsifier passed: mapping each night to the wrong date collapses r from +0.591 to -0.151. So the value is stored, shown, and — on a ring night — scored. One limit remains, and it is about SHAPE, not trust: - STORED: `breath` -> `respSample.raw` in MILLI-breaths-per-minute. The wire field is a u8 / 8, so raw == wireByte * 125 exactly for all 256 values — no rounding, and therefore no rounding rule for the two platforms to disagree about. Centi would land on x.5 for half the alphabet, and whole bpm would erase the ~0.30 bpm the whole question is about. - KEPT OUT OF THE SLEEP STAGER — a SHAPE refusal, not a trust one. `respSample` otherwise carries a WHOOP 4.0's raw respiration ADC WAVEFORM, which the stager runs a peak detector over; the ring's rows are a per-window RATE, so feeding them in is wrong however good the rate is (the same reason 0x47 motion is never folded into gravitySample, ryanbr#804). `OuraRespScale.forScoring` drops them at every scoring read (nightly scan, re-stage/self-heal, and the offline SleepBench harness). Today's ~296 s cadence would have produced NaN anyway — the tests pin that staging is bit-identical with and without the rows, plus a control proving a dense stream DOES move staging, so the invariance is not vacuous. Refusing by provenance is what keeps that true if the cadence ever changes. - WRITTEN TO `dailyMetric.respRateBpm` on a ring night, as the MEDIAN of the rows inside a matched in-bed session. On such a night that replaces an RSA-from-banked- R-R estimate which carries no breathing information at all — shuffling or reversing the night returns the same 13.3333 bpm — so this is strictly better, not merely different. Two coverage guards, both about representativeness rather than trust: the in-session rows must SPAN >= 1 h (AnalyticsEngine .vendorRespMinSpanS), and the median must land inside the same 8-25 bpm band the RSA path is clamped to. The span gate is on time, not row count, because the cadence is not constant: the ledger's 36-minute tail holds ~70 rows and is still not a night. A WHOOP night passes no vendor rows and keeps the RSA path verbatim (pinned: an empty list is byte-identical to omitting the parameter). - THE BASELINE THAT VALUE FEEDS IS SCOPED TO THE CURRENT DEVICE ERA. Without this the promotion would manufacture a false signal: respRateBpm's 28-day baseline is a single day-keyed series SHARED ACROSS SOURCES (imported values win per day, computed ones fill the rest), so a WHOOP-import day at ~16.1 beside a ring night at ~14.6 is a ~3 sigma illness-ward step against a ~0.52 bpm spread — a strap switch read as physiology. `Baselines.deviceEraEpoch` (ryanbr#459) already solves exactly this for a brand change and was implemented, unit-tested and NEVER WIRED TO ANYTHING; this wires it for the respiration fold. A single-brand history returns epoch 0.0, so every existing WHOOP user folds byte-identically to before. The HRV and resting-HR baselines ryanbr#459 was written for stay unwired — that is ryanbr#459's own scope. Known gap, pre-existing: the bucket is per BRAND, so it does not separate an imported WHOOP vendor rate from NOOP's own RSA estimate on WHOOP nights; those were already pooled and still are. - SHOWN: the day/Deep-Timeline respiration track scales a ring's rows back to breaths/min (~14-16, not ~14,375). `OuraRespScale` is the single place both the display conversion and the scoring refusal live, because `DeviceFamily` has no Oura case by design (ryanbr#1086) and so cannot carry this distinction. `average_hr` from the same record is refused — it would join the beat-derived HR series at a different cadence and a different provenance — as are `breath_v` / `mzci` / `dzci` / `cv` / `sleep_state`, which stay in the investigation log beside the stored row. RSA-from-banked-R-R stays refused separately, and that one IS a night's R-R returns the same 13.3333 bpm. This does not retract the respiratory-rate gate. That work proved RSA is unrecoverable from banked IBI and refusing a fabricated number is right regardless; what 0x6A corrects is that finding's second clause — "the app must use a channel we never receive". We do receive one; we had simply never decoded the tag. Docs: §6.12 rewritten (layout table, the `× 0.5` correction called out as a correction, the four-night verification, the vendor-ceiling measurement and the instrumentation disposition); §9's census row for 0x6a marked SOLVED — its "recurring 0001f8b0 trailer" was never a trailer, it is motion_count=0, sleep_state=1 and the 2-byte cv, recurring because a still sleeper repeats them. No new user-facing strings, so no i18n work. Tests: 7 real-record decode fixtures per platform (same hex both sides), plus new twinned mapping/scale/scoring-exclusion suites and a 9-test vendor-rate suite per platform (median, out-of-session rows ignored, the span gate refusing a dense 36-minute fragment, the plausible-band refusal, analyzeDay preferring the device rate, empty-vendor byte-identity, and the era-scoped fold with a pooled-baseline control showing the same ring night crossing 1 sigma when brands are mixed). WhoopStore 384/0, StrandAnalytics 1357/0, OuraProtocol 191/0, SleepBench 13/0, Android 3837/0. macOS `Strand` and iOS `NOOPiOS` app targets both compiled locally (CI covers neither). NOT yet validated on hardware: the live-source arm anchors and enqueues 0x6A like its banked siblings and advances the drain resume cursor with them (0x6A is banked-only and never live, so it cannot leap the cursor to "now"), which needs a real overnight drain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FxLiKdWF4iegX5NHK24FwR (cherry picked from commit 0e17cbd)
pipiche38
added a commit
to pipiche38/noop
that referenced
this pull request
Aug 19, 2026
…iratory rate NOOP received tag 0x6A and dropped it as unknown. It carries the ring's own per-window sleep summary — an average heart rate and a breath rate. This decodes it on both platforms, persists ONE field of it, `breath`, as a respiration row under the ring's own deviceId, shows it, and makes it the night's `dailyMetric.respRateBpm` on a ring night. Our §6.12 already had the OFFSETS from [ringverse] but no NAMES, so the tag read as four anonymous metrics; it also typed `average_hr` as `int8` with no `× 0.5`, which reads every value above 63.5 bpm as negative. [open_ring]'s `decode_sleep_period_info_2` supplies the names and the `.rodata` multipliers (clean-room fact citation, no code copied): 0 average_hr u8 ×0.5 · 1 hr_trend s8 ×0.0625 · 2 mzci / 3 dzci u8 ×0.0625 4 breath u8 /8.0 · 5 breath_v u8 /8.0 · 6 motion_count · 7 sleep_state 8-9 cv u16 LE /65536 Verified on four consecutive real Gen 3 overnights (2026-08-05→09, 3,493 records): every body exactly 10 bytes, every record satisfies the source's two declared invariants, and every `breath` is an exact multiple of 0.125 — the `/8.0` fixed point confirmed from the data rather than assumed. The `× 0.5` scale is settled by its falsifier: read as `× 1.0` these records sit +56…+62 bpm above every other HR channel we hold, while `× 0.5` medians 53–54 bpm, matching the banked-IBI channel's 54 (ryanbr#511, WHOOP-validated at RHR 55). Both decoders REJECT a body that violates either declared invariant (motion_count < 121, sleep_state ∈ {0,1,2}) rather than returning numbers built from bytes that mean something else; all 3,493 real records pass. `mzci` / `dzci` keep the source's opaque names — we do not know what they measure. Cadence ≈296 s, emitted only during sleep periods. `breath` is computed BY THE RING and read off the wire. It is not a signal NOOP derives from raw sensor data, so the ryanbr#194 rule — written for methods like PPG→HR autocorrelation and RSA-from-R-R, where NOOP invents the number and can manufacture a peak that looks physiological — is not the test. What has to be right here is the DECODE, which the structural verification above establishes. This is the same standing as the ring's own SleepNet hypnogram, which NOOP already persists and scores from (ryanbr#773/ryanbr#877). The strongest cross-check is not against WHOOP at all: these records median 14.75/min against the SAME wearer's 851-night Oura APP export at median 15.250 (IQR 14.875-15.625) — byte 4 checked against Oura's own reported respiratory rate, same quantity, same band, our medians on the low side (~12th pct). That is a distribution check, NOT paired: the export ends 2026-07-07 and these records start 2026-08-05, and a paired test is impossible by construction, since the ring pairs to one app at a time and nothing reaches Oura's cloud while NOOP holds it. Against a WHOOP worn the same nights, the decode reproduces the VENDOR's own offset: over 18 nights of that wearer's history carrying both vendors, Oura's own app reads below WHOOP 18/18 (delta -1.158, sd 0.359) and scores r = +0.680; ours is delta -1.458 sign-stable 6/6, r = +0.599 over 6 paired nights and +0.748 over the 4 well-covered ones. Judging this decode by "does it beat WHOOP" would ask it to beat Oura's own app at reproducing WHOOP — the wrong test for a vendor-computed value, and an impossible one. The date falsifier passed: mapping each night to the wrong date collapses r from +0.591 to -0.151. So the value is stored, shown, and — on a ring night — scored. One limit remains, and it is about SHAPE, not trust: - STORED: `breath` -> `respSample.raw` in MILLI-breaths-per-minute. The wire field is a u8 / 8, so raw == wireByte * 125 exactly for all 256 values — no rounding, and therefore no rounding rule for the two platforms to disagree about. Centi would land on x.5 for half the alphabet, and whole bpm would erase the ~0.30 bpm the whole question is about. - KEPT OUT OF THE SLEEP STAGER — a SHAPE refusal, not a trust one. `respSample` otherwise carries a WHOOP 4.0's raw respiration ADC WAVEFORM, which the stager runs a peak detector over; the ring's rows are a per-window RATE, so feeding them in is wrong however good the rate is (the same reason 0x47 motion is never folded into gravitySample, ryanbr#804). `OuraRespScale.forScoring` drops them at every scoring read (nightly scan, re-stage/self-heal, and the offline SleepBench harness). Today's ~296 s cadence would have produced NaN anyway — the tests pin that staging is bit-identical with and without the rows, plus a control proving a dense stream DOES move staging, so the invariance is not vacuous. Refusing by provenance is what keeps that true if the cadence ever changes. - WRITTEN TO `dailyMetric.respRateBpm` on a ring night, as the MEDIAN of the rows inside a matched in-bed session. On such a night that replaces an RSA-from-banked- R-R estimate which carries no breathing information at all — shuffling or reversing the night returns the same 13.3333 bpm — so this is strictly better, not merely different. Two coverage guards, both about representativeness rather than trust: the in-session rows must SPAN >= 1 h (AnalyticsEngine .vendorRespMinSpanS), and the median must land inside the same 8-25 bpm band the RSA path is clamped to. The span gate is on time, not row count, because the cadence is not constant: the ledger's 36-minute tail holds ~70 rows and is still not a night. A WHOOP night passes no vendor rows and keeps the RSA path verbatim (pinned: an empty list is byte-identical to omitting the parameter). - THE BASELINE THAT VALUE FEEDS IS SCOPED TO THE CURRENT DEVICE ERA. Without this the promotion would manufacture a false signal: respRateBpm's 28-day baseline is a single day-keyed series SHARED ACROSS SOURCES (imported values win per day, computed ones fill the rest), so a WHOOP-import day at ~16.1 beside a ring night at ~14.6 is a ~3 sigma illness-ward step against a ~0.52 bpm spread — a strap switch read as physiology. `Baselines.deviceEraEpoch` (ryanbr#459) already solves exactly this for a brand change and was implemented, unit-tested and NEVER WIRED TO ANYTHING; this wires it for the respiration fold. A single-brand history returns epoch 0.0, so every existing WHOOP user folds byte-identically to before. The HRV and resting-HR baselines ryanbr#459 was written for stay unwired — that is ryanbr#459's own scope. Known gap, pre-existing: the bucket is per BRAND, so it does not separate an imported WHOOP vendor rate from NOOP's own RSA estimate on WHOOP nights; those were already pooled and still are. - SHOWN: the day/Deep-Timeline respiration track scales a ring's rows back to breaths/min (~14-16, not ~14,375). `OuraRespScale` is the single place both the display conversion and the scoring refusal live, because `DeviceFamily` has no Oura case by design (ryanbr#1086) and so cannot carry this distinction. `average_hr` from the same record is refused — it would join the beat-derived HR series at a different cadence and a different provenance — as are `breath_v` / `mzci` / `dzci` / `cv` / `sleep_state`, which stay in the investigation log beside the stored row. RSA-from-banked-R-R stays refused separately, and that one IS a night's R-R returns the same 13.3333 bpm. This does not retract the respiratory-rate gate. That work proved RSA is unrecoverable from banked IBI and refusing a fabricated number is right regardless; what 0x6A corrects is that finding's second clause — "the app must use a channel we never receive". We do receive one; we had simply never decoded the tag. Docs: §6.12 rewritten (layout table, the `× 0.5` correction called out as a correction, the four-night verification, the vendor-ceiling measurement and the instrumentation disposition); §9's census row for 0x6a marked SOLVED — its "recurring 0001f8b0 trailer" was never a trailer, it is motion_count=0, sleep_state=1 and the 2-byte cv, recurring because a still sleeper repeats them. No new user-facing strings, so no i18n work. Tests: 7 real-record decode fixtures per platform (same hex both sides), plus new twinned mapping/scale/scoring-exclusion suites and a 9-test vendor-rate suite per platform (median, out-of-session rows ignored, the span gate refusing a dense 36-minute fragment, the plausible-band refusal, analyzeDay preferring the device rate, empty-vendor byte-identity, and the era-scoped fold with a pooled-baseline control showing the same ring night crossing 1 sigma when brands are mixed). WhoopStore 384/0, StrandAnalytics 1357/0, OuraProtocol 191/0, SleepBench 13/0, Android 3837/0. macOS `Strand` and iOS `NOOPiOS` app targets both compiled locally (CI covers neither). NOT yet validated on hardware: the live-source arm anchors and enqueues 0x6A like its banked siblings and advances the drain resume cursor with them (0x6A is banked-only and never live, so it cannot leap the cursor to "now"), which needs a real overnight drain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FxLiKdWF4iegX5NHK24FwR (cherry picked from commit 0e17cbd)
ryanbr
added a commit
that referenced
this pull request
Sep 9, 2026
…blished
Re-review of my own commit. The frontier change used the `unix` a HISTORY_END carries as "the time
this acked section ends at". The comment at the very call site I edited says otherwise:
#773: corrupt future-RTC detection. A HISTORY_END carries the strap's own clock
If that value is the strap's live RTC rather than the section's end time, then the safety property
the whole change rested on inverts. "Taking the later of the two can only close a gap, never open
one" is only benign when the second value describes ground actually covered. A live clock is
approximately now on every ack, so it would close EVERY gap, on every strap, and stop a strap with
real backlog after a single session. That is not a rare edge; it is what the change would do to
every offload.
I could not settle which it is from the code. `clockRef` is built from the GET_DATA_RANGE newest
rather than from this field, so the clock-correlation path does not answer it either, and the #773
comment reads both ways: "the strap's own clock" says live, "always PAST-dated (it's banked
history)" says section. Deciding by inference here means betting the offload loop on a guess, so
the frontier work comes out until the field's meaning is established from a capture.
What stays is the layout warning, which is independent of all of this and was verified on its own
terms: a mapped 5/MG layout is no longer reported as undecodable, asked of the dispatch table rather
than a hand-maintained list of field names.
`offloadFrontier` goes with it rather than sitting unused, along with its tests. The diagnosis in
the issue stands and is worth keeping: the row frontier genuinely cannot reach a strap whose newest
records do not decode, and #1144's guard genuinely cannot catch it on a mixed-layout strap. What is
missing is a consumed marker that is demonstrably a record time.
Android suites 5,747 green; WhoopProtocol layout tests 4 green.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Branch:
oura-sleep-timeline-persist-v2→mainSupersedes: DRAFT #446 · Re-opens the cancelled #727
What & why
An Oura ring stages sleep on-device (its "SleepNet" classifier) and banks the result as a
whole-night phase-code burst — but NOOP had no path to turn that into a real night. Today a pure-Oura
day either shows a sparse-motion computed guess or nothing. This change decodes the ring's own
hypnogram, reconstructs its time axis, and persists it as a
CachedSleepSessionunder the ring's owndeviceId, soSleepMerge's existing imported-over-computed rule surfaces Oura's staging insteadof the computed sibling — the same arbitration that already lets a WHOOP/HealthConnect import win over
a computed night. WHOOP/Apple imports still out-rank Oura in that order.
Because the persisted night is the ring's raw on-device classification (not the Oura app's
cloud-adjusted stages, and not a NOOP computation), the sleep surfaces now label it "Oura" with an
honest caveat rather than the generic "On-device".
How
Decode & reconstruct (pure, both platforms — byte-identical Swift/Kotlin twins):
HypnogramAssemblerreconstructs the 30 s/code time axis and applies the0x49sleep-summarywindow at both ends: the burst end is anchored to the sleep-end, and the start is clamped to
the sleep-onset so the burst's pre-window epochs are clipped. The clamp lives in the pure
codesWithTimes(…, sleepStartUnixSeconds:)with a never-empty guard — a mis-paired0x49cannever erase the night.
0x49window pairing inOuraHistoryDrain(closest window to the burst: overnight vs nap, withtolerance);
OuraEvents/Decoders/Framing/OuraDriveradditions.Persist (both platforms):
OuraSleepSessionMappingmaps the anchored per-code hypnogram → aCachedSleepSessionstagebreakdown with a fixed, byte-identical
stagesJSONkey order;OuraStreamMappingpasses thesleep-phase records through.
OuraLiveSourceassembles the burst, anchors +0x49-refines both ends, and calls a newpersistSleepSessionclosure;SourceCoordinatorupserts it viaupsertSleepSessions([_], deviceId:).Idempotent on
(deviceId, startTs), so re-drains update in place.Provenance UI (both platforms, design tokens only):
DeviceBrandCatalog.isOura(deviceId)— ONE canonical brand resolver (brand table, no"oura"literal);
Repository.activeDeviceIsOura(Swift) andSleepScreen(Kotlin) call it.SleepView/SleepScreen: hero + main-sleep badges read "Oura"; the stage caption reads"raw on-device stages"; a caveat note states the split is the ring's RAW BLE classification (more
Awake / less Deep+REM) — NOT the Oura app's cloud-adjusted stages.
daySourceBadgegains the Ourabrand so the ring-id session (and By-Day) label correctly.
Honest-data / tier discipline
The persisted session is a ring-PROVIDED night (Tier-A sleep-phase data), not a NOOP-computed
score — it sidesteps the #960 Tier-B durable-rows pin. No values are fabricated: the caveat states
plainly that the ring's raw split differs from the Oura app's cloud-adjusted stages.
Cross-platform parity
Swift and Kotlin twins are byte-identical (decode,
stagesJSON, brand resolution). No schemamigration; no new merge inputs; no read-id plumbing.
Verification
swift test— OuraProtocol 139/0, WhoopStore 276/0 (green after rebase onto currentmain).Covers: the
0x49start-clamp (drop-before-onset, inclusive boundary, onset-before-burst keeps all,never-empty fallback) + backward-layout cases; anchored hypnogram → stage breakdown; closest-
0x49window selection;
DeviceBrandCatalog.isOuraregistry-id resolution; the twin decode/mapping/framing/driver/history-drain suites.
OuraLiveSource,SourceCoordinator,Repository,SleepView) →built locally (default CI does not compile app targets).
On-device (BLE path — compile proves nothing here)
Persist / provenance / wear already validated on a real Gen3 ring. Remaining gate before merge: one
overnight capture confirming the start-clamp fires —
hypnogram start clamped…log with thesession start moving to the
0x49onset.