Repository navigation
android: advance the #836 watermark after the post-backfill re-score - #1120
Merged
Merged
Conversation
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
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.
10 tasks done
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/rhrlines 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 inAppViewModel'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 seesfp != watermarkand 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/
maxDaysis 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.swiftsets the watermark at the end of every successfulanalyzeRecent(:1549), forced or not.refreshAfterCompletedBackfillcallsanalyzeRecent()(force: true) — always runs and advances the watermark — so the Swift 15-minanalyzeRecent(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
compileFullDebugKotlinpasses.finallyat:2172still 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.