Skip to content
Open
76 changes: 71 additions & 5 deletions lib/compute/derivation_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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';
// TEMPORARY @ 9fc1d6a — OpenStrap/analytics PR #80 head (band-state night
// end: bandTrimmedOffsetSec, segmentSleep(bandSleepState:),
// SleepSegmentation.bandOffsetTrimSec) on the author's fork, for v98 above.
// Repin to the OpenStrap/analytics merge SHA before this PR is merged.
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// How a day should be filed after its second half failed and the previous
Expand Down Expand Up @@ -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<NapEdit> 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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -8049,6 +8105,7 @@ class DerivationEngine {
wristOff: inp.wristOffSpans,
charging: inp.chargingSpans,
napEdits: inp.napEdits,
napExcludeEndSec: inp.napExcludeEndSec ?? offset,
);
bundlePatch['sleep_periods'] = _sleepPeriods(
onset,
Expand Down Expand Up @@ -8664,6 +8721,7 @@ class DerivationEngine {
List<List<int>> wristOff = const [],
List<List<int>> charging = const [],
List<NapEdit> napEdits = const [],
int? napExcludeEndSec,
}) =>
_attachNaps(
bundle,
Expand All @@ -8676,6 +8734,7 @@ class DerivationEngine {
wristOff: wristOff,
charging: charging,
napEdits: napEdits,
napExcludeEndSec: napExcludeEndSec,
);

void _log(String m) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions lib/compute/derive_prepare.dart
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ class _PrepareAccumulator {
/// See [Substrate.stepCount] for why the sentinel is not 0.
final List<int> stepCount = [];
final List<int> hrValid = [];
final List<int> bandSleepState = [];

/// The DISTINCT non-null `device_family` stamps seen across every page fed in
/// (see [Substrate.deviceFamily]). Exactly one ⇒ that is the substrate's
Expand Down Expand Up @@ -565,6 +566,9 @@ class _PrepareAccumulator {
hrValid.addAll(sub.hrValid.length == sub.length
? sub.hrValid
: List<int>.filled(sub.length, -1));
bandSleepState.addAll(sub.bandSleepState.length == sub.length
? sub.bandSleepState
: List<int>.filled(sub.length, -1));
}

void addDecodedPage(
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -720,6 +729,7 @@ class _PrepareAccumulator {
skinContact: skinContact,
stepCount: stepCount,
hrValid: hrValid,
bandSleepState: bandSleepState,
deviceFamily: deviceFamily,
deviceIds: _deviceIds,
);
Expand Down
3 changes: 3 additions & 0 deletions lib/compute/onehz_pipeline.dart
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ Map<String, dynamic> deriveDayBundle(Map<String, dynamic> 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) ──────────────────────────────────────────────
Expand Down Expand Up @@ -1010,6 +1011,8 @@ Map<String, dynamic> deriveDayBundle(Map<String, dynamic> 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
? {
Expand Down
37 changes: 36 additions & 1 deletion lib/compute/substrate.dart
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,12 @@ class Substrate {
/// Same absent-marker discipline as [stepCount] and [accelPresentAt].
final List<int> 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<int> bandSleepState;

/// WHICH STRAP MEASURED THIS SUBSTRATE — `'gen4'`, `'gen5'`, or null.
///
/// Stamped at ingest into `decoded_onehz.device_family` and carried here so
Expand Down Expand Up @@ -318,6 +324,7 @@ class Substrate {
required List<int> skinContact,
List<int> stepCount = const [],
List<int> hrValid = const [],
List<int> bandSleepState = const [],
String? deviceFamily,
Set<String> deviceIds = const {},
}) =>
Expand All @@ -337,6 +344,7 @@ class Substrate {
skinContact: skinContact,
stepCount: stepCount,
hrValid: hrValid,
bandSleepState: bandSleepState,
);

const Substrate._({
Expand All @@ -353,6 +361,7 @@ class Substrate {
required this.skinContact,
this.stepCount = const [],
this.hrValid = const [],
this.bandSleepState = const [],
this.deviceFamily,
this.deviceIds = const {},
});
Expand Down Expand Up @@ -476,6 +485,13 @@ class Substrate {
/// [stepCount] sliced to [lo, hi), tolerating the legacy empty list.
List<int> _stepSlice(int lo, int hi) => _perSecSlice(stepCount, lo, hi);

/// Positional band envelope for [lo, hi), -1 where absent (legacy empty
/// list included).
List<int> bandSleepStateSlice(int lo, int hi) =>
bandSleepState.length == length
? bandSleepState.sublist(lo, hi)
: List<int>.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.
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -653,6 +672,11 @@ class Substrate {
final l = ints(m, 'hr_valid');
return l.length == n ? l : List<int>.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<int>.filled(n, -1);
}(),
deviceFamily: m['device_family'] as String?,
deviceIds: ((m['device_ids'] as List?) ?? const [])
.map((e) => e.toString())
Expand Down Expand Up @@ -802,6 +826,8 @@ Substrate decodeSubstrate(List<String> hexes) {
// replay, which carries no device stamp either — so it would refuse at
// `hrValidAt` regardless.
hrValid: List<int>.filled(n, -1),
// Raw-hex replay carries no band envelope either: ABSENT.
bandSleepState: List<int>.filled(n, -1),
);
}

Expand Down Expand Up @@ -1172,6 +1198,10 @@ List<PhysioDay> 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
Expand Down Expand Up @@ -1207,10 +1237,15 @@ List<PhysioDay> 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;
Expand Down
4 changes: 2 additions & 2 deletions lib/data/db.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?',
Expand All @@ -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 > ?)) '
Expand Down
5 changes: 3 additions & 2 deletions lib/data/models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
6 changes: 3 additions & 3 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -937,9 +937,9 @@ packages:
dependency: "direct main"
description:
path: "."
ref: "0441ef9e6fc6d5681c309ce6341911285e829f20"
resolved-ref: "0441ef9e6fc6d5681c309ce6341911285e829f20"
url: "https://github.com/OpenStrap/analytics.git"
ref: "9fc1d6a9b13240ee668e74940e03cb5646113a5c"
resolved-ref: "9fc1d6a9b13240ee668e74940e03cb5646113a5c"
url: "https://github.com/DropTabl/analytics.git"
source: git
version: "1.0.0"
openstrap_protocol:
Expand Down
Loading