Skip to content

Strain banner compares fresh HRV against stored HRV the engine no longer produces, with no baseline-trust gate #2130

Open

Description

@ryanbr

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% requires b around 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

Activity

  1. ryanbr commented on Sep 12, 2026

    @ryanbr
    OwnerAuthor

    Correcting two things I wrote above, both found by checking the Swift side.

    1. I said iOS has no IllnessWatch and this was not a parity gap. Wrong.

    AppModel.applyIllnessSignal builds the same windows, down to the comment:

    let recent = Array(days.suffix(2))
    let base = Array(days.suffix(31).dropLast(3))    // ~28 days ending 3 days ago

    Everything after that diverges. Swift folds that window through Baselines.foldHistory with the metric's cfg and refuses the signal outright unless the state is usable:

    let state = Baselines.foldHistory(base.map(kp), cfg: cfg)
    guard state.usable else { return (IllnessSignalEngine.SignalReading(zIllnessward: 0, present: false), false) }

    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(...).usable needs nothing from the caller: it takes the values IllnessWatch already holds. The Kotlin twin has the same API and the codebase already uses that exact idiom (IntelligenceEngine.kt does it for skin temp), so the change stays local to Analytics.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 feeds IllnessSignalEngine, 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.

  2. ryanbr commented on Sep 12, 2026

    @ryanbr
    OwnerAuthor

    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:

    • IntelligenceEngine folds hrvSeq, this pass's freshly computed nightly values. That is where hrvNValid=2 comes from.
    • IllnessWatch folds base.map { it.avgHrv }, the stored daily rows, which stay dense and healthy-looking. minNightsSeed is 4 and staleDays is 14, so a window holding ten stored values with a three-night gap folds to PROVISIONAL and is usable.

    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(...).usable is 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:

    if (days.takeLast(31).none { it.recovery != null }) return null

    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=hrvBaselineNotUsable on 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 ProfileBaselines out 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.80 and r >= b + 5 should give way to the z-scores iOS feeds IllnessSignalEngine, 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.

  3. ryanbr commented on Sep 12, 2026

    @ryanbr
    OwnerAuthor

    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, and days is the stored state that OUTLIVES a failed scoring pass. That is the whole disease:

    • the stored HRV rows stay dense, so folding them reports a usable baseline;
    • the stored recovery stays populated, which is why the hero still renders CHARGE 51 through resolveTodayRow(days, ...), so "did the app score anything" reads as yes;
    • the stale values are exactly what a failed pass leaves behind.

    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", leaving IntelligenceEngine and 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:

    • the two series called "the HRV baseline" are different lists: hrvSeq, freshly computed (2 valid on the reporting device), versus base.map { it.avgHrv }, stored and dense
    • minNightsSeed = 4 and staleDays = 14, so a stored window with ten values and a three-night gap folds to PROVISIONAL and is usable
    • the banner's own arithmetic is r = mean(last 2 days), b = mean(days -31..-4), firing at r <= b * 0.80, so -51% needs b near 35.7ms against r of 17.5
    • iOS does not share this path: AppModel.applyIllnessSignal folds the same window through Baselines.foldHistory and gates on state.usable, so it reuses the score's baseline rather than keeping a second one

    The 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.

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

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions