Skip to content

fix(oura): give the bare 0x44 IBI tag its own channel code, not 0x60's - #1084

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
pipiche38:fix/oura-label-0x44-ibi-channel
Aug 5, 2026
Merged

fix(oura): give the bare 0x44 IBI tag its own channel code, not 0x60's#1084
ryanbr merged 1 commit into
ryanbr:mainfrom
pipiche38:fix/oura-label-0x44-ibi-channel

Conversation

@pipiche38

Copy link
Copy Markdown

Branch: fix/oura-label-0x44-ibi-channel (based on main @ 7c7b3f0c, commit 4d35fc32)
Base: main. No migrationsrcChannel is an existing nullable column (v32 / Room 26) and
this adds a new value, not a schema change.
Follows: #1071 (merged as #1076), whose channel labelling this completes.


The gap

0x44 ibi_event and 0x60 ibi_and_amplitude_event carry the same bit-packed layout byte for
byte, so OuraDriver routes both through decodeIBIAmplitude. Until now they also shared a channel
code
, so once a beat was stored nothing could say which tag produced it.

That matters because it collapses the question the scoring-channel choice turns on. A post-#1076
validation night measured srcChannel = ibiAmplitude covering 1.25× the wall-clock it spans
over-counted from that channel alone, and the dominant source of the night's implausible ~197 ms
SDNN. But "that channel" is two tags, so the capture cannot distinguish:

  • one tag repeating beats across consecutive records → the fix is a time-and-value de-dup; from
  • two tags reporting the same beats to each other → the fix is a second channel exclusion.

Different fixes. No stored night can tell them apart, because the label merged them before the row
was written.

The change

  • OuraIBIChannel.ibiBare / OuraIbiChannel.IBI_BARE = 4, with the storage twin
    RRSourceChannel.ibiBare / RrSourceChannel.IBI_BARE = 4. Appended, never renumbered — the raw
    values are the durable cross-platform storage codes that reach rrInterval.srcChannel and cross the
    .noopbak boundary.
  • decodeIBIAmplitude gains a channel parameter defaulting to .ibiAmplitude, so every existing
    call site means exactly what it meant before.
  • The driver's 0x44 arm passes .ibiBare.

This is a label, not a filter

Nothing about which beats are read changes. The scoring read (Reads.rrIntervals) still excludes only
0x6E, so both tags remain scored exactly as the merged label was — no night's coverage, HRV, resting
HR or scoring moves. There is a test pinning precisely that, because if it ever starts failing the
split has quietly become a behaviour change, and then the capture it exists to make measurable would
be measuring a different night.

Pre-split rows keep code 3 and stay honest: on those, 3 means "0x60 or 0x44, unknown which" —
which is the state this ends going forward. Nothing is backfilled; the tag was never recorded, so it
cannot be recovered.

Tests

Package-level on both platforms (swift-packages.yml / ./gradlew testFullDebugUnitTest):

  • the same golden bytes routed as each tag decode to identical intervals and amplitudes under
    distinct channels
    — same layout, different origin;
  • the driver labels a 0x44 record ibiBare and a 0x60 record ibiAmplitude, both still emitting
    every beat
    ;
  • a 0x44 row is stored with code 4 and still returned by the scoring read, while 0x60 keeps
    code 3;
  • the durable-code pins extended to 4, and the existing cross-enum parity test (which compares
    allCases counts and every mapped raw value) now covers the new case automatically.

Kotlin twins of each.

Verification

  • swift test: OuraProtocol 168 pass, WhoopStore 351 pass, 0 failures.
  • ./gradlew testFullDebugUnitTest: 3,470 tests, the same 3 pre-existing locale failures as clean
    main.
  • xcodebuild Strand (macOS) and NOOPiOS both BUILD SUCCEEDED — app targets have no default CI.
  • No hardware needed for correctness, but the point of the change is the next capture: a drain from
    a build carrying this will show rows in srcChannel 3 and/or 4 and settle which of the two
    hypotheses above is true. Worth landing alongside fix(oura): hand the store a record's beats at once so beat order stops being 0 #1082 so one night answers both questions.

0x44 `ibi_event` and 0x60 `ibi_and_amplitude_event` share a bit-packed layout
byte for byte, so they share a decoder — and until now they also shared a
channel code, so once a beat was stored nothing could say which tag produced it.

That collapses the question the scoring-channel choice actually turns on. A
validation night measured `srcChannel = ibiAmplitude` covering 1.25x the
wall-clock it spans — over-counted from one channel alone, and the dominant
source of the night's implausible SDNN. But "that channel" is two tags, so the
capture cannot distinguish one tag repeating beats across its records from two
tags reporting the same beats to each other. Those need different fixes: a
time-and-value de-dup versus a channel exclusion. No stored night can tell them
apart, because the label merged them before the row was written.

