Skip to content

Charge baselines stay permanently anchored to imported WHOOP history, however old it is #2525

Open

Description

@DX23876

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

  1. 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: 14 nights 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.
  2. Use imported history only as a cold-start seed until enough own nights exist (e.g. minNightsSeed / minNightsTrust), then stop folding it.
  3. Age nights by calendar time, or ignore nights older than a fixed age.
  4. 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.

Refs #459, #2522.

Edited: added where the import weight sits, the fitness-change example, and a concrete window proposal.

Activity

  1. added a commit that references this issue on Oct 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions