Charge: parasympathetic-saturation guard on the low-HRV penalty#461
Charge: parasympathetic-saturation guard on the low-HRV penalty#461vishk23 wants to merge 3 commits into
Conversation
…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).
|
Reviewed. The engineering here is excellent — genuinely one of the more careful scoring changes to come through.
The reservation is not the code — it's shipping it default-on to everyone's Charge right now, for three converging reasons:
Suggested path (either fits the repo's instrument-first discipline):
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.
|
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.
On "byte-identical" — I verified it empirically rather than asserting it. A differential harness dumped
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:
Now the firing nights can accumulate against ground truth, and enabling it becomes the small, evidence-backed follow-up you described. |
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 (itsrhrLow < 0), so real fatigue stays red.Constants (all named/documented):
satEnterZ = 0.5,satFullZ = 1.5,satMaxDampFraction = 0.5. TheChargeDriversHRV verdict explains the easing when it fires; the trace emits acharge saturation activeline 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
RecoverySaturationGuardTestspin BOTH directions (saturation not tanked; fatigue still red) plus the guard-off cases; fullStrandAnalyticssuite green.Notes
RecoveryScorer.recovery/recoveryTrace/chargeDriverscarry the identical guard (byte-identicalsatEnterZ/satFullZ/satMaxDampFractionconstants, the same damp formula and rounding).chargeDriversnow routes each term'sdeltaPointsthroughrecovery(...)so a neutralized RHR correctly re-evaluates the guard, matching the Swift attribution exactly.RecoverySaturationGuardTestmirrorsRecoverySaturationGuardTests(11 tests); the existingRecoveryDriversTest/RecoveryScorerTraceTeststay green (the rewrite is behaviour-preserving for non-saturation nights)../gradlew :app:testFullDebugUnitTestgreen.