fix(ui): respiratory rate is an on-device estimate on BOTH gens, not read off the strap (supersedes #1365) - #1367
Merged
Merged
Conversation
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.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Supersedes #1365 (by @gdorgian, cherry-picked with authorship intact) and completes it across both WHOOP generations.
Why
NoopLimitationsViewmarked "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 alwaysSleepStager.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:
AnalyticsEngine'srespRateDailycallsSleepStager.respRateFromRR(...)with no family branch — both generations get the RSA estimate.resp_rate_raw, butHistoricalStreamsstores it as a rawRespSampleand never converts it (schema:"raw; resp rate computed server-side").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
compileFullDebugKotlinclean;i18n_audit --ci+doc_comment_lintpass.NoopLimitationsView.swift) validated via the on-demand app-build gate.Thanks to @gdorgian for the fix and for flagging the 4.0 column.