Skip to content

rr: label WHOOP 4 type-47 history and score one R-R stream per window - #2478

Merged
ryanbr merged 3 commits into
mainfrom
rb-2441-whoop4-hrv
Sep 26, 2026
Merged

ryanbr merged 3 commits into
mainfrom
rb-2441-whoop4-hrv

Conversation

@ryanbr

@ryanbr ryanbr commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

@rodrigosa7's work from #2441, rebased onto current main with the conflicts worked through and the parity gate satisfied. Both of their commits are unchanged. This branch exists only because a push to their fork is refused (maintainer_can_modify reports true, the push does not), so the follow-up could not be added to their PR. Merging with rebase so all three commits keep their own author.

Raised in #2335, #1118, #2336.

Their change

WHOOP 4.0 type-47 historical R-R gets its own label (code 8), so it can be told apart from the overlapping unlabelled live BLE feed. HRV reads then select ONE stream for the whole window, history when the window has any, the unlabelled rows otherwise. Exact-key historical replays promote existing legacy rows, and the analysis fingerprints bump so cached nights rescore.

The diagnosis is right and it matches the shape #2423 settled on for Oura: when two sources cover the same beats, score one of them for the interval rather than splicing both and reading 1.0+ coverage. Here the pair read as crossSecondOverCount and the #1118 gate withheld a plausible whole-night RMSSD.

Checked rather than assumed: the family: DeviceFamily = .whoop4 default that labels unlabelled frames looks risky in isolation, but it mirrors the Kotlin twin, where that exact default has been on main at three signatures already, and both production call sites (Backfiller, RawHistoryArchive) pass it explicitly.

What the follow-up does

The rebase, which is the substantive part. The base predates #2423 and #2457 and both touch the one read this changes, so taking either side literally would have dropped one of them without saying so. rrIntervals is now a three-way choice: WHOOP 5 takes the lowest scorable transport, a WHOOP 4 takes its type-47 history (or its unlabelled rows when the window has none), and everything else takes the Oura branch, one channel per window. OuraRedrainCollapse.withoutRedrainedRuns still wraps the result on both platforms; the Kotlin conflict would have removed it from WhoopRepository too.

