diff --git a/lib/src/onehz/foundations/baseline.dart b/lib/src/onehz/foundations/baseline.dart index 283fcb4..11095f2 100644 --- a/lib/src/onehz/foundations/baseline.dart +++ b/lib/src/onehz/foundations/baseline.dart @@ -84,8 +84,23 @@ RobustBaseline robustBaseline(List window, {int minValid = 3}) { /// — a 14-night whole-bpm baseline alternating 58/59 has MAD 0.5 (robustZ /// succeeds) but SD ~0.52, exactly the unresolvable-dispersion case this /// exists to catch). +/// +/// Only a baseline that actually SITS ON the quantum grid is quantized. The +/// nightly producers (`nocturnalRhr`'s low30Mean, `nightlySkinTemp`'s mean) +/// average ~1800 whole-unit samples, so their values are continuous: a tight +/// but real between-night SD under one step is physiology there, not rounding, +/// and refusing it dropped RHR from readiness on exactly the night it rose. +/// A MIXED history (whole-bpm imported nights + band nights) is still +/// quantized while at least half of it sits on the grid: that is when MAD +/// collapses, so one off-grid night must not switch the guard off. bool dispersionBelowQuantum(List base, double quantum) { if (quantum <= 0) return false; + var onGrid = 0; + for (final v in base) { + final q = v / quantum; + if ((q - q.roundToDouble()).abs() <= 1e-9) onGrid++; + } + if (onGrid * 2 < base.length) return false; // mostly continuous final sd = stddev(base); return sd == null || sd < quantum; } diff --git a/lib/src/onehz/human/readiness_glassbox.dart b/lib/src/onehz/human/readiness_glassbox.dart index 43034a6..526bc53 100644 --- a/lib/src/onehz/human/readiness_glassbox.dart +++ b/lib/src/onehz/human/readiness_glassbox.dart @@ -213,7 +213,7 @@ Metric glassBoxReadiness( beyondUsualSpread: beyond, used: true, )); - raw.add(_RawItem(inp.label, contribution, beyond)); + raw.add(_RawItem(inp.label, contribution, beyond, delta)); wsum += inp.weight; wpsum += inp.weight * oriented; nUsable++; @@ -236,8 +236,10 @@ Metric glassBoxReadiness( // Drivers ranked by |contribution|; only NAME a driver past the SWC. final ranked = [...raw]..sort((a, b) => b.c.abs().compareTo(a.c.abs())); final drivers = []; + double? topDelta; for (final r in ranked) { if (!r.beyondUsualSpread) continue; // never name a mover inside the noise + topDelta ??= r.delta; drivers.add(Driver( r.label, r.c, @@ -245,7 +247,7 @@ Metric glassBoxReadiness( )); } - final narrative = _buildNarrative(score, drivers); + final narrative = _buildNarrative(score, drivers, topDelta ?? 0); // Confidence reflects how many of the priority inputs were usable. final conf = (nUsable / inputs.length.toDouble()).clamp(0.3, 0.9); @@ -264,20 +266,23 @@ class _RawItem { final String label; final double c; // weighted contribution final bool beyondUsualSpread; - const _RawItem(this.label, this.c, this.beyondUsualSpread); + final double delta; // raw value - baseline centre (NOT oriented) + const _RawItem(this.label, this.c, this.beyondUsualSpread, this.delta); } -String _buildNarrative(double score, List drivers) { +String _buildNarrative(double score, List drivers, double topDelta) { final band = score >= 70 ? 'You\'re ready' : score >= 40 ? 'A moderate day' - : 'Take it easier today'; + : 'A low-readiness day'; if (drivers.isEmpty) { return '$band — nothing moved beyond your normal day-to-day noise.'; } final top = drivers.first; - final dir = top.contribution >= 0 ? 'up' : 'down'; + // Direction of the RAW value, not the oriented contribution: a lower RHR + // lifts the score but is still "down". + final dir = topDelta >= 0 ? 'up' : 'down'; final word = _humanLabel(top.label); return '$band — mainly because your $word is $dir vs your usual.'; } diff --git a/test/dispersion_guard_test.dart b/test/dispersion_guard_test.dart index 0c6c836..102b931 100644 --- a/test/dispersion_guard_test.dart +++ b/test/dispersion_guard_test.dart @@ -30,6 +30,37 @@ void main() { test('an empty baseline (SD null) with a real quantum refuses', () { expect(dispersionBelowQuantum([], 1), isTrue); }); + + test('an off-grid (averaged, continuous) baseline is not quantized', () { + // nightly 30-min-mean RHR: tight but real, SD ~0.37 bpm. + expect( + dispersionBelowQuantum( + [52.1, 52.4, 51.8, 52.9, 52.3, 51.6, 52.0], 1), + isFalse); + }); + + test('a mostly whole-bpm baseline with one off-grid night still refuses', + () { + // imported whole-bpm history + one band night (low30Mean). + final base = [ + 58, 58, 58, 58, 58, 58, 58, 58, 59, 59, 58, 58, 58, 58.3 + ]; + expect(dispersionBelowQuantum(base, 1), isTrue); + final m = readinessComposite([rhrInput(61, base)], + minInputs: 1, minWeightSum: 0.0); + expect(m.inputs_used, isNot(contains('RHR'))); + }); + }); + + test('readinessComposite keeps a tight continuous RHR baseline', () { + final base = [ + 52.1, 52.4, 51.8, 52.9, 52.3, 51.6, 52.0, 52.7, 52.2, 51.9, 52.5, 52.3, + 52.0, 52.6 + ]; + final m = readinessComposite([rhrInput(58.0, base)], + minInputs: 1, minWeightSum: 0.0); + expect(m.inputs_used, contains('RHR')); + expect(m.value!.score, lessThan(50)); }); group('readinessComposite still emits the byte-identical refusal string', diff --git a/test/onehz/human_test.dart b/test/onehz/human_test.dart index 4a8a751..14c648e 100644 --- a/test/onehz/human_test.dart +++ b/test/onehz/human_test.dart @@ -352,6 +352,29 @@ void main() { expect(v.narrative.toLowerCase(), isNot(contains('noise'))); }); + test('narrative direction follows the raw value, not the oriented score', + () { + final hist = [55, 56, 57, 58, 59, 55, 56, 57, 58, 59]; + String say(double tonight) { + // ignore: deprecated_member_use_from_same_package + final m = glassBoxReadiness([ + GlassBoxInput( + label: 'rhr', + value: tonight, + history: hist, + weight: wRhr, + lowerIsBetter: true), + ]); + return m.value!.narrative; + } + + // a LOWER rhr lifts the score but the rhr itself went down. + expect(say(48), contains('resting heart rate is down')); + expect(say(66), contains('resting heart rate is up')); + // describes, never prescribes. + expect(say(66), isNot(contains('easier'))); + }); + test('a whole-bpm quantized baseline is not named a driver even past 0.5*scale', () { // Alternating 58/59 bpm: MAD=0.5 -> scaled MAD ~0.74, so 0.5*scale ~0.37