Skip to content

fix: preserve HRV with partial WHOOP 4 history - #2496

Closed
rodrigosa7 wants to merge 4 commits into
ryanbr:mainfrom
rodrigosa7:fix/whoop4-hrv-partial-history
Closed

rodrigosa7 wants to merge 4 commits into
ryanbr:mainfrom
rodrigosa7:fix/whoop4-hrv-partial-history

Conversation

@rodrigosa7

@rodrigosa7 rodrigosa7 commented Sep 26, 2026 •

Copy link
Copy Markdown

What this PR does

A WHOOP 4 scoring read could choose type-47 historical R-R data for its entire multi-night interval as soon as any historical row existed. Partial offloads then hid unlabelled live R-R intervals in the remaining hours, leaving nights without enough coverage to score HRV. This changes selection to use the fuller of the historical and live streams in each UTC hour, with history winning ties. The Swift and Android readers use the same policy.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • CI / tooling

How it was tested

  • swift test --filter RrSourceChannelTests passed (22 tests), including the partial-history multi-hour regression.
  • Per request, the local Android test run was stopped so GitHub Actions can run the complete configured checks. CI results are pending.

Checklist

  • Swift package tests pass for any package I touched (swift test in Packages/<name>)
  • Android unit tests pass if I touched android/ (./gradlew testFullDebugUnitTest; after a branch switch add --no-build-cache --rerun-tasks, or Gradle may fail classes you never touched)
  • No new build warnings introduced
  • UI changes use only StrandDesign tokens — no hardcoded colors, fonts, or spacing
  • No hardcoded hex frame bytes; protocol facts live in the schema / decoders
  • Follows the conventions in docs/CONTRIBUTING.md
  • I did not commit generated output (Strand.xcodeproj/) or any secrets/keystores

Related issues

Refs #1118, #2335

ryanbr added a commit that referenced this pull request Sep 27, 2026
Labelling live WHOOP 4 beats is the point of the change, so the ingress test
moves to the new shape rather than losing the assertion: a standard-BLE beat is
`whoop4Standard` now where it was left unlabelled, and that NULL is exactly what
let a partial type-47 offload hide live intervals. Only a beat with no family
stays nil. The test's own name reads truer for it.

`extractStreams` gained `family` on both platforms in the same change, which
changed the arity on both sides at once and unpaired them in the ledger even
though they moved together. Declared twin references pair them again, and the
derived authority is refreshed for it: the baseline holds at 292 known findings
and no disposition is touched.

The Kotlin query now says why it is not `AS MATERIALIZED` like the Swift twin.
That hint wants SQLite 3.35 and `minSdk` is 26, so it is a syntax error on most
supported devices; the cost is the per-row re-evaluation the Swift comment names,
and the note exists so nobody closes the gap by adding the keyword.

Worth stating because the PR description says otherwise: the merged policy is
provenance order, not the fuller stream. History, then type-40 realtime, then
standard BLE, then legacy rows, chosen per UTC hour. Commits one to three
selected by count; commit four replaced that with `MIN(rank)` and rewrote the
tests to pin it. Keeping it: type-47 carries the strap's own device timestamps
where standard BLE is stamped on receipt, which is where #1008's cross-second
over-count came from, so the better-timestamped stream winning is the safer read.
The residual is that an hour holding sparse history still prefers it over dense
live beats.

Refs #2496, #1118, #2335
@ryanbr

ryanbr commented Sep 27, 2026

Copy link
Copy Markdown
Owner

Merged via #2515, rebased so all four of your commits keep their authorship and dates: aee05cb, 8fec919, 8f16e9e, 3472cd1, with 12ca2f0 on top.

Thanks @rodrigosa7. The diagnosis is the valuable part: history winning the entire multi-night interval the moment one row existed, so a partial offload starved every hour it never covered. Moving the decision to the hour is the right granularity, and testWhoop4PartialHistoryKeepsUnlabelledRowsFromOtherHours pins exactly that.

Three things underneath it are worth calling out. Giving WHOOP 4 three labelled transports where live traffic was all NULL is the direct follow-on from #2478, which found that history and live BLE both carrying srcChannel NULL was what made them indistinguishable in the first place. Legacy NULL rows still participate at the lowest rank, so existing installs benefit rather than only future data. And bumping the read cache keys on both platforms (v4 to v5, s3 to s4) is the sort of thing that gets forgotten and then serves stale memoised reads straight past the change.

One change went in on top, in three parts.

The ingress test still pinned the old NULL for a live standard-BLE beat, so build (Strand, macOS) was red. Labelling that beat is the point of the change, so the assertion moved to [.whoop5Standard, .whoop4Standard, nil] rather than losing coverage; only a beat with no family stays nil, and the test's own name reads truer for it. That leg is the only place StrandTests runs, which is why stopping the local run hid it.

extractStreams gained family on both platforms at once, changing the arity on both sides together and unpairing them in the ledger even though they moved as one. Declared twin references pair them again and the derived authority is refreshed, with the baseline holding at 292 known findings and no disposition touched. Worth knowing for next time: the parity tools are worth running locally on a change like this, because parity-governance triggers only on Tools/parity_* while the files you touched sit inside the parity globs, so your branch could not have caught it.

And the Kotlin query now records why it is not AS MATERIALIZED like the Swift twin: the hint wants SQLite 3.35 and minSdk is 26, so it would be a syntax error on most supported devices. The note is there so the asymmetry does not get closed by adding the keyword.

One thing to flag rather than change. The description says the fuller stream wins per hour with history breaking ties, which is what your first three commits did. The fourth replaced that with MIN(rank) provenance order and rewrote the tests to pin it, so the code and tests agree and it is the prose that is stale. I kept provenance order: type-47 carries the strap's own device timestamps where standard BLE is stamped on receipt, and receipt stamping is where #1008's cross-second over-count came from, so the better-timestamped stream winning is the safer read even when sparser. The residual is that an hour holding sparse history still prefers it over dense live beats. If coverage should win there instead, that is a scoring decision worth its own issue rather than a quiet revert.

What none of this establishes is whether a real partially-offloaded 4.0 night now scores HRV. That wants a 4.0 with a partial type-47 offload, so it stays open on the hardware.

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