From a52fc3c2ccc3326a88ec35aa1c7d886b9ae8ecc4 Mon Sep 17 00:00:00 2001 From: therealslurp <118546910+therealslurp@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:22:51 -0500 Subject: [PATCH 1/8] fix(sleep-coach): read past nights on the clock they were lived on After a 7 h westward flight the Sleep tab printed "Target bedtime 4:00 PM / Target wake 1:31 AM". The cross-day rollup took the typical wake as the median of past wake INSTANTS converted with the phone's CURRENT zone (_localTodMin), so 08:31 wakes recorded at UTC+2 re-read as 01:31 and the bedtime backed off them landed mid-afternoon. Chronotype and social jetlag read mid-sleep the same way. The targets are now a local-clock schedule carried into the new zone. Each night's own clock is recovered from the SLP-09 sleep_onset_sec scalar (the onset's local clock, frozen at derive time): the record builder compares it with the same instant read in today's zone and writes the difference as clock_shift_sec, only when non-zero, so nights recorded in the current zone read exactly as before and the WHOOP goldens are unchanged. The crossday_input artifact is stamped with the zone it was built in and rebuilt on a mismatch, since its records are zone-relative. No kAlgoVersion bump: no per-day output changes, and the cross-day bundle is rebuilt on every pass. --- lib/compute/crossday_pipeline.dart | 82 +++++++++- lib/compute/derivation_engine.dart | 32 +++- lib/compute/onehz_pipeline.dart | 9 +- test/sleep_coach_timezone_test.dart | 237 ++++++++++++++++++++++++++++ 4 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 test/sleep_coach_timezone_test.dart diff --git a/lib/compute/crossday_pipeline.dart b/lib/compute/crossday_pipeline.dart index 5a8481eb9..b9d2372c6 100644 --- a/lib/compute/crossday_pipeline.dart +++ b/lib/compute/crossday_pipeline.dart @@ -24,7 +24,7 @@ import '../data/day_label.dart'; // IO, no Flutter binding, so importing it does not compromise this file's // isolate safety. One grammar for "a required input was missing" across both // pipelines rather than a second one here. -import 'onehz_pipeline.dart' show needInputNote; +import 'onehz_pipeline.dart' show kSleepClockAnchorSec, needInputNote; /// Build the cross-day analytics bundle from a time-ordered (OLDEST FIRST) list /// of per-day records and the user profile. @@ -33,6 +33,8 @@ import 'onehz_pipeline.dart' show needInputNote; /// {'date': 'YYYY-MM-DD', 'rhr': num?, 'rmssd': num?, 'readiness': num?, /// 'resp_rate': num?, 'skin_temp_z': num?, 'trimp': num?, /// 'onset_sec': int?, 'wake_sec': int?, 'tst_min': int?, +/// 'clock_shift_sec': int? (present only for a night recorded in another +/// zone than the phone's current one — see [recordedClockShiftSec]), /// 'hypnogram': [{start,end,stage}]?} /// /// Returns a JSON-safe map of the latest/aggregate cross-day results. Every @@ -152,7 +154,10 @@ Map buildCrossDayBundle( // LOCAL clock-hours [0,24). onset/wake are epoch SECONDS (UTC); `% 86400` // would give the UTC time-of-day, not the user's wall clock — convert via a // local DateTime so a UTC+5:30 user's 08:00 wake reads as 08:00, not 02:30. - final midH = _localTodMin(midSec.round()) / 60.0; + // …and on the clock the night was LIVED on, not the phone's current one: + // after a flight every past night would otherwise move by the zone change + // and chronotype/social jetlag would read the trip as a schedule shift. + final midH = _nightClockMin(midSec.round(), d) / 60.0; final free = _isFreeDay(d['date'] as String?); if (free) { freeMidH.add(midH); @@ -403,9 +408,24 @@ Map buildCrossDayBundle( note: need.present ? needInputNote('tst_min') : need.note, ); // typical wake clock-minute + efficiency from recent days (medians). + // + // The wake is read on the clock of the zone each night was LIVED in, not the + // phone's current zone. Converting the absolute instants with today's zone + // is what printed "Target bedtime 4:00 PM / Target wake 1:31 AM" to a user + // who had flown 7 h west: 08:31 wakes recorded at UTC+2 re-read as 01:31 + // in the new zone, and the bedtime backed off them landed mid-afternoon. + // + // DESIGN: the targets are a LOCAL CLOCK schedule (wake ~07:45, bed ~23:30), + // carried into whatever zone the phone is in now — the social schedule moves + // with the traveller, the absolute instants do not. Nothing here models the + // body clock catching up across the trip; the median over up to 90 nights + // absorbs the first jet-lagged nights in the new zone, and as nights are + // lived there it simply keeps reading their local clock too. See + // [recordedClockShiftSec] for how a stored night's own clock is recovered. final wakeMins = [ for (final d in days) - if (d['wake_sec'] != null) _localTodMin((d['wake_sec'] as num).toInt()), + if (d['wake_sec'] != null) + _nightClockMin((d['wake_sec'] as num).toInt(), d), ]; final effs = [for (final d in days) ?_numOrNull(d['efficiency'])]; final typicalWakeMin = _median(wakeMins); @@ -658,6 +678,62 @@ double _localTodMin(int epochSec) { return t.hour * 60.0 + t.minute + t.second / 60.0; } +/// Local minute-of-day [0,1440) of [epochSec] on the clock night [d] was LIVED +/// on: [_localTodMin] moved by the record's `clock_shift_sec` (see +/// [recordedClockShiftSec]). A record without one was recorded in the phone's +/// current zone, or we cannot tell — and this IS [_localTodMin], to the second. +double _nightClockMin(int epochSec, Map d) { + final shift = d['clock_shift_sec']; + final shiftMin = shift is num && shift.isFinite ? shift / 60.0 : 0.0; + return (_localTodMin(epochSec) + shiftMin) % 1440.0; +} + +/// How far a stored night's OWN local clock sits from the phone's CURRENT +/// zone, in seconds [0,86400) — 0 when it was recorded in this zone, or when +/// we cannot tell. Called by the record builder +/// (`DerivationEngine.crossDayInputRecord`), which writes it as +/// `clock_shift_sec` only when it is non-zero. +/// +/// WHY THIS EXISTS. `onset_sec`/`wake_sec` are absolute instants. The only way +/// to put one on a wall clock is a zone, and `DateTime` only knows the phone's +/// CURRENT zone: after a trip it converts every past night with the new zone's +/// rules (DST-correct for the new zone, but the user was never there). The +/// zone they were in is not stored on the day row — but the per-day pipeline +/// wrote `sleep_onset_sec` (onehz_pipeline's `sleepClockOffsetSec`): the +/// onset's LOCAL clock position, frozen at derive time in the zone the phone +/// was in then ([onsetClockSec], signed seconds either side of +/// [kSleepClockAnchorSec]). Comparing that frozen clock with the same instant +/// re-read in today's zone yields the frame shift, which then applies to every +/// other instant of that night (wake, mid-sleep). +/// +/// SNAPPED TO 15 MIN, because every real UTC offset is a multiple of 15 min +/// and the two clocks are the same instant read twice — any residue is +/// rounding, never a zone. +/// +/// LIMITS, stated rather than hidden: +/// - A night without the frozen clock (derived before SLP-09 started writing +/// it, mid-August 2026) reads in the current zone, as it always did. Those +/// age out of the 90-night window. +/// - A night (re)derived AFTER the trip — "Re-analyze data", today's still +/// settling row — froze the new zone's clock and reads in it. At most +/// `rawRetentionDays` of them; the medians ride over them. +/// - A DST change inside the night itself is applied with the CURRENT zone's +/// rules at the wake instant: off by at most an hour, twice a year, for a +/// traveller only (the shift is 0 at home). +int recordedClockShiftSec({ + required num? onsetSec, + required num? onsetClockSec, +}) { + if (onsetSec == null || onsetClockSec == null || !onsetClockSec.isFinite) { + return 0; + } + final recordedSecOfDay = + (onsetClockSec.round() + kSleepClockAnchorSec) % 86400; + final nowFrameSecOfDay = (_localTodMin(onsetSec.toInt()) * 60).round(); + final raw = (recordedSecOfDay - nowFrameSecOfDay) % 86400; + return ((raw / 900).round() * 900) % 86400; +} + double? _mean(List xs) { if (xs.isEmpty) return null; var s = 0.0; diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index fba1cc9e6..813d063b9 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -6582,6 +6582,18 @@ class DerivationEngine { return out; } + /// The phone zone a `crossday_input` artifact was built in. Its records are + /// zone-RELATIVE (`clock_shift_sec` is "this night's clock minus the zone the + /// record was built in"), so an artifact built before a flight, served after + /// it on the same day, would re-read every past night on the new clock — the + /// "Target bedtime 4:00 PM" bug again, until the next rebuild. A mismatch + /// (or an artifact without the stamp) rebuilds. Name AND offset: a DST change + /// alone rebuilds too, which is one cheap rebuild twice a year. + static String _crossDayZoneStamp() { + final now = DateTime.now(); + return '${now.timeZoneName}|${now.timeZoneOffset.inMinutes}'; + } + Future>> _crossDayInputDays() async { final reviewRevision = await LocalDb.activityReviewRevision(); final artifact = await LocalDb.baseline('crossday_input'); @@ -6594,7 +6606,8 @@ class DerivationEngine { // day makes `_todayNum` read yesterday's strain and nap minutes as // today's (§3.3). See [crossDayArtifactUsableToday]. if (crossDayArtifactUsableToday(decoded, LocalDb.localDayLabelNow()) && - (decoded as Map)['review_revision'] == reviewRevision) { + (decoded as Map)['review_revision'] == reviewRevision && + decoded['zone'] == _crossDayZoneStamp()) { final rows = decoded['days'] as List; return [ for (final row in rows) @@ -6620,6 +6633,7 @@ class DerivationEngine { final rows = await LocalDb.recentDayResults(_crossDayWindow); final today = LocalDb.localDayLabelNow(); final imported = await LocalDb.importedDates(); + final zone = _crossDayZoneStamp(); final (days, json) = await _runIsolateCancellable(() { final days = >[]; for (final row in rows.reversed) { @@ -6643,6 +6657,7 @@ class DerivationEngine { 'algo_version': kAlgoVersion, 'built_for_day': today, 'review_revision': reviewRevision, + 'zone': zone, 'days': days, }) ); @@ -6843,6 +6858,21 @@ class DerivationEngine { 'efficiency': sc('efficiency'), 'onset_sec': onsetMs == null ? null : (onsetMs / 1000).round(), 'wake_sec': offsetMs == null ? null : (offsetMs / 1000).round(), + // Which clock the night was LIVED on, relative to the phone's zone now. + // `onset_sec`/`wake_sec` are absolute instants with no zone attached; + // SLP-09's `sleep_onset_sec` scalar froze the onset's LOCAL clock in the + // zone the phone was in when the day was derived, and the gap between + // the two readings is the zone change. Without it a flight slid every + // past wake by the shift and the Sleep Coach printed "Target bedtime + // 4:00 PM / Target wake 1:31 AM" after 7 h west. + // + // Only the shift is written: a night recorded in this zone (the shift + // is 0) carries no new key and reads exactly as before. + if (recordedClockShiftSec( + onsetSec: onsetMs == null ? null : (onsetMs / 1000).round(), + onsetClockSec: sc('sleep_onset_sec'), + ) case final shift when shift != 0) + 'clock_shift_sec': shift, 'tst_min': tstSec == null ? null : (tstSec / 60).round(), // Fraction of the night we actually watched — wall-clock in-bed minus the // seconds nobody observed, over in-bed. TS-11 drops a morning whose night diff --git a/lib/compute/onehz_pipeline.dart b/lib/compute/onehz_pipeline.dart index d2f2880e2..dc749eed9 100644 --- a/lib/compute/onehz_pipeline.dart +++ b/lib/compute/onehz_pipeline.dart @@ -1862,7 +1862,12 @@ Map deriveDayBundle(Map inputJson) { /// /// Any fixed hour would do; 04:00 is the one furthest from a normal bedtime /// AND from a normal wake time, so neither series sits near the wrap. -const int _sleepClockAnchorSec = 4 * 3600; +/// +/// Public because the cross-day rollup reads `sleep_onset_sec` back to learn +/// which clock a stored night was LIVED on (crossday_pipeline's +/// `recordedClockShiftSec`); decoding it with a second copy of this number +/// is the drift that would quietly put every night an anchor-change off. +const int kSleepClockAnchorSec = 4 * 3600; /// A sleep instant's LOCAL clock position, in seconds either side of 04:00 — /// negative before, positive after. Null for an absent window (never 0, which @@ -1883,7 +1888,7 @@ double? sleepClockOffsetSec(int epochSec) { if (epochSec <= 0) return null; final local = epochSec + tzOffsetSecondsAt(epochSec); final secOfDay = ((local % 86400) + 86400) % 86400; - var offset = secOfDay - _sleepClockAnchorSec; + var offset = secOfDay - kSleepClockAnchorSec; if (offset > 43200) offset -= 86400; if (offset <= -43200) offset += 86400; return offset.toDouble(); diff --git a/test/sleep_coach_timezone_test.dart b/test/sleep_coach_timezone_test.dart new file mode 100644 index 000000000..41faef00c --- /dev/null +++ b/test/sleep_coach_timezone_test.dart @@ -0,0 +1,237 @@ +// SLEEP COACH ACROSS A TIME-ZONE CHANGE. +// +// A user flew 7 h west and, at 08:33 local, the Sleep tab read "Target bedtime +// 4:00 PM / Target wake 1:31 AM". The cross-day rollup took the typical wake +// as the median of every past wake INSTANT converted with the phone's CURRENT +// zone, so 08:31 wakes recorded at UTC+2 re-read as 01:31 and the bedtime +// backed off them landed mid-afternoon. +// +// The fix keeps the targets a LOCAL CLOCK schedule: each night is read on the +// clock it was lived on (recovered from the per-day `sleep_onset_sec` scalar, +// which froze the onset's local clock at derive time) and that schedule is +// applied in whatever zone the phone is in now. +// +// These tests build the rows by hand with the history's zone written out +// explicitly (UTC+2, or Europe's CEST→CET), so they assert the same answer in +// ANY runner zone. Under the old code they fail in every zone except the one +// the history was recorded in. Worth running under a few zones, e.g. +// TZ=Etc/GMT+5 flutter test test/sleep_coach_timezone_test.dart (UTC−5) +// TZ=America/New_York flutter test test/sleep_coach_timezone_test.dart +// TZ=Europe/Amsterdam flutter test test/sleep_coach_timezone_test.dart + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/crossday_pipeline.dart'; +import 'package:openstrap_edge/compute/derivation_engine.dart'; +import 'package:openstrap_edge/compute/onehz_pipeline.dart' + show kSleepClockAnchorSec, sleepClockOffsetSec; + +/// Epoch seconds for wall-clock [h]:[m] on [y]-[mo]-[d] in a zone at +/// [offsetMin] from UTC. +int _at(int y, int mo, int d, int h, int m, int offsetMin) => + DateTime.utc(y, mo, d, h, m).millisecondsSinceEpoch ~/ 1000 - + offsetMin * 60; + +/// What `sleepClockOffsetSec` would have written for [localH]:[localM] — +/// signed seconds either side of 04:00 — had the phone been in that zone. +double _clockAsRecorded(int localH, int localM) { + var off = localH * 3600 + localM * 60 - kSleepClockAnchorSec; + if (off > 43200) off -= 86400; + if (off <= -43200) off += 86400; + return off.toDouble(); +} + +String _label(DateTime d) => + '${d.year}-${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; + +/// One stored day as `day_result` holds it: the sleep window as absolute +/// instants, plus the onset's frozen local clock in `scalars`. +({Map row, Map payload}) _stored({ + required String day, + required int onsetSec, + required int wakeSec, + required double? onsetClockSec, +}) => + ( + row: {'day_id': day, 'finalized': 1}, + payload: { + 'scalars': { + 'efficiency': 95.0, + 'sleep_onset_sec': ?onsetClockSec, + }, + 'sleep': { + 'window': { + 'value': {'onset_ms': onsetSec * 1000, 'offset_ms': wakeSec * 1000}, + }, + 'accounting': { + 'value': {'tst_sec': (wakeSec - onsetSec) * 0.95}, + }, + }, + }, + ); + +/// Three weeks of nights lived at UTC+2: in bed 23:30, up 07:45 (one night +/// falls asleep after midnight, 00:20, so the day boundary is crossed). +List<({Map row, Map payload})> _europe() { + const tz = 120; + final out = <({Map row, Map payload})>[]; + for (var i = 0; i < 21; i++) { + // The night of Sep i+1 → the morning of Sep i+2 (labelled by the morning). + final late = i == 10; + final onset = late + ? _at(2026, 9, i + 2, 0, 20, tz) + : _at(2026, 9, i + 1, 23, 30, tz); + final wake = _at(2026, 9, i + 2, 7, 45, tz); + out.add(_stored( + day: _label(DateTime(2026, 9, i + 2)), + onsetSec: onset, + wakeSec: wake, + onsetClockSec: late ? _clockAsRecorded(0, 20) : _clockAsRecorded(23, 30), + )); + } + return out; +} + +List> _records( + List<({Map row, Map payload})> stored, +) => [ + for (final s in stored) + DerivationEngine.crossDayInputRecord(s.row, s.payload, + today: '2026-12-31', imported: const {})!, + ]; + +Map _coach(List> days) => + (buildCrossDayBundle(days, const {})['sleep_coach'] as Map) + .cast(); + +double _min(Map coach, String key, String field) => + (((coach[key] as Map)['value'] as Map)[field] as num).toDouble(); + +/// Circular distance in minutes on a 24 h clock. +double _clockDist(double a, double b) { + final d = (a - b).abs() % 1440; + return d > 720 ? 1440 - d : d; +} + +void main() { + group('history recorded at UTC+2, phone now elsewhere', () { + test('targets read ~23:30 / 07:45 local, not 16:00 / 01:31', () { + final days = _records(_europe()); + final coach = _coach(days); + final wake = _min(coach, 'wake', 'wake_min_of_day'); + final bed = _min(coach, 'bedtime', 'bedtime_min_of_day'); + final needSec = _min(coach, 'need', 'need_sec'); + + // Target wake = the bedtime plus the in-bed time it was backed off, so it + // lands back on the typical wake: 07:45 on the clock the user lives by. + expect(_clockDist(wake, 7 * 60 + 45), lessThan(1.0), + reason: 'target wake $wake min'); + // Bedtime is that wake minus need ÷ efficiency, on the same clock. + final expectedBed = (7 * 60 + 45 - needSec / 0.95 / 60) % 1440; + expect(_clockDist(bed, expectedBed), lessThan(1.0)); + // …which for this history is the late-evening bedtime it kept, not a + // mid-afternoon one. + expect(_clockDist(bed, 23 * 60 + 30), lessThan(45.0), + reason: 'target bedtime $bed min'); + expect(_clockDist(bed, 16 * 60), greaterThan(300.0)); + expect(_clockDist(wake, 60 + 31), greaterThan(300.0)); + }); + + test('the record carries the shift only when the zone actually differs', + () { + final days = _records(_europe()); + final onset = days.first['onset_sec'] as int; + final hereOffsetMin = + DateTime.fromMillisecondsSinceEpoch(onset * 1000).timeZoneOffset + .inMinutes; + if (hereOffsetMin == 120) { + // Runner is in the history's own zone: nothing written, old path. + expect(days.first.containsKey('clock_shift_sec'), isFalse); + } else { + expect(days.first['clock_shift_sec'], + ((120 - hereOffsetMin) * 60) % 86400); + } + }); + + test('a few nights already lived in the new zone do not move the target', + () { + // The first nights after landing are derived in the phone's new zone and + // carry no shift; read on their own clock they are the same schedule. + final stored = _europe(); + for (var i = 0; i < 3; i++) { + final dLocal = DateTime(2026, 9, 23 + i, 23, 30); + final onset = dLocal.millisecondsSinceEpoch ~/ 1000; + final wake = + DateTime(2026, 9, 24 + i, 7, 45).millisecondsSinceEpoch ~/ 1000; + stored.add(_stored( + day: _label(DateTime(2026, 9, 24 + i)), + onsetSec: onset, + wakeSec: wake, + onsetClockSec: sleepClockOffsetSec(onset), + )); + } + final coach = _coach(_records(stored)); + expect(_clockDist(_min(coach, 'wake', 'wake_min_of_day'), 7 * 60 + 45), + lessThan(1.0)); + }); + }); + + test('history straddling a DST change keeps its local schedule', () { + // Europe leaves CEST (UTC+2) for CET (UTC+1) on 25 Oct 2026. The user went + // to bed at 23:30 and woke at 07:45 on BOTH sides; the instants moved by an + // hour, the clock did not, and neither should the target. + final stored = <({Map row, Map payload})>[]; + for (var i = 0; i < 20; i++) { + final morning = DateTime(2026, 10, 15 + i); + final tz = morning.isBefore(DateTime(2026, 10, 25, 12)) ? 120 : 60; + final night = DateTime(2026, 10, 14 + i); + stored.add(_stored( + day: _label(morning), + onsetSec: _at(night.year, night.month, night.day, 23, 30, tz), + wakeSec: _at(morning.year, morning.month, morning.day, 7, 45, tz), + onsetClockSec: _clockAsRecorded(23, 30), + )); + } + final days = _records(stored); + final coach = _coach(days); + expect(_clockDist(_min(coach, 'wake', 'wake_min_of_day'), 7 * 60 + 45), + lessThan(1.0)); + }); + + test('a night recorded in the current zone reads exactly as before', () { + // At home the frozen clock and today's reading agree, so no key is written + // and the bundle is identical to one built without the scalar at all. + final withClock = + <({Map row, Map payload})>[]; + final without = + <({Map row, Map payload})>[]; + for (var i = 0; i < 21; i++) { + final onset = + DateTime(2026, 9, i + 1, 23, 10).millisecondsSinceEpoch ~/ 1000; + final wake = + DateTime(2026, 9, i + 2, 7, 5).millisecondsSinceEpoch ~/ 1000; + final day = _label(DateTime(2026, 9, i + 2)); + withClock.add(_stored( + day: day, + onsetSec: onset, + wakeSec: wake, + onsetClockSec: sleepClockOffsetSec(onset))); + without.add( + _stored(day: day, onsetSec: onset, wakeSec: wake, onsetClockSec: null)); + } + final a = _records(withClock); + expect(a.any((d) => d.containsKey('clock_shift_sec')), isFalse); + expect(_coach(a).toString(), _coach(_records(without)).toString()); + expect(_clockDist(_min(_coach(a), 'wake', 'wake_min_of_day'), 7 * 60 + 5), + lessThan(1.0)); + }); + + test('no frozen clock and no onset → no shift, never a guessed one', () { + expect(recordedClockShiftSec(onsetSec: null, onsetClockSec: -16200), 0); + expect(recordedClockShiftSec(onsetSec: 1789000000, onsetClockSec: null), 0); + expect( + recordedClockShiftSec( + onsetSec: 1789000000, onsetClockSec: double.nan), + 0); + }); +} From 735bc86e3d7d6d2b76399979dc7c30bcfefbd34e Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Thu, 29 Oct 2026 01:08:52 +0530 Subject: [PATCH 2/8] keep a night's lived clock across re-derives, bump to v117 re-deriving a night lived before a trip froze sleep_onset_sec in the new zone, losing the only record of where it was lived, so the sleep coach read it on the new clock again. a re-derive now carries the stored row's shift onto sleep_onset_sec and midsleep_sec. the rollup's output moves for anyone with nights from another zone, so kAlgoVersion goes to 117 and today's bundle built by the old code is not served. the freeze goldens change only in their version stamp. --- lib/compute/crossday_pipeline.dart | 33 +++++++++++++++-- lib/compute/derivation_engine.dart | 26 +++++++++++++- .../fixtures/whoop_freeze/gen4_minus0400.json | 18 +++++----- test/fixtures/whoop_freeze/gen4_plus0530.json | 18 +++++----- test/fixtures/whoop_freeze/gen4_utc.json | 18 +++++----- .../fixtures/whoop_freeze/gen5_minus0400.json | 18 +++++----- test/fixtures/whoop_freeze/gen5_plus0530.json | 18 +++++----- test/fixtures/whoop_freeze/gen5_utc.json | 18 +++++----- test/sleep_coach_timezone_test.dart | 35 +++++++++++++++++++ 9 files changed, 144 insertions(+), 58 deletions(-) diff --git a/lib/compute/crossday_pipeline.dart b/lib/compute/crossday_pipeline.dart index b9d2372c6..1c31034b9 100644 --- a/lib/compute/crossday_pipeline.dart +++ b/lib/compute/crossday_pipeline.dart @@ -714,9 +714,10 @@ double _nightClockMin(int epochSec, Map d) { /// - A night without the frozen clock (derived before SLP-09 started writing /// it, mid-August 2026) reads in the current zone, as it always did. Those /// age out of the 90-night window. -/// - A night (re)derived AFTER the trip — "Re-analyze data", today's still -/// settling row — froze the new zone's clock and reads in it. At most -/// `rawRetentionDays` of them; the medians ride over them. +/// - A night first derived AFTER the trip (synced only once there) froze +/// the new zone's clock and reads in it. A RE-derive keeps the clock the +/// stored row already had ([storedNightClockShiftSec]), so "Re-analyze +/// data" or a version bump does not move a pre-trip night. /// - A DST change inside the night itself is applied with the CURRENT zone's /// rules at the wake instant: off by at most an hour, twice a year, for a /// traveller only (the shift is 0 at home). @@ -734,6 +735,32 @@ int recordedClockShiftSec({ return ((raw / 900).round() * 900) % 86400; } +/// [recordedClockShiftSec] of a stored `day_result` payload: its sleep onset +/// instant against the `sleep_onset_sec` clock it froze. 0 when either is +/// missing. +int storedNightClockShiftSec(Map? payload) { + final sleep = payload?['sleep']; + final win = sleep is Map ? sleep['window'] : null; + final val = win is Map ? win['value'] : null; + final onsetMs = val is Map ? val['onset_ms'] : null; + final scalars = payload?['scalars']; + final clock = scalars is Map ? scalars['sleep_onset_sec'] : null; + return recordedClockShiftSec( + onsetSec: onsetMs is num ? (onsetMs / 1000).round() : null, + onsetClockSec: clock is num ? clock : null, + ); +} + +/// A frozen sleep clock (signed seconds either side of +/// [kSleepClockAnchorSec], as `sleepClockOffsetSec` writes it) moved by +/// [shiftSec] and wrapped back into (-12 h, +12 h]. Null stays null. +double? shiftRecordedClockSec(num? clockSec, int shiftSec) { + if (clockSec == null || !clockSec.isFinite) return null; + var v = (clockSec + shiftSec) % 86400; + if (v > 43200) v -= 86400; + return v.toDouble(); +} + double? _mean(List xs) { if (xs.isEmpty) return null; var s = 0.0; diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 813d063b9..ffc34cd60 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -1810,7 +1810,13 @@ import 'vendor_sleep.dart'; // store `skin_temp_ambient_r` and the illness watch leaves out WHOOP 4 nights // whose skin temp tracks ambient (|r| >= 0.8, or unmeasured). One-shot, // raw-free: days derived before v106 drop naps overlapping the next night. -const int kAlgoVersion = 113; +// 113 → 117 (#624): the cross-day rollup reads each night's onset/wake/mid-sleep +// on the clock it was lived on (`clock_shift_sec`, recovered from the stored +// `sleep_onset_sec`), so after a zone change the Sleep Coach targets, typical +// wake, chronotype and social jetlag no longer move by the zone difference. A +// re-derive keeps the stored night's clock. Unchanged for a user who has not +// changed zone. Edge-only. +const int kAlgoVersion = 117; /// The sibling SHAs this version was derived against, asserted against /// pubspec.yaml in test/db_serve_version_and_reads_test.dart. /// @@ -5831,6 +5837,24 @@ class DerivationEngine { } } + // RE-DERIVING KEEPS THE NIGHT'S OWN CLOCK. The pipeline freezes + // `sleep_onset_sec`/`midsleep_sec` with the phone's zone NOW, so re-deriving + // a night lived before a trip (re-analyze, a rescan, a version bump) would + // overwrite the only record of the zone it was lived in, and the cross-day + // rollup would read it on the new clock. Carry the stored row's shift. + final prevForClock = await LocalDb.dayResult(day.date); + if (_isRealDayResult(prevForClock)) { + final shift = storedNightClockShiftSec( + _decodeBundle(prevForClock!['payload_json'])); + final s = bundle['scalars']; + if (shift != 0 && s is Map) { + for (final k in const ['sleep_onset_sec', 'midsleep_sec']) { + final v = shiftRecordedClockSec(s[k] as num?, shift); + if (v != null) s[k] = v; + } + } + } + final scalars = (bundle['scalars'] as Map?)?.cast() ?? const {}; double? sc(String k) => (scalars[k] as num?)?.toDouble(); diff --git a/test/fixtures/whoop_freeze/gen4_minus0400.json b/test/fixtures/whoop_freeze/gen4_minus0400.json index 82c4b8122..c78e9d46f 100644 --- a/test/fixtures/whoop_freeze/gen4_minus0400.json +++ b/test/fixtures/whoop_freeze/gen4_minus0400.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 117, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 117, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 117, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 117, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 117, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 117, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen4_plus0530.json b/test/fixtures/whoop_freeze/gen4_plus0530.json index 071bd2e28..d8c0240f4 100644 --- a/test/fixtures/whoop_freeze/gen4_plus0530.json +++ b/test/fixtures/whoop_freeze/gen4_plus0530.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 117, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 117, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 117, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 117, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 117, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 117, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen4_utc.json b/test/fixtures/whoop_freeze/gen4_utc.json index 985e1bd8f..3c80d40f7 100644 --- a/test/fixtures/whoop_freeze/gen4_utc.json +++ b/test/fixtures/whoop_freeze/gen4_utc.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 117, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 117, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 117, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 117, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 117, "payload_json": { "day_id": "2026-07-14", "active_min": 239, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 117, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen5_minus0400.json b/test/fixtures/whoop_freeze/gen5_minus0400.json index 6c4a2008d..b1d842e15 100644 --- a/test/fixtures/whoop_freeze/gen5_minus0400.json +++ b/test/fixtures/whoop_freeze/gen5_minus0400.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 117, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 117, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 117, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 117, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 117, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 117, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen5_plus0530.json b/test/fixtures/whoop_freeze/gen5_plus0530.json index 3109bf0c1..b6f6fd61e 100644 --- a/test/fixtures/whoop_freeze/gen5_plus0530.json +++ b/test/fixtures/whoop_freeze/gen5_plus0530.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 117, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 117, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 117, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 117, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 117, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 117, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen5_utc.json b/test/fixtures/whoop_freeze/gen5_utc.json index 781e9ee03..5ef56c1b0 100644 --- a/test/fixtures/whoop_freeze/gen5_utc.json +++ b/test/fixtures/whoop_freeze/gen5_utc.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 117, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 117, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 117, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 113, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 113, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 113, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 113, + "algo_version": 117, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 113, + "algo_version": 117, "payload_json": { "day_id": "2026-07-14", "active_min": 239, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 113, + "algo_version": 117, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/sleep_coach_timezone_test.dart b/test/sleep_coach_timezone_test.dart index 41faef00c..7eeb2e63b 100644 --- a/test/sleep_coach_timezone_test.dart +++ b/test/sleep_coach_timezone_test.dart @@ -226,6 +226,41 @@ void main() { lessThan(1.0)); }); + test('re-deriving a pre-trip night keeps the clock it was lived on', () { + // A re-derive freezes `sleep_onset_sec` in the phone's zone NOW; carrying + // the stored row's shift puts it back on the UTC+2 clock, so the cross-day + // record is the same as before the re-derive, in any runner zone. + for (final prev in _europe()) { + final onsetMs = (((prev.payload['sleep'] as Map)['window'] + as Map)['value'] as Map)['onset_ms'] as int; + final onset = onsetMs ~/ 1000; + final original = + (prev.payload['scalars'] as Map)['sleep_onset_sec'] as double; + final rederived = sleepClockOffsetSec(onset); + final carried = shiftRecordedClockSec( + rederived, storedNightClockShiftSec(prev.payload)); + expect(carried, original); + + final again = _stored( + day: prev.row['day_id'] as String, + onsetSec: onset, + wakeSec: onset + 8 * 3600, + onsetClockSec: carried, + ); + expect(_records([again]).single['clock_shift_sec'], + _records([prev]).single['clock_shift_sec']); + } + }); + + test('a shifted clock wraps back into ±12 h and null stays null', () { + expect(shiftRecordedClockSec(43000, 1000), -42400); + expect(shiftRecordedClockSec(-43000, 86400 - 1000), 42400); + expect(shiftRecordedClockSec(-16200, 0), -16200); + expect(shiftRecordedClockSec(null, 7200), isNull); + expect(storedNightClockShiftSec(null), 0); + expect(storedNightClockShiftSec(const {'scalars': {}}), 0); + }); + test('no frozen clock and no onset → no shift, never a guessed one', () { expect(recordedClockShiftSec(onsetSec: null, onsetClockSec: -16200), 0); expect(recordedClockShiftSec(onsetSec: 1789000000, onsetClockSec: null), 0); From 700f374a7009fc404698d56d543e123388d743e3 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 15:17:15 +0530 Subject: [PATCH 3/8] read the stored night's clock without decoding its bundle every derived day read the previous row and decoded the whole payload on the main isolate to recover two numbers. they come out of SQLite with json_extract now. --- lib/compute/derivation_engine.dart | 16 +++-- lib/data/db.dart | 36 ++++++++++ test/stored_night_clock_test.dart | 108 +++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 test/stored_night_clock_test.dart diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 2e696a64f..b22c96ad3 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -6097,10 +6097,18 @@ class DerivationEngine { // a night lived before a trip (re-analyze, a rescan, a version bump) would // overwrite the only record of the zone it was lived in, and the cross-day // rollup would read it on the new clock. Carry the stored row's shift. - final prevForClock = await LocalDb.dayResult(day.date); - if (_isRealDayResult(prevForClock)) { - final shift = storedNightClockShiftSec( - _decodeBundle(prevForClock!['payload_json'])); + // + // Two numbers off the stored row, extracted in SQLite: decoding the whole + // previous bundle here cost a full payload read and curve expansion on + // this isolate for every derived day, to learn nothing for anyone who has + // not changed zone. + final prevClock = await LocalDb.storedNightClock(day.date); + if (prevClock != null) { + final onsetMs = prevClock.onsetMs; + final shift = recordedClockShiftSec( + onsetSec: onsetMs == null ? null : (onsetMs / 1000).round(), + onsetClockSec: prevClock.onsetClockSec, + ); final s = bundle['scalars']; if (shift != 0 && s is Map) { for (final k in const ['sleep_onset_sec', 'midsleep_sec']) { diff --git a/lib/data/db.dart b/lib/data/db.dart index 1dfb259b8..01639369d 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -9113,6 +9113,42 @@ class LocalDb { return rows.isEmpty ? null : _withDate(rows.first); } + /// The two numbers a re-derive needs to keep a stored night on the clock it + /// was lived on: the served row's sleep onset instant (ms) and the + /// `sleep_onset_sec` clock it froze. Read with json_extract, so the bundle is + /// never decoded on the calling isolate — a re-derive runs this for every + /// day. Null when the day has no served row, the row is a skip marker, or + /// the payload is malformed. + static Future<({num? onsetMs, num? onsetClockSec})?> storedNightClock( + String dayId) async { + final db = await instance; + // Nested CASE: json_extract raises on a malformed document and only CASE + // branches are evaluated lazily. + final rows = await db.rawQuery( + 'SELECT ' + "CASE WHEN json_valid(payload_json) THEN CASE WHEN " + "json_type(payload_json, '\$.skipped') = 'true' THEN 1 ELSE 0 END " + 'ELSE 1 END AS bad, ' + "CASE WHEN json_valid(payload_json) THEN " + "json_extract(payload_json, '\$.sleep.window.value.onset_ms') END " + 'AS onset_ms, ' + "CASE WHEN json_valid(payload_json) THEN " + "json_extract(payload_json, '\$.scalars.sleep_onset_sec') END " + 'AS onset_clock, skipped ' + 'FROM day_result WHERE day_id = ? AND algo_version <= ? ' + 'ORDER BY algo_version DESC LIMIT 1', + [dayId, _servedAlgoCeiling], + ); + if (rows.isEmpty) return null; + final r = rows.first; + if (r['bad'] == 1 || (r['skipped'] as num?)?.toInt() == 1) return null; + final onset = r['onset_ms'], clock = r['onset_clock']; + return ( + onsetMs: onset is num ? onset : null, + onsetClockSec: clock is num ? clock : null, + ); + } + /// The `readiness_absent_diag` diagnostic off [day]'s stored bundle — per /// input `{value, baseline_n}` plus the composite's own `note`. Produced on /// every day readiness comes back absent; null when [day] is null, has no diff --git a/test/stored_night_clock_test.dart b/test/stored_night_clock_test.dart new file mode 100644 index 000000000..3a752f5a9 --- /dev/null +++ b/test/stored_night_clock_test.dart @@ -0,0 +1,108 @@ +// A re-derive carries the stored night's clock forward. It reads two numbers +// off the stored row with json_extract instead of decoding the bundle; these +// tests hold that read to what decoding the bundle answered. + +import 'dart:convert'; + +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/crossday_pipeline.dart' + show recordedClockShiftSec, storedNightClockShiftSec; +import 'package:openstrap_edge/compute/derivation_engine.dart' show kAlgoVersion; +import 'package:openstrap_edge/data/db.dart'; + +Map _bundle({int? onsetMs, double? clock}) => { + 'sleep': { + 'window': { + 'value': {'onset_ms': ?onsetMs, 'offset_ms': ?onsetMs}, + }, + }, + 'scalars': {'rhr': 52, 'sleep_onset_sec': ?clock}, + 'series': { + 'hr_curve': [ + for (var i = 0; i < 50; i++) {'t': 1767225600 + i * 60, 'v': 60}, + ], + }, + }; + +Future _put(String day, Object payload, + {int? version, bool skipped = false}) => + LocalDb.putDayResult( + dayId: day, + algoVersion: version ?? kAlgoVersion, + payloadJson: payload is String ? payload : jsonEncode(payload), + windowJson: '{}', + skipped: skipped, + ); + +int _shift(({num? onsetMs, num? onsetClockSec})? c) => c == null + ? 0 + : recordedClockShiftSec( + onsetSec: c.onsetMs == null ? null : (c.onsetMs! / 1000).round(), + onsetClockSec: c.onsetClockSec, + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const name = 'stored_night_clock_test.db'; + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + await LocalDb.close(); + LocalDb.dbName = name; + await databaseFactory.deleteDatabase( + p.join(await databaseFactory.getDatabasesPath(), name), + ); + }); + + tearDownAll(LocalDb.close); + + test('matches the decoded bundle for a night lived in another zone', () async { + // 23:10 local onset, frozen two hours east of wherever the runner is. + final onset = DateTime(2026, 9, 1, 23, 10); + final onsetMs = onset.millisecondsSinceEpoch; + final local = onset.hour * 3600 + onset.minute * 60; + var clock = (local + 7200 - 4 * 3600) % 86400; + if (clock > 43200) clock -= 86400; + final b = _bundle(onsetMs: onsetMs, clock: clock.toDouble()); + await _put('2026-09-02', b); + + final got = await LocalDb.storedNightClock('2026-09-02'); + expect(got, isNotNull); + expect(_shift(got), storedNightClockShiftSec(b)); + expect(_shift(got), isNot(0)); + }); + + test('null for no row, a skip marker, or a malformed payload', () async { + expect(await LocalDb.storedNightClock('2026-01-01'), isNull); + + await _put('2026-09-03', _bundle(onsetMs: 1, clock: 1), skipped: true); + expect(await LocalDb.storedNightClock('2026-09-03'), isNull); + + await _put('2026-09-04', {'skipped': true}); + expect(await LocalDb.storedNightClock('2026-09-04'), isNull); + + await _put('2026-09-05', '{not json'); + expect(await LocalDb.storedNightClock('2026-09-05'), isNull); + }); + + test('reads the served version, not an older one under it', () async { + await _put('2026-09-06', _bundle(onsetMs: 1000, clock: 60), + version: kAlgoVersion - 1); + await _put('2026-09-06', _bundle(), version: kAlgoVersion); + final got = await LocalDb.storedNightClock('2026-09-06'); + expect(got, isNotNull); + expect(got!.onsetMs, isNull); + expect(got.onsetClockSec, isNull); + + // A newer skip marker hides the older real row, as dayResult does. + await _put('2026-09-07', _bundle(onsetMs: 1000, clock: 60), + version: kAlgoVersion - 1); + await _put('2026-09-07', {'skipped': true}, + version: kAlgoVersion, skipped: true); + expect(await LocalDb.storedNightClock('2026-09-07'), isNull); + }); +} From ab345896fa5c141bf0e216304ca5c6e0ec38d291 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 30 Oct 2026 15:32:45 +0530 Subject: [PATCH 4/8] restamp the freeze goldens at v117 only the version moves; the frozen output is unchanged for a band that never changed zone. --- test/fixtures/whoop_freeze/gen4_minus0400.json | 18 +++++++++--------- test/fixtures/whoop_freeze/gen4_plus0530.json | 18 +++++++++--------- test/fixtures/whoop_freeze/gen4_utc.json | 18 +++++++++--------- test/fixtures/whoop_freeze/gen5_minus0400.json | 18 +++++++++--------- test/fixtures/whoop_freeze/gen5_plus0530.json | 18 +++++++++--------- test/fixtures/whoop_freeze/gen5_utc.json | 18 +++++++++--------- 6 files changed, 54 insertions(+), 54 deletions(-) diff --git a/test/fixtures/whoop_freeze/gen4_minus0400.json b/test/fixtures/whoop_freeze/gen4_minus0400.json index 2509c77ca..887783571 100644 --- a/test/fixtures/whoop_freeze/gen4_minus0400.json +++ b/test/fixtures/whoop_freeze/gen4_minus0400.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen4_plus0530.json b/test/fixtures/whoop_freeze/gen4_plus0530.json index 4f489c854..f56647ae3 100644 --- a/test/fixtures/whoop_freeze/gen4_plus0530.json +++ b/test/fixtures/whoop_freeze/gen4_plus0530.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen4_utc.json b/test/fixtures/whoop_freeze/gen4_utc.json index 080f8e0ad..685a4c57b 100644 --- a/test/fixtures/whoop_freeze/gen4_utc.json +++ b/test/fixtures/whoop_freeze/gen4_utc.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -24902,7 +24902,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49595,7 +49595,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73355,7 +73355,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73363,7 +73363,7 @@ }, { "date": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73371,7 +73371,7 @@ }, { "date": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen4", "coverage_devices": "", @@ -73381,7 +73381,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -74589,7 +74589,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-14", "active_min": 239, @@ -75797,7 +75797,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen5_minus0400.json b/test/fixtures/whoop_freeze/gen5_minus0400.json index c1752aa1d..9c0ce70c2 100644 --- a/test/fixtures/whoop_freeze/gen5_minus0400.json +++ b/test/fixtures/whoop_freeze/gen5_minus0400.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen5_plus0530.json b/test/fixtures/whoop_freeze/gen5_plus0530.json index 05bb93f35..2efc7740d 100644 --- a/test/fixtures/whoop_freeze/gen5_plus0530.json +++ b/test/fixtures/whoop_freeze/gen5_plus0530.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-14", "active_min": 240, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-15", "active_min": 40, diff --git a/test/fixtures/whoop_freeze/gen5_utc.json b/test/fixtures/whoop_freeze/gen5_utc.json index f09f6882b..8415368ca 100644 --- a/test/fixtures/whoop_freeze/gen5_utc.json +++ b/test/fixtures/whoop_freeze/gen5_utc.json @@ -2,7 +2,7 @@ "day_result": [ { "day_id": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-13", "day_confidence": 0.6, @@ -25080,7 +25080,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-14", "day_confidence": 0.6, @@ -49946,7 +49946,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "payload_json": { "date": "2026-07-15", "day_confidence": 0.6, @@ -73868,7 +73868,7 @@ "metric_series_version": [ { "date": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73876,7 +73876,7 @@ }, { "date": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73884,7 +73884,7 @@ }, { "date": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "source": "band", "device_family": "gen5", "coverage_devices": "", @@ -73894,7 +73894,7 @@ "wake_day_features": [ { "day_id": "2026-07-13", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-13", "active_min": 90, @@ -75102,7 +75102,7 @@ }, { "day_id": "2026-07-14", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-14", "active_min": 239, @@ -76310,7 +76310,7 @@ }, { "day_id": "2026-07-15", - "algo_version": 116, + "algo_version": 117, "payload_json": { "day_id": "2026-07-15", "active_min": 40, From 9237758113f46f9fef704267a0c1dcc39949d612 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 13:49:42 +0530 Subject: [PATCH 5/8] bin a day's hourly heart rate on the clock it was lived on after a flight the hourly profile used the phone's new hours, so every past day's heart rate moved by the zone difference and the circadian results moved with it. the same shift that puts onset and wake back on the lived clock now applies to the hourly bins too. --- lib/compute/derivation_engine.dart | 25 +++++++++++++++++-------- test/sleep_coach_timezone_test.dart | 22 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index b22c96ad3..ed1cfadfc 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -7183,6 +7183,10 @@ class DerivationEngine { final tstSec = (acctVal?['tst_sec'] as num?)?.toDouble(); final inBedSec = (acctVal?['in_bed_sec'] as num?)?.toDouble(); final observedSec = (acctVal?['observed_in_bed_sec'] as num?)?.toDouble(); + final clockShift = recordedClockShiftSec( + onsetSec: onsetMs == null ? null : (onsetMs / 1000).round(), + onsetClockSec: sc('sleep_onset_sec'), + ); return { 'date': date, @@ -7221,11 +7225,7 @@ class DerivationEngine { // // Only the shift is written: a night recorded in this zone (the shift // is 0) carries no new key and reads exactly as before. - if (recordedClockShiftSec( - onsetSec: onsetMs == null ? null : (onsetMs / 1000).round(), - onsetClockSec: sc('sleep_onset_sec'), - ) case final shift when shift != 0) - 'clock_shift_sec': shift, + if (clockShift != 0) 'clock_shift_sec': clockShift, 'tst_min': tstSec == null ? null : (tstSec / 60).round(), // Fraction of the night we actually watched — wall-clock in-bed minus the // seconds nobody observed, over in-bed. TS-11 drops a morning whose night @@ -7246,7 +7246,10 @@ class DerivationEngine { // does not exist far enough back. `circadianNonparametric` documents HR // as an accepted input alongside activity. 24 numbers a day, so the // artifact stays small. - 'hourly_hr': hourlyHrProfile(series?['hr_curve']), + // Binned on the clock the day was lived on, like onset and wake: after a + // flight the phone's hours are not the hours that day's HR was lived in. + 'hourly_hr': + hourlyHrProfile(series?['hr_curve'], clockShiftSec: clockShift), }; } @@ -7257,7 +7260,11 @@ class DerivationEngine { /// it, so a bin is always a mean over genuine readings — never an average of /// one stray second, and never an imputed value. Gaps stay null and the /// consumer excludes the day; nothing is filled in. - static List hourlyHrProfile(Object? hrCurve, {int minMinutes = 5}) { + /// + /// [clockShiftSec] moves each sample onto the day's own clock (see + /// [recordedClockShiftSec]); 0 bins on the phone's current local hour. + static List hourlyHrProfile(Object? hrCurve, + {int minMinutes = 5, int clockShiftSec = 0}) { final sums = List.filled(24, 0); final counts = List.filled(24, 0); if (hrCurve is List) { @@ -7269,7 +7276,9 @@ class DerivationEngine { // LOCAL hour. The day model is local-midnight-to-midnight, so the UTC // hour would smear a user's evening into the next bin (and, for a // non-integral-offset zone, into the wrong one entirely). - final h = DateTime.fromMillisecondsSinceEpoch(t * 1000).hour; + final lt = DateTime.fromMillisecondsSinceEpoch(t * 1000); + final sod = lt.hour * 3600 + lt.minute * 60 + lt.second; + final h = ((sod + clockShiftSec) % 86400) ~/ 3600; if (h < 0 || h > 23) continue; sums[h] += v; counts[h] += 1; diff --git a/test/sleep_coach_timezone_test.dart b/test/sleep_coach_timezone_test.dart index 7eeb2e63b..cae229371 100644 --- a/test/sleep_coach_timezone_test.dart +++ b/test/sleep_coach_timezone_test.dart @@ -252,6 +252,28 @@ void main() { } }); + test('a day lived at UTC+2 keeps its heart rate in its own hours', () { + // One night at UTC+2 plus six minutes of HR at 14:00-14:05 its time. In + // any runner zone the record bins that HR at 14, not at the phone's hour. + final night = _europe().first; + final onsetMs = (((night.payload['sleep'] as Map)['window'] as Map)['value'] + as Map)['onset_ms'] as int; + final afternoon = onsetMs ~/ 1000 + 14 * 3600 + 30 * 60; // 23:30 + 14:30 + final payload = { + ...night.payload, + 'series': { + 'hr_curve': [ + for (var i = 0; i < 6; i++) {'t': afternoon + i * 60, 'v': 70 + i}, + ], + }, + }; + final hourly = _records([(row: night.row, payload: payload)]) + .single['hourly_hr'] as List; + expect(hourly[14], closeTo(72.5, 1e-9)); + expect([for (var h = 0; h < 24; h++) if (h != 14) hourly[h]], + everyElement(isNull)); + }); + test('a shifted clock wraps back into ±12 h and null stays null', () { expect(shiftRecordedClockSec(43000, 1000), -42400); expect(shiftRecordedClockSec(-43000, 86400 - 1000), 42400); From 8896d2287311e760d147c16f97a18f26098b9cb2 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 14:09:26 +0530 Subject: [PATCH 6/8] serve the cross-day rollup only in the zone it was built in the as-of reader rebuilt a past day from inputs read in another zone, and served a same-day rollup from before a flight, so every night sat off by the zone difference until the next derive. both artifacts carry the zone now, the reader abstains on a mismatch, and a pass with no day to derive rebuilds the rollup when the zone has moved. --- lib/compute/derivation_engine.dart | 28 ++++++++-- lib/compute/inputs/canonical.dart | 10 +++- test/crossday_as_of_zone_test.dart | 86 ++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 test/crossday_as_of_zone_test.dart diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index ed1cfadfc..5b4921414 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -3281,7 +3281,9 @@ class DerivationEngine { // the todo set; re-score it here too rather than leave it a pass. final rescored = await _quietLevelRepass(profile, dataNowSec, scope.rawDays); - if (rescored > 0) { + // A zone change with no day to derive still moves every past night's + // clock: rebuild the rollup, or it keeps the old zone's reading. + if (rescored > 0 || await _crossDayZoneStale()) { await _refreshBaselines(); await _runCrossDay(profile); await _runNotifications(); @@ -6844,6 +6846,9 @@ class DerivationEngine { // one layer up on the output. final builtForDay = LocalDb.localDayLabelNow(); final builtAtEpoch = DateTime.now().millisecondsSinceEpoch ~/ 1000; + // The zone the inputs were read in: the as-of reader serves this bundle + // only in the same zone ([crossDayAsOf]). + final zone = crossDayZoneStamp(); final (bundleJson, dropped) = await _runIsolateCancellable( () { final bundle = @@ -6856,7 +6861,8 @@ class DerivationEngine { ) ..['algo_version'] = kAlgoVersion ..['built_for_day'] = builtForDay - ..['built_at_epoch'] = builtAtEpoch; + ..['built_at_epoch'] = builtAtEpoch + ..['zone'] = zone; // Encode-safety BEFORE jsonEncode, never a try/catch around it: one // non-finite leaf must cost that leaf, not the whole artifact. final paths = []; @@ -6935,11 +6941,23 @@ class DerivationEngine { /// "Target bedtime 4:00 PM" bug again, until the next rebuild. A mismatch /// (or an artifact without the stamp) rebuilds. Name AND offset: a DST change /// alone rebuilds too, which is one cheap rebuild twice a year. - static String _crossDayZoneStamp() { + static String crossDayZoneStamp() { final now = DateTime.now(); return '${now.timeZoneName}|${now.timeZoneOffset.inMinutes}'; } + /// Whether the stored cross-day inputs were read in another zone. + Future _crossDayZoneStale() async { + final raw = (await LocalDb.baseline('crossday_input'))?['payload_json']; + if (raw is! String || raw.isEmpty) return false; + try { + final decoded = jsonDecode(raw); + return decoded is Map && decoded['zone'] != crossDayZoneStamp(); + } catch (_) { + return false; + } + } + Future>> _crossDayInputDays() async { final reviewRevision = await LocalDb.activityReviewRevision(); final artifact = await LocalDb.baseline('crossday_input'); @@ -6953,7 +6971,7 @@ class DerivationEngine { // today's (§3.3). See [crossDayArtifactUsableToday]. if (crossDayArtifactUsableToday(decoded, LocalDb.localDayLabelNow()) && (decoded as Map)['review_revision'] == reviewRevision && - decoded['zone'] == _crossDayZoneStamp()) { + decoded['zone'] == crossDayZoneStamp()) { final rows = decoded['days'] as List; return [ for (final row in rows) @@ -6979,7 +6997,7 @@ class DerivationEngine { final rows = await LocalDb.recentDayResults(_crossDayWindow); final today = LocalDb.localDayLabelNow(); final imported = await LocalDb.importedDates(); - final zone = _crossDayZoneStamp(); + final zone = crossDayZoneStamp(); final (days, json) = await _runIsolateCancellable(() { final days = >[]; for (final row in rows.reversed) { diff --git a/lib/compute/inputs/canonical.dart b/lib/compute/inputs/canonical.dart index e5c231d7d..ff4b2ca8b 100644 --- a/lib/compute/inputs/canonical.dart +++ b/lib/compute/inputs/canonical.dart @@ -18,7 +18,7 @@ import '../../ble/adapters/_registry.dart' import '../../data/day_label.dart'; import '../../data/db.dart'; import '../crossday_pipeline.dart' show buildCrossDayBundle; -import '../derivation_engine.dart' show kAlgoVersion; +import '../derivation_engine.dart' show DerivationEngine, kAlgoVersion; import '../substrate.dart'; import '../vendor_sleep.dart'; import 'colmi_inputs.dart'; @@ -1412,7 +1412,12 @@ Map _decode(Object? raw) => raw is String ? jsonDecode(raw) as Map : const {}; Future crossDayAsOf(String date) async { final stored = _decode((await LocalDb.baseline('crossday'))?['payload_json']); final recent = stored['recent']; + // Both artifacts read each night on its lived clock relative to the zone + // they were built in; read in another zone they move every night by the + // difference, so they are served only in the zone they were built in. + final zone = DerivationEngine.crossDayZoneStamp(); if ((stored['algo_version'] as num?)?.toInt() == kAlgoVersion && + stored['zone'] == zone && recent is List && recent.isNotEmpty && recent.last is Map && (recent.last as Map)['date'] == date) { return stored; @@ -1424,7 +1429,8 @@ Future crossDayAsOf(String date) async { final input = jsonDecode(raw); if (input is! Map || (input['algo_version'] as num?)?.toInt() != kAlgoVersion || - input['review_revision'] != reviewRevision) { + input['review_revision'] != reviewRevision || + input['zone'] != zone) { return const {}; } final days = >[ diff --git a/test/crossday_as_of_zone_test.dart b/test/crossday_as_of_zone_test.dart new file mode 100644 index 000000000..a8b23a1fc --- /dev/null +++ b/test/crossday_as_of_zone_test.dart @@ -0,0 +1,86 @@ +// The cross-day artifacts hold each night on its lived clock relative to the +// zone they were built in. Read in another zone, every night moves by the +// difference, so a past day's rollup is served only in the zone it was built +// in; elsewhere it abstains until the next pass rebuilds it. + +import 'dart:convert'; + +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/inputs/canonical.dart' + show crossDayAsOf; +import 'package:openstrap_edge/data/db.dart'; + +Map _day(String date, int onset) => { + 'date': date, + 'rhr': 52, + 'rmssd': 60, + 'readiness': 70, + 'onset_sec': onset, + 'wake_sec': onset + 8 * 3600, + 'tst_min': 450, + 'efficiency': 95, + }; + +Future _putInput(String zone) async { + final base = DateTime.utc(2026, 9, 1, 22).millisecondsSinceEpoch ~/ 1000; + final days = [ + for (var i = 0; i < 5; i++) + _day('2026-09-0${i + 2}', base + i * 86400), + ]; + await LocalDb.putBaseline( + 'crossday_input', + jsonEncode({ + 'algo_version': kAlgoVersion, + 'built_for_day': '2026-09-06', + 'review_revision': await LocalDb.activityReviewRevision(), + 'zone': zone, + 'days': days, + }), + ); +} + +void main() { + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_crossday_as_of_zone_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('inputs built in this zone rebuild a past day', () async { + await _putInput(DerivationEngine.crossDayZoneStamp()); + expect(await crossDayAsOf('2026-09-05'), isNotEmpty); + }); + + test('inputs built in another zone abstain', () async { + await _putInput('Elsewhere|-420'); + expect(await crossDayAsOf('2026-09-05'), isEmpty); + }); + + test('a stored rollup from another zone is not served as current', + () async { + await _putInput('Elsewhere|-420'); + await LocalDb.putBaseline( + 'crossday', + jsonEncode({ + 'algo_version': kAlgoVersion, + 'zone': 'Elsewhere|-420', + 'recent': [ + {'date': '2026-09-06'}, + ], + }), + ); + expect(await crossDayAsOf('2026-09-06'), isEmpty); + }); +} From 90b2fbb8cb6f04901631e51ef0d120bb8ca57c7f Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 14:59:54 +0530 Subject: [PATCH 7/8] rebuild an old-zone rollup reliably, and tell same-named zones apart a restored backup with no band data returned before the zone check, so past days stayed blank after a flight. the retry went by the inputs' stamp, which is rewritten before the rollup, so a rollup write that failed was never retried. and the zone abbreviation alone collides (denver and phoenix are both MST in winter), so the stamp carries the january and july offsets too. --- lib/compute/derivation_engine.dart | 31 +++++++++++++-- test/crossday_as_of_zone_test.dart | 63 ++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 5b4921414..c798a565b 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -3255,6 +3255,13 @@ class DerivationEngine { await _withWearableEdge(await LocalDb.lastDecodedRecTs()) ?? 0; if (dataNowSec <= 0) { _log('derive: no decoded data'); + // A restored backup can hold every day and no decoded data. The + // rollup is built from stored days, so a zone change still rebuilds + // it here; otherwise the reader keeps abstaining on the old zone. + if (await _crossDayZoneStale()) { + await _refreshBaselines(); + await _runCrossDay(profile); + } return 0; } final finalized = await LocalDb.finalizedDayIds(kAlgoVersion); @@ -6941,14 +6948,32 @@ class DerivationEngine { /// "Target bedtime 4:00 PM" bug again, until the next rebuild. A mismatch /// (or an artifact without the stamp) rebuilds. Name AND offset: a DST change /// alone rebuilds too, which is one cheap rebuild twice a year. + /// + /// The abbreviation alone collides (Denver and Phoenix are both "MST" in + /// winter), so the stamp also carries the zone's offsets mid-January and + /// mid-July: two zones that agree on all three keep every night on the same + /// clock anyway. static String crossDayZoneStamp() { final now = DateTime.now(); - return '${now.timeZoneName}|${now.timeZoneOffset.inMinutes}'; + int offsetAt(int month) => DateTime.fromMillisecondsSinceEpoch( + DateTime.utc(now.year, month, 15, 12).millisecondsSinceEpoch) + .timeZoneOffset + .inMinutes; + return zoneStampOf(now.timeZoneName, now.timeZoneOffset.inMinutes, + offsetAt(1), offsetAt(7)); } - /// Whether the stored cross-day inputs were read in another zone. + @visibleForTesting + static String zoneStampOf( + String name, int nowOffsetMin, int janOffsetMin, int julOffsetMin) => + '$name|$nowOffsetMin|$janOffsetMin|$julOffsetMin'; + + /// Whether the stored cross-day ROLLUP was built in another zone. The + /// rollup, not its inputs: [_refreshBaselines] re-stamps the inputs before + /// the rollup is written, so a rollup that then timed out would read as + /// current on the inputs' stamp and never be retried. Future _crossDayZoneStale() async { - final raw = (await LocalDb.baseline('crossday_input'))?['payload_json']; + final raw = (await LocalDb.baseline('crossday'))?['payload_json']; if (raw is! String || raw.isEmpty) return false; try { final decoded = jsonDecode(raw); diff --git a/test/crossday_as_of_zone_test.dart b/test/crossday_as_of_zone_test.dart index a8b23a1fc..bc8ae34fe 100644 --- a/test/crossday_as_of_zone_test.dart +++ b/test/crossday_as_of_zone_test.dart @@ -10,6 +10,7 @@ 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/profile.dart'; import 'package:openstrap_edge/compute/inputs/canonical.dart' show crossDayAsOf; import 'package:openstrap_edge/data/db.dart'; @@ -68,6 +69,68 @@ void main() { expect(await crossDayAsOf('2026-09-05'), isEmpty); }); + test('zones sharing an abbreviation do not share a stamp', () { + // Denver and Phoenix both read "MST" in January; only Denver moves in July. + final denver = DerivationEngine.zoneStampOf('MST', -420, -420, -360); + final phoenix = DerivationEngine.zoneStampOf('MST', -420, -420, -420); + expect(denver, isNot(phoenix)); + expect(DerivationEngine.crossDayZoneStamp(), + DerivationEngine.crossDayZoneStamp(), + reason: 'stable within one zone'); + }); + + test('a restored backup with no band data rebuilds an old-zone rollup', + () async { + // Stored days, no decoded rows, and a rollup from before a flight. + for (var i = 0; i < 5; i++) { + final day = '2026-09-0${i + 2}'; + final onset = + DateTime.utc(2026, 9, i + 1, 22).millisecondsSinceEpoch ~/ 1000; + await LocalDb.putDayResult( + dayId: day, + algoVersion: kAlgoVersion, + finalized: true, + rhr: 52, + readiness: 70, + windowJson: '{}', + payloadJson: jsonEncode({ + 'scalars': {'rhr': 52, 'readiness': 70, 'efficiency': 95}, + 'sleep': { + 'window': { + 'value': { + 'onset_ms': onset * 1000, + 'offset_ms': (onset + 8 * 3600) * 1000, + }, + }, + 'accounting': { + 'value': {'tst_sec': 27000}, + }, + }, + }), + ); + } + await LocalDb.putBaseline( + 'crossday', + jsonEncode({ + 'algo_version': kAlgoVersion, + 'zone': 'Elsewhere|-420|-420|-420', + 'recent': const [], + }), + ); + // The inputs were already re-read in this zone before the rollup write + // failed: the retry has to go by the rollup's own stamp. + await _putInput(DerivationEngine.crossDayZoneStamp()); + await DerivationEngine().run(const Profile( + ageYears: 35, + sex: 'm', + weightKg: 75, + heightCm: 178, + )); + final rollup = jsonDecode( + (await LocalDb.baseline('crossday'))!['payload_json'] as String) as Map; + expect(rollup['zone'], DerivationEngine.crossDayZoneStamp()); + }); + test('a stored rollup from another zone is not served as current', () async { await _putInput('Elsewhere|-420'); From af419dd6ba412ae010f45870afb7309552024f1a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sat, 31 Oct 2026 18:28:58 +0530 Subject: [PATCH 8/8] take the rollup's zone before reading its inputs the stamp was read after the inputs, so a zone change during the build stamped old-zone shifts with the new zone and the stale check accepted them. the zone is taken first now, and a rollup whose zone moved while it was built is dropped for the next pass to rebuild. --- lib/compute/derivation_engine.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index c798a565b..8a0437de5 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -6821,6 +6821,9 @@ class DerivationEngine { Future _runCrossDay(Profile profile) async { try { final reviewRevision = await LocalDb.activityReviewRevision(); + // The zone the inputs are read in, taken BEFORE they are read: a change + // during the build must not stamp old-zone shifts with the new zone. + final zone = crossDayZoneStamp(); final days = await _crossDayInputDays(); if (days.length < 3) { _log('crossday: only ${days.length} usable day(s) — skip'); @@ -6853,9 +6856,6 @@ class DerivationEngine { // one layer up on the output. final builtForDay = LocalDb.localDayLabelNow(); final builtAtEpoch = DateTime.now().millisecondsSinceEpoch ~/ 1000; - // The zone the inputs were read in: the as-of reader serves this bundle - // only in the same zone ([crossDayAsOf]). - final zone = crossDayZoneStamp(); final (bundleJson, dropped) = await _runIsolateCancellable( () { final bundle = @@ -6880,6 +6880,12 @@ class DerivationEngine { _crossDayTimeout, label: 'crossday', ); + if (crossDayZoneStamp() != zone) { + // The zone moved while this was built: drop it; the stale check + // rebuilds it on the next pass in the new zone. + _log('crossday: zone changed during the build; not stored'); + return false; + } if (!await LocalDb.putReviewedBaseline('crossday', bundleJson, reviewRevision)) { _log('crossday: newer activity review; leaving its refresh queued'); return false;