diff --git a/lib/src/onehz/human/overreaching_conjunction.dart b/lib/src/onehz/human/overreaching_conjunction.dart index 62449fb..4cde0c3 100644 --- a/lib/src/onehz/human/overreaching_conjunction.dart +++ b/lib/src/onehz/human/overreaching_conjunction.dart @@ -107,7 +107,13 @@ Metric overreachingConjunction({ final base = robustBaseline(rhrBaselineWindow, minValid: minBaseline); final centre = base.center; final scale = base.scale; - final gate = (scale != null && scale > 0) ? 0.5 * scale : null; + // Nightly RHR is whole-bpm; an alternating baseline (e.g. 58/59) can carry + // a small nonzero MAD that is unresolvable rounding noise, not real + // dispersion — the same guard illness_cusum.dart and readiness_composite.dart + // already apply to this exact channel. + final rhrBelowQuantum = dispersionBelowQuantum(rhrBaselineWindow, 1.0); + final gate = + (!rhrBelowQuantum && scale != null && scale > 0) ? 0.5 * scale : null; if (centre == null || gate == null || !base.sufficient) { return Metric.absent( tier: Tier.estimate, diff --git a/test/onehz/overreaching_conjunction_test.dart b/test/onehz/overreaching_conjunction_test.dart new file mode 100644 index 0000000..b1f457d --- /dev/null +++ b/test/onehz/overreaching_conjunction_test.dart @@ -0,0 +1,51 @@ +// Regression for the whole-bpm RHR quantization false positive: a baseline +// alternating between two adjacent integers carries a small nonzero MAD that +// is unresolvable rounding noise, not real dispersion. Without +// dispersionBelowQuantum this let a 1 bpm rise clear the gate. + +import 'package:test/test.dart'; +import 'package:openstrap_analytics/onehz.dart'; + +void main() { + group('overreachingConjunction RHR quantum guard', () { + final load = Metric( + value: const LoadState(40.0, 65.0, -25.0), // atl/ctl = 1.625 >= 1.5 + confidence: 0.8, + tier: Tier.high, + inputs_used: const ['daily_trimp'], + ); + + test('alternating whole-bpm baseline abstains instead of firing', () { + final baseline = [ + 58, 59, 58, 59, 58, 59, 58, 59, 58, 59, 58, 59, 58, 59, + ]; + // 1 bpm above the median baseline on every recent night — exactly the + // rounding-noise case the guard exists to catch. + final recent = [60.0, 60.0, 60.0, 60.0, 60.0]; + + final m = overreachingConjunction( + load: load, + rhrRecent: recent, + rhrBaselineWindow: baseline, + ); + + expect(m.present, isFalse); + }); + + test('real dispersion in the baseline still lets it fire', () { + final baseline = [ + 54, 56, 58, 60, 55, 57, 59, 61, 54, 58, 60, 56, 59, 55, + ]; + final recent = [65.0, 65.0, 65.0, 65.0, 65.0]; + + final m = overreachingConjunction( + load: load, + rhrRecent: recent, + rhrBaselineWindow: baseline, + ); + + expect(m.present, isTrue); + expect(m.value!.bothPointSameWay, isTrue); + }); + }); +}