The parity gate. parity-governance.yml filters on Tools/parity_*, and the branch as SUBMITTED touched only Packages/** and android/**, so the gate would not have run on it and the findings below would have reached main ungoverned. It runs on this branch only because the follow-up commit touches Tools/parity_dispositions.json, which matches that filter. Found by running it locally, and each of the five findings got the remedy that fits it:

  • extractHistoricalStreams took a declared twin reference, NOT a disposition. The Kotlin twin exists; adding family only moved the Swift identity, which the inventory keys on arity. A platform_specific rationale there would have been false, and permanent.
  • the three Room DAO methods and their two @Query constants took platform_specific dispositions. Room needs a generated method and a compile-time constant string; the Swift store issues the same SQL inline in Reads.swift and StreamStore.swift, which is why the WHOOP 5 siblings are one-sided for the same reason. Hashes came from parity_ledger._canonical_sha256.

--migrate-authority was deliberately not used. It waives whether the BASE's stored manifest reproduces, so using it to absorb debt a branch introduces would be laundering.

Two small things: isWhoop4RRSource gained the doc its siblings have, including that confirmedRegistryFamily returns nil for an unknown registry and nil must not be read as "a WHOOP 4"; and the promote path names the enum case instead of force-unwrapping an optional its own pattern match already proved non-nil.

Verification

  • swift test WhoopProtocol: 803 tests, 0 failures, 1 corpus-dependent skip
  • swift build WhoopStore: clean, with the merged three-way predicate
  • swift test WhoopStore: cannot link on Linux (sqlite3_snapshot_cmp), so its new selection tests are CI's to run
  • Android: compile clean, 6462 tests, 2 failures
  • parity_ratchet.py: errors=0 locally, and parity-governance is in this PR's own check roster
  • parity_ledger.py: the three DAO findings remain listed as inventory drift and are dispositioned, which is what the ratchet checks

The two Android failures are DebugExportSchedulerTest.targetEarlierTodayRollsToTomorrow and targetEqualToNowRollsToTomorrow, which fail identically on untouched main. They are #2477, a DST-boundary bug in that test, and they clear by themselves after 2026-09-28.

One known trade, worth stating rather than discovering later

Promotion relabels NULL to code 8 only on exact key matches, while the read is all-or-nothing per window. A PARTIAL historical offload therefore promotes part of a night, and the read then drops the un-promoted unlabelled rows, lowering coverage, which is what the #1118 gate refuses on. So an interrupted offload could newly withhold an RMSSD that today's over-counted read produces.

Merged as-is on purpose. The alternative, preferring history and filling gaps from the live feed, re-introduces the overlap at the seam, which is the bug. The direction of failure here is a refusal to score rather than a wrong number, and a refusal is visible in the hrv diag coverage line.

Hardware proof is owed, as the description says: a whole-night RMSSD on a 4.0 whose window carries both a type-47 offload and live BLE, with hrv diag showing coverage at or below 1.0 rather than crossSecondOverCount.

rodrigosa7 and others added 3 commits September 26, 2026 12:43
… WHOOP 4 rebase

The branch was written against a base that predates #2423 and #2457, and both
touch the ONE read it changes. A literal conflict resolution would have dropped
one or both silently, so this records what the merged read has to keep.

`rrIntervals` is now a three-way choice rather than two: WHOOP 5 picks the lowest
scorable transport for the interval; a WHOOP 4 picks its labelled type-47 history,
or its unlabelled standard-BLE rows when the window has no history; everything
else takes the Oura branch from #2423, one beat channel per window, the fuller of
green and the amplitude family. `OuraRedrainCollapse.withoutRedrainedRuns` still
wraps the result on BOTH platforms, which the Kotlin conflict would also have
removed from `WhoopRepository`.

Parity, which CI would not have caught here: `parity-governance.yml` filters on
`Tools/parity_*`, and this branch touches `Packages/**` and `android/**`, so that
job never runs on it. Run locally instead, and the ratchet named five findings.

`extractHistoricalStreams` gained a declared twin reference rather than a
disposition: the Kotlin twin exists, and adding `family` only moved the Swift
identity, which the inventory keys on arity. Calling that platform-specific would
have written a false rationale into the registry permanently.

The three Room DAO methods and their two `@Query` constants took
`platform_specific` dispositions. Room needs a compile-time constant string and a
generated method; the Swift store issues the same SQL inline in `Reads.swift` and
`StreamStore.swift`, which is why the WHOOP 5 siblings are one-sided for the same
reason. Hashes came from `parity_ledger._canonical_sha256`, not by hand.

`--migrate-authority` was deliberately not used. It waives whether the BASE's
stored manifest reproduces, and reaching for it to absorb debt a branch introduces
is laundering; the ratchet asks for an issue-bound authority change instead.

Two smaller things: `isWhoop4RRSource` gained the doc its siblings have, including
that `confirmedRegistryFamily` returns nil for an unknown registry and that nil
must not be read as "a WHOOP 4"; and the promote path names
`RRSourceChannel.whoop4Historical` instead of force-unwrapping the optional its
own pattern match already proved non-nil.

The derived authority is refreshed here too, which the first push missed. The
ratchet went green on the dispositions and I took that as the whole verdict, but
the ledger had already said `twin-map-authority-drift|functions`: pairing
`extractHistoricalStreams` and adding five functions moved the derived inventory,
so the stored snapshot no longer reproduced. CI caught it as
`test_checked_metadata_is_compact_v3_and_expands_losslessly`, stored 4486 against a
derived 4491.

Refreshed with `--refresh-derived` and NOT `--migrate-authority`: the base
authority reproduces fine, only this branch's derived counts moved, and migration
would have waived a check that was not the thing failing. Findings held at 292.
@ryanbr
ryanbr merged commit 1106f6d into main Sep 26, 2026
18 checks passed
@ryanbr
ryanbr deleted the rb-2441-whoop4-hrv branch September 26, 2026 01:34
DX23876 added a commit to DX23876/noop that referenced this pull request Sep 28, 2026
…e AI-12)

The reconciler matched each beat against the nearest beat of a better path
within 3 s. That only pairs the two copies of a beat while the strap's clock
(history) and the phone's receipt clock (standard 0x2A37) agree to within the
radius, and they drift. On one WHOOP 5/MG, nights 2026-09-09 to 09-26, the
standard copy sat most often 1-2 s after its history twin at the start and
5-6 s on 09-26. The radius still held only because the history train is dense
enough that some beat always lies nearby; the seams it spliced into the
scored train rose from 12-48 a night to 146 and 233.

Each five-minute UTC segment is now read from one path: the highest-ranked
(history > standard > realtime) that occupies at least four fifths of the
seconds the fullest path occupies there. Occupancy counts seconds, not rows.
Two labelled paths never share a segment. Rows without provenance fill only
outside the chosen path's stretch, so an upgrade or a partial offload never
blanks the older beats beside it. Only the owner family's labels name a
path. rrIntervals reads the segments a window touches in full and cuts back
afterwards, so a five-minute read selects what a whole-night read does; the
day witness covers the same whole segments and both witness prefixes bump.

Upstream (ryanbr#2478) picks one source per UTC hour, where an hour holding a
little history still hides a complete live train; the completeness bar
closes that residual too.

Checked on the 18 real nights above, old reconciler as the oracle against
the new one through HRVAnalyzer: no coverage verdict changes, coverage moves
by at most 0.008, RMSSD by at most 0.2 ms, seams fall to 0-2 a night.

The hrv diag gains an "rr paths" line (segments per path, seams) and, for
the newest night, "rr paths clock" with the standard-minus-history offset,
so the drift the segment choice guards against stays visible in the log.

Analysis migration required: yes. Recipe AI-12 re-scores the standard
21-day window without day reuse. No raw row is rewritten or deleted.

Verified: WhoopStore 792 and StrandAnalytics 2776 tests pass, Strand
(macOS) and NOOPiOS build, StrandTests 3652 pass after the recipe pin fix.

Refs ryanbr#1118, ryanbr#2478.
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