perf: drop two redundant hot-path reads added in 10.5.1, plus two staleness/parity fixes found reviewing them - #1521
Merged
Merged
Conversation
…el on hot paths Two allocations added during the 10.5.0 -> 10.5.1 work that do not need to exist. R-R diagnostic ords (both platforms). The over-count branch of the sleep R-R diagnostics calls three analysers, and each was handed its own freshly mapped copy of the night's `ord` column -- roughly 70k elements a night, on a branch reached about 21 times per `analyzeRecent`, which itself runs every 15 minutes. The three copies are identical by construction, so two of them were pure waste. Built once above the calls now. Same values, same order, same diagnostic strings -- the pinned parity tests (`DuplicatePairRatioTest` / `HRVAnalyzerDuplicatePairTests`) still assert the exact output. Strap-model read (iOS). `stepsPipelineActive` read `WhoopModel.persisted`, a `UserDefaults` lookup, from inside `keyMetricTile` -- once per metric per body pass, on a view that recomposes with live heart rate. Read through `@AppStorage` instead, which is the idiom the rest of `TodayView` already uses. The `?? .whoop4` fallback is preserved exactly: an absent key reads as the empty string, which is not a valid raw value, so it lands on the same default. Secondary benefit -- the value is now observed, so switching straps re-renders the tile instead of waiting for an unrelated recomposition. No behavioural change on either platform; both are refactors of work introduced in 10.5.1 itself. Verified: android compileFullDebugKotlin + testFullDebugUnitTest (4189 tests, 0 failures, --no-build-cache --rerun-tasks); i18n audit clean; doc lint clean. App-target Swift (`TodayView`) validated by app-build, which default CI does not run -- see the PR for the run.
…eads Found re-reviewing the iOS half of this branch. `stepsCalibrationPrompt` returns null for anything that is not a 4.0 -- it reads `noop.selectedWhoopModel` as its first act -- but the `remember` wrapping it was keyed only on the three calibration values. The model key is rewritten under a composed Today screen, both from Settings and from live detection in `WhoopBleClient`, so switching straps left the previous answer on screen until a calibration value happened to move independently. Adding the model to the key list costs a loaded-map lookup per pass and leaves the rest of the prompt memoised, which is where the cost being avoided was. This is the same defect the iOS change in this branch removes from the other side: reading the model through `@AppStorage` makes it observed, so the tile re-renders on a strap switch. Both platforms now react to the same event. Not covered by a test: the repo's Android tests are JVM unit tests and this is a Compose `remember` key, which needs instrumentation to observe. Verified: compileFullDebugKotlin + testFullDebugUnitTest (4189 tests, 0 failures, --no-build-cache --rerun-tasks); doc lint and i18n audit clean.
…been identified (iOS) Parity gap found reviewing this branch. #1512 landed the same prompt on both platforms, and the two halves disagreed about one state from the first commit. `stepsCalibrationPrompt` on Android reads the model key and bails outright when it is unset (`?: return null`). The Apple side read it through `WhoopModel.persisted`, which falls back to `.whoop4` when nothing has been selected — so a user who has never paired a strap read as a 4.0 owner. Given a scored day with no step count on it, which an import-only user has, iOS offered "Need N more days where your phone also counted steps" for a strap nobody has ever seen, and Android showed nothing. Optional-chained now, so an unset key is not a 4.0. The key is written by `BLEManager.persistSelectedModel` as soon as a strap is identified and only ever holds a `WhoopModel` rawValue, so every real 4.0 owner still qualifies and #1512's intent is untouched: what goes away is only the never-paired case. `hasDayData` stays as the second guard. It answers whether the date being shown has anything scored on it, which is a different question from whether a strap is known, and it was carrying both jobs before. Deliberately NOT fixed here, to keep this to the one state: the calibration-state terms are still OR-ed outside the family check, so leftover sample days can activate the pipeline on a non-4.0. Android cannot do that. Filing separately. Verified: app-build (see PR). No Android change — Android already behaves this way, which is what made the gap visible.
This was referenced Aug 21, 2026
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.
Going back over what changed between 10.5.0 and 10.5.1 for anything worth tightening. Two things were doing avoidable work on paths that run constantly, and re-reviewing each one turned up a real bug beside it.
Everything here is a refactor of code introduced in 10.5.1 itself — nothing predates this release.
1. Build the R-R
ordarray once (both platforms)The over-count branch of the sleep R-R diagnostics calls three analysers, and each was handed its own freshly mapped copy of the night's
ordcolumn. Roughly 70k elements a night, on a branch reached about 21 times peranalyzeRecent, which itself runs every 15 minutes. All three expressions were byte-identical, so two of the copies were pure waste.Hoisted above the calls. Same values in the same order, so the diagnostic strings are unchanged. (#1510)
2. Read the strap model through
@AppStorage(iOS)stepsPipelineActivereadWhoopModel.persisted— aUserDefaultslookup — from insidekeyMetricTile, so once per metric per body pass, on a view that recomposes with live heart rate.@AppStorageis the idiom the rest ofTodayViewalready uses, andSettingsViewalready declares this same key that way. The secondary benefit turned out to matter: the value becomes observed, so the tile re-renders when the model changes.BLEManager.persistSelectedModelwrites it toUserDefaults.standard, the same suite@AppStoragebinds to.3. Android's prompt could not see a strap switch
Making the iOS read observed prompted me to check what Android does on the same event, and it could not see it at all.
stepsCalibrationPromptreadsnoop.selectedWhoopModelas its first act and returns null for anything that is not a 4.0. Therememberwrapping it was keyed on the three calibration values only. That key is rewritten under a composed Today screen — from Settings, and from live detection inWhoopBleClient— so switching straps left the previous answer on screen until a calibration value happened to move independently.Added the model to the key list. It costs a loaded-map lookup per pass; what stays memoised is the rest of the prompt, which is where the cost being avoided was.
4. The unset strap model read as a 4.0 (iOS)
The parity check on (2). #1512 landed this prompt on both platforms and the two halves disagreed about one state from the first commit.
Android bails when the model key is unset (
?: return null). Apple read it throughWhoopModel.persisted, which falls back to.whoop4when nothing has been selected — so a user who has never paired a strap read as a 4.0 owner. Given a scored day with no step count on it, which an import-only user has, iOS offered "Need N more days where your phone also counted steps" for a strap nobody has ever seen, and Android showed nothing.Optional-chained now, so an unset key is not a 4.0. The key is written as soon as a strap is identified and only ever holds a
WhoopModelrawValue, so every real 4.0 owner still qualifies and #1512's intent is untouched — what goes away is only the never-paired case.hasDayDatastays as the second guard; it answers whether the date being shown has anything scored on it, which is a different question from whether a strap is known, and it was carrying both jobs before.Left alone deliberately: the calibration-state terms are still OR-ed outside the family check, so leftover sample days can activate the pipeline on a non-4.0 where Android cannot. That one is a design question rather than an oversight — filed as #1523 rather than decided inside a performance PR.
One non-finding, recorded so it isn't re-raised
Android suppresses the prompt on three conditions and iOS on two: iOS omits the
stepsCalibrationManualflag. That reads as a gap until you checkStepsEstimateEngine.calibrate, where the manual branch isif let k = manualOverride, k > 0.manual == truetherefore always carries a positive coefficient, and iOS's two-condition guard is equivalent. Android's third check is defensive, not something iOS is missing.Verification
compileFullDebugKotlin+testFullDebugUnitTest— 4189 tests, 0 failures, run--no-build-cache --rerun-tasks.a16d885a(the branch head) —Strand(macOS) andNOOPiOS— including theTest Strandstep, soStrandTestsran and passed. Default CI does not cover these targets.DuplicatePairRatioTest/HRVAnalyzerDuplicatePairTests) still assert the analysers' exact output strings. Note they pin the analysers, not these call sites; what protects the hoist is that all three replaced expressions were identical, so it is value-identical by construction.Not covered by a test: the
rememberkey in (3), since observing a Compose key needs instrumentation the repo does not have, and the gate in (4), which lives in a SwiftUI view body.No hardware needed — nothing here touches the BLE, offload, or live-HR paths. The iOS changes read a preference that BLE writes; they do not write one.