Skip to content
Merged
109 changes: 106 additions & 3 deletions lib/compute/crossday_pipeline.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -34,6 +34,8 @@ import 'onehz_pipeline.dart' show needInputNote;
/// 'resp_rate': num?, 'skin_temp_z': num?, 'skin_temp_adc': num?,
/// 'skin_temp_family': String?, '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
Expand Down Expand Up @@ -209,7 +211,10 @@ Map<String, dynamic> 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);
Expand Down Expand Up @@ -482,9 +487,24 @@ Map<String, dynamic> 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 = <double>[
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 = <double>[for (final d in days) ?_numOrNull(d['efficiency'])];
final typicalWakeMin = _median(wakeMins);
Expand Down Expand Up @@ -741,6 +761,89 @@ 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<String, dynamic> 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 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).
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;
}

/// [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<String, dynamic>? 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<double> xs) {
if (xs.isEmpty) return null;
var s = 0.0;
Expand Down
134 changes: 127 additions & 7 deletions lib/compute/derivation_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1825,7 +1825,13 @@ import 'vendor_sleep.dart';
// carries a workout-raised headline; a logged nap over the main sleep is
// counted once; an implausible stored age is named as the reason strain,
// calories and zones are absent.
const int kAlgoVersion = 116;
// 116 → 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.
///
Expand Down Expand Up @@ -3249,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);
Expand All @@ -3275,7 +3288,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()) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
await _refreshBaselines();
await _runCrossDay(profile);
await _runNotifications();
Expand Down Expand Up @@ -6086,6 +6101,32 @@ 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.
//
// 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']) {
final v = shiftRecordedClockSec(s[k] as num?, shift);
if (v != null) s[k] = v;
}
}
}

final scalars =
(bundle['scalars'] as Map?)?.cast<String, dynamic>() ?? const {};
double? sc(String k) => (scalars[k] as num?)?.toDouble();
Expand Down Expand Up @@ -6780,6 +6821,9 @@ class DerivationEngine {
Future<bool> _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');
Expand Down Expand Up @@ -6824,7 +6868,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 = <String>[];
Expand All @@ -6835,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;
Expand Down Expand Up @@ -6896,6 +6947,48 @@ 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.
///
/// 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();
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));
}

@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<bool> _crossDayZoneStale() async {
final raw = (await LocalDb.baseline('crossday'))?['payload_json'];
if (raw is! String || raw.isEmpty) return false;
try {
final decoded = jsonDecode(raw);
return decoded is Map && decoded['zone'] != crossDayZoneStamp();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
} catch (_) {
return false;
}
}

Future<List<Map<String, dynamic>>> _crossDayInputDays() async {
final reviewRevision = await LocalDb.activityReviewRevision();
final artifact = await LocalDb.baseline('crossday_input');
Expand All @@ -6908,7 +7001,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()) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
final rows = decoded['days'] as List;
return [
for (final row in rows)
Expand All @@ -6934,6 +7028,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 = <Map<String, dynamic>>[];
for (final row in rows.reversed) {
Expand All @@ -6957,6 +7052,7 @@ class DerivationEngine {
'algo_version': kAlgoVersion,
'built_for_day': today,
'review_revision': reviewRevision,
'zone': zone,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
'days': days,
})
);
Expand Down Expand Up @@ -7136,6 +7232,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,
Expand Down Expand Up @@ -7164,6 +7264,17 @@ 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 (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
Expand All @@ -7184,7 +7295,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),
};
}

Expand All @@ -7195,7 +7309,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<double?> 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<double?> hourlyHrProfile(Object? hrCurve,
{int minMinutes = 5, int clockShiftSec = 0}) {
final sums = List<double>.filled(24, 0);
final counts = List<int>.filled(24, 0);
if (hrCurve is List) {
Expand All @@ -7207,7 +7325,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;
Expand Down
Loading
Loading