Skip to content

fw/services/activity: keep short sleep cycles inside a fragmented night#1742

Open
ericmigi wants to merge 1 commit into
coredevices:mainfrom
ericmigi:ericmigi/firm-3473-sleep-tracker-incorrectly-shows-user-awake-overnight
Open

fw/services/activity: keep short sleep cycles inside a fragmented night#1742
ericmigi wants to merge 1 commit into
coredevices:mainfrom
ericmigi:ericmigi/firm-3473-sleep-tracker-incorrectly-shows-user-awake-overnight

Conversation

@ericmigi

Copy link
Copy Markdown
Collaborator

Fixes FIRM-3473

What changed

The Kraepelin sleep detector discarded any sleep cycle shorter than 60 minutes, so brief mid-night restlessness that split a night into fragments made genuinely-slept 20–59 min chunks display as "awake". Short cycles were doubly doomed: the running avg-VMC / active-pct checks include the trailing awake run, and vmc_clip can't stop that movement from dominating a short cycle's averages (14 clipped minutes alone push a 45-min cycle over the threshold), so they usually died before the length check even ran.

Now a short cycle is kept when it is sandwiched inside a night:

  • Average-based rejections are deferred for a cycle that starts within 30 min of the previous cycle's end while under 60 min.
  • At end-of-cycle validation, those checks re-run over the slept portion only (trailing awake run excluded). A cycle that slept ≥20 min and passes is held back as a pending cycle (with its deep-sleep sessions).
  • The pending cycle registers only once a following cycle is kept and starts within the same 30-min gap; otherwise it is dropped. Requiring sleep on both sides keeps post-wake stillness (dozing / reading in bed) from extending the night.

Why

Deep dive on FIRM-3473 (getafix, fw v4.27.0): overnight the firmware recorded two sleep sessions with a 71-min "awake" gap covering the disputed 3:31 AM. The logs show the algorithm detected sleep in that gap — including a 37-min deep-sleep run — then rejected the cycle (Session to delete not found at 10:59:01Z is the rejection's only trace; that warning fires exactly when a never-registered <60-min cycle is discarded). Full analysis in the Linear issue.

How I validated

  • ./waf test -M ".*kraepelin.*" — all captured reference nights pass unchanged except sleep_4, which gains a 23-min interior cycle the old code discarded (the exact reported bug shape, on real data). The post-wake phantom-sleep guard nights (sleep_7, sleep_8, pbl_27921, pbl_37734, watch_on_table*) are all bit-identical to baseline.
  • 5 new synthetic-night tests: mid-night short cycle kept; nightstand (flat/still) cycle still vetoed; far-gap cycle still rejected; tiny (<20 min) cycle still rejected; chained fragments kept (last fragment conservatively dropped).
  • All 12 activity suites + health/sleep/alarm suites pass.
  • Built and booted qemu_emery; firmware boots to launcher normally.
  • Surveyed ~70 sleep-tracking Linear issues before designing: phantom-sleep complaints (FIRM-1533, FIRM-2500, FIRM-1121) are as common as missed-sleep ones — hence the sandwich requirement instead of a naive length relaxation, which measurably regressed labeled reference nights (+33 to +81 min phantom sleep) when tried.

Known limitations (deliberate)

  • The last short fragment of a broken night is dropped (nothing follows to prove it was sleep) — indistinguishable from post-wake dozing without HRM.
  • The pending cycle registers when its follower reaches 60-min ongoing registration; a follower rejected after that point leaves the pending cycle registered. Undoing it would require deleting a non-ongoing session (asserts in activity_sessions.c) or breaking chronological session order.
  • The pre-existing too-short gate still uses elapsed minutes (including the awake tail), so a ~50-min-slept cycle with an 11-min tail passes as a full cycle, as before. Left untouched for scope/regression safety.

Emulator-only validation. Not yet run on hardware; the algorithm is platform-independent C exercised heavily by the host test suite, but a night of real-wrist data on getafix/obelix before release would be prudent.

🤖 Generated with Claude Code

The Kraepelin sleep detector discarded any sleep cycle shorter than
min_sleep_cycle_len_minutes (60), so when brief mid-night restlessness
split a night into fragments, a genuinely slept 20-59 minute chunk
displayed as "awake". In the reported incident the watch detected sleep
(including 37 minutes of deep sleep) between two recorded sessions and
then threw it away, showing the user awake for over an hour of real
sleep. The only trace was the misleading "Session to delete not found"
warning, which fires because rejected short cycles were never registered.

Short cycles were doubly doomed: the running avg-VMC/active-pct checks
include the trailing run of awake minutes, and vmc_clip cannot stop that
movement from dominating the averages of a short cycle (14 clipped
minutes push a 45-minute cycle's average over the threshold on their
own), so such cycles were usually rejected before the length check even
ran.

Keep a short cycle when it is sandwiched inside a night:

- Defer the average-based rejections for a cycle that starts within
  max_adjacent_cycle_gap_m (30) of the previous cycle's end while it is
  under 60 minutes, so it survives to end-of-cycle validation.
- At validation, re-apply those checks over the slept portion only
  (excluding the trailing awake run). If the cycle slept at least
  min_adjacent_cycle_len_m (20) and passes, hold it back as a pending
  cycle together with the deep sleep sessions found inside it.
- Register the pending cycle only once a following cycle is kept and
  starts within the same gap of its end; otherwise drop it. Requiring
  sleep on both sides keeps post-wake stillness (dozing or reading in
  bed at the end of the night) from extending sleep, which the captured
  reference nights sleep_7, sleep_8, pbl_27921 and pbl_37734 guard.

The pending cycle is registered when the follower reaches ongoing
registration (60 minutes); a follower rejected after that point leaves
the pending cycle registered. Accepted: the pending cycle passed the
worn and quality checks itself, deleting an already-registered
non-ongoing session would trip the ongoing-only assert in
activity_sessions.c, and registering later would break the chronological
session order the health UI relies on.

All captured reference nights pass unchanged except sleep_4, which gains
a 23-minute interior cycle that the old code discarded - the exact shape
of the reported bug, on real data. New synthetic-night tests cover the
kept mid-night cycle, nightstand rejection, far-gap rejection, tiny-cycle
rejection and chained fragments.

Fixes FIRM-3473

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Eric Migicovsky <eric@repebble.com>
@ericmigi
ericmigi marked this pull request as ready for review July 20, 2026 16:48
@ericmigi
ericmigi requested review from gmarull and jplexer as code owners July 20, 2026 16:48

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cdae4ae315

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// The deep sleep sessions were captured into the pending cycle; abort the live ones
prv_deep_sleep_update(alg_state, sample_utc, score, KAlgDeepSleepAction_Abort,
false /*ok_to_register*/, sessions_cb, context);
state->last_cycle_end_utc = sleep_end_time;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear rejected pending cycles as adjacency anchors

When a short cycle is held back, last_cycle_end_utc is advanced to that unconfirmed cycle. If that pending cycle is later dropped in prv_resolve_pending_cycle because the next candidate is rejected or too far away, this timestamp is not restored, so a subsequent short fragment that is within 30 minutes of the dropped fragment can still pass may_continue_night even though it no longer follows accepted sleep. This admits exactly the kind of post-wake/tiny/not-worn fragmentation the sandwich rule is trying to exclude; the accepted-cycle end and pending-cycle end need to be distinguished or the anchor reset when pending is forgotten.

Useful? React with 👍 / 👎.

@ericmigi

Copy link
Copy Markdown
Collaborator Author

@jplexer speculative fix - i'm not really sure how to test this?

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.

1 participant