Rework the sleep score around five contributors - #124
Open
ak710 wants to merge 2 commits into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
totalMinutes, whichSleepSegmentationsets fromend − start— time in bed. 8 h in bed with 90 min awake was credited as 8 h of sleep.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 —
testAnIdealNightScoresTheSameWithAndWithoutRemCoveragepins it.Two deliberate omissions
totalMinutesbeing time in bed, efficiency is exactly1 − 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 everySleepSessionwith a nil score, and every screen callsSleepScore.calculatelive from the stage blocks. Only demo data and imported archives carry a stored score. So v2 takes effect immediately with no recompute pass.algorithmVersionis stamped anyway, for a future change that does need one.Testing
SleepScoreV2Tests— 15 tests. Full suite green (896). Docs:docs/project/sleep-score.md.