Repository navigation
fix(charge): stop an old WHOOP import from anchoring the Charge baselines - #2666
Merged
Merged
Conversation
Refs ryanbr#2525. The Charge baselines were folded from the whole imported vendor history plus only the nights of the current scan window, so an import kept about a third of the weight for good (0.5^(21/14) with the 14-night half-life), however long ago it ended. ChargeBaselines (StrandAnalytics, with its Kotlin twin) states the corrected rule as one pure function: - only nights inside a 21-calendar-day window ending on the anchor day count; 21 is the default scan window, so a wearer with no import folds exactly the nights the 21-day pass always folded; - imported nights only seed the baseline while the wearer's own nights alone would not be trusted (minNightsTrust valid nights after the epoch), then drop out entirely; - while seeding, the per-day precedence is the existing one: an imported value wins, an own night fills a day the import leaves open. A bug fix, not a new scoring method. Both suites assert the same oracle literal over a deterministic spread of histories, produced by the Swift implementation. Wired into the engines and the dashboard in the next two commits.
Refs ryanbr#2525. Pass 2 of analyzeRecent now builds the HRV, resting-HR, respiration and skin-temperature histories with ChargeBaselines.history on both platforms, anchored on the pass's local today. The own nights are the pass's fresh values; the window trims a full-history repair pass to the same 21 days as the normal tick. Recalibration epochs and the respiration device-era cut apply as before. A Recovery Test Centre line per pass names what each baseline was folded from (own nights, or own nights still seeded by the import). mergeNightlyIntoHistory is removed on both platforms, since it would be reached only from tests; its precedence cases now live in the ChargeBaselines suites. The respiration era comment no longer claims that hist holds the days this engine scored: those are written under computedId. Removing the Kotlin helper reattaches a detached doc comment, so its doc_comment_lint count drops from 5 to 4.
Refs ryanbr#2525. The "What shaped it" rows, the "Calibrating N of 4" count and the Charge confidence tier folded repo.days themselves: every imported and every stored own night, and the resting-HR row without the recovery epoch. That is a different history than the one the Charge headline was scored against. They now read one resolver per platform, Repository.chargeBaselines and AppViewModel.chargeBaselines, which applies ChargeBaselines.resolve to the imported and own rows before the dashboard merge blends them. Android gains WhoopRepository.importedDailyUnionFlow, the imported twin of computedDailyUnionFlow, for that. Parity ledger refreshed against upstream/main; importedDailyUnionFlow is recorded as an out_of_scope_twin of Repository.refresh.
8 of 12 tasks
Author
|
@ryanbr heads-up: the workflow runs on this PR are waiting at action required for your "Approve and run workflows" click (first PR from me here, so GitHub holds them). This replaces #2615, which went stale before CI could run. Same three commits, rebased onto current |
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.
What this PR does
A bug fix in which nights the Charge baselines are folded from. It is not a new scoring method and does not claim to make Charge more accurate.
The bug (Refs #2525).
analyzeRecentfolded the HRV, resting-HR and respiration baselines from the whole imported vendor history plus only the nights of the current scan window (21 by default). The wearer's own older nights dropped out on every pass; the import never did. With the 14-night centre half-life and at most 21 own nights after it, the import keeps0.5^(21/14) ≈ 35 %of the weight on every pass, for good, pinned to whatever state the wearer was in at the end of their subscription, however long ago that was.The fix. One pure rule,
ChargeBaselinesinStrandAnalyticswith its Kotlin twin, decides which nights a Charge baseline reads:Baselines.minNightsTrust, 14 valid nights, counted after the recalibration epoch), and drop out entirely once they would. The rule keeps no state.mergeNightlyIntoHistory: an imported value wins a shared day, and an own night fills a day the import does not cover or covers without a value.Both engines (
IntelligenceEngine.swift/.kt) use it for the four pass-2 baselines. The dashboard readouts that describe the same baselines, the "What shaped it" rows, the "Calibrating N of 4" count and the Charge confidence tier, used to foldrepo.daysthemselves (every imported and every stored own night, and the resting-HR row without the recovery epoch). They now read one resolver (Repository.chargeBaselines/AppViewModel.chargeBaselines) that applies the same rule to the imported and own rows before the dashboard merge blends them, so they read the same rule and the same nights as the headline (two edge cases are listed under "Known gaps").Who notices what
A visible change on Today
After a gap of 21 days or more, the Charge tile on Today (and the Coupled and Liquid views, and the Android twins) now reads "Calibrating" instead of carrying the last score as "Latest sleep". The calibration count now reads the baseline the next score will be computed against, which is the rule the #393 follow-up (Bug B) set for it: N "tracks the baseline the Charge ring rides and can never over-state it". Before, the count folded every stored night, so it said "not calibrating" while the engine's baseline was already empty: for a wearer without an import, the regular 21-day pass had no nights left after such a gap, and the next score needed four nights either way.
Keeping the count on the whole history instead would bring back the #393 shape in a new place: a wearer who pairs a new strap after an old import would see a months-old imported score carried as "Latest sleep" for four days while no Charge is coming. The last score itself stays in the history. If you prefer the carried display for this case, it is a small follow-up in the state mapper (
MetricTileState.resolveon Apple,scoreStateForTodayon Android), independent of this fix.Also in this PR
charge baseline anchor=… window=21d hrv=own/N rhr=seed/N+M …), byte-identical on both platforms.mergeNightlyIntoHistoryis removed on both platforms (it would have become test-only); its precedence cases moved intoChargeBaselinesTests/ChargeBaselinesTest.histholds every dayanalyzeRecenthas scored; those are written undercomputedId. Corrected.Tools/doc_comment_lint_baseline.txt:IntelligenceEngine.kt5 → 4 (removing the helper reattached a detached doc comment).--base upstream/main. Oneout_of_scope_twindisposition forWhoopRepository.importedDailyUnionFlow, whose Swift role twin isRepository.refreshin the excluded app layer.Deliberately not in this PR
sleepWearRescore.v1) is your call; happy to add it.Known gaps, stated rather than implied
-noopsibling. They are the same ids on a single-strap install; with a re-added strap they can differ, as they could before.TodayView.watchScoreState(Apple Watch nights) still foldsrepo.days; that path scores againstWatchRecovery's baseline and is out of this change's scope.Type of change
How it was tested
All on the rebased branch (base
6ce657305), on a macOS host with a German system locale.swift testinPackages/StrandAnalytics: 2134 tests, 0 failures (20 new inChargeBaselinesTests).xcodegen generate+xcodebuild -scheme Strand -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO testwith-testLanguage en -testRegion US: build succeeded; StrandTests 2263 tests, 2 skipped, 1 failure (AppLanguageTests.testExplicitLanguageWritesAndSystemRemovesAppleOverride, caused by that flag injecting anAppleLanguagesoverride; without the flag it passes and the English-copy assertions fail on this German host instead).ChargeBreakdownWiringTestspass.xcodebuild -scheme NOOPiOS -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build: build succeeded../gradlew testFullDebugUnitTest: 6632 tests, 8 failures, none in code this PR touches. 6 are locale only and pass with an English JVM locale (TodayExplainabilityTest×3,VitalReadingsTableTest×1,StandardHrSensorFormatTest×2). The other 2 are theRecoveryDriversTesthalf-tie cases (-0.4999999999999929vs-0.5, the libm case test(android): keep the Charge driver half-tie tests independent of platform libm #2588 addresses); they call onlyRecoveryScorer/RecoveryDrivers, which this PR does not change, and your Linux CI is green on the same base. The new and changed suites pass (ChargeBaselinesTest,RecoveryCalibrationTest,RecoveryDriversUiTest).python3 Tools/doc_comment_lint.pyandpython3 Tools/i18n_audit.py --ci upstream/mainpass.python3 Tools/parity_ledger.py --refresh-derived --base upstream/main: accepted (290 known findings). The parity-governance unit tests: 160 tests, OK.Cross-platform parity is pinned by an oracle: both suites generate the same deterministic spread of 40 imported/own histories, anchors and epochs (17 of them in the seeding phase) and assert the same literal, which was produced by the Swift implementation. Reverting the seeding precedence to a key-presence check fails it.
Not tested on hardware: nothing here is on the BLE path.
Validation
This PR changes a scored output (Charge), so the checklist from
docs/VALIDATION_PROTOCOL.mdfollows. No improvement is claimed; the change corrects which nights enter the baselines.Checklist
swift testinPackages/StrandAnalytics)android/(8 failures outside the touched code: 6 locale, 2 libm half-tie; see above)StrandDesigntokens — no UI was restyled, only the data behind existing rowsdocs/CONTRIBUTING.mdStrand.xcodeproj/) or any secrets/keystoresOne concern, two platforms: the root
CONTRIBUTING.mdasks to keep Android changes separate where practical, whileAGENTS.mdrequires the Kotlin twin of an analytics change in the same PR. This followsAGENTS.md, since splitting would leave the two platforms scoring Charge differently between merges.Related issues
Refs #2525, #459, #2583. Supersedes #2615.