Skip to content

Charge: parasympathetic-saturation guard on the low-HRV penalty#461

Open
vishk23 wants to merge 3 commits into
ryanbr:mainfrom
vishk23:upstream-pr/recovery-saturation-guard
Open

Charge: parasympathetic-saturation guard on the low-HRV penalty#461
vishk23 wants to merge 3 commits into
ryanbr:mainfrom
vishk23:upstream-pr/recovery-saturation-guard

Conversation

@vishk23

@vishk23 vishk23 commented Jul 14, 2026

Copy link
Copy Markdown

Charge: parasympathetic-saturation guard on the low-HRV penalty

RecoveryScorer.recovery(...) weights the HRV term at 0.55 and the RHR term at 0.20 and applies a straight low-HRV penalty. On a parasympathetic-saturation night — HRV low AND resting HR ALSO low, weakly coupled (a very-fit / very-relaxed vagal-saturation state, not fatigue) — the dominant HRV penalty tanks Charge even though the low RHR says "well recovered." That's a false red.

Fix

A conservative, additive guard (parasympatheticSaturation(hrvZ:rhrZ:)): it fires ONLY when both HRV and RHR are ≥0.5σ into their below-baseline directions, then damps the HRV penalty by at most 50% (linear ramp to full at 1.5σ). It only ever REDUCES the penalty — never removes it or flips its sign — and a low-HRV + HIGH-RHR genuine-fatigue night can't qualify (its rhrLow < 0), so real fatigue stays red.

Constants (all named/documented): satEnterZ = 0.5, satFullZ = 1.5, satMaxDampFraction = 0.5. The ChargeDrivers HRV verdict explains the easing when it fires; the trace emits a charge saturation active line only then.

Worked example (HRV 41 vs μ50/σ6.27; RHR baseline μ55/σ5): a saturation night (RHR 48) → ~50 (yellow) instead of ~12; a same-HRV fatigue night (RHR 62) stays ~12 (red).

Validation

  • 11 new RecoverySaturationGuardTests pin BOTH directions (saturation not tanked; fatigue still red) plus the guard-off cases; full StrandAnalytics suite green.
  • Replayed the guard's firing condition against ~2 weeks of real WHOOP HRV+RHR: 0 false-fires — the recent low-HRV nights there are supplement-driven (high RHR = fatigue) and correctly stay red.

Notes

  • The thresholds are principled but not empirically fit — worth validating against your own low-HRV + low-RHR nights and tuning if needed.
  • Kotlin twin included + gradle-tested. RecoveryScorer.recovery/recoveryTrace/chargeDrivers carry the identical guard (byte-identical satEnterZ/satFullZ/satMaxDampFraction constants, the same damp formula and rounding). chargeDrivers now routes each term's deltaPoints through recovery(...) so a neutralized RHR correctly re-evaluates the guard, matching the Swift attribution exactly. RecoverySaturationGuardTest mirrors RecoverySaturationGuardTests (11 tests); the existing RecoveryDriversTest/RecoveryScorerTraceTest stay green (the rewrite is behaviour-preserving for non-saturation nights). ./gradlew :app:testFullDebugUnitTest green.

vishk23 added 2 commits July 14, 2026 11:57
…RV penalty

The Charge (recovery) score is HRV-dominant (wHRV = 0.55): a low nightly HRV
drives the score down. That mis-reads one benign pattern — parasympathetic
(vagal) saturation — where in a very fit or very relaxed state HRV reads LOW
while resting HR reads LOW too and the two decouple. That is a saturated rest
state, not fatigue, yet the raw z-score tanked Charge on those nights.

Add a conservative, additive guard in RecoveryScorer. When the HRV term points
"bad" (below baseline) AND the resting-HR term points "good" (below baseline) —
the decoupling signature — ease the low-HRV penalty by shrinking its (negative)
z toward 0. Strength ramps from satEnterZ (0.5 sigma) to satFullZ (1.5 sigma) on
the WEAKER of the two arms, capped at satMaxDampFraction (0.5), so at least half
the penalty always survives, the penalty is never removed or sign-flipped, and a
low-HRV + HIGH-resting-HR fatigue night is left exactly as before. With no
resting-HR term the guard cannot fire (nothing corroborates the low HRV).

The trace emits a "charge saturation active" line and the eased HRV term only
when the guard fires; the ChargeDrivers HRV verdict explains the easing. Both
route through recovery() so the breakdown and headline can never diverge.

Adds RecoverySaturationGuardTests pinning both directions (saturation night not
tanked; real fatigue still red) plus the helper, trace, and driver wiring.
1044 tests pass.
…arity with PR #461)

