diff --git a/messages/de.json b/messages/de.json index e5629cc51..6425dcc38 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6891,6 +6891,8 @@ "recoveryGapLagSymptom": "Dir ging es {days} Tage besser, bevor deine erfassten Symptome abklangen.", "recoveryGapLeadSymptom": "Deine erfassten Symptome klangen {days} Tage ab, bevor du Genesung vermerktest.", "recoveryGapSame": "Deine Werte normalisierten sich am selben Tag, an dem es dir besser ging.", + "sleepContextMore": "Während dieser Episode hast du deutlich mehr geschlafen als sonst (etwa {hours} Std./Nacht).", + "sleepContextLess": "Während dieser Episode hast du deutlich weniger geschlafen als sonst (etwa {hours} Std./Nacht).", "redFlagTitle": "Genauer ansehen", "redFlagSpo2": "Deine Sauerstoffsättigung blieb mehrere Tage niedrig. Wenn das erneut auftritt, suche ärztliche Hilfe.", "redFlagFever": "Du hattest anhaltend Fieber. Wenn das erneut auftritt, suche ärztliche Hilfe.", diff --git a/messages/en.json b/messages/en.json index a44932a9f..e010dba9a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6891,6 +6891,8 @@ "recoveryGapLagSymptom": "You felt better {days} days before your logged symptoms eased.", "recoveryGapLeadSymptom": "Your logged symptoms eased {days} days before you marked recovery.", "recoveryGapSame": "Your numbers settled the same day you felt better.", + "sleepContextMore": "During this episode you slept noticeably more than usual (about {hours}h/night).", + "sleepContextLess": "During this episode you slept noticeably less than usual (about {hours}h/night).", "redFlagTitle": "Worth a closer look", "redFlagSpo2": "Your oxygen saturation stayed low for several days. If this recurs, seek medical care.", "redFlagFever": "You ran a sustained fever. If this recurs, seek medical care.", diff --git a/messages/es.json b/messages/es.json index e8f6b9456..4f7cdf34a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6891,6 +6891,8 @@ "recoveryGapLagSymptom": "Te sentiste mejor {days} días antes de que cedieran tus síntomas registrados.", "recoveryGapLeadSymptom": "Tus síntomas registrados cedieron {days} días antes de marcar la recuperación.", "recoveryGapSame": "Tus valores se estabilizaron el mismo día en que te sentiste mejor.", + "sleepContextMore": "Durante este episodio dormiste notablemente más de lo habitual (unas {hours} h/noche).", + "sleepContextLess": "Durante este episodio dormiste notablemente menos de lo habitual (unas {hours} h/noche).", "redFlagTitle": "Conviene revisarlo", "redFlagSpo2": "Tu saturación de oxígeno se mantuvo baja varios días. Si se repite, busca atención médica.", "redFlagFever": "Tuviste fiebre sostenida. Si se repite, busca atención médica.", diff --git a/messages/fr.json b/messages/fr.json index 1c58b4827..e669ee63e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6891,6 +6891,8 @@ "recoveryGapLagSymptom": "Tu t'es senti mieux {days} jours avant que tes symptômes consignés s'apaisent.", "recoveryGapLeadSymptom": "Tes symptômes consignés se sont apaisés {days} jours avant que tu notes la guérison.", "recoveryGapSame": "Tes valeurs se sont stabilisées le jour même où tu t'es senti mieux.", + "sleepContextMore": "Pendant cet épisode, tu as dormi nettement plus que d'habitude (environ {hours} h/nuit).", + "sleepContextLess": "Pendant cet épisode, tu as dormi nettement moins que d'habitude (environ {hours} h/nuit).", "redFlagTitle": "À examiner de plus près", "redFlagSpo2": "Ta saturation en oxygène est restée basse plusieurs jours. Si cela se reproduit, consulte un médecin.", "redFlagFever": "Tu as eu une fièvre persistante. Si cela se reproduit, consulte un médecin.", diff --git a/messages/it.json b/messages/it.json index 6778c0ec7..e68135dfa 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6891,6 +6891,8 @@ "recoveryGapLagSymptom": "Ti sei sentito meglio {days} giorni prima che i sintomi registrati si attenuassero.", "recoveryGapLeadSymptom": "I tuoi sintomi registrati si sono attenuati {days} giorni prima di segnare la guarigione.", "recoveryGapSame": "I tuoi valori si sono stabilizzati lo stesso giorno in cui ti sei sentito meglio.", + "sleepContextMore": "Durante questo episodio hai dormito molto più del solito (circa {hours} h/notte).", + "sleepContextLess": "Durante questo episodio hai dormito molto meno del solito (circa {hours} h/notte).", "redFlagTitle": "Da guardare più da vicino", "redFlagSpo2": "La tua saturazione di ossigeno è rimasta bassa per diversi giorni. Se si ripete, rivolgiti a un medico.", "redFlagFever": "Hai avuto febbre persistente. Se si ripete, rivolgiti a un medico.", diff --git a/messages/pl.json b/messages/pl.json index 638f8a520..54ae78cff 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6891,6 +6891,8 @@ "recoveryGapLagSymptom": "Poczułeś się lepiej {days} dni przed ustąpieniem zapisanych objawów.", "recoveryGapLeadSymptom": "Twoje zapisane objawy ustąpiły {days} dni przed oznaczeniem wyzdrowienia.", "recoveryGapSame": "Twoje wartości ustabilizowały się tego samego dnia, gdy poczułeś się lepiej.", + "sleepContextMore": "Podczas tego epizodu spałeś zauważalnie więcej niż zwykle (około {hours} godz./noc).", + "sleepContextLess": "Podczas tego epizodu spałeś zauważalnie mniej niż zwykle (około {hours} godz./noc).", "redFlagTitle": "Warto przyjrzeć się bliżej", "redFlagSpo2": "Twoje wysycenie tlenem pozostawało niskie przez kilka dni. Jeśli się powtórzy, zasięgnij pomocy medycznej.", "redFlagFever": "Miałeś utrzymującą się gorączkę. Jeśli się powtórzy, zasięgnij pomocy medycznej.", diff --git a/src/components/illness/illness-correlation-card.tsx b/src/components/illness/illness-correlation-card.tsx index 57e937154..4edec9d0b 100644 --- a/src/components/illness/illness-correlation-card.tsx +++ b/src/components/illness/illness-correlation-card.tsx @@ -41,6 +41,11 @@ function DeviationRow({ d }: { d: IllnessVitalDeviation }) { ); } +/** Round asleep-minutes to a friendly hours figure (one decimal). */ +function hoursFromMinutes(minutes: number): string { + return (Math.round((minutes / 60) * 10) / 10).toString(); +} + function CorrelationBody({ value }: { value: IllnessCorrelationValue }) { const { t } = useTranslations(); const gap = value.recoveryGapDays; @@ -48,6 +53,9 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) { // the symptoms ("…before your symptoms eased"); otherwise the existing // vital-driven phrasing. Clinically calm — observation, never prediction. const symptomDriven = value.gapDriverType === "FUNCTIONAL_IMPACT"; + // Sleep-as-context: a neutral observation subordinate to the gap, withheld by + // the engine on thin data — never a recovery claim, never a second gap. + const sleep = value.sleepContext; return (
@@ -90,6 +98,16 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) { ) : t("illness.correlation.recoveryGapSame")}