Separating the codes costs a case and makes the next capture decisive:

- `OuraIBIChannel.ibiBare` / `OuraIbiChannel.IBI_BARE` = 4, and the storage twin
  `RRSourceChannel.ibiBare` / `RrSourceChannel.IBI_BARE` = 4, pinned to the same
  durable value on both platforms and appended, never renumbered;
- `decodeIBIAmplitude` takes the channel to stamp, defaulting to 0x60's, so
  every existing call site means exactly what it meant before;
- the driver's 0x44 arm passes `ibiBare`.

**This is a label, not a filter.** No migration (`srcChannel` is an existing
nullable column and this is a new VALUE, not a schema change); the scoring read
still excludes only 0x6E, so both tags are read exactly as before and no night's
coverage, HRV or scoring moves. Pre-split rows keep code 3 and stay honest: on
those, 3 means "0x60 or 0x44, unknown which", which is the state this ends.

Verification: `swift test` OuraProtocol 168 + WhoopStore 351, incl. new tests
that the same bytes routed as each tag decode to identical intervals under
distinct channels, that the driver labels 0x44 without dropping a beat, that a
0x44 row is stored with code 4 AND still returned by the scoring read, and the
durable-code pins extended to 4 on both platforms. Kotlin twins of each.
`./gradlew testFullDebugUnitTest` 3,470 tests with the same 3 pre-existing
locale failures as clean main. App targets built locally since no default CI
compiles them: `xcodebuild` Strand (macOS) and NOOPiOS both succeed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AiQZ5XBeAMgnyUugPMqXPi
@pipiche38
pipiche38 marked this pull request as ready for review August 5, 2026 09:35
@ryanbr
ryanbr merged commit df534b5 into ryanbr:main Aug 5, 2026
16 checks passed
@pipiche38
pipiche38 deleted the fix/oura-label-0x44-ibi-channel branch August 5, 2026 12:02
@pipiche38

Copy link
Copy Markdown
Author

The split did its job: the question it was merged to make answerable now has an answer, from an
overnight drain on a build carrying it.

Capture. Gen-3 ring oura-2H3B2405003655, night 2026-08-05 22:12 → 2026-08-06 07:18 (9.1 h),
build integration/oura-full@692f24a1 (= main a29c4612 + #1085 + this PR), confirmed by the
Branch: marker in the strap log. WHOOP 4.0 worn the same night as a control (22:10 → 07:12).

Result: 0x44 does not fire on this ring at all.

srcChannel rows in the night
1 greenQuality (0x80) 1,849
2 spo2Ibi (0x6E) 46,458
3 ibiAmplitude (0x60) 29,542
4 ibiBare (0x44) 0
NULL (pre-v32) 69

That zero is a real absence, not an unreached code path. The raw-notification sidecar logs every
tag before any enum filtering, and across 29,597 records / 34 distinct tags covering the night's
bank it holds 4,073 × 0x60 and 0 × 0x44. The driver's 0x44 arm passing .ibiBare is
present in the build; the ring simply never emits the tag.

What that settles. srcChannel = 3 on this hardware is 0x60 alone — so the 1.25× over-coverage
measured on 2026-08-05 was never two tags reporting the same beats to each other. It was one tag
overlapping its own records. Concretely, laying each record's beats back by cumulative IBI and
comparing against the inter-record gap:

2026-08-04→05 (pre-#1082 build) 2026-08-05→06 (this build)
inter-record gap, median 6.00 s 7.00 s
IBI sum per record, median 6.77 s 6.63 s
fill ratio (beat-seconds / gap-seconds) 1.061 0.990
records overfilling their gap by >10 % 2,431 / 5,789 = 42 % 610 / 4,835 = 12.6 %

So the overlap is real and measurable — and it is intermittent, not a constant property of the
channel. On this night the records tile the timeline almost exactly (coverage 0.98 for 0x60 alone,
rrIntegrity = plausible), and no channel filter would have improved it.

The follow-up work therefore takes the de-bank / overlap-trim shape, not an exclusion. A channel
whitelist chosen before this capture would have excluded a channel that carries 99 % of the night's
usable beats and is correctly tiled most nights.

One caveat worth recording: this is one ring (Gen 3, BLB_03) on one night. 0x44 firing on
other generations would still be distinguishable now — which is the whole point of the split — so
the label stays worth having regardless.

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