From 2a624b97303f38877841df9068c72a52b67e5dc9 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:48:32 +0530 Subject: [PATCH] glassbox: guard SWC gate against quantized rhr/temp baselines readiness_composite and overreaching_conjunction both already gate their 0.5*scale worth-mentioning check with dispersionBelowQuantum, since a whole-bpm rhr (or integer skin-temp adc) baseline alternating between two adjacent values has a nonzero MAD that's really rounding noise, not real dispersion. readiness_glassbox does the identical 0.5*scale standardization on the same channels but never got the guard, so crossday_pipeline's rhr/temp inputs could get named as narrative drivers on pure quantization. adds a quantum field to GlassBoxInput (mirrors ReadinessInput.quantum) and uses it in the beyond gate. narrative-only change, doesn't touch the score. --- lib/src/onehz/human/readiness_glassbox.dart | 16 ++++++++++++++-- test/onehz/human_test.dart | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/lib/src/onehz/human/readiness_glassbox.dart b/lib/src/onehz/human/readiness_glassbox.dart index aa65aa2..43034a6 100644 --- a/lib/src/onehz/human/readiness_glassbox.dart +++ b/lib/src/onehz/human/readiness_glassbox.dart @@ -29,7 +29,7 @@ import '../types.dart'; import '../util.dart'; -import '../foundations/baseline.dart'; +import '../foundations/baseline.dart' show robustBaseline, dispersionBelowQuantum; /// One readiness input the caller supplies. class GlassBoxInput { @@ -39,12 +39,16 @@ class GlassBoxInput { final double weight; // relative weight (HRV>RHR>RR>temp) /// If true, a LOWER value is better-for-you (e.g. RHR, resp, temp deviation). final bool lowerIsBetter; + /// The input's own measurement quantum (e.g. 1 whole bpm, 1 ADC count). + /// 0 = continuous/not quantized, no guard. See the SWC gate below. + final double quantum; const GlassBoxInput({ required this.label, required this.value, required this.history, required this.weight, this.lowerIsBetter = false, + this.quantum = 0, }); } @@ -190,7 +194,15 @@ Metric glassBoxReadiness( final base = robustBaseline(inp.history, minValid: minHistory); final scale = base.scale; final delta = (base.center == null) ? 0.0 : (inp.value - base.center!); - final beyond = scale != null && scale > 0 && delta.abs() >= 0.5 * scale; + // A whole-bpm RHR (or integer skin-temp ADC) baseline alternating between + // two adjacent values can carry a nonzero-but-unresolvable MAD that is + // really quantization noise, not physiology (the same 58/59-bpm case + // readiness_composite.dart and overreaching_conjunction.dart guard against + // on this exact channel). If the baseline's dispersion doesn't clear the + // input's own measurement quantum, don't name it as "beyond usual spread". + final quantized = dispersionBelowQuantum(inp.history, inp.quantum); + final beyond = + !quantized && scale != null && scale > 0 && delta.abs() >= 0.5 * scale; final contribution = inp.weight * (oriented - 50.0); items.add(ReadinessBreakdownItem( diff --git a/test/onehz/human_test.dart b/test/onehz/human_test.dart index e042ebd..4a8a751 100644 --- a/test/onehz/human_test.dart +++ b/test/onehz/human_test.dart @@ -352,6 +352,27 @@ void main() { expect(v.narrative.toLowerCase(), isNot(contains('noise'))); }); + 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 + // is cleared by essentially every night -- but that's 1-bpm rounding + // noise, not real physiology. quantum:1 must suppress it. + final rhr = List.generate(14, (i) => i.isEven ? 58.0 : 59.0); + final inputs = [ + GlassBoxInput( + label: 'rhr', + value: 58.0, + history: rhr, + weight: wRhr, + lowerIsBetter: true, + quantum: 1), + ]; + // ignore: deprecated_member_use_from_same_package + final m = glassBoxReadiness(inputs); + expect(m.value!.breakdown.single.beyondUsualSpread, isFalse); + expect(m.value!.drivers, isEmpty); + }); + test('a mover inside the usual spread is never named as a driver', () { // All inputs essentially at their median => nothing clears the SWC. final hist = List.generate(20, (i) => 50.0 + (i % 5) * 0.1);