Skip to content

Rework the sleep score around five contributors - #124

Open
ak710 wants to merge 2 commits into
saksham2001:mainfrom
ak710:feat/sleep-score-v2
Open

Rework the sleep score around five contributors#124
ak710 wants to merge 2 commits into
saksham2001:mainfrom
ak710:feat/sleep-score-v2

Conversation

@ak710

@ak710 ak710 commented Aug 3, 2026

Copy link
Copy Markdown

Closes #115

Merge order: 5 of 9. ⚠️ Stacked on #120 — GitHub can't set a cross-fork base, so this diff also contains #120's commit. Merge #120 first and this reduces to its own commit.

What

v1 scored duration (35), deep % (30), light % (20), awake % (15). Three of those four had problems:

  • Duration scored totalMinutes, which SleepSegmentation sets from end − start — time in bed. 8 h in bed with 90 min awake was credited as 8 h of sleep.
  • Light % used a band calibrated for a no-REM decoder. On a ring that tags REM separately the same night reads ~20 points lower and was scored harshly for a split that was correct.
  • Awake % scored 55% of its points when the ring gave no wake signal, quietly docking every jring night for a sensor it never had.

v2: duration (30, on total sleep time), deep (25), REM (20), restfulness (15), bedtime consistency (10).

Missing signals leave the denominator

The rule readiness already follows. A jring is scored out of 80, a first week out of 90, and coverage rides on the result. That's what makes one number comparable across hardware — testAnIdealNightScoresTheSameWithAndWithoutRemCoverage pins it.

Two deliberate omissions

  • Light is reported, not scored. Once deep and REM are both scored it's their residual; scoring it counts the same night twice.
  • Sleep efficiency is absent. With totalMinutes being time in bed, efficiency is exactly 1 − awake % — it would restate restfulness while looking like an independent sixth signal. Oura can score both because it separates total sleep time from time in bed using data these rings don't provide.

No migration needed

Worth flagging, because I expected the opposite: the production sync path (PulseEventBus, SleepSegmentation) creates every SleepSession with a nil score, and every screen calls SleepScore.calculate live from the stage blocks. Only demo data and imported archives carry a stored score. So v2 takes effect immediately with no recompute pass. algorithmVersion is stamped anyway, for a future change that does need one.

Testing

SleepScoreV2Tests — 15 tests. Full suite green (896). Docs: docs/project/sleep-score.md.

ak710 and others added 2 commits August 2, 2026 14:19
The Colmi big-data timeline (stage 0x04) and the YCBT timeline (tag 3) both
report REM, and both decoders have always stored it as a SleepStageBlock. It
just never got any further: SleepSummary carried light/deep/awake only, so REM
never reached the sleep score, the Sleep tab, or the coach — which was still
hard-coded to tell the model "light/deep/awake only, no REM" on every ring.

Carry remMinutes through SleepSummary, collapseByDay and averageStages, and
report it as remPct. Nil rather than zero throughout when the ring reported no
REM stage at all: a jring's 0x11 timeline genuinely has none, and "absent" and
"you slept no REM" are different claims.

This also fixes a scoring side-effect. hasAwakeSignal falls back to asking
whether the stage timeline accounted for essentially the whole night, and REM
was excluded from that sum — so a fully described REM night looked only ~80%
covered, failed the 0.95 gate, and had its awake reading thrown away as "no
signal", costing it 45% of the awake sub-score. REM now counts toward coverage.

REM is measured but still not scored: the light band (ideal 50-60%) is
calibrated for a no-REM decoder that lumps REM into light, so re-weighting the
score would move every stored night and needs the versioned recompute readiness
does. Noted in SleepScore.calculate for the sleep-score rework.

The coach's caveat is now chosen per night from that night's own blocks rather
than from the connected ring's capabilities — stored nights outlive the ring
that recorded them, so switching rings must not retro-actively disclaim last
week's REM.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
v1 scored duration (35), deep % (30), light % (20) and awake % (15). Three of
those four had problems.

Duration scored SleepSession.totalMinutes, which SleepSegmentation sets from
end - start — time in bed, not time asleep. A night with 8h in bed and 90 min
awake was credited as 8h of sleep. Awake minutes now come off the top.

Light % was scored against a band (ideal 50-60%) calibrated for a no-REM decoder
that lumps REM minutes into light. On a ring that tags REM separately, the same
night reads ~20 points lower and was scored harshly for a split that was
correct. Light is now the residual of deep and REM, so it is reported and not
scored — scoring it counted the same night twice.

Awake % was scored at 55% of its points when the ring gave no usable wake
signal, which quietly docked every jring night for a sensor it never had.
Missing signals now leave the denominator instead, exactly as readiness does: a
jring is scored out of 80, a first week out of 90, and coverage is reported
alongside the number. That is what makes one score comparable across hardware —
an ideal night now scores the same on both.

REM joins as a contributor (20 pts, ideal 20-25%), and bedtime consistency as a
new one (10 pts), computed from the user's own median bedtime over the previous
14 nights on an axis wrapped around midnight, so 23:40 and 00:20 average to
midnight rather than noon. The night being scored is excluded from its own
baseline — including it would drag the median toward it and forgive exactly the
drift the contributor exists to notice.

Sleep efficiency is deliberately absent: with totalMinutes being time in bed it
works out to exactly 1 - awake %, so it would restate restfulness while looking
like an independent sixth signal.

No migration needed. The production sync path stores every SleepSession with a
nil score and every screen computes live from the stage blocks, so v2 took
effect immediately; only demo and imported data carry a stored score.
algorithmVersion is stamped anyway, for a future change that does need one.

Full contributor table, thresholds and rationale in docs/project/sleep-score.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

[Feature]: Sleep score scores time in bed as if it were time asleep

1 participant