Repository navigation
Strain banner compares fresh HRV against stored HRV the engine no longer produces, with no baseline-trust gate #2130
Description
Summary
The Android "Your body looks strained" banner compares freshly computed HRV against stored HRV the current engine no longer reproduces, and calls the difference a physiological signal. It has no baseline-trust gate of any kind.
Observed on a real device tonight: the banner read "resting HR +5 bpm, HRV -51%" while the recovery engine in the same log reported its HRV baseline as status=calibrating hrvNValid=2 (need nValid>=4).
The arithmetic
IllnessWatch.evaluate in Analytics.kt, whose return string is character-for-character what appears on screen:
val recent = days.takeLast(2) // last 2 days
val base = days.takeLast(31).dropLast(3) // ~28 days ending 3 days ago
if (r != null && b != null && b > 0 && r <= b * 0.80) {
flags.add("HRV -${((1 - r / b) * 100).roundToInt()}%")
}Both are plain means of stored DailyMetric.avgHrv.
recent= mean(13.7, 21.3) = 17.5-51%requiresbaround 35.7- the recovery engine's own baseline at the same moment: 19.71, and it does not trust it
So the app holds two different HRV baselines at once, 35.7 and 19.71, and the banner quotes the one nothing gates.
Where 35.7 comes from
The base window is roughly 12 Aug to 9 Sep and reads stored rows written by earlier passes, including nights the engine now refuses outright. Those same nights printed this tonight, on the build carrying #2129:
hrv nightSummary reported=nil refused=overCount wholeNight=31.55ms deepOnly=28.56ms nWin=87 nDeep=22
hrv nightSummary reported=nil refused=overCount wholeNight=44.43ms deepOnly=41.26ms nWin=132 nDeep=28
hrv nightSummary reported=nil refused=overCount wholeNight=46.76ms deepOnly=41.98ms nWin=166 nDeep=30
Whole-night means of 31.5, 44.4 and 46.8 that the current engine declines to produce, still sitting in the rows the banner averages. The re-score census for the same window reads hrv=2 out of 21 nights.
No trust gate at all
IllnessWatch.evaluate's only guard is days.size >= 14. A baseline the app itself labels calibrating, nValid=2 (need >=4) is good enough for it to tell a wearer their body is strained.
The baselineTrusted machinery does exist, with the documented intent "don't warn off a cold-start baseline", but it lives in IllnessSignalEngine reached through V5HealthSignals, which this banner never goes through.
iOS has no IllnessWatch at all. AppModel.evaluateIllness routes only through IllnessSignalEngine, gated on per-signal baseline trust. So Android runs a second, ungated banner engine with no counterpart, and that is the one the wearer sees.
Note the RHR flag rides the same two windows (r >= b + 5), so BOTH flags come from the same stale-base comparison, and two flags is exactly the firing threshold.
Fix
The principled fix is to gate this path the way iOS gates its equivalent: no HRV flag while the HRV baseline is not usable. The caller (AppViewModel, WhoopConnectionService) holds no trust flag today, only the baseline epoch prefs, so that needs the fold state plumbed through and is not a small change.
A smaller, independent hole is fixed separately and immediately: mean() returns a value from a SINGLE night, so b can be a one-night "baseline". The respiration flag in the same function already guards against this (respBase.size >= 10, "require enough valid baseline nights for a stable baseline mean"); HRV and resting HR do not. That guard is worth having on its own terms but will NOT silence the case above, where the stale values are plentiful.
Environment
- Android, staging build 494 (base 11.6.0), WHOOP 5.0 MG active with a 4.0 in history
- Related: Charge scores a WHOOP 5 against an HRV baseline that mixes two R-R unit regimes #2126 (the same stale-versus-fresh fault in the recovery baseline), WHOOP 4.0 R-R over-count contaminates HRV (not reproducible since 2026-08-25, cause unidentified) #1118 (why the nights are refused), hrv: say why the nightly summary reported nothing #2129 (the diagnostic that made this visible)
Activity
illness: gate on the baseline the score already uses, as Swift does
ryanbr commentedon Sep 12, 2026
Correcting two things I wrote above, both found by checking the Swift side.
1. I said iOS has no
IllnessWatchand this was not a parity gap. Wrong.AppModel.applyIllnessSignalbuilds the same windows, down to the comment:Everything after that diverges. Swift folds that window through
Baselines.foldHistorywith the metric's cfg and refuses the signal outright unless the state is usable:So the gap was never "Android has an extra engine". It is that Android holds a second baseline for a metric the score already has one for, while iOS reuses the score's. That is exactly the 35.7 versus 19.71 in the report.
2. I said this needs fold state plumbed through the ViewModel and is not a small change. Also wrong.
Baselines.foldHistory(...).usableneeds nothing from the caller: it takes the valuesIllnessWatchalready holds. The Kotlin twin has the same API and the codebase already uses that exact idiom (IntelligenceEngine.ktdoes it for skin temp), so the change stays local toAnalytics.kt.#2132 now does that, so it reaches the reported case rather than only the thin version of it: the device's HRV baseline is calibrating on 2 valid nights, the fold is not usable, the HRV flag is withheld, and one flag is not a banner.
What stays open here is the wider question, if anything: whether the mean-based firing rules (
r <= b * 0.80,r >= b + 5) should also give way to the z-scores iOS feedsIllnessSignalEngine, which would converge the two platforms completely rather than only at the gate. That is a product decision about when the banner should fire, not a defect, so it is worth deciding deliberately rather than folding into a fix.illness: pin the gate on the seed, not on staleness
illness: gate the strain banner on the baseline the score already uses (
ryanbr commentedon Sep 12, 2026
Correcting myself twice, with a build to show for it.
The gate that landed for this did nothing. Build 495 carried it and the banner came back byte-identical, same
-51%. The reason is a distinction I collapsed:IntelligenceEnginefoldshrvSeq, this pass's freshly computed nightly values. That is wherehrvNValid=2comes from.IllnessWatchfoldsbase.map { it.avgHrv }, the stored daily rows, which stay dense and healthy-looking.minNightsSeedis 4 andstaleDaysis 14, so a window holding ten stored values with a three-night gap folds toPROVISIONALand isusable.I quoted the first number and gated on the second. The fold passed and nothing changed.
And my earlier correction in this thread was itself wrong. I said the claim about needing fold state plumbed through the ViewModel was mistaken, because
Baselines.foldHistory(...).usableis reachable locally. It is reachable: it simply folds the wrong series. The verdict that matters belongs to the engine, and retracting that was a mistake.What replaces it
#2139 removes the fold rather than adding a second gate beside it, since it cannot see staleness and staleness is the whole substance of this issue, then asks the app's own verdict instead:
When no Charge was produced anywhere in the span being compared, the app has already said it cannot judge this physiology, so a warning drawn from the same inputs contradicts it. On the reporting device that is
nilScore reason=hrvBaselineNotUsableon all 21 nights while the banner asserted strain from them.It is a proxy for "the HRV baseline is not usable" rather than that verdict directly. The direct route needs
ProfileBaselinesout of the engine, which never leaves it today.What stays open here
The mean-based firing rules are untouched, so the wider question is unchanged: whether
r <= b * 0.80andr >= b + 5should give way to the z-scores iOS feedsIllnessSignalEngine, converging the platforms rather than only gating one of them.The underlying fault is also untouched and is the real one: the window still averages whatever was stored, including values a later engine would no longer produce. The guard stops that being announced as strain; it does not make the comparison sound.
ryanbr commentedon Sep 12, 2026
Third attempt withdrawn, and the reason is worth recording because it is the same one each time.
Every local signal I reached for lives in
days, anddaysis the stored state that OUTLIVES a failed scoring pass. That is the whole disease:recoverystays populated, which is why the hero still renders CHARGE 51 throughresolveTodayRow(days, ...), so "did the app score anything" reads as yes;Three different guards, all reading the same residue, all inert. A window cannot tell you that its own contents are out of date.
So the verdict has to come from the engine rather than from its leftovers. Concretely:
ProfileBaselines, or a flag derived from it such as "the HRV baseline was usable on this pass", leavingIntelligenceEngineand reaching the illness evaluation. It never leaves today. That is a real change with a real surface, not a guard, and worth scoping deliberately rather than attempting a fourth time.What is now known and does not need rediscovering:
hrvSeq, freshly computed (2 valid on the reporting device), versusbase.map { it.avgHrv }, stored and denseminNightsSeed = 4andstaleDays = 14, so a stored window with ten values and a three-night gap folds to PROVISIONAL and isusabler = mean(last 2 days),b = mean(days -31..-4), firing atr <= b * 0.80, so -51% needsbnear 35.7ms againstrof 17.5AppModel.applyIllnessSignalfolds the same window throughBaselines.foldHistoryand gates onstate.usable, so it reuses the score's baseline rather than keeping a second oneThe wider question is unchanged: whether the mean-based firing rules should give way to the z-scores iOS feeds
IllnessSignalEngine, which would converge the platforms instead of gating one of them.