Skip to content

fix(ui): respiratory rate is an on-device estimate on BOTH gens, not read off the strap (supersedes #1365) - #1367

Merged
ryanbr merged 2 commits into
mainfrom
fix/resp-partial-both-gens
Aug 16, 2026
Merged

fix(ui): respiratory rate is an on-device estimate on BOTH gens, not read off the strap (supersedes #1365)#1367
ryanbr merged 2 commits into
mainfrom
fix/resp-partial-both-gens

Conversation

@ryanbr

@ryanbr ryanbr commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Supersedes #1365 (by @gdorgian, cherry-picked with authorship intact) and completes it across both WHOOP generations.

Why

NoopLimitationsView marked "Respiratory rate" .full ("read live off the strap") on both 4.0 and 5.0/MG. On neither generation is respiratory rate read off the strap — the displayed value is always SleepStager.respRateFromRR, an on-device RSA estimate off the R-R stream, which is what .partial ("on-device estimate, or experimental / firmware-gated") describes.

The 4.0 column (gdorgian's open question — confirmed)

@gdorgian fixed 5.0/MG and flagged that 4.0 might be wrong for the same reason but couldn't confirm without hardware. Traced it — it is:

So both columns → .partial. Same reasoning as #548 (advertising a capability the strap doesn't deliver makes an empty tile read as a fault, not design).

Changes

Swift row + Kotlin twin, both columns .partial (parity contract). No new i18n string — .partial's label already exists.

Verification

  • Android compileFullDebugKotlin clean; i18n_audit --ci + doc_comment_lint pass.
  • App-target Swift (NoopLimitationsView.swift) validated via the on-demand app-build gate.

Thanks to @gdorgian for the fix and for flagging the 4.0 column.

gdorgian and others added 2 commits August 16, 2026 14:42
The Limitations grid marks respiratory rate `.full` for both generations, and the
legend defines `.full` as "Read live off the strap". On a 5.0/MG nothing is read
off the strap: the v18 wire carries no respiratory channel, pinned by
`testHistoricalV18OpticalFieldsAreNotNamedPhysiologically` asserting `resp_rate_raw`
is nil. The displayed value is always `SleepStager.respRateFromRR`, an RSA estimate
from the R-R stream — which is precisely what the legend's `.partial` describes:
"On-device estimate, or experimental / firmware-gated".

The app's own device cards already label it "Resp rate*" = on-device estimate, so
the grid was the one place contradicting the rest of the UI.

Changes the Swift row and its Kotlin twin together, per the parity contract.
The 4.0 column is left alone — see the PR for the open question there.
…vice estimates

Completes gdorgian's 5.0/MG fix: the 4.0 column had the same defect. The displayed
respiratory rate is SleepStager.respRateFromRR on BOTH generations (AnalyticsEngine's
respRateDaily, no family branch) — an on-device RSA estimate, which is what .partial
means, not .full's "read live off the strap".

4.0's v24 layout carries a resp_rate_raw ADC, but HistoricalStreams stores it as a raw
RespSample and never converts it (schema: "resp rate computed server-side"), so it
never becomes the shown value. That the shown 4.0 value is RSA-derived is exactly why
an over-counted-R-R night (#1331) blanks it. Both platforms, per the parity contract.
@ryanbr
ryanbr merged commit b894a39 into main Aug 16, 2026
5 checks passed
@ryanbr
ryanbr deleted the fix/resp-partial-both-gens branch August 16, 2026 02:53
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
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