mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
a7b2b61b99
commit
745fa28c8b
16 files changed
+1737
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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(),
|
||||
});
|
||||
Reference in new issue
Block a user