From 81bc55e6000830015768d1342b2624bf60e441d0 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 6 Aug 2026 23:19:08 +0530 Subject: [PATCH] A sedentary day is not eight naps: keep sedentary wake in the awake baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #38's awake HR baseline excluded the main sleep and EVERY detected bout. Since every stretch of >=5 min of wrist immobility is a bout, that removed all of the day's still time, and what was left was the AMBULATORY HR median wearing the name "awake baseline". The dip gate `medHr > baseline * napRestingHrMult` is then cleared by any motionless awake stretch whose HR sits more than 5% below walking HR: desk work, reading, driving, a sofa. Measured on the merged code, not argued. A synthetic day of 8 x (6 min walking @ 96 bpm, 25 min motionless @ 72 bpm) — nobody napped — returned EIGHT naps totalling 199 minutes, each at confidence 0.85, the cap. An unremarkable 10% contrast (84 vs 76 bpm) did the same. Keeping the still seconds puts the median at 72, and 0.95 x 72 = 68.4 < 72 rejects all eight. The change was reaching for two real exclusions, and both are kept: * The CANDIDATE's own low-HR seconds, or a bout is graded against a median it is itself dragging down. That is inherently per-candidate, so the baseline now moves inside the loop: the day's awake pool minus THIS bout. * Any UNFINISHED bout. The nap window deliberately runs hours past midnight, so the first hours of tonight's sleep sit in the record; that is sleep, not sedentary wake, and belongs in no baseline. Still excluded, day-wide. What is no longer excluded is ordinary daytime stillness — which is precisely what an awake baseline is supposed to be made of. Absence stays honest. Moving the baseline per-candidate moved the "not enough awake HR" abstain with it, and a day whose only still block cannot be judged must not come back as an empty list that every caller reads as "judged, none". A new `noBaseline` counter carries that case, and when nothing was emitted and at least one candidate went unjudged the day returns Metric.absent with the count — restoring at day level what the whole-day check used to give for free. The existing "too few awake HR samples" test pins it. Tests: three new cases, and they are not decorative. Reintroducing the bug (drop the `if (!unfinished[b]) continue;` guard) fails exactly these three, 26 -> 23, while EVERY pre-existing nap test still passes — the old suite structurally could not catch this, because its false-positive fixture uses bpm 80 for both its active and its still segments, so active and sedentary HR are identical and the baseline cannot be inflated. The contrast is the bug, so it is now in the fixture. The third test is the other direction: a genuine 56 bpm nap on an otherwise sedentary day is still detected, so this does not over-correct into missing real naps. dart analyze clean; 409 tests green. --- lib/src/onehz/sleep/nap.dart | 107 ++++++++++++++++++++++++++--------- test/onehz/nap_test.dart | 71 +++++++++++++++++++++++ 2 files changed, 150 insertions(+), 28 deletions(-) diff --git a/lib/src/onehz/sleep/nap.dart b/lib/src/onehz/sleep/nap.dart index 4e72fbc..3437ef0 100644 --- a/lib/src/onehz/sleep/nap.dart +++ b/lib/src/onehz/sleep/nap.dart @@ -227,49 +227,60 @@ Metric> detectNaps( } } - // The AWAKE HR baseline: seconds that are neither the main sleep nor ANY - // detected sleep bout. Excluding only `mainSleep` was not enough — it left - // the candidate bout's own low-HR seconds in the median it is then judged - // against, and on this device the nap window deliberately extends hours past - // midnight, so the first hours of tonight's sleep were dragging the bar down - // too. Both make the gate self-suppressing: the quieter the sleep, the lower - // the threshold it has to beat. - final inBout = List.filled(n, false); - for (final b in bouts) { - for (var k = b[0]; k < b[1]; k++) { - inBout[k] = true; + // The AWAKE HR baseline pool: every second that is not the main sleep and not + // a bout we have already DEFERRED as unfinished. + // + // SEDENTARY WAKE STAYS IN THIS POOL, and that is the whole point. Excluding + // every detected bout — which is what "neither the main sleep nor ANY bout" + // did — removes all of the day's still time, so what survives is the + // AMBULATORY HR median, not an awake baseline. The gate `medHr > baseline * + // napRestingHrMult` is then cleared by any motionless awake stretch whose HR + // sits more than 5% below walking HR: desk work, reading, driving, a sofa. + // Measured, not argued: a synthetic day of 8 x (6 min walking @ 96 bpm, 25 + // min motionless @ 72 bpm) reported EIGHT naps totalling 199 minutes, each at + // confidence 0.85 — the cap — where nobody had napped. A 10% contrast (76 vs + // 84 bpm) did the same. Keeping the still seconds puts the median at 72, and + // 0.95 x 72 = 68.4 < 72 rejects all eight. The exclusion WAS the bug. + // + // Two exclusions are still right, and they are the two the original change + // was actually reaching for: + // * the CANDIDATE's own low-HR seconds, or the gate grades a bout against a + // median it is itself dragging down — self-suppressing, and the quieter + // the sleep the lower the bar it has to beat. That is per-candidate, so + // it is done inside the loop below, not here. + // * any UNFINISHED bout. The nap window deliberately runs hours past + // midnight, so the first hours of tonight's sleep sit in this record; + // they are sleep, not sedentary wake, and they belong in no baseline. + final deferredSec = List.filled(n, false); + for (var b = 0; b < bouts.length; b++) { + if (!unfinished[b]) continue; + for (var k = bouts[b][0]; k < bouts[b][1]; k++) { + deferredSec[k] = true; } } - final awake = []; + // Indices, not values: each candidate has to subtract ITSELF from this pool. + final awakeIdx = []; for (var k = 0; k < n; k++) { - if (hr[k] <= 0 || inBout[k]) continue; + if (hr[k] <= 0 || deferredSec[k]) continue; if (mainSleep != null && k >= mainSleep.start && k < mainSleep.end) continue; - awake.add(hr[k]); + awakeIdx.add(k); } - if (awake.length < minAwakeHrSamples) { + if (awakeIdx.length < minAwakeHrSamples) { return Metric>.absent( tier: Tier.estimate, inputs_used: inputs, note: 'not enough awake daytime HR to set a baseline ' - '(${awake.length}s, need ${minAwakeHrSamples}s) — ' + '(${awakeIdx.length}s, need ${minAwakeHrSamples}s) — ' 'cannot corroborate stillness as sleep', ); } - final baseline = median(awake)!; - if (baseline <= 0) { - return const Metric>.absent( - tier: Tier.estimate, - inputs_used: inputs, - note: 'no usable awake daytime HR baseline', - ); - } final naps = []; var deferred = 0, unverifiable = 0, offWrist = 0, awakeStill = 0; // Every rejection path increments one of these and reports it in `skipped`. // A silent `continue` turns "your 7-hour still block is too long to be a nap" // into a bare "no qualifying nap", which tells the caller nothing about why. - var outOfRange = 0, inMainSleep = 0; + var outOfRange = 0, inMainSleep = 0, noBaseline = 0; for (var bi = 0; bi < bouts.length; bi++) { final start = bouts[bi][0], end = bouts[bi][1]; @@ -308,9 +319,6 @@ Metric> detectNaps( continue; } - // NOT `inBout` — that name belongs to the whole-day boolean baseline mask - // above, and shadowing it here would silently hand the HR list to any later - // edit that reaches for the mask inside this loop. final boutHr = []; for (var k = start; k < end; k++) { if (hr[k] > 0) boutHr.add(hr[k]); @@ -321,6 +329,32 @@ Metric> detectNaps( continue; } final medHr = median(boutHr)!; + + // PER-CANDIDATE baseline: the day's awake pool minus THIS bout. A bout must + // not be graded against a median it is itself pulling down — see the pool + // construction above for why only the candidate and the deferred bouts come + // out, and not every still block in the day. + final awakeHr = []; + for (final k in awakeIdx) { + if (k >= start && k < end) continue; + awakeHr.add(hr[k]); + } + if (awakeHr.length < minAwakeHrSamples) { + // The day had enough awake HR, but not once this candidate is removed — + // so THIS bout cannot be corroborated, while others still may be. Abstain + // for it rather than judging it against a median built from a handful of + // seconds. Counted separately from `unverifiable` because it is the one + // rejection that makes the DAY's verdict incomplete: see the check after + // the loop. + noBaseline++; + continue; + } + final baseline = median(awakeHr)!; + if (baseline <= 0) { + noBaseline++; + continue; + } + if (medHr > baseline * napRestingHrMult) { awakeStill++; continue; @@ -366,8 +400,25 @@ Metric> detectNaps( )); } + // A still block we could not RULE OUT is not the same as a day with no nap. + // If nothing was emitted and at least one candidate went unjudged for want of + // an awake baseline, the day's verdict is unknown — say so, rather than + // returning an empty list that every caller reads as "judged, none". This is + // the day-level abstain that used to fall out of the whole-day baseline check + // before it became per-candidate. + if (naps.isEmpty && noBaseline > 0) { + return Metric>.absent( + tier: Tier.estimate, + inputs_used: inputs, + note: 'not enough awake daytime HR to set a baseline for ' + '$noBaseline still block(s) (need ${minAwakeHrSamples}s outside the ' + 'block itself) — cannot corroborate stillness as sleep', + ); + } + final skipped = [ if (deferred > 0) '$deferred deferred (record ends mid-bout)', + if (noBaseline > 0) '$noBaseline without an awake HR baseline', if (outOfRange > 0) '$outOfRange outside 15 min–6 h', if (inMainSleep > 0) '$inMainSleep inside the main sleep window', if (unverifiable > 0) '$unverifiable unverifiable (HR coverage <50%)', diff --git a/test/onehz/nap_test.dart b/test/onehz/nap_test.dart index 082a9a8..2220e24 100644 --- a/test/onehz/nap_test.dart +++ b/test/onehz/nap_test.dart @@ -147,6 +147,50 @@ void main() { reason: 'the rejection reason must be visible, not silent'); }); + test('a SEDENTARY DAY is not eight naps — sedentary wake stays in the ' + 'awake baseline', () { + // THE REGRESSION. The baseline once excluded every detected bout, which + // removed all of the day's still time and left the AMBULATORY HR median + // in its place. Every motionless block then cleared `medHr > baseline * + // napRestingHrMult` on nothing but the ordinary sit/walk HR difference: + // this exact fixture returned EIGHT naps totalling 199 minutes, each at + // confidence 0.85 — the cap — for a day nobody napped on. + // + // The test above cannot catch it: it uses bpm 80 for BOTH its active and + // still segments, so active and sedentary HR are identical and the + // baseline cannot be inflated. The contrast IS the bug, so it has to be + // in the fixture. + final d = _Day(); + for (var b = 0; b < 8; b++) { + d + ..active(6, bpm: 96) // walking + ..still(25, bpm: 72); // at the desk — awake, just not moving + } + d.active(60, bpm: 96); + + final m = detectNaps(d.accel, d.hr); + + expect(m.value, isEmpty, + reason: 'sitting still at 72 bpm on a day you walk at 96 is not a ' + 'nap; keeping the still seconds puts the median at 72, and ' + '0.95 x 72 = 68.4 < 72 rejects every block'); + expect(m.note, contains('no HR dip')); + }); + + test('the same day at a 10% HR contrast is also not a nap', () { + // The failure did not need a dramatic difference — 84 vs 76 bpm, which is + // an unremarkable sit-versus-walk gap, produced all eight at 0.84. + final d = _Day(); + for (var b = 0; b < 8; b++) { + d + ..active(6, bpm: 84) + ..still(25, bpm: 76); + } + d.active(60, bpm: 84); + + expect(detectNaps(d.accel, d.hr).value, isEmpty); + }); + test('an off-wrist span is rejected even though it is perfectly still', () { final d = _Day() ..active(90) @@ -377,6 +421,33 @@ void main() { reason: 'the awake baseline is ~78 bpm; a 60 bpm nap clears it'); expect(m.value!.single.tstSec, closeTo(30 * 60, 90)); }); + + test('a real nap is still detected on a SEDENTARY day', () { + // The other side of the same coin, and the reason the fix keeps sedentary + // wake in the pool instead of dropping every bout: a day that is mostly + // sitting still must still be able to report the one block that was + // actually sleep. Baseline lands at desk HR (~72), and 56 clears + // 0.95 x 72 = 68.4 comfortably. + final d = _Day(); + for (var b = 0; b < 4; b++) { + d + ..active(6, bpm: 96) + ..still(25, bpm: 72); + } + d.still(50, bpm: 56); // the genuine nap, a real autonomic dip + for (var b = 0; b < 4; b++) { + d + ..active(6, bpm: 96) + ..still(25, bpm: 72); + } + d.active(60, bpm: 96); // end awake: nothing to defer at the record end + + final m = detectNaps(d.accel, d.hr); + + expect(m.value, hasLength(1), + reason: 'the deep-dip block is a nap even though the day around it ' + 'is sedentary'); + }); }); group('detectNaps — recording holes are not stillness', () {