From 75f85ccb2f8cd934c455f6cc7337850f2575e3fd Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:56:16 +0530 Subject: [PATCH] fix: alcoholNightFlag missing whole-bpm quantum guard on nightly RHR same gap illness_cusum just got patched for. a quantized-but-nonzero RHR baseline (alternating 54/55/56, sd ~0.76) let a small +3bpm night fabricate an extreme z and clear mdc trivially. add the same dispersionBelowQuantum(rhrHistory, 1.0) guard, abstain the RHR axis when it fires. rmssd untouched, it's not quantized this way. widened the shared test fixture (was itself sub-quantum) so the heavy/moderate/light tests keep testing real signal, and added a regression test for the abstain case. --- lib/src/onehz/human/event_detection.dart | 32 ++++++++++++++++++------ test/onehz/human_test.dart | 29 +++++++++++++++++++-- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/lib/src/onehz/human/event_detection.dart b/lib/src/onehz/human/event_detection.dart index 32bac50..abf5164 100644 --- a/lib/src/onehz/human/event_detection.dart +++ b/lib/src/onehz/human/event_detection.dart @@ -44,6 +44,12 @@ import '../types.dart'; import '../util.dart'; import '../foundations/baseline.dart'; +/// Nightly RHR is read in whole bpm; a baseline whose SD sits below this +/// quantum has no dispersion the instrument can actually resolve — see +/// [dispersionBelowQuantum]. Same convention as illness_cusum.dart and +/// readiness_composite.dart's `rhrInput` (quantum: 1). +const double _rhrQuantum = 1.0; + /// One night's nocturnal summary vs the personal baseline window. class NightSignature { final double rhr; // nocturnal RHR tonight (bpm) @@ -135,12 +141,21 @@ Metric alcoholNightFlag( final rmsBase = robustBaseline(rmssdHistory, minValid: minNights); final rhrDelta = tonight.rhr - (rhrBase.center ?? tonight.rhr); final rmssdDelta = tonight.rmssd - (rmsBase.center ?? tonight.rmssd); - final rhrZ = rhrBase.modZ(tonight.rhr); + + // Nightly RHR is read in whole bpm; a baseline whose SD sits below this + // quantum (quantized-but-nonzero, e.g. an alternating 54/55/56 baseline) + // has no dispersion the sensor can actually resolve. Standardizing against + // it fabricates an extreme z off ordinary rounding noise — the same guard + // illness_cusum.dart and readiness_composite.dart already apply to this + // exact channel. Abstain on the RHR axis only; RMSSD isn't quantized this + // way, so it keeps standardizing normally. + final rhrBelowQuantum = dispersionBelowQuantum(rhrHistory, _rhrQuantum); + final rhrZ = rhrBelowQuantum ? null : rhrBase.modZ(tonight.rhr); final rmssdZ = rmsBase.modZ(tonight.rmssd); // MDC gates: a sign only counts if it clears the metric's minimal detectable // change. When MDC is unavailable (degenerate scale) the sign cannot fire. - final rhrMdc = mdc(rhrBase); + final rhrMdc = rhrBelowQuantum ? null : mdc(rhrBase); final rmsMdc = mdc(rmsBase); final rhrUp = rhrMdc != null && rhrDelta > rhrMdc; final rmssdDown = rmsMdc != null && (-rmssdDelta) > rmsMdc; @@ -217,11 +232,14 @@ Metric alcoholNightFlag( tonight.skinTempZ != null || tonight.respRate != null; final ambiguous = state == 'autonomically_stressed' && !hasDisambiguator; - final note = state == 'normal' - ? 'no autonomic signature tonight' - : 'STATE = autonomic stress (confident). "Alcohol" is a tag-confirmable ' - 'hypothesis only — late meal / early illness / luteal / hot room ' - 'share this signature; disambiguate with temp + respiration, not HR.'; + final note = (state == 'normal' + ? 'no autonomic signature tonight' + : 'STATE = autonomic stress (confident). "Alcohol" is a tag-confirmable ' + 'hypothesis only — late meal / early illness / luteal / hot room ' + 'share this signature; disambiguate with temp + respiration, not HR.') + + (rhrBelowQuantum + ? '; rhr baseline dispersion below whole-bpm quantum — RHR axis abstained' + : ''); return Metric( value: EventState( diff --git a/test/onehz/human_test.dart b/test/onehz/human_test.dart index 617929f..e042ebd 100644 --- a/test/onehz/human_test.dart +++ b/test/onehz/human_test.dart @@ -107,8 +107,9 @@ void main() { }); group('alcohol-night flag — fires at the right band, silent when normal', () { - // Personal baseline: RHR ~55 (tight), RMSSD ~60 (tight), dip ~12%, temp z ~0. - final rhrHist = [54.0, 55.0, 56.0, 55.0, 54.0, 55.0, 56.0, 55.0]; + // Personal baseline: RHR ~55 (real dispersion, SD >= 1 whole-bpm quantum), + // RMSSD ~60 (tight), dip ~12%, temp z ~0. + final rhrHist = [52.0, 55.0, 58.0, 54.0, 56.0, 53.0, 57.0, 55.0]; final rmsHist = [60.0, 61.0, 59.0, 60.0, 62.0, 58.0, 60.0, 61.0]; final dipHist = [12.0, 11.5, 12.5, 12.0, 11.0, 13.0, 12.0, 12.0]; final tempHist = [0.0, 0.1, -0.1, 0.0, 0.2, -0.2, 0.0, 0.1]; @@ -198,6 +199,30 @@ void main() { // other baseline-gated metric in this package uses. expect(m.note, 'need_baseline:have=2,need=7'); }); + + test( + 'sub-quantum RHR baseline: ordinary +3 bpm night does not fabricate ' + 'the alcohol signature', () { + // Whole-bpm-quantized baseline (SD ~0.76, below the 1 bpm quantum) — + // the exact shape illness_cusum.dart's guard was written for. An + // ordinary +3 bpm night must not standardize against rounding noise. + final quantizedRhrHist = [54.0, 55.0, 56.0, 55.0, 54.0, 55.0, 56.0, 55.0]; + final tonight = NightSignature( + rhr: 58, // +3 bpm vs median 55 — unremarkable + rmssd: 60, // no RMSSD move at all + hrDipPct: 12, + skinTempZ: 0.0, + ); + final m = alcoholNightFlag(tonight, + rhrHistory: quantizedRhrHist, + rmssdHistory: rmsHist, + hrDipHistory: dipHist, + skinTempZHistory: tempHist); + final v = m.value!; + expect(v.alcoholHypothesisBand, isNot(anyOf('moderate', 'heavy'))); + expect(v.rhrZ, isNull); + expect(m.note, contains('rhr baseline dispersion below whole-bpm quantum')); + }); }); group('percentile-of-you + records (MDC-gated)', () {