Skip to content

fix(today): don't flash a stale readiness before today's settles#121

Merged
abdulsaheel merged 2 commits into
OpenStrap:mainfrom
dannymcc:fix/readiness-flash-100
Jul 21, 2026
Merged

fix(today): don't flash a stale readiness before today's settles#121
abdulsaheel merged 2 commits into
OpenStrap:mainfrom
dannymcc:fix/readiness-flash-100

Conversation

@dannymcc

@dannymcc dannymcc commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Problem

On the Today screen the big READINESS ring shows a wrong score on load/refresh: it flashes 100 ("Primed", green) and then snaps to the true value (e.g. 73, orange) — and, when today is still settling, it can keep jumping to ~100 and back. The settled score is correct; the transient 100 is the bug.

Root cause

Two distinct paths both let a not-yet-final readiness reach the headline ring:

  1. Held-over prior night (building window). While today's overnight is still building, getToday deliberately surfaces the last settled night's readiness (showing_prior_overnight) so the rest of the screen has data. The orbit hero rendered t.readiness.value directly, so it headlined the prior night's number until today's settled, then snapped.

  2. Ready→ready saturation bounce. A day stays recomputable for ~48h and re-derives on every BLE drain, so today's readiness can change value between renders while overnight_state stays ready the whole time — a gate on the ready state alone can't catch it. The composite maps its weighted robust-z to a score via a logistic, score = 100 / (1 + exp(-z)). robustZ only returns null on exact-zero MAD (fully-quantised baseline → the honest blank ). A near-degenerate baseline — duplicate-day pollution collapsing the trailing window toward one repeated value — has a tiny non-zero MAD, so robustZ does not null: it returns a huge z, the logistic saturates to ~100, and that bogus value is persisted and headlined until a cleaner re-derive snaps it back. That is the observed "keeps jumping to 100 and back".

Fix

Two complementary guards, both on the compute→render seam:

  • Building-window guard (UI model). TodayData.settledReadinessScore only returns a score once today's overnight has settled (overnight_state == 'ready'); while building/missing, any readiness present is a held-over prior night and is withheld. The orbit hero and the once-a-morning recovery story route through it. Status-less synthetic payloads keep their previous behaviour, so existing Today widget tests are unaffected.

  • Saturation guard (compute seam). headlineReadinessScalar / kReadinessZCap (onehz_pipeline.dart) abstain from a saturated readiness: if the composite is present but |composite z| exceeds the cap it persists absent (null) instead of the ~100. The cap is 5, chosen physiologically — a real composite z is a weighted mean of per-input robust-z's, so even a flawless morning (every input at +2 z) lands at z=2 → score 88, and +3 z all-round is z=3 → 95; z=5 is score 99.3, unreachable by a genuine reading. So the cap suppresses only the degenerate rail and hides zero legitimate scores, on every surface (ring, home-screen widget, AI briefing).

Tests

  • test/readiness_flash_test.dart — the building-window gate: settled → surfaced; building/missing → withheld; absent → null; status-less → unchanged.
  • test/readiness_saturation_test.dart — the saturation gate: a near-degenerate (tiny-non-zero-MAD) baseline saturates the composite to >99 and is abstained; a clean baseline surfaces its real score untouched; a ready→ready sequence (saturated derive then clean) never headlines the saturated value; exact-zero MAD stays the honest blank path.

Notes

  • flutter/dart were not available in the environment this was developed in, so flutter analyze/flutter test were not run here and the runtime flash was not reproduced on a device — verified by inspection and the unit tests above. CI is the gate.
  • Known remaining gap: these guards guarantee no wrong saturated headline on any surface. A residual value↔ flicker is still possible if degenerate and clean derives alternate (the number briefly disappears rather than showing a wrong value). Fully removing that requires the sibling fix/readiness-baseline-pollution branch, which eliminates the degenerate baselines at source (de-duplicates the rolling window so MAD never collapses). The two are complementary.

Closes #117

Summary by CodeRabbit

  • Bug Fixes
    • Prevented the Readiness score from briefly displaying outdated or pending overnight values.
    • Readiness now appears only when overnight data is settled and valid.
    • Suppressed implausibly saturated scores instead of displaying misleading near-100 values.
    • Updated recovery insights to use the confirmed Readiness score, improving consistency across the Today screen.

The readiness ring briefly showed a wrong score (e.g. 100) on load/refresh and
then snapped to the true value. While today's overnight is still building,
getToday surfaces the last settled night's readiness (showing_prior_overnight)
so the rest of the screen has data; the orbit hero rendered that held-over value
as the big number, so it flashed the prior night's score until today's real one
landed.

Add TodayData.settledReadinessScore, which withholds the number until today's
overnight has actually settled (overnight_state == 'ready'), and route both the
hero and the once-a-morning recovery story through it. Until then the hero shows
its honest empty/loading centre instead of a bogus figure.

Status-less synthetic payloads keep their existing behaviour, so the current
Today widget tests are unaffected; new unit tests pin the gate.
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Readiness headlines and recovery content now use a settled score that excludes pending or missing overnight data and saturated composite values. The Today screen and compute pipeline include regression coverage for transient flashes, saturation, valid scores, and absent metrics.

Changes

Readiness stabilization

Layer / File(s) Summary
Settled readiness contract and UI integration
lib/models/payloads.dart, lib/ui/today/today_screen.dart, test/readiness_flash_test.dart
settledReadinessScore gates rounded readiness by overnight status; recovery story content and the orbit hero now consume the gated value, with coverage for supported payload states.
Saturated readiness abstention
lib/compute/onehz_pipeline.dart, test/readiness_saturation_test.dart
Readiness scalars are withheld when composite z-scores exceed kReadinessZCap, while valid scores remain available and saturated or zero-MAD cases return null.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested reviewers: abdulsaheel

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address issue #117 by gating readiness display until settled, preventing the transient 100 placeholder from appearing.
Out of Scope Changes check ✅ Passed The added saturation guard and regression tests are still aligned with the readiness-flash fix and do not appear unrelated.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: preventing stale readiness from flashing before today’s value settles.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

…bounce to 100

The READINESS ring could keep jumping to ~100 and back while today's overnight
was already `ready`. A day stays recomputable ~48h and re-derives on every BLE
drain, so today's readiness can change value between renders with the state
`ready` throughout — the settled-state gate can't catch it.

The composite maps its weighted robust-z to a score via a logistic,
score = 100 / (1 + exp(-z)). robustZ nulls only on exact-zero MAD (fully
quantised → honest blank). A near-degenerate baseline (duplicate-day pollution
collapsing the window toward one value) has a tiny NON-zero MAD, so robustZ
returns a huge z, the logistic saturates to ~100, and that bogus value is
persisted and headlined until a cleaner re-derive snaps it back.

Add headlineReadinessScalar / kReadinessZCap: abstain from the headline scalar
when |composite z| exceeds a physiological cap (5 → score 99.3, unreachable by a
real reading; a flawless +2z-all-round morning is only z=2/score 88). This hides
zero legitimate scores and stops the bogus 100 on every surface (ring, widget,
briefing). Removing the degenerate baselines at source is the sibling
fix/readiness-baseline-pollution work; this is the belt-and-braces guard.
@abdulsaheel
abdulsaheel merged commit eab2473 into OpenStrap:main Jul 21, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Readiness score briefly flashes 100 before settling to the true value

2 participants