Skip to content

fix(import): select Oura's main sleep session for the daily rollup - #375

Merged
ryanbr merged 4 commits into
ryanbr:mainfrom
vishk23:oura-daily-main-session
Jul 31, 2026
Merged

fix(import): select Oura's main sleep session for the daily rollup#375
ryanbr merged 4 commits into
ryanbr:mainfrom
vishk23:oura-daily-main-session

Conversation

@vishk23

@vishk23 vishk23 commented Jul 13, 2026

Copy link
Copy Markdown

What

OuraApiParser.parseSleep's per-day fold used first-write-wins: whichever
sleep session the API happened to list first for a given day claimed
every daily field (totalSleepMin, restingHr, deep/light/rem/awake,
efficiency, HRV, respiratory rate). Oura can return more than one session
per day (a nap or short fragment alongside the real night), and the API
gives no ordering guarantee, so a fragment could — and did — beat the main
sleep.

Audit evidence

Checked across a real 24-month Oura dataset:

  • 36/36 days where the daily totalSleepMin was 0.5-25 min while the
    true main sleep (250-780 min) existed adjacent in the same session list —
    confirmed at the session level in every case.
  • Two impossible daily restingHr values produced this way: 92 bpm on
    one day from a 32-min wake-dominated micro-session (the real night was
    277 min at 57 bpm), and 90 bpm on another amid neighboring days of
    41-48 bpm.
  • Five near-misses at 89-100 bpm with the same root cause.

The visible symptom for a user is a resting-heart-rate reading that spikes
to 90+ bpm for a single day, with normal-looking days on either side — if
you've seen that on an Oura-imported day, this is why.

Fix

Select the day's MAIN session instead of first-write-wins. Oura marks the
primary sleep with type == "long_sleep":

  • a long_sleep session always outranks any other type;
  • among same-rank candidates, the longer total_sleep_duration wins;
  • the winner supplies every rollup field as a unit — fields are never
    mixed across two different sessions on the same day.

The per-session periods list (what backs the Sleep tab) is untouched —
every detected session, including naps, still shows up there. Only the
single-row daily rollup now picks one session's fields consistently.

The day-key logic (day field, fallback to the wake day) is unchanged.
sleepScore continues to come from daily_sleep, not from this fold.

Healing existing data

dailyMetric is upserted keyed by (deviceId, day)
(WhoopStore.upsertDailyMetrics, ON CONFLICT(deviceId, day) DO UPDATE),
so this requires no migration — re-running the Oura sync overwrites every
affected day's row in place with the corrected values. This is the same
mechanism that healed the earlier RHR-score corruption.

Tests

Packages/StrandImport/Tests/StrandImportTests/OuraApiParserSleepTests.swift,
4 new cases:

  • a 2-min fragment listed before the day's long_sleep → the rollup
    takes the long_sleep's totalSleepMin/restingHr, not the fragment's;
  • the same fixture with the long_sleep listed first → identical result
    (order-independent);
  • a fragment-only day (no long_sleep present) → keeps the fragment's own
    values, honestly, rather than dropping or zeroing the day;
  • two long_sleep sessions on one day → the longer total_sleep_duration
    wins.

Verification:

cd Packages/StrandImport && swift build && swift test
# Build complete
# Executed 202 tests, with 1 test skipped (unrelated, gated behind
#   XIAOMI_REAL_DB) and 0 failures

All pre-existing OuraApiParserSleepTests cases (single-session fold,
invalid-span skip, huge-value guards) stay green — no regressions.

Honest verification

  • The upsert-heals-on-reimport claim above is based on reading
    Packages/WhoopStore/Sources/WhoopStore/MetricsCache.swift
    (upsertDailyMetrics/upsertSleepSessions, both true
    ON CONFLICT DO UPDATE upserts keyed by natural key, not append-only) and
    the write path from WearableDailyRow through
    Strand/Oura/OuraSyncWriter.swift into DailyMetric. I did not run a live
    Oura re-sync against a real account as part of this change (this package
    is pure/network-free and covered by swift test); the upsert mechanics
    are the same ones already exercised by the existing RHR-score heal.
  • Scope: this PR only changes the API-import path
    (Packages/StrandImport/Sources/StrandImport/OuraApiParser.swift). The
    separate file-export parser (OuraExportParser.swift, used for Oura's
    downloadable account export) has its own, independent per-day fold and
    was not touched — left for a follow-up if the same issue is confirmed
    there.

vishk23 added 2 commits July 13, 2026 00:30
The per-day fold in OuraApiParser.parseSleep used first-write-wins:
whichever session the API listed first for a `day` claimed every daily
field, so a short nap/fragment could beat the real night. Audited across
a real 24-month dataset: 36 days where totalSleepMin was 0.5-25 min while
the true main sleep (250-780 min) existed adjacent, including two
impossible daily restingHr values (92 bpm from a 32-min wake-dominated
micro-session where the real night was 277 min at 57 bpm; 90 bpm amid
41-48 bpm neighbors) plus five near-misses at 89-100 bpm. A sudden 90+ bpm
daily resting-HR spike is the visible symptom of this bug.

Select the day's MAIN session instead: a `long_sleep` session always
outranks any other type; among same-rank candidates, the longer
total_sleep_duration wins. The winner supplies every rollup field as a
unit, so fields are never mixed across two sessions on the same day.

