diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 8be2472b1..dc84117b9 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1719,7 +1719,22 @@ import 'substrate.dart'; // withheld — a fabricated-metric bug on `circadian_lifestyle`'s stored // output. kAnalyticsPin repinned to analytics main's tip (one commit past // PR #75's merge SHA). -const int kAlgoVersion = 97; +// 97 → 98 (band-state night END, gen5/MG): an AUTO night now ENDS at the +// band's own last SLEEP second when the band's continuously observed tail +// after it is ≥ 10 min awake (UP/WAKE, no re-settling) — the lie-in is no +// longer counted as sleep, so in-bed/TST/efficiency and the wake time move +// earlier on those mornings (`sleep.band_offset_trim_sec` records the seconds +// removed). Naps cannot reclaim that tail: the nap main-sleep exclusion runs +// to the UNTRIMMED end (`_DayBlocksInput.napExcludeEndSec`). A band-corrected +// night replaces a banked longer one with the same onset, bounded to the +// corroborated tail (`isRicherSleep`). The morning readiness pin, which fires +// at `sleepOffsetSec + 1 h`, now freezes earlier on those mornings. UNCHANGED: +// onset, stages, which day owns the night (decided on the untrimmed end), +// gen4 (no band envelope), manual/confirmed overrides and the HR-led +// fallback. Analytics change: OpenStrap/analytics PR #80 — pinned below to +// that PR's head on the author's fork until #80 merges; repinned to +// the OpenStrap/analytics merge SHA before this PR is merged. +const int kAlgoVersion = 98; /// The sibling SHAs this version was derived against, asserted against /// pubspec.yaml in test/db_serve_version_and_reads_test.dart. /// @@ -1895,7 +1910,11 @@ const int kAlgoVersion = 97; // skin-temp window (see pubspec.yaml's comment beside the `ref:` for the // verification command). kAlgoVersion bumped 96 -> 97, see the changelog // entry above. -const String kAnalyticsPin = '0441ef9e6fc6d5681c309ce6341911285e829f20'; +// REPIN @ 9fc1d6a — analytics PR #80 (band-state night end: +// bandTrimmedOffsetSec, segmentSleep(bandSleepState:), +// SleepSegmentation.bandOffsetTrimSec), on OpenStrap/analytics main, for v98 +// above. +const String kAnalyticsPin = '9fc1d6a9b13240ee668e74940e03cb5646113a5c'; // Repinned to analytics main's tip, which carries BOTH PR #72 (hrv_freq // Welch gap guard) and PR #73 (overreachingConjunction rhr quantum guard) — // the two independent kAlgoVersion bumps above (93 and 94). Verified both @@ -4497,6 +4516,8 @@ class DerivationEngine { profile: profile, onsetSec: day.sleepOnsetSec, offsetSec: day.sleepOffsetSec, + napExcludeEndSec: day.sleepOffsetSec + + ((day.sleepJson['band_offset_trim_sec'] as num?)?.toInt() ?? 0), // NOCTURNAL-ONLY. `scalars.rhr` is allowed to fall back to daytime HR // for the resting-HR card; feeding that into TRIMP charged a day // against an awake reference and published a strain the pure pipeline @@ -4974,6 +4995,12 @@ class DerivationEngine { /// A candidate with no night at all is never richer than one that has one, and /// EQUAL is not richer — a pass that reproduces the same night writes, so an /// otherwise-identical candidate still refreshes. + /// + /// One bounded exception: a candidate whose end the band corroborated + /// (`band_offset_trim_sec` set), with the same onset (±60 s), whose UNTRIMMED + /// end covers the banked end, and whose TST loss does not exceed what was + /// removed from the banked window (+60 s), replaces the banked night — that is + /// a corrected wake time, not less substrate. @visibleForTesting static bool isRicherSleep( SleepSessionCandidate prev, @@ -4982,7 +5009,30 @@ class DerivationEngine { final p = _tstSec(prev); if (p == null) return false; final n = _tstSec(next); - return n == null || p > n; + if (n == null) return true; + int? trimOf(SleepSessionCandidate c) => + (c.sleepJson['band_offset_trim_sec'] as num?)?.toInt(); + final sameOnset = (next.sleepOnsetSec - prev.sleepOnsetSec).abs() <= 60; + final nextTrim = trimOf(next); + // A band END correction is not "less substrate" — but only for the part of + // the banked night that lies inside the tail the band corroborated as + // awake: the banked end must fall WITHIN (next end, next untrimmed end], + // and the TST lost may not exceed what was removed from the banked window. + // "Within", not "equal to", because the morning record GROWS: an early + // pass banks a night whose awake tail is still < 10 min; a later pass trims. + if (sameOnset && + nextTrim != null && + next.sleepOffsetSec < prev.sleepOffsetSec && + prev.sleepOffsetSec <= next.sleepOffsetSec + nextTrim + 60 && + p - n <= (prev.sleepOffsetSec - next.sleepOffsetSec) + 60) { + return false; + } + // NO mirror rule (deliberately — review round 3): the trim is a + // deterministic function of the stored rows, and rows are only added or + // replaced by the same record, so a later pass with no trim means the band + // now shows SLEEP later (e.g. a backfilled gap) — new evidence, which must + // be allowed to win through the ordinary comparison. + return p > n; } /// How a day should be filed after its second half failed and the previous @@ -7684,6 +7734,11 @@ class DerivationEngine { // Read on the main isolate and carried in, like every other DB-sourced // input here — this runs inside the compute worker, which has no database. List napEdits = const [], + // Where the main-sleep exclusion ENDS — the night's UNTRIMMED end when the + // band cut the auto window short (`sleep.band_offset_trim_sec`), so the + // lie-in the band rule removed from the night cannot be reclaimed as a + // nap. Null = [offsetSec]. + int? napExcludeEndSec, }) { try { final n = s.length; @@ -7703,11 +7758,12 @@ class DerivationEngine { final hr = [for (final h in s.hr) h.toDouble()]; // Map the main-sleep epoch-second window to indices into the day arrays. ana.SleepWindowSpan? main; - if (offsetSec > onsetSec) { + final excludeEndSec = napExcludeEndSec ?? offsetSec; + if (excludeEndSec > onsetSec) { var lo = -1, hi = -1; for (var i = 0; i < n; i++) { if (lo < 0 && s.tsSec[i] >= onsetSec) lo = i; - if (s.tsSec[i] < offsetSec) hi = i + 1; + if (s.tsSec[i] < excludeEndSec) hi = i + 1; } if (lo >= 0 && hi > lo) main = ana.SleepWindowSpan(lo, hi); } @@ -8049,6 +8105,7 @@ class DerivationEngine { wristOff: inp.wristOffSpans, charging: inp.chargingSpans, napEdits: inp.napEdits, + napExcludeEndSec: inp.napExcludeEndSec ?? offset, ); bundlePatch['sleep_periods'] = _sleepPeriods( onset, @@ -8664,6 +8721,7 @@ class DerivationEngine { List> wristOff = const [], List> charging = const [], List napEdits = const [], + int? napExcludeEndSec, }) => _attachNaps( bundle, @@ -8676,6 +8734,7 @@ class DerivationEngine { wristOff: wristOff, charging: charging, napEdits: napEdits, + napExcludeEndSec: napExcludeEndSec, ); void _log(String m) { @@ -8715,6 +8774,12 @@ class _DayBlocksInput { final int onsetSec; final int offsetSec; + /// Where nap detection's main-sleep exclusion ends: [offsetSec] plus the + /// band trim (`sleep.band_offset_trim_sec`), i.e. the UNTRIMMED auto end, so + /// a lie-in the band rule cut off the night is not reclaimed as a nap. Null + /// = [offsetSec]. + final int? napExcludeEndSec; + /// NOCTURNAL resting HR for this day (`scalars.rhr_nocturnal`) — null unless a /// sleep session was detected. NOT `scalars.rhr`, which is allowed to fall /// back to daytime HR for the resting-HR card and is not a TRIMP reference. @@ -8781,6 +8846,7 @@ class _DayBlocksInput { required this.profile, required this.onsetSec, required this.offsetSec, + this.napExcludeEndSec, required this.rhr, required this.maxHrUsed, required this.liveStepsReal, diff --git a/lib/compute/derive_prepare.dart b/lib/compute/derive_prepare.dart index 507beb9d4..d0edd82eb 100644 --- a/lib/compute/derive_prepare.dart +++ b/lib/compute/derive_prepare.dart @@ -456,6 +456,7 @@ class _PrepareAccumulator { /// See [Substrate.stepCount] for why the sentinel is not 0. final List stepCount = []; final List hrValid = []; + final List bandSleepState = []; /// The DISTINCT non-null `device_family` stamps seen across every page fed in /// (see [Substrate.deviceFamily]). Exactly one ⇒ that is the substrate's @@ -565,6 +566,9 @@ class _PrepareAccumulator { hrValid.addAll(sub.hrValid.length == sub.length ? sub.hrValid : List.filled(sub.length, -1)); + bandSleepState.addAll(sub.bandSleepState.length == sub.length + ? sub.bandSleepState + : List.filled(sub.length, -1)); } void addDecodedPage( @@ -662,6 +666,11 @@ class _PrepareAccumulator { // `device_family == 'gen5'` check that used to sit on top of it was a // band id in the neutral layer (BANDAGNOSTIC C12). hrValid.add(_num(row?['hr_valid'])?.toInt() ?? -1); + // The band's own envelope (0 wake, 1 still, 2 sleep, 3 up). NULL is ABSENT + // (-1): gen4 has no such field, and 0 is a real "wake". On a two-device + // second this is the hr1Hz owner's row (composeOneHzFrames keeps base + // columns) — fine for one WHOOP 5. + bandSleepState.add(_num(row?['band_sleep_state'])?.toInt() ?? -1); final beats = rrByRecTs[recTs]; if (beats == null) continue; for (final beat in beats) { @@ -720,6 +729,7 @@ class _PrepareAccumulator { skinContact: skinContact, stepCount: stepCount, hrValid: hrValid, + bandSleepState: bandSleepState, deviceFamily: deviceFamily, deviceIds: _deviceIds, ); diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index beae36e8b..6b8de894e 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -390,6 +390,7 @@ Map deriveDayBundle(Map inputJson) { // worse night. `absence_reason` is why a window produced nothing. final unobservedSec = (d.sleepJson['unobserved_sec'] as num?)?.toInt(); final absenceReason = d.sleepJson['absence_reason'] as String?; + final bandOffsetTrimSec = (d.sleepJson['band_offset_trim_sec'] as num?)?.toInt(); final runs = _sleepRuns(d); // ── CLINICAL (sleep-windowed) ────────────────────────────────────────────── @@ -1010,6 +1011,8 @@ Map deriveDayBundle(Map inputJson) { // Why this night produced nothing, when the segmenter knows. Null on a // normal night; never render a bare dash for an absence that carries one. 'absence_reason': absenceReason, + // Seconds the band's own last SLEEP cut off the end of the auto window; null = rule did not apply. Provenance, never a metric. + 'band_offset_trim_sec': bandOffsetTrimSec, 'stager': _envelope( hasSleep ? { diff --git a/lib/compute/substrate.dart b/lib/compute/substrate.dart index 426aba5cf..83ca0896f 100644 --- a/lib/compute/substrate.dart +++ b/lib/compute/substrate.dart @@ -257,6 +257,12 @@ class Substrate { /// Same absent-marker discipline as [stepCount] and [accelPresentAt]. final List hrValid; + /// The band's own sleep envelope per second (0 wake, 1 still, 2 sleep, 3 up). + /// Parallel to [tsSec]. **`-1` means absent** (gen4, or a row decoded before + /// the column existed) — 0 is a real "wake". Corroboration only; see + /// analytics band_offset.dart. Read it through [bandSleepStateSlice]. + final List bandSleepState; + /// WHICH STRAP MEASURED THIS SUBSTRATE — `'gen4'`, `'gen5'`, or null. /// /// Stamped at ingest into `decoded_onehz.device_family` and carried here so @@ -318,6 +324,7 @@ class Substrate { required List skinContact, List stepCount = const [], List hrValid = const [], + List bandSleepState = const [], String? deviceFamily, Set deviceIds = const {}, }) => @@ -337,6 +344,7 @@ class Substrate { skinContact: skinContact, stepCount: stepCount, hrValid: hrValid, + bandSleepState: bandSleepState, ); const Substrate._({ @@ -353,6 +361,7 @@ class Substrate { required this.skinContact, this.stepCount = const [], this.hrValid = const [], + this.bandSleepState = const [], this.deviceFamily, this.deviceIds = const {}, }); @@ -476,6 +485,13 @@ class Substrate { /// [stepCount] sliced to [lo, hi), tolerating the legacy empty list. List _stepSlice(int lo, int hi) => _perSecSlice(stepCount, lo, hi); + /// Positional band envelope for [lo, hi), -1 where absent (legacy empty + /// list included). + List bandSleepStateSlice(int lo, int hi) => + bandSleepState.length == length + ? bandSleepState.sublist(lo, hi) + : List.filled(hi - lo, -1); + /// Slice to the half-open window [startSec, endSec) by record time. Returns a /// new Substrate with the 1 Hz arrays sliced and the sparse RR arrays filtered /// to beats whose end time falls in the window. @@ -500,6 +516,7 @@ class Substrate { skinContact: skinContact.sublist(lo, hi), stepCount: _stepSlice(lo, hi), hrValid: _perSecSlice(hrValid, lo, hi), + bandSleepState: _perSecSlice(bandSleepState, lo, hi), deviceFamily: deviceFamily, deviceIds: deviceIds, rrTsMs: rr.$1, @@ -529,6 +546,7 @@ class Substrate { skinContact: skinContact.sublist(lo, hi), stepCount: _stepSlice(lo, hi), hrValid: _perSecSlice(hrValid, lo, hi), + bandSleepState: _perSecSlice(bandSleepState, lo, hi), deviceFamily: deviceFamily, deviceIds: deviceIds, rrTsMs: rr.$1, @@ -595,6 +613,7 @@ class Substrate { 'skin_contact': skinContact, 'step_count': stepCount, 'hr_valid': hrValid, + 'band_sleep_state': bandSleepState, // Null (unknown provenance) is a real answer — emit the key regardless. 'device_family': deviceFamily, 'device_ids': deviceIds.toList(), @@ -653,6 +672,11 @@ class Substrate { final l = ints(m, 'hr_valid'); return l.length == n ? l : List.filled(n, -1); }(), + // Same reason again: absent is -1, and 0 is a real "wake". + bandSleepState: () { + final l = ints(m, 'band_sleep_state'); + return l.length == n ? l : List.filled(n, -1); + }(), deviceFamily: m['device_family'] as String?, deviceIds: ((m['device_ids'] as List?) ?? const []) .map((e) => e.toString()) @@ -802,6 +826,8 @@ Substrate decodeSubstrate(List hexes) { // replay, which carries no device stamp either — so it would refuse at // `hrValidAt` regardless. hrValid: List.filled(n, -1), + // Raw-hex replay carries no band envelope either: ABSENT. + bandSleepState: List.filled(n, -1), ); } @@ -1172,6 +1198,10 @@ List calendarDays( rrMs: rrMsSeg, rrTsMs: rrTsSeg, habitualMidsleepSec: habitualMidsleepSec, + // Gen5/MG band envelope, positional 1:1 with accelSlice: may END + // the auto night at the band's own last SLEEP (AUTO path only — + // never the override or the HR-led fallback below). + bandSleepState: sub.bandSleepStateSlice(loS, hiS), ); } else { // Not an error and not "no sleep" — just no accel evidence. Fall @@ -1207,10 +1237,15 @@ List calendarDays( if (s.present && s.window != null) { final offSec = s.window!.offsetMs! ~/ 1000; + // Which day owns the night is decided on the UNTRIMMED end, so the band + // rule moves the wake time but never the night's day (each day searches a + // slice clipped at its own midnight and would otherwise see two different + // tails — a night could be claimed by both days or by neither). + final ownerSec = offSec + (s.bandOffsetTrimSec ?? 0); // Auto/fallback: attribute only if the wake lands in this calendar day. // Manual/confirmed: trust the user — attribute to the day they set it on. final userSet = ov != null; - if (userSet || (offSec >= dayStart && offSec < dayEnd)) { + if (userSet || (ownerSec >= dayStart && ownerSec < dayEnd)) { seg = s; sleepLo = loS + s.window!.onsetIdx; sleepHi = loS + s.window!.offsetIdx; diff --git a/lib/data/db.dart b/lib/data/db.dart index f4ee1ecfe..c9ba18623 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -7341,7 +7341,7 @@ class LocalDb { 'SELECT counter, rec_ts, hr, ax, ay, az, ' 'spo2_red_raw, spo2_ir_raw, skin_temp_raw, ' 'step_count, step_cadence, activity_class, skin_temp_c, ' - 'on_wrist, hr_valid, hr_alt, device_family, device_id ' + 'on_wrist, hr_valid, hr_alt, device_family, device_id, band_sleep_state ' 'FROM decoded_onehz ' 'WHERE rec_ts >= ? AND rec_ts <= ? AND ${derivableSourceSql()} ' 'ORDER BY rec_ts ASC, counter ASC LIMIT ?', @@ -7352,7 +7352,7 @@ class LocalDb { 'SELECT counter, rec_ts, hr, ax, ay, az, ' 'spo2_red_raw, spo2_ir_raw, skin_temp_raw, ' 'step_count, step_cadence, activity_class, skin_temp_c, ' - 'on_wrist, hr_valid, hr_alt, device_family, device_id ' + 'on_wrist, hr_valid, hr_alt, device_family, device_id, band_sleep_state ' 'FROM decoded_onehz ' 'WHERE rec_ts >= ? AND rec_ts <= ? AND ${derivableSourceSql()} ' 'AND (rec_ts > ? OR (rec_ts = ? AND counter > ?)) ' diff --git a/lib/data/models.dart b/lib/data/models.dart index ed619c12d..333564c6c 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -146,8 +146,9 @@ class Sample { /// sleep. (Those records are the ARCHIVE, a biased sample, so the counts are /// not a population; the ordering is the claim.) /// - /// Nothing reads it. Persisting it claims nothing — same contract as - /// [tempCh2C] and [dynAccelG]. + /// The derive query reads it into `Substrate.bandSleepState`, and the auto + /// sleep segmenter uses it to END a night at the band's last SLEEP second + /// (analytics `band_offset.dart`) — never to create, extend or stage one. final int? bandSleepState; Sample({ diff --git a/pubspec.lock b/pubspec.lock index d35cae58f..3d3dbc0e6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -937,8 +937,8 @@ packages: dependency: "direct main" description: path: "." - ref: "0441ef9e6fc6d5681c309ce6341911285e829f20" - resolved-ref: "0441ef9e6fc6d5681c309ce6341911285e829f20" + ref: "9fc1d6a9b13240ee668e74940e03cb5646113a5c" + resolved-ref: "9fc1d6a9b13240ee668e74940e03cb5646113a5c" url: "https://github.com/OpenStrap/analytics.git" source: git version: "1.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 2a2303048..dd34b44dd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -494,7 +494,7 @@ dependencies: # window, so kAlgoVersion bumped (96 -> 97). Verified: # `git show 0441ef9:lib/src/onehz/wellness/temp_circadian.dart | # grep -n 'varTot == 0\|diffN == 0'` - ref: 0441ef9e6fc6d5681c309ce6341911285e829f20 + ref: 9fc1d6a9b13240ee668e74940e03cb5646113a5c # BLE — flutter_blue_plus is the maintained cross-platform GATT client. flutter_blue_plus: ^1.36.8 diff --git a/test/derive_result_protection_test.dart b/test/derive_result_protection_test.dart index 00292fc93..3e0076cc5 100644 --- a/test/derive_result_protection_test.dart +++ b/test/derive_result_protection_test.dart @@ -353,14 +353,23 @@ void main() { // replaced the good one. "It got fixed, then a few syncs later it went // back." group('a night never re-stages shorter', () { - SleepSessionCandidate night(num? tstSec) => SleepSessionCandidate( + SleepSessionCandidate night( + num? tstSec, { + int onset = 1000, + int offset = 2000, + int? bandTrim, + }) => + SleepSessionCandidate( dayId: '2026-08-19', confidence: 0.8, flags: const [], - sleepJson: {'tst_sec': ?tstSec}, + sleepJson: { + 'tst_sec': ?tstSec, + 'band_offset_trim_sec': ?bandTrim, + }, hypnoStages: const [], - sleepOnsetSec: 1000, - sleepOffsetSec: 2000, + sleepOnsetSec: onset, + sleepOffsetSec: offset, ); test('a shorter re-stage loses to the banked night', () { @@ -380,6 +389,69 @@ void main() { expect(DerivationEngine.isRicherSleep(night(null), night(27000)), isFalse); expect(DerivationEngine.isRicherSleep(night(null), night(null)), isFalse); }); + + // A band-corrected END (sleep.band_offset_trim_sec) is not "less + // substrate": it may replace a banked longer night with the same onset, + // but only within the tail the band corroborated as awake. + // prev untrimmed 1000→2000 tst 27000; next trimmed to 1700 with trim 300. + test('band END correction replaces the banked longer night', () { + // TST loss 250 s ≤ trim 300 + 60; untrimmed end 1700+300 == banked 2000. + expect(DerivationEngine.isRicherSleep( + night(27000), night(26750, offset: 1700, bandTrim: 300)), isFalse); + }); + test('a small trim cannot authorise a much larger regression', () { + // banked end 2000 is beyond the untrimmed end 1700+10 (+60) = 1770 + expect(DerivationEngine.isRicherSleep( + night(27000), night(20000, offset: 1700, bandTrim: 10)), isTrue); + }); + test('TST loss larger than the removed interval (+60 s) is refused', () { + // removed from the banked window: 2000-1700 = 300; loss 7000 > 360 + expect(DerivationEngine.isRicherSleep( + night(27000), night(20000, offset: 1700, bandTrim: 300)), isTrue); + }); + test('growing morning record: an early short bank yields to the later trim', () { + // 07:19-style bank: end 1760, no trim. Later pass: untrimmed 2500 → 1700 + // (trim 800). 1760 lies inside (1700, 2560]; loss 50 ≤ 60 + 60. + expect(DerivationEngine.isRicherSleep( + night(26800, offset: 1760), night(26750, offset: 1700, bandTrim: 800)), + isFalse); + }); + test('backfilled SLEEP removes the trim: the longer new night wins', () { + // banked trimmed 1000→1700 (trim 300); a gap was backfilled with SLEEP, + // so the next pass ends 1990 untrimmed with more TST → ordinary compare. + expect(DerivationEngine.isRicherSleep( + night(26700, offset: 1700, bandTrim: 300), night(26950, offset: 1990)), + isFalse); + }); + test('different onset (pruned start): no exception either way', () { + expect(DerivationEngine.isRicherSleep( + night(27000), night(9000, onset: 1500, offset: 1700, bandTrim: 300)), isTrue); + }); + // Each 60 s tolerance is inclusive: exactly 60 applies, 61 falls through + // to the ordinary compare (prev TST is larger, so prev wins = isTrue). + test('onset tolerance: 60 s apart applies, 61 s falls through', () { + expect(DerivationEngine.isRicherSleep(night(27000), + night(26750, onset: 1060, offset: 1700, bandTrim: 300)), isFalse); + expect(DerivationEngine.isRicherSleep(night(27000), + night(26750, onset: 1061, offset: 1700, bandTrim: 300)), isTrue); + }); + test('end tolerance: banked end == untrimmed end + 60 applies, 1 s past falls through', () { + // 1700 + 240 + 60 == 2000 (applies); 1700 + 239 + 60 == 1999 < 2000. + expect(DerivationEngine.isRicherSleep(night(27000), + night(26750, offset: 1700, bandTrim: 240)), isFalse); + expect(DerivationEngine.isRicherSleep(night(27000), + night(26750, offset: 1700, bandTrim: 239)), isTrue); + }); + test('TST-loss tolerance: loss == removed + 60 applies, 1 s more falls through', () { + // removed = 2000 - 1700 = 300; allowed loss 360. + expect(DerivationEngine.isRicherSleep(night(27000), + night(26640, offset: 1700, bandTrim: 300)), isFalse); + expect(DerivationEngine.isRicherSleep(night(27000), + night(26639, offset: 1700, bandTrim: 300)), isTrue); + }); + test('shorter WITHOUT trim still loses (pruning guard intact)', () { + expect(DerivationEngine.isRicherSleep(night(27000), night(9000)), isTrue); + }); }); // 4. A re-derive at the retention-cutoff edge (edge#305): daytime HR diff --git a/test/gen5_sample_fields_test.dart b/test/gen5_sample_fields_test.dart index 91b143992..ef3c7a219 100644 --- a/test/gen5_sample_fields_test.dart +++ b/test/gen5_sample_fields_test.dart @@ -213,6 +213,53 @@ void main() { expect((await LocalDb.latestSample())!.hrValid, isFalse); }); + test('derive read path selects band_sleep_state in both call shapes', + () async { + const name = 'openstrap_band_sleep_state_test.db'; + created.add(name); + await _useFreshDb(name); + + const recTs = 1785000000; + for (final c in [9101, 9102]) { + await LocalDb.insertRecord( + _raw(recTs + c - 9101, c), + Sample( + tsEpoch: recTs + c - 9101, + counter: c, + hr: 58, + ax: 0.02, + ay: -0.05, + az: 0.99, + spo2RedRaw: 1, + spo2IrRaw: 2, + skinTempRaw: 3, + ), + ); + } + final db = await LocalDb.instance; + await db.rawUpdate('UPDATE decoded_onehz SET band_sleep_state = 3'); + + final first = await LocalDb.decodedOneHzBatchByRecTsRange( + limit: 10, + fromRecTs: recTs - 1, + toRecTs: recTs + 5, + ); + expect(first, hasLength(2)); + expect(first.first.containsKey('band_sleep_state'), isTrue); + expect(first.first['band_sleep_state'], 3); + + final cont = await LocalDb.decodedOneHzBatchByRecTsRange( + limit: 10, + fromRecTs: recTs - 1, + toRecTs: recTs + 5, + afterRecTs: recTs, + afterCounter: 9101, + ); + expect(cont, hasLength(1)); + expect(cont.single.containsKey('band_sleep_state'), isTrue); + expect(cont.single['band_sleep_state'], 3); + }); + test('a gen4 second stores NULL — never a fabricated 0 — for all of them', () async { const name = 'openstrap_gen4_null_fields_test.db'; diff --git a/test/sleep_band_trim_wiring_test.dart b/test/sleep_band_trim_wiring_test.dart new file mode 100644 index 000000000..ee1ccb45b --- /dev/null +++ b/test/sleep_band_trim_wiring_test.dart @@ -0,0 +1,361 @@ +// Band-state night END (gen5/MG): when the band reports its own LAST SLEEP +// second inside our auto window and stays awake afterwards, analytics ends the +// night there (`SleepSegmentation.bandOffsetTrimSec`). These pin the EDGE +// wiring of that rule end to end: +// (a) the trim reaches the day `calendarDays` builds (AUTO path only); +// (b) the day payload carries `sleep.band_offset_trim_sec` (null when absent); +// (c) which day owns a midnight-crossing night is decided on the UNTRIMMED +// end — the band moves the wake time, never the night's day; +// (d) the trimmed-off lie-in cannot be reclaimed as a nap, through the +// production handoff (`_DayBlocksInput.napExcludeEndSec`). +// +// Fixed local `DateTime(...)` literals, never `DateTime.now()`. + +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:openstrap_edge/compute/derivation_engine.dart'; +import 'package:openstrap_edge/compute/onehz_pipeline.dart'; +import 'package:openstrap_edge/compute/profile.dart'; +import 'package:openstrap_edge/compute/substrate.dart'; +import 'package:openstrap_edge/data/db.dart'; + +// Band envelope codes (analytics band_offset.dart). +const _wake = 0; +const _sleep = 2; +const _up = 3; + +typedef _Sec = ({int hr, bool still, int band}); + +int _local(int y, int mo, int d, [int h = 0, int mi = 0]) => + DateTime(y, mo, d, h, mi).millisecondsSinceEpoch ~/ 1000; + +/// A 1 Hz substrate from [start] for [n] seconds; [at] describes each second. +/// Still = a constant gravity vector (a genuine van Hees immobile block); +/// moving = a 10°/s RAMP (an alternation would be erased by the mask's 5-s +/// rolling median — see substrate_accel_absence_test). [withBand] false gives +/// the identical substrate with every band state ABSENT (-1). +Substrate _build(int start, int n, _Sec Function(int t) at, + {bool withBand = true}) { + final ts = [], hr = [], band = []; + final ax = [], ay = [], az = []; + for (var i = 0; i < n; i++) { + final t = start + i; + final s = at(t); + ts.add(t); + hr.add(s.hr); + band.add(withBand ? s.band : -1); + if (s.still) { + ax.add(0.0); + ay.add(0.0); + az.add(1.0); + } else { + final rad = (i % 9) * 10.0 * math.pi / 180.0; + ax.add(math.cos(rad)); + ay.add(0.0); + az.add(math.sin(rad)); + } + } + return Substrate( + tsSec: ts, + hr: hr, + rrTsMs: const [], + rrMs: const [], + ax: ax, + ay: ay, + az: az, + spo2Red: List.filled(n, 0), + spo2Ir: List.filled(n, 0), + skinTemp: List.filled(n, 0), + skinContact: List.filled(n, 0), + bandSleepState: band, + ); +} + +int _offSec(PhysioDay d) => d.sleep.window!.offsetMs! ~/ 1000; + +// ── (a)/(b) fixture: the `night()` shape of substrate_accel_absence_test ── +// 22:00 → 08:00 local, asleep (still, HR 50) 23:00 → 07:00. The band says +// SLEEP 23:00 → 06:30 and UP from 06:30: the last half hour was a lie-in. +final _nightStart = _local(2025, 6, 15, 22); +final _bandWake = _local(2025, 6, 16, 6, 30); // band last SLEEP + 1 +const _nightLen = 10 * 3600; + +_Sec _nightAt(int t) { + final i = t - _nightStart; + final asleep = i > 3600 && i < 9 * 3600; + final band = i < 3600 ? _wake : (t < _bandWake ? _sleep : _up); + return (hr: asleep ? 50 : 70, still: asleep, band: band); +} + +// ── (d) fixture: post-midnight onset, a lie-in, then a still hour ───────── +// 00:00 awake → 01:00 asleep (band SLEEP) → 07:10 a 20-min walk (band UP, real +// movement so van Hees breaks the bout) → 07:30 still, HR well under 0.95× the +// awake baseline (band UP) → 08:30 activity until 14:00. The nocturnal +// detector bridges the walk (gap < 60 min), so the untrimmed night runs to +// ~08:30; the band ends it at 07:10. +final _lieDay = _local(2025, 6, 16); +final _lieTrimmed = _local(2025, 6, 16, 7, 10); +const _lieLen = 14 * 3600; + +_Sec _lieAt(int t) { + final s = t - _lieDay; + if (s < 3600) return (hr: 78, still: false, band: _wake); + if (s < 7 * 3600 + 600) return (hr: 50, still: true, band: _sleep); + if (s < 7 * 3600 + 1800) return (hr: 90, still: false, band: _up); + if (s < 8 * 3600 + 1800) return (hr: 56, still: true, band: _up); + return (hr: 78, still: false, band: _wake); +} + +void main() { + group('(a) the band trim reaches the day calendarDays builds', () { + test('band-trimmed: the owning day ends at the band\'s last SLEEP', () { + final days = calendarDays(_build(_nightStart, _nightLen, _nightAt)); + final owner = days.singleWhere((d) => d.sleep.present); + expect(owner.sleepSource, 'auto'); + expect(_offSec(owner), _bandWake); + expect(owner.sleep.bandOffsetTrimSec, isNotNull); + expect(owner.sleep.bandOffsetTrimSec, greaterThan(0)); + }); + + test('band absent: the old, later end and no trim', () { + final days = calendarDays( + _build(_nightStart, _nightLen, _nightAt, withBand: false)); + final owner = days.singleWhere((d) => d.sleep.present); + expect(_offSec(owner), greaterThan(_bandWake)); + expect(owner.sleep.bandOffsetTrimSec, isNull); + }); + + test('trimmed + trim == the untrimmed end of the no-band run', () { + final trimmed = calendarDays(_build(_nightStart, _nightLen, _nightAt)) + .singleWhere((d) => d.sleep.present); + final plain = calendarDays( + _build(_nightStart, _nightLen, _nightAt, withBand: false)) + .singleWhere((d) => d.sleep.present); + expect(trimmed.date, plain.date); + expect(_offSec(trimmed) + trimmed.sleep.bandOffsetTrimSec!, + _offSec(plain)); + }); + }); + + group('(b) the day payload carries sleep.band_offset_trim_sec', () { + Map payloadFor(PhysioDay day, Substrate sub) { + final ts = sub.tsSec.sublist(day.sleepLoIdx, day.sleepHiIdx); + final hr = sub.hr.sublist(day.sleepLoIdx, day.sleepHiIdx); + final out = deriveDayBundle( + DayBundleInput( + date: day.date, + dayTsSec: sub.tsSec, + dayHr: sub.hr, + sleepTsSec: ts, + sleepHr: hr, + sleepRrTsMs: const [], + sleepRrMs: const [], + sleepSkinTemp: List.filled(ts.length, 0), + sleepJson: day.sleep.toJson(), + hypnoStages: const [], + sleepOnsetSec: day.sleep.window!.onsetMs! ~/ 1000, + sleepOffsetSec: _offSec(day), + profile: const { + 'age': 30, + 'sex': 'm', + 'weight_kg': 70, + 'height_cm': 175, + }, + deviceFamily: 'gen5', + ).toJson(), + ); + return (out['sleep'] as Map).cast(); + } + + test('trimmed: the key equals the segmentation\'s trim', () { + final sub = _build(_nightStart, _nightLen, _nightAt); + final day = calendarDays(sub).singleWhere((d) => d.sleep.present); + expect(day.sleep.bandOffsetTrimSec, isNotNull, reason: 'precondition'); + final sleep = payloadFor(day, sub); + expect(sleep.containsKey('band_offset_trim_sec'), isTrue); + expect(sleep['band_offset_trim_sec'], day.sleep.bandOffsetTrimSec); + }); + + test('band absent: the key is null', () { + final sub = _build(_nightStart, _nightLen, _nightAt, withBand: false); + final day = calendarDays(sub).singleWhere((d) => d.sleep.present); + expect(payloadFor(day, sub)['band_offset_trim_sec'], isNull); + }); + }); + + group('(c) midnight ownership is unchanged vs the no-band baseline', () { + // Onset 20:30, untrimmed Edge end 00:30. Day D-1 already sees a ~3½ h + // prefix closed at its last sample and passes the 3 h gate, so today the + // night can be owned by BOTH days. That pre-existing double ownership is + // out of scope; the band must simply not change the SET of owners. + final d = _local(2025, 6, 16); + final onset = _local(2025, 6, 15, 20, 30); + final offset = _local(2025, 6, 16, 0, 30); + final start = _local(2025, 6, 15, 18); + const n = 12 * 3600; + + for (final lastSleep in [ + _local(2025, 6, 15, 23, 40), + _local(2025, 6, 15, 23, 55), + ]) { + final label = DateTime.fromMillisecondsSinceEpoch(lastSleep * 1000); + test('band last SLEEP at ${label.hour}:${label.minute}', () { + _Sec at(int t) { + if (t < onset || t >= offset) { + return (hr: 75, still: false, band: _wake); + } + return (hr: 50, still: true, band: t < lastSleep ? _sleep : _up); + } + + Map owners(List days) => { + for (final x in days) + if (x.sleep.present) + x.date: (x.sleep.window!.onsetMs! ~/ 1000, _offSec(x)), + }; + + final baseline = owners(calendarDays(_build(start, n, at, withBand: false))); + final banded = calendarDays(_build(start, n, at)); + expect(baseline, isNotEmpty, reason: 'precondition: a night exists'); + expect(banded.any((x) => x.sleep.bandOffsetTrimSec != null), isTrue, + reason: 'precondition: the band rule actually fired'); + expect(owners(banded).keys.toSet(), baseline.keys.toSet(), + reason: 'baseline windows: $baseline'); + // And the trimmed window on day D still ends at the band's wake. + final dayD = banded.singleWhere((x) => x.startSec == d); + expect(_offSec(dayD), lastSleep); + }); + } + }); + + group('(d) the trimmed-off lie-in is not a nap', () { + // The untrimmed end the edge would have used without the band. + int untrimmedEnd() { + final day = calendarDays(_build(_lieDay, _lieLen, _lieAt)) + .singleWhere((x) => x.sleep.present); + expect(day.sleep.bandOffsetTrimSec, isNotNull, reason: 'precondition'); + expect(_offSec(day), _lieTrimmed, reason: 'precondition'); + return _offSec(day) + day.sleep.bandOffsetTrimSec!; + } + + List>? naps(int excludeEnd) { + final sub = _build(_lieDay, _lieLen, _lieAt); + final onset = calendarDays(sub) + .singleWhere((x) => x.sleep.present) + .sleep + .window! + .onsetMs! ~/ + 1000; + return DerivationEngine.debugAttachNaps( + {}, + {}, + sub, + onset, + _lieTrimmed, + attributionStartSec: _lieDay, + attributionEndSec: _lieDay + 86400, + napExcludeEndSec: excludeEnd, + ); + } + + final napStart = _local(2025, 6, 16, 7, 30); + + test('control: excluding only up to the trimmed wake emits the nap', () { + final out = naps(_lieTrimmed); + expect(out, isNotNull); + expect( + out!.any((n) => + (n['onset_ts'] as int) >= napStart - 120 && + (n['onset_ts'] as int) <= napStart + 300), + isTrue, + reason: 'the fixture must create a nap candidate: $out', + ); + }); + + test('excluding up to the UNTRIMMED end suppresses it', () { + final end = untrimmedEnd(); + expect(end, greaterThan(napStart), reason: 'precondition'); + final out = naps(end); + expect(out, isNotNull); + expect( + out!.where((n) => + (n['onset_ts'] as int) >= _lieTrimmed && + (n['onset_ts'] as int) < end), + isEmpty, + ); + }); + }); + + group('(d) end to end through the derive engine', () { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'sleep_band_trim_wiring_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + tearDownAll(() async { + await LocalDb.close(); + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + test('a band-trimmed day carries no nap inside [trimmed, untrimmed)', + () async { + final db = await LocalDb.instance; + final batch = db.batch(); + for (var i = 0; i < _lieLen; i++) { + final t = _lieDay + i; + final s = _lieAt(t); + final rad = (i % 9) * 10.0 * math.pi / 180.0; + batch.insert('decoded_onehz', { + 'device_id': LocalDb.kPrimaryDeviceId, + 'ts_ms': t * 1000, + 'rec_ts': t, + 'counter': i, + 'hr': s.hr, + 'ax': s.still ? 0.0 : math.cos(rad), + 'ay': 0.0, + 'az': s.still ? 1.0 : math.sin(rad), + 'device_family': 'gen5', + 'band_sleep_state': s.band, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await batch.commit(noResult: true); + + const dayId = '2025-06-16'; + final done = + await DerivationEngine().runDays(const Profile(), {dayId}, force: true); + expect(done, 1, reason: 'the day must actually derive'); + + final row = await LocalDb.dayResult(dayId); + final bundle = jsonDecode(row!['payload_json'] as String) as Map; + final sleep = (bundle['sleep'] as Map).cast(); + final trim = (sleep['band_offset_trim_sec'] as num?)?.toInt(); + expect(trim, isNotNull, reason: 'precondition: the day was band-trimmed'); + final win = ((sleep['window'] as Map)['value'] as Map) + .cast(); + final trimmedEnd = (win['offset_ms'] as num).toInt() ~/ 1000; + expect(trimmedEnd, _lieTrimmed); + final untrimmed = trimmedEnd + trim!; + + final naps = (bundle['naps'] as Map).cast(); + final list = ((naps['value'] as List?) ?? const []) + .cast() + .map((m) => m.cast()); + expect(naps['value'], isNotNull, reason: 'naps were judged: $naps'); + expect( + list.where((n) { + final s = (n['start'] as num).toInt(); + return s >= trimmedEnd && s < untrimmed; + }), + isEmpty, + reason: 'naps: ${naps['value']}', + ); + }); + }); +} diff --git a/test/substrate_band_sleep_state_test.dart b/test/substrate_band_sleep_state_test.dart new file mode 100644 index 000000000..8fa5be8da --- /dev/null +++ b/test/substrate_band_sleep_state_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/derive_prepare.dart'; +import 'package:openstrap_edge/compute/substrate.dart'; + +Map _row(int ts, Object? band) => { + 'counter': ts, 'rec_ts': ts, 'hr': 60, 'ax': 0.1, 'ay': 0.2, 'az': 0.95, + 'device_family': 'gen5', 'device_id': 'd', 'band_sleep_state': band, + }; + +void main() { + test('decoded rows carry the band state; NULL is -1 (absent), never 0', () { + final sub = substrateFromDecodedPage( + [_row(1000, 2), _row(1001, null), _row(1002, 3)], const []); + expect(sub.bandSleepState, [2, -1, 3]); + expect(sub.bandSleepStateSlice(1, 3), [-1, 3]); + }); + + test('a beat-only second (RR, no row) keeps alignment with -1', () { + // rec_ts 1001 has an RR row but no decoded row → still one slot. + final sub = substrateFromDecodedPage([_row(1000, 2), _row(1002, 3)], + [{'rec_ts': 1001, 'rr_ms': 900}]); + expect(sub.tsSec, [1000, 1001, 1002]); + expect(sub.bandSleepState, [2, -1, 3]); + }); + + test('slice() and sliceIdx() both carry it', () { + final sub = substrateFromDecodedPage( + [for (var t = 1000; t < 1010; t++) _row(t, t < 1005 ? 2 : 3)], const []); + expect(sub.sliceIdx(3, 7).bandSleepState, [2, 2, 3, 3]); + expect(sub.slice(1003, 1007).bandSleepState, [2, 2, 3, 3]); + }); + + test('round-trips through JSON (worker isolate boundary)', () { + final sub = substrateFromDecodedPage([_row(1000, 2), _row(1001, 3)], const []); + expect(Substrate.fromJson(sub.toJson()).bandSleepState, [2, 3]); + }); + + test('legacy JSON without the key: all -1, length preserved', () { + final sub = substrateFromDecodedPage([_row(1000, 2), _row(1001, 3)], const []); + final m = sub.toJson()..remove('band_sleep_state'); + expect(Substrate.fromJson(m).bandSleepStateSlice(0, 2), [-1, -1]); + }); +}