Repository navigation
Description
TL;DR: The HRV and resting-HR baselines behind Charge are rebuilt on every pass from the entire imported WHOOP history plus only the last 21 nights NOOP computed itself. Because own nights never accumulate beyond that window, the import never grows out. It carries roughly 35–40 % of the baseline permanently, however long someone uses NOOP. The fold advances per value, not per calendar day, so it doesn't matter whether the last imported night was last month or three years ago. Charge ends up measuring today's nights against the wearer's state at the end of their WHOOP subscription.
How the baseline is built
IntelligenceEngine.analyzeRecent reads the imported rows over the full history (store.dailyMetrics(deviceId:) on the imported id), merges in the nightly values of the current scan window only (maxDays, 21 by default, via mergeNightlyIntoHistory), and folds the result with Baselines.foldHistory. With no recalibration epoch, foldHistory calls update once per value in date order. There is no notion of elapsed time.
1. The import's weight never decays
With halfLifeB: 14 and at most 21 own nights after the import, the imported nights keep roughly a third of the weight, on every pass and forever. Measured on real data: shifting every imported RHR value by +10 bpm moves the current RHR baseline by +3.9 bpm (~39 %). The weight concentrates on the last imported weeks, and those can be arbitrarily old: a gap between the import and the first NOOP night ages nothing.
Where that weight sits, for this wearer: the last 14 imported nights hold ~18 % of today's baseline, the last 56 (about four months before the import ended) ~33 %. Imported years 2021–2023 contribute 0 %. So the baseline is effectively pinned to whatever state the wearer was in during their final WHOOP months, which is chance.
2. Own nights expire, imported nights never do
Imported rows enter over the full history (here 710 nights, 2021-03 to 2026-06). Computed nights enter only from the 21-day window. Everything NOOP measured before that window is absent from the baseline, while imported nights from years ago are present. The order of trust is inverted.
3. Two algorithms in one baseline
Imported HRV and RHR are WHOOP's own vendor figures; the window values are NOOP's. deviceEraEpoch buckets by brand, so both count as one era, and the code itself notes that #459's primitive "is likewise still unwired for the HRV and resting-HR baselines". HRV carries the largest Charge weight (W_HRV = 0.55). Respiratory rate is folded the same way.
Why it matters
People use a fitness app because they want to change: lose weight, get fitter, get healthier. Athletes with an established level barely move their baseline over months; the general population does. That is exactly the group a frozen anchor hurts most.
A baseline should follow the wearer's level over weeks, so Charge can keep flagging good and bad nights relative to where they are now. Without an import NOOP does this: the fold tracks own nights with a lag of two to three weeks. With the import pinned in, it never gets there. Example: resting HR falls from 55 to 44 over a year. An own-nights baseline sits near 44, so a 48-bpm night correctly reads as worse than usual. With a third of the weight on the imported period, the baseline stays around 55, so the same night reads as well recovered. The app stops warning precisely when it should. For a wearer who was fitter during their WHOOP months, the reverse happens, and Charge stays permanently depressed.
Effort is not affected by this: it uses no baseline.
Impact (one wearer, WHOOP 5.0, last 14 nights)
Recomputed with Baselines.foldHistory and RecoveryScorer.recovery from the stored daily rows (reproducing the stored Charge exactly):
| With imported WHOOP history (today) | Own NOOP nights only | |
|---|---|---|
| HRV baseline | 43.1 ± 6.7 | 47.7 ± 5.4 |
| RHR baseline | 68.8 ± 5.0 | 65.2 ± 2.3 |
Charge with the import is +16.5 points higher on average (range +3.0 to +27.5) than against the wearer's own nights.
Workaround today
"Recalibrate Charge baseline" sets an epoch that drops everything before it: the import, but also all of the wearer's own nights. That costs a new calibration (4 nights to a score, 14 to full trust), so it's a blunt workaround, not a fix.
Proposal
- Build Charge baselines from the wearer's own nights over a time-based window (e.g. 90 days), aged by calendar days rather than by count, re-derived under the current definitions. Note that with
halfLifeB: 14nights older than ~60 days already carry almost no weight, so the window length matters less than letting own nights beyond 21 in at all. If more stability is wanted, a longer half-life (e.g. 21–30 days) is the lever, at the cost of slower adaptation. - Use imported history only as a cold-start seed until enough own nights exist (e.g.
minNightsSeed/minNightsTrust), then stop folding it. - Age nights by calendar time, or ignore nights older than a fixed age.
- Separate eras by source algorithm (vendor import vs NOOP-computed), not by brand, i.e. wire Baselines mix incompatible HRV scales across a device switch (Oura↔WHOOP), corrupting readiness/trends z-scores at the transition #459 for HRV and RHR.
This is independent of #2522, and not an argument for bending NOOP's resting-HR definition towards WHOOP's figure: the fix for mixed sources is to stop mixing them.
Edited: added where the import weight sits, the fitness-change example, and a concrete window proposal.
Activity
fix(charge): add the rule for which nights a Charge baseline reads
fix(charge): fold the engine's Charge baselines through ChargeBaselines
fix(charge): read the headline's baselines in the Charge breakdown
test(charge): pin the gap-longer-than-the-window display as a decision
test(charge): pin the gap-longer-than-the-window display as a decision
fix(analytics): bound imported Charge baselines to recent calendar ni…
test(parity): record reviewed twins and platform-specific fix boundaries
test(parity): record reviewed twins and platform-specific fix boundaries
fix(analytics): preserve the watch confidence fallback after shell in…
fix(charge): require source-owned freshness for retained HRV
fix(charge): replace stale freshness on imported score rewrites
Merge source-owned Charge freshness and retain monitor provenance
test(charge): isolate quiet rescore days from overlapping stream reads
refactor(android): extract the imported recovery persistence fold
test(charge): retain fourteen unscanned valid fixture nights
Keep scoring instrumentation within its existing method-size limit
Make shared import and Charge fixtures compile in hosted tests
refactor(charge): extract unchanged skipped-day baseline eviction
test(analytics): correct HRV integration fixtures
test(analytics): distinguish retained nights from valid inputs
Apply owned diagnostics and cache fixture corrections
Merge upstream/main through 12.0.0 (9f98f81) into the fork
test(charge): pin the gap-longer-than-the-window display as a decision
fix(charge): add the rule for which nights a Charge baseline reads
fix(charge): fold the engine's Charge baselines through ChargeBaselines
fix(charge): read the headline's baselines in the Charge breakdown
test(charge): pin the gap-longer-than-the-window display as a decision