dailyMetric is upserted keyed by (deviceId, day) (WhoopStore.upsertDailyMetrics),
so re-running Oura sync heals every already-imported day in place - no
migration needed, the same way the earlier RHR-score corruption healed.
The CSV-side OuraExportParser already skips type == "deleted" rows (a
night the user removed in the Oura app, #862); the API parser had no
type check at all, so a deleted period still became a session and --
with its full bedtime span -- could even win the day's rollup over the
real night. Skip it before it becomes a period, same rule both parsers.
@vishk23

vishk23 commented Jul 13, 2026

Copy link
Copy Markdown
Author

Pushed a second commit folding in a directly-related session-type gap found in the same audit: the API parser had no type handling for deleted periods, while the CSV-side OuraExportParser already skips them (#862). A user-removed night could therefore still become a session — and since a deleted period keeps its full bedtime span, it could even win the day's rollup that this PR's selection logic decides. Now skipped before it becomes a period, matching the CSV rule; test covers a deleted doc that would otherwise win the day on duration. StrandImport 203/203.

@ryanbr

ryanbr commented Jul 13, 2026

Copy link
Copy Markdown
Owner

The API-lane fix here is correct and well-tested — but the same first-write-wins daily-rollup bug exists in the Oura file-export lane, which runs on both platforms, and this PR doesn't touch it. So to answer "is Android applicable?": yes — not for OuraApiParser (that lane is genuinely Swift-only), but via the file-import path, which the API fix leaves exposed.

The same bug, in the file-export fold, on both platforms

The per-day sleep rollup in the export importer is per-field first-write-wins:

Packages/StrandImport/Sources/StrandImport/OuraExportParser.swift

row.totalSleepMin = row.totalSleepMin ?? session.totalSleepMin
row.deepMin       = row.deepMin ?? session.deepMin
...
row.efficiencyPct = row.efficiencyPct ?? session.efficiencyPct
row.avgHrvMs      = row.avgHrvMs ?? session.avgHrvMs
if row.restingHr == nil { row.restingHr = session.lowestHr }

android/app/src/main/java/com/noop/ingest/WearableExportImporter.kt (byte-parity twin)

d.totalSleepMin = d.totalSleepMin ?: session.totalSleepMin
d.efficiencyPct = d.efficiencyPct ?: session.efficiencyPct
if (d.restingHr == null) d.restingHr = session.lowestHr

Same root cause as the API lane — a nap/fragment listed first for a day claims the rollup — and the export carries the same type (long_sleep/short_sleep), so the same long_sleep-rank + longest-duration selection applies. It's arguably worse here: because it's per-field ??, it can mix fields across two sessions (a fragment's totalSleepMin + the real night's restingHr), not just pick the wrong session wholesale.

Suggestion

OuraApiParser and the export lane can't literally share code (different shapes), but they should share the rule: pick the day's long_sleep (then longest total_sleep_duration) and apply its fields as a unit. Extend the same fix to OuraExportParser.swift + WearableExportImporter.parseOura — that's what closes the Android exposure and stops the field-mixing.

This PR's API-lane fix stands on its own and is mergeable as-is; the file-export twin can be a follow-up (and would pair naturally with #376, which already touches the same export fold for the efficiency scale). Flagging so the file lane doesn't get forgotten — it's the same bug a phone-export user hits.

…ort lane (Swift + Kotlin)

Closes the file-lane exposure flagged in review: the account-export importer folded a day's
sleep sessions per-FIELD first-write-wins (OuraExportParser.swift JSON + CSV paths, and the
Kotlin WearableExportImporter.parseOura/parseOuraCsv twins), so a nap/fragment listed before
the real night could mix its totalSleepMin with the main session's restingHr. Applies the same
long_sleep-outranks-any-other-type, then longer-total_sleep_duration rule as OuraApiParser's
dayWinner, on both the JSON and the real per-category CSV sleep rows, on both platforms — the
winner now supplies every rollup field as a unit, never mixed across sessions.

StrandImport: 207/207 (3 new OuraExportParserTests + 1 new WearableExportImporterTests fixture).
Kotlin: transcribed carefully (no Android toolchain available to run testFullDebugUnitTest here);
4 new tests added to WearableExportImporterTest mirroring the Swift fixtures.
@vishk23

vishk23 commented Jul 13, 2026

Copy link
Copy Markdown
Author

Pushed de900bd extending the main-session day-rollup rule to the file-export lane, closing the exposure flagged in review.

What changed, both platforms:

  • Packages/StrandImport/Sources/StrandImport/OuraExportParser.swift — both the JSON sleep[] fold (parse()) and the real per-category CSV sleep rows fold (parseCSV(), sleep.csv) now track a sleepDayWinner (rank, totalSleepMin) per day, same rule as this branch's OuraApiParser.dayWinner: long_sleep outranks any other type; among equal rank, longer total_sleep_duration wins. The winner writes every rollup field (totalSleepMin/deep/light/rem/awake/efficiency/avgHrv/respRate/restingHr) as a unit — a losing session's fields are never mixed in. The existing deleted-skip (CSV ~line 225 area, and via sleepSession()/ouraSleep() for JSON) is unchanged.
    • Side fix in the same block: the CSV path's per-session WearableSleepSession construction was reading back row.efficiencyPct/row.avgHrvMs (the day's current rollup state) instead of its own row's values — harmless before since every field was ??-filled-once, but would have leaked a different session's values into a losing session's own record once the rollup started overwriting. Now uses the row's own locals.
  • android/app/src/main/java/com/noop/ingest/WearableExportImporter.ktparseOura's JSON sleep loop gets the identical sleepDayWinner treatment. parseOuraCsv needed one extra layer since it mutates the shared byDay map in place (unlike Swift's separately-merged parseCSV): it tracks its own CSV-internal winner + a SleepRollup snapshot per day, then folds only the winning day's fields into byDay via ?: in a final pass — so the pre-existing "JSON wins field-by-field, CSV only fills gaps" cross-format contract (fix(workouts): one HR device-id rule for the chart, zones and Avg HR #857) is untouched, while the CSV-internal multi-session-per-day bug is fixed the same way.

