Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions lib/compute/derivation_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,49 @@ const int _finalizationSec = 48 * 3600;
/// How many trailing derived days feed readiness/composite baselines.
const int _baselineWindowDays = 28;

/// Readiness is meant to be a stable MORNING score, but a day stays recomputable
/// for ~48 h (`_finalizationSec`) and every re-derive overwrites the persisted
/// readiness scalar. As the night's flash finishes draining and the trailing
/// 28-day baseline shifts, the surfaced value legitimately drifts through the
/// day (#128: "morning it was 49, now 45"). Once today's overnight is genuinely
/// COMPLETE we PIN the first such readiness as the headline so it stops moving.
///
/// "Complete" must be stronger than `overnight_state == 'ready'` — that flips as
/// soon as the FIRST sleep-bearing row lands (mid-drain), so pinning on it could
/// freeze a partial-night value. We instead require the drained data edge to
/// have moved at least this far PAST the sleep offset (wake): the whole sleep
/// window is then decoded and the segmentation-placed wake is settled, so the
/// overnight inputs are final. Same "edge past the window" model finalisation
/// uses, anchored at wake+margin rather than wake+48 h. Conservative but still
/// reached within the first post-wake sync in practice; raise it to trade a
/// slightly later freeze for more safety margin.
const int _headlineFreezeMarginSec = 60 * 60;

/// The frozen morning readiness headline that should be persisted/surfaced for
/// [today], given the current pin and a fresh look at today's live readiness and
/// whether today's overnight is genuinely COMPLETE. Pure so the freeze semantics
/// are unit-tested without the derive/DB machinery.
///
/// - Not yet a complete overnight → no pin (the headline tracks the live value).
/// - First complete overnight → pin the live value.
/// - Later same-day looks → keep the FIRST pin (the whole point: no daytime
/// drift — a re-derive that would RAISE or LOWER the score is ignored).
/// - A new day → the prior day's pin no longer applies; re-pins once the new
/// day's overnight completes.
@visibleForTesting
({String day, int value})? nextFrozenHeadline({
required String today,
required bool overnightComplete,
required int? liveReadiness,
required ({String day, int value})? current,
}) {
if (current != null && current.day == today) return current; // pinned; hold
if (overnightComplete && liveReadiness != null) {
return (day: today, value: liveReadiness); // first complete settle → pin
}
return null; // nothing to pin yet for today
}

/// Test seam: the rolling baseline window the readiness computation actually
/// runs against, loaded exactly as production does. Exposed to assert the read
/// path ignores a polluted `rolling_artifact` and rebuilds from `metric_series`.
Expand Down Expand Up @@ -1684,6 +1727,41 @@ class DerivationEngine {
'derived ${day.date} v$kAlgoVersion '
'(sleep=${day.sleepOffsetSec > day.sleepOnsetSec}, final=$finalized)',
);
await _maybeFreezeHeadlineReadiness(day, dataNowSec, sc('readiness'));
}

/// Pin today's morning readiness headline once its overnight is genuinely
/// COMPLETE, so the Today hero + recovery story stop drifting through the day
/// (#128). Only the headline is pinned — this row's `day_result`, the
/// baselines, trends and finalisation all keep updating on later re-derives.
Future<void> _maybeFreezeHeadlineReadiness(
PreparedDerivationDay day,
int dataNowSec,
double? readiness,
) async {
// Only today's headline is pinned. Historical/backfill + imported days never
// reach the Today hero, and imports are immutable snapshots anyway.
if (day.date != todayLabel()) return;
final hasSleep = day.sleepOffsetSec > day.sleepOnsetSec;
if (!hasSleep) return;
final overnightComplete =
dataNowSec >= day.sleepOffsetSec + _headlineFreezeMarginSec;
final current = await LocalDb.frozenHeadline();
final next = nextFrozenHeadline(
today: day.date,
overnightComplete: overnightComplete,
liveReadiness: readiness?.round(),
current: current,
);
if (next == null) return;
// Already pinned to this exact value → skip the redundant write.
if (current != null &&
current.day == next.day &&
current.value == next.value) {
return;
}
await LocalDb.setFrozenHeadline(next.day, next.value);
_log('froze headline readiness ${next.value} for ${next.day}');
}

void _logSpo2Diagnostics(
Expand Down
26 changes: 26 additions & 0 deletions lib/data/db.dart
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,32 @@ class LocalDb {
}, conflictAlgorithm: ConflictAlgorithm.replace);
}

/// Cursor holding the FROZEN morning readiness headline (see #128): the
/// day-tagged value pinned once today's overnight first settles on a genuinely
/// complete night, so the Today hero + recovery story stop drifting through
/// the day. Day-tagged so it survives restarts and is ignored on a new day.
static const String kFrozenHeadlineCursor = 'frozen_headline';

/// The pinned morning readiness headline (day + value), or null if unset /
/// unparseable. The `day` must be compared to today's label by the caller — a
/// pin left over from a previous day must NOT be surfaced.
static Future<({String day, int value})?> frozenHeadline() async {
final raw = await getCursor(kFrozenHeadlineCursor);
if (raw == null || raw.isEmpty) return null;
try {
final d = jsonDecode(raw);
if (d is Map && d['day'] is String && d['value'] is num) {
return (day: d['day'] as String, value: (d['value'] as num).round());
}
} catch (_) {/* malformed → treat as unset */}
return null;
}

/// Pin [value] as the frozen readiness headline for [day] (overwrites any
/// prior pin — first-complete-settle-per-day is enforced by the caller).
static Future<void> setFrozenHeadline(String day, int value) =>
setCursor(kFrozenHeadlineCursor, jsonEncode({'day': day, 'value': value}));

