Conversation
`Oura: first SpO2 decoded (last night) - value N (unit)` fired on whichever `.spo2` event the history drain happened to serve first. Two decoders feed that event with quantities three orders of magnitude apart — 0x6F/0x7B carry a firmware-computed percentage, 0x77 carries a raw DC perfusion magnitude — so on one ring, minutes apart, the log read `value 93 (raw)` and then `value 101144 (dc_raw)`. A reporter read the five-digit one as a percentage and opened a defect against SpO2 that was never wrong; their capture in fact carried 5,089 0x6F packets beside 12,646 0x77, with the percentage channel live all night. `OuraSpO2Channel` (OuraProtocol + com.noop.oura) resolves the channel from the unit tag and owns both the label and the log line, so the two platforms cannot disagree about what they call these numbers. One latch per channel replaces the single `loggedFirstSpo2` flag, so each line names one quantity and a session that only ever saw perfusion says so instead of implying a percentage arrived: Oura: first SpO2 percentage decoded (last night) - 93 % (channel "raw") Oura: first SpO2 raw DC perfusion (NOT a percentage) decoded (last night) - 101144 (channel "dc_raw") The `%` is appended on the percentage channel only — `-288 %` would be the same bug in a new costume, and a test pins that. `OuraStreamMapping` keeps its own `unit == "raw"` allow-list on both platforms and is untouched: it is a persistence gate, so an unrecognised future unit must fall on the "do not store" side, whereas this resolver has to name every sample it is handed. Log copy only; no behaviour, no stored value, no new strings. Verified: OuraProtocol 262/0 (10 new, incl. the decoders really stamping those tags); Kotlin `OuraSpO2ChannelOracleTest` 3/3 asserting the verbatim stdout of the shipped Swift `firstDecodedLogLine`; Android 6381 tests, the 8 failures are pre-existing on `upstream/main`; macOS `Strand` build + `StrandTests` TEST SUCCEEDED; iOS `NOOPiOS` BUILD SUCCEEDED; doc lint and `i18n_audit --ci` clean. Refs ryanbr#2372 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RQ5haNgsWbrEFMpjviYns8
ryanbr
left a comment
There was a problem hiding this comment.
Thanks @pipiche38. The premise checks out against the decoders: OuraSpO2.init defaults unit to "raw", so 0x6F/0x7B carry it implicitly while only 0x77 stamps "dc_raw". Both platforms build the line identically, every latch reset site was converted, and the oracle being captured Swift stdout rather than hand-written twins is the right instinct for this bug class.
One blocker, and it is small.
The parity ledger fails
parity-governance only runs when Tools/ is touched, so CI stays green on this. I ran it: main is clean, so the findings are this branch's.
add-unpaired-function: Packages/OuraProtocol/Sources/OuraProtocol/OuraEvents.swift::forUnit/1#1
add-unpaired-function: android/app/src/main/java/com/noop/oura/OuraEvents.kt::forUnit/1#1
Identical name and arity on both sides, so the pairing needs the annotation firstDecodedLogLine already has and forUnit does not. Adding this to the Swift doc clears it (tested: declared twin references 966 to 967, finding gone):
/// Kotlin twin: `OuraSpO2Channel.forUnit`.Behind that one sits a second finding, twin-map-authority-drift|unpaired_properties. Cause is that the scanner indexes Swift's channel but not Kotlin's, because the Kotlin one is a top-level extension property with a dotted receiver. That resolution is a maintainer call, so leave it to me rather than chasing it here.
Not blocking
forUnit treats anything that is not dc_raw as a percentage and appends %. Your own oracle encodes where that bites:
3|DC_RAW|percentage|first SpO2 percentage decoded (last night) - 3 % (channel "DC_RAW")
A case variant of the perfusion tag prints a perfusion magnitude with a percent sign, which is the bug this PR exists to stop, in a new costume. The body defends the default by contrast with OuraStreamMapping's allow-list, but naming every sample does not require guessing: a third unknown case that names the tag and omits % satisfies both. Nothing reaches it today, since the ring emits only those two tags, so this is durability rather than a live defect. Your call whether to take it now.
…tag as unknown Review follow-up. `forUnit` had no `Kotlin twin:` reference, so the parity ledger counted both sides as one-sided (`add-unpaired-function ... forUnit/1#1`, Swift and Kotlin). The reference is now on both declarations. `forUnit` also treated anything that is not `dc_raw` as a percentage, so a case variant or a future tag would print its magnitude with a `%` on it: the defect this type exists to stop. Both known tags (`raw`, `dc_raw`) now match exactly and anything else resolves to a third `unknown` channel, which names its tag and never appends `%`. Nothing reaches it today (the ring emits only those two tags); this is durability. The per-channel latch gains the matching third slot for free. Verified: OuraProtocol 263/0; Kotlin `OuraSpO2ChannelOracleTest` 3/3 against the re-captured stdout of the shipped Swift (15 rows, 5 of them unknown tags); `parity_ledger.py` leaves only `twin-map-authority-drift|unpaired_properties`, which the reviewer kept; doc lint clean. Refs ryanbr#2372 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01484Ef871BnAuniyDJYYHoV
|
Thanks @ryanbr. Both points are in Ledger. The
Verified: OuraProtocol 263/0; |
The defect
Oura: first SpO2 decoded (last night) - value N (unit)fired on whichever.spo2event the historydrain happened to serve first. Two decoders feed that one event with quantities three orders of
magnitude apart:
0x6F/0x7B"raw"0x77"dc_raw"So the same ring, on consecutive reconnects of one night, printed:
Nothing changed about the ring between 23:50 and 02:32 — only which packet arrived first.
"raw"namesthe channel, not the quantity, and reads as "unprocessed" to anyone who has not read
decodeSpO2PerSample, so there was nothing in the line to tell the two apart.This cost a reporter a false alarm: they read the five-digit value as a percentage and opened a defect
against SpO2 that was never wrong. Their capture carried 5,089
0x6Fpackets beside 12,6460x77—the percentage channel was live the whole night, and their 360,847 stored
spo2rows match.The change
OuraSpO2Channel— new, inOuraProtocolandcom.noop.oura— resolves the channel from the unit tagand owns both the label and the log line, so the two platforms cannot disagree about what they call
these numbers. One latch per channel replaces the single
loggedFirstSpo2flag:Each line now names one quantity, the unit tag is still printed so a log ties back to the decoder, and a
session that only ever saw perfusion says so instead of implying a percentage arrived. The
%isappended on the percentage channel only —
-288 %would be the same bug in a new costume, and there isa test for exactly that.
Why a type rather than a
unit == "raw"check at each call siteBoth known tags are matched exactly:
"raw"is the percentage channel,"dc_raw"is perfusion, andanything else resolves to a third
unknownchannel that names its tag and never takes a%. Treating"not perfusion" as a percentage would print a case variant or a future tag's magnitude as
N %, which isthe defect this PR fixes.
OuraStreamMappingkeeps its ownunit == "raw"allow-list for the samereason from the other side: it is a persistence gate, so an unrecognised unit is not stored there and is
not called a percentage here. Nothing reaches
unknowntoday, because the ring emits only those twotags; it is there for durability.
Scope
Log copy only. No behaviour change, no stored value, no schema, no new user-facing strings, no BLE path
touched. 6 files, +364 / −16 (most of it doc comments and the two test files).
Verification
OuraProtocolswift test%-on-perfusion case, and thatdecodeSpO2PerSample/decodeSpO2DC/decodeSpO2Stablereally stamp the tags the resolver keys on)OuraSpO2ChannelOracleTestfirstDecodedLogLine, captured by linkingmain.swiftagainst the builtOuraProtocolmodule, not written by handtestFullDebugUnitTestupstream/main(RecoveryDriversTest×2,StandardHrSensorFormatTest×2,StepsDetailIntegrationTest,TodayExplainabilityTest×3) — reproduced in a clean worktree ate3f6ef09dbefore this branch existedStrandStrandTestsTEST SUCCEEDEDNOOPiOSTools/doc_comment_lint.pyTools/i18n_audit.py --ci upstream/mainThe oracle guards Kotlin against the Swift of the day;
OuraSpO2ChannelTestspins the same strings onthe Swift side, which is what stops Swift drifting silently. Both directions, per the parity contract.
Refs #2372