Skip to content

Readiness score drifts during the day (ready→ready recompute) #128

Description

@dannymcc

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) < dataNowSeclib/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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions