diff --git a/lib/ai/briefing_engine.dart b/lib/ai/briefing_engine.dart index ff49e9a..3c5a63c 100644 --- a/lib/ai/briefing_engine.dart +++ b/lib/ai/briefing_engine.dart @@ -134,10 +134,12 @@ Future> collectBriefingInputs( // ── prompt building (PURE — unit-tested on sample data) ─────────────────────── -/// The reader's local time of day — used only for the briefing's greeting, and -/// deliberately distinct from [BriefingPeriod]: the morning briefing (last -/// night's sleep + recovery) is shown right up to 17:00, so a reader opening it -/// in the afternoon must be greeted for the afternoon, never the "morning". +/// The reader's local time of day at GENERATION time — passed into the prompt +/// as context only (the model is told not to write a greeting off it; the app +/// renders its own greeting fresh at read time, since a cached briefing can be +/// read hours after it was generated). Deliberately distinct from +/// [BriefingPeriod]: the morning briefing (last night's sleep + recovery) is +/// shown right up to 17:00. String partOfDay(DateTime now) { final h = now.hour; if (h < 12) return 'morning'; @@ -164,15 +166,17 @@ String readinessBand(num v) { return 'good'; } -String briefingSystemPrompt(BriefingPeriod period, String timeOfDay) { +String briefingSystemPrompt(BriefingPeriod period) { final scope = period == BriefingPeriod.morning ? 'last night\'s sleep and recovery, and what they mean for the day ahead' : 'today\'s activity, strain and stress, and how the day landed'; return 'You write a health briefing for a local-first fitness band app. ' - 'It is currently $timeOfDay for the reader — if you open with a greeting, ' - 'greet for the $timeOfDay and never assume a different time of day. ' 'Summarize $scope.\n' 'HARD RULES:\n' + '- Do NOT open with a greeting or any reference to the time of day — ' + 'the app shows its own greeting separately, computed at the moment the ' + 'reader actually opens it, and this text may be read hours after it was ' + 'written. Start straight with the substance.\n' '- Use ONLY the numbers provided. Never invent, estimate or mention a ' 'metric that is not in the data. No medical advice or diagnosis.\n' '- If a "readiness" value is given, its parenthesized band label ' @@ -286,7 +290,7 @@ class BriefingEngine { (({required String system, required String user}) => CoachEngine.completeText( config: config, system: system, user: user)))( - system: briefingSystemPrompt(period, tod), + system: briefingSystemPrompt(period), user: buildBriefingUserPrompt(period, day, inputs, tod), ); if (raw.trim().isEmpty) { diff --git a/lib/compute/crossday_pipeline.dart b/lib/compute/crossday_pipeline.dart index c4b08c9..e2d874d 100644 --- a/lib/compute/crossday_pipeline.dart +++ b/lib/compute/crossday_pipeline.dart @@ -47,20 +47,30 @@ Map buildCrossDayBundle( for (final d in days) _numOrNull(d['skin_temp_z']), ]; + // A day flagged `unsettled` (today, still syncing / not finalized) is a + // truncated reading, not a physiological signal — it must not drive an + // illness/anomaly/temperature ALERT. It stays in `days` for everything else + // (readiness, RHR trend, load, sleep debt, `recent`), which is why this is a + // per-input null rather than dropping the row from the list. + final unsettled = [for (final d in days) d['unsettled'] == true]; + T? settled(int i, T? v) => unsettled[i] ? null : v; + // ── illness CUSUM (NightSignal) on nightly RHR ───────────────────────────── - final illness = ana.illnessCusum(dates, rhrList); + final illness = ana.illnessCusum(dates, [ + for (var i = 0; i < n; i++) settled(i, rhrList[i]), + ]); // ── multivariate anomaly {RHR↑,HRV↓,temp↑,resp↑} ─────────────────────────── // Build one AnomalyFeatures per day (same length as dates). Days with all // features null still occupy a slot — the detector handles the nulls // internally (needs ≥2 present features tonight to compute a distance). final feats = [ - for (final d in days) + for (var i = 0; i < n; i++) ana.AnomalyFeatures( - rhr: _numOrNull(d['rhr']), - hrv: _numOrNull(d['rmssd']), - temp: _numOrNull(d['skin_temp_z']), - resp: _numOrNull(d['resp_rate']), + rhr: settled(i, rhrList[i]), + hrv: settled(i, rmssdList[i]), + temp: settled(i, tempList[i]), + resp: settled(i, respList[i]), ), ]; final anomaly = ana.multivariateAnomaly(dates, feats); @@ -81,7 +91,9 @@ Map buildCrossDayBundle( final load = ana.ctlAtlTsb(dailyTrimp); // ── skin-temp illness flag (Smarr, cycle-aware) ──────────────────────────── - final tempIllness = ana.tempIllnessFlag(dates, tempList); + final tempIllness = ana.tempIllnessFlag(dates, [ + for (var i = 0; i < n; i++) settled(i, tempList[i]), + ]); // ── circadian: mid-sleep, free/work split, jetlag, chronotype, sleep debt ── // mid-sleep epoch = (onset+wake)/2; local clock-hours in [0,24) via mod-day. diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart index 61b0d0b..1afb400 100644 --- a/lib/compute/derivation_engine.dart +++ b/lib/compute/derivation_engine.dart @@ -803,6 +803,7 @@ class DerivationEngine { var done = 0; var completed = 0; + var failures = 0; final activeDays = {}; _diag['stage'] = 'per_day'; _diag['active_days'] = const []; @@ -844,6 +845,7 @@ class DerivationEngine { ); _diag['skipped_days'] = (_diag['skipped_days'] as int) + 1; _diag['last_error'] = 'no_bounded_window_payload day=$dayId'; + failures++; } } catch (e) { _log('derive day $dayId FAILED/skipped: $e'); @@ -856,6 +858,7 @@ class DerivationEngine { ); _diag['skipped_days'] = (_diag['skipped_days'] as int) + 1; _diag['last_error'] = '$e'; + failures++; } activeDays.remove(dayId); _diag['active_days'] = activeDays.toList(); @@ -878,6 +881,22 @@ class DerivationEngine { if (scope.fullHistory) { _diag['stage'] = 'prune'; await _pruneOldDecoded(todoDays, dataNowSec); + // Re-baseline the travel guard — but ONLY if every targeted day + // actually got re-derived under the current timezone. processDay + // swallows per-day errors and marks the day skipped, so a restage can + // "finish" with days still unresolved; clearing the hold then would + // drop it without the adjacency ever having been fixed. (A day kept + // deliberately — a pruned override day — is not a failure.) + if (failures == 0) { + await LocalDb.putBaseline( + 'tz_travel_guard', + jsonEncode({'offset_min': DateTime.now().timeZoneOffset.inMinutes}), + ); + } else { + _log( + 'derive: $failures day(s) unresolved — keeping the timezone hold', + ); + } } return done; } catch (e, st) { @@ -953,6 +972,7 @@ class DerivationEngine { final orderedDays = todoDays.reversed.toList(); var done = 0; var completed = 0; + var failures = 0; final activeDays = {}; Future processDay(String dayId) async { @@ -968,11 +988,13 @@ class DerivationEngine { } else { _diag['skipped_days'] = (_diag['skipped_days'] as int) + 1; _diag['last_error'] = 'no_bounded_window_payload day=$dayId'; + failures++; } } catch (e) { _log('derive selected day $dayId FAILED/skipped: $e'); _diag['skipped_days'] = (_diag['skipped_days'] as int) + 1; _diag['last_error'] = '$e'; + failures++; } activeDays.remove(dayId); _diag['active_days'] = activeDays.toList(); @@ -981,6 +1003,23 @@ class DerivationEngine { } await runWithConcurrency(orderedDays, _deriveConcurrency, processDay); + // A SELECTED re-analyze that happens to cover the whole raw history, with + // every day resolved, is a full restage by any other name — it re-derived + // every day under the current timezone, so it clears the travel hold too. + // A partial selection deliberately does not: those days say nothing about + // the ones still held. + if (force && failures == 0) { + final rawDays = (await LocalDb.decodedRecTsMaxByDay()).keys.toSet(); + if (rawDays.isNotEmpty && rawDays.difference(days).isEmpty) { + await LocalDb.putBaseline( + 'tz_travel_guard', + jsonEncode({ + 'offset_min': DateTime.now().timeZoneOffset.inMinutes, + }), + ); + _log('derive selected: full-coverage restage — timezone hold cleared'); + } + } if (done > 0) { await _refreshBaselines(); await _runCrossDay(profile); @@ -1017,12 +1056,19 @@ class DerivationEngine { final candidate = await _sleepCandidateForDay(dayId, stats: stats); final dayStart = _localDayLabelToSec(dayId); final dayEnd = _localNextDayLabelToSec(dayId); - final daySub = await _loadSubstrateRange( + // Load the day PLUS the nap boundary buffer in ONE pass (each + // _loadSubstrateRange spawns its own isolate, so a second load would + // double that cost) and slice the calendar day back out of it. Without + // this, the live decoded path — run()/runDays()/rescanRecent(), i.e. every + // non-import day — fell back to napSub == daySub and went on bisecting + // naps at midnight. + final napSub = await _loadSubstrateRange( dayStart, - dayEnd - 1, + dayEnd - 1 + napBoundaryBufferSec, dayId: dayId, stats: stats, ); + final daySub = napSub.slice(dayStart, dayEnd); Substrate sleepSub = Substrate.empty; if (candidate.present && candidate.sleepOffsetSec > candidate.sleepOnsetSec) { @@ -1042,7 +1088,11 @@ class DerivationEngine { if (stats.rows > (_diag['max_day_raw_rows'] as int)) { _diag['max_day_raw_rows'] = stats.rows; } - return candidate.toPreparedDay(daySub: daySub, sleepSub: sleepSub); + return candidate.toPreparedDay( + daySub: daySub, + napSub: napSub, + sleepSub: sleepSub, + ); } Future _sleepCandidateForDay( @@ -1351,14 +1401,50 @@ class DerivationEngine { } final rawDays = rawByDay.keys.toList()..sort(); if (force) { + // A full restage resolves any held-back timezone-adjacent days on its + // own, so it clears the guard — but only once it has actually RUN. See + // the reset at the end of run(): clearing it here, at scope-selection + // time, meant an interrupted restage still dropped the hold. return _scopeForDays(rawDays, reason: 'full-history', fullHistory: true); } final finalized = await LocalDb.finalizedDayIds(kAlgoVersion); - final pending = [ + var pending = [ for (final day in rawDays) if (!finalized.contains(day)) day, ]; + + // decodedRecTsMaxByDay() buckets by the CURRENT device timezone, but + // `finalized` was frozen under whatever timezone was active when each day + // was derived. A real cross-timezone trip (not an ~1h DST shift) can make + // the SAME rec_ts rows relabel to a day adjacent to one already finalized + // — looking like a brand-new "pending" night that's really a duplicate, or + // silently landing on an already-finalized day_id and looking lost. Once + // that's detected, hold off auto-deriving anything adjacent to finalized + // data until "Re-analyze data" (full restage) resolves it properly. + if (await _timezoneTravelSuspected()) { + final adjacent = { + for (final day in finalized) ..._adjacentDayIds(day), + }; + final held = pending.where(adjacent.contains).toList(); + if (held.isNotEmpty) { + _log( + 'derive: possible timezone change — holding ${held.length} day(s) ' + 'adjacent to finalized data until Re-analyze data runs: $held', + ); + pending = pending.where((d) => !adjacent.contains(d)).toList(); + if (pending.isEmpty) { + // Everything pending was held. Falling through to the + // 'latest-finalized-check' below would re-derive rawDays.last — + // one of the very days just held — defeating the hold entirely. + return const _DeriveScope( + fullHistory: false, + targetDays: [], + reason: 'tz-travel-hold', + ); + } + } + } if (pending.isEmpty) { return _scopeForDays([rawDays.last], reason: 'latest-finalized-check'); } @@ -1375,6 +1461,60 @@ class DerivationEngine { return _scopeForDays(light.days, reason: light.reason); } + /// The day before and after [dayId] ('YYYY-MM-DD'), DST-safe (goes through + /// real DateTime arithmetic, not a raw ±86400s offset). + static List _adjacentDayIds(String dayId) { + final d = DateTime.tryParse(dayId); + if (d == null) return const []; + String label(DateTime x) => + '${x.year.toString().padLeft(4, '0')}-' + '${x.month.toString().padLeft(2, '0')}-' + '${x.day.toString().padLeft(2, '0')}'; + return [ + label(DateTime(d.year, d.month, d.day - 1)), + label(DateTime(d.year, d.month, d.day + 1)), + ]; + } + + /// True right after the device timezone jumps by more than a real DST shift + /// ever would (>=3h) — a strong signal of actual cross-timezone travel + /// rather than a seasonal clock change. Stays true across repeated calls + /// (derive runs many times a day) by only ever updating the persisted + /// baseline offset when NO jump is detected — updating it unconditionally + /// would make the very next call see lastOffset == nowOffset and silently + /// drop the guard after a single pass. `force` (full restage) is what + /// resets it, per _deriveScope. + static const int _tzJumpThresholdMin = 180; + Future _timezoneTravelSuspected() async { + final nowOffsetMin = DateTime.now().timeZoneOffset.inMinutes; + final row = await LocalDb.baseline('tz_travel_guard'); + final raw = row?['payload_json']; + int? lastOffsetMin; + if (raw is String && raw.isNotEmpty) { + try { + final d = jsonDecode(raw); + if (d is Map) lastOffsetMin = (d['offset_min'] as num?)?.toInt(); + } catch (_) { + // fall through — treat as unknown + } + } + if (lastOffsetMin == null) { + await LocalDb.putBaseline( + 'tz_travel_guard', + jsonEncode({'offset_min': nowOffsetMin}), + ); + return false; + } + final jumped = (nowOffsetMin - lastOffsetMin).abs() >= _tzJumpThresholdMin; + if (!jumped) { + await LocalDb.putBaseline( + 'tz_travel_guard', + jsonEncode({'offset_min': nowOffsetMin}), + ); + } + return jumped; + } + _DeriveScope _scopeForDays( List days, { required String reason, @@ -1599,6 +1739,10 @@ class DerivationEngine { bool forceFinalize = false, }) async { final daySub = sub.slice(day.startSec, day.endSec); + // Same buffered slice prepareDerivationPayload uses — without it, imported + // days fall back to daySub and a nap straddling midnight is bisected again + // on exactly the path this PR set out to fix. + final napSub = sub.slice(day.startSec, day.endSec + napBoundaryBufferSec); final sleepSub = day.hasSleep ? sub.sliceIdx(day.sleepLoIdx, day.sleepHiIdx) : Substrate.empty; @@ -1632,6 +1776,7 @@ class DerivationEngine { sleepOnsetSec: onsetSec, sleepOffsetSec: offsetSec, daySub: daySub, + napSub: napSub, sleepSub: sleepSub, ), profile, @@ -1836,6 +1981,7 @@ class DerivationEngine { // sendable object (never `this`, `day`, or `bundle`). final blocksInput = _DayBlocksInput( daySub: daySub, + napSub: day.napSub, sleepSub: sleepSub, profile: profile, onsetSec: day.sleepOnsetSec, @@ -1878,7 +2024,11 @@ class DerivationEngine { if (nb != null) { await NotificationCenter.instance.emit( NotificationEvent( - dedupeKey: '${day.date}:auto_workout', + // Per-bout, not per-day — a per-day key silently swallowed the + // notification for a second real workout later the same day + // (fire-once-per-key by design). endSec is stable across re-derive + // passes re-detecting the SAME bout, so that case still dedupes. + dedupeKey: '${day.date}:auto_workout:${nb.endSec}', category: NotifCategory.recovery, priority: NotifPriority.normal, title: 'Did you work out?', @@ -2275,6 +2425,7 @@ class DerivationEngine { // unconditionally on every heavy pass. _decodeBundle/_crossDayRecord are // both static, so this whole transform+encode step is isolate-safe. final rows = await LocalDb.recentDayResults(_crossDayWindow); + final today = LocalDb.localDayLabelNow(); final (days, json) = await _runIsolateCancellable(() { final days = >[]; for (final row in rows.reversed) { @@ -2282,7 +2433,22 @@ class DerivationEngine { if (payload == null) continue; if (payload['skipped'] == true) continue; final rec = _crossDayRecord(row, payload); - if (rec != null) days.add(rec); + if (rec == null) continue; + // Today's own row updates on every derive pass while the night is + // still syncing/settling — feeding that partial reading into the + // illness/anomaly CUSUM can fire a false "possible illness onset" on + // data that's really just a truncated/mid-drain night. Only exclude + // TODAY specifically; older days already had their 48h to settle. + // + // FLAG it rather than DROP it: `days` is the single input list for the + // whole cross-day bundle, so dropping today also silently removed it + // from readiness/glass-box, the resting-HR trend-shift CUSUM, load, + // sleep debt and `recent` (whose last row dates every notification). + // buildCrossDayBundle nulls only the alert inputs for a flagged day. + if (row['day_id'] == today && (row['finalized'] as num?) != 1) { + rec['unsettled'] = true; + } + days.add(rec); } return (days, jsonEncode({'algo_version': kAlgoVersion, 'days': days})); }, _crossDayTimeout, label: 'crossday-input'); @@ -3267,7 +3433,12 @@ class DerivationEngine { /// Sleep periods: the main sleep + any NAPS (still, on-wrist minute-runs ≥20 /// min OUTSIDE the main window). Conservative — naps need sustained stillness. - static Map _sleepPeriods(Substrate s, int onsetSec, int offsetSec) { + static Map _sleepPeriods( + Substrate s, + int onsetSec, + int offsetSec, { + int? attributionEndSec, + }) { final periods = >[]; var totalAsleep = 0; if (offsetSec > onsetSec) { @@ -3317,8 +3488,13 @@ class DerivationEngine { j++; } final lenMin = j - i; - if (lenMin >= 20) { - final start = keys[i] * 60, end = keys[j - 1] * 60 + 60; + final start = keys[i] * 60; + // A run STARTING at/after the real day boundary belongs to tomorrow's + // own (unbuffered) window — only count runs that started today. + final startsToday = + attributionEndSec == null || start < attributionEndSec; + if (lenMin >= 20 && startsToday) { + final end = keys[j - 1] * 60 + 60; periods.add({ 'is_main': false, 'start': start, @@ -3342,8 +3518,9 @@ class DerivationEngine { Map? scMap, Substrate s, int onsetSec, - int offsetSec, - ) { + int offsetSec, { + int? attributionEndSec, + }) { try { final n = s.length; if (n < 60) return; @@ -3363,8 +3540,14 @@ class DerivationEngine { if (lo >= 0 && hi > lo) main = ana.SleepWindowSpan(lo, hi); } final m = ana.detectNaps(accel, hr, mainSleep: main); - final naps = m.value ?? const []; final t0 = s.tsSec.first; + // A nap STARTING at/after the real day boundary is tomorrow's — its own + // (unbuffered) window finds it independently, so keeping it here too + // would double-count it. + final naps = (m.value ?? const []).where((nap) { + if (attributionEndSec == null) return true; + return t0 + nap.startSec < attributionEndSec; + }).toList(); bundle['naps'] = { 'value': [ for (final nap in naps) @@ -3604,8 +3787,15 @@ class DerivationEngine { seriesPatch['resp_day'] = _dayRespCurve(daySub); seriesPatch['skin_temp_day'] = _daySkinTempCurve(daySub); bundlePatch['restlessness'] = _restlessness(sleepSub); - bundlePatch['sleep_periods'] = _sleepPeriods(daySub, onset, offset); - _attachNaps(bundlePatch, scMap, daySub, onset, offset); + // napSub extends a few hours past this day's calendar end so a nap/ + // secondary-sleep block spanning midnight isn't bisected — but a run that + // actually STARTS in that borrowed buffer belongs to tomorrow (which sees + // it in its own regular window), so both helpers drop anything starting + // at/after dayEndSec to avoid double-counting. + bundlePatch['sleep_periods'] = + _sleepPeriods(inp.napSub, onset, offset, attributionEndSec: inp.dayEndSec); + _attachNaps(bundlePatch, scMap, inp.napSub, onset, offset, + attributionEndSec: inp.dayEndSec); // Overrides wake's activity_curve (same value, computed once here). bundlePatch['activity_curve'] = _activityCurve(daySub); bundlePatch['detected_workouts'] = const >[]; @@ -3974,6 +4164,7 @@ Future runCancellableIsolate( /// pure compute needs are performed by the caller and passed in here. class _DayBlocksInput { final Substrate daySub; + final Substrate napSub; final Substrate sleepSub; final Profile profile; final int onsetSec; @@ -3998,6 +4189,7 @@ class _DayBlocksInput { final int dataNowSec; const _DayBlocksInput({ required this.daySub, + required this.napSub, required this.sleepSub, required this.profile, required this.onsetSec, diff --git a/lib/compute/derive_prepare.dart b/lib/compute/derive_prepare.dart index d172128..7892a9d 100644 --- a/lib/compute/derive_prepare.dart +++ b/lib/compute/derive_prepare.dart @@ -18,6 +18,14 @@ class PreparedDerivationDay { final Substrate daySub; final Substrate sleepSub; + /// Same as [daySub] but extended [napBoundaryBufferSec] past the calendar + /// end — a nap/secondary-sleep block that starts before midnight and runs + /// past it was being silently bisected at the exact day boundary (each half + /// falling under the 20-min floor and vanishing from BOTH days). Only nap + /// detection reads this wider slice; every other day-scoped calc keeps using + /// the strict [daySub] so steps/wear/activity are never double-counted. + final Substrate napSub; + const PreparedDerivationDay({ required this.date, required this.endSec, @@ -29,8 +37,9 @@ class PreparedDerivationDay { required this.sleepOffsetSec, required this.daySub, required this.sleepSub, + Substrate? napSub, this.sleepSource = 'auto', - }); + }) : napSub = napSub ?? daySub; Map toJson() => { 'date': date, @@ -44,11 +53,15 @@ class PreparedDerivationDay { 'sleep_source': sleepSource, 'day_sub': daySub.toJson(), 'sleep_sub': sleepSub.toJson(), + 'nap_sub': napSub.toJson(), }; static PreparedDerivationDay fromJson(Map m) { List strs(String k) => ((m[k] as List?) ?? const []).map((e) => e.toString()).toList(); + final daySub = Substrate.fromJson( + ((m['day_sub'] as Map?) ?? const {}).cast(), + ); return PreparedDerivationDay( date: m['date'] as String? ?? '', endSec: (m['end_sec'] as num?)?.toInt() ?? 0, @@ -60,16 +73,24 @@ class PreparedDerivationDay { sleepOnsetSec: (m['sleep_onset_sec'] as num?)?.toInt() ?? 0, sleepOffsetSec: (m['sleep_offset_sec'] as num?)?.toInt() ?? 0, sleepSource: m['sleep_source'] as String? ?? 'auto', - daySub: Substrate.fromJson( - ((m['day_sub'] as Map?) ?? const {}).cast(), - ), + daySub: daySub, sleepSub: Substrate.fromJson( ((m['sleep_sub'] as Map?) ?? const {}).cast(), ), + napSub: m['nap_sub'] is Map + ? Substrate.fromJson((m['nap_sub'] as Map).cast()) + : daySub, ); } } +/// How far past a day's calendar end nap detection is allowed to look, so a +/// nap/secondary-sleep block spanning midnight is seen whole by the day it +/// started on. Naps starting inside this buffer belong to tomorrow, which +/// sees them anyway in its own regular (unbuffered) window — so this can't +/// double-count. +const int napBoundaryBufferSec = 3 * 3600; + class PreparedDerivationPayload { final int dataNowSec; final List days; @@ -161,6 +182,7 @@ class SleepSessionCandidate { PreparedDerivationDay toPreparedDay({ required Substrate daySub, required Substrate sleepSub, + Substrate? napSub, }) => PreparedDerivationDay( date: dayId, // `endSec` is what the engine anchors FINALIZATION on @@ -181,6 +203,7 @@ class SleepSessionCandidate { sleepOffsetSec: sleepOffsetSec, sleepSource: sleepSource, daySub: daySub, + napSub: napSub, sleepSub: sleepSub, ); } @@ -287,6 +310,7 @@ PreparedDerivationPayload prepareDerivationPayload( for (final day in calendarDays(sub, override: override)) { if (targetDay != null && day.date != targetDay) continue; final daySub = sub.slice(day.startSec, day.endSec); + final napSub = sub.slice(day.startSec, day.endSec + napBoundaryBufferSec); final sleepSub = day.hasSleep ? sub.sliceIdx(day.sleepLoIdx, day.sleepHiIdx) : Substrate.empty; @@ -322,6 +346,7 @@ PreparedDerivationPayload prepareDerivationPayload( sleepSource: day.sleepSource, daySub: daySub, sleepSub: sleepSub, + napSub: napSub, ), ); } diff --git a/lib/data/db.dart b/lib/data/db.dart index 33d11f7..ea66fbf 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -790,6 +790,22 @@ class LocalDb { }); } + /// True when a coverage row for exactly this window already exists. + /// + /// `live_coverage` is an append-only SUM (no uniqueness on the window), so a + /// replayed write double-counts the day's real steps. The orphaned-session + /// recovery uses this to stay idempotent: a process killed AFTER + /// `_finalizeLivePedometer` wrote coverage but BEFORE it cleared the + /// checkpoint would otherwise re-add the same bout on the next launch. + static Future hasLiveCoverageWindow(int startTs, int endTs) async { + final db = await instance; + final r = await db.rawQuery( + 'SELECT 1 FROM live_coverage WHERE start_ts = ? AND end_ts = ? LIMIT 1', + [startTs, endTs], + ); + return r.isNotEmpty; + } + /// Real (100 Hz) steps attributed to [day]. static Future liveStepsForDay(String day) async { final db = await instance; @@ -2712,12 +2728,20 @@ class LocalDb { 'rmssd': rmssd, 'readiness': readiness, }, conflictAlgorithm: ConflictAlgorithm.replace); - for (final e in series.entries) { - await txn.insert('metric_series', { - 'date': dayId, - 'key': e.key, - 'value': e.value, - }, conflictAlgorithm: ConflictAlgorithm.replace); + // A `partial` row already doesn't count as "derived" for the raw-pruning + // guard (see above) — extend the same caution to the rolling baselines: + // don't let a day whose second-half compute failed/timed out overwrite + // (or seed, for a brand-new day) the value tomorrow's readiness/illness + // baseline reads via metric_series. The next successful (non-partial) + // pass writes the real value once it lands. + if (!partial) { + for (final e in series.entries) { + await txn.insert('metric_series', { + 'date': dayId, + 'key': e.key, + 'value': e.value, + }, conflictAlgorithm: ConflictAlgorithm.replace); + } } }); } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index d4465cd..88e5cbf 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -1085,6 +1085,17 @@ class AppState extends ChangeNotifier { want = BriefingPeriod.morning; } if (want == null || BriefingStore.read(want) != null) return; + if (want == BriefingPeriod.morning) { + // Don't opportunistically write (and permanently cache) a morning + // briefing off a still-syncing/truncated overnight — e.g. the band + // disconnected mid-sleep and the app is only foregrounded at 5am, so + // "overnight_state" is still 'building'. Wait for it to genuinely + // settle; the 10-min rate limit above already caps how often we check. + final today = await r.getToday(); + final status = (today['status'] as Map?)?.cast(); + final overnightState = status?['overnight_state']?.toString(); + if (overnightState != 'ready') return; + } _lastBriefingAttemptMs = at.millisecondsSinceEpoch; await BriefingEngine(config: cfg, repo: r).generate(want, now: at); _log('[ai] ${want.id} briefing generated'); @@ -1462,6 +1473,13 @@ class AppState extends ChangeNotifier { try { await _ensureForegroundLease(); if (await engine.connectToRemoteId(paired!.remoteId)) { + // A process kill followed by an iOS BLE-restore relaunch lands + // HERE, not in openSession() — this is the primary case the live + // step checkpoint exists for, so recovery has to run on this path + // too or those steps sit in prefs forever. Counters are fresh on a + // cold launch, so there is nothing to double-count. + await _recoverOrphanedLiveSession(); + _resetLivePedometer(); _maybeDowngradeLiveForBackground(); _startBackfillTimer(); } else { @@ -1805,7 +1823,34 @@ class AppState extends ChangeNotifier { /// gain-applied). Used for cadence calibration. 0 when not streaming. int get _liveRaw => _committedRaw + (_magMin.isEmpty ? 0 : ana.pedometer(_magMin)); - int get liveSteps => (_liveRaw * ana.StepParams.gain).round(); + + // A routine BLE disconnect zeroes the live counter for a fresh session + // (`_resetLivePedometer`) before the derived day_result has had a chance to + // fold the just-ended session's steps in — the Today tile would otherwise + // visibly drop then jump back once derivation catches up. Hold the ended + // session's total on display for a short grace window so the number never + // regresses; the derived total supersedes it well within that window. + int _sessionStepsCushion = 0; + int _sessionCushionSetAtMs = 0; + static const int _sessionCushionGraceMs = 20 * 1000; + + /// The TRUE gain-applied step count for THIS connected session, with no + /// display cushion. Everything that PERSISTS or CALIBRATES must read this — + /// [liveSteps] is display-only and can deliberately report a just-ended + /// PRIOR session's larger total during its grace window, which would + /// double-count into `live_coverage` and poison the cadence model. + int get _rawSessionSteps => (_liveRaw * ana.StepParams.gain).round(); + + int get liveSteps { + final raw = _rawSessionSteps; + if (_sessionStepsCushion <= 0) return raw; + if (DateTime.now().millisecondsSinceEpoch - _sessionCushionSetAtMs >= + _sessionCushionGraceMs) { + _sessionStepsCushion = 0; + return raw; + } + return math.max(raw, _sessionStepsCushion); + } // Snapshot of the RAW session total at the moment a manual workout started, so // the live-session screen shows steps FOR THIS WORKOUT (not since connection). @@ -1865,13 +1910,19 @@ class AppState extends ChangeNotifier { // Commit each completed minute into the raw total (matches the gain's // per-minute calibration), then keep counting the next partial minute. + var committedThisTick = false; while (_magMin.length >= _minuteSamples) { final minute = _magMin.sublist(0, _minuteSamples); _magMin.removeRange(0, _minuteSamples); final before = _committedRaw; _committedRaw += ana.pedometer(minute); if (_committedRaw > before) _lastWalkMs = nowMs; + committedThisTick = true; } + // Checkpoint once a minute (only on an actual commit, not every frame) so + // a killed process doesn't lose the whole session — only whatever hasn't + // completed a minute yet. See _recoverOrphanedLiveSession. + if (committedThisTick) unawaited(_checkpointLiveSession()); if (nowMs - _lastLiveUiNotifyMs >= 1000) { _lastLiveUiNotifyMs = nowMs; notifyListeners(); // live readout re-counts the partial minute on read @@ -1908,23 +1959,39 @@ class AppState extends ChangeNotifier { /// End-of-session: if the bout is credible walking, fold it into the personal /// cadence calibration (persisted) so the 24/7 estimate gets more accurate. Future _finalizeLivePedometer() async { - final steps = liveSteps; // gain-applied + // RAW, never the cushioned display value: a second short session ending + // inside the first one's grace window would otherwise persist the FIRST + // session's total again (double-counted coverage + a nonsense cadence). + final steps = _rawSessionSteps; // gain-applied final durS = _liveSamples / 100.0; final enmo = _liveEnmoN > 0 ? _liveEnmoSum / _liveEnmoN : 0.0; // Derive the coverage window BEFORE resetting (it reads session counters). final window = _liveCoverageWindow(steps); + if (steps > 0) { + // Never LOWER a still-active cushion — a small follow-up session ending + // inside the grace window must not reintroduce the visible regression + // the cushion exists to prevent. + final active = liveSteps; // expires the cushion if it is already stale + _sessionStepsCushion = math.max( + steps, + math.max(active, _sessionStepsCushion), + ); + _sessionCushionSetAtMs = DateTime.now().millisecondsSinceEpoch; + } _resetLivePedometer(); // Record the REAL 100 Hz step window (device time). The derivation pass adds // it to the day's steps AND excludes those minutes from the 1 Hz estimate, so // 100 Hz always wins and a minute is never counted twice. if (window != null) { - final d = DateTime.fromMillisecondsSinceEpoch(window.startTs * 1000); - final day = - '${d.year.toString().padLeft(4, '0')}-' - '${d.month.toString().padLeft(2, '0')}-' - '${d.day.toString().padLeft(2, '0')}'; + final day = dayLabelOf( + DateTime.fromMillisecondsSinceEpoch(window.startTs * 1000), + ); await LocalDb.addLiveCoverage(window.startTs, window.endTs, steps, day); } + // The session ended cleanly and is now durably recorded — the checkpoint + // that would otherwise let a killed-process session recover is no longer + // needed. + await _clearLiveSessionCheckpoint(); if (steps <= 0 || durS < 20) return; final cadence = steps / (durS / 60.0); // Any nonzero AN-2554 count is CONFIRM-gated gait; confidence is high when @@ -1946,6 +2013,110 @@ class AppState extends ChangeNotifier { } } + // Whatever accrued via _committedRaw/_magMin between minute-commits is + // in-memory ONLY — if the OS kills the app (backgrounded walk, phone + // reboot) mid-session, none of it was ever going to reach + // _finalizeLivePedometer, so it just vanished with no trace and no + // fallback (the 1 Hz estimator only backfills minutes a coverage row + // says are UNCOVERED). Checkpoint the committed total once a minute so + // the next session start can recover it instead of losing it outright. + static const String _kLiveSessionCheckpoint = 'live_session_checkpoint'; + + Future _checkpointLiveSession() async { + if (_committedRaw <= 0 || _liveCoverStartTs == null) return; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _kLiveSessionCheckpoint, + jsonEncode({ + 'steps': (_committedRaw * ana.StepParams.gain).round(), + 'samples': _liveSamples, + 'cover_start_ts': _liveCoverStartTs, + 'cover_end_ts': _liveCoverEndTs, + 'first_ingest_ms': _liveFirstIngestMs, + 'last_ingest_ms': _liveLastIngestMs, + }), + ); + } catch (e) { + _log('[steps] checkpoint skipped: $e'); + } + } + + Future _clearLiveSessionCheckpoint() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_kLiveSessionCheckpoint); + } catch (_) { + // best-effort + } + } + + /// Recover a checkpoint left behind by a session that never reached + /// [_finalizeLivePedometer] (the process was killed, not a clean + /// disconnect) — folds the committed steps into `live_coverage` just like + /// a normal session end, so a killed background walk doesn't just vanish. + /// Call this BEFORE starting a fresh session ([_resetLivePedometer]). + /// Single-flight: two entry points can now call this (openSession's full + /// connect and the background cold-launch branch). Interleaving them would + /// let both read the checkpoint before either removed it, and + /// `live_coverage` is an append-only SUM with no window uniqueness — the + /// duplicate would silently inflate the day's real steps. + Future? _orphanRecovery; + + Future _recoverOrphanedLiveSession() => + _orphanRecovery ??= _recoverOrphanedLiveSessionOnce().whenComplete(() { + _orphanRecovery = null; + }); + + Future _recoverOrphanedLiveSessionOnce() async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_kLiveSessionCheckpoint); + if (raw == null || raw.isEmpty) return; + // Durable-FIRST, like everywhere else in this codebase (commit-before-ACK + // is the same rule): the checkpoint is only dropped once its steps are + // banked, so a kill anywhere in here re-runs the recovery rather than + // losing the bout. Replay is safe because of the coverage-window check + // below and because this method is single-flight. The one exception is a + // checkpoint that can never be recovered — dropped immediately so it + // can't be retried on every single connect forever. + Future drop() => prefs.remove(_kLiveSessionCheckpoint); + final m = jsonDecode(raw); + if (m is! Map) return await drop(); + final steps = (m['steps'] as num?)?.toInt() ?? 0; + final startTs = (m['cover_start_ts'] as num?)?.toInt(); + final endTs = (m['cover_end_ts'] as num?)?.toInt(); + if (steps <= 0 || startTs == null || endTs == null || endTs <= startTs) { + return await drop(); + } + final window = deriveLiveCoverageWindow( + steps: steps, + samples100Hz: (m['samples'] as num?)?.toInt() ?? 0, + bandStartTs: startTs, + bandEndTs: endTs, + firstIngestMs: (m['first_ingest_ms'] as num?)?.toInt(), + lastIngestMs: (m['last_ingest_ms'] as num?)?.toInt(), + ); + if (window == null) return await drop(); + // The clean-shutdown path writes coverage BEFORE clearing the checkpoint, + // so a kill in that gap leaves a checkpoint whose bout is already banked — + // and `live_coverage` has no uniqueness on the window, so replaying it + // would silently inflate the day. Skip anything already recorded. + if (await LocalDb.hasLiveCoverageWindow(window.startTs, window.endTs)) { + _log('[steps] orphan checkpoint already banked — not re-adding'); + return await drop(); + } + final day = dayLabelOf( + DateTime.fromMillisecondsSinceEpoch(window.startTs * 1000), + ); + await LocalDb.addLiveCoverage(window.startTs, window.endTs, steps, day); + await drop(); + _log('[steps] recovered $steps orphaned step(s) from a killed session'); + } catch (e) { + _log('[steps] orphan recovery skipped: $e'); + } + } + /// "Charge it now or lose tonight" — the one battery warning that is about /// DATA rather than about the battery. /// @@ -2355,6 +2526,14 @@ class AppState extends ChangeNotifier { // just wires the strap event stream + persistence + the fired notification. final AlarmConfirmation _alarm = AlarmConfirmation(); Timer? _alarmGraceTimer; + // Event 56 is a one-shot BLE notification — if that single packet gets + // dropped by an ordinary momentary disconnect right after the write (the + // band DID latch the alarm), there is no retry/re-poll for it and the + // GET_ALARM readback fallback is parked (unconfirmed format), so the app had + // no way to ever clear the "unconfirmed" warning short of the user + // re-sending the whole alarm. One silent, automatic re-arm covers that + // common case; only a still-unconfirmed retry falls through to the warning. + bool _alarmAutoRetried = false; /// The strap emitted ALARM_SET (event 56) — the alarm is confirmed armed. bool get alarmConfirmed => _alarm.confirmed; @@ -2391,17 +2570,59 @@ class AppState extends ChangeNotifier { _savedAlarm = epoch; device.alarmEpoch = epoch; // optimistic display _alarm.set(epoch, DateTime.now().millisecondsSinceEpoch); // await event 56 + _alarmAutoRetried = false; // fresh user-initiated set gets its one retry final prefs = await SharedPreferences.getInstance(); await prefs.setInt('alarm_epoch', epoch); // Nudge the UI once the grace window elapses so an unconfirmed alarm flips to // its soft warning even if no event ever arrives. + _armAlarmGraceTimer(when); + notifyListeners(); + } + + /// (Re)arm the "grace window elapsed" timer. One helper so the grace + /// duration and the retry wiring can't drift between the two call sites. + void _armAlarmGraceTimer(DateTime when) { _alarmGraceTimer?.cancel(); _alarmGraceTimer = Timer( Duration(milliseconds: _alarm.graceMs + 250), - () { - if (!_alarm.confirmed) notifyListeners(); - }, + () => unawaited(_onAlarmGraceElapsed(when)), ); + } + + /// Grace window elapsed with no event 56. Before showing the soft warning, + /// try ONE silent re-arm — if the strap really did latch it and only the + /// confirmation notification was dropped, this re-send gives it a second + /// chance to confirm without the user having to notice or do anything. + Future _onAlarmGraceElapsed(DateTime when) async { + if (_disposed || _alarm.confirmed) return; + final epoch = when.millisecondsSinceEpoch ~/ 1000; + // A newer alarm was armed while this timer was pending — that set owns the + // confirmation machine now; retrying the stale time would clobber it. + if (_savedAlarm != epoch) return; + if (_alarmAutoRetried || !isConnected) { + notifyListeners(); + return; + } + _alarmAutoRetried = true; + var rearmed = false; + try { + rearmed = await engine.setAlarm(when); + } catch (e) { + _log('[alarm] auto-retry re-arm failed: $e'); + } + // The write itself never landed, so the one retry was not actually spent — + // give it back rather than latching this alarm out of any future retry. + if (!rearmed) _alarmAutoRetried = false; + // dispose() ran while the write was in flight — do NOT create a timer it + // no longer has any chance to cancel (it would keep poking a torn-down + // engine on every fire). + if (_disposed) return; + // Re-check staleness after the await for the same reason as above. + if (rearmed && _savedAlarm == epoch && !_alarm.confirmed) { + _alarm.set(epoch, DateTime.now().millisecondsSinceEpoch); + _armAlarmGraceTimer(when); + return; + } notifyListeners(); } @@ -2633,8 +2854,13 @@ class AppState extends ChangeNotifier { // no-progress timer, so bursts ran long). The drain's correctness is // untouched: commit-before-ACK and the HISTORY_COMPLETE bookkeeping all // live inside the engine regardless of who awaits the report. - await engine.enableLiveStreams(); + // Recover any steps orphaned by a killed process, and zero the counters + // for this session, BEFORE live delivery starts. Doing it after + // enableLiveStreams() left a window where frames ingested during the + // (awaited, I/O-bound) recovery were then wiped by _resetLivePedometer. + await _recoverOrphanedLiveSession(); _resetLivePedometer(); // fresh live step count for this connected session + await engine.enableLiveStreams(); dbCounts = await LocalDb.counts(); unawaited( _kickSyncBurst(kickFirst: false).then((report) async { @@ -3211,7 +3437,7 @@ class AppState extends ChangeNotifier { /// model (refEnmo + cadence). Returns the learned cadence (spm), or null if the /// walk wasn't credible. Stops the stream we turned on. Future finishStepCalibration() async { - final steps = liveSteps; + final steps = _rawSessionSteps; // raw, never the display cushion final durS = _liveSamples / 100.0; final enmo = _liveEnmoN > 0 ? _liveEnmoSum / _liveEnmoN : 0.0; double? learned; diff --git a/lib/ui/today/today_screen.dart b/lib/ui/today/today_screen.dart index 7d450e0..3ddd18b 100644 --- a/lib/ui/today/today_screen.dart +++ b/lib/ui/today/today_screen.dart @@ -178,16 +178,16 @@ class _TodayScreenState extends State @override Widget build(BuildContext context) { - // SELECT the 3 fields _emptyOrProcessing actually reads, not the whole - // AppState — this screen used to fully rebuild on EVERY notifyListeners() - // (67 call sites incl. per-second timers and every derive-day callback), - // which is exactly what made a multi-day backfill/reanalyze visibly - // freeze this screen (several notifications in quick succession, each one - // forcing a full ListView rebuild while a screen switch might also be - // in flight). `app` itself is still the live, same-instance object (read, - // not watch) — only the REBUILD TRIGGER is now scoped. - context.select, bool, String)>( - (a) => (a.dbCounts, a.reanalyzing, a.reanalyzeProgress), + // SELECT the fields this screen actually reads, not the whole AppState — + // it used to fully rebuild on EVERY notifyListeners() (67 call sites incl. + // per-second timers and every derive-day callback), which is exactly what + // made a multi-day backfill/reanalyze visibly freeze this screen. `app` + // itself is still the live, same-instance object (read, not watch) — only + // the REBUILD TRIGGER is scoped. liveSteps is included (already rate- + // limited to ~1/s at the source) so the steps tile doesn't freeze mid-walk + // while waiting on an unrelated dbCounts change. + context.select, bool, String, int)>( + (a) => (a.dbCounts, a.reanalyzing, a.reanalyzeProgress, a.liveSteps), ); final app = context.read(); final t = TodayData.fromJson(data); diff --git a/test/ai_briefing_test.dart b/test/ai_briefing_test.dart index 2eee28d..9d4bd26 100644 --- a/test/ai_briefing_test.dart +++ b/test/ai_briefing_test.dart @@ -114,23 +114,36 @@ void main() { group('prompt building (pure)', () { test('system prompt scopes by period and forbids invention', () { - final m = briefingSystemPrompt(BriefingPeriod.morning, 'morning'); + final m = briefingSystemPrompt(BriefingPeriod.morning); expect(m, contains('ONLY the numbers provided')); expect(m.toLowerCase(), contains('sleep')); - final e = briefingSystemPrompt(BriefingPeriod.evening, 'evening'); + final e = briefingSystemPrompt(BriefingPeriod.evening); expect(e.toLowerCase(), contains('strain')); }); - test('greeting follows the clock, not the period (issue #134)', () { + test('greeting comes from the app at read time, never baked into the ' + 'model text (issue #134)', () { expect(partOfDay(DateTime(2026, 7, 22, 9)), 'morning'); expect(partOfDay(DateTime(2026, 7, 22, 15)), 'afternoon'); expect(partOfDay(DateTime(2026, 7, 22, 19)), 'evening'); expect(partOfDay(DateTime(2026, 7, 22, 23)), 'night'); - // The morning briefing is shown until 17:00, so an afternoon reader must - // be told it's afternoon and the prompt must never say "morning". - final sys = briefingSystemPrompt(BriefingPeriod.morning, 'afternoon'); - expect(sys, contains('currently afternoon')); - expect(sys.toLowerCase(), isNot(contains('morning'))); + // The morning briefing is shown until 17:00, so it can be generated at + // 5am and read at 4pm — the model must never be told to write a + // greeting/time-of-day reference at all, since that word gets cached + // and can't track the actual read time. This must hold regardless of + // which period is requested. + for (final period in BriefingPeriod.values) { + final sys = briefingSystemPrompt(period).toLowerCase(); + expect(sys, contains('do not open with a greeting')); + // No "currently " framing — that's the exact pattern + // that got baked into the cached text and read stale hours later. + expect(sys, isNot(contains('currently'))); + expect(sys, isNot(contains('good morning'))); + expect(sys, isNot(contains('good afternoon'))); + expect(sys, isNot(contains('good evening'))); + } + // The USER prompt still carries the real read-time context for the + // model to reason with, distinct from the system prompt's rules. final usr = buildBriefingUserPrompt( BriefingPeriod.morning, '2026-07-22', {'readiness': 74}, 'afternoon'); expect(usr, contains('afternoon')); diff --git a/test/crossday_pipeline_test.dart b/test/crossday_pipeline_test.dart index 22834cb..bb8361d 100644 --- a/test/crossday_pipeline_test.dart +++ b/test/crossday_pipeline_test.dart @@ -306,4 +306,46 @@ void main() { expect((load['atl'] as num).toDouble(), greaterThan(50.0)); }); }); + group('unsettled (today, not finalized) day scoping', () { + // Regression: today's unfinalized row used to be DROPPED from the input + // list entirely to keep it out of the illness CUSUM. That also removed it + // from readiness/glass-box, the resting-HR trend-shift CUSUM feed, load, + // sleep debt and `recent` — whose last row dates every notification. It + // must now stay in the series and only be nulled out of the alert inputs. + test('stays in `recent` (so notifications date to today)', () { + final days = _synthDays(30); + final lastDate = days.last['date'] as String; + days.last['unsettled'] = true; + + final bundle = buildCrossDayBundle(days, const {}); + final recent = bundle['recent'] as List; + + expect(recent.length, days.length); + expect((recent.last as Map)['date'], lastDate); + // The resting-HR trend-shift CUSUM reads `rhr` back off these rows. + expect((recent.last as Map)['rhr'], isNotNull); + }); + + test('does not drive the illness/anomaly alert', () { + // A sustained spike on the final days trips the flag when settled... + final settled = _synthDays(30, rhrSpikeLast: true); + expect(buildCrossDayBundle(settled, const {})['illness'], isNotNull); + + // ...and the SAME spike on a still-syncing today must not, because its + // inputs are withheld from the CUSUMs. + final unsettled = _synthDays(30, rhrSpikeLast: true); + unsettled.last['unsettled'] = true; + final bundle = buildCrossDayBundle(unsettled, const {}); + + final last = (bundle['recent'] as List).last as Map; + expect(last['illness'], isFalse); + expect(last['anomaly'], isFalse); + }); + + test('an all-settled series is unaffected by the flag plumbing', () { + final bundle = buildCrossDayBundle(_synthDays(30), const {}); + expect((bundle['recent'] as List).length, 30); + expect(bundle['n_days'], 30); + }); + }); }