Summary
The headline readiness score changes during the day. A user (RR, 22 Jul 2026) reported: "in the morning my readiness score was 49, now it changed to 45." Readiness is meant to be a stable morning score — a daytime drift is confusing and reads as a bug.
This is the "Gap 1" residual that was explicitly left open after #117 / #121. #121 stopped the ring flashing a prior night's value before today's overnight settled; it did not stop today's own value from moving after it has settled (a ready → ready recompute).
Mechanism (confirmed by code inspection)
Today's day-result stays recomputable for ~48 h and is re-derived (with today prioritised) on essentially every BLE drain, re-persisting a fresh readiness scalar each time — while overnight_state stays ready throughout.
_finalizationSec = 48 * 3600 — a day only finalises (locks) ~48 h after its wake — lib/compute/derivation_engine.dart:257
- Finalisation is anchored on the data edge, not wall-clock:
ageFinalized = (day.endSec + _finalizationSec) < dataNowSec — lib/compute/derivation_engine.dart:1599-1600. So for the whole of "today" the day is not finalised and stays in the pending set (pending = raw days not in finalizedDayIds) — derivation_engine.dart:1096-1100.
- Light-derive then re-derives today on each drain:
selectLightDeriveDays(...) returns (days: [today], reason: 'today-priority') whenever raw has reached today — derivation_engine.dart:263-272 (the today-priority branch, :268-269), invoked at :1109-1114.
- Each derive overwrites the persisted readiness scalar:
putDayResult(..., readiness: sc('readiness'), series: {... 'readiness': sc('readiness') ...}) — derivation_engine.dart:1605-1621.
- The recomputed value legitimately moves between renders because its inputs accrete during the morning: (a) more of last night's flash drains in, so the overnight window / HRV becomes more complete, and (b) the trailing readiness baseline (median/MAD z) shifts as recent days append/finalise (
_baselineWindowDays, rescanRecent baseline-dirty rescan — derivation_engine.dart:259-260, 1133-1147). Composite readiness = HRV ∩ sleep ∩ dip ∩ arousal (lib/models/payloads.dart:128-129), all z-scored against that moving baseline.
Net: readiness.value for today is recomputed and re-saved repeatedly through the morning, so the number the user sees can go 49 → 45 with no state transition. RR's report is almost certainly exactly this.
Why #117 / #121 do not cover it
#121 added TodayData.settledReadinessScore, which withholds the headline only while today's overnight hasn't settled:
int? get settledReadinessScore {
if (readiness.isEmpty) return null;
final s = ...; // TodayStatus
if (s != null && !s.overnightReady) return null; // building/missing → withhold
return readiness.value!.round(); // ready → pass CURRENT value straight through
}
Once overnight_state == 'ready' (lib/data/db.dart:3289-3291, set once and stays ready), the gate simply returns readiness.value!.round() — whatever the latest re-derive produced. It gates building/saturation, not a ready→ready value change, so by construction it cannot prevent this drift. (Note: the current orbit hero renders readiness.value.round() directly — lib/ui/today/today_screen.dart:740-741 — so the raw drift is visible regardless of the gate.)
Suggested fix (for discussion — not implemented)
Make the headline readiness stable once first settled for the day, while letting other metrics keep updating:
- Option A (recommended): freeze the first ready-state readiness for the day. On the first derive where today's
overnight_state == 'ready', persist that readiness as the day's headline (e.g. a readiness_settled field / cursor keyed on the day) and have the hero + once-a-morning recovery story read the frozen value. Later re-derives still update the full day_result, baselines, trends, and finalisation — only the headline number is pinned.
- Risks to guard: must NOT freeze a value that was computed on a still-partial overnight (freeze only when the overnight window is genuinely complete, not merely
ready); must NOT hide a legitimately-absent day (freeze applies only once a real score exists); decide product intent for a genuinely improved score later in the day (A treats stability as the priority — matches "morning score" mental model).
- Option B: gate the headline on the derive being finalised/stable (e.g.
partial == false and overnight window complete) rather than just overnightReady. Simpler, but delays the first shown number and doesn't fully pin it until finalisation.
Recommendation: Option A (freeze-first-ready), medium-high confidence on the mechanism/diagnosis; medium confidence on the exact fix shape — the freeze trigger needs to be "overnight window complete", not just ready, and the desired behaviour for a legitimately-improved same-day score is a product call.
Needs on-device confirmation
No Flutter/Dart SDK or device in the investigation environment — this is code inspection only. To confirm end-to-end: capture a morning where the app is opened early (partial overnight drain) and again a few hours later on the same physical device, logging the persisted readiness scalar per derive and the overnight_state, and confirm the value moves while state stays ready.
Refs: #117, #121.
Summary
The headline readiness score changes during the day. A user (RR, 22 Jul 2026) reported: "in the morning my readiness score was 49, now it changed to 45." Readiness is meant to be a stable morning score — a daytime drift is confusing and reads as a bug.
This is the "Gap 1" residual that was explicitly left open after #117 / #121. #121 stopped the ring flashing a prior night's value before today's overnight settled; it did not stop today's own value from moving after it has settled (a
ready → readyrecompute).Mechanism (confirmed by code inspection)
Today's day-result stays recomputable for ~48 h and is re-derived (with today prioritised) on essentially every BLE drain, re-persisting a fresh
readinessscalar each time — whileovernight_statestaysreadythroughout._finalizationSec = 48 * 3600— a day only finalises (locks) ~48 h after its wake —lib/compute/derivation_engine.dart:257ageFinalized = (day.endSec + _finalizationSec) < dataNowSec—lib/compute/derivation_engine.dart:1599-1600. So for the whole of "today" the day is not finalised and stays in the pending set (pending= raw days not infinalizedDayIds) —derivation_engine.dart:1096-1100.selectLightDeriveDays(...)returns(days: [today], reason: 'today-priority')whenever raw has reached today —derivation_engine.dart:263-272(thetoday-prioritybranch,:268-269), invoked at:1109-1114.putDayResult(..., readiness: sc('readiness'), series: {... 'readiness': sc('readiness') ...})—derivation_engine.dart:1605-1621._baselineWindowDays,rescanRecentbaseline-dirty rescan —derivation_engine.dart:259-260, 1133-1147). Composite readiness = HRV ∩ sleep ∩ dip ∩ arousal (lib/models/payloads.dart:128-129), all z-scored against that moving baseline.Net:
readiness.valuefor today is recomputed and re-saved repeatedly through the morning, so the number the user sees can go 49 → 45 with no state transition. RR's report is almost certainly exactly this.Why #117 / #121 do not cover it
#121 added
TodayData.settledReadinessScore, which withholds the headline only while today's overnight hasn't settled:Once
overnight_state == 'ready'(lib/data/db.dart:3289-3291, set once and staysready), the gate simply returnsreadiness.value!.round()— whatever the latest re-derive produced. It gates building/saturation, not a ready→ready value change, so by construction it cannot prevent this drift. (Note: the current orbit hero rendersreadiness.value.round()directly —lib/ui/today/today_screen.dart:740-741— so the raw drift is visible regardless of the gate.)Suggested fix (for discussion — not implemented)
Make the headline readiness stable once first settled for the day, while letting other metrics keep updating:
overnight_state == 'ready', persist that readiness as the day's headline (e.g. areadiness_settledfield / cursor keyed on the day) and have the hero + once-a-morning recovery story read the frozen value. Later re-derives still update the full day_result, baselines, trends, and finalisation — only the headline number is pinned.ready); must NOT hide a legitimately-absent day (freeze applies only once a real score exists); decide product intent for a genuinely improved score later in the day (A treats stability as the priority — matches "morning score" mental model).partial == falseand overnight window complete) rather than justovernightReady. Simpler, but delays the first shown number and doesn't fully pin it until finalisation.Recommendation: Option A (freeze-first-ready), medium-high confidence on the mechanism/diagnosis; medium confidence on the exact fix shape — the freeze trigger needs to be "overnight window complete", not just
ready, and the desired behaviour for a legitimately-improved same-day score is a product call.Needs on-device confirmation
No Flutter/Dart SDK or device in the investigation environment — this is code inspection only. To confirm end-to-end: capture a morning where the app is opened early (partial overnight drain) and again a few hours later on the same physical device, logging the persisted
readinessscalar per derive and theovernight_state, and confirm the value moves while state staysready.Refs: #117, #121.