Ports the Charge low-HRV penalty guard from StrandAnalytics/RecoveryScorer.swift:
when a low nightly HRV is corroborated by a low, DECOUPLED resting HR (benign
vagal saturation, not fatigue), the dominant HRV penalty is eased — never removed,
and never on a low-HRV + HIGH-resting-HR fatigue night.

- RecoveryScorer: satEnterZ/satFullZ/satMaxDampFraction constants, the
  ParasympatheticSaturation result, parasympatheticSaturation(hrvZ, rhrZ), and
  recovery() computes rhrZ up front + applies the guard to the HRV term.
- RecoveryScorerTrace: reuses rhrZ for the guard + emits the 'charge saturation
  active' line only when it fires.
- RecoveryDrivers: routes each term's deltaPoints through recovery() (so a
  neutralized RHR correctly re-evaluates the guard) + the eased HRV verdict.

Byte-identical constants/formulas/rounding. RecoverySaturationGuardTest mirrors
the Swift RecoverySaturationGuardTests (11 tests); existing RecoveryDrivers/Trace
tests stay green (rewrite is behaviour-preserving for non-saturation nights).
@ryanbr

ryanbr commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Reviewed. The engineering here is excellent — genuinely one of the more careful scoring changes to come through.

  • Verified byte-parity. The Kotlin twin matches Swift byte-for-byte: same constants (satEnterZ=0.5, satFullZ=1.5, satMaxDampFraction=0.5), same hrvLow=-hrvZ / rhrLow=rhrZ, same gate, the same min-driven couplingStrength, and the same s/dampFraction/effectiveHrvZ formula in the same operation order → identical Double arithmetic → the same rounded Charge on both platforms. Routing chargeDrivers deltaPoints back through recovery(...) so a neutralized RHR re-evaluates the guard is the right call for attribution parity.
  • Conservative by construction. Fires only when HRV and RHR are each ≥0.5σ below baseline, eases by ≤50% (never removes, never sign-flips), driven by the weaker arm, refuses to fire without an RHR baseline, and a low-HRV + HIGH-RHR fatigue night can't qualify. 11+11 tests, StrandAnalytics green.

The reservation is not the code — it's shipping it default-on to everyone's Charge right now, for three converging reasons:

  1. The real-data validation doesn't actually exercise the guard. "0 false-fires over ~2 weeks" means the guard never fired on that data — those low-HRV nights were supplement-driven high-RHR (fatigue), which it correctly ignores. So the replay validates "doesn't over-fire on a fatigue fortnight," not "eases correctly on a real saturation night." Every night where it actually changes Charge is covered by synthetic tests only; there's no real night where it fired and was confirmed right.
  2. The premise is contested, not settled. Low HRV + low RHR isn't unambiguously benign vagal saturation — parasympathetic (non-functional) overreaching can present with a low resting HR and suppressed HRV, i.e. the same signature, on a genuinely maladaptive night. The ≤50% cap bounds the harm (worst case yellow-not-red), but the interpretation the guard rests on is exactly the kind the literature disagrees on — which lines up with your own note that the thresholds are principled but not empirically fit.
  3. Recent precedent. feat(sleep): re-tune SleepStagerV2 deep boundary on DREAMT (n=100 gold) #348 (the DREAMT sleep-wake tune) was a principled-but-unfit scoring change that shipped default-on and was reverted in fix(sleep): restore pre-#348 V2 staging defaults — DREAMT wake tune over-calls awake in the field (#431) #437 for over-calling on an unvalidated population. Same class: a physiology-motivated adjustment to a core gate, default-on, on constants that haven't fired against ground truth.

