feat(illness): correlation + recovery-gap engine

Add the retrospective correlation engine for illness episodes: a pre-onset
anomaly scan (deviation in robust SDs from the user's own median±MAD
baseline), the nadir / what-dropped across the active span, and the
recovery-gap (physiological return-to-baseline vs the felt-better marker).
CHRONIC_ONGOING is excluded from the gap. Every derived number is gated by
the shared coverage/confidence model — a thin signal asserts nothing rather
than showing a wrong figure.

The baseline window ends strictly before the pre-onset lookback, so an
episode's own days can never contaminate the baseline it is measured against.
Reads are DAY-native (rollup tier with a live-SQL fallback), reusing the
baseline engine's read path so the rollup-vs-live parity carries over.

Surfaces:
- GET /api/illness/episodes/{id}/correlation — per-episode findings (gated
  Derived wire shape; red-flag escalation for sustained low SpO2 / fever).
- GET /api/illness/insights — cross-episode retrospective ("sick N times ·
  typical recovery gap X days"), with the typical-gap withheld below the
  min-sample floor. Retrospective only — never a predictor or diagnoser.

Reliability battery: golden-fixture recovery-gap math, rollup-vs-live parity,
baseline-contamination guard, thin-data / coverage-gate, red-flag escalation.
Born-gated + owner-scoped; server-authoritative DTO (iOS renders). i18n ×6.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 22:16:19 +02:00
1 parent a7b2b61b99
commit 745fa28c8b
16 files changed
+1737

No files matched your search

+28
View File
@@ -6834,6 +6834,34 @@
"headache": "Kopfschmerzen",
"bodyAches": "Gliederschmerzen",
"fatigue": "Erschöpfung"
},
"correlation": {
"title": "Wie es verlief",
"learning": "Noch am Lernen — erfasse ein paar weitere Tage, um hier Muster zu sehen.",
"preOnsetTitle": "Wie es sich ankündigte",
"preOnsetBody": "Werte, die vor dem Beginn von deiner Baseline abwichen.",
"nadirTitle": "Was sich änderte",
"nadirBody": "Wie weit jeder erfasste Wert von deinem typischen Bereich abwich.",
"recoveryGapTitle": "Erholungslücke",
"recoveryGapLag": "Deine Werte normalisierten sich {days} Tage, nachdem es dir besser ging.",
"recoveryGapLead": "Deine Werte normalisierten sich {days} Tage, bevor du Genesung vermerktest.",
"recoveryGapSame": "Deine Werte normalisierten sich am selben Tag, an dem es dir besser ging.",
"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.",
"direction": {
"above": "über deinem Üblichen",
"below": "unter deinem Üblichen"
}
},
"insights": {
"title": "Dein Krankheitsverlauf",
"countOne": "Du hast in diesem Zeitraum 1 Episode erfasst.",
"countOther": "Du hast in diesem Zeitraum {count} Episoden erfasst.",
"typicalGap": "Typische Erholungslücke: {days} Tage zwischen Besserung und normalisierten Werten.",
"typicalGapLearning": "Noch ein paar abgeschlossene Episoden und hier erscheint eine typische Erholungslücke.",
"byMonth": "Wann du Episoden erfasst",
"none": "In diesem Zeitraum keine Episoden erfasst."
}
},
"coach": {
+28
View File
@@ -6834,6 +6834,34 @@
"headache": "Headache",
"bodyAches": "Body aches",
"fatigue": "Fatigue"
},
"correlation": {
"title": "How it unfolded",
"learning": "Still learning — log a few more days to see patterns here.",
"preOnsetTitle": "How it announced itself",
"preOnsetBody": "Signals that drifted from your baseline before you marked the onset.",
"nadirTitle": "What changed",
"nadirBody": "How far each tracked signal strayed from your typical range.",
"recoveryGapTitle": "Recovery gap",
"recoveryGapLag": "Your numbers settled {days} days after you felt better.",
"recoveryGapLead": "Your numbers settled {days} days before you marked recovery.",
"recoveryGapSame": "Your numbers settled the same day you felt better.",
"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.",
"direction": {
"above": "above your usual",
"below": "below your usual"
}
},
"insights": {
"title": "Your illness history",
"countOne": "You logged 1 episode in this period.",
"countOther": "You logged {count} episodes in this period.",
"typicalGap": "Typical recovery gap: {days} days between feeling better and your numbers settling.",
"typicalGapLearning": "A few more resolved episodes and a typical recovery gap will appear here.",
"byMonth": "When you tend to log episodes",
"none": "No episodes logged in this period."
}
},
"coach": {
+28
View File
@@ -6834,6 +6834,34 @@
"headache": "Dolor de cabeza",
"bodyAches": "Dolores musculares",
"fatigue": "Fatiga"
},
"correlation": {
"title": "Cómo se desarrolló",
"learning": "Aún aprendiendo: registra unos días más para ver patrones aquí.",
"preOnsetTitle": "Cómo se anunció",
"preOnsetBody": "Señales que se alejaron de tu base antes de marcar el inicio.",
"nadirTitle": "Qué cambió",
"nadirBody": "Cuánto se alejó cada señal registrada de tu rango habitual.",
"recoveryGapTitle": "Brecha de recuperación",
"recoveryGapLag": "Tus valores se estabilizaron {days} días después de sentirte mejor.",
"recoveryGapLead": "Tus valores se estabilizaron {days} días antes de marcar la recuperación.",
"recoveryGapSame": "Tus valores se estabilizaron el mismo día en que te sentiste mejor.",
"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.",
"direction": {
"above": "por encima de lo habitual",
"below": "por debajo de lo habitual"
}
},
"insights": {
"title": "Tu historial de enfermedades",
"countOne": "Registraste 1 episodio en este período.",
"countOther": "Registraste {count} episodios en este período.",
"typicalGap": "Brecha de recuperación típica: {days} días entre sentirte mejor y la estabilización de tus valores.",
"typicalGapLearning": "Con unos pocos episodios resueltos más aparecerá aquí una brecha de recuperación típica.",
"byMonth": "Cuándo sueles registrar episodios",
"none": "Sin episodios registrados en este período."
}
},
"coach": {
+28
View File
@@ -6834,6 +6834,34 @@
"headache": "Mal de tête",
"bodyAches": "Courbatures",
"fatigue": "Fatigue"
},
"correlation": {
"title": "Comment cela s'est déroulé",
"learning": "Encore en apprentissage — enregistre quelques jours de plus pour voir des tendances ici.",
"preOnsetTitle": "Comment cela s'est annoncé",
"preOnsetBody": "Signaux qui se sont écartés de ta base avant le début que tu as noté.",
"nadirTitle": "Ce qui a changé",
"nadirBody": "À quel point chaque signal suivi s'est écarté de ta plage habituelle.",
"recoveryGapTitle": "Écart de récupération",
"recoveryGapLag": "Tes valeurs se sont stabilisées {days} jours après que tu te sois senti mieux.",
"recoveryGapLead": "Tes valeurs se sont stabilisées {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.",
"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.",
"direction": {
"above": "au-dessus de ton habituel",
"below": "en dessous de ton habituel"
}
},
"insights": {
"title": "Ton historique de maladies",
"countOne": "Tu as enregistré 1 épisode sur cette période.",
"countOther": "Tu as enregistré {count} épisodes sur cette période.",
"typicalGap": "Écart de récupération typique : {days} jours entre le mieux-être et la stabilisation de tes valeurs.",
"typicalGapLearning": "Encore quelques épisodes résolus et un écart de récupération typique apparaîtra ici.",
"byMonth": "Quand tu enregistres des épisodes",
"none": "Aucun épisode enregistré sur cette période."
}
},
"coach": {
+28
View File
@@ -6834,6 +6834,34 @@
"headache": "Mal di testa",
"bodyAches": "Dolori muscolari",
"fatigue": "Affaticamento"
},
"correlation": {
"title": "Come si è svolto",
"learning": "Ancora in apprendimento: registra qualche giorno in più per vedere i pattern qui.",
"preOnsetTitle": "Come si è annunciato",
"preOnsetBody": "Segnali che si sono allontanati dalla tua base prima dell'inizio che hai segnato.",
"nadirTitle": "Cosa è cambiato",
"nadirBody": "Quanto ogni segnale registrato si è allontanato dal tuo intervallo abituale.",
"recoveryGapTitle": "Divario di recupero",
"recoveryGapLag": "I tuoi valori si sono stabilizzati {days} giorni dopo esserti sentito meglio.",
"recoveryGapLead": "I tuoi valori si sono stabilizzati {days} giorni prima di segnare la guarigione.",
"recoveryGapSame": "I tuoi valori si sono stabilizzati lo stesso giorno in cui ti sei sentito meglio.",
"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.",
"direction": {
"above": "sopra il tuo solito",
"below": "sotto il tuo solito"
}
},
"insights": {
"title": "La tua storia di malattie",
"countOne": "Hai registrato 1 episodio in questo periodo.",
"countOther": "Hai registrato {count} episodi in questo periodo.",
"typicalGap": "Divario di recupero tipico: {days} giorni tra il sentirti meglio e la stabilizzazione dei valori.",
"typicalGapLearning": "Ancora qualche episodio risolto e qui apparirà un divario di recupero tipico.",
"byMonth": "Quando tendi a registrare episodi",
"none": "Nessun episodio registrato in questo periodo."
}
},
"coach": {
+28
View File
@@ -6834,6 +6834,34 @@
"headache": "Ból głowy",
"bodyAches": "Bóle ciała",
"fatigue": "Zmęczenie"
},
"correlation": {
"title": "Jak przebiegało",
"learning": "Wciąż się uczę — zapisz jeszcze kilka dni, aby zobaczyć tu wzorce.",
"preOnsetTitle": "Jak się zapowiadało",
"preOnsetBody": "Sygnały, które odbiegły od Twojej bazy przed oznaczonym początkiem.",
"nadirTitle": "Co się zmieniło",
"nadirBody": "Jak daleko każdy śledzony sygnał odbiegł od Twojego typowego zakresu.",
"recoveryGapTitle": "Luka rekonwalescencji",
"recoveryGapLag": "Twoje wartości ustabilizowały się {days} dni po tym, jak poczułeś się lepiej.",
"recoveryGapLead": "Twoje wartości ustabilizowały się {days} dni przed oznaczeniem wyzdrowienia.",
"recoveryGapSame": "Twoje wartości ustabilizowały się tego samego dnia, gdy poczułeś się lepiej.",
"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.",
"direction": {
"above": "powyżej Twojej normy",
"below": "poniżej Twojej normy"
}
},
"insights": {
"title": "Twoja historia chorób",
"countOne": "W tym okresie zapisałeś 1 epizod.",
"countOther": "W tym okresie zapisałeś {count} epizodów.",
"typicalGap": "Typowa luka rekonwalescencji: {days} dni między poprawą a ustabilizowaniem wartości.",
"typicalGapLearning": "Jeszcze kilka zakończonych epizodów i pojawi się tu typowa luka rekonwalescencji.",
"byMonth": "Kiedy zwykle zapisujesz epizody",
"none": "Brak epizodów zapisanych w tym okresie."
}
},
"coach": {
@@ -0,0 +1,87 @@
/**
* `GET /api/illness/episodes/{id}/correlation` — the retrospective correlation
* findings for one episode: the pre-onset anomaly scan ("how did it announce
* itself"), the nadir / what-dropped, and the recovery-gap (physiological
* return-to-baseline vs the felt-better marker).
*
* Server-authoritative + coverage-gated: the response is a `Derived<T>` flat
* wire shape (`status: "ok" | "insufficient"`). When the signal is thin the
* engine returns `insufficient` with coverage + a reason — the surface renders
* "we're still learning", never a fabricated number. The findings are derived
* from the user's OWN baseline (median ± MAD), never a population constant, and
* the baseline window is contamination-guarded (ends before the pre-onset
* lookback). Retrospective ONLY — never a predictor or diagnoser. Born-gated +
* owner-scoped.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import { requireIllnessEnabled } from "@/lib/illness/gate";
import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const { id } = await params;
const episode = await prisma.illnessEpisode.findUnique({
where: { id },
select: {
id: true,
userId: true,
deletedAt: true,
onsetAt: true,
resolvedAt: true,
lifecycle: true,
},
});
if (!episode || episode.userId !== user.id || episode.deletedAt !== null) {
return apiError("Episode not found", 404);
}
const tz = user.timezone ?? DEFAULT_TIMEZONE;
const derived = await computeEpisodeCorrelation(
user.id,
{
id: episode.id,
onsetAt: episode.onsetAt,
resolvedAt: episode.resolvedAt,
lifecycle: episode.lifecycle,
},
tz,
);
annotate({
action: {
name: "illness.correlation.read",
entity_type: "illness_episode",
entity_id: id,
},
meta: {
status: derived.status,
recovery_gap_days:
derived.status === "ok" ? derived.value.recoveryGapDays : null,
red_flags: derived.status === "ok" ? derived.value.redFlags.length : 0,
},
});
return apiSuccess({
episodeId: id,
status: derived.status,
value: derived.status === "ok" ? derived.value : null,
coverage: derived.coverage,
confidence: derived.status === "ok" ? derived.confidence : null,
provenance: derived.provenance,
reason: derived.status === "insufficient" ? derived.reason : null,
});
},
);
+107
View File
@@ -0,0 +1,107 @@
/**
* `GET /api/illness/insights?windowDays=365` — the cross-episode retrospective
* summary: "sick N times this year · typical recovery gap X days", with a
* recurrence-by-month tally and a per-type breakdown.
*
* RETROSPECTIVE ONLY — it summarises past episodes. It is NEVER a predictor or
* diagnoser; the recurrence figure is a count, not a forecast. Server-
* authoritative + gated: the "typical gap" is withheld below the min-sample
* floor (asserts nothing thin). Per-episode gaps come from the same coverage-
* gated correlation engine the per-episode route uses, so a thin episode
* contributes no gap rather than a wrong one. Born-gated + owner-scoped.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { requireIllnessEnabled } from "@/lib/illness/gate";
import { illnessInsightsQuerySchema } from "@/lib/validations/illness";
import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read";
import {
summarizeIllnessRetrospective,
type RetrospectiveEpisode,
} from "@/lib/illness/retrospective";
import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
import { resolveUserTimezone } from "@/lib/measurements/consolidation-base";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** Cap the per-episode gap computations so a heavy history can't fan out. */
const MAX_GAP_COMPUTATIONS = 24;
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const parsed = illnessInsightsQuerySchema.safeParse({
windowDays: new URL(request.url).searchParams.get("windowDays") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "illness.insights.invalid",
});
}
const windowDays = parsed.data.windowDays ?? 365;
const now = new Date();
const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
const tz = resolveUserTimezone(user.timezone ?? null);
const rows = await prisma.illnessEpisode.findMany({
where: { userId: user.id, deletedAt: null, onsetAt: { gte: since } },
orderBy: { onsetAt: "desc" },
select: {
id: true,
type: true,
lifecycle: true,
onsetAt: true,
resolvedAt: true,
},
});
// Compute the recovery-gap only for resolved, non-chronic episodes (the
// only ones that CAN have a gap), bounded to MAX_GAP_COMPUTATIONS most
// recent. Each gap comes from the coverage-gated engine — a thin episode
// yields null and simply doesn't contribute to the typical-gap median.
const gapCandidates = rows
.filter((r) => r.resolvedAt !== null && r.lifecycle !== "CHRONIC_ONGOING")
.slice(0, MAX_GAP_COMPUTATIONS);
const gapById = new Map<string, number | null>();
for (const r of gapCandidates) {
const derived = await computeEpisodeCorrelation(
user.id,
{ id: r.id, onsetAt: r.onsetAt, resolvedAt: r.resolvedAt, lifecycle: r.lifecycle },
tz,
now,
);
gapById.set(
r.id,
derived.status === "ok" ? derived.value.recoveryGapDays : null,
);
}
const episodes: RetrospectiveEpisode[] = rows.map((r) => ({
id: r.id,
type: r.type,
onsetDay: dayKeyForUserTz(r.onsetAt, tz),
resolved: r.resolvedAt !== null,
recoveryGapDays: gapById.get(r.id) ?? null,
lifecycle: r.lifecycle,
}));
const summary = summarizeIllnessRetrospective(episodes);
annotate({
action: { name: "illness.insights.read", entity_type: "illness_episode" },
meta: {
episode_count: summary.episodeCount,
gap_sample_size: summary.gapSampleSize,
typical_gap_days: summary.typicalRecoveryGapDays,
},
});
return apiSuccess({ windowDays, ...summary });
});
@@ -0,0 +1,210 @@
/**
* v1.18.1 P3 — illness correlation READ layer + rollup-vs-live parity.
*
* The reliability anchor: the same per-day series fed to the engine must
* produce IDENTICAL findings whether it was resolved from the rollup DAY tier
* or the live-SQL fallback. We drive `computeEpisodeCorrelation` twice over the
* SAME underlying daily data — once with full rollup coverage (the DAY path),
* once with a coverage miss (the live raw-read path) — and assert the engine
* output is byte-identical (modulo the provenance `source` tag).
*
* Also pins the contamination-guard READ window: the baseline read window must
* end strictly before the pre-onset lookback (no episode-span day reaches the
* baseline query).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/* The DAY-native daily means the test universe holds for RESTING_HEART_RATE.
* Baseline window sits at ~55; the episode-span (from 01-03 onward) spikes.
* Onset 2026-01-10, lookback 7 → pre-onset starts 2026-01-03; baseline window
* is the 30 days ending 2026-01-03. */
const DAILY: Array<{ day: string; mean: number }> = [];
{
// 30 clean baseline days ending the day before 2026-01-03.
let cursor = Date.parse("2026-01-02T00:00:00Z");
// Even spread around 55 so the MAD (and band) is non-zero and stable.
const offsets = [-2, -1, 0, 1, 2];
for (let i = 0; i < 30; i++) {
DAILY.unshift({
day: new Date(cursor).toISOString().slice(0, 10),
mean: 55 + offsets[i % offsets.length],
});
cursor -= 24 * 60 * 60 * 1000;
}
// Episode span.
for (const [day, mean] of [
["2026-01-03", 56],
["2026-01-08", 70], // pre-onset notable
["2026-01-10", 74],
["2026-01-12", 76], // nadir
["2026-01-15", 64],
["2026-01-17", 58],
["2026-01-18", 55],
["2026-01-19", 55],
["2026-01-20", 55],
] as const) {
DAILY.push({ day, mean });
}
}
const NOW = new Date("2026-01-21T12:00:00Z");
const db = vi.hoisted(() => ({
measurement: { findMany: vi.fn() },
}));
const rollup = vi.hoisted(() => ({
probeRollupCoverage: vi.fn(),
readBestGranularityRollups: vi.fn(),
}));
vi.mock("@/lib/db", () => ({ prisma: db }));
vi.mock("@/lib/rollups/measurement-coverage", () => ({
probeRollupCoverage: rollup.probeRollupCoverage,
}));
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
readBestGranularityRollups: rollup.readBestGranularityRollups,
}));
import { computeEpisodeCorrelation } from "../correlation-read";
import { ILLNESS_SCAN_TYPES } from "../correlation";
/**
* An ACTIVE episode (no resolvedAt) so the episode-window read's `to` is
* effectively `now` — this exercises the trailing rollup DAY path in the
* covered case and the trailing live path in the miss case, the exact seam
* the parity test must pin. The baseline window stays bounded (raw both ways).
*/
const EPISODE = {
id: "ep-parity",
onsetAt: new Date("2026-01-10T08:00:00Z"),
resolvedAt: null as Date | null,
lifecycle: "ACUTE",
};
/** Daily rows within [from, to) as a raw-measurement findMany result. */
function rawRowsWithin(from: Date, to: Date) {
const fromKey = from.toISOString().slice(0, 10);
const toKey = to.toISOString().slice(0, 10);
return DAILY.filter((d) => d.day >= fromKey && d.day < toKey).map((d) => ({
value: d.mean,
measuredAt: new Date(`${d.day}T12:00:00Z`),
}));
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("computeEpisodeCorrelation — rollup-vs-live parity", () => {
function onlyRhr<T>(map: Map<string, T>): Map<string, T> {
return map;
}
it("produces identical findings on the DAY path and the live path", async () => {
// ── DAY path: full coverage for RESTING_HEART_RATE, rollup serves the
// trailing (to == now) episode-window read; the bounded baseline read
// goes through measurement.findMany (to != now). ──
const dayCoverage = onlyRhr(
new Map<string, boolean>(ILLNESS_SCAN_TYPES.map((t) => [String(t), t === "RESTING_HEART_RATE"])),
);
rollup.probeRollupCoverage.mockResolvedValue(dayCoverage);
rollup.readBestGranularityRollups.mockImplementation(
async (_u: string, type: string, windowDays: number) => {
if (type !== "RESTING_HEART_RATE") return null;
const since = new Date(NOW.getTime() - windowDays * 24 * 60 * 60 * 1000);
const sinceKey = since.toISOString().slice(0, 10);
const rows = DAILY.filter((d) => d.day >= sinceKey).map((d) => ({
bucketStart: new Date(`${d.day}T00:00:00Z`),
count: 1,
mean: d.mean,
sd: null,
slope: null,
r2: null,
sumValue: null,
minValue: d.mean,
maxValue: d.mean,
}));
return rows.length > 0 ? { granularity: "DAY" as const, rows } : null;
},
);
db.measurement.findMany.mockImplementation(async ({ where }: { where: { type: string; measuredAt: { gte: Date; lt: Date } } }) => {
if (where.type !== "RESTING_HEART_RATE") return [];
return rawRowsWithin(where.measuredAt.gte, where.measuredAt.lt);
});
const dayOut = await computeEpisodeCorrelation("u1", EPISODE, "UTC", NOW);
// ── Live path: zero rollup coverage, every read falls to findMany. The
// baseline engine's live fallback groups raw rows itself for the
// trailing read; we serve the same DAILY universe for any window. ──
vi.clearAllMocks();
const noCoverage = new Map<string, boolean>(
ILLNESS_SCAN_TYPES.map((t) => [String(t), false]),
);
rollup.probeRollupCoverage.mockResolvedValue(noCoverage);
rollup.readBestGranularityRollups.mockResolvedValue(null);
db.measurement.findMany.mockImplementation(
async ({ where }: { where: { type: string; measuredAt: { gte: Date; lt?: Date } } }) => {
if (where.type !== "RESTING_HEART_RATE") return [];
const from = where.measuredAt.gte;
const to = where.measuredAt.lt ?? NOW;
return rawRowsWithin(from, to);
},
);
const liveOut = await computeEpisodeCorrelation("u1", EPISODE, "UTC", NOW);
expect(dayOut.status).toBe("ok");
expect(liveOut.status).toBe("ok");
if (dayOut.status !== "ok" || liveOut.status !== "ok") return;
// Same numbers regardless of read path (the parity guarantee): the
// DAY-rollup-fed series and the live-SQL series produce byte-identical
// findings.
expect(liveOut.value.recoveryGapDays).toBe(dayOut.value.recoveryGapDays);
expect(liveOut.value.nadir).toEqual(dayOut.value.nadir);
expect(liveOut.value.preOnset).toEqual(dayOut.value.preOnset);
expect(liveOut.value.returns).toEqual(dayOut.value.returns);
// And the findings are the expected golden values.
expect(dayOut.value.nadir[0]?.day).toBe("2026-01-12");
expect(dayOut.value.nadir[0]?.value).toBe(76);
expect(dayOut.value.preOnset[0]?.day).toBe("2026-01-08");
// Active episode → no felt-better marker → the gap is null but the
// physiological return is still detected (01-17 is the first in-band day
// that then holds for ≥3 observed days).
expect(dayOut.value.recoveryGapDays).toBeNull();
expect(dayOut.value.returns[0]?.returnedDay).toBe("2026-01-17");
});
});
describe("computeEpisodeCorrelation — contamination-guard read window", () => {
it("never queries baseline rows on or after the pre-onset lookback start", async () => {
rollup.probeRollupCoverage.mockResolvedValue(
new Map<string, boolean>(ILLNESS_SCAN_TYPES.map((t) => [String(t), false])),
);
rollup.readBestGranularityRollups.mockResolvedValue(null);
const seen: Array<{ gte: Date; lt?: Date }> = [];
db.measurement.findMany.mockImplementation(
async ({ where }: { where: { type: string; measuredAt: { gte: Date; lt?: Date } } }) => {
if (where.type === "RESTING_HEART_RATE") seen.push(where.measuredAt);
return [];
},
);
await computeEpisodeCorrelation("u1", EPISODE, "UTC", NOW);
// The pre-onset lookback starts at onset − 7 days = 2026-01-03.
const lookbackStart = Date.parse("2026-01-03T08:00:00Z");
// The bounded baseline read (the one with a `lt`) must END at/ before
// the lookback start — no baseline query may reach into the episode span.
const boundedBaseline = seen.filter((w) => w.lt !== undefined);
expect(boundedBaseline.length).toBeGreaterThan(0);
for (const w of boundedBaseline) {
expect(w.lt!.getTime()).toBeLessThanOrEqual(lookbackStart);
}
});
});
@@ -0,0 +1,294 @@
/**
* v1.18.1 P3 — illness correlation engine (the pure math).
*
* The reliability battery for `computeIllnessCorrelation`:
* - golden-fixture recovery-gap math (felt-better vs physiological return)
* - baseline-contamination guard (episode days must not poison the baseline)
* - thin-data / coverage-gate (asserts NOTHING below the floor)
* - pre-onset anomaly scan + nadir direction (adverse vs not)
* - red-flag escalation (sustained SpO2 drop / fever)
* - CHRONIC_ONGOING exclusion from the gap
*
* Pure — no DB. Series are hand-built so each finding is exactly predictable.
*/
import { describe, expect, it } from "vitest";
import type { MeasurementType } from "@/generated/prisma/client";
import {
computeIllnessCorrelation,
dayDiff,
MIN_BASELINE_DAYS,
type IllnessCorrelationInput,
type VitalDayPoint,
} from "../correlation";
const NOW = new Date("2026-02-01T00:00:00Z");
/** Build a flat baseline series of `days` days at `value`, ending `endDay`. */
function flatBaseline(value: number, days: number, endDay: string): VitalDayPoint[] {
const out: VitalDayPoint[] = [];
let cursor = Date.parse(`${endDay}T00:00:00Z`);
for (let i = 0; i < days; i++) {
out.unshift({ day: new Date(cursor).toISOString().slice(0, 10), mean: value });
cursor -= 24 * 60 * 60 * 1000;
}
return out;
}
/**
* A baseline whose per-day means spread evenly so the MAD (and thus the band)
* is non-zero and stable. Means cycle through `center − jitter … center +
* jitter` so the median is `center` and the MAD is ~`jitter/2`.
*/
function jitteredBaseline(center: number, jitter: number, days: number, endDay: string): VitalDayPoint[] {
const base = flatBaseline(center, days, endDay);
const offsets = [-jitter, -jitter / 2, 0, jitter / 2, jitter];
return base.map((p, i) => ({ ...p, mean: center + offsets[i % offsets.length] }));
}
function input(over: Partial<IllnessCorrelationInput>): IllnessCorrelationInput {
return {
episodeId: "ep1",
window: { onsetDay: "2026-01-10", feltBetterDay: "2026-01-17", lifecycle: "ACUTE" },
series: [],
source: "DAY",
now: NOW,
...over,
};
}
describe("computeIllnessCorrelation — coverage gate", () => {
it("returns insufficient with no baselined vitals", () => {
const out = computeIllnessCorrelation(input({ series: [] }));
expect(out.status).toBe("insufficient");
if (out.status === "insufficient") expect(out.reason).toBe("no_baselined_vitals");
});
it("returns insufficient when a vital lacks the min baseline days", () => {
const type: MeasurementType = "RESTING_HEART_RATE";
const out = computeIllnessCorrelation(
input({
series: [
{
type,
baselineDays: flatBaseline(55, MIN_BASELINE_DAYS - 1, "2026-01-02"),
episodeDays: flatBaseline(55, 6, "2026-01-15"),
},
],
}),
);
expect(out.status).toBe("insufficient");
});
it("returns insufficient when episode coverage is below the floor", () => {
const type: MeasurementType = "RESTING_HEART_RATE";
const out = computeIllnessCorrelation(
input({
series: [
{
type,
baselineDays: jitteredBaseline(55, 1, 14, "2026-01-02"),
episodeDays: flatBaseline(55, 2, "2026-01-11"), // only 2 episode days
},
],
}),
);
expect(out.status).toBe("insufficient");
if (out.status === "insufficient")
expect(out.reason).toBe("insufficient_episode_coverage");
});
});
describe("computeIllnessCorrelation — golden recovery-gap", () => {
it("computes a positive gap when the body lags the felt-better marker", () => {
// RHR baseline 55, MAD-band ≈ ±4.4 (spread ≈ 2.22, NOTABLE = 2σ). Onset
// 01-10, felt better 01-17. RHR spikes to 75, stays clearly out-of-band
// (≥62) through 01-19, then re-enters the band and holds from 01-20.
const type: MeasurementType = "RESTING_HEART_RATE";
const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02");
const episodeDays: VitalDayPoint[] = [
{ day: "2026-01-09", mean: 55 }, // pre-onset, in band
{ day: "2026-01-10", mean: 70 },
{ day: "2026-01-12", mean: 75 }, // nadir
{ day: "2026-01-15", mean: 68 },
{ day: "2026-01-17", mean: 64 }, // felt-better day, still out-of-band
{ day: "2026-01-19", mean: 62 }, // still out-of-band
{ day: "2026-01-20", mean: 55 }, // return start (in band)
{ day: "2026-01-21", mean: 55 },
{ day: "2026-01-22", mean: 55 }, // stable 3 days
];
const out = computeIllnessCorrelation(
input({ series: [{ type, baselineDays, episodeDays }] }),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
// returned 01-20, felt better 01-17 → gap +3
expect(out.value.recoveryGapDays).toBe(dayDiff("2026-01-17", "2026-01-20"));
expect(out.value.recoveryGapDays).toBe(3);
// nadir at 01-12, value 75, adverse (RHR up = adverse)
expect(out.value.nadir[0]?.day).toBe("2026-01-12");
expect(out.value.nadir[0]?.value).toBe(75);
expect(out.value.nadir[0]?.adverse).toBe(true);
expect(out.value.nadir[0]?.direction).toBe("above");
});
it("flags a pre-onset anomaly when the body deviated before onset", () => {
const type: MeasurementType = "HEART_RATE_VARIABILITY";
// HRV baseline 60±2. Drops to 30 two days before onset → adverse (down).
const baselineDays = jitteredBaseline(60, 2, 21, "2026-01-02");
const episodeDays: VitalDayPoint[] = [
{ day: "2026-01-08", mean: 30 }, // pre-onset crash (notable)
{ day: "2026-01-10", mean: 28 },
{ day: "2026-01-12", mean: 32 },
{ day: "2026-01-14", mean: 45 },
{ day: "2026-01-15", mean: 60 },
];
const out = computeIllnessCorrelation(
input({ series: [{ type, baselineDays, episodeDays }] }),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.preOnset[0]?.day).toBe("2026-01-08");
expect(out.value.preOnset[0]?.direction).toBe("below");
expect(out.value.preOnset[0]?.adverse).toBe(true);
});
it("excludes CHRONIC_ONGOING from the recovery-gap", () => {
const type: MeasurementType = "RESTING_HEART_RATE";
const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02");
const episodeDays: VitalDayPoint[] = [
{ day: "2026-01-10", mean: 70 },
{ day: "2026-01-12", mean: 75 },
{ day: "2026-01-15", mean: 55 },
{ day: "2026-01-16", mean: 55 },
{ day: "2026-01-17", mean: 55 },
];
const out = computeIllnessCorrelation(
input({
window: { onsetDay: "2026-01-10", feltBetterDay: "2026-01-15", lifecycle: "CHRONIC_ONGOING" },
series: [{ type, baselineDays, episodeDays }],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.recoveryGapDays).toBeNull();
expect(out.value.returns).toHaveLength(0);
});
it("leaves the gap null when the vital never stably returns (still active)", () => {
const type: MeasurementType = "RESTING_HEART_RATE";
const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02");
const episodeDays: VitalDayPoint[] = [
{ day: "2026-01-10", mean: 72 },
{ day: "2026-01-12", mean: 75 },
{ day: "2026-01-14", mean: 73 },
{ day: "2026-01-16", mean: 70 }, // never returns to band
];
const out = computeIllnessCorrelation(
input({
window: { onsetDay: "2026-01-10", feltBetterDay: null, lifecycle: "ACUTE" },
series: [{ type, baselineDays, episodeDays }],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.recoveryGapDays).toBeNull();
expect(out.value.returns[0]?.returnedDay).toBeNull();
});
});
describe("computeIllnessCorrelation — baseline-contamination guard", () => {
it("uses ONLY baselineDays for the band, never the episode-span values", () => {
// If episode-span spikes contaminated the band, the 75 readings would
// widen the band and the deviation would shrink. We assert the band is
// built purely from baselineDays by comparing two episodes whose
// baselineDays are identical but episodeDays differ wildly — the nadir
// deviationSd for the SAME nadir value must be identical.
const type: MeasurementType = "RESTING_HEART_RATE";
const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02");
const calm = computeIllnessCorrelation(
input({
series: [
{
type,
baselineDays,
episodeDays: [
{ day: "2026-01-10", mean: 56 },
{ day: "2026-01-11", mean: 75 }, // single nadir
{ day: "2026-01-12", mean: 56 },
{ day: "2026-01-13", mean: 55 },
],
},
],
}),
);
const stormy = computeIllnessCorrelation(
input({
series: [
{
type,
baselineDays,
episodeDays: [
{ day: "2026-01-10", mean: 90 },
{ day: "2026-01-11", mean: 75 }, // SAME nadir value as calm
{ day: "2026-01-12", mean: 88 },
{ day: "2026-01-13", mean: 92 },
],
},
],
}),
);
expect(calm.status).toBe("ok");
expect(stormy.status).toBe("ok");
if (calm.status !== "ok" || stormy.status !== "ok") return;
// The band center is the clean baseline median (55) in BOTH episodes —
// the wildly different episode-span values never shift it. A contaminated
// baseline (one that ingested the 75–92 readings) would pull the center
// upward and the two centers would diverge.
const calmCenter = calm.value.nadir[0]?.baselineCenter;
const stormyCenter = stormy.value.nadir[0]?.baselineCenter;
expect(calmCenter).toBe(55);
expect(stormyCenter).toBe(55);
expect(stormyCenter).toBe(calmCenter);
});
});
describe("computeIllnessCorrelation — red-flag escalation", () => {
it("escalates a sustained low SpO2 run", () => {
const type: MeasurementType = "OXYGEN_SATURATION";
const baselineDays = jitteredBaseline(98, 1, 21, "2026-01-02");
const episodeDays: VitalDayPoint[] = [
{ day: "2026-01-10", mean: 91 },
{ day: "2026-01-11", mean: 90 },
{ day: "2026-01-12", mean: 89 }, // 3 consecutive ≤92
{ day: "2026-01-13", mean: 96 },
{ day: "2026-01-14", mean: 98 },
];
const out = computeIllnessCorrelation(
input({ series: [{ type, baselineDays, episodeDays }] }),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.redFlags).toHaveLength(1);
expect(out.value.redFlags[0].reason).toBe("sustained_low_spo2");
expect(out.value.redFlags[0].worstValue).toBe(89);
expect(out.value.redFlags[0].days).toBe(3);
});
it("does not escalate a single low SpO2 day", () => {
const type: MeasurementType = "OXYGEN_SATURATION";
const baselineDays = jitteredBaseline(98, 1, 21, "2026-01-02");
const episodeDays: VitalDayPoint[] = [
{ day: "2026-01-10", mean: 91 }, // one dip only
{ day: "2026-01-11", mean: 97 },
{ day: "2026-01-12", mean: 98 },
{ day: "2026-01-13", mean: 98 },
];
const out = computeIllnessCorrelation(
input({ series: [{ type, baselineDays, episodeDays }] }),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.redFlags).toHaveLength(0);
});
});
@@ -0,0 +1,73 @@
/**
* v1.18.1 P3 — illness retrospective summary aggregation.
*
* Asserts the count/byMonth/byType tallies, the CHRONIC_ONGOING gap exclusion,
* and the thin-data gate: the "typical recovery gap" is WITHHELD below the
* min-sample floor (never a "typical" claim from one episode).
*/
import { describe, expect, it } from "vitest";
import {
summarizeIllnessRetrospective,
MIN_EPISODES_FOR_TYPICAL_GAP,
type RetrospectiveEpisode,
} from "../retrospective";
function ep(over: Partial<RetrospectiveEpisode>): RetrospectiveEpisode {
return {
id: "e",
type: "INFECTION",
onsetDay: "2026-01-10",
resolved: true,
recoveryGapDays: 2,
lifecycle: "ACUTE",
...over,
};
}
describe("summarizeIllnessRetrospective", () => {
it("counts episodes, resolved, by-month, by-type", () => {
const out = summarizeIllnessRetrospective([
ep({ id: "a", onsetDay: "2026-01-05", type: "INFECTION" }),
ep({ id: "b", onsetDay: "2026-11-20", type: "INFECTION", resolved: false, recoveryGapDays: null }),
ep({ id: "c", onsetDay: "2026-11-02", type: "ALLERGY" }),
]);
expect(out.episodeCount).toBe(3);
expect(out.resolvedCount).toBe(2);
expect(out.byMonth[1]).toBe(1);
expect(out.byMonth[11]).toBe(2);
expect(out.byType.INFECTION).toBe(2);
expect(out.byType.ALLERGY).toBe(1);
});
it("withholds the typical gap below the min-sample floor", () => {
const out = summarizeIllnessRetrospective([
ep({ id: "a", recoveryGapDays: 3 }),
ep({ id: "b", recoveryGapDays: 5 }),
]);
expect(MIN_EPISODES_FOR_TYPICAL_GAP).toBe(3);
expect(out.gapSampleSize).toBe(2);
expect(out.typicalRecoveryGapDays).toBeNull();
});
it("reports the median typical gap at/above the floor", () => {
const out = summarizeIllnessRetrospective([
ep({ id: "a", recoveryGapDays: 2 }),
ep({ id: "b", recoveryGapDays: 4 }),
ep({ id: "c", recoveryGapDays: 6 }),
]);
expect(out.gapSampleSize).toBe(3);
expect(out.typicalRecoveryGapDays).toBe(4);
});
it("excludes CHRONIC_ONGOING and null gaps from the median", () => {
const out = summarizeIllnessRetrospective([
ep({ id: "a", recoveryGapDays: 2 }),
ep({ id: "b", recoveryGapDays: 4 }),
ep({ id: "c", recoveryGapDays: 6 }),
ep({ id: "d", recoveryGapDays: 100, lifecycle: "CHRONIC_ONGOING" }),
ep({ id: "e", recoveryGapDays: null }),
]);
expect(out.gapSampleSize).toBe(3);
expect(out.typicalRecoveryGapDays).toBe(4);
});
});
+168
View File
@@ -0,0 +1,168 @@
/**
* Illness correlation engine — the READ layer (v1.18.1, Workstream B / P3).
*
* Resolves the per-vital per-day mean series the pure `computeIllnessCorrelation`
* engine needs, then runs the engine. Two windows per vital, and the seam
* between them is the BASELINE-CONTAMINATION GUARD:
*
* baselineDays : [onset − lookback − BASELINE_WINDOW_DAYS, onset − lookback)
* episodeDays : [onset − lookback, end]
*
* The baseline window ENDS strictly before the pre-onset lookback, so no day
* inside (or just before) the episode can poison the baseline the episode is
* measured against. This is the single most important reliability property of
* the engine — a baseline that includes the illness days would shrink every
* deviation toward zero and silently under-report the very anomaly we exist to
* surface.
*
* Reads are DAY-native (the spread invariant forbids composing a band from a
* coarser tier's `sd`): the rollup DAY tier when covered, a bounded per-type
* live-SQL fallback on a coverage miss. The `readDayMeanSeries` helper the
* baseline engine already exports does exactly this and is reused verbatim, so
* the rollup-vs-live parity the baseline tests pin carries over for free.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
import { resolveUserTimezone } from "@/lib/measurements/consolidation-base";
import type { Derived } from "@/lib/insights/derived/types";
import {
BASELINE_WINDOW_DAYS,
ILLNESS_SCAN_TYPES,
PRE_ONSET_LOOKBACK_DAYS,
computeIllnessCorrelation,
type IllnessCorrelationValue,
type VitalSeries,
} from "./correlation";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** The episode shape the read layer needs (a subset of the Prisma row). */
export interface EpisodeForCorrelation {
id: string;
onsetAt: Date;
resolvedAt: Date | null;
lifecycle: string;
}
/**
* Resolve the per-vital series for one episode (contamination-guarded
* baseline window) and run the pure engine. Returns the same gated
* `Derived<T>` the engine returns — `insufficient` flows straight through.
*/
export async function computeEpisodeCorrelation(
userId: string,
episode: EpisodeForCorrelation,
timezone: string | null,
now: Date = new Date(),
): Promise<Derived<IllnessCorrelationValue>> {
const tz = resolveUserTimezone(timezone);
const onset = episode.onsetAt;
const end = episode.resolvedAt ?? now;
// Window seams (UTC instants; the day keys are read in the user's tz).
const preOnsetStart = new Date(onset.getTime() - PRE_ONSET_LOOKBACK_DAYS * MS_PER_DAY);
const baselineEnd = preOnsetStart; // baseline ends where the lookback starts
const baselineStart = new Date(
baselineEnd.getTime() - BASELINE_WINDOW_DAYS * MS_PER_DAY,
);
const onsetDay = dayKeyForUserTz(onset, tz);
const feltBetterDay = episode.resolvedAt
? dayKeyForUserTz(episode.resolvedAt, tz)
: null;
// One coverage probe shared across every vital read (pool-contention
// mitigation — the same pattern the derived route uses).
const coverage = await probeRollupCoverage(userId);
const series: VitalSeries[] = [];
let anyLive = false;
let anyDay = false;
// We attempt every scan type. The read helper reuses the baseline engine's
// rollup-first path (live fallback on a coverage miss); an untracked vital
// returns empty points and is dropped below. Each read is a single windowed
// query, so attempting all types is bounded.
for (const type of ILLNESS_SCAN_TYPES) {
const baseline = await readWindowMeans(userId, type, baselineStart, baselineEnd, coverage, now);
const episodeWin = await readWindowMeans(userId, type, preOnsetStart, end, coverage, now);
if (baseline.points.length === 0 && episodeWin.points.length === 0) continue;
if (baseline.source === "live" || episodeWin.source === "live") anyLive = true;
if (baseline.source === "DAY" || episodeWin.source === "DAY") anyDay = true;
series.push({
type,
baselineDays: baseline.points.map((p) => ({ day: p.day, mean: p.mean })),
episodeDays: episodeWin.points.map((p) => ({ day: p.day, mean: p.mean })),
});
}
const source: "DAY" | "live" | "none" = anyDay
? "DAY"
: anyLive
? "live"
: "none";
return computeIllnessCorrelation({
episodeId: episode.id,
window: { onsetDay, feltBetterDay, lifecycle: episode.lifecycle },
series,
source,
now,
});
}
/**
* Per-day means for `(userId, type)` over an explicit [from, to) window.
* Reuses the baseline engine's `readDayMeanSeries` (DAY-native rollup with a
* live fallback) for the whole-window read, then trims to the window so the
* baseline and episode windows can share the one cached read path while
* keeping their seams exact. The trim is on the local day key so the
* contamination guard holds in the user's timezone.
*/
async function readWindowMeans(
userId: string,
type: MeasurementType,
from: Date,
to: Date,
coverage: Awaited<ReturnType<typeof probeRollupCoverage>>,
now: Date,
): Promise<{ points: { day: string; mean: number }[]; source: "DAY" | "live" | "none" }> {
// The baseline helper reads a trailing window ending at `now`. We need an
// arbitrary [from, to) window, so read directly here: DAY rollup first via
// the helper for the trailing case, else a bounded raw read. To keep the
// seam exact we always do the bounded raw per-day grouping for the bounded
// window (a single indexed query), and only use the helper's rollup path
// when `to` is effectively `now` (the common active-episode case).
const toIsNow = Math.abs(to.getTime() - now.getTime()) < MS_PER_DAY;
if (toIsNow) {
const windowDays = Math.max(1, Math.ceil((to.getTime() - from.getTime()) / MS_PER_DAY));
const resolved = await readDayMeanSeries(userId, type, windowDays, now, coverage);
const fromKey = from.toISOString().slice(0, 10);
return {
points: resolved.points.filter((p) => p.day >= fromKey),
source: resolved.source === "DAY" ? "DAY" : resolved.source === "none" ? "none" : "live",
};
}
const rows = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null, measuredAt: { gte: from, lt: to } },
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true },
});
if (rows.length === 0) return { points: [], source: "none" };
const byDay = new Map<string, { sum: number; count: number }>();
for (const row of rows) {
const day = row.measuredAt.toISOString().slice(0, 10);
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
acc.sum += row.value;
acc.count += 1;
byDay.set(day, acc);
}
const points = [...byDay.entries()]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([day, acc]) => ({ day, mean: acc.sum / acc.count }));
return { points, source: "live" };
}
+508
View File
@@ -0,0 +1,508 @@
/**
* Illness correlation + recovery-gap engine (v1.18.1, Workstream B / P3).
*
* Server-authoritative, reliability-first. Given ONE illness episode this
* assembles three retrospective findings — never a prediction, never a
* diagnosis — each gated by the shared coverage/confidence model so a thin
* signal asserts NOTHING:
*
* 1. **Pre-onset anomaly scan** — the N days BEFORE onset, each tracked
* vital's deviation in robust SDs from the user's OWN baseline (median
* ± MAD, the same `computeVitalsBaseline` engine), the baseline computed
* from a window that ENDS before the pre-onset lookback so the episode's
* own physiology can never poison the baseline it is measured against.
* "How did it announce itself?"
* 2. **Nadir / what-dropped** — across the episode's active span, the
* furthest each vital strayed from baseline (the nadir day + magnitude +
* direction). "What dropped."
* 3. **Recovery-gap** — the intellectual core: physiological return-to-
* baseline (first day a previously-deviated vital re-enters its band and
* STAYS in for `RETURN_STABILITY_DAYS`) vs the felt-better marker
* (`resolvedAt`). The gap is `physiologicalReturn − feltBetter` in days:
* positive = the body lagged the feeling (you felt better before your
* numbers did), negative = numbers normalised first. CHRONIC_ONGOING is
* excluded (no recovered date).
*
* The directionality of "worse" is metric-specific (a HRV/recovery DROP and a
* resting-HR/temperature RISE both mean "stressed"); `ADVERSE_DIRECTION`
* pins it per metric. Red-flag patterns (a sustained large oxygen-saturation
* drop, a sustained fever) ESCALATE to a "seek care" flag — never reassure.
*
* Pure given its inputs: the caller does the bounded reads (baseline series +
* per-day vital means within the episode window) and passes them in, so the
* whole engine is unit-testable against golden fixtures with no DB. The route
* wires the reads; the parity test asserts the rollup-fed path and a raw-SQL
* path agree.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { buildBaselineBand, median } from "@/lib/insights/derived/baseline";
import {
buildInsufficient,
buildOk,
deriveCoverage,
nowProvenanceTimestamp,
} from "@/lib/insights/derived/coverage";
import type { Derived } from "@/lib/insights/derived/types";
/* ── tunables (documented invariants) ────────────────────────────────── */
/** Days before onset scanned for the "how did it announce itself" signal. */
export const PRE_ONSET_LOOKBACK_DAYS = 7;
/** Trailing baseline window (days), ending BEFORE the pre-onset lookback. */
export const BASELINE_WINDOW_DAYS = 30;
/**
* Minimum distinct baseline days a vital needs before we trust its band.
* Mirrors `computeVitalsBaseline`'s 7-day floor — below this the vital is
* dropped from the scan rather than asserted on a fragile band.
*/
export const MIN_BASELINE_DAYS = 7;
/**
* A deviation of ≥ this many robust SDs (k·MAD·scale units) from baseline
* counts as "notable" for the pre-onset + nadir surfaces. ~2σ-equivalent.
*/
export const NOTABLE_SD = 2;
/**
* Consecutive in-band days required to call a vital "returned to baseline"
* for the recovery-gap (one lucky in-band day is not a recovery).
*/
export const RETURN_STABILITY_DAYS = 3;
/** Minimum episode days with ANY vital coverage before the gap is asserted. */
export const MIN_EPISODE_COVERAGE_DAYS = 4;
/**
* Per-metric adverse direction — which way a reading moves when the body is
* under illness stress. `up` = a RISE is adverse (resting HR, temperature,
* respiratory rate, BP); `down` = a DROP is adverse (HRV, recovery, SpO2).
* Weight/glucose have no single illness-adverse direction → omitted (their
* deviation magnitude is still surfaced, direction stays neutral).
*/
export const ADVERSE_DIRECTION: Partial<Record<MeasurementType, "up" | "down">> =
{
RESTING_HEART_RATE: "up",
PULSE: "up",
BODY_TEMPERATURE: "up",
SKIN_TEMPERATURE: "up",
RESPIRATORY_RATE: "up",
BLOOD_PRESSURE_SYS: "up",
BLOOD_PRESSURE_DIA: "up",
HEART_RATE_VARIABILITY: "down",
RECOVERY_SCORE: "down",
OXYGEN_SATURATION: "down",
};
/**
* Vitals scanned by the illness engine. Superset of the typical-range set
* plus RECOVERY_SCORE (a canonical wellness signal). The caller only passes
* series for the vitals the user actually tracks; the rest are skipped.
*/
export const ILLNESS_SCAN_TYPES: MeasurementType[] = [
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
"RECOVERY_SCORE",
"OXYGEN_SATURATION",
"BODY_TEMPERATURE",
"RESPIRATORY_RATE",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"WEIGHT",
];
/* ── inputs the caller resolves and passes in (pure engine) ──────────── */
/** A per-day mean for one vital (YYYY-MM-DD local day → mean). */
export interface VitalDayPoint {
/** Local day key, `YYYY-MM-DD`. */
day: string;
/** Mean reading for the day. */
mean: number;
}
/**
* Everything the engine needs for ONE vital: the baseline-window series
* (ENDS before the pre-onset lookback — contamination guard lives in the
* caller's read window) and the episode-window series (pre-onset lookback
* through the felt-better marker, inclusive).
*/
export interface VitalSeries {
type: MeasurementType;
/** Per-day means over the clean baseline window (no episode-span days). */
baselineDays: VitalDayPoint[];
/** Per-day means from `onset − lookback` through `resolvedAt` (or today). */
episodeDays: VitalDayPoint[];
}
/** The dates the engine reasons over, all `YYYY-MM-DD` local day keys. */
export interface EpisodeWindow {
/** Onset day. */
onsetDay: string;
/** Felt-better marker (resolvedAt local day), or null when still active. */
feltBetterDay: string | null;
/** Lifecycle — CHRONIC_ONGOING suppresses the recovery-gap. */
lifecycle: string;
}
export interface IllnessCorrelationInput {
episodeId: string;
window: EpisodeWindow;
/** One entry per vital the user tracks (others omitted by the caller). */
series: VitalSeries[];
/** Provenance source the dominant read resolved against. */
source: "DAY" | "live" | "none";
/** Compute time (injected for deterministic tests). */
now?: Date;
}
/* ── output value (the server-authoritative DTO payload) ─────────────── */
/** One vital's deviation finding on a given day. */
export interface VitalDeviationFinding {
type: MeasurementType;
/** The day this finding describes (`YYYY-MM-DD`). */
day: string;
/** The day's mean reading. */
value: number;
/** Robust baseline center the deviation is measured from. */
baselineCenter: number;
/** Signed deviation in robust-SD (k·MAD·scale) units; +above / −below. */
deviationSd: number;
/** Which way the reading moved. */
direction: "above" | "below";
/** True when this move is the illness-adverse direction for the metric. */
adverse: boolean;
}
/** A vital's recovery timing within the episode. */
export interface VitalReturnFinding {
type: MeasurementType;
/** First day the vital re-entered its band AND stayed in for the window. */
returnedDay: string | null;
/** Days from felt-better to physiological return (signed); null if N/A. */
gapDays: number | null;
}
export interface IllnessCorrelationValue {
episodeId: string;
/** "How did it announce itself" — notable pre-onset deviations. */
preOnset: VitalDeviationFinding[];
/** "What dropped" — each vital's worst (nadir) deviation in the episode. */
nadir: VitalDeviationFinding[];
/** Per-vital physiological-return timing. */
returns: VitalReturnFinding[];
/**
* The headline recovery-gap in days (median of the per-vital gaps), or
* null when CHRONIC_ONGOING / still active / no vital returned. Positive
* = the body lagged the felt-better marker.
*/
recoveryGapDays: number | null;
/** Felt-better marker echoed for the surface. */
feltBetterDay: string | null;
/**
* Red-flag escalation — a sustained adverse SpO2 drop or sustained fever
* during the episode. Retrospective, but the copy must escalate ("if this
* recurs, seek care"), NEVER reassure. Empty when nothing tripped.
*/
redFlags: IllnessRedFlag[];
}
export interface IllnessRedFlag {
type: MeasurementType;
/** A short stable reason key the i18n layer renders (not free text). */
reason: "sustained_low_spo2" | "sustained_fever";
/** The worst observed value during the flagged run. */
worstValue: number;
/** Consecutive days the adverse threshold held. */
days: number;
}
/* ── red-flag thresholds (clinical floors, conservative) ─────────────── */
/** SpO2 at/below this for ≥ RED_FLAG_RUN_DAYS escalates. */
const SPO2_RED_FLAG = 92;
/** Body temperature at/above this (°C) for ≥ RED_FLAG_RUN_DAYS escalates. */
const FEVER_RED_FLAG = 38.5;
/** Consecutive days an adverse clinical threshold must hold to escalate. */
const RED_FLAG_RUN_DAYS = 3;
/* ── pure helpers ─────────────────────────────────────────────────────── */
/** Robust band for a vital, or null below the min-baseline-days floor. */
function bandFor(series: VitalSeries): {
center: number;
spread: number;
} | null {
if (series.baselineDays.length < MIN_BASELINE_DAYS) return null;
const band = buildBaselineBand(series.baselineDays.map((p) => p.mean));
if (!band || band.spread <= 0) return null;
return { center: band.center, spread: band.spread };
}
/** Signed deviation of a value in robust-SD units (spread = k·MAD·scale). */
function deviationSd(value: number, center: number, spread: number): number {
return (value - center) / spread;
}
/** Build a deviation finding for one day/value against a band. */
function finding(
type: MeasurementType,
day: string,
value: number,
center: number,
spread: number,
): VitalDeviationFinding {
const sd = deviationSd(value, center, spread);
const direction: "above" | "below" = sd >= 0 ? "above" : "below";
const adverseDir = ADVERSE_DIRECTION[type];
const adverse =
(adverseDir === "up" && direction === "above") ||
(adverseDir === "down" && direction === "below");
return {
type,
day,
value,
baselineCenter: center,
deviationSd: Math.round(sd * 100) / 100,
direction,
adverse,
};
}
/** Order day keys chronologically. */
function byDay(a: { day: string }, b: { day: string }): number {
return a.day < b.day ? -1 : a.day > b.day ? 1 : 0;
}
/* ── the engine ───────────────────────────────────────────────────────── */
/**
* Compute the retrospective correlation findings for one episode. Pure given
* its inputs — the caller resolves the reads (rollup tier with a live-SQL
* fallback) and passes the series in. Returns a gated `Derived<T>`: below the
* coverage floor it returns `insufficient` (never a fabricated finding).
*/
export function computeIllnessCorrelation(
input: IllnessCorrelationInput,
): Derived<IllnessCorrelationValue> {
const now = input.now ?? new Date();
const computedAt = nowProvenanceTimestamp(now);
const { window } = input;
// Only vitals with a trustworthy own-baseline band participate.
const banded = input.series
.map((s) => ({ series: s, band: bandFor(s) }))
.filter((b): b is { series: VitalSeries; band: { center: number; spread: number } } => b.band !== null);
const inputs = banded.map((b) => String(b.series.type));
const provenance = {
inputs,
source: input.source,
windowDays: BASELINE_WINDOW_DAYS,
computedAt,
};
// Coverage: how many distinct episode days carry ANY banded vital reading.
const episodeDayKeys = new Set<string>();
for (const b of banded) {
for (const p of b.series.episodeDays) {
if (p.day >= window.onsetDay) episodeDayKeys.add(p.day);
}
}
const coverageDays = episodeDayKeys.size;
if (banded.length === 0 || coverageDays < MIN_EPISODE_COVERAGE_DAYS) {
const { coverage } = deriveCoverage({
requiredInputs: 1,
presentInputs: banded.length,
historyDays: coverageDays,
missing: banded.length === 0 ? ILLNESS_SCAN_TYPES.map(String) : [],
fullHistoryDays: MIN_EPISODE_COVERAGE_DAYS,
});
return buildInsufficient<IllnessCorrelationValue>({
coverage,
provenance,
reason:
banded.length === 0
? "no_baselined_vitals"
: "insufficient_episode_coverage",
});
}
const preOnset: VitalDeviationFinding[] = [];
const nadir: VitalDeviationFinding[] = [];
const returns: VitalReturnFinding[] = [];
const redFlags: IllnessRedFlag[] = [];
for (const { series, band } of banded) {
const { type } = series;
const { center, spread } = band;
const episode = [...series.episodeDays].sort(byDay);
// 1) Pre-onset scan: lookback window strictly before onset.
const pre = episode.filter((p) => p.day < window.onsetDay);
let worstPre: VitalDeviationFinding | null = null;
for (const p of pre) {
const f = finding(type, p.day, p.mean, center, spread);
if (
Math.abs(f.deviationSd) >= NOTABLE_SD &&
(worstPre === null ||
Math.abs(f.deviationSd) > Math.abs(worstPre.deviationSd))
) {
worstPre = f;
}
}
if (worstPre) preOnset.push(worstPre);
// 2) Nadir: worst deviation across the active span (onset → end).
const active = episode.filter((p) => p.day >= window.onsetDay);
let worstActive: VitalDeviationFinding | null = null;
for (const p of active) {
const f = finding(type, p.day, p.mean, center, spread);
if (
Math.abs(f.deviationSd) >= NOTABLE_SD &&
(worstActive === null ||
Math.abs(f.deviationSd) > Math.abs(worstActive.deviationSd))
) {
worstActive = f;
}
}
if (worstActive) nadir.push(worstActive);
// 3) Physiological return: first active day this vital deviated
// notably-adverse, then later re-entered the band and STAYED in for
// RETURN_STABILITY_DAYS. Only meaningful if it actually deviated.
const deviatedActive = active.some((p) => {
const f = finding(type, p.day, p.mean, center, spread);
return Math.abs(f.deviationSd) >= NOTABLE_SD;
});
if (deviatedActive && window.lifecycle !== "CHRONIC_ONGOING") {
const returnedDay = firstStableReturn(active, center, spread);
const gapDays =
returnedDay && window.feltBetterDay
? dayDiff(window.feltBetterDay, returnedDay)
: null;
returns.push({ type, returnedDay, gapDays });
}
// Red-flag escalation (clinical floors, retrospective).
const flag = detectRedFlag(type, active);
if (flag) redFlags.push(flag);
}
// Headline gap = median of the per-vital signed gaps (robust to one vital
// recovering oddly). Null for CHRONIC_ONGOING / still active / no returns.
const gaps = returns
.map((r) => r.gapDays)
.filter((g): g is number => g !== null);
const recoveryGapDays =
window.lifecycle === "CHRONIC_ONGOING" || gaps.length === 0
? null
: Math.round(median(gaps));
const { coverage, confidence } = deriveCoverage({
requiredInputs: 1,
presentInputs: banded.length,
historyDays: coverageDays,
missing: [],
fullHistoryDays: Math.max(MIN_EPISODE_COVERAGE_DAYS, 14),
});
return buildOk<IllnessCorrelationValue>({
value: {
episodeId: input.episodeId,
preOnset: preOnset.sort((a, b) => Math.abs(b.deviationSd) - Math.abs(a.deviationSd)),
nadir: nadir.sort((a, b) => Math.abs(b.deviationSd) - Math.abs(a.deviationSd)),
returns,
recoveryGapDays,
feltBetterDay: window.feltBetterDay,
redFlags,
},
coverage,
confidence,
provenance,
});
}
/**
* First day in `active` (chronological) 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.
*/
function firstStableReturn(
active: VitalDayPoint[],
center: number,
spread: number,
): string | null {
const inBand = active.map((p) => ({
day: p.day,
in: Math.abs(deviationSd(p.mean, center, spread)) < NOTABLE_SD,
}));
for (let i = 0; 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;
}
/**
* Detect a retrospective red flag: a sustained adverse SpO2 drop or a
* sustained fever during the active span. Conservative clinical floors;
* escalation copy lives in the i18n layer.
*/
function detectRedFlag(
type: MeasurementType,
active: VitalDayPoint[],
): IllnessRedFlag | null {
const sorted = [...active].sort(byDay);
if (type === "OXYGEN_SATURATION") {
return runFlag(sorted, (v) => v <= SPO2_RED_FLAG, "sustained_low_spo2", type, "min");
}
if (type === "BODY_TEMPERATURE") {
return runFlag(sorted, (v) => v >= FEVER_RED_FLAG, "sustained_fever", type, "max");
}
return null;
}
/** Longest consecutive run matching `predicate`; flag when ≥ RED_FLAG_RUN_DAYS. */
function runFlag(
sorted: VitalDayPoint[],
predicate: (v: number) => boolean,
reason: IllnessRedFlag["reason"],
type: MeasurementType,
worst: "min" | "max",
): IllnessRedFlag | null {
let bestRun = 0;
let bestWorst: number | null = null;
let run = 0;
let runWorst: number | null = null;
for (const p of sorted) {
if (predicate(p.mean)) {
run++;
runWorst =
runWorst === null
? p.mean
: worst === "min"
? Math.min(runWorst, p.mean)
: Math.max(runWorst, p.mean);
if (run > bestRun) {
bestRun = run;
bestWorst = runWorst;
}
} else {
run = 0;
runWorst = null;
}
}
if (bestRun >= RED_FLAG_RUN_DAYS && bestWorst !== null) {
return { type, reason, worstValue: bestWorst, days: bestRun };
}
return null;
}
/** Whole-day signed difference `to − from` for two `YYYY-MM-DD` keys. */
export function dayDiff(from: string, to: string): number {
const a = Date.parse(`${from}T00:00:00Z`);
const b = Date.parse(`${to}T00:00:00Z`);
return Math.round((b - a) / (24 * 60 * 60 * 1000));
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Illness retrospective summary (v1.18.1, Workstream B / P3).
*
* The look-back surface: "sick N times this year · typical recovery gap X
* days". RETROSPECTIVE ONLY — it summarises what already happened. It is
* NEVER a predictor or diagnoser; the recurrence figure is a count of past
* episodes, not a forecast, and the copy layer must frame it as such.
*
* Pure given a list of resolved episode summaries + their computed gaps. The
* caller resolves the per-episode recovery-gap (via the correlation engine,
* which is itself coverage-gated) and passes the numbers in; this layer only
* aggregates. The aggregate is itself gated — with too few resolved episodes
* to say anything honest, it reports the count but withholds the "typical
* gap" (asserts nothing thin).
*/
import { median } from "@/lib/insights/derived/baseline";
/** Minimum resolved-with-gap episodes before a "typical gap" is asserted. */
export const MIN_EPISODES_FOR_TYPICAL_GAP = 3;
/** One episode's contribution to the summary. */
export interface RetrospectiveEpisode {
id: string;
type: string;
/** Onset local day `YYYY-MM-DD` (for the recurrence-month tally). */
onsetDay: string;
/** Whether the episode is resolved (has a felt-better marker). */
resolved: boolean;
/** The computed recovery-gap in days, or null when ungated/unavailable. */
recoveryGapDays: number | null;
/** Lifecycle — CHRONIC_ONGOING never contributes a gap. */
lifecycle: string;
}
export interface IllnessRetrospectiveSummary {
/** Episodes whose onset falls in the requested window (e.g. trailing year). */
episodeCount: number;
/** Of those, how many are resolved. */
resolvedCount: number;
/**
* Typical (median) recovery-gap in days across episodes with a computed
* gap — null when fewer than `MIN_EPISODES_FOR_TYPICAL_GAP` qualify. A
* positive gap means the body typically lagged the felt-better marker.
*/
typicalRecoveryGapDays: number | null;
/** How many episodes backed the typical-gap figure (transparency). */
gapSampleSize: number;
/**
* Recurrence tally by calendar month (1–12) → count, retrospective only.
* Drives "you tend to log illnesses in November" as an OBSERVATION, never
* a prediction. Empty when nothing falls in the window.
*/
byMonth: Record<number, number>;
/** Per-type episode counts in the window (retrospective breakdown). */
byType: Record<string, number>;
}
/**
* Aggregate the per-episode figures into the retrospective summary. Pure. The
* typical-gap is withheld (null) below the min-sample floor so a single
* episode never becomes a "typical" claim.
*/
export function summarizeIllnessRetrospective(
episodes: RetrospectiveEpisode[],
): IllnessRetrospectiveSummary {
const byMonth: Record<number, number> = {};
const byType: Record<string, number> = {};
let resolvedCount = 0;
const gaps: number[] = [];
for (const e of episodes) {
const month = Number(e.onsetDay.slice(5, 7));
if (month >= 1 && month <= 12) byMonth[month] = (byMonth[month] ?? 0) + 1;
byType[e.type] = (byType[e.type] ?? 0) + 1;
if (e.resolved) resolvedCount++;
if (
e.lifecycle !== "CHRONIC_ONGOING" &&
e.recoveryGapDays !== null &&
Number.isFinite(e.recoveryGapDays)
) {
gaps.push(e.recoveryGapDays);
}
}
const typicalRecoveryGapDays =
gaps.length >= MIN_EPISODES_FOR_TYPICAL_GAP
? Math.round(median(gaps))
: null;
return {
episodeCount: episodes.length,
resolvedCount,
typicalRecoveryGapDays,
gapSampleSize: gaps.length,
byMonth,
byType,
};
}
+13
View File
@@ -18,4 +18,17 @@ export const illnessKeys = {
illnessEpisode: (id: string) => ["illness", "episode", id] as const,
illnessDayLog: (episodeId: string, date: string) =>
["illness", "day-log", episodeId, date] as const,
/**
* v1.18.1 P3 — the per-episode retrospective correlation findings
* (pre-onset scan, nadir, recovery-gap). Server-authoritative; the read
* is gated, so the surface pattern-matches `status` rather than recomputing.
*/
illnessCorrelation: (id: string) =>
["illness", "correlation", id] as const,
/**
* v1.18.1 P3 — the cross-episode retrospective summary ("sick N times ·
* typical recovery gap X days") over a trailing window in days.
*/
illnessInsights: (windowDays: number) =>
["illness", "insights", windowDays] as const,
};
+11
View File
@@ -133,3 +133,14 @@ export type IllnessDayLogInput = z.infer<typeof illnessDayLogInputSchema>;
/** The single-day read query: `GET .../day-logs?date=YYYY-MM-DD`. */
export const illnessDayLogQuerySchema = z.object({ date: dateString });
/* ── P3 retrospective insight window ─────────────────────────────────── */
/**
* The retrospective-insight window query: `GET /api/illness/insights?
* windowDays=365`. Bounded 30..1095 days; defaults to a trailing year.
* Retrospective only — the engine summarises past episodes, never forecasts.
*/
export const illnessInsightsQuerySchema = z.object({
windowDays: z.coerce.number().int().min(30).max(1095).optional(),
});