Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions lib/src/onehz/foundations/baseline.dart
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,23 @@ RobustBaseline robustBaseline(List<double> 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<double> 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;
}
Expand Down
17 changes: 11 additions & 6 deletions lib/src/onehz/human/readiness_glassbox.dart
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ Metric<GlassBoxReadiness> 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++;
Expand All @@ -236,16 +236,18 @@ Metric<GlassBoxReadiness> 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 = <Driver>[];
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,
detail: r.c >= 0 ? 'lifting your score' : 'dragging your score down',
));
}

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);
Expand All @@ -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<Driver> drivers) {
String _buildNarrative(double score, List<Driver> 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.';
}
Expand Down
31 changes: 31 additions & 0 deletions test/dispersion_guard_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <double>[
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 = <double>[
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',
Expand Down
23 changes: 23 additions & 0 deletions test/onehz/human_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <double>[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
Expand Down
Loading