/// Persist a sync batch atomically: the raw records, their samples, AND the
/// continuation cursor in ONE transaction. This is the durable half of the
/// safe-trim invariant — it MUST return before the engine writes the ACK frame.
Expand Down
15 changes: 14 additions & 1 deletion lib/data/local_repository_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -238,9 +238,22 @@ class LocalRepositoryImpl extends LocalRepository {
// Readiness/recovery: when the composite abstains for lack of baseline, the
// envelope carries a `need_baseline:have=H,need=N` note. Pass that note
// through so the hero can render "Need N more nights" instead of a number.
final readinessScalar = showOvernight
var readinessScalar = showOvernight
? _scalar(sleepBundle, 'readiness')
: null;
// FROZEN MORNING HEADLINE (#128): once today's overnight first settled on a
// genuinely complete night, the derive pinned that readiness. Surface the
// pin so the hero + once-a-morning recovery story stop drifting as the day's
// re-derives (more daytime data, a shifting baseline) move the live scalar.
// ONLY the headline is pinned — every other metric below still reflects the
// latest re-derive. Gated to today's OWN overnight (`ready`, matching day)
// so a prior-night fallback or a stale yesterday pin can never leak in.
if (overnightState == 'ready') {
final pin = await LocalDb.frozenHeadline();
if (pin != null && pin.day == todayDay) {
readinessScalar = pin.value.toDouble();
}
}
final readinessNote = readinessScalar == null && showOvernight
? _needNote(sleepBundle, 'clinical.readiness_composite')
: null;
Expand Down
137 changes: 137 additions & 0 deletions test/readiness_freeze_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// Regression tests for readiness DRIFTING through the day (#128 — "morning it
// was 49, now 45").
//
// Root cause: a day stays recomputable for ~48 h and every re-derive overwrites
// the persisted readiness scalar; as the night's flash finishes draining and the
// trailing 28-day baseline shifts, the surfaced value legitimately moves. The fix
// FREEZES the morning headline once today's overnight is genuinely COMPLETE and
// pins that first value for the rest of the day.
//
// `nextFrozenHeadline` is the pure decision at the heart of that freeze. These
// tests pin its semantics: the headline does NOT move after the first
// complete-overnight settle, and a new day resets it.

import 'package:flutter_test/flutter_test.dart';
import 'package:openstrap_edge/compute/derivation_engine.dart';

void main() {
group('nextFrozenHeadline', () {
const d1 = '2026-07-21';
const d2 = '2026-07-22';

test('before a complete overnight → no pin (headline tracks the live value)',
() {
final pin = nextFrozenHeadline(
today: d1,
overnightComplete: false,
liveReadiness: 49,
current: null,
);
expect(pin, isNull);
});

test('first complete overnight → pins the live value', () {
final pin = nextFrozenHeadline(
today: d1,
overnightComplete: true,
liveReadiness: 49,
current: null,
);
expect(pin, isNotNull);
expect(pin!.day, d1);
expect(pin.value, 49);
});

test('absent readiness at completion → still no pin', () {
final pin = nextFrozenHeadline(
today: d1,
overnightComplete: true,
liveReadiness: null,
current: null,
);
expect(pin, isNull);
});

test(
'ready→ready with a changing value → the frozen headline does NOT move '
'after the first complete settle', () {
// Morning: the overnight completes and 49 is pinned.
var frozen = nextFrozenHeadline(
today: d1,
overnightComplete: true,
liveReadiness: 49,
current: null,
);
expect(frozen, isNotNull);
expect(frozen!.value, 49);

// Midday re-derive: baseline shifted, the live value dropped to 45 — the
// exact drift from #128. The pin must hold.
frozen = nextFrozenHeadline(
today: d1,
overnightComplete: true,
liveReadiness: 45,
current: frozen,
);
expect(frozen!.value, 49, reason: 'a lower re-derive must not move it');

// Afternoon re-derive that would RAISE it: still pinned (product call —
// stability wins over a same-day improvement).
frozen = nextFrozenHeadline(
today: d1,
overnightComplete: true,
liveReadiness: 53,
current: frozen,
);
expect(frozen!.value, 49, reason: 'a higher re-derive must not move it');
});

test('a new day → the prior pin is dropped and re-pins on completion', () {
final day1Pin = (day: d1, value: 49);

// New day, overnight not complete yet → the day-1 pin no longer applies
// (getToday also guards by day, but the decision drops it too).
var frozen = nextFrozenHeadline(
today: d2,
overnightComplete: false,
liveReadiness: 61,
current: day1Pin,
);
expect(frozen, isNull, reason: 'yesterday\'s pin must not carry over');

// Day-2 overnight completes → a fresh pin for the new day.
frozen = nextFrozenHeadline(
today: d2,
overnightComplete: true,
liveReadiness: 61,
current: day1Pin,
);
expect(frozen, isNotNull);
expect(frozen!.day, d2);
expect(frozen.value, 61);
});

test(
'settling before completion then completing → pins the COMPLETE-night '
'value, not the partial one', () {
// Early morning: overnight present but not yet complete → no pin; the live
// partial-night 70 is shown but never frozen.
var frozen = nextFrozenHeadline(
today: d1,
overnightComplete: false,
liveReadiness: 70,
current: null,
);
expect(frozen, isNull);

// Once the night is genuinely complete the (now different) value pins.
frozen = nextFrozenHeadline(
today: d1,
overnightComplete: true,
liveReadiness: 66,
current: frozen,
);
expect(frozen!.value, 66, reason: 'the complete-night value is pinned');
});
});
}