Tests:

  • Swift: 3 new fixtures in OuraExportParserTests (fragment-before-main → main wins; fragment-only day keeps the fragment; two long_sleep sessions tie-break on duration with restingHr+totalSleepMin asserted from the same winning session) + 1 new CSV-path fixture in WearableExportImporterTests (sleep.csv fragment-before-main). cd Packages/StrandImport && swift test207/207, 1 skipped by design (opt-in real-export test), 0 failures.
  • Kotlin: 4 tests transcribed 1:1 into WearableExportImporterTest (3 JSON + 1 CSV, mirroring the Swift fixtures).

Caveat — please run on your side: I have no Android toolchain in this environment (no JDK; ./gradlew can't even report --version), so the Kotlin change is careful transcription, not a compiled/executed one. I reviewed it closely (brace/paren balance, field-name cross-checks against DayAcc/SleepAcc, matching the Swift logic line-for-line) but it is untested until testFullDebugUnitTest runs. Could you run it and flag anything that doesn't build or pass?

One conflict, in `OuraApiParserSleepTests.swift` — a purely additive collision
with upstream's efficiency-normalization test. Both test blocks kept.

Note on the second commit here (`skip Oura 'deleted' sleep periods in the API
parser`): upstream landed the equivalent skip in #862 while this sat open, and
git resolved the two identical additions to a single line rather than a
conflict. The merged `OuraApiParser` has exactly one `deleted` guard, upstream's
0-100 → 0-1 `efficiency` normalization, and this branch's `dayWinner` selection,
all intact.

Verified: StrandImport 217/217 (1 skipped).
@vishk23

vishk23 commented Jul 27, 2026

Copy link
Copy Markdown
Author

Rebuilt on current main (the branch was 318 commits behind and DIRTY). One conflict, in OuraApiParserSleepTests.swift — a purely additive collision with upstream's efficiency-normalization test. Both test blocks kept.

Worth noting how the two deleted-period fixes met: upstream landed the equivalent skip in #862 while this sat open, and because both sides added the identical guard git resolved it to a single line rather than a conflict. The merged OuraApiParser has exactly one deleted guard, upstream's 0-100 → 0-1 efficiency normalization, and this branch's dayWinner selection, all intact and non-duplicated. The diff against current main is back to exactly the original 7 files.

The review's ask — extending the main-session rule to the file-export lane on both platforms — is in the branch already (de900bd2), and upstream's #862 only covered the deleted skip there, not the first-write-wins rollup, so that exposure is still open on main and this still closes it.

Verified: StrandImport 217/217 (1 skipped). Android ./gradlew testFullDebugUnitTest 3064 tests, only the pre-existing SyncChipStateTest.lastSyncedAt_takesPriorityOverHistorySync failure ("NoopApplication is not attached"), which also fails on clean main.

@pipiche38

Copy link
Copy Markdown

Kindly check #877

@vishk23

vishk23 commented Jul 30, 2026

Copy link
Copy Markdown
Author

@pipiche38 — belatedly closing the loop on your 07-27 ping here: I did pick it up, but I replied on #877 itself rather than on this thread, so it looks unanswered from here. Sorry about that.

The substance is at #877 (comment) — a sequencing note rather than an objection: #877 is what first routes an Oura night's banked R-R into respRateFromRR, so #883's beat-accuracy gate wants to land first or the night gets a confidently-wrong ~10 bpm respiration that the existing 8–25 bpm plausibility clamp won't catch. You picked that up and restated it on both threads, and nothing further is needed from me there.

On this PR: rebased onto current main on 07-27 (the branch had drifted 318 commits), one additive test conflict resolved, mergeable: CLEAN as of now. Diff is back to the original 7 files, and the file-export-lane extension @ryanbr asked for in review is in the branch at de900bd2. Ready for another look whenever there's time.

@pipiche38

Copy link
Copy Markdown

I'm sorry, @vishk23 i do not understand your comment or review
Please clarify what you expect.
This PR works fine on my end.

@ryanbr

ryanbr commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Reviewed. The fix is right and more complete than the description claims — which is the main thing to flag.

Verified

  • The rule is sound and applied identically in all three places: Swift OuraApiParser, Swift OuraExportParser, Kotlin WearableExportImporter. Same rank/duration tie-break, byte-identical comment.

  • long_sleep is not an invented protocol fact. It is already documented at OuraExportParser.swift:12 and already load-bearing, via the WHOOP 5/MG: passive optical-block phase experiment (harness for #858, on top of #546) #862 deleted-night filter.

    Correcting myself: I wrote that the PR "keeps" that filter ahead of the ranking. It does not keep it — it adds it. The API parser had no type check at all, so a deleted period became a session and, with its full bedtime span, could win the day outright. That is a second real bug fixed here (commit 03f2e465e), and it is not in the PR description either. Correctly placed before the ranking.

  • The parity requirement is fully met. Android has no Oura API parser — that path is Apple-only — so the Kotlin twin for the export path is the complete obligation, not a partial one. Worth stating explicitly because "Swift changed 2 files, Kotlin changed 1" looks asymmetric until you check why.

  • Evidence is strong: 36/36 days confirmed at session level, two impossible daily RHRs (92 bpm from a 32-min fragment against a real 277-min night at 57; 90 bpm amid neighbours at 41-48).

The body understates the PR, and that matters

It says:

Scope: this PR only changes the API-import path (OuraApiParser.swift). The separate file-export parser (OuraExportParser.swift) ... was not touched — left for a follow-up if the same issue is confirmed there.

OuraExportParser.swift is in the diff and is fixed, as is the Android export importer. The description is stale relative to the branch.

That is worth correcting rather than shrugging at, because the export path was the worse of the two. Its old fold was per-FIELD:

row.totalSleepMin = row.totalSleepMin ?? session.totalSleepMin
row.restingHr     = row.restingHr     ?? session.lowestHr        // different session!

so it could take sleep minutes from a fragment and resting HR from the real night — mixing two sessions into one day, which is strictly worse than the API path's first-write-wins. Anyone reading the body would think that is still outstanding.

One behaviour change worth naming

The winner now overwrites every field unconditionally, including with nil. So if the main long_sleep session has no avgHrvMs but a nap did, the day now shows no HRV where it previously borrowed the nap's.

That is the correct trade — mixing is the bug, and coherence beats completeness here — but it is a change beyond "stop showing impossible values", and it should be in the description rather than discovered.

Before merging

CI on this is 17 days old (job IDs from July 13; main has moved ~35 commits since). MERGEABLE CLEAN only means no textual conflict. The changed files are all in StrandImport, which swift-packages.yml does cover, so a re-run is cheap and worth having — push an empty commit or rebase and let it go green against current main.

Otherwise this is ready. Good find, and the session-level confirmation across a 24-month dataset is what makes it convincing rather than plausible.

@ryanbr
ryanbr merged commit 58d457a into ryanbr:main Jul 31, 2026
20 checks passed
ryanbr added a commit that referenced this pull request Jul 31, 2026
Notes covering everything merged since v9.2.1 (43 PRs), the in-app What's
New entry generated for both platforms, and the version bumps.

- docs/releases/v9.3.0.md — release notes plus the whatsnew front-matter,
  with title translations for all five locales so the i18n gate has no
  English fallback to warn about.
- AppChangelog.{kt,swift} + six strings.xml — generated by
  Tools/appchangelog-gen.py, not hand-written, so the two platforms carry
  byte-identical items.
- CHANGELOG.md — a 9.3.0 section in the existing house format.
- MARKETING_VERSION 9.2.2 -> 9.3.0, versionName 9.2.2 -> 9.3.0,
  versionCode 305 -> 306.

The notes lead with a 'Scores that change' section because three merges
move numbers users have already seen — gap-weighted strain (#963), the
workout resting-HR fix (#983), and the sleep transition rule (#348) — and
the Oura fix (#375) corrects the importer, not rows already written, so it
needs a re-import to take effect.
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.

3 participants