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 5f0293784..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"; @@ -995,3 +997,158 @@ describe("computeIllnessCorrelation — relapse-aware return (last sustained)", 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/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 09c12618f..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 { @@ -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, @@ -734,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