Skip to content

android: advance the #836 watermark after the post-backfill re-score - #1120

Merged
ryanbr merged 1 commit into
mainfrom
fix/836-gate-post-backfill-rescore
Aug 7, 2026
Merged

ryanbr merged 1 commit into
mainfrom
fix/836-gate-post-backfill-rescore

Conversation

@ryanbr

@ryanbr ryanbr commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Symptom

On a phone that is not battery-optimization-exempt, Android kills NOOP's background BLE, so on the next foreground/reconnect the strap hands over a large banked backlog. A WHOOP 4.0 strap log (last sync ~10h prior) shows the reconnect doing a ~25k-row offload and then a multi-day re-score — hrv diag/rhr lines marching back through ~10 days, ~70s of solid CPU over a 16.6M-row DB. And it runs twice per reconnect.

Root cause

onBackfillChunkCommitted (WhoopBleClient.kt:2060) fires after each committed backfill chunk → IntelligenceEngine.analyzeRecent(maxDays = 21), a ~54h × up-to-21-day re-score. That pass is a legitimate update path and should run — but it never advanced the #836 HR-fingerprint watermark. On Android the watermark gate (PR #841) lives only in AppViewModel's 15-min loop (AppViewModel.kt:907/1011); the post-backfill caller bypasses it and never writes it. So right after the post-backfill pass, the next 15-min idle tick sees fp != watermark and re-runs the identical multi-day re-score — the double-charge. A sliced offload can multiply it.

Fix

Capture repository.hrFingerprint() before the post-backfill pass and, on success, NoopPrefs.setAnalyzeWatermark(context, fp). The next idle tick then sees no change and skips. Captured-before / written-only-on-success mirrors the UI loop, so an interrupted/failed pass can never advance the watermark past unscored data.