+ {sleep ? ( +

+ {t( + sleep.deltaMinutes > 0 + ? "illness.correlation.sleepContextMore" + : "illness.correlation.sleepContextLess", + { hours: hoursFromMinutes(Math.abs(sleep.deltaMinutes)) }, + )} +

+ ) : null}
) : null} diff --git a/src/components/illness/types.ts b/src/components/illness/types.ts index 581dcd3fd..4f87580e5 100644 --- a/src/components/illness/types.ts +++ b/src/components/illness/types.ts @@ -111,6 +111,19 @@ export interface IllnessRedFlag { days: number; } +/** + * A neutral sleep-as-context observation for the episode — slept more / less + * than usual — surfaced ALONGSIDE the gap, never part of the recovery-gap + * number. Null when the sleep data is too thin or the delta sub-floor. + */ +export interface SleepContext { + baselineMeanMinutes: number; + episodeMeanMinutes: number; + /** `episode − baseline` asleep minutes (positive = slept more than usual). */ + deltaMinutes: number; + nightsCounted: number; +} + export interface IllnessCorrelationValue { episodeId: string; preOnset: IllnessVitalDeviation[]; @@ -126,6 +139,11 @@ export interface IllnessCorrelationValue { */ gapDriverType: string | null; redFlags: IllnessRedFlag[]; + /** + * Sleep-as-context observation, or null when withheld (thin data / sub-floor + * delta). Additive — never part of the headline recovery-gap. + */ + sleepContext: SleepContext | null; } /** The flat `Derived` wire shape the correlation route returns. */ diff --git a/src/lib/illness/__tests__/correlation-read.test.ts b/src/lib/illness/__tests__/correlation-read.test.ts index 8850bc813..1bbd00b9a 100644 --- a/src/lib/illness/__tests__/correlation-read.test.ts +++ b/src/lib/illness/__tests__/correlation-read.test.ts @@ -65,6 +65,9 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({ vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ readBestGranularityRollups: rollup.readBestGranularityRollups, })); +vi.mock("@/lib/rollups/measurement-read", () => ({ + loadUserSourcePriority: vi.fn(async () => null), +})); import { computeEpisodeCorrelation } from "../correlation-read"; import { ILLNESS_SCAN_TYPES } from "../correlation"; @@ -302,3 +305,120 @@ describe("computeEpisodeCorrelation — contamination-guard read window", () => } }); }); + +describe("computeEpisodeCorrelation — sleep-as-context read", () => { + it("reconstructs baseline + episode nights and surfaces the observation, deduping multi-source nights", async () => { + rollup.probeRollupCoverage.mockResolvedValue( + new Map( + ILLNESS_SCAN_TYPES.map((t) => [String(t), false]), + ), + ); + rollup.readBestGranularityRollups.mockResolvedValue(null); + + // A banded RHR vital (flat-ish at 55) clears the engine coverage floor. + const rhrRows: Array<{ value: number; measuredAt: Date }> = []; + { + let cursor = Date.parse("2025-12-05T12:00:00Z"); + const jit = [-2, -1, 0, 1, 2]; + for (let i = 0; i < 40; i++) { + rhrRows.push({ + value: 55 + jit[i % jit.length], + measuredAt: new Date(cursor), + }); + cursor += 24 * 60 * 60 * 1000; + } + } + + // Sleep rows. Baseline nights ~420 min (7h); episode nights ~510 min + // (8.5h) → +90 min. The episode 2026-01-11 night is DOUBLE-WRITTEN by two + // sources (WHOOP + Apple Health); the canonical reconstruction must count + // it ONCE, not sum it to ~1020 min and inflate the delta. + // A "night" is one asleep block whose wake-day is the local calendar day. + type SleepRow = { + value: number; + measuredAt: Date; + sleepStage: null; + source: string; + deviceType: string | null; + }; + const sleepRows: SleepRow[] = []; + const asleepNight = ( + wakeDay: string, + minutes: number, + source: string, + ): SleepRow => ({ + // The block ENDS at 07:00Z on the wake-day; one ASLEEP sample of `minutes`. + value: minutes, + measuredAt: new Date(`${wakeDay}T07:00:00Z`), + sleepStage: null, + source, + deviceType: source, + }); + // Baseline nights (well before the pre-onset lookback start 2026-01-03). + for (const day of [ + "2025-12-20", + "2025-12-21", + "2025-12-22", + "2025-12-23", + "2025-12-24", + "2025-12-25", + ]) { + sleepRows.push(asleepNight(day, 420, "WHOOP")); + } + // Episode nights (active span ≥ onset 2026-01-10). + sleepRows.push(asleepNight("2026-01-10", 510, "WHOOP")); + // Double-written night — same wake-day, two writers. + sleepRows.push(asleepNight("2026-01-11", 510, "WHOOP")); + sleepRows.push(asleepNight("2026-01-11", 510, "APPLE_HEALTH")); + sleepRows.push(asleepNight("2026-01-12", 510, "WHOOP")); + + db.measurement.findMany.mockImplementation( + async ({ + where, + }: { + where: { + type: string; + measuredAt: { gte: Date; lt?: Date; lte?: Date }; + }; + }) => { + const from = where.measuredAt.gte.getTime(); + const to = + where.measuredAt.lt?.getTime() ?? + where.measuredAt.lte?.getTime() ?? + NOW.getTime(); + if (where.type === "RESTING_HEART_RATE") { + return rhrRows.filter( + (r) => + r.measuredAt.getTime() >= from && r.measuredAt.getTime() <= to, + ); + } + if (where.type === "SLEEP_DURATION") { + return sleepRows.filter( + (r) => + r.measuredAt.getTime() >= from && r.measuredAt.getTime() <= to, + ); + } + return []; + }, + ); + + const episode = { + id: "ep-sleep", + onsetAt: new Date("2026-01-10T08:00:00Z"), + resolvedAt: null as Date | null, + lifecycle: "ACUTE", + }; + + const out = await computeEpisodeCorrelation("u1", episode, "UTC", NOW); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const sleep = out.value.sleepContext; + expect(sleep).not.toBeNull(); + expect(sleep?.baselineMeanMinutes).toBe(420); + // The double-written 01-11 night is counted ONCE → episode mean ≈ 510, not + // a doubled ~680 that summing two writers would produce. + expect(sleep?.episodeMeanMinutes).toBe(510); + expect(sleep?.deltaMinutes).toBe(90); + expect(sleep?.nightsCounted).toBe(3); + }); +}); diff --git a/src/lib/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts index fd5a855b3..e2fe3a3d6 100644 --- a/src/lib/illness/__tests__/correlation.test.ts +++ b/src/lib/illness/__tests__/correlation.test.ts @@ -20,7 +20,9 @@ import { isAdverseDeviation, MIN_BASELINE_DAYS, MIN_EPISODE_COVERAGE_DAYS, + SLEEP_CONTEXT_MIN_DELTA_MINUTES, type IllnessCorrelationInput, + type SleepNightPoint, type SymptomBurdenPoint, type VitalDayPoint, } from "../correlation"; @@ -810,3 +812,343 @@ describe("computeIllnessCorrelation — symptom-burden return track", () => { ).toBeUndefined(); }); }); + +describe("computeIllnessCorrelation — relapse-aware return (last sustained)", () => { + it("reports the LAST sustained return when the vital relapses mid-episode", () => { + // Deviate 01-10, settle in-band 01-13..01-15 (a 3-day run that the OLD + // first-return rule would have called the return), re-deviate 01-16..01-18, + // then settle again 01-20..01-22 and hold. felt-better 01-14. The honest + // answer is the SECOND settle (01-20), not the first — the relapse pushes + // the return later and extends the gap. + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 75 }, // first deviation + { day: "2026-01-12", mean: 74 }, + { day: "2026-01-13", mean: 55 }, // first settle (old return) + { day: "2026-01-14", mean: 55 }, + { day: "2026-01-15", mean: 55 }, // 3-day in-band run + { day: "2026-01-16", mean: 75 }, // RELAPSE + { day: "2026-01-17", mean: 74 }, + { day: "2026-01-18", mean: 73 }, + { day: "2026-01-20", mean: 55 }, // final settle start + { day: "2026-01-21", mean: 55 }, + { day: "2026-01-22", mean: 55 }, // holds to end + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [{ type, baselineDays, episodeDays }], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.returns[0]?.returnedDay).toBe("2026-01-20"); + // Gap = 01-20 − 01-14 = +6 (the old first-return would have given −1). + expect(out.value.recoveryGapDays).toBe(dayDiff("2026-01-14", "2026-01-20")); + expect(out.value.recoveryGapDays).toBe(6); + }); + + it("reports the LAST sustained symptom return when the curve relapses", () => { + // Parity with the vital track on the symptom curve: impact eases to 0 for 3 + // logged days, flares back to 2, eases to 0 again for 3 logged days → the + // return is the SECOND easing, not the first. + const rhr: MeasurementType = "RESTING_HEART_RATE"; + const rhrBaseline = jitteredBaseline(55, 1, 21, "2026-01-02"); + const rhrFlat: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 55 }, + { day: "2026-01-11", mean: 55 }, + { day: "2026-01-12", mean: 55 }, + { day: "2026-01-13", mean: 55 }, + { day: "2026-01-14", mean: 55 }, + ]; + const symptomBurden: SymptomBurdenPoint[] = [ + { day: "2026-01-10", impact: 3 }, + { day: "2026-01-12", impact: 2 }, + { day: "2026-01-13", impact: 0 }, // first easing + { day: "2026-01-14", impact: 0 }, + { day: "2026-01-15", impact: 0 }, // 3 logged in-band days + { day: "2026-01-16", impact: 2 }, // FLARE + { day: "2026-01-18", impact: 0 }, // final easing start + { day: "2026-01-19", impact: 0 }, + { day: "2026-01-20", impact: 0 }, // 3 logged in-band days, holds + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat }, + ], + symptomBurden, + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const sym = out.value.returns.find( + (r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY, + ); + expect(sym?.returnedDay).toBe("2026-01-18"); + expect(sym?.gapDays).toBe(dayDiff("2026-01-14", "2026-01-18")); + expect(out.value.recoveryGapDays).toBe(4); + }); + + it("withholds when the final in-band run is too short after a relapse", () => { + // Deviate, settle, relapse, then only 2 in-band days to the window end — + // the final run is shorter than RETURN_STABILITY_DAYS, so no return is + // fabricated off the too-short tail. + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 75 }, // deviation + { day: "2026-01-12", mean: 55 }, // settle + { day: "2026-01-13", mean: 55 }, + { day: "2026-01-14", mean: 55 }, // 3-day run + { day: "2026-01-16", mean: 75 }, // RELAPSE + { day: "2026-01-17", mean: 74 }, + { day: "2026-01-19", mean: 55 }, // only 2 in-band days to end + { day: "2026-01-20", mean: 55 }, + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [{ type, baselineDays, episodeDays }], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.returns[0]?.returnedDay).toBeNull(); + expect(out.value.recoveryGapDays).toBeNull(); + }); + + it("picks the FINAL settle across multiple relapses", () => { + // settle → flare → settle → flare → settle: the last contiguous in-band + // span wins regardless of how many intervening cycles. + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 75 }, // deviation + { day: "2026-01-11", mean: 55 }, // settle 1 + { day: "2026-01-12", mean: 55 }, + { day: "2026-01-13", mean: 55 }, + { day: "2026-01-14", mean: 75 }, // flare 1 + { day: "2026-01-15", mean: 55 }, // settle 2 + { day: "2026-01-16", mean: 55 }, + { day: "2026-01-17", mean: 55 }, + { day: "2026-01-18", mean: 75 }, // flare 2 + { day: "2026-01-20", mean: 55 }, // FINAL settle start + { day: "2026-01-21", mean: 55 }, + { day: "2026-01-22", mean: 55 }, // holds to end + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [{ type, baselineDays, episodeDays }], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.returns[0]?.returnedDay).toBe("2026-01-20"); + expect(out.value.recoveryGapDays).toBe(6); + }); + + it("returns null when the episode ends out-of-band after a relapse", () => { + // Deviate, settle, relapse, never resolve (ends elevated) → no sustained + // final return → null gap. Honest: it did not finally settle in-window. + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 75 }, // deviation + { day: "2026-01-12", mean: 55 }, // settle + { day: "2026-01-13", mean: 55 }, + { day: "2026-01-14", mean: 55 }, // 3-day run + { day: "2026-01-16", mean: 75 }, // RELAPSE + { day: "2026-01-18", mean: 74 }, + { day: "2026-01-20", mean: 73 }, // still elevated at the end + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [{ type, baselineDays, episodeDays }], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.returns[0]?.returnedDay).toBeNull(); + expect(out.value.recoveryGapDays).toBeNull(); + }); +}); + +describe("computeIllnessCorrelation — sleep-as-context observation", () => { + // A banded RHR vital clears the engine coverage floor in every case; the + // sleep context is computed independently from the night arrays. + const rhr: MeasurementType = "RESTING_HEART_RATE"; + const rhrBaseline = jitteredBaseline(55, 1, 21, "2026-01-02"); + const rhrFlat = flatBaseline(55, 5, "2026-01-15"); + + /** N baseline nights at `minutes`, ending the day before onset. */ + function nights( + minutes: number, + count: number, + endDay: string, + ): SleepNightPoint[] { + const out: SleepNightPoint[] = []; + let cursor = Date.parse(`${endDay}T00:00:00Z`); + for (let i = 0; i < count; i++) { + out.unshift({ + day: new Date(cursor).toISOString().slice(0, 10), + asleepMinutes: minutes, + }); + cursor -= 24 * 60 * 60 * 1000; + } + return out; + } + + it("surfaces a slept-more observation when the episode delta clears the floor", () => { + // Baseline ~420 min (7h), episode ~510 min (8.5h) → +90 min, well past the + // 30-min floor. Episode nights are inside the active span. + const out = computeIllnessCorrelation( + input({ + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat }, + ], + baselineNights: nights(420, 6, "2026-01-02"), + episodeNights: [ + { day: "2026-01-10", asleepMinutes: 510 }, + { day: "2026-01-11", asleepMinutes: 505 }, + { day: "2026-01-12", asleepMinutes: 515 }, + ], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.sleepContext).not.toBeNull(); + expect(out.value.sleepContext?.baselineMeanMinutes).toBe(420); + expect(out.value.sleepContext?.deltaMinutes).toBeGreaterThanOrEqual( + SLEEP_CONTEXT_MIN_DELTA_MINUTES, + ); + expect(out.value.sleepContext?.nightsCounted).toBe(3); + // It never touches the recovery-gap. + expect(out.value.recoveryGapDays).toBeNull(); + }); + + it("withholds when the delta is below the magnitude floor", () => { + // Baseline 420, episode 430 → +10 min, sub-floor jitter → no observation. + const out = computeIllnessCorrelation( + input({ + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat }, + ], + baselineNights: nights(420, 6, "2026-01-02"), + episodeNights: [ + { day: "2026-01-10", asleepMinutes: 430 }, + { day: "2026-01-11", asleepMinutes: 425 }, + { day: "2026-01-12", asleepMinutes: 435 }, + ], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.sleepContext).toBeNull(); + }); + + it("withholds when too few episode nights are scorable", () => { + const out = computeIllnessCorrelation( + input({ + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat }, + ], + baselineNights: nights(420, 6, "2026-01-02"), + episodeNights: [ + { day: "2026-01-10", asleepMinutes: 520 }, + { day: "2026-01-11", asleepMinutes: 525 }, // only 2 episode nights + ], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.sleepContext).toBeNull(); + }); + + it("withholds when the baseline night count is too thin", () => { + const out = computeIllnessCorrelation( + input({ + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat }, + ], + baselineNights: nights(420, 3, "2026-01-02"), // only 3 baseline nights + episodeNights: [ + { day: "2026-01-10", asleepMinutes: 520 }, + { day: "2026-01-11", asleepMinutes: 525 }, + { day: "2026-01-12", asleepMinutes: 515 }, + ], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.sleepContext).toBeNull(); + }); + + it("surfaces a slept-less observation with a negative delta", () => { + const out = computeIllnessCorrelation( + input({ + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat }, + ], + baselineNights: nights(480, 6, "2026-01-02"), + episodeNights: [ + { day: "2026-01-10", asleepMinutes: 360 }, + { day: "2026-01-11", asleepMinutes: 370 }, + { day: "2026-01-12", asleepMinutes: 350 }, + ], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.sleepContext?.deltaMinutes).toBeLessThanOrEqual( + -SLEEP_CONTEXT_MIN_DELTA_MINUTES, + ); + }); + + it("excludes pre-onset nights from the episode mean", () => { + // A pre-onset night (before onsetDay 2026-01-10) must not count toward the + // episode mean — only the active-span nights do. + const out = computeIllnessCorrelation( + input({ + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat }, + ], + baselineNights: nights(420, 6, "2026-01-02"), + episodeNights: [ + { day: "2026-01-08", asleepMinutes: 999 }, // pre-onset — must be dropped + { day: "2026-01-10", asleepMinutes: 510 }, + { day: "2026-01-11", asleepMinutes: 510 }, + { day: "2026-01-12", asleepMinutes: 510 }, + ], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.sleepContext?.episodeMeanMinutes).toBe(510); + expect(out.value.sleepContext?.nightsCounted).toBe(3); + }); +}); diff --git a/src/lib/illness/__tests__/retrospective.test.ts b/src/lib/illness/__tests__/retrospective.test.ts index f9dc38125..3cb4d062e 100644 --- a/src/lib/illness/__tests__/retrospective.test.ts +++ b/src/lib/illness/__tests__/retrospective.test.ts @@ -15,6 +15,30 @@ import { MIN_TYPICAL_GAP_MAGNITUDE_DAYS, type RetrospectiveEpisode, } from "../retrospective"; +import { computeIllnessCorrelation, type VitalDayPoint } from "../correlation"; + +/** A baseline with a non-zero, stable MAD (mirrors the correlation suite). */ +function jitteredBaseline( + center: number, + jitter: number, + days: number, + endDay: string, +): VitalDayPoint[] { + const out: VitalDayPoint[] = []; + let cursor = Date.parse(`${endDay}T00:00:00Z`); + const offsets = [-jitter, -jitter / 2, 0, jitter / 2, jitter]; + for (let i = 0; i < days; i++) { + out.unshift({ + day: new Date(cursor).toISOString().slice(0, 10), + mean: center, + }); + cursor -= 24 * 60 * 60 * 1000; + } + return out.map((p, i) => ({ + ...p, + mean: center + offsets[i % offsets.length], + })); +} function ep(over: Partial): RetrospectiveEpisode { return { @@ -207,4 +231,68 @@ describe("summarizeIllnessRetrospective", () => { ]); expect(out.typicalRecoveryGapDays).toBe(-3); }); + + it("threads the larger last-return gap through the cross-episode median", () => { + // Integration guard for the relapse-aware (last-sustained) return change: + // one episode relapses mid-course, so its ENGINE-computed gap grows from + // the old first-return value (−1) to the last-return value (+6). The + // retrospective consumes that engine number verbatim — confirm the larger + // value pulls the cross-episode median up rather than the stale first + // return. No retrospective code changes; this guards the integration. + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const relapseEpisode: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 75 }, // first deviation + { day: "2026-01-13", mean: 55 }, // first settle (old return) + { day: "2026-01-14", mean: 55 }, + { day: "2026-01-15", mean: 55 }, // 3-day run + { day: "2026-01-16", mean: 75 }, // RELAPSE + { day: "2026-01-18", mean: 73 }, + { day: "2026-01-20", mean: 55 }, // final settle start + { day: "2026-01-21", mean: 55 }, + { day: "2026-01-22", mean: 55 }, // holds to end + ]; + const engine = computeIllnessCorrelation({ + episodeId: "relapse", + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [ + { + type: "RESTING_HEART_RATE", + baselineDays, + episodeDays: relapseEpisode, + }, + ], + source: "DAY", + now: new Date("2026-02-01T00:00:00Z"), + }); + expect(engine.status).toBe("ok"); + if (engine.status !== "ok") return; + // Last-return = 01-20, gap = +6 (not the old first-return −1). + expect(engine.value.recoveryGapDays).toBe(6); + + const out = summarizeIllnessRetrospective([ + ep({ + id: "relapse", + recoveryGapDays: engine.value.recoveryGapDays, + gapMeasurementDays: engine.coverage.historyDays, + gapReturnTypes: ["RESTING_HEART_RATE"], + }), + ep({ + id: "b", + recoveryGapDays: 4, + gapReturnTypes: ["RESTING_HEART_RATE"], + }), + ep({ + id: "c", + recoveryGapDays: 5, + gapReturnTypes: ["RESTING_HEART_RATE"], + }), + ]); + // Median of [6, 4, 5] = 5 — the relapse episode's grown gap is in the mix. + expect(out.typicalRecoveryGapDays).toBe(5); + expect(out.gapDriverType).toBe("RESTING_HEART_RATE"); + }); }); diff --git a/src/lib/illness/correlation-read.ts b/src/lib/illness/correlation-read.ts index ca05de559..08b5840bf 100644 --- a/src/lib/illness/correlation-read.ts +++ b/src/lib/illness/correlation-read.ts @@ -31,6 +31,8 @@ import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy"; import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; import { resolveUserTimezone } from "@/lib/measurements/consolidation-base"; +import { reconstructNights } from "@/lib/insights/derived/sleep-score"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import type { Derived } from "@/lib/insights/derived/types"; import { BASELINE_WINDOW_DAYS, @@ -39,6 +41,7 @@ import { computeIllnessCorrelation, type DayLogFeverPoint, type IllnessCorrelationValue, + type SleepNightPoint, type SymptomBurdenPoint, type VitalSeries, } from "./correlation"; @@ -96,15 +99,20 @@ export async function computeEpisodeCorrelation( : null; // One coverage probe shared across every vital read (pool-contention - // mitigation — the same pattern the derived route uses). - const coverage = await probeRollupCoverage(userId); + // mitigation — the same pattern the derived route uses). The source priority + // feeds the canonical sleep-night writer-dedup (same ladder every sleep + // surface uses), resolved once. + const [coverage, sleepPriorityJson] = await Promise.all([ + probeRollupCoverage(userId), + loadUserSourcePriority(userId), + ]); // Attempt every scan type under a bounded concurrency cap. The two // windows per type (baseline + episode) are independent reads, so they // run paired rather than serially (halving the per-type round-trips). // The day-log fever read shares the same fan-out budget. const limit = pLimit(VITAL_SCAN_CONCURRENCY); - const [perType, dayLogFever, symptomBurden] = await Promise.all([ + const [perType, dayLogFever, symptomBurden, sleepNights] = await Promise.all([ Promise.all( ILLNESS_SCAN_TYPES.map((type) => limit(async () => { @@ -134,6 +142,14 @@ export async function computeEpisodeCorrelation( ), limit(() => readDayLogFever(episode.id, preOnsetStart, end, tz)), limit(() => readDayLogSymptomBurden(episode.id, preOnsetStart, end, tz)), + limit(() => + readEpisodeSleepNights( + userId, + { baselineStart, baselineEnd, preOnsetStart, end }, + tz, + sleepPriorityJson, + ), + ), ]); const series: VitalSeries[] = []; @@ -170,6 +186,8 @@ export async function computeEpisodeCorrelation( series, dayLogFever, symptomBurden, + baselineNights: sleepNights.baselineNights, + episodeNights: sleepNights.episodeNights, source, now, }); @@ -331,3 +349,75 @@ async function readDayLogSymptomBurden( } return points.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)); } + +/** + * Reconstruct per-night asleep totals over the well baseline window AND the + * episode window for the sleep-as-context observation. Reuses the canonical + * `reconstructNights` engine wholesale (session-clustering, local-wake-day + * keying, multi-source writer-dedup — a WHOOP + Apple Health night is counted + * ONCE) so the figures match every other sleep surface. The night's `night` + * key is ALREADY the local wake-day string, so it joins the engine's + * onset/feltBetter day-space directly — no extra tz conversion. + * + * Two windows mirror the vital reads' contamination-guard seam: + * baselineNights : [baselineStart, baselineEnd) → the user's well baseline + * episodeNights : [preOnsetStart, end] → the episode span + * + * Each is a single bounded `findMany` over the raw per-stage SLEEP_DURATION + * rows; the engine withholds the observation entirely on thin data. + */ +async function readEpisodeSleepNights( + userId: string, + windows: { + baselineStart: Date; + baselineEnd: Date; + preOnsetStart: Date; + end: Date; + }, + tz: string, + priorityJson: unknown, +): Promise<{ + baselineNights: SleepNightPoint[]; + episodeNights: SleepNightPoint[]; +}> { + const select = { + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + } as const; + + const [baselineRows, episodeRows] = await Promise.all([ + prisma.measurement.findMany({ + where: { + userId, + type: "SLEEP_DURATION" satisfies MeasurementType, + deletedAt: null, + measuredAt: { gte: windows.baselineStart, lt: windows.baselineEnd }, + }, + orderBy: { measuredAt: "asc" }, + select, + }), + prisma.measurement.findMany({ + where: { + userId, + type: "SLEEP_DURATION" satisfies MeasurementType, + deletedAt: null, + measuredAt: { gte: windows.preOnsetStart, lte: windows.end }, + }, + orderBy: { measuredAt: "asc" }, + select, + }), + ]); + + const toPoints = (rows: typeof baselineRows): SleepNightPoint[] => + reconstructNights(rows, tz, priorityJson) + .filter((n) => n.asleepMinutes > 0) + .map((n) => ({ day: n.night, asleepMinutes: n.asleepMinutes })); + + return { + baselineNights: toPoints(baselineRows), + episodeNights: toPoints(episodeRows), + }; +} diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index 7756c7c33..da2d51aab 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -83,6 +83,20 @@ export const FUNCTIONAL_IMPACT_RETURN_KEY = "FUNCTIONAL_IMPACT" as const; export const FUNCTIONAL_IMPACT_ADVERSE_FLOOR = 1; /** Minimum episode days with ANY vital coverage before the gap is asserted. */ export const MIN_EPISODE_COVERAGE_DAYS = 4; +/** + * Minimum scorable baseline nights before the sleep-context observation is + * trusted (mirrors the sleep-debt engine's `minNights` floor). Below this the + * "usual" asleep figure is too fragile to compare against. + */ +export const SLEEP_CONTEXT_MIN_BASELINE_NIGHTS = 4; +/** Minimum scorable episode nights before the sleep-context line surfaces. */ +export const SLEEP_CONTEXT_MIN_EPISODE_NIGHTS = 3; +/** + * Minimum |episode − baseline| asleep-minutes delta before the sleep-context + * line is shown. Day-to-day jitter below this floor is not narrated as a + * finding (honest withholding, never a fabricated observation). + */ +export const SLEEP_CONTEXT_MIN_DELTA_MINUTES = 30; /** * Per-metric adverse direction — which way a reading moves when the body is @@ -184,6 +198,21 @@ export interface SymptomBurdenPoint { impact: number; } +/** + * One reconstructed night's asleep total, keyed by the local WAKE-DAY (the same + * `YYYY-MM-DD` day-space the engine's onset/feltBetter markers live in — a + * canonical `NightSummary.night`). The caller reconstructs nights from the raw + * per-stage SLEEP_DURATION rows (writer-deduped, session-clustered) and passes + * the asleep totals in; the engine never touches raw rows. Used ONLY for the + * sleep-context observation, NEVER as a recovery-gap return track. + */ +export interface SleepNightPoint { + /** Local wake-day key, `YYYY-MM-DD`. */ + day: string; + /** Asleep minutes for the night (canonical, multi-source-deduped). */ + asleepMinutes: number; +} + /** The dates the engine reasons over, all `YYYY-MM-DD` local day keys. */ export interface EpisodeWindow { /** Onset day. */ @@ -216,6 +245,20 @@ export interface IllnessCorrelationInput { * passive-vital-thin episode. Optional. */ symptomBurden?: SymptomBurdenPoint[]; + /** + * Reconstructed per-night asleep totals over the user's WELL baseline window + * (the span ending before the pre-onset lookback). Their median is the user's + * usual asleep figure the episode is compared against. Optional — absent / + * thin → no sleep-context observation. NEVER feeds the recovery-gap. + */ + baselineNights?: SleepNightPoint[]; + /** + * Reconstructed per-night asleep totals over the episode window (`onset − + * lookback` through end). Compared against the baseline median as a neutral + * CONTEXT observation (slept more / less than usual), never a return track and + * never in the gap median. Optional — absent / thin → no observation. + */ + episodeNights?: SleepNightPoint[]; /** Provenance source the dominant read resolved against. */ source: "DAY" | "live" | "none"; /** Compute time (injected for deterministic tests). */ @@ -276,6 +319,25 @@ export function isAdverseDeviation( ); } +/** + * A neutral sleep-context observation for the episode — the user's median + * asleep minutes over the well baseline vs the mean over the episode window. A + * pure OBSERVATION ("you slept more / less than usual"), never a recovery + * return, never in the gap median, never a causal/recovery claim. Null when the + * sleep data is too thin (below the night-count floors) or the delta is below + * the magnitude floor — honest withholding, never a fabricated finding. + */ +export interface SleepContext { + /** Median asleep minutes over the well baseline window. */ + baselineMeanMinutes: number; + /** Mean asleep minutes over the episode window. */ + episodeMeanMinutes: number; + /** `episode − baseline` asleep minutes (positive = slept more than usual). */ + deltaMinutes: number; + /** Scorable episode nights the observation is built from (transparency). */ + nightsCounted: number; +} + export interface IllnessCorrelationValue { episodeId: string; /** "How did it announce itself" — notable pre-onset deviations. */ @@ -317,6 +379,13 @@ export interface IllnessCorrelationValue { * recurs, seek care"), NEVER reassure. Empty when nothing tripped. */ redFlags: IllnessRedFlag[]; + /** + * Sleep-as-context observation — slept more / less than usual during the + * episode — or null when the sleep data is too thin or the delta sub-floor. + * A neutral observation surfaced ALONGSIDE the gap, NEVER a return track and + * NEVER part of the headline recovery-gap number. Optional/additive. + */ + sleepContext: SleepContext | null; } export interface IllnessRedFlag { @@ -508,7 +577,7 @@ export function computeIllnessCorrelation( // deviation (which yielded a spurious negative gap). Excluded for // CHRONIC_ONGOING (no recovered date by design). if (firstDeviationIndex >= 0 && window.lifecycle !== "CHRONIC_ONGOING") { - const returnedDay = firstStableReturn( + const returnedDay = lastStableReturn( active, center, spread, @@ -593,6 +662,15 @@ export function computeIllnessCorrelation( fullHistoryDays: Math.max(MIN_EPISODE_COVERAGE_DAYS, 14), }); + // Sleep-as-context — a neutral observation surfaced alongside the gap, NEVER a + // return track and NEVER in the recovery-gap median. Withholds (null) on thin + // data or a sub-floor delta. + const sleepContext = computeSleepContext( + input.baselineNights ?? [], + input.episodeNights ?? [], + window, + ); + return buildOk({ value: { episodeId: input.episodeId, @@ -608,6 +686,7 @@ export function computeIllnessCorrelation( feltBetterDay: window.feltBetterDay, gapDriverType: gapDriverType === null ? null : String(gapDriverType), redFlags, + sleepContext, }, coverage, confidence, @@ -616,13 +695,22 @@ export function computeIllnessCorrelation( } /** - * First day in `active` (chronological), starting at `fromIndex`, where the - * vital is in-band AND stays in-band for the next `RETURN_STABILITY_DAYS` - * observed days. Null when it never settles. "In-band" = |deviation| < - * NOTABLE_SD. `fromIndex` pins the search to AFTER the first deviation so an - * early in-band run (the run-up to the illness) is never mistaken for a return. + * Start of the FINAL sustained in-band run in `active` (chronological) at/after + * `fromIndex` — the day the vital *finally* settled and held to the end of the + * observed span. "In-band" = |deviation| < NOTABLE_SD. Null when it never + * settles, or when it ends out-of-band (still elevated at the last reading), or + * when the final in-band run is shorter than `RETURN_STABILITY_DAYS` observed + * days. `fromIndex` pins the search to AFTER the first deviation so an early + * in-band run (the run-up to the illness) is never mistaken for a return. + * + * This is the relapse-aware "last sustained return": an episode that settles, + * re-deviates (a mid-episode flare), and settles again reports the LAST settle. + * The change is monotone — a last return is always >= the first return day — so + * any series that settles ONCE (first == last) is unaffected. Multiple relapses + * collapse to the same rule: the final contiguous in-band span wins by + * construction, no special-casing. */ -function firstStableReturn( +function lastStableReturn( active: VitalDayPoint[], center: number, spread: number, @@ -632,13 +720,16 @@ function firstStableReturn( day: p.day, in: Math.abs(deviationSd(p.mean, center, spread)) < NOTABLE_SD, })); - for (let i = Math.max(0, fromIndex); i < inBand.length; i++) { - if (!inBand[i].in) continue; - let run = 0; - for (let j = i; j < inBand.length && inBand[j].in; j++) run++; - if (run >= RETURN_STABILITY_DAYS) return inBand[i].day; - } - return null; + // Series ends out-of-band → no sustained final return (still elevated). + if (inBand.length === 0 || !inBand[inBand.length - 1].in) return null; + // Walk back from the end to the start of the final maximal in-band run. + let start = inBand.length - 1; + while (start - 1 >= 0 && inBand[start - 1].in) start--; + const run = inBand.length - start; + // The final run must be long enough AND begin at/after the first deviation. + if (run < RETURN_STABILITY_DAYS || start < Math.max(0, fromIndex)) + return null; + return inBand[start].day; } /** @@ -652,11 +743,15 @@ function firstStableReturn( * - The anchor is the first logged adverse day (same role as the vital * `firstDeviationIndex`): a return search starts AFTER it, so a run-up is * never read as a return. - * - A "return" is the first logged day at/after the anchor that is in-band AND - * stays in-band for the next `RETURN_STABILITY_DAYS` LOGGED days. LOGGED-days + * - A "return" is the START of the FINAL sustained in-band logged run at/after + * the anchor — the day symptoms *finally* eased and stayed eased through the + * last logged day, requiring `RETURN_STABILITY_DAYS` LOGGED days. Relapse- + * aware (parity with the vital `lastStableReturn`): if symptoms ease, flare + * again, then ease again, the SECOND easing is the return. LOGGED-days * stability (not calendar days) is the honest-withholding handler for sparse - * journals: a single trailing impact-0 log never satisfies the run, so the - * track WITHHOLDS rather than fabricating recovery from absence of rows. + * journals: a single trailing impact-0 log never satisfies the run, and a + * journal that ends on an adverse log has no final in-band run, so the track + * WITHHOLDS rather than fabricating recovery from absence of rows. * - The gap is `dayDiff(feltBetterDay, returnedDay)`, same signed semantic. * - Always `adverse:true` (a logged symptom curve is illness-relevant), so it * feeds `adverseCoverageDays` and can drive the headline gap. @@ -688,21 +783,17 @@ function computeSymptomReturn( const firstAdverseIndex = active.findIndex( (p) => p.impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR, ); + // Start of the FINAL sustained in-band logged run (relapse-aware "last + // return"): walk back from the last logged day. A journal ending on an + // adverse log has no final in-band run → null (honest withholding). + const inBand = active.map((p) => p.impact < FUNCTIONAL_IMPACT_ADVERSE_FLOOR); let returnedDay: string | null = null; - for (let i = firstAdverseIndex; i < active.length; i++) { - if (active[i].impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR) continue; - // Count consecutive in-band LOGGED days from here. - let run = 0; - for ( - let j = i; - j < active.length && active[j].impact < FUNCTIONAL_IMPACT_ADVERSE_FLOOR; - j++ - ) { - run++; - } - if (run >= RETURN_STABILITY_DAYS) { - returnedDay = active[i].day; - break; + if (inBand.length > 0 && inBand[inBand.length - 1]) { + let start = inBand.length - 1; + while (start - 1 >= 0 && inBand[start - 1]) start--; + const run = inBand.length - start; + if (run >= RETURN_STABILITY_DAYS && start >= firstAdverseIndex) { + returnedDay = active[start].day; } } @@ -722,6 +813,56 @@ function computeSymptomReturn( }; } +/** + * Compute the sleep-as-context observation: the user's MEDIAN asleep minutes + * over the well baseline vs the MEAN over the episode's active span. A neutral + * OBSERVATION only — never a recovery return, never folded into the gap median, + * never a causal claim. Mirrors the engine's withholding ethos: + * + * - Baseline needs ≥ `SLEEP_CONTEXT_MIN_BASELINE_NIGHTS` scorable nights AND + * the episode needs ≥ `SLEEP_CONTEXT_MIN_EPISODE_NIGHTS` (thin sleep data + * produces no observation — say nothing rather than guess). + * - The |delta| must reach `SLEEP_CONTEXT_MIN_DELTA_MINUTES` so day-to-day + * jitter is never narrated as a finding. + * + * Episode nights are clipped to the active span (`>= onsetDay`) so the + * comparison reflects the illness, not the pre-onset lookback. Returns null + * (says nothing) below either floor. + */ +function computeSleepContext( + baselineNights: SleepNightPoint[], + episodeNights: SleepNightPoint[], + window: EpisodeWindow, +): SleepContext | null { + const baseMinutes = baselineNights.map((n) => n.asleepMinutes); + const episodeMinutes = episodeNights + .filter((n) => n.day >= window.onsetDay) + .map((n) => n.asleepMinutes); + + if ( + baseMinutes.length < SLEEP_CONTEXT_MIN_BASELINE_NIGHTS || + episodeMinutes.length < SLEEP_CONTEXT_MIN_EPISODE_NIGHTS + ) { + return null; + } + + const baselineMeanMinutes = Math.round(median(baseMinutes)); + const episodeMeanMinutes = Math.round( + episodeMinutes.reduce((s, v) => s + v, 0) / episodeMinutes.length, + ); + const deltaMinutes = episodeMeanMinutes - baselineMeanMinutes; + + // Sub-floor delta is jitter, not a finding → say nothing. + if (Math.abs(deltaMinutes) < SLEEP_CONTEXT_MIN_DELTA_MINUTES) return null; + + return { + baselineMeanMinutes, + episodeMeanMinutes, + deltaMinutes, + nightsCounted: episodeMinutes.length, + }; +} + /** * Detect the retrospective red flags — DECOUPLED from the banded loop and the * own-baseline band entirely. SpO2 and temperature escalate against ABSOLUTE diff --git a/src/lib/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts index 4b8131e8a..66660dac0 100644 --- a/src/lib/openapi/routes/illness.ts +++ b/src/lib/openapi/routes/illness.ts @@ -202,6 +202,19 @@ const illnessRedFlag = z "A retrospective red-flag escalation (sustained low SpO2 or sustained fever) against absolute clinical floors. Copy must escalate ('seek care if this recurs'), never reassure.", }); +const illnessSleepContext = z + .object({ + baselineMeanMinutes: z.number(), + episodeMeanMinutes: z.number(), + deltaMinutes: z.number(), + nightsCounted: z.number().int(), + }) + .meta({ + id: "IllnessSleepContext", + description: + "A neutral sleep-as-context observation: the user's median asleep minutes over the well baseline vs the mean over the episode window, and their signed delta (positive = slept more than usual). A pure OBSERVATION surfaced alongside the gap — NEVER a recovery return and NEVER part of the headline recovery-gap. Withheld (the field is null) on thin sleep data or a sub-floor delta.", + }); + const illnessCorrelationValue = z .object({ episodeId: z.string(), @@ -213,11 +226,12 @@ const illnessCorrelationValue = z feltBetterDay: z.string().nullable(), gapDriverType: z.string().nullable(), redFlags: z.array(illnessRedFlag), + sleepContext: illnessSleepContext.nullable(), }) .meta({ id: "IllnessCorrelationValue", description: - "The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital physiological returns (incl. the user-logged symptom-burden track, `type:FUNCTIONAL_IMPACT`), the headline recovery-gap (median of per-track gaps), the driving metric (`gapDriverType`), and any red flags.", + "The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital physiological returns (incl. the user-logged symptom-burden track, `type:FUNCTIONAL_IMPACT`), the headline recovery-gap (median of per-track gaps), the driving metric (`gapDriverType`), any red flags, and an optional sleep-as-context observation (`sleepContext`, never in the gap).", }); const illnessCorrelationResponse = z