Skip to content

Persist the Oura ring's SleepNet hypnogram as a stage-timeline night, labelled "Oura" - #773

Merged
ryanbr merged 3 commits into
ryanbr:mainfrom
pipiche38:oura-sleep-timeline-persist-v2
Jul 24, 2026
Merged

ryanbr merged 3 commits into
ryanbr:mainfrom
pipiche38:oura-sleep-timeline-persist-v2

Conversation

@pipiche38

Copy link
Copy Markdown

Branch: oura-sleep-timeline-persist-v2 → main
Supersedes: 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 CachedSleepSession under the ring's own
deviceId, so SleepMerge's existing imported-over-computed rule surfaces Oura's staging instead
of 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):

  • HypnogramAssembler reconstructs the 30 s/code time axis and applies the 0x49 sleep-summary
    window 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-paired 0x49 can
    never erase the night.
  • 0x49 window pairing in OuraHistoryDrain (closest window to the burst: overnight vs nap, with
    tolerance); OuraEvents / Decoders / Framing / OuraDriver additions.

Persist (both platforms):

  • OuraSleepSessionMapping maps the anchored per-code hypnogram → a CachedSleepSession stage
    breakdown with a fixed, byte-identical stagesJSON key order; OuraStreamMapping passes the
    sleep-phase records through.
  • App wiring: OuraLiveSource assembles the burst, anchors + 0x49-refines both ends, and calls a new
    persistSleepSession closure; SourceCoordinator upserts it via upsertSleepSessions([_], 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) and SleepScreen (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. daySourceBadge gains the Oura
    brand 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 schema
migration; no new merge inputs; no read-id plumbing.

Verification

  • swift test — OuraProtocol 139/0, WhoopStore 276/0 (green after rebase onto current main).
    Covers: the 0x49 start-clamp (drop-before-onset, inclusive boundary, onset-before-burst keeps all,
    never-empty fallback) + backward-layout cases; anchored hypnogram → stage breakdown; closest-0x49
    window selection; DeviceBrandCatalog.isOura registry-id resolution; the twin decode/mapping/framing/
    driver/history-drain suites.
  • Kotlin twins mirror them (their run is gated only by the unrelated Skin Temp, Wrong Strap Model #716 test-fake compile gap / Android: unblock testFullDebugUnitTest — implement DeviceRegistryDao.setModel in the three fake DAOs #725).
  • App-target Swift touched (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 the
session start moving to the 0x49 onset.

pipiche38 and others added 3 commits July 24, 2026 10:37
… 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
ryanbr merged commit f8ddfc3 into ryanbr:main Jul 24, 2026
9 checks passed
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
pipiche38 deleted the oura-sleep-timeline-persist-v2 branch July 24, 2026 18:47
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants