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