Suggested path (either fits the repo's instrument-first discipline):

  • Instrument-first — keep the detection + the charge saturation active trace + the ChargeDrivers verdict, but leave the Charge number unchanged (log a "would ease by N"), so real low-HRV+low-RHR nights accumulate and the firing can be confirmed before it drives the gate; or
  • Experimental gate — put the applied easing behind a default-off toggle so users who suspect saturation opt in and generate the firing data.

To be clear, the problem you're fixing is real — the current low-HRV penalty is a false-red for the fit/relaxed cohort. The only ask is to not fix it default-on for everyone on thresholds that have never fired against a real saturation night. Instrument it, gather the nights where it would fire, and the empirical tuning + a confident default-on becomes a small follow-up.

Reworks the parasympathetic-saturation guard to be instrument-first, per review.
The guard previously EASED the low-HRV penalty by up to 50%, default-on, changing
everyone's Charge. It no longer touches the score at all.

Why: the real-data validation recorded ZERO firings over ~2 weeks. That evidences
the gate does not over-fire; it does NOT evidence the easing is correct, or
correctly sized, on a real saturation night, because no real saturation night was
ever observed. Every Charge-moving night was covered by synthetic fixtures only.
The premise is contested too: low HRV + low resting HR is also a reported
signature of parasympathetic (non-functional) OVERREACHING, which is maladaptive
and wants the OPPOSITE correction. On the HRV/RHR pair alone the two are not
distinguishable. #348 -> #437 is the precedent for not shipping a
principled-but-unvalidated scoring change default-on.

So: keep the detection, log what it WOULD have done, leave the gate alone.

  - RecoveryScorer.recovery(): the HRV term is the RAW z again. The term-building
    code is now character-identical to pre-guard main; only the final logistic is
    factored into logisticScore(compositeZ:) so the trace's counterfactual runs
    through the exact same curve. Verified bit-exact against the merge-base over a
    73,062-point input grid (the original guard moved 6,004 of those rows).
  - parasympatheticSaturation(): detection, gate, weaker-arm coupling strength and
    damp fraction are UNCHANGED. effectiveHrvZ -> easedHrvZ, since it is now a
    counterfactual the scorer does not consume.
  - Trace: emits "charge saturation active ... damp= wouldEaseHrvZTo=
    wouldRaiseCharge= wouldBand= (... not applied: the score above is unchanged)"
    only when the signature fires, so real firings accumulate with their exact
    would-be magnitude. On the fixture night: score=31.69 band=red,
    wouldRaiseCharge=18.07 wouldBand=yellow.
  - ChargeDrivers: the HRV verdict still names the detected pattern but keeps the
    "limiting recovery" read and hedges ("may be ... rather than fatigue"), because
    the penalty was not in fact eased and the benign reading is not established.
  - Kotlin twin tracks all of the above; parity preserved.

Tests: the assertions that pinned a CHANGED Charge now pin it UNCHANGED (equal to
the independently recomputed raw composite, and still red) while asserting the
detection, the trace counterfactual and the verdict still fire. Detection tests
kept as-is. Swift 1081 pass; Kotlin 2721 pass.

Empirical tuning and a confident default-on become a small follow-up, gated on
confirmed real firings rather than fixtures.
@vishk23

vishk23 commented Jul 15, 2026

Copy link
Copy Markdown
Author

Reworked to instrument-first — your first option. Thank you for the review; the validation-gap argument is correct and I'd rather have it caught here than in a #437-style revert.

What changed: the guard now detects and reports but never touches the Charge number.

  • Detection is untouched — same gate, same couplingStrength, same dampFraction. That's the thing we want observing real nights.
  • The easing is computed but not applied. The HRV term is the raw z; term-building is now character-identical to pre-guard main.
  • The trace reports the counterfactual, on firing only:
    charge saturation active hrvZraw=-1.44 rhrZ=1.4 damp=0.45 wouldEaseHrvZTo=-0.79 wouldRaiseCharge=18.07 wouldBand=yellow (… Easing DETECTED ONLY, not applied: the score above is unchanged)
  • ChargeDrivers verdict still fires, so attribution still surfaces the detection.

On "byte-identical" — I verified it empirically rather than asserting it. A differential harness dumped recovery() bit patterns over a 73,062-point input grid on the merge-base and on this branch:

  • reworked vs pre-PR: identical on all 73,062 rows
  • negative control — the original PR vs pre-PR: 6,004 rows differ (~8%)

The control matters: it proves the grid actually covers the firing region, so the parity result isn't vacuous. (Harness was scaffolding, not committed.)

Kotlin twin reworked in lockstep — same instrument-only shape, detection untouched, so the byte-parity you verified still holds. Swift 1081 pass; Kotlin 2721 pass / 0 fail across 334 suites.

The score-changed tests now assert score-unchanged (equal to an independently recomputed raw composite, still red) while asserting detection + the trace counterfactual + the verdict still fire. One test explicitly asserts the fixture does trip the detector, so the suite can't silently rot into proving nothing.

Two judgment calls to flag:

  1. Renamed effectiveHrvZeasedHrvZ (internal). It's no longer "effective" — nothing consumes it — and the old name would mislead the next reader.
  2. The ChargeDrivers verdict is now hedged rather than de-claimed: it was "…so the penalty is eased", now "below baseline, limiting recovery, though low resting HR suggests this may be parasympathetic saturation rather than fatigue." It keeps "limiting recovery" because that's what the score actually did, and hedges because — per your point 2 — the benign reading isn't established. If you'd rather the contested hypothesis not surface in user-facing copy at all until it's confirmed, that's a one-line change and I'm happy to make it.

Now the firing nights can accumulate against ground truth, and enabling it becomes the small, evidence-backed follow-up you described.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants