Repository navigation
fix(oura): SpO2 candidate mean floors instead of rounds (Swift+Kotlin) - #1591
Conversation
nightlySpo2CeilingMean and nightlySpo2CandidateMean both computed sum/kept with integer operands, which truncates toward zero rather than rounding. Found 2026-08-24 comparing a live-persisted ceiling@100 row (08-23/24) against the Oura app: the transform's precise mean, 97.97, round-matches the app's displayed 98%, but the shipped int division floored it to 97 - a spurious miss the transform's own track record doesn't actually have. Same bug, same helper shape, in the older WHOOP spo2_candidate_82 path. Both now round via Double division (.rounded() / roundToInt()). All sample values are positive (50...110 / 70...100), so the choice of rounding rule (half-up vs half-away-from-zero) is inert - no platform-divergence risk. Diagnostic-only value, never spo2Pct, never scored - this changes what the "strap estimate (unverified)" tile displays, not any scored/gated behavior. 6 new tests (3 platforms unaffected, 1 new pinning test each on the two touched functions per platform) - all existing fixtures already used exact-integer means so none needed updating. swift test 1573/1573; Android com.noop.analytics.* 1659/1659 (2 known-preexisting, unrelated RecoveryDriversTest failures confirmed present on pristine upstream/main before this change - not stale-cache as previously suspected, a real environment discrepancy, out of scope here).
ryanbr
left a comment
There was a problem hiding this comment.
Thanks @pipiche38 — this one's correct and I'd take it as-is. The bug is real (sum / kept on two integer operands truncates in both languages), the fix is symmetric, and the reasoning about the rounding rule is right rather than merely plausible, which is the part I checked hardest.
Verified rather than taken on trust
The rounding-rule parity claim holds. Swift's .rounded() is round-half-away-from-zero; Kotlin's roundToInt() rounds ties toward positive infinity. Those are different rules and would diverge on negative ties — so the claim rests entirely on the inputs being positive. They are, and by construction rather than by luck: the candidate path filters to 70...100, and the ceiling path gates on spo2SingleChannelPlausible (50...110) before min(s.red, 100). No negative value reaches either divide, so the two rules agree on every input. Good call flagging it explicitly.
Ran the Android side here: Spo2CandidateNightlyTest 8/8 and Spo2CeilingNightlyTest 7/7, both meanRoundsRatherThanFloors present and green. kotlin.math.roundToInt is imported (line 24). And you're right that this needs no app-build — pure package plus com.noop.analytics, no app-target Swift, so the default CI genuinely covers it.
One thing the description doesn't mention: the value is persisted
This isn't display-only. The engine writes spo2_candidate as a metricSeries under the -noop computed device id (PuffinExperiment.swift:72), and the tiles read it back via exploreSeries. So changing the computation leaves a mixed-vintage series: rows written before this keep the floored value, rows re-scored after it get the rounded one, and analyzeRecent only re-scores a bounded window — so anything older than that window stays floored indefinitely.
Not blocking. It's diagnostic-only, default-OFF, and the new value is strictly the better one. But it bites precisely the use you built it for: a groundtruth tally against the Oura app would be comparing floored old rows against rounded new ones, which is the measurement error this PR exists to remove. Worth a sentence in the doc comment, and worth only tallying rows written after this lands.
Your pre-existing-failure note — it doesn't reproduce here
RecoveryDriversTest passes 10/10, zero failures on my box, run on your branch. So those two failures are specific to your machine rather than a real environment-dependent floating-point discrepancy in the project, and CI agrees with me.
That's worth knowing before anyone spends time on it: the honest read is "fails on my setup", not "the repo has a float problem". Something local — JDK build, locale, or arch — is the likelier culprit. Happy to help narrow it if it keeps blocking your runs; it just isn't a signal about #1584 either way.
Merging shortly unless you want to add the doc line first — say the word and I'll wait.
|
Fair to push back, and worth being precise rather than re-asserting the same framing — I went and The 6 (AiCoachContextTest, StandardHrSensorFormatTest x2, TodayExplainabilityTest x3): all green The 2 ( Full triage: |
What this PR does
nightlySpo2CeilingMean(Oura, queue 11a) andnightlySpo2CandidateMean(WHOOP@82, #103) bothcompute their nightly mean as
sum / keptwith integer operands — in both Swift and Kotlin that'sinteger division, which truncates toward zero (a floor), not a round.
Found 2026-08-24 comparing the first live-persisted
ceiling@100row (2026-08-23/24) against theOura app's own displayed value: the transform's precise mean was 97.97, which round-matches the app's
98% — the same way every other hit in the running §6.5.0 groundtruth tally has been scored — but the
shipped
sum / keptfloored it to 97, a spurious 1-point miss the transform's own track recorddoesn't actually have. Same bug, same helper shape, in the older (pre-dates queue 11a by weeks)
WHOOP
spo2_candidate_82path, which has been floor-biased the same way since #103.The fix
Both functions on both platforms now compute the mean via
Doubledivision and round(
.rounded()in Swift,roundToInt()in Kotlin — both already used elsewhere in these files). Everyvalue that reaches these functions is positive (Oura:
50...110/ceilinged to 100; WHOOP:70...100),so the choice between round-half-up and round-half-away-from-zero is inert — no platform-divergence
risk from the rounding rule itself.
Diagnostic-only value, never
spo2Pct, never scored (CLAUDE.md's derived-biosignal rule, bothtransforms ship the same way
spo2_candidate_82always has, default-OFF toggle, labelled"unverified"). This changes what the "strap estimate (unverified)" tile displays, nothing about any
scored or gated behavior.
Tests
6 new tests (3 samples averaging 97.666..., pinned to round to 98 not floor to 97), one per touched
function per platform. Every existing fixture already used exact-integer means (no fractional
remainder), so none needed updating — same expected values before and after this change.
Verification
swift test(StrandAnalytics): 1573/1573, including the 6 new tests../gradlew compileFullDebugKotlin: clean../gradlew testFullDebugUnitTest --tests "com.noop.analytics.*": 1659/1661, only the 2pre-existing
RecoveryDriversTesthalf-tie-rounding failures — confirmed present on pristineupstream/mainbefore this change (ran--no-build-cache --rerun-tasksagainst a clean checkoutwith none of this branch's commits applied) and completely unrelated (recovery-driver point
rounding, not SpO2). Worth a note for whoever picks this thread up: this reproduces on a genuinely
fresh build, so it isn't the stale-Gradle/KSP-cache explanation floated on Oura SpO2 candidate tile + Deep Timeline single-channel fix (rebase of #1568) #1584 — looks like a real
environment-dependent floating-point discrepancy (this machine vs whatever CI/
ryanbr's box uses),not chased further here since it's pre-existing and out of scope for this fix.
Tools/doc_comment_lint.py: clean.Packages/StrandAnalytics(pure) and Androidcom.noop.analytics.*touched — no app-targetSwift, so this is fully covered by the default-active
swift-packages.yml+android.ymlCI, noapp-build gap to worry about.
Not yet done
Not flashed to a device — the toggle is default-OFF so this ships inert either way, and the affected
value is diagnostic-only.
integration/oura-full's next rebase should pick this up along with #1584/#1585.