Skip to content

fix(oura): SpO2 candidate mean floors instead of rounds (Swift+Kotlin) - #1591

Merged
ryanbr merged 1 commit into
ryanbr:mainfrom
pipiche38:fix/oura-spo2-candidate-mean-rounding
Aug 25, 2026
Merged

ryanbr merged 1 commit into
ryanbr:mainfrom
pipiche38:fix/oura-spo2-candidate-mean-rounding

Conversation

@pipiche38

Copy link
Copy Markdown

What this PR does

nightlySpo2CeilingMean (Oura, queue 11a) and nightlySpo2CandidateMean (WHOOP @82, #103) both
compute their nightly mean as sum / kept with integer operands — in both Swift and Kotlin that's
integer division, which truncates toward zero (a floor), not a round.

Found 2026-08-24 comparing the first live-persisted ceiling@100 row (2026-08-23/24) against the
Oura 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 / kept floored it to 97, a spurious 1-point miss the transform's own track record
doesn't actually have. Same bug, same helper shape, in the older (pre-dates queue 11a by weeks)
WHOOP spo2_candidate_82 path, which has been floor-biased the same way since #103.

The fix

Both functions on both platforms now compute the mean via Double division and round
(.rounded() in Swift, roundToInt() in Kotlin — both already used elsewhere in these files). Every
value 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, both
transforms ship the same way spo2_candidate_82 always 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 2
    pre-existing RecoveryDriversTest half-tie-rounding failures — confirmed present on pristine
    upstream/main before this change (ran --no-build-cache --rerun-tasks against a clean checkout
    with 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.
  • Only Packages/StrandAnalytics (pure) and Android com.noop.analytics.* touched — no app-target
    Swift, so this is fully covered by the default-active swift-packages.yml + android.yml CI, no
    app-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.

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
ryanbr marked this pull request as ready for review August 25, 2026 03:18

@ryanbr ryanbr left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@ryanbr
ryanbr merged commit 71e913a into ryanbr:main Aug 25, 2026
14 checks passed
@pipiche38

Copy link
Copy Markdown
Author

Fair to push back, and worth being precise rather than re-asserting the same framing — I went and
actually triaged all 8 instead of assuming they're one bucket. They're not: it's 6 locale artifacts
and 2 architecture-dependent float ones, different causes, different fixes.

The 6 (AiCoachContextTest, StandardHrSensorFormatTest x2, TodayExplainabilityTest x3): all green
under JAVA_TOOL_OPTIONS="-Duser.language=en -Duser.country=US". This machine's system locale is
fr_FR. TodayExplainabilityTest specifically fails because TodayScoring.kt's carriedCaption/
recalibrationDay format dates with DateTimeFormatter.ofPattern("d MMM", Locale.getDefault()) —
correct production behavior, since that's user-facing copy and should localize — but the test
hardcodes the English abbreviation ("14 Jan") as its expected string with no locale pin. Not a repo
bug; a test-hygiene gap that happens to be invisible on an English-locale machine, which is most of
them.

The 2 (RecoveryDriversTest.driverPointRoundingUsesNearestWithHalfTiesAwayFromZero and
issue51NegativeHalfTieUsesDefaultArg8...):
this one actually IS a real, reproducible,
architecture-dependent float discrepancy — not "my setup" in the sense of something misconfigured,
and not random. I probed it directly (printed the intermediate values the test normally asserts away):
on this arm64 Mac, the test's negative-side magic HRV constant (chosen to land the score delta exactly
on -0.5) actually computes to -0.4999999999999929 — 7.1e-15 short. Math.round on that legitimately
returns 0, not the intended -1, so the assertion fails by design once the input misses the exact bit
pattern it needs. The positive-side constant round-trips fine (0.5 exactly), which is why only the
negative branch fails. Math.exp is documented to be accurate to within 1 ulp, not bit-identical
across platforms — StrictMath would guarantee that, Math doesn't. If your box is x86_64 (or Intel
Mac) and mine's Apple Silicon, that's consistent with a JIT-selected hardware intrinsic difference,
not a config problem on my end. RecoveryScorer/RecoveryDrivers' actual rounding contract (nearest,
ties away from zero) is correct given its input — the bug, if it's one at all, is in the test using a
reverse-engineered magic constant to hit an exact tie through a multi-step float chain, which was
never going to be portable across CPU families. I haven't touched it — fixing it properly means either
pulling the private points() closure out into something the tie can be injected into directly, or
accepting the raw-delta assertion can't be bit-exact. Wanted to flag the real mechanism rather than
leave "fails on my setup" as the last word, since it'll reproduce for anyone else on arm64.

Full triage: worklog/analysis/2026-08-25-0940-android-test-backlog-triage.txt (not published,
happy to share specifics if useful).

@pipiche38
pipiche38 deleted the fix/oura-spo2-candidate-mean-rounding branch September 12, 2026 17:41
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