fix(insights): circular sleep-midpoint distance and explicit efficiency overlap

The sleep-score consistency + timing sub-scores measured the midpoint as a
linear minutes-of-day difference, so a sleeper whose midpoint straddles
midnight (and any night across a DST shift) read a spurious ~24-hour spread
that inflated the SD and timing distance. Measure the midpoint on the
circular 24-hour clock (mod 1440) — a circular mean for the habitual midpoint
and a circular distance for the SD and timing offset.

Efficiency clamped asleep > in-bed (overlapping HealthKit stages) silently to
100, masking a data problem. Handle the overlap explicitly: a small overshoot
caps at the AASM ceiling of 100 (asleep cannot exceed time in bed), a gross
overshoot drops the sub-score because the in-bed denominator is not usable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-02 23:40:24 +02:00
1 parent cd8e34dac7
commit fc6924d52f
2 files changed
+120 -18

No files matched your search

@@ -15,6 +15,8 @@ import {
scoreComposition,
scoreConsistency,
scoreTiming,
circularMinuteDistance,
circularMeanMinutes,
type SleepSubScoreKey,
} from "../sleep-score";
@@ -51,6 +53,15 @@ describe("pure sleep scorers", () => {
expect(scoreEfficiency(420, 480)).toBe(88);
});
it("efficiency handles asleep > in-bed overlap explicitly, not via a blind clamp", () => {
// A small overshoot (≤ 5 %, overlapping HK stages) caps at the AASM
// ceiling of 100 rather than reporting > 100 %.
expect(scoreEfficiency(485, 480)).toBe(100); // ~101 % → 100
// A gross overshoot means the in-bed denominator is not usable — drop the
// sub-score (null) rather than masking the data problem with a fake 100.
expect(scoreEfficiency(600, 480)).toBeNull(); // 125 % → unusable
});
it("composition is null without a stage breakdown, 100 inside the band", () => {
expect(scoreComposition(0, 0, 420, false)).toBeNull();
// REM 90 + Deep 60 = 150 of 420 ≈ 0.357 — inside [0.33, 0.48].
@@ -66,6 +77,39 @@ describe("pure sleep scorers", () => {
expect(scoreTiming(180, null, 1)).toBeNull();
expect(scoreTiming(180, 180, 5)).toBe(100);
});
it("circular distance wraps across midnight (mod 1440)", () => {
// 23:50 (1430) and 00:10 (10) are 20 min apart, not 1420.
expect(circularMinuteDistance(1430, 10)).toBe(20);
expect(circularMinuteDistance(10, 1430)).toBe(20);
// Within-day distance unchanged.
expect(circularMinuteDistance(100, 160)).toBe(60);
});
it("circular mean of midnight-straddling midpoints stays near midnight", () => {
// 23:50, 00:10, 00:00 cluster around midnight; a linear mean would
// collapse to ~08:00 (480). The circular mean stays near 0/1440.
const mean = circularMeanMinutes([1430, 10, 0])!;
const distFromMidnight = Math.min(mean, 1440 - mean);
expect(distFromMidnight).toBeLessThan(20);
});
it("consistency does NOT penalise a midnight-straddling sleeper", () => {
// Tight cluster around midnight: circular SD is small → high score. A
// linear SD would read a spurious ~24-h spread → 0.
const score = scoreConsistency([1430, 10, 0, 1435, 5])!;
expect(score).toBeGreaterThan(80);
});
it("timing uses circular distance across the wrap", () => {
// Night midpoint 00:05 (5) vs habitual 23:55 (1435) is 10 min off (NOT
// 1430), so the score stays high. A linear distance would read ~1430 →
// clamp to 0.
const wrapped = scoreTiming(5, 1435, 5)!;
expect(wrapped).toBeGreaterThan(85);
// 10 min off the habitual midpoint within the day scores identically.
expect(scoreTiming(100, 110, 5)).toBe(wrapped);
});
});
describe("blendSleepSubScores reweighting", () => {
+76 -18
View File
@@ -121,9 +121,20 @@ export function scoreSufficiency(
}
/**
* Efficiency: asleep ÷ in-bed, rescaled so the AASM 85 % "good" floor
* maps to ~85 and 100 % maps to 100. Null when in-bed is unknown (legacy
* nights with only asleep stages and no IN_BED row).
* Efficiency: asleep ÷ in-bed (AASM convention; ≥ 85 % is the clinical
* "good" floor). Null when in-bed is unknown (legacy nights with only asleep
* stages and no IN_BED row).
*
* Overlap handling: HealthKit can report ASLEEP-class stages whose summed
* minutes exceed the explicit IN_BED total when stages overlap (the watch
* writes per-sample stage rows and an IN_BED block that don't tile cleanly).
* Treating that as a real ratio yields efficiency > 100 %, which a blind
* `clamp(…, 100)` would silently swallow as a perfect night and hide the
* data problem. Instead, treat asleep > in-bed as the impossible-overlap case
* it is: cap efficiency at 100 % (asleep cannot exceed time in bed) but only
* when the overshoot is within a small tolerance — a gross overshoot signals
* a malformed night with no trustworthy in-bed denominator, so we drop the
* sub-score (null) rather than report a fabricated 100.
*/
export function scoreEfficiency(
asleepMinutes: number,
@@ -131,6 +142,14 @@ export function scoreEfficiency(
): number | null {
if (inBedMinutes == null || inBedMinutes <= 0) return null;
const pct = (asleepMinutes / inBedMinutes) * 100;
if (pct > 100) {
// A small overshoot (≤ 5 %) is rounding / stage-boundary noise — asleep
// cannot truly exceed in-bed, so cap at the AASM ceiling of 100. A larger
// overshoot means the in-bed total is not a usable denominator; the night
// has no honest efficiency, so it drops from the blend.
const EFFICIENCY_OVERSHOOT_TOLERANCE = 105;
return pct <= EFFICIENCY_OVERSHOOT_TOLERANCE ? 100 : null;
}
return clamp100(pct);
}
@@ -158,27 +177,67 @@ export function scoreComposition(
return clamp100(100 * (1 - dist / halfWidth));
}
const MINUTES_PER_DAY = 1440;
/**
* Consistency: lower SD of the sleep midpoint (minutes-of-day) across the
* window → higher score. A 90-min SD maps to 0, a 0-min SD to 100. Null
* below 3 nights (no variance signal).
* Shortest distance between two minutes-of-day on the 24-hour clock, treating
* the day as circular (mod 1440). A midpoint at 23:50 (1430) and one at 00:10
* (10) are 20 minutes apart, NOT 1420 — the linear difference inflates timing
* SD and distance for sleepers whose midpoint straddles midnight (and on a DST
* shift). Pure.
*/
export function circularMinuteDistance(a: number, b: number): number {
const raw = Math.abs(a - b) % MINUTES_PER_DAY;
return Math.min(raw, MINUTES_PER_DAY - raw);
}
/**
* Circular mean of minutes-of-day via the mean resultant vector (the standard
* directional-statistics circular mean) — averaging clock minutes linearly
* collapses a midnight-straddling cluster to noon. Returns null on an empty
* input or a near-zero resultant (no defined mean direction). Pure.
*/
export function circularMeanMinutes(minutes: number[]): number | null {
if (minutes.length === 0) return null;
let sumSin = 0;
let sumCos = 0;
for (const m of minutes) {
const angle = (m / MINUTES_PER_DAY) * 2 * Math.PI;
sumSin += Math.sin(angle);
sumCos += Math.cos(angle);
}
if (Math.abs(sumSin) < 1e-9 && Math.abs(sumCos) < 1e-9) return null;
let meanAngle = Math.atan2(sumSin, sumCos);
if (meanAngle < 0) meanAngle += 2 * Math.PI;
return (meanAngle / (2 * Math.PI)) * MINUTES_PER_DAY;
}
/**
* Consistency: lower circular SD of the sleep midpoint (minutes-of-day)
* across the window → higher score. A 90-min SD maps to 0, a 0-min SD to 100.
* Null below 3 nights (no variance signal). The SD is computed against the
* CIRCULAR mean using circular distance so a midnight-straddling sleeper is
* not penalised for a spurious ~24-hour spread.
*/
export function scoreConsistency(midpointMinutes: number[]): number | null {
if (midpointMinutes.length < 3) return null;
const mean =
midpointMinutes.reduce((s, v) => s + v, 0) / midpointMinutes.length;
const mean = circularMeanMinutes(midpointMinutes);
if (mean == null) return null;
const variance =
midpointMinutes.reduce((s, v) => s + (v - mean) ** 2, 0) /
midpointMinutes.length;
midpointMinutes.reduce(
(s, v) => s + circularMinuteDistance(v, mean) ** 2,
0,
) / midpointMinutes.length;
const sd = Math.sqrt(variance);
const FULL_SCALE = 90; // minutes — beyond this the rhythm reads erratic.
return clamp100(100 * (1 - Math.min(sd, FULL_SCALE) / FULL_SCALE));
}
/**
* Timing: this night's midpoint vs the user's habitual midpoint (the
* window mean). On-target → 100; 90 min off → 0. Null when the habitual
* window is not yet established (< 3 nights).
* Timing: this night's midpoint vs the user's habitual midpoint (the window
* circular mean). On-target → 100; 90 min off → 0. Null when the habitual
* window is not yet established (< 3 nights). Uses circular distance so a
* midnight-straddling night is measured by its true clock offset.
*/
export function scoreTiming(
nightMidpoint: number | null,
@@ -189,7 +248,7 @@ export function scoreTiming(
return null;
}
const FULL_SCALE = 90;
const dist = Math.abs(nightMidpoint - habitualMidpoint);
const dist = circularMinuteDistance(nightMidpoint, habitualMidpoint);
return clamp100(100 * (1 - Math.min(dist, FULL_SCALE) / FULL_SCALE));
}
@@ -406,10 +465,9 @@ export async function computeSleepScore(
const midpoints = scorableNights
.map((n) => n.midpoint)
.filter((m): m is number => m != null);
const habitualMidpoint =
midpoints.length > 0
? midpoints.reduce((s, v) => s + v, 0) / midpoints.length
: null;
// Circular mean (mod 1440) so a midnight-straddling sleeper's habitual
// midpoint is not dragged to noon by a linear average.
const habitualMidpoint = circularMeanMinutes(midpoints);
const needMinutes = sleepNeedMinutes(profile.ageYears);