The pass itself still always runs (a real sync should re-score) and the window/maxDays is unchanged — no per-day skip and no scope shrink, because the window is scored as a unit (baselines folded across all days; #899 does window-wide sleep-dedup deletes), which makes partial scoping a data-loss trap. Scores stay byte-identical; only the redundant second pass is removed.

Parity — no Swift change needed

This is exactly what Swift already does:

  • IntelligenceEngine.swift sets the watermark at the end of every successful analyzeRecent (:1549), forced or not.
  • refreshAfterCompletedBackfill calls analyzeRecent() (force: true) — always runs and advances the watermark — so the Swift 15-min analyzeRecent(force: false) tick already skips.

Android's watermark-advance was simply missing from the post-backfill path. This brings Android into lockstep with the shipped Swift behavior — no Swift twin required.

Verification

  • compileFullDebugKotlin passes.
  • Behavioral change is a scoring-trigger gate (no analytics value changes); mirrors the already-shipped Swift force+watermark semantics. The finally at :2172 still resets the debounce flag, so the early path can't strand it.

Battery context: #1007 (offload radio), #1005; this addresses the CPU half (the reconnect re-score) by killing its redundant second run.

On reconnect after a gap, a strap offload commits chunks and fires
onBackfillChunkCommitted → IntelligenceEngine.analyzeRecent(maxDays=21), a
~54h × up-to-21-day re-score (~70s of CPU on a large DB). That pass is a real
update path and should run — but it never advanced the #836 HR-fingerprint
watermark, because on Android the watermark logic lived ONLY in AppViewModel's
15-min loop. So immediately afterwards the next idle tick saw fp != watermark
and re-ran the IDENTICAL multi-day re-score: every reconnect paid for the
heavy pass twice (worse across sliced offloads).

Fix: capture repository.hrFingerprint() before the post-backfill pass and, on
success, NoopPrefs.setAnalyzeWatermark(fp) — so the next 15-min tick sees no
change and skips. Captured-before / written-only-on-success matches the UI
loop, so an interrupted pass can't advance the watermark past unscored data.

Parity: this is exactly what Swift already does — analyzeRecent sets the
watermark at the end of EVERY successful run (IntelligenceEngine.swift:1549)
and refreshAfterCompletedBackfill calls it force:true, so the Swift 15-min
tick already skips. No Swift change; this brings Android into lockstep. Scope
(maxDays / window) is unchanged — no per-day skip (the baseline-fold + #899
window-wide dedup make that a data-loss trap), so scores stay byte-identical.
@ryanbr
ryanbr merged commit 1460a9f into main Aug 7, 2026
3 checks passed
@ryanbr
ryanbr deleted the fix/836-gate-post-backfill-rescore branch August 7, 2026 06:57
ryanbr added a commit that referenced this pull request Aug 7, 2026
…tery, both platforms) (#1124)

* analyze: halve the idle backstop re-score cadence to 30 min (#836 battery)

A phone-battery capture showed ~6-7 full 21-day analyzeRecent passes in a
single hour — each reading ~21×54h of HR and running analyzeDay + the sleep
stager over a 16.6M-row DB. Root: the 15-min backstop loop's watermark gate
(#1120) can't skip while the strap is connected and streaming live HR, because
the HR fingerprint (count:maxTs) advances every second — so it re-scored the
WHOLE 21-day window every 15 min even though only TODAY's daytime HR changed
(the other 20 days, and the night-computed baseline, are unaffected by daytime
HR). That's the #836 CPU cost.

Fix: bump ANALYZE_INTERVAL_MS 15 -> 30 min. This loop is a pure BACKSTOP —
every real update (sync-with-rows, import, sleep/workout edit, recalibrate, the
#547 heal) rescores via its OWN forced path, and the #386 app-resume kick still
wakes it EARLY the moment the user opens NOOP. So halving the cadence only
delays the idle refresh of Today's live Effort/steps by up to 15 min while
connected; recovery/sleep are night-computed and untouched, and no real data
update is delayed.

Lowest-risk of the #836 options: a pure time throttle, NOT day-scoping (which
would hit the #899 window-wide-dedup / baseline-fold data-loss trap). One
constant, all-or-nothing (never a partial pass). Android-only tuning; the Swift
AppModel loop is a parity follow-up. compileFullDebugKotlin passes.

* analyze: mirror the 30-min backstop cadence on iOS/macOS (#836 parity)

Swift AppModel's analyze loop had the same 15-min backstop (Task.sleep 900s)
with the same force:false gate that can't skip while live HR streams — so iOS
paid the same #836 re-score CPU. Bump to 30 min to match the Android
ANALYZE_INTERVAL_MS change. App-target Swift — validated by app-build, not a
strap.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…score (ryanbr#1120)

On reconnect after a gap, a strap offload commits chunks and fires
onBackfillChunkCommitted → IntelligenceEngine.analyzeRecent(maxDays=21), a
~54h × up-to-21-day re-score (~70s of CPU on a large DB). That pass is a real
update path and should run — but it never advanced the ryanbr#836 HR-fingerprint
watermark, because on Android the watermark logic lived ONLY in AppViewModel's
15-min loop. So immediately afterwards the next idle tick saw fp != watermark
and re-ran the IDENTICAL multi-day re-score: every reconnect paid for the
heavy pass twice (worse across sliced offloads).

Fix: capture repository.hrFingerprint() before the post-backfill pass and, on
success, NoopPrefs.setAnalyzeWatermark(fp) — so the next 15-min tick sees no
change and skips. Captured-before / written-only-on-success matches the UI
loop, so an interrupted pass can't advance the watermark past unscored data.

Parity: this is exactly what Swift already does — analyzeRecent sets the
watermark at the end of EVERY successful run (IntelligenceEngine.swift:1549)
and refreshAfterCompletedBackfill calls it force:true, so the Swift 15-min
tick already skips. No Swift change; this brings Android into lockstep. Scope
(maxDays / window) is unchanged — no per-day skip (the baseline-fold + ryanbr#899
window-wide dedup make that a data-loss trap), so scores stay byte-identical.
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
… battery, both platforms) (ryanbr#1124)

* analyze: halve the idle backstop re-score cadence to 30 min (ryanbr#836 battery)

A phone-battery capture showed ~6-7 full 21-day analyzeRecent passes in a
single hour — each reading ~21×54h of HR and running analyzeDay + the sleep
stager over a 16.6M-row DB. Root: the 15-min backstop loop's watermark gate
(ryanbr#1120) can't skip while the strap is connected and streaming live HR, because
the HR fingerprint (count:maxTs) advances every second — so it re-scored the
WHOLE 21-day window every 15 min even though only TODAY's daytime HR changed
(the other 20 days, and the night-computed baseline, are unaffected by daytime
HR). That's the ryanbr#836 CPU cost.

Fix: bump ANALYZE_INTERVAL_MS 15 -> 30 min. This loop is a pure BACKSTOP —
every real update (sync-with-rows, import, sleep/workout edit, recalibrate, the
ryanbr#547 heal) rescores via its OWN forced path, and the ryanbr#386 app-resume kick still
wakes it EARLY the moment the user opens NOOP. So halving the cadence only
delays the idle refresh of Today's live Effort/steps by up to 15 min while
connected; recovery/sleep are night-computed and untouched, and no real data
update is delayed.

Lowest-risk of the ryanbr#836 options: a pure time throttle, NOT day-scoping (which
would hit the ryanbr#899 window-wide-dedup / baseline-fold data-loss trap). One
constant, all-or-nothing (never a partial pass). Android-only tuning; the Swift
AppModel loop is a parity follow-up. compileFullDebugKotlin passes.

* analyze: mirror the 30-min backstop cadence on iOS/macOS (ryanbr#836 parity)

Swift AppModel's analyze loop had the same 15-min backstop (Task.sleep 900s)
with the same force:false gate that can't skip while live HR streams — so iOS
paid the same ryanbr#836 re-score CPU. Bump to 30 min to match the Android
ANALYZE_INTERVAL_MS change. App-target Swift — validated by app-build, not a
strap.
ryanbr added a commit that referenced this pull request Aug 18, 2026
Adds a user-elected hourly background-offload cadence under Settings -> Power
saving, beside the existing battery threshold and "Pause HRV capture" levers.
Unlike the battery lever it is not charge-gated: once chosen, 60 min is the
BASE the other levers stretch from, at any strap charge.

Fewer periodic offloads means fewer reconnect bursts, which is the measured
WHOOP 4.0 drain mechanism (#1120/#1144) - a strap log from a 10.1.1 staging
build showed 52 periodic offloads in a session, 26 of them idle-timeout stalls,
each holding the link and re-connecting for little or no new data.

Composition: every lever now stretches from the low-refresh base with `max`, so
a lever can only make the cadence quieter, never restore a faster one the user
asked to slow down. The 5/MG empty-history stretch (45 min) and the low-battery
stretch (45 min) are both shorter than 60 min, so they become no-ops rather than
speed-ups. Default off; with the toggle off every existing path is byte-identical.

DELIBERATELY CADENCE-ONLY - the idea comes from @cbedsole03, whose version also
stretched the keep-alive 30s -> 300s and released continuous HRV capture. Both
are dropped here because they cost data rather than delaying it:

- The keep-alive tick is not just a ping: it re-arms the WHOOP 4 realtime burst
  (R10/R11) every cycle so streaming cannot lapse, and it evaluates the stall
  fuse, which is 120s on a 4.0. A 300s tick under-samples that fuse, so a
  stalled 4.0 link would sit stalled up to 5 min - prolonging exactly the
  reconnect churn this feature exists to reduce. `startKeepAlive` already
  documents that the battery gain there "rounds to nothing on a 30 s timer".
- Releasing continuous HRV capture drops dense beat-to-beat R-R while it is on,
  INCLUDING overnight, which is where that stream exists to be dense. Density
  not captured cannot be backfilled later, and it would silently override the
  user`s own Continuous-HRV-capture preference. "Pause HRV capture" (#477) and
  the #927 overnight window are the levers for that, and they stay in charge.

So the shipped feature delays sync into larger batches and loses nothing: the
strap banks to flash and only trims on our ack.
PARITY: the Android twin lands in the same PR — NoopPrefs.lowRefresh, the
WhoopBleClient LOW_REFRESH_BACKFILL_INTERVAL_MS/baseBackfillIntervalMs pair, the
same max() composition inside nextBackfillDelayMs, and the same sub-toggle inside
the Settings if(powerSaving) block. Both platforms apply on the NEXT re-arm
(matching each side's existing battery lever), so a sync in flight is never cut.
New copy localized de/es/fr/pl/pt-PT/zh on Android and de/es/fr/pl/pt-PT in the
iOS catalog.

Tests: LowRefreshCadenceTests (Swift) + LowRefreshCadenceTest (Kotlin) pin the
same five cases — base on/off, and the three composition cases (low-battery
lever, 5/MG empty-history stretch, unchanged default path).

Validation: full Android suite 4042 tests / 490 classes green (5 new);
compileFullDebugKotlin + processFullDebugResources + i18n --ci green. The Swift
half is app-target, so it is validated by app-build; note StrandTests do not run
in default CI, so the Swift twin of the cadence test is compile-checked there
rather than executed.
ryanbr added a commit that referenced this pull request Aug 18, 2026
Adds a user-elected hourly background-offload cadence under Settings -> Power
saving, beside the existing battery threshold and "Pause HRV capture" levers.
Unlike the battery lever it is not charge-gated: once chosen, 60 min is the
BASE the other levers stretch from, at any strap charge.

Fewer periodic offloads means fewer reconnect bursts, which is the measured
WHOOP 4.0 drain mechanism (#1120/#1144) - a strap log from a 10.1.1 staging
build showed 52 periodic offloads in a session, 26 of them idle-timeout stalls,
each holding the link and re-connecting for little or no new data.

Composition: every lever now stretches from the low-refresh base with `max`, so
a lever can only make the cadence quieter, never restore a faster one the user
asked to slow down. The 5/MG empty-history stretch (45 min) and the low-battery
stretch (45 min) are both shorter than 60 min, so they become no-ops rather than
speed-ups. Default off; with the toggle off every existing path is byte-identical.

DELIBERATELY CADENCE-ONLY - the idea comes from @cbedsole03, whose version also
stretched the keep-alive 30s -> 300s and released continuous HRV capture. Both
are dropped here because they cost data rather than delaying it:

- The keep-alive tick is not just a ping: it re-arms the WHOOP 4 realtime burst
  (R10/R11) every cycle so streaming cannot lapse, and it evaluates the stall
  fuse, which is 120s on a 4.0. A 300s tick under-samples that fuse, so a
  stalled 4.0 link would sit stalled up to 5 min - prolonging exactly the
  reconnect churn this feature exists to reduce. `startKeepAlive` already
  documents that the battery gain there "rounds to nothing on a 30 s timer".
- Releasing continuous HRV capture drops dense beat-to-beat R-R while it is on,
  INCLUDING overnight, which is where that stream exists to be dense. Density
  not captured cannot be backfilled later, and it would silently override the
  user`s own Continuous-HRV-capture preference. "Pause HRV capture" (#477) and
  the #927 overnight window are the levers for that, and they stay in charge.

So the shipped feature delays sync into larger batches and loses nothing: the
strap banks to flash and only trims on our ack.
PARITY: the Android twin lands in the same PR — NoopPrefs.lowRefresh, the
WhoopBleClient LOW_REFRESH_BACKFILL_INTERVAL_MS/baseBackfillIntervalMs pair, the
same max() composition inside nextBackfillDelayMs, and the same sub-toggle inside
the Settings if(powerSaving) block. Both platforms apply on the NEXT re-arm
(matching each side's existing battery lever), so a sync in flight is never cut.
New copy localized de/es/fr/pl/pt-PT/zh on Android and de/es/fr/pl/pt-PT in the
iOS catalog.

Tests: LowRefreshCadenceTests (Swift) + LowRefreshCadenceTest (Kotlin) pin the
same five cases — base on/off, and the three composition cases (low-battery
lever, 5/MG empty-history stretch, unchanged default path).

Validation: full Android suite 4042 tests / 490 classes green (5 new);
compileFullDebugKotlin + processFullDebugResources + i18n --ci green. The Swift
half is app-target, so it is validated by app-build; note StrandTests do not run
in default CI, so the Swift twin of the cadence test is compile-checked there
rather than executed.
ryanbr added a commit that referenced this pull request Aug 18, 2026
Adds a user-elected hourly background-offload cadence under Settings -> Power
saving, beside the existing battery threshold and "Pause HRV capture" levers.
Unlike the battery lever it is not charge-gated: once chosen, 60 min is the
BASE the other levers stretch from, at any strap charge.

Fewer periodic offloads means fewer reconnect bursts, which is the measured
WHOOP 4.0 drain mechanism (#1120/#1144) - a strap log from a 10.1.1 staging
build showed 52 periodic offloads in a session, 26 of them idle-timeout stalls,
each holding the link and re-connecting for little or no new data.

Composition: every lever now stretches from the low-refresh base with `max`, so
a lever can only make the cadence quieter, never restore a faster one the user
asked to slow down. The 5/MG empty-history stretch (45 min) and the low-battery
stretch (45 min) are both shorter than 60 min, so they become no-ops rather than
speed-ups. Default off; with the toggle off every existing path is byte-identical.

DELIBERATELY CADENCE-ONLY - the idea comes from @cbedsole03, whose version also
stretched the keep-alive 30s -> 300s and released continuous HRV capture. Both
are dropped here because they cost data rather than delaying it:

- The keep-alive tick is not just a ping: it re-arms the WHOOP 4 realtime burst
  (R10/R11) every cycle so streaming cannot lapse, and it evaluates the stall
  fuse, which is 120s on a 4.0. A 300s tick under-samples that fuse, so a
  stalled 4.0 link would sit stalled up to 5 min - prolonging exactly the
  reconnect churn this feature exists to reduce. `startKeepAlive` already
  documents that the battery gain there "rounds to nothing on a 30 s timer".
- Releasing continuous HRV capture drops dense beat-to-beat R-R while it is on,
  INCLUDING overnight, which is where that stream exists to be dense. Density
  not captured cannot be backfilled later, and it would silently override the
  user`s own Continuous-HRV-capture preference. "Pause HRV capture" (#477) and
  the #927 overnight window are the levers for that, and they stay in charge.

So the shipped feature delays sync into larger batches and loses nothing: the
strap banks to flash and only trims on our ack.
PARITY: the Android twin lands in the same PR — NoopPrefs.lowRefresh, the
WhoopBleClient LOW_REFRESH_BACKFILL_INTERVAL_MS/baseBackfillIntervalMs pair, the
same max() composition inside nextBackfillDelayMs, and the same sub-toggle inside
the Settings if(powerSaving) block. Both platforms apply on the NEXT re-arm
(matching each side's existing battery lever), so a sync in flight is never cut.
New copy localized de/es/fr/pl/pt-PT/zh on Android and de/es/fr/pl/pt-PT in the
iOS catalog.

Tests: LowRefreshCadenceTests (Swift) + LowRefreshCadenceTest (Kotlin) pin the
same five cases — base on/off, and the three composition cases (low-battery
lever, 5/MG empty-history stretch, unchanged default path).

Validation: full Android suite 4042 tests / 490 classes green (5 new);
compileFullDebugKotlin + processFullDebugResources + i18n --ci green. The Swift
half is app-target, so it is validated by app-build; note StrandTests do not run
in default CI, so the Swift twin of the cadence test is compile-checked there
rather than executed.
ryanbr added a commit that referenced this pull request Aug 18, 2026
…1430)

Adds a user-elected hourly background-offload cadence under Settings -> Power
saving, beside the existing battery threshold and "Pause HRV capture" levers.
Unlike the battery lever it is not charge-gated: once chosen, 60 min is the
BASE the other levers stretch from, at any strap charge.

Fewer periodic offloads means fewer reconnect bursts, which is the measured
WHOOP 4.0 drain mechanism (#1120/#1144) - a strap log from a 10.1.1 staging
build showed 52 periodic offloads in a session, 26 of them idle-timeout stalls,
each holding the link and re-connecting for little or no new data.

Composition: every lever now stretches from the low-refresh base with `max`, so
a lever can only make the cadence quieter, never restore a faster one the user
asked to slow down. The 5/MG empty-history stretch (45 min) and the low-battery
stretch (45 min) are both shorter than 60 min, so they become no-ops rather than
speed-ups. Default off; with the toggle off every existing path is byte-identical.

DELIBERATELY CADENCE-ONLY - the idea comes from @cbedsole03, whose version also
stretched the keep-alive 30s -> 300s and released continuous HRV capture. Both
are dropped here because they cost data rather than delaying it:

- The keep-alive tick is not just a ping: it re-arms the WHOOP 4 realtime burst
  (R10/R11) every cycle so streaming cannot lapse, and it evaluates the stall
  fuse, which is 120s on a 4.0. A 300s tick under-samples that fuse, so a
  stalled 4.0 link would sit stalled up to 5 min - prolonging exactly the
  reconnect churn this feature exists to reduce. `startKeepAlive` already
  documents that the battery gain there "rounds to nothing on a 30 s timer".
- Releasing continuous HRV capture drops dense beat-to-beat R-R while it is on,
  INCLUDING overnight, which is where that stream exists to be dense. Density
  not captured cannot be backfilled later, and it would silently override the
  user`s own Continuous-HRV-capture preference. "Pause HRV capture" (#477) and
  the #927 overnight window are the levers for that, and they stay in charge.

So the shipped feature delays sync into larger batches and loses nothing: the
strap banks to flash and only trims on our ack.
PARITY: the Android twin lands in the same PR — NoopPrefs.lowRefresh, the
WhoopBleClient LOW_REFRESH_BACKFILL_INTERVAL_MS/baseBackfillIntervalMs pair, the
same max() composition inside nextBackfillDelayMs, and the same sub-toggle inside
the Settings if(powerSaving) block. Both platforms apply on the NEXT re-arm
(matching each side's existing battery lever), so a sync in flight is never cut.
New copy localized de/es/fr/pl/pt-PT/zh on Android and de/es/fr/pl/pt-PT in the
iOS catalog.

Tests: LowRefreshCadenceTests (Swift) + LowRefreshCadenceTest (Kotlin) pin the
same five cases — base on/off, and the three composition cases (low-battery
lever, 5/MG empty-history stretch, unchanged default path).

Validation: full Android suite 4042 tests / 490 classes green (5 new);
compileFullDebugKotlin + processFullDebugResources + i18n --ci green. The Swift
half is app-target, so it is validated by app-build; note StrandTests do not run
in default CI, so the Swift twin of the cadence test is compile-checked there
rather than executed.
ryanbr added a commit that referenced this pull request Sep 12, 2026
…by the unit policy

#2117: a WHOOP 5 R-R window is pinned to one transport, so a window holding no beat on a scorable
channel reads back EMPTY rather than falling back. Everything beat-derived then blanks (HRV,
respiratory rate) while heart-rate values carry on, which is what a wearer reports as "HRV stopped
working" and what sent one back to 11.5.

Refusing to score mixed-unit beats is right. Refusing silently is not, and the app cannot currently
say which happened: handed nothing, the HRV analyzer honestly reports nInput=0 and has no way to tell
a strap that banked nothing from a strap whose beats the policy declined. Answering that has meant
asking the wearer for a database export, which is why the issue is blocked on data.

The store already computes both facts. They were loaded in exactly one place, the CLASSIC Today
screen, which is not the iOS default, so the wearer this explains never saw them. This carries them
to the diagnostics instead: a universal `rrTransport` line that rides every Test Centre export,
beside the clock-drift line and for the same reason, since the wearer who needs it is the one who did
not know to turn a mode on.

    rrTransport recorded=none scorable=none                                  nothing banked
    rrTransport recorded=2025-06-15 scorable=none unscorableHistory=yes      banked, all refused
    rrTransport recorded=2025-06-15 scorable=2025-07-15 ... gapDays=30       partly refused
    rrTransport recorded=2025-06-15 scorable=2025-06-15 ... gapDays=0        nothing refused

The second line is the one worth recognising, because from every other vantage point it is
indistinguishable from "no data". The line is absent entirely for a device the policy does not
govern, so a WHOOP 4 export is byte-unchanged.

Every judgement is in the pure formatter, tested on both platforms: the four states, the gap
arithmetic, and the distinction the diagnosis turns on, that a strap which never banked a beat is
NOT reported as having unscorable history. The plumbing carries facts and decides nothing.

Also folds in a duplicate that this investigation surfaced. `Whoop5RRReadPolicy` re-implemented
`DeviceRegistryStore.activeDeviceId`'s query inline, and that query decides whose unit rules a
wearer's legacy history is judged by. One copy now, which is what Android already had.

The Apple facts deliberately SURVIVE a disconnect, unlike the strap-range they sit beside. The two
describe different things: a strap range is the strap's own clock, which must not outlive the link
that reported it, while these describe what our database holds, which stays true afterwards. That
difference decides whether the line is there when it is wanted, because the wearer this exists for is
the one who notices a blank HRV, opens Test Centre and exports, quite possibly with nothing connected.
A failed read clears rather than leaving a previous answer standing.

A device the policy does not govern stops after one registry read, because the two timestamps only
feed a line the formatter suppresses for it. That case is not hypothetical: a 4.0 in a reconnect burst
(#1120) runs this repeatedly, and would otherwise fetch them from the store queue the backfill is
writing through, to discard them every time.

Two asymmetries left deliberately, both following each platform's own precedent. Apple banks the
facts on connect unconditionally, as `strapRange` does ("observability, not gated"); Android emits at
connect gated on an active mode, as its clock-drift line does. The consequence on Android is that the
line rides an export only when a mode was on as the link came up, so enabling one wants a reconnect.
ryanbr added a commit that referenced this pull request Sep 12, 2026
…by the unit policy

#2117: a WHOOP 5 R-R window is pinned to one transport, so a window holding no beat on a scorable
channel reads back EMPTY rather than falling back. Everything beat-derived then blanks (HRV,
respiratory rate) while heart-rate values carry on, which is what a wearer reports as "HRV stopped
working" and what sent one back to 11.5.

Refusing to score mixed-unit beats is right. Refusing silently is not, and the app cannot currently
say which happened: handed nothing, the HRV analyzer honestly reports nInput=0 and has no way to tell
a strap that banked nothing from a strap whose beats the policy declined. Answering that has meant
asking the wearer for a database export, which is why the issue is blocked on data.

The store already computes both facts. They were loaded in exactly one place, the CLASSIC Today
screen, which is not the iOS default, so the wearer this explains never saw them. This carries them
to the diagnostics instead: a universal `rrTransport` line that rides every Test Centre export,
beside the clock-drift line and for the same reason, since the wearer who needs it is the one who did
not know to turn a mode on.

    rrTransport recorded=none scorable=none                                  nothing banked
    rrTransport recorded=2025-06-15 scorable=none unscorableHistory=yes      banked, all refused
    rrTransport recorded=2025-06-15 scorable=2025-07-15 ... gapDays=30       partly refused
    rrTransport recorded=2025-06-15 scorable=2025-06-15 ... gapDays=0        nothing refused

The second line is the one worth recognising, because from every other vantage point it is
indistinguishable from "no data". The line is absent entirely for a device the policy does not
govern, so a WHOOP 4 export is byte-unchanged.

Every judgement is in the pure formatter, tested on both platforms: the four states, the gap
arithmetic, and the distinction the diagnosis turns on, that a strap which never banked a beat is
NOT reported as having unscorable history. The plumbing carries facts and decides nothing.

Also folds in a duplicate that this investigation surfaced. `Whoop5RRReadPolicy` re-implemented
`DeviceRegistryStore.activeDeviceId`'s query inline, and that query decides whose unit rules a
wearer's legacy history is judged by. One copy now, which is what Android already had.

The Apple facts deliberately SURVIVE a disconnect, unlike the strap-range they sit beside. The two
describe different things: a strap range is the strap's own clock, which must not outlive the link
that reported it, while these describe what our database holds, which stays true afterwards. That
difference decides whether the line is there when it is wanted, because the wearer this exists for is
the one who notices a blank HRV, opens Test Centre and exports, quite possibly with nothing connected.
A failed read clears rather than leaving a previous answer standing.

A device the policy does not govern stops after one registry read on BOTH platforms, because the two timestamps only
feed a line the formatter suppresses for it. That case is not hypothetical: a 4.0 in a reconnect burst
(#1120) runs this repeatedly, and would otherwise fetch them from the store queue the backfill is
writing through, to discard them every time.

Two asymmetries left deliberately, both following each platform's own precedent. Apple banks the
facts on connect unconditionally, as `strapRange` does ("observability, not gated"); Android emits at
connect gated on an active mode, as its clock-drift line does. The consequence on Android is that the
line rides an export only when a mode was on as the link came up, so enabling one wants a reconnect.
ryanbr added a commit that referenced this pull request Sep 12, 2026
…by the unit policy

#2117: a WHOOP 5 R-R window is pinned to one transport, so a window holding no beat on a scorable
channel reads back EMPTY rather than falling back. Everything beat-derived then blanks (HRV,
respiratory rate) while heart-rate values carry on, which is what a wearer reports as "HRV stopped
working" and what sent one back to 11.5.

Refusing to score mixed-unit beats is right. Refusing silently is not, and the app cannot currently
say which happened: handed nothing, the HRV analyzer honestly reports nInput=0 and has no way to tell
a strap that banked nothing from a strap whose beats the policy declined. Answering that has meant
asking the wearer for a database export, which is why the issue is blocked on data.

The store already computes both facts. They were loaded in exactly one place, the CLASSIC Today
screen, which is not the iOS default, so the wearer this explains never saw them. This carries them
to the diagnostics instead: a universal `rrTransport` line that rides every Test Centre export,
beside the clock-drift line and for the same reason, since the wearer who needs it is the one who did
not know to turn a mode on.

    rrTransport recorded=none scorable=none                                  nothing banked
    rrTransport recorded=2025-06-15 scorable=none unscorableHistory=yes      banked, all refused
    rrTransport recorded=2025-06-15 scorable=2025-07-15 ... gapDays=30       partly refused
    rrTransport recorded=2025-06-15 scorable=2025-06-15 ... gapDays=0        nothing refused

The second line is the one worth recognising, because from every other vantage point it is
indistinguishable from "no data". The line is absent entirely for a device the policy does not
govern, so a WHOOP 4 export is byte-unchanged.

Every judgement is in the pure formatter, tested on both platforms: the four states, the gap
arithmetic, and the distinction the diagnosis turns on, that a strap which never banked a beat is
NOT reported as having unscorable history. The plumbing carries facts and decides nothing.

Also folds in a duplicate that this investigation surfaced. `Whoop5RRReadPolicy` re-implemented
`DeviceRegistryStore.activeDeviceId`'s query inline, and that query decides whose unit rules a
wearer's legacy history is judged by. One copy now, which is what Android already had.

The Apple facts deliberately SURVIVE a disconnect, unlike the strap-range they sit beside. The two
describe different things: a strap range is the strap's own clock, which must not outlive the link
that reported it, while these describe what our database holds, which stays true afterwards. That
difference decides whether the line is there when it is wanted, because the wearer this exists for is
the one who notices a blank HRV, opens Test Centre and exports, quite possibly with nothing connected.
A failed read clears rather than leaving a previous answer standing.

A device the policy does not govern stops after one registry read on BOTH platforms, because the two timestamps only
feed a line the formatter suppresses for it. That case is not hypothetical: a 4.0 in a reconnect burst
(#1120) runs this repeatedly, and would otherwise fetch them from the store queue the backfill is
writing through, to discard them every time.

Two asymmetries left deliberately, both following each platform's own precedent, and both worth
stating precisely rather than as "it works on both".

Apple banks the facts from a `connected` transition, unconditionally, as `strapRange` does
("observability, not gated"), and the assembler formats them at export time.

Android formats and logs inline, gated on an active mode, from inside the GET_DATA_RANGE reply
handler where its clock-drift sibling lives. Two consequences follow. The line rides an export only
when a mode was on as the link came up, so enabling one wants a reconnect. And it is coupled to a
frame this diagnostic does not otherwise care about: the facts describe our own database, so a strap
that never answers the range request, or answers unparseably, yields no line even though the facts
were readable the whole time. That coupling is inherited from where the sibling line sits rather than
chosen, and it is the weaker half of this change. Moving it to a connect hook is the follow-up, kept
out of here because it is churn in the BLE path for a placement that does fire once per connect in
the ordinary case.
ryanbr added a commit that referenced this pull request Sep 12, 2026
…by the unit policy

#2117: a WHOOP 5 R-R window is pinned to one transport, so a window holding no beat on a scorable
channel reads back EMPTY rather than falling back. Everything beat-derived then blanks (HRV,
respiratory rate) while heart-rate values carry on, which is what a wearer reports as "HRV stopped
working" and what sent one back to 11.5.

Refusing to score mixed-unit beats is right. Refusing silently is not, and the app cannot currently
say which happened: handed nothing, the HRV analyzer honestly reports nInput=0 and has no way to tell
a strap that banked nothing from a strap whose beats the policy declined. Answering that has meant
asking the wearer for a database export, which is why the issue is blocked on data.

The store already computes both facts. They were loaded in exactly one place, the CLASSIC Today
screen, which is not the iOS default, so the wearer this explains never saw them. This carries them
to the diagnostics instead: a universal `rrTransport` line that rides every Test Centre export,
beside the clock-drift line and for the same reason, since the wearer who needs it is the one who did
not know to turn a mode on.

    rrTransport recorded=none scorable=none                                  nothing banked
    rrTransport recorded=2025-06-15 scorable=none unscorableHistory=yes      banked, all refused
    rrTransport recorded=2025-06-15 scorable=2025-07-15 ... gapDays=30       partly refused
    rrTransport recorded=2025-06-15 scorable=2025-06-15 ... gapDays=0        nothing refused

The second line is the one worth recognising, because from every other vantage point it is
indistinguishable from "no data". The line is absent entirely for a device the policy does not
govern, so a WHOOP 4 export is byte-unchanged.

Every judgement is in the pure formatter, tested on both platforms: the four states, the gap
arithmetic, and the distinction the diagnosis turns on, that a strap which never banked a beat is
NOT reported as having unscorable history. The plumbing carries facts and decides nothing.

The whole line is additionally pinned byte for byte, with the same literal on each side. The other
cases assert fragments, which would have let the platforms drift on anything a `contains` does not
look at: field order, spacing, the shared date format. Two independent implementations produce it
(ISO8601DateFormatter against SimpleDateFormat, `.rounded()` against Math.round), so this is verified
by oracle rather than by reading them side by side and judging them equivalent.

Also folds in a duplicate that this investigation surfaced. `Whoop5RRReadPolicy` re-implemented
`DeviceRegistryStore.activeDeviceId`'s query inline, and that query decides whose unit rules a
wearer's legacy history is judged by. One copy now, which is what Android already had.

The Apple facts deliberately SURVIVE a disconnect, unlike the strap-range they sit beside. The two
describe different things: a strap range is the strap's own clock, which must not outlive the link
that reported it, while these describe what our database holds, which stays true afterwards. That
difference decides whether the line is there when it is wanted, because the wearer this exists for is
the one who notices a blank HRV, opens Test Centre and exports, quite possibly with nothing connected.
A failed read clears rather than leaving a previous answer standing.

A device the policy does not govern stops after one registry read on BOTH platforms, because the two timestamps only
feed a line the formatter suppresses for it. That case is not hypothetical: a 4.0 in a reconnect burst
(#1120) runs this repeatedly, and would otherwise fetch them from the store queue the backfill is
writing through, to discard them every time.

Two asymmetries left deliberately, both following each platform's own precedent, and both worth
stating precisely rather than as "it works on both".

Apple banks the facts from a `connected` transition, unconditionally, as `strapRange` does
("observability, not gated"), and the assembler formats them at export time.

Android formats and logs inline, gated on an active mode, from inside the GET_DATA_RANGE reply
handler where its clock-drift sibling lives. Two consequences follow. The line rides an export only
when a mode was on as the link came up, so enabling one wants a reconnect. And it is coupled to a
frame this diagnostic does not otherwise care about: the facts describe our own database, so a strap
that never answers the range request, or answers unparseably, yields no line even though the facts
were readable the whole time. That coupling is inherited from where the sibling line sits rather than
chosen, and it is the weaker half of this change. Moving it to a connect hook is the follow-up, kept
out of here because it is churn in the BLE path for a placement that does fire once per connect in
the ordinary case.
ryanbr added a commit that referenced this pull request Sep 12, 2026
…d by the unit policy (#2118)

diag: say whether a device's beats were never banked or were refused by the unit policy

#2117: a WHOOP 5 R-R window is pinned to one transport, so a window holding no beat on a scorable
channel reads back EMPTY rather than falling back. Everything beat-derived then blanks (HRV,
respiratory rate) while heart-rate values carry on, which is what a wearer reports as "HRV stopped
working" and what sent one back to 11.5.

Refusing to score mixed-unit beats is right. Refusing silently is not, and the app cannot currently
say which happened: handed nothing, the HRV analyzer honestly reports nInput=0 and has no way to tell
a strap that banked nothing from a strap whose beats the policy declined. Answering that has meant
asking the wearer for a database export, which is why the issue is blocked on data.

The store already computes both facts. They were loaded in exactly one place, the CLASSIC Today
screen, which is not the iOS default, so the wearer this explains never saw them. This carries them
to the diagnostics instead: a universal `rrTransport` line that rides every Test Centre export,
beside the clock-drift line and for the same reason, since the wearer who needs it is the one who did
not know to turn a mode on.

    rrTransport recorded=none scorable=none                                  nothing banked
    rrTransport recorded=2025-06-15 scorable=none unscorableHistory=yes      banked, all refused
    rrTransport recorded=2025-06-15 scorable=2025-07-15 ... gapDays=30       partly refused
    rrTransport recorded=2025-06-15 scorable=2025-06-15 ... gapDays=0        nothing refused

The second line is the one worth recognising, because from every other vantage point it is
indistinguishable from "no data". The line is absent entirely for a device the policy does not
govern, so a WHOOP 4 export is byte-unchanged.

Every judgement is in the pure formatter, tested on both platforms: the four states, the gap
arithmetic, and the distinction the diagnosis turns on, that a strap which never banked a beat is
NOT reported as having unscorable history. The plumbing carries facts and decides nothing.

The whole line is additionally pinned byte for byte, with the same literal on each side. The other
cases assert fragments, which would have let the platforms drift on anything a `contains` does not
look at: field order, spacing, the shared date format. Two independent implementations produce it
(ISO8601DateFormatter against SimpleDateFormat, `.rounded()` against Math.round), so this is verified
by oracle rather than by reading them side by side and judging them equivalent.

Also folds in a duplicate that this investigation surfaced. `Whoop5RRReadPolicy` re-implemented
`DeviceRegistryStore.activeDeviceId`'s query inline, and that query decides whose unit rules a
wearer's legacy history is judged by. One copy now, which is what Android already had.

The Apple facts deliberately SURVIVE a disconnect, unlike the strap-range they sit beside. The two
describe different things: a strap range is the strap's own clock, which must not outlive the link
that reported it, while these describe what our database holds, which stays true afterwards. That
difference decides whether the line is there when it is wanted, because the wearer this exists for is
the one who notices a blank HRV, opens Test Centre and exports, quite possibly with nothing connected.
A failed read clears rather than leaving a previous answer standing.

A device the policy does not govern stops after one registry read on BOTH platforms, because the two timestamps only
feed a line the formatter suppresses for it. That case is not hypothetical: a 4.0 in a reconnect burst
(#1120) runs this repeatedly, and would otherwise fetch them from the store queue the backfill is
writing through, to discard them every time.

Two asymmetries left deliberately, both following each platform's own precedent, and both worth
stating precisely rather than as "it works on both".

Apple banks the facts from a `connected` transition, unconditionally, as `strapRange` does
("observability, not gated"), and the assembler formats them at export time.

Android formats and logs inline, gated on an active mode, from inside the GET_DATA_RANGE reply
handler where its clock-drift sibling lives. Two consequences follow. The line rides an export only
when a mode was on as the link came up, so enabling one wants a reconnect. And it is coupled to a
frame this diagnostic does not otherwise care about: the facts describe our own database, so a strap
that never answers the range request, or answers unparseably, yields no line even though the facts
were readable the whole time. That coupling is inherited from where the sibling line sits rather than
chosen, and it is the weaker half of this change. Moving it to a connect hook is the follow-up, kept
out of here because it is churn in the BLE path for a placement that does fire once per connect in
the ordinary case.
suprfire pushed a commit to suprfire/noop that referenced this pull request Oct 13, 2026
buzz -> buzzAcknowledged alters what goes over the radio on every mirrored notification, in a PR whose subject is a settings screen, and the justification is the ryanbr#921 hypothesis, not a measurement that a without-response write was dropped. Confirmed writes cost a GATT round trip on a strap family with a drain history (ryanbr#1120/ryanbr#1144/ryanbr#1197/ryanbr#1198). The picker lands with the bare write; the acknowledged write ships separately with drop evidence attached, so a later drain move has one variable to look at.
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.

1 participant