From 95a2a7692f17b8c6c205468826554e098a2f393e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 20:37:57 +0200 Subject: [PATCH] feat(insights): add composite scores, score-anatomy view, and correlation discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the composite tier of the derived-metrics layer on the shared seam: - Sleep score — a transparent 0–100 composite from sufficiency, efficiency, consistency, timing and composition sub-scores over the per-stage sleep rows, reweighting around any sub-score the data cannot support (a legacy asleep-only night drops composition gracefully rather than fabricating it). - Wellness/readiness index — extends the health-score null-redistribution blend with resting-HR, HRV (SDNN), sleep, respiratory-rate and mood deviation-from-baseline components; returns insufficient below two present components so it never headlines from one signal. - Coincident-deviation flag — counts vitals outside their personal band on the latest day and fires when two or more coincide, listing the contributing vitals as possible factors, never a cause or a diagnosis. Each composite cites its standard, carries coverage + confidence + provenance, and reweights around missing inputs. Add an FDR-controlled correlation discovery engine behind GET /api/insights/correlations: it scans a curated behaviour x outcome matrix, lag-joins each behaviour day to the next day's outcome, runs Pearson with the exact Student-t p-value, and applies Benjamini-Hochberg control across every tested pair so only statistically-defensible patterns surface — each tagged with n, r, p and the adjusted q, framed descriptive not causal. Add the score-anatomy detail view at /insights/scores/: a full score ring, ranked contributor rows each showing its push/pull and weight share, and the cited-standard provenance explainer — reused by every composite, mobile-first and accessible. Regenerate the OpenAPI contract for the new metric values + the correlation discovery response, and add the i18n keys across all six locales. --- docs/api/openapi.yaml | 107 ++++ messages/de.json | 34 ++ messages/en.json | 34 ++ messages/es.json | 34 ++ messages/fr.json | 34 ++ messages/it.json | 34 ++ messages/pl.json | 34 ++ .../correlations/__tests__/route.test.ts | 33 +- src/app/api/insights/correlations/route.ts | 137 ++++- src/app/insights/scores/[metric]/page.tsx | 43 ++ .../__tests__/score-anatomy-view.test.tsx | 88 ++++ .../derived/composite-score-anatomy.tsx | 161 ++++++ src/components/insights/derived/index.ts | 5 + .../insights/derived/score-anatomy-view.tsx | 227 +++++++++ src/hooks/use-derived-metric.ts | 72 +++ .../__tests__/correlation-discovery.test.ts | 97 ++++ src/lib/insights/correlation-discovery.ts | 252 ++++++++++ .../__tests__/coincident-deviation.test.ts | 108 ++++ .../derived/__tests__/dispatch.test.ts | 53 +- .../derived/__tests__/readiness.test.ts | 117 +++++ .../derived/__tests__/sleep-score.test.ts | 173 +++++++ .../insights/derived/coincident-deviation.ts | 228 +++++++++ src/lib/insights/derived/dispatch.ts | 15 + src/lib/insights/derived/index.ts | 46 ++ src/lib/insights/derived/readiness.ts | 391 +++++++++++++++ src/lib/insights/derived/registry.ts | 6 +- src/lib/insights/derived/sleep-score.ts | 473 ++++++++++++++++++ src/lib/openapi/routes.ts | 63 +++ src/lib/query-keys.ts | 18 + 29 files changed, 3087 insertions(+), 30 deletions(-) create mode 100644 src/app/insights/scores/[metric]/page.tsx create mode 100644 src/components/insights/derived/__tests__/score-anatomy-view.test.tsx create mode 100644 src/components/insights/derived/composite-score-anatomy.tsx create mode 100644 src/components/insights/derived/score-anatomy-view.tsx create mode 100644 src/hooks/use-derived-metric.ts create mode 100644 src/lib/insights/__tests__/correlation-discovery.test.ts create mode 100644 src/lib/insights/correlation-discovery.ts create mode 100644 src/lib/insights/derived/__tests__/coincident-deviation.test.ts create mode 100644 src/lib/insights/derived/__tests__/readiness.test.ts create mode 100644 src/lib/insights/derived/__tests__/sleep-score.test.ts create mode 100644 src/lib/insights/derived/coincident-deviation.ts create mode 100644 src/lib/insights/derived/readiness.ts create mode 100644 src/lib/insights/derived/sleep-score.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3e810ff0f..9e63b701b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2371,6 +2371,26 @@ paths: "401": *a1 "422": *a2 "429": *a3 + /api/insights/correlations: + get: + tags: + - Insights + summary: Correlation discovery (FDR-controlled) + description: v1.10.0 — scans a curated behaviour × outcome matrix (daylight / mood / glucose / BP / steps × sleep / HRV + / resting HR / weight), lag-joins each behaviour day to the next day's outcome, runs Pearson with the exact + Student-t p-value, and applies Benjamini-Hochberg FDR control across every tested pair. Only + statistically-defensible pairs surface, each carrying n, r, p, and the BH-adjusted q. Descriptive, never causal. + Gated by the operator `correlations` assistant surface. Auth via cookie or Bearer. + responses: + "200": + description: The discovered correlations + the tested-pair count. + content: + application/json: + schema: + $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" + "401": *a1 + "422": *a2 + "429": *a3 components: schemas: PasskeyLoginVerifyRequest: @@ -7461,6 +7481,93 @@ components: - windowDays - computedAt additionalProperties: false + CorrelationDiscoveryResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CorrelationDiscoveryResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CorrelationDiscoveryResponse: + type: object + properties: + discovered: + type: array + items: + $ref: "#/components/schemas/DiscoveredCorrelation" + description: Pairs surviving n ≥ 20, p < 0.05, AND the BH-FDR control. + pairsTested: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Behaviour × outcome pairs assessed (for the honest footer). + fdrQ: + type: number + description: The FDR target the surface used. + minPairs: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Minimum paired-day count enforced per pair. + required: + - discovered + - pairsTested + - fdrQ + - minPairs + additionalProperties: false + description: v1.10.0 — FDR-controlled correlation discovery over a curated behaviour × outcome matrix, lagged behaviour + → next-day outcome. Only statistically-defensible pairs surface; descriptive, never causal. + DiscoveredCorrelation: + type: object + properties: + behaviour: + type: string + description: Behaviour channel (lag source), e.g. TIME_IN_DAYLIGHT, MOOD. + outcome: + type: string + description: Outcome channel (lag target), e.g. SLEEP_DURATION, HEART_RATE_VARIABILITY. + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Paired-day count after the day+1 lag join (≥ 20). + r: + type: number + description: Pearson r over the lag-joined daily series. + pValue: + type: number + description: Two-sided exact Student-t p-value (< 0.05). + qValue: + type: number + description: Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold). + interpretation: + type: string + description: Conservative, descriptive interpretation — never causal. + lagDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Lag in days applied (1). + required: + - behaviour + - outcome + - n + - r + - pValue + - qValue + - interpretation + - lagDays + additionalProperties: false MedicationScheduleOutput: type: object properties: diff --git a/messages/de.json b/messages/de.json index e05531307..cec242b40 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2511,6 +2511,40 @@ "overweight": "Übergewicht (WHO)", "obese": "Adipositas (WHO)" } + }, + "anatomy": { + "outOf": "/100", + "contributorsLabel": "Faktoren", + "contributorValue": "{value} · {weight}% Gewicht", + "contributorMissing": "keine Daten", + "disclaimer": "Richtwert — keine klinische Bewertung.", + "insufficient": "Noch nicht genug Daten für eine Bewertung. Tracke weiter, dann erscheint sie hier." + }, + "composite": { + "SLEEP_SCORE": { + "title": "Schlaf-Score", + "description": "Ein transparenter Schlafqualitäts-Score mit allen Teilwerten.", + "method": "Eine 0–100-Mischung aus Schlafmenge (Schlaf vs. altersbasierter Bedarf), Effizienz (Schlaf ÷ Bettzeit), Konstanz (wie stabil deine Schlafmitte ist), Timing (vs. deine gewohnte Schlafmitte) und Zusammensetzung (REM- + Tiefschlafanteil). Teilwerte werden umgewichtet, wenn deine Daten einen nicht stützen; nichts wird erfunden.", + "sub": { + "sufficiency": "Schlafmenge", + "efficiency": "Effizienz", + "consistency": "Konstanz", + "timing": "Timing", + "composition": "Zusammensetzung" + } + }, + "READINESS": { + "title": "Bereitschaft", + "description": "Ein täglicher Wohlbefindens-Indikator daraus, wie deine Werte zu deiner eigenen Baseline stehen.", + "method": "Eine 0–100-Mischung aus Ruhepuls-Abweichung, HRV-(SDNN-)Abweichung, Schlaf, Atemfrequenz-Stabilität und Stimmungsstabilität — jeweils gegen deine persönliche Baseline gemessen. Gewichte werden über die von deinen Daten gestützten Komponenten neu verteilt. Ein Wohlbefindens-Indikator, kein Erholungs- oder klinischer Score.", + "component": { + "rhr": "Ruhepuls", + "hrv": "HRV (SDNN)", + "sleep": "Schlaf", + "respiratory": "Atemfrequenz", + "mood": "Stimmungsstabilität" + } + } } } }, diff --git a/messages/en.json b/messages/en.json index da93a9e3a..a59731193 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2511,6 +2511,40 @@ "overweight": "Overweight (WHO)", "obese": "Obese (WHO)" } + }, + "anatomy": { + "outOf": "/100", + "contributorsLabel": "Contributors", + "contributorValue": "{value} · {weight}% weight", + "contributorMissing": "no data", + "disclaimer": "Indicative — not a clinical assessment.", + "insufficient": "Not enough data yet to score this. Keep tracking and it will appear here." + }, + "composite": { + "SLEEP_SCORE": { + "title": "Sleep score", + "description": "A transparent sleep-quality score with every sub-score shown.", + "method": "A 0–100 blend of sufficiency (asleep vs your age-based need), efficiency (asleep ÷ in-bed), consistency (how steady your sleep midpoint is), timing (vs your habitual midpoint) and composition (REM + deep share). Sub-scores reweight around any that your data cannot support; nothing is fabricated.", + "sub": { + "sufficiency": "Sufficiency", + "efficiency": "Efficiency", + "consistency": "Consistency", + "timing": "Timing", + "composition": "Composition" + } + }, + "READINESS": { + "title": "Readiness", + "description": "A daily wellness proxy from how your signals sit against your own baseline.", + "method": "A 0–100 blend of resting-HR deviation, HRV (SDNN) deviation, sleep adequacy, respiratory-rate stability and mood stability — each measured against your personal baseline. Weights renormalise over the components your data supports. A wellness proxy, not a recovery or clinical score.", + "component": { + "rhr": "Resting heart rate", + "hrv": "HRV (SDNN)", + "sleep": "Sleep", + "respiratory": "Respiratory rate", + "mood": "Mood stability" + } + } } } }, diff --git a/messages/es.json b/messages/es.json index 6e4f16b4d..5cc0d1f34 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2511,6 +2511,40 @@ "overweight": "Sobrepeso (OMS)", "obese": "Obesidad (OMS)" } + }, + "anatomy": { + "outOf": "/100", + "contributorsLabel": "Factores", + "contributorValue": "{value} · {weight}% de peso", + "contributorMissing": "sin datos", + "disclaimer": "Indicativo — no es una evaluación clínica.", + "insufficient": "Aún no hay datos suficientes para puntuarlo. Sigue registrando y aparecerá aquí." + }, + "composite": { + "SLEEP_SCORE": { + "title": "Puntuación de sueño", + "description": "Una puntuación de calidad del sueño transparente con todos sus subvalores.", + "method": "Una mezcla de 0–100 de suficiencia (sueño frente a tu necesidad por edad), eficiencia (sueño ÷ tiempo en cama), consistencia (qué tan estable es tu punto medio de sueño), horario (frente a tu punto medio habitual) y composición (proporción de REM + sueño profundo). Los subvalores se reponderan en torno a los que tus datos no pueden sustentar; nada se inventa.", + "sub": { + "sufficiency": "Suficiencia", + "efficiency": "Eficiencia", + "consistency": "Consistencia", + "timing": "Horario", + "composition": "Composición" + } + }, + "READINESS": { + "title": "Disposición", + "description": "Un indicador diario de bienestar según cómo se sitúan tus señales frente a tu propia base.", + "method": "Una mezcla de 0–100 de desviación de la frecuencia cardíaca en reposo, desviación de la VFC (SDNN), calidad del sueño, estabilidad de la frecuencia respiratoria y estabilidad del estado de ánimo — cada una medida frente a tu base personal. Los pesos se renormalizan según los componentes que tus datos sustentan. Un indicador de bienestar, no una puntuación de recuperación ni clínica.", + "component": { + "rhr": "Frecuencia cardíaca en reposo", + "hrv": "VFC (SDNN)", + "sleep": "Sueño", + "respiratory": "Frecuencia respiratoria", + "mood": "Estabilidad del ánimo" + } + } } } }, diff --git a/messages/fr.json b/messages/fr.json index 2835dcd36..5b6d1d20a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2511,6 +2511,40 @@ "overweight": "Surpoids (OMS)", "obese": "Obésité (OMS)" } + }, + "anatomy": { + "outOf": "/100", + "contributorsLabel": "Facteurs", + "contributorValue": "{value} · {weight}% du poids", + "contributorMissing": "aucune donnée", + "disclaimer": "Indicatif — pas une évaluation clinique.", + "insufficient": "Pas encore assez de données pour l’évaluer. Continuez à enregistrer et cela apparaîtra ici." + }, + "composite": { + "SLEEP_SCORE": { + "title": "Score de sommeil", + "description": "Un score de qualité du sommeil transparent avec chaque sous-score affiché.", + "method": "Un mélange de 0–100 entre suffisance (sommeil vs votre besoin selon l’âge), efficacité (sommeil ÷ temps au lit), régularité (stabilité de votre milieu de sommeil), timing (vs votre milieu habituel) et composition (part de REM + sommeil profond). Les sous-scores sont repondérés autour de ceux que vos données ne peuvent pas étayer ; rien n’est inventé.", + "sub": { + "sufficiency": "Suffisance", + "efficiency": "Efficacité", + "consistency": "Régularité", + "timing": "Timing", + "composition": "Composition" + } + }, + "READINESS": { + "title": "Disponibilité", + "description": "Un indicateur quotidien de bien-être selon la position de vos signaux par rapport à votre propre référence.", + "method": "Un mélange de 0–100 entre l’écart de la fréquence cardiaque au repos, l’écart de la VFC (SDNN), la qualité du sommeil, la stabilité de la fréquence respiratoire et la stabilité de l’humeur — chacun mesuré par rapport à votre référence personnelle. Les poids sont renormalisés sur les composantes que vos données étayent. Un indicateur de bien-être, pas un score de récupération ni clinique.", + "component": { + "rhr": "Fréquence cardiaque au repos", + "hrv": "VFC (SDNN)", + "sleep": "Sommeil", + "respiratory": "Fréquence respiratoire", + "mood": "Stabilité de l’humeur" + } + } } } }, diff --git a/messages/it.json b/messages/it.json index 5dc16442c..0905faeff 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2511,6 +2511,40 @@ "overweight": "Sovrappeso (OMS)", "obese": "Obesità (OMS)" } + }, + "anatomy": { + "outOf": "/100", + "contributorsLabel": "Fattori", + "contributorValue": "{value} · {weight}% di peso", + "contributorMissing": "nessun dato", + "disclaimer": "Indicativo — non è una valutazione clinica.", + "insufficient": "Dati ancora insufficienti per assegnare un punteggio. Continua a registrare e comparirà qui." + }, + "composite": { + "SLEEP_SCORE": { + "title": "Punteggio del sonno", + "description": "Un punteggio di qualità del sonno trasparente con ogni sotto-punteggio mostrato.", + "method": "Una combinazione 0–100 di sufficienza (sonno vs il tuo bisogno in base all’età), efficienza (sonno ÷ tempo a letto), costanza (quanto è stabile il punto medio del tuo sonno), orario (vs il tuo punto medio abituale) e composizione (quota di REM + sonno profondo). I sotto-punteggi vengono ripesati attorno a quelli che i tuoi dati non possono supportare; nulla è inventato.", + "sub": { + "sufficiency": "Sufficienza", + "efficiency": "Efficienza", + "consistency": "Costanza", + "timing": "Orario", + "composition": "Composizione" + } + }, + "READINESS": { + "title": "Prontezza", + "description": "Un indicatore quotidiano di benessere in base a come i tuoi segnali si collocano rispetto alla tua baseline.", + "method": "Una combinazione 0–100 di deviazione della frequenza cardiaca a riposo, deviazione della HRV (SDNN), qualità del sonno, stabilità della frequenza respiratoria e stabilità dell’umore — ciascuna misurata rispetto alla tua baseline personale. I pesi vengono rinormalizzati sui componenti supportati dai tuoi dati. Un indicatore di benessere, non un punteggio di recupero né clinico.", + "component": { + "rhr": "Frequenza cardiaca a riposo", + "hrv": "HRV (SDNN)", + "sleep": "Sonno", + "respiratory": "Frequenza respiratoria", + "mood": "Stabilità dell’umore" + } + } } } }, diff --git a/messages/pl.json b/messages/pl.json index c7f892257..73256201c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2511,6 +2511,40 @@ "overweight": "Nadwaga (WHO)", "obese": "Otyłość (WHO)" } + }, + "anatomy": { + "outOf": "/100", + "contributorsLabel": "Czynniki", + "contributorValue": "{value} · {weight}% wagi", + "contributorMissing": "brak danych", + "disclaimer": "Orientacyjne — to nie ocena kliniczna.", + "insufficient": "Za mało danych, by to ocenić. Rejestruj dalej, a wynik pojawi się tutaj." + }, + "composite": { + "SLEEP_SCORE": { + "title": "Wynik snu", + "description": "Przejrzysty wynik jakości snu z każdym podwynikiem.", + "method": "Mieszanka 0–100 z wystarczalności (sen vs zapotrzebowanie według wieku), wydajności (sen ÷ czas w łóżku), regularności (jak stabilny jest środek twojego snu), pory (vs twój zwykły środek snu) i struktury (udział REM + snu głębokiego). Podwyniki są ważone na nowo wokół tych, których dane nie wspierają; nic nie jest zmyślane.", + "sub": { + "sufficiency": "Wystarczalność", + "efficiency": "Wydajność", + "consistency": "Regularność", + "timing": "Pora", + "composition": "Struktura" + } + }, + "READINESS": { + "title": "Gotowość", + "description": "Codzienny wskaźnik samopoczucia oparty na tym, jak twoje sygnały wypadają względem twojej własnej bazy.", + "method": "Mieszanka 0–100 z odchylenia tętna spoczynkowego, odchylenia HRV (SDNN), jakości snu, stabilności częstości oddechu i stabilności nastroju — każde mierzone względem twojej osobistej bazy. Wagi są renormalizowane na komponentach wspieranych przez twoje dane. Wskaźnik samopoczucia, nie wynik regeneracji ani kliniczny.", + "component": { + "rhr": "Tętno spoczynkowe", + "hrv": "HRV (SDNN)", + "sleep": "Sen", + "respiratory": "Częstość oddechu", + "mood": "Stabilność nastroju" + } + } } } }, diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 213ec066c..ae31fdea0 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -5,6 +5,9 @@ vi.mock("@/lib/db", () => ({ prisma: { auditLog: { create: vi.fn() }, appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + user: { findUnique: vi.fn().mockResolvedValue({ timezone: "Europe/Berlin" }) }, + measurement: { findMany: vi.fn().mockResolvedValue([]) }, + moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, }, })); @@ -31,7 +34,6 @@ vi.mock("next/headers", () => ({ import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; -import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; const SESSION_OK = { @@ -47,6 +49,16 @@ beforeEach(() => { (prisma.appSettings.findUnique as ReturnType).mockResolvedValue( null, ); + // v1.10.0 — the discovery engine reads the profile timezone + the + // measurement / mood series. Default to an empty corpus so the engine + // returns no discovered pairs (the n ≥ 20 gate trivially fails). + (prisma.user.findUnique as ReturnType).mockResolvedValue({ + timezone: "Europe/Berlin", + }); + (prisma.measurement.findMany as ReturnType).mockResolvedValue( + [], + ); + (prisma.moodEntry.findMany as ReturnType).mockResolvedValue([]); }); const callGet = GET as unknown as (req: NextRequest) => Promise; @@ -61,21 +73,16 @@ describe("GET /api/insights/correlations", () => { expect(res.status).toBe(401); }); - it("returns an empty array as the placeholder", async () => { + it("returns the discovery result shape (no pairs on an empty corpus)", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const res = await callGet(makeReq()); expect(res.status).toBe(200); - const body = (await res.json()) as { data: unknown[] }; - expect(body.data).toEqual([]); - }); - - it("writes the empty-state audit log", async () => { - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - await callGet(makeReq()); - expect(auditLog).toHaveBeenCalledWith( - "insights.correlations.empty", - expect.objectContaining({ userId: "user-1" }), - ); + const body = (await res.json()) as { + data: { discovered: unknown[]; pairsTested: number; fdrQ: number }; + }; + expect(body.data.discovered).toEqual([]); + expect(body.data.pairsTested).toBe(0); + expect(body.data.fdrQ).toBeGreaterThan(0); }); it("returns 403 + errorCode when the correlations flag is off", async () => { diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index a45cc8cf5..df48026fb 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -1,29 +1,142 @@ /** - * GET /api/insights/correlations — placeholder iOS endpoint. + * GET /api/insights/correlations — FDR-controlled correlation discovery. * - * The full correlations engine is part of `/api/insights/comprehensive` - * and is intentionally not re-exposed yet — iOS only needs a stable - * shape to render the empty state. We return `[]` and emit an audit log - * so the gap is visible until Phase 7 fills it in. + * v1.10.0 — promotes the former placeholder to the real discovery engine. + * Scans a curated behaviour × outcome matrix (daylight / mood / glucose / + * BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour + * day to the NEXT day's outcome, runs Pearson with the exact Student-t + * p-value, and applies Benjamini-Hochberg FDR control across every tested + * pair so only statistically-defensible patterns surface. Every surfaced + * pair carries n, r, p, and the BH-adjusted q, framed descriptive — never + * causal. + * + * Reads daily series bounded to a trailing window, day-keyed in the user's + * display timezone (late-night readings mis-bucket under UTC). Pure compute + * lives in `src/lib/insights/correlation-discovery.ts`; this route only + * fetches + day-keys + responds. No LLM, no narrative, no cache table. */ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; -import { auditLog } from "@/lib/auth/audit"; import { requireAssistantSurface } from "@/lib/feature-flags"; +import { prisma } from "@/lib/db"; +import { getLocalDateParts } from "@/lib/timezone"; +import type { MeasurementType } from "@/generated/prisma/client"; +import { + discoverCorrelations, + DISCOVERY_BEHAVIOURS, + DISCOVERY_OUTCOMES, + type DailySeriesPoint, + type NamedSeries, +} from "@/lib/insights/correlation-discovery"; export const dynamic = "force-dynamic"; +const MS_PER_DAY = 24 * 60 * 60 * 1000; +/** Trailing window for the discovery scan (days). */ +const WINDOW_DAYS = 180; + +/** Day key (YYYY-MM-DD) for an instant in the user's display timezone. */ +function tzDayKey(at: Date, tz: string): string { + const { year, month, day } = getLocalDateParts(at, tz); + const mm = String(month).padStart(2, "0"); + const dd = String(day).padStart(2, "0"); + return `${year}-${mm}-${dd}`; +} + +/** Collapse rows to per-day means keyed in the user's tz. */ +function toDailyMeans( + rows: Array<{ value: number; at: Date }>, + tz: string, +): DailySeriesPoint[] { + const byDay = new Map(); + for (const r of rows) { + if (!Number.isFinite(r.value)) continue; + const day = tzDayKey(r.at, tz); + const acc = byDay.get(day) ?? { sum: 0, count: 0 }; + acc.sum += r.value; + acc.count += 1; + byDay.set(day, acc); + } + return [...byDay.entries()] + .map(([day, acc]) => ({ day, value: acc.sum / acc.count })) + .sort((a, b) => (a.day < b.day ? -1 : 1)); +} + export const GET = apiHandler(async () => { const { user } = await requireAuth(); - // v1.4.31 — operator can hide the correlation narration tile. + // Operator can hide the correlation surface entirely. await requireAssistantSurface("correlations"); - annotate({ action: { name: "insights.correlations" } }); - await auditLog("insights.correlations.empty", { - userId: user.id, - details: { reason: "phase7_pending" }, + const profile = await prisma.user.findUnique({ + where: { id: user.id }, + select: { timezone: true }, + }); + const tz = profile?.timezone ?? "Europe/Berlin"; + const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY); + + // Behaviour channels are measurement-backed except MOOD (mood entries). + const behaviourTypes = DISCOVERY_BEHAVIOURS.filter( + (k) => k !== "MOOD", + ) as MeasurementType[]; + const outcomeTypes = [...DISCOVERY_OUTCOMES] as MeasurementType[]; + + const [measurements, moodEntries] = await Promise.all([ + prisma.measurement.findMany({ + where: { + userId: user.id, + deletedAt: null, + measuredAt: { gte: since }, + type: { in: [...behaviourTypes, ...outcomeTypes] }, + }, + orderBy: { measuredAt: "asc" }, + take: 20000, + select: { type: true, value: true, measuredAt: true }, + }), + prisma.moodEntry.findMany({ + where: { userId: user.id, deletedAt: null, moodLoggedAt: { gte: since } }, + orderBy: { moodLoggedAt: "asc" }, + take: 5000, + select: { score: true, moodLoggedAt: true }, + }), + ]); + + const measurementsByType = new Map>(); + for (const m of measurements) { + const list = measurementsByType.get(m.type) ?? []; + list.push({ value: m.value, at: m.measuredAt }); + measurementsByType.set(m.type, list); + } + + const series: NamedSeries[] = []; + for (const key of DISCOVERY_BEHAVIOURS) { + const points = + key === "MOOD" + ? toDailyMeans( + moodEntries.map((e) => ({ value: e.score, at: e.moodLoggedAt })), + tz, + ) + : toDailyMeans(measurementsByType.get(key) ?? [], tz); + series.push({ key, role: "behaviour", points }); + } + for (const key of DISCOVERY_OUTCOMES) { + series.push({ + key, + role: "outcome", + points: toDailyMeans(measurementsByType.get(key) ?? [], tz), + }); + } + + const result = discoverCorrelations(series); + + annotate({ + action: { name: "insights.correlations.discover" }, + meta: { + pairs_tested: result.pairsTested, + discovered: result.discovered.length, + fdr_q: result.fdrQ, + }, }); - return apiSuccess([]); + return apiSuccess(result); }); diff --git a/src/app/insights/scores/[metric]/page.tsx b/src/app/insights/scores/[metric]/page.tsx new file mode 100644 index 000000000..71802fdcc --- /dev/null +++ b/src/app/insights/scores/[metric]/page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useParams } from "next/navigation"; + +import { useTranslations } from "@/lib/i18n/context"; +import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { + CompositeScoreAnatomy, + type CompositeMetricId, +} from "@/components/insights/derived/composite-score-anatomy"; + +/** + * v1.10.0 — `/insights/scores/` — the composite score-anatomy + * detail surface. + * + * Reached on tap from a composite's dashboard / insights entry. Renders the + * Oura-style anatomy view (big ring + ranked contributor rows + cited + * standard) for one composite. The `[metric]` param is validated against the + * two W3 composites — an unknown slug falls back to the readiness view's + * insufficient state rather than 404-ing, so a stale link never dead-ends. + */ + +const SLUG_TO_METRIC: Record = { + sleep: "SLEEP_SCORE", + readiness: "READINESS", +}; + +export default function CompositeScorePage() { + const params = useParams<{ metric: string }>(); + const { t } = useTranslations(); + const slug = typeof params.metric === "string" ? params.metric : ""; + const metric: CompositeMetricId = SLUG_TO_METRIC[slug] ?? "READINESS"; + + return ( + + + + ); +} diff --git a/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx b/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx new file mode 100644 index 000000000..ddca7518a --- /dev/null +++ b/src/components/insights/derived/__tests__/score-anatomy-view.test.tsx @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ScoreAnatomyView, type AnatomyContributor } from "../score-anatomy-view"; +import { I18nProvider } from "@/lib/i18n/context"; + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +const COVERAGE = { + requiredInputs: 5, + presentInputs: 4, + historyDays: 21, + missing: ["respiratory"], +}; +const CONFIDENCE = { score: 72, band: "medium" as const }; +const PROVENANCE = { + inputs: ["RESTING_HEART_RATE", "HEART_RATE_VARIABILITY"], + source: "DAY" as const, + windowDays: 30, + computedAt: "2026-06-02T07:00:00+02:00", +}; +const CONTRIBUTORS: AnatomyContributor[] = [ + { key: "rhr", label: "Resting heart rate", value: 80, weight: 0.3 }, + { key: "hrv", label: "HRV (SDNN)", value: 60, weight: 0.3 }, + { key: "respiratory", label: "Respiratory rate", value: null, weight: 0 }, +]; + +describe("", () => { + it("renders the ok state: ring + contributor rows + provenance", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="score-anatomy-view"'); + expect(html).toContain('data-status="ok"'); + expect(html).toContain('data-slot="score-ring"'); + // Each contributor renders a row with its stable key attr. + expect(html).toContain('data-contributor="rhr"'); + expect(html).toContain('data-contributor="hrv"'); + // The missing contributor renders present=false (dropped, not blank). + expect(html).toContain('data-contributor="respiratory"'); + expect(html).toContain('data-present="false"'); + expect(html).toContain('data-slot="provenance-explainer-trigger"'); + expect(html).toContain('data-slot="score-anatomy-disclaimer"'); + }); + + it("renders the insufficient state with the provisional ring + nudge", () => { + const html = render( + , + ); + expect(html).toContain('data-status="insufficient"'); + expect(html).toContain('data-provisional="true"'); // the ring's empty state + expect(html).toContain('data-slot="score-anatomy-insufficient"'); + // No contributor rows in the insufficient state. + expect(html).not.toContain('data-slot="anatomy-contributor-row"'); + }); +}); diff --git a/src/components/insights/derived/composite-score-anatomy.tsx b/src/components/insights/derived/composite-score-anatomy.tsx new file mode 100644 index 000000000..09c9bcadb --- /dev/null +++ b/src/components/insights/derived/composite-score-anatomy.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useTranslations } from "@/lib/i18n/context"; +import { useDerivedMetric } from "@/hooks/use-derived-metric"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { + ReadinessValue, + SleepScoreValue, +} from "@/lib/insights/derived"; +import { + ScoreAnatomyView, + type AnatomyContributor, +} from "./score-anatomy-view"; +import type { ProvenanceStandard } from "./provenance-explainer"; + +/** + * v1.10.0 — the data-bound wrapper that fetches a composite derived metric + * and renders the reusable `ScoreAnatomyView`. It owns the per-metric + * presentation map (title, contributor labels, the cited standard, the + * plain-language method) so the anatomy view itself stays metric-agnostic. + * + * Supports the W3 composites: `SLEEP_SCORE` and `READINESS`. Each maps its + * `value` (sub-scores / components) onto the normalised `AnatomyContributor` + * list and supplies its cited standard + method copy from the i18n bundle. + * + * Client-only: `import type` for the value shapes (no server graph leaks); + * the fetch rides the same `/api/insights/derived` route + 8 s client + * ceiling as the rest of the surface. + */ + +export type CompositeMetricId = "SLEEP_SCORE" | "READINESS"; + +/** The cited standard per composite (the explainer's external link). */ +const STANDARDS: Record = { + SLEEP_SCORE: { + name: "Hirshkowitz et al. 2015, Sleep Health", + url: "https://doi.org/10.1016/j.sleh.2014.12.010", + }, + READINESS: { + name: "Plews et al. 2013, Sports Medicine", + url: "https://doi.org/10.1007/s40279-013-0071-8", + }, +}; + +export interface CompositeScoreAnatomyProps { + metric: CompositeMetricId; + className?: string; +} + +export function CompositeScoreAnatomy({ + metric, + className, +}: CompositeScoreAnatomyProps) { + const { t } = useTranslations(); + + const sleep = useDerivedMetric("SLEEP_SCORE", { + enabled: metric === "SLEEP_SCORE", + }); + const readiness = useDerivedMetric("READINESS", { + enabled: metric === "READINESS", + }); + + const query = metric === "SLEEP_SCORE" ? sleep : readiness; + + if (query.isLoading) { + return ( +
+ +
+ ); + } + + const data = query.data; + const title = t(`insights.derived.composite.${metric}.title`); + const standard = STANDARDS[metric]; + + if (!data) { + // Network/abort fallback — render the insufficient state honestly. + return ( + + ); + } + + const insufficient = data.status !== "ok"; + let score: number | null = null; + let contributors: AnatomyContributor[] = []; + let caption: string | undefined; + + if (data.status === "ok" && data.value) { + if (metric === "SLEEP_SCORE") { + const v = data.value as SleepScoreValue; + score = v.score; + caption = t(`insights.derived.scoreRing.band.${v.band}`); + contributors = v.subScores + .map((s) => ({ + key: s.key, + label: t(`insights.derived.composite.SLEEP_SCORE.sub.${s.key}`), + value: s.value, + weight: s.weight, + })) + .sort(rankByImpact); + } else { + const v = data.value as ReadinessValue; + score = v.score; + caption = t(`insights.derived.scoreRing.band.${v.band}`); + contributors = v.components + .map((c) => ({ + key: c.key, + label: t(`insights.derived.composite.READINESS.component.${c.key}`), + value: c.value, + weight: c.weight, + })) + .sort(rankByImpact); + } + } + + return ( + + ); +} + +/** Rank contributors by impact: present-and-higher-weight first. */ +function rankByImpact(a: AnatomyContributor, b: AnatomyContributor): number { + const aPresent = a.value != null ? 1 : 0; + const bPresent = b.value != null ? 1 : 0; + if (aPresent !== bPresent) return bPresent - aPresent; + return b.weight - a.weight; +} diff --git a/src/components/insights/derived/index.ts b/src/components/insights/derived/index.ts index fc4afbf01..a86a06aea 100644 --- a/src/components/insights/derived/index.ts +++ b/src/components/insights/derived/index.ts @@ -18,6 +18,11 @@ export { type ProvenanceExplainerProps, type ProvenanceStandard, } from "./provenance-explainer"; +export { + ScoreAnatomyView, + type ScoreAnatomyViewProps, + type AnatomyContributor, +} from "./score-anatomy-view"; export { bandForScore, clampScore, diff --git a/src/components/insights/derived/score-anatomy-view.tsx b/src/components/insights/derived/score-anatomy-view.tsx new file mode 100644 index 000000000..8a2b59310 --- /dev/null +++ b/src/components/insights/derived/score-anatomy-view.tsx @@ -0,0 +1,227 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import type { + DerivedConfidence, + DerivedCoverage, + DerivedProvenance, +} from "@/lib/insights/derived/types"; +import { ScoreRing } from "./score-ring"; +import { CoverageMeter } from "./coverage-meter"; +import { + ProvenanceExplainer, + type ProvenanceStandard, +} from "./provenance-explainer"; +import { + bandForScore, + BAND_PROGRESS_CLASS, + clampScore, + type ScoreBand, +} from "./band-tokens"; + +/** + * v1.10.0 — the Oura-style score-anatomy detail view (the Hybrid + * direction's "reinhauen" surface). + * + * A full-bleed `ScoreRing` + ranked contributor rows + a cited-standard + * `ProvenanceExplainer`. Each contributor row is one input's push/pull on + * the score: a label, an impact bar (band-coloured, width = the + * contributor's 0..100 value), and the contributor's own mini coverage + * (its effective weight share). Reused by every composite (sleep score, + * readiness, …) — the caller normalises its `value` into the + * `contributors` list, so this component stays metric-agnostic. + * + * Renders the same three states the rest of the design system speaks: + * - ok → ring + contributors + coverage + provenance + * - insufficient → the ring's provisional state + the coverage "track N" + * nudge (never a blank, never a fabricated number) + * + * Mobile-first (the ring is large where there is room), dark-first, a11y: + * the ring restates the number in its aria-label; contributor bars carry a + * text value (never colour-only); the provenance explainer threads + * aria-describedby. Plain React text children only — no markdown library. + */ + +/** One normalised contributor the view ranks by impact. */ +export interface AnatomyContributor { + /** Stable key (drives the data-attr + the localised label lookup by caller). */ + key: string; + /** Localised display label. */ + label: ReactNode; + /** 0..100 contribution score, or null when the input was missing (dropped). */ + value: number | null; + /** Effective weight share after null-redistribution, 0..1. */ + weight: number; +} + +export interface ScoreAnatomyViewProps { + /** Localised metric title (e.g. "Readiness", "Sleep score"). */ + title: ReactNode; + /** The composite 0..100 score, or null for the insufficient/empty state. */ + score: number | null; + /** Band override; derived from the score when omitted. */ + band?: ScoreBand; + /** Short caption under the ring (e.g. a band word or a one-line summary). */ + caption?: ReactNode; + /** Ranked contributors (the caller pre-ranks; the view renders in order). */ + contributors: AnatomyContributor[]; + coverage: DerivedCoverage; + confidence?: DerivedConfidence | null; + provenance: DerivedProvenance; + /** Plain-language method shown in the provenance explainer. */ + method: ReactNode; + /** Cited standard rendered as an external link in the explainer footer. */ + standard?: ProvenanceStandard; + /** When true the score is unavailable — render the provisional state. */ + insufficient?: boolean; + /** Localised "not enough data" nudge for the insufficient state. */ + insufficientNote?: ReactNode; + className?: string; +} + +export function ScoreAnatomyView({ + title, + score, + band, + caption, + contributors, + coverage, + confidence, + provenance, + method, + standard, + insufficient = false, + insufficientNote, + className, +}: ScoreAnatomyViewProps) { + const { t } = useTranslations(); + const effectiveScore = insufficient ? null : score; + + return ( +
+ {/* Hero: title + ring + caption */} +
+

+ {title} +

+ + {caption && !insufficient ? ( +

+ {caption} +

+ ) : null} + +
+ + {insufficient ? ( +

+ {insufficientNote ?? + t("insights.derived.anatomy.insufficient")} +

+ ) : ( +
+

+ {t("insights.derived.anatomy.contributorsLabel")} +

+
    + {contributors.map((c) => ( + + ))} +
+
+ )} + + {/* Provenance + cited standard + the non-clinical disclaimer */} +
+

+ {t("insights.derived.anatomy.disclaimer")} +

+ +
+
+ ); +} + +/** One ranked contributor row — label + impact bar + value + weight share. */ +function ContributorRow({ contributor }: { contributor: AnatomyContributor }) { + const { t } = useTranslations(); + const present = contributor.value != null && Number.isFinite(contributor.value); + const clamped = present ? clampScore(contributor.value as number) : 0; + const rowBand: ScoreBand = bandForScore(clamped); + const weightPct = Math.round(contributor.weight * 100); + + return ( +
  • +
    + + {contributor.label} + + + {present + ? t("insights.derived.anatomy.contributorValue", { + value: Math.round(clamped), + weight: weightPct, + }) + : t("insights.derived.anatomy.contributorMissing")} + +
    +
    +
    +
    +
  • + ); +} diff --git a/src/hooks/use-derived-metric.ts b/src/hooks/use-derived-metric.ts new file mode 100644 index 000000000..2da6959dd --- /dev/null +++ b/src/hooks/use-derived-metric.ts @@ -0,0 +1,72 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { useAuth } from "@/hooks/use-auth"; +import { queryKeys } from "@/lib/query-keys"; +import type { + DerivedConfidence, + DerivedCoverage, + DerivedProvenance, +} from "@/lib/insights/derived/types"; + +/** + * v1.10.0 — client loader for one derived wellness metric. + * + * Reads the flat `Derived` envelope off `/api/insights/derived?metric=…` + * (the same shape the score-anatomy view + iOS decode). Pure compute on the + * server — sub-second on a warm rollup tenant — so the hook bounds the fetch + * on an 8 s client ceiling (a slow response means a degraded network, not a + * provider call), keeps a 60 s `staleTime`, and never inline-retries. + * + * `T` is the metric-specific value shape (e.g. `ReadinessValue`, + * `SleepScoreValue`); the caller supplies it so the anatomy view stays + * strongly typed without a second cast. + */ + +const DERIVED_TIMEOUT_MS = 8_000; + +/** The flat wire shape the derived route returns inside `{ data }`. */ +export interface DerivedMetricEnvelope { + metric: string; + status: "ok" | "insufficient"; + value: T | null; + coverage: DerivedCoverage; + confidence: DerivedConfidence | null; + provenance: DerivedProvenance; + reason: string | null; +} + +export function useDerivedMetric( + metric: string, + options: { type?: string; enabled?: boolean } = {}, +) { + const { isAuthenticated } = useAuth(); + const enabled = (options.enabled ?? true) && isAuthenticated && metric !== ""; + + return useQuery({ + queryKey: queryKeys.insightsDerivedMetric(metric, options.type), + queryFn: async (): Promise> => { + const controller = new AbortController(); + const timeoutHandle = setTimeout( + () => controller.abort(), + DERIVED_TIMEOUT_MS, + ); + try { + const params = new URLSearchParams({ metric }); + if (options.type) params.set("type", options.type); + const res = await fetch(`/api/insights/derived?${params.toString()}`, { + signal: controller.signal, + }); + if (!res.ok) throw new Error("Failed"); + const json = (await res.json()) as { data: DerivedMetricEnvelope }; + return json.data; + } finally { + clearTimeout(timeoutHandle); + } + }, + enabled, + staleTime: 60 * 1000, + retry: 0, + }); +} diff --git a/src/lib/insights/__tests__/correlation-discovery.test.ts b/src/lib/insights/__tests__/correlation-discovery.test.ts new file mode 100644 index 000000000..d472e2863 --- /dev/null +++ b/src/lib/insights/__tests__/correlation-discovery.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; + +import { + discoverCorrelations, + lagJoin, + benjaminiHochberg, + type DailySeriesPoint, + type NamedSeries, +} from "../correlation-discovery"; + +/** Build a contiguous daily series from day-1 of a month. */ +function series(values: number[], startDay = 1): DailySeriesPoint[] { + return values.map((value, i) => ({ + day: `2026-03-${String(startDay + i).padStart(2, "0")}`, + value, + })); +} + +describe("lagJoin", () => { + it("pairs a behaviour day with the next day's outcome", () => { + const behaviour = series([1, 2, 3]); // Mar 1,2,3 + const outcome = series([10, 20, 30]); // Mar 1,2,3 + const { xs, ys } = lagJoin(behaviour, outcome, 1); + // Mar1→Mar2 (1,20), Mar2→Mar3 (2,30); Mar3→Mar4 has no outcome. + expect(xs).toEqual([1, 2]); + expect(ys).toEqual([20, 30]); + }); +}); + +describe("benjaminiHochberg", () => { + it("adjusts p-values monotonically and never exceeds 1", () => { + const q = benjaminiHochberg([0.01, 0.04, 0.5]); + expect(q.every((v) => v <= 1 && v >= 0)).toBe(true); + // Smallest p gets the smallest q under the monotone step-up. + expect(q[0]).toBeLessThanOrEqual(q[1]); + }); + + it("controls discovery — a single tiny p stays significant, noise inflates", () => { + // 1 true (p=0.001) + 19 noise (p≈0.5): BH keeps the true one well under q. + const ps = [0.001, ...Array.from({ length: 19 }, () => 0.5)]; + const q = benjaminiHochberg(ps); + expect(q[0]).toBeLessThan(0.05); + expect(q[1]).toBeGreaterThan(0.1); + }); +}); + +describe("discoverCorrelations", () => { + it("returns none when no pair clears the n ≥ 20 gate", () => { + const behaviours: NamedSeries[] = [ + { key: "MOOD", role: "behaviour", points: series([3, 4, 5]) }, + ]; + const outcomes: NamedSeries[] = [ + { key: "SLEEP_DURATION", role: "outcome", points: series([400, 410, 420]) }, + ]; + const result = discoverCorrelations([...behaviours, ...outcomes]); + expect(result.discovered).toHaveLength(0); + expect(result.pairsTested).toBe(0); + }); + + it("surfaces a strong lagged pair and tags n, r, p, q", () => { + // 30 days: outcome[d+1] tracks behaviour[d] linearly → strong r. + const n = 30; + const behaviourVals = Array.from({ length: n }, (_, i) => i + (i % 3)); + // Outcome on day d+1 mirrors behaviour on day d (shift by one). + const outcomeVals = [0, ...behaviourVals.slice(0, n - 1).map((v) => v * 2 + 5)]; + const result = discoverCorrelations( + [ + { key: "TIME_IN_DAYLIGHT", role: "behaviour", points: series(behaviourVals) }, + { key: "SLEEP_DURATION", role: "outcome", points: series(outcomeVals) }, + ], + { fdrQ: 0.1 }, + ); + expect(result.pairsTested).toBe(1); + expect(result.discovered).toHaveLength(1); + const pair = result.discovered[0]; + expect(pair.behaviour).toBe("TIME_IN_DAYLIGHT"); + expect(pair.outcome).toBe("SLEEP_DURATION"); + expect(pair.n).toBeGreaterThanOrEqual(20); + expect(pair.pValue).toBeLessThan(0.05); + expect(pair.qValue).toBeLessThanOrEqual(0.1); + expect(pair.interpretation).toMatch(/not a cause/); + expect(pair.lagDays).toBe(1); + }); + + it("drops a pure-noise pair under FDR control", () => { + // 30 days of independent-ish noise → no defensible correlation. + const noiseA = Array.from({ length: 30 }, (_, i) => Math.sin(i) * 10 + 50); + const noiseB = Array.from({ length: 30 }, (_, i) => Math.cos(i * 1.7) * 10 + 50); + const result = discoverCorrelations([ + { key: "BLOOD_GLUCOSE", role: "behaviour", points: series(noiseA) }, + { key: "WEIGHT", role: "outcome", points: series(noiseB) }, + ]); + // It is tested (n ≥ 20) but should not survive p < 0.05 + FDR. + expect(result.pairsTested).toBe(1); + expect(result.discovered).toHaveLength(0); + }); +}); diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts new file mode 100644 index 000000000..8a86f6a4d --- /dev/null +++ b/src/lib/insights/correlation-discovery.ts @@ -0,0 +1,252 @@ +/** + * v1.10.0 — FDR-controlled correlation discovery engine. + * + * Promotes the legacy three-fixed-hypothesis `correlations.ts` to a bounded + * ALL-PAIRS discovery over a curated behaviour × outcome matrix, lagged + * (a behaviour on day D vs an outcome on day D+1), Benjamini-Hochberg + * FDR-controlled on top of the existing exact Student-t / n ≥ 20 / p < 0.05 + * gates. Only statistically-defensible pairs surface, each with an honest + * "descriptive, not causal" caveat. + * + * Why FDR: scanning many pairs inflates the family-wise false-discovery + * rate — at p < 0.05 alone, 1-in-20 noise pairs surface as "significant". + * Benjamini & Hochberg (1995), J. R. Stat. Soc. B 57(1):289–300 controls + * the expected proportion of false discoveries among the surfaced pairs to + * `FDR_Q` (default 0.10). A pair surfaces only when its rank-adjusted q + * clears the threshold — so the card count stays honest as the matrix grows. + * + * Lagging: a behaviour (today's daylight, mood, glucose, BP) is paired with + * the NEXT day's outcome (sleep, HRV, resting HR, weight). The lag encodes + * the plausible direction ("more daylight today → better sleep tonight") + * without claiming causation — the caveat is displayed verbatim. + * + * Pearson + the exact incomplete-beta p-value are reused from + * `src/lib/insights/correlations.ts` (its docstring explicitly anticipates + * this auto-discovery widening the pair grid). This module is PURE over + * already-fetched daily series — the DB read lives in the route. + * + * Framing discipline: descriptive, never causal; every pair carries n, r, + * p, and the BH-adjusted q; medication compliance is NOT yet a behaviour + * channel (the cadence-aware per-day rate needs the compliance engine — a + * later wave), documented here so the omission is intentional, not a gap. + */ +import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations"; + +/** Default Benjamini-Hochberg target false-discovery rate. */ +export const FDR_Q = 0.1; + +/** A single day's value for one metric (daily mean / sum). */ +export interface DailySeriesPoint { + /** Day key YYYY-MM-DD. */ + day: string; + value: number; +} + +/** A named daily series feeding the discovery matrix. */ +export interface NamedSeries { + /** Stable channel key (e.g. "TIME_IN_DAYLIGHT", "MOOD"). */ + key: string; + /** Whether the channel is a behaviour (lag source) or an outcome (lag target). */ + role: "behaviour" | "outcome"; + points: DailySeriesPoint[]; +} + +/** One discovered, FDR-surviving correlation pair. */ +export interface DiscoveredCorrelation { + behaviour: string; + outcome: string; + /** Paired-day count after the day+1 lag join. */ + n: number; + /** Pearson r (lag-joined). */ + r: number; + /** Two-sided exact Student-t p-value. */ + pValue: number; + /** Benjamini-Hochberg adjusted q-value. */ + qValue: number; + /** Conservative, descriptive interpretation — never causal. */ + interpretation: string; + /** Lag in days applied (always 1 here). */ + lagDays: number; +} + +export interface CorrelationDiscoveryResult { + /** Pairs surviving n ≥ 20, p < 0.05, AND the BH-FDR control. */ + discovered: DiscoveredCorrelation[]; + /** How many behaviour × outcome pairs were tested (for the honest footer). */ + pairsTested: number; + /** The FDR target the surface used. */ + fdrQ: number; + /** Minimum paired-day count enforced per pair. */ + minPairs: number; +} + +/** Add `lagDays` to a YYYY-MM-DD day key, returning the shifted key. */ +function shiftDay(day: string, lagDays: number): string { + const [y, m, d] = day.split("-").map(Number); + const dt = new Date(Date.UTC(y, m - 1, d)); + dt.setUTCDate(dt.getUTCDate() + lagDays); + return dt.toISOString().slice(0, 10); +} + +/** + * Lag-join a behaviour series (day D) against an outcome series (day D+lag). + * Returns the paired (behaviour, outcome) value arrays. Pure. + */ +export function lagJoin( + behaviour: DailySeriesPoint[], + outcome: DailySeriesPoint[], + lagDays: number, +): { xs: number[]; ys: number[] } { + const outcomeByDay = new Map(outcome.map((p) => [p.day, p.value])); + const xs: number[] = []; + const ys: number[] = []; + for (const b of behaviour) { + const target = outcomeByDay.get(shiftDay(b.day, lagDays)); + if (target != null && Number.isFinite(b.value) && Number.isFinite(target)) { + xs.push(b.value); + ys.push(target); + } + } + return { xs, ys }; +} + +interface RawPair { + behaviour: string; + outcome: string; + n: number; + r: number; + pValue: number; +} + +/** + * Apply the Benjamini-Hochberg step-up procedure to a set of p-values. + * Returns the BH-adjusted q-value per input index (monotone-enforced from + * the largest rank down), so a caller can surface pairs whose q ≤ `fdrQ`. + * Pure. `m` is the number of tests (the full family), which can exceed + * `pValues.length` if some pairs were dropped before ranking — but here we + * pass every tested pair, so `m === pValues.length`. + */ +export function benjaminiHochberg( + pValues: number[], + m: number = pValues.length, +): number[] { + const indexed = pValues.map((p, i) => ({ p, i })); + indexed.sort((a, b) => a.p - b.p); + const q = new Array(pValues.length); + // Walk from the largest p (rank m) down, enforcing monotonicity so a + // smaller p never gets a larger q than a larger p. + let prev = 1; + for (let rank = indexed.length; rank >= 1; rank--) { + const { p, i } = indexed[rank - 1]; + const raw = (p * m) / rank; + prev = Math.min(prev, raw); + q[i] = Math.min(1, prev); + } + return q; +} + +/** + * Build a conservative, descriptive interpretation for a surviving pair. + * Sign of `r` flips the phrasing; never claims causation. + */ +function interpret(behaviour: string, outcome: string, r: number): string { + const b = humanise(behaviour); + const o = humanise(outcome); + if (r <= -0.1) { + return `Higher ${b} tends to go with lower next-day ${o} in your data — a pattern worth watching, not a cause.`; + } + if (r >= 0.1) { + return `Higher ${b} tends to go with higher next-day ${o} in your data — a pattern worth watching, not a cause.`; + } + return `${b} and next-day ${o} move together only weakly in this window.`; +} + +/** Lower-case, space-separated label from a channel key. */ +function humanise(key: string): string { + return key.replace(/_/g, " ").toLowerCase(); +} + +/** + * Run the FDR-controlled discovery over the behaviour × outcome matrix. + * + * 1. For every (behaviour, outcome) pair, lag-join (D → D+1) and run + * Pearson with the exact p-value. Pairs below `minPairs` paired days + * are dropped (not tested — they cannot be defensibly assessed). + * 2. Across the tested pairs, compute BH q-values. + * 3. Surface pairs with p < 0.05 AND q ≤ `fdrQ`, ranked by q then |r|. + * + * Pure — the caller fetches the daily series. + */ +export function discoverCorrelations( + series: NamedSeries[], + opts: { lagDays?: number; minPairs?: number; fdrQ?: number } = {}, +): CorrelationDiscoveryResult { + const lagDays = opts.lagDays ?? 1; + const minPairs = opts.minPairs ?? MIN_PAIRED_N; + const fdrQ = opts.fdrQ ?? FDR_Q; + + const behaviours = series.filter((s) => s.role === "behaviour"); + const outcomes = series.filter((s) => s.role === "outcome"); + + const tested: RawPair[] = []; + for (const b of behaviours) { + for (const o of outcomes) { + if (b.key === o.key) continue; + const { xs, ys } = lagJoin(b.points, o.points, lagDays); + if (xs.length < minPairs) continue; + const result = pearson({ xs, ys, minPairs }); + if (result.status !== "ok") continue; + tested.push({ + behaviour: b.key, + outcome: o.key, + n: result.n, + r: result.r, + pValue: result.pValue, + }); + } + } + + const pairsTested = tested.length; + if (pairsTested === 0) { + return { discovered: [], pairsTested: 0, fdrQ, minPairs }; + } + + const qValues = benjaminiHochberg(tested.map((t) => t.pValue)); + const discovered: DiscoveredCorrelation[] = tested + .map((t, i) => ({ ...t, qValue: qValues[i] })) + .filter((t) => t.pValue < 0.05 && t.qValue <= fdrQ) + .map((t) => ({ + behaviour: t.behaviour, + outcome: t.outcome, + n: t.n, + r: t.r, + pValue: t.pValue, + qValue: Math.round(t.qValue * 1000) / 1000, + interpretation: interpret(t.behaviour, t.outcome, t.r), + lagDays, + })) + .sort((a, b) => a.qValue - b.qValue || Math.abs(b.r) - Math.abs(a.r)); + + return { discovered, pairsTested, fdrQ, minPairs }; +} + +/** + * The curated discovery matrix — the channels the engine pairs. Behaviours + * (lag sources) on the left, outcomes (lag targets) on the right. Medication + * compliance is deliberately NOT yet a behaviour channel — its cadence-aware + * per-day rate needs the compliance engine; folding it in is a later wave. + */ +export const DISCOVERY_BEHAVIOURS = [ + "TIME_IN_DAYLIGHT", + "MOOD", + "BLOOD_GLUCOSE", + "BLOOD_PRESSURE_SYS", + "ACTIVITY_STEPS", +] as const; + +export const DISCOVERY_OUTCOMES = [ + "SLEEP_DURATION", + "HEART_RATE_VARIABILITY", + "RESTING_HEART_RATE", + "WEIGHT", +] as const; diff --git a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts new file mode 100644 index 000000000..5ef846c60 --- /dev/null +++ b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { findMany: vi.fn() } }, +})); +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: vi.fn().mockResolvedValue(new Map()), +})); +vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ + readBestGranularityRollups: vi.fn().mockResolvedValue(null), +})); + +import { prisma } from "@/lib/db"; +import { + computeCoincidentDeviation, + classifyDeviation, + COINCIDENT_FIRE_THRESHOLD, +} from "../coincident-deviation"; + +const PROFILE = { ageYears: 40, sex: "MALE" as const }; +const NOW = new Date("2026-06-02T08:00:00Z"); +const findMany = prisma.measurement.findMany as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + findMany.mockResolvedValue([]); +}); + +describe("classifyDeviation", () => { + it("flags above / below / in-band", () => { + expect(classifyDeviation("PULSE", 90, 60, 80, 70).direction).toBe("above"); + expect(classifyDeviation("PULSE", 50, 60, 80, 70).direction).toBe("below"); + expect(classifyDeviation("PULSE", 70, 60, 80, 70).direction).toBe("in"); + expect(classifyDeviation("PULSE", 70, 60, 80, 70).outside).toBe(false); + }); +}); + +describe("computeCoincidentDeviation", () => { + it("returns insufficient with fewer than two banded vitals", async () => { + // Only one vital has a usable band. + findMany.mockImplementation(async (args: { where: { type: string } }) => { + if (args.where.type === "RESTING_HEART_RATE") { + return Array.from({ length: 10 }, (_, i) => ({ + value: 58, + measuredAt: new Date( + `2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`, + ), + })); + } + return []; + }); + const result = await computeCoincidentDeviation("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("too_few_banded_vitals"); + } + }); + + it("fires when ≥ 2 vitals sit outside their band on the latest day", async () => { + // RHR + HRV each have a flat band, then a final outlier day that + // pushes both outside the band. + const baseline = ( + base: number, + outlier: number, + ): Array<{ value: number; measuredAt: Date }> => [ + ...Array.from({ length: 9 }, (_, i) => ({ + value: base, + measuredAt: new Date( + `2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`, + ), + })), + { value: outlier, measuredAt: new Date("2026-06-02T07:00:00Z") }, + ]; + findMany.mockImplementation(async (args: { where: { type: string } }) => { + if (args.where.type === "RESTING_HEART_RATE") return baseline(58, 95); + if (args.where.type === "HEART_RATE_VARIABILITY") return baseline(60, 10); + return []; + }); + const result = await computeCoincidentDeviation("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.contributing.length).toBeGreaterThanOrEqual( + COINCIDENT_FIRE_THRESHOLD, + ); + expect(result.value.fired).toBe(true); + } + }); + + it("does not fire when vitals stay inside their bands", async () => { + const flat = (base: number): Array<{ value: number; measuredAt: Date }> => + Array.from({ length: 10 }, (_, i) => ({ + value: base, + measuredAt: new Date( + `2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`, + ), + })); + findMany.mockImplementation(async (args: { where: { type: string } }) => { + if (args.where.type === "RESTING_HEART_RATE") return flat(58); + if (args.where.type === "HEART_RATE_VARIABILITY") return flat(60); + return []; + }); + const result = await computeCoincidentDeviation("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.fired).toBe(false); + } + }); +}); diff --git a/src/lib/insights/derived/__tests__/dispatch.test.ts b/src/lib/insights/derived/__tests__/dispatch.test.ts index 271bd453f..6babe8d0a 100644 --- a/src/lib/insights/derived/__tests__/dispatch.test.ts +++ b/src/lib/insights/derived/__tests__/dispatch.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ - prisma: { measurement: { findMany: vi.fn().mockResolvedValue([]) } }, + prisma: { + measurement: { findMany: vi.fn().mockResolvedValue([]) }, + moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + }, })); vi.mock("@/lib/rollups/measurement-coverage", () => ({ probeRollupCoverage: vi.fn().mockResolvedValue(new Map()), @@ -33,6 +36,13 @@ describe("registry", () => { // Composites still land in W3. expect(getDerivedMetricMeta("READINESS")?.implemented).toBe(false); expect(getDerivedMetricMeta("COINCIDENT_DEVIATION")?.implemented).toBe(false); + it("exposes the flagship + W3 composites as implemented; the passthroughs stay stubs", () => { + expect(getDerivedMetricMeta("VITALS_BASELINE")?.implemented).toBe(true); + expect(getDerivedMetricMeta("SLEEP_SCORE")?.implemented).toBe(true); + expect(getDerivedMetricMeta("READINESS")?.implemented).toBe(true); + expect(getDerivedMetricMeta("COINCIDENT_DEVIATION")?.implemented).toBe(true); + // FITNESS_AGE + VASCULAR_AGE_DELTA land in a sibling wave. + expect(getDerivedMetricMeta("FITNESS_AGE")?.implemented).toBe(false); }); it("isDerivedMetricId rejects unknown ids", () => { @@ -68,7 +78,7 @@ describe("computeDerivedMetric dispatch", () => { it("returns not_implemented for a stubbed metric (never a 500)", async () => { const result = await computeDerivedMetric({ - metric: "READINESS", + metric: "FITNESS_AGE", userId: "u1", profile: PROFILE, now: NOW, @@ -79,6 +89,45 @@ describe("computeDerivedMetric dispatch", () => { } }); + it("routes READINESS to the engine (no data → insufficient, not not_implemented)", async () => { + const result = await computeDerivedMetric({ + metric: "READINESS", + userId: "u1", + profile: PROFILE, + now: NOW, + }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("insufficient_components"); + } + }); + + it("routes SLEEP_SCORE to the engine (no sleep → insufficient)", async () => { + const result = await computeDerivedMetric({ + metric: "SLEEP_SCORE", + userId: "u1", + profile: PROFILE, + now: NOW, + }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("no_sleep_in_window"); + } + }); + + it("routes COINCIDENT_DEVIATION to the engine (no bands → insufficient)", async () => { + const result = await computeDerivedMetric({ + metric: "COINCIDENT_DEVIATION", + userId: "u1", + profile: PROFILE, + now: NOW, + }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("too_few_banded_vitals"); + } + }); + it("returns unsupported_baseline_type for a bad VITALS_BASELINE type", async () => { const result = await computeDerivedMetric({ metric: "VITALS_BASELINE", diff --git a/src/lib/insights/derived/__tests__/readiness.test.ts b/src/lib/insights/derived/__tests__/readiness.test.ts new file mode 100644 index 000000000..44dfd345f --- /dev/null +++ b/src/lib/insights/derived/__tests__/readiness.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn() }, + moodEntry: { findMany: vi.fn() }, + }, +})); +vi.mock("@/lib/rollups/measurement-coverage", () => ({ + probeRollupCoverage: vi.fn().mockResolvedValue(new Map()), +})); +vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ + readBestGranularityRollups: vi.fn().mockResolvedValue(null), +})); + +import { prisma } from "@/lib/db"; +import { + computeReadiness, + blendReadinessComponents, + scoreDeviation, + READINESS_MIN_COMPONENTS, + type ReadinessComponentKey, +} from "../readiness"; + +const PROFILE = { ageYears: 40, sex: "MALE" as const }; +const NOW = new Date("2026-06-02T08:00:00Z"); +const measurementFindMany = prisma.measurement.findMany as ReturnType; +const moodFindMany = prisma.moodEntry.findMany as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + measurementFindMany.mockResolvedValue([]); + moodFindMany.mockResolvedValue([]); +}); + +describe("scoreDeviation", () => { + it("scores 100 on or in the good direction (lower-better)", () => { + expect(scoreDeviation(50, 60, 5, "lower-better")).toBe(100); + expect(scoreDeviation(60, 60, 5, "lower-better")).toBe(100); + }); + + it("suppresses the score one spread worse (lower-better RHR elevated)", () => { + expect(scoreDeviation(65, 60, 5, "lower-better")).toBe(50); + expect(scoreDeviation(70, 60, 5, "lower-better")).toBe(0); + }); + + it("suppresses on an HRV drop (higher-better)", () => { + expect(scoreDeviation(55, 60, 5, "higher-better")).toBe(50); + expect(scoreDeviation(65, 60, 5, "higher-better")).toBe(100); + }); + + it("degrades gracefully when the spread is zero", () => { + expect(scoreDeviation(61, 60, 0, "lower-better")).toBe(50); + expect(scoreDeviation(59, 60, 0, "lower-better")).toBe(100); + }); +}); + +describe("blendReadinessComponents reweighting", () => { + it("renormalises over present components and drops missing ones", () => { + const raw: Record = { + rhr: 80, + hrv: 70, + sleep: null, + respiratory: null, + mood: 60, + }; + const { score, components } = blendReadinessComponents(raw); + const sleep = components.find((c) => c.key === "sleep")!; + expect(sleep.weight).toBe(0); + const presentSum = components + .filter((c) => c.value !== null) + .reduce((s, c) => s + c.weight, 0); + expect(presentSum).toBeCloseTo(1, 5); + expect(score).toBeGreaterThan(0); + }); +}); + +describe("computeReadiness gating", () => { + it("returns insufficient below the minimum-components floor (no 1-of-N headline)", async () => { + // No data at all → 0 components present. + const result = await computeReadiness("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("insufficient_components"); + expect(result.coverage.presentInputs).toBeLessThan( + READINESS_MIN_COMPONENTS, + ); + } + }); + + it("produces a headline once ≥ 2 deviation components are present", async () => { + // RHR + HRV baselines: ≥ 7 distinct days each, plus a latest reading. + const days = (type: string, base: number) => + Array.from({ length: 10 }, (_, i) => ({ + value: base, + // 10 distinct days within May (15..24) — all valid calendar days. + measuredAt: new Date(`2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`), + type, + })); + measurementFindMany.mockImplementation( + async (args: { where: { type: string } }) => { + const type = args.where.type; + if (type === "RESTING_HEART_RATE") return days(type, 58); + if (type === "HEART_RATE_VARIABILITY") return days(type, 65); + return []; + }, + ); + const result = await computeReadiness("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + const present = result.value.components.filter((c) => c.value !== null); + expect(present.length).toBeGreaterThanOrEqual(READINESS_MIN_COMPONENTS); + expect(result.value.score).toBeGreaterThanOrEqual(0); + expect(result.value.score).toBeLessThanOrEqual(100); + } + }); +}); diff --git a/src/lib/insights/derived/__tests__/sleep-score.test.ts b/src/lib/insights/derived/__tests__/sleep-score.test.ts new file mode 100644 index 000000000..1dc05154a --- /dev/null +++ b/src/lib/insights/derived/__tests__/sleep-score.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { findMany: vi.fn() } }, +})); + +import { prisma } from "@/lib/db"; +import { + computeSleepScore, + blendSleepSubScores, + reconstructNights, + sleepNeedMinutes, + scoreSufficiency, + scoreEfficiency, + scoreComposition, + scoreConsistency, + scoreTiming, + type SleepSubScoreKey, +} from "../sleep-score"; + +const PROFILE = { ageYears: 40, sex: "MALE" as const }; +const NOW = new Date("2026-06-02T08:00:00Z"); +const findMany = prisma.measurement.findMany as ReturnType; + +beforeEach(() => vi.clearAllMocks()); + +/** Build per-stage rows for one night keyed on a wake-day. */ +function night(day: string, stages: Array<[string | null, number, string]>) { + return stages.map(([sleepStage, value, hhmm]) => ({ + value, + measuredAt: new Date(`${day}T${hhmm}:00Z`), + sleepStage: sleepStage as never, + })); +} + +describe("pure sleep scorers", () => { + it("sleepNeedMinutes is age-banded with an adult default", () => { + expect(sleepNeedMinutes(40)).toBe(7 * 60); + expect(sleepNeedMinutes(10)).toBe(10 * 60); + expect(sleepNeedMinutes(null)).toBe(7 * 60); + }); + + it("sufficiency caps at 100 and never penalises oversleep", () => { + expect(scoreSufficiency(7 * 60, 7 * 60)).toBe(100); + expect(scoreSufficiency(10 * 60, 7 * 60)).toBe(100); + expect(scoreSufficiency(3.5 * 60, 7 * 60)).toBe(50); + }); + + it("efficiency is null without in-bed minutes, else the asleep ratio", () => { + expect(scoreEfficiency(420, null)).toBeNull(); + expect(scoreEfficiency(420, 480)).toBe(88); + }); + + it("composition is null without a stage breakdown, 100 inside the band", () => { + expect(scoreComposition(0, 0, 420, false)).toBeNull(); + // REM 90 + Deep 60 = 150 of 420 ≈ 0.357 — inside [0.33, 0.48]. + expect(scoreComposition(90, 60, 420, true)).toBe(100); + }); + + it("consistency is null below 3 nights, 100 for a flat midpoint", () => { + expect(scoreConsistency([100, 100])).toBeNull(); + expect(scoreConsistency([180, 180, 180])).toBe(100); + }); + + it("timing is null without a habitual window", () => { + expect(scoreTiming(180, null, 1)).toBeNull(); + expect(scoreTiming(180, 180, 5)).toBe(100); + }); +}); + +describe("blendSleepSubScores reweighting", () => { + it("renormalises weights over the present sub-scores (drops a missing one)", () => { + const raw: Record = { + sufficiency: 80, + efficiency: 90, + consistency: 70, + timing: 60, + composition: null, // legacy ASLEEP-only night + }; + const { score, subScores } = blendSleepSubScores(raw); + const composition = subScores.find((s) => s.key === "composition")!; + expect(composition.weight).toBe(0); + // Present weights sum to 1. + const sum = subScores.reduce((s, c) => s + c.weight, 0); + expect(sum).toBeCloseTo(1, 5); + expect(score).toBeGreaterThan(0); + expect(score).toBeLessThanOrEqual(100); + }); +}); + +describe("reconstructNights", () => { + it("sums asleep stages, derives in-bed, flags the stage breakdown", () => { + const rows = night("2026-06-02", [ + ["IN_BED", 480, "06:00"], + ["REM", 90, "02:00"], + ["CORE", 240, "03:00"], + ["DEEP", 60, "04:00"], + ["AWAKE", 30, "05:00"], + ]); + const nights = reconstructNights(rows); + expect(nights).toHaveLength(1); + expect(nights[0].asleepMinutes).toBe(390); + expect(nights[0].inBedMinutes).toBe(480); + expect(nights[0].hasStageBreakdown).toBe(true); + expect(nights[0].remMinutes).toBe(90); + expect(nights[0].deepMinutes).toBe(60); + }); +}); + +describe("computeSleepScore", () => { + it("returns insufficient when no sleep rows exist", async () => { + findMany.mockResolvedValue([]); + const result = await computeSleepScore("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("no_sleep_in_window"); + } + }); + + it("scores a multi-stage night and shows the present sub-scores", async () => { + // Three nights so consistency/timing can compute. + const rows = [ + ...night("2026-05-31", [ + ["IN_BED", 480, "06:00"], + ["REM", 90, "02:00"], + ["CORE", 240, "03:00"], + ["DEEP", 60, "04:00"], + ]), + ...night("2026-06-01", [ + ["IN_BED", 470, "06:10"], + ["REM", 85, "02:10"], + ["CORE", 250, "03:10"], + ["DEEP", 55, "04:10"], + ]), + ...night("2026-06-02", [ + ["IN_BED", 480, "06:05"], + ["REM", 95, "02:05"], + ["CORE", 235, "03:05"], + ["DEEP", 65, "04:05"], + ]), + ]; + findMany.mockResolvedValue(rows); + const result = await computeSleepScore("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.score).toBeGreaterThan(0); + expect(result.value.windowNights).toBe(3); + // All five sub-scores present on a full-stage multi-night history. + const present = result.value.subScores.filter((s) => s.value !== null); + expect(present.length).toBe(5); + expect(result.coverage.presentInputs).toBe(5); + } + }); + + it("reweights around composition on a legacy ASLEEP-only night", async () => { + const rows = [ + ...night("2026-05-31", [["ASLEEP", 420, "06:00"]]), + ...night("2026-06-01", [["ASLEEP", 430, "06:10"]]), + ...night("2026-06-02", [["ASLEEP", 410, "06:05"]]), + ]; + findMany.mockResolvedValue(rows); + const result = await computeSleepScore("u1", PROFILE, { now: NOW }); + expect(result.status).toBe("ok"); + if (result.status === "ok") { + const composition = result.value.subScores.find( + (s) => s.key === "composition", + )!; + expect(composition.value).toBeNull(); + expect(composition.weight).toBe(0); + expect(result.coverage.missing).toContain("composition"); + } + }); +}); diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts new file mode 100644 index 000000000..74359d7f5 --- /dev/null +++ b/src/lib/insights/derived/coincident-deviation.ts @@ -0,0 +1,228 @@ +/** + * v1.10.0 — Coincident-deviation flag (catalogue metric #2, COMPOSITE). + * + * "2 of your vitals are outside their usual range this morning — possible + * factors: illness, alcohol, altitude, a hard workout." For each vital + * with an established personal band (#1 `VITALS_BASELINE`), check whether + * the latest reading falls outside the band; **≥ 2 outside on the same day + * → fire** a flag listing the contributing vitals. No black-box score — it + * is a transparent COUNT of deviations, mirroring Apple Vitals' ≥2-metric + * next-morning notification. + * + * Coverage gate: ≥ 2 vitals each with an established band. Below that the + * flag is `insufficient` (it cannot coincide). The minimum-inputs floor is + * the composite contract — it never emits a single composite number, and + * never labels a cause ("illness"); it lists the contributing vitals and + * frames them as "possible factors" only. + * + * Standard: the same MAD / personal-baseline basis as #1 (Hampel 1974; + * Leys et al. 2013). Multi-signal coincidence is descriptive, NOT a + * diagnosis. Frame: Apple Vitals / WHOOP Health Monitor / Fitbit Health + * Metrics / Oura Symptom Radar — all descriptive, none continuous. + * + * Server-only — fans the baseline engine across the supported vitals with + * one shared coverage probe (the pool-contention mitigation). The pure + * classifier is exported for tests. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + probeRollupCoverage, + type RollupCoverageMap, +} from "@/lib/rollups/measurement-coverage"; +import { + buildInsufficient, + buildOk, + deriveCoverage, + nowProvenanceTimestamp, +} from "./coverage"; +import { computeVitalsBaseline, type BaselineProfile } from "./baseline"; +import { VITALS_BASELINE_TYPES } from "./registry"; +import type { Derived, DerivedProvenanceSource } from "./types"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const DEFAULT_WINDOW_DAYS = 30; +/** ≥ this many out-of-band vitals on a day fires the flag. */ +export const COINCIDENT_FIRE_THRESHOLD = 2; +/** Need ≥ this many banded vitals before the flag can even coincide. */ +export const COINCIDENT_MIN_BANDS = 2; + +/** One vital's standing against its personal band today. */ +export interface VitalDeviation { + type: MeasurementType; + /** Today's value. */ + value: number; + /** Band center (median). */ + center: number; + low: number; + high: number; + /** True when today's value falls outside [low, high]. */ + outside: boolean; + /** "above" / "below" the band, or "in" when inside. */ + direction: "above" | "below" | "in"; +} + +export interface CoincidentDeviationValue { + /** True when ≥ COINCIDENT_FIRE_THRESHOLD vitals are outside their band. */ + fired: boolean; + /** All banded vitals checked today (the anatomy view lists them). */ + vitals: VitalDeviation[]; + /** Just the out-of-band vitals (the contributing factors). */ + contributing: VitalDeviation[]; + /** The day the flag was evaluated (YYYY-MM-DD). */ + day: string; +} + +// ── pure classifier (exported for tests) ─────────────────────────────── + +/** Classify one vital's latest value against its band. Pure. */ +export function classifyDeviation( + type: MeasurementType, + value: number, + low: number, + high: number, + center: number, +): VitalDeviation { + const above = value > high; + const below = value < low; + return { + type, + value, + center, + low, + high, + outside: above || below, + direction: above ? "above" : below ? "below" : "in", + }; +} + +// ── compute ───────────────────────────────────────────────────────────── + +export interface CoincidentDeviationOpts { + windowDays?: number; + now?: Date; + coverage?: RollupCoverageMap; +} + +/** + * The most recent DAY mean for a type within the window, plus its day key. + * Bounded raw read; null when no reading in the window. + */ +async function readLatestDayMean( + userId: string, + type: MeasurementType, + windowDays: number, + now: Date, +): Promise<{ value: number; day: string } | null> { + const since = new Date(now.getTime() - windowDays * MS_PER_DAY); + const rows = await prisma.measurement.findMany({ + where: { userId, type, deletedAt: null, measuredAt: { gte: since } }, + orderBy: { measuredAt: "desc" }, + take: 50, + select: { value: true, measuredAt: true }, + }); + if (rows.length === 0) return null; + // Derive the most-recent day defensively (do not assume the DB ordering) + // so the "today" reading is always the genuine latest, then mean its rows. + let day = ""; + for (const r of rows) { + const d = r.measuredAt.toISOString().slice(0, 10); + if (d > day) day = d; + } + const sameDay = rows.filter( + (r) => r.measuredAt.toISOString().slice(0, 10) === day, + ); + return { value: sameDay.reduce((s, r) => s + r.value, 0) / sameDay.length, day }; +} + +/** + * Compute the coincident-deviation flag across the supported vitals. Below + * `COINCIDENT_MIN_BANDS` banded vitals it returns `insufficient`. + */ +export async function computeCoincidentDeviation( + userId: string, + profile: BaselineProfile, + opts: CoincidentDeviationOpts = {}, +): Promise> { + const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS; + const now = opts.now ?? new Date(); + const computedAt = nowProvenanceTimestamp(now); + const coverage = opts.coverage ?? (await probeRollupCoverage(userId)); + + const vitals: VitalDeviation[] = []; + let latestDay = ""; + let anyDaySource = false; + + // Fan the baseline engine across the supported vitals. Each baseline read + // shares the one coverage probe (no per-vital re-probe). + for (const type of VITALS_BASELINE_TYPES) { + const baseline = await computeVitalsBaseline(userId, profile, { + type, + windowDays, + now, + coverage, + }); + if (baseline.status !== "ok") continue; + if (baseline.provenance.source === "DAY") anyDaySource = true; + const latest = await readLatestDayMean(userId, type, windowDays, now); + if (!latest) continue; + if (latest.day > latestDay) latestDay = latest.day; + vitals.push( + classifyDeviation( + type, + latest.value, + baseline.value.low, + baseline.value.high, + baseline.value.center, + ), + ); + } + + const inputs = vitals.map((v) => String(v.type)); + const source: DerivedProvenanceSource = + vitals.length === 0 ? "none" : anyDaySource ? "DAY" : "live"; + + if (vitals.length < COINCIDENT_MIN_BANDS) { + const { coverage: cov } = deriveCoverage({ + requiredInputs: COINCIDENT_MIN_BANDS, + presentInputs: vitals.length, + historyDays: 0, + missing: [], + fullHistoryDays: windowDays, + }); + return buildInsufficient({ + coverage: cov, + provenance: { + inputs: inputs.length > 0 ? inputs : VITALS_BASELINE_TYPES.map(String), + source, + windowDays, + computedAt, + }, + reason: "too_few_banded_vitals", + }); + } + + const contributing = vitals.filter((v) => v.outside); + const fired = contributing.length >= COINCIDENT_FIRE_THRESHOLD; + + const { coverage: cov, confidence } = deriveCoverage({ + // The coverage axis is "how many vitals could have coincided". + requiredInputs: vitals.length, + presentInputs: vitals.length, + historyDays: windowDays, + missing: [], + fullHistoryDays: windowDays, + }); + + return buildOk({ + value: { + fired, + vitals, + contributing, + day: latestDay, + }, + coverage: cov, + confidence, + provenance: { inputs, source, windowDays, computedAt }, + }); +} diff --git a/src/lib/insights/derived/dispatch.ts b/src/lib/insights/derived/dispatch.ts index 9be848e39..c393f7535 100644 --- a/src/lib/insights/derived/dispatch.ts +++ b/src/lib/insights/derived/dispatch.ts @@ -26,6 +26,9 @@ import { computeFitnessAge } from "./fitness-age"; import { computeVascularAgeDelta } from "./vascular-age"; import { computeHrvBalance } from "./hrv-balance"; import { computeBmi } from "./bmi"; +import { computeSleepScore } from "./sleep-score"; +import { computeReadiness } from "./readiness"; +import { computeCoincidentDeviation } from "./coincident-deviation"; import type { Derived } from "./types"; export interface DerivedComputeArgs { @@ -121,6 +124,18 @@ export async function computeDerivedMetric( }) as Promise>; case "BMI": return computeBmi(args.userId, args.profile, { + case "SLEEP_SCORE": + return computeSleepScore(args.userId, args.profile, { + windowDays: args.windowDays, + now, + }) as Promise>; + case "READINESS": + return computeReadiness(args.userId, args.profile, { + windowDays: args.windowDays, + now, + }) as Promise>; + case "COINCIDENT_DEVIATION": + return computeCoincidentDeviation(args.userId, args.profile, { windowDays: args.windowDays, now, }) as Promise>; diff --git a/src/lib/insights/derived/index.ts b/src/lib/insights/derived/index.ts index 90b50d13d..d0db86158 100644 --- a/src/lib/insights/derived/index.ts +++ b/src/lib/insights/derived/index.ts @@ -84,3 +84,49 @@ export type { HrvBalanceValue, HrvBalanceBand } from "./hrv-balance"; export { computeBmi, classifyBmi } from "./bmi"; export type { BmiValue, BmiBand, BmiCategory } from "./bmi"; +// ── W3 composites (server-only compute engines) ────────────────────── +export { + computeSleepScore, + blendSleepSubScores, + reconstructNights, + sleepNeedMinutes, + scoreSufficiency, + scoreEfficiency, + scoreComposition, + scoreConsistency, + scoreTiming, + SLEEP_SUBSCORE_WEIGHTS, +} from "./sleep-score"; +export type { + SleepScoreValue, + SleepScoreOpts, + SleepSubScore, + SleepSubScoreKey, + NightSummary, +} from "./sleep-score"; + +export { + computeReadiness, + blendReadinessComponents, + scoreDeviation, + READINESS_WEIGHTS, + READINESS_MIN_COMPONENTS, +} from "./readiness"; +export type { + ReadinessValue, + ReadinessOpts, + ReadinessComponent, + ReadinessComponentKey, +} from "./readiness"; + +export { + computeCoincidentDeviation, + classifyDeviation, + COINCIDENT_FIRE_THRESHOLD, + COINCIDENT_MIN_BANDS, +} from "./coincident-deviation"; +export type { + CoincidentDeviationValue, + CoincidentDeviationOpts, + VitalDeviation, +} from "./coincident-deviation"; diff --git a/src/lib/insights/derived/readiness.ts b/src/lib/insights/derived/readiness.ts new file mode 100644 index 000000000..1a2d4ccb2 --- /dev/null +++ b/src/lib/insights/derived/readiness.ts @@ -0,0 +1,391 @@ +/** + * v1.10.0 — Wellness / readiness index (catalogue metric #7, COMPOSITE). + * + * A daily "how recovered are you" proxy, framed as a wellness index, with + * every contributing component SHOWN. It extends the existing + * `health-score.ts` weighted-blend + null-redistribution pattern with + * deviation-from-baseline components: + * + * - **RHR deviation** — resting HR below/at baseline scores high; + * an elevation suppresses the score. + * - **HRV deviation** — SDNN at/above baseline scores high; a drop + * suppresses it. (Surfaced as "HRV (SDNN)" — it + * is NEVER relabelled RMSSD, and this is never + * called "Recovery".) + * - **Sleep adequacy** — the Sleep Score (#6) folded in as a 0..100 + * component (stubbed behind coverage so this + * builds independently and lights up when sleep + * data exists). + * - **Respiratory-rate stability** — closeness to the personal baseline. + * - **Mood stability** — the existing mood-aggregate stability score. + * + * Each component is 0..100; weights renormalise over the PRESENT components + * (exactly as the health score redistributes a null pillar). Below the + * minimum-inputs floor (`minInputs = 2`) it returns `insufficient` — never + * a headline from 1-of-N. The overnight-HR-return contributor is dropped + * (needs intra-night HR — absent from the schema). + * + * Standard: RHR elevation + HRV suppression as recovery/strain markers — + * Plews et al. 2013, Sports Medicine 43(9):773–781; Buchheit 2014, + * Frontiers in Physiology 5:73. Framing: our own heuristic — a daily + * wellness proxy, NOT a training-recovery score, NOT clinical. + * + * Server-only — reads the rollup tier (via the baseline engine) + the + * sleep score + mood aggregates. The pure blend is exported for tests. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + probeRollupCoverage, + type RollupCoverageMap, +} from "@/lib/rollups/measurement-coverage"; +import { computeMoodStability } from "@/lib/insights/mood-aggregates"; +import { + buildInsufficient, + buildOk, + deriveCoverage, + nowProvenanceTimestamp, +} from "./coverage"; +import { computeVitalsBaseline, type BaselineProfile } from "./baseline"; +import { computeSleepScore } from "./sleep-score"; +import type { Derived, DerivedProvenanceSource } from "./types"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const DEFAULT_WINDOW_DAYS = 30; +/** Minimum present components before a headline is produced (no 1-of-N). */ +export const READINESS_MIN_COMPONENTS = 2; + +/** Transparent component weights (renormalised over present components). */ +export const READINESS_WEIGHTS = { + rhr: 0.25, + hrv: 0.25, + sleep: 0.25, + respiratory: 0.1, + mood: 0.15, +} as const; + +export type ReadinessComponentKey = keyof typeof READINESS_WEIGHTS; + +/** One readiness contributor the anatomy view ranks by impact. */ +export interface ReadinessComponent { + key: ReadinessComponentKey; + /** 0..100, or null when the input is missing (drops from the blend). */ + value: number | null; + /** Effective weight after null-redistribution, 0..1. */ + weight: number; +} + +export interface ReadinessValue { + score: number; + band: "green" | "yellow" | "red"; + components: ReadinessComponent[]; +} + +// ── pure deviation scorers (exported for tests) ──────────────────────── + +function clamp100(v: number): number { + if (!Number.isFinite(v)) return 0; + return Math.max(0, Math.min(100, Math.round(v))); +} + +function bandForScore(score: number): "green" | "yellow" | "red" { + if (score >= 70) return "green"; + if (score >= 40) return "yellow"; + return "red"; +} + +/** + * Map a today-vs-baseline deviation to a 0..100 component score. `spread` + * is the band half-width (k·MAD·σ-scale from the baseline engine); a value + * one spread in the "bad" direction lands near 50, two spreads near 0. + * + * `direction = "lower-better"` (RHR, respiratory rate): a value above + * baseline suppresses the score. `direction = "higher-better"` (HRV): a + * value below baseline suppresses it. On-baseline (or in the good + * direction) scores 100. + */ +export function scoreDeviation( + today: number, + center: number, + spread: number, + direction: "lower-better" | "higher-better", +): number { + if (!Number.isFinite(spread) || spread <= 0) { + // No usable spread → degrade to a coarse same/above/below. + if (today === center) return 100; + const worse = + direction === "lower-better" ? today > center : today < center; + return worse ? 50 : 100; + } + const worseDelta = + direction === "lower-better" ? today - center : center - today; + if (worseDelta <= 0) return 100; // at or in the good direction. + // 1 spread worse → 50, 2 spreads → 0 (linear). + return clamp100(100 - (worseDelta / spread) * 50); +} + +/** + * Blend the present components with null-redistribution (the + * `health-score.ts` pattern). Returns the composite + per-component + * effective weights for the anatomy view. Pure. + */ +export function blendReadinessComponents( + raw: Record, +): { score: number; components: ReadinessComponent[] } { + const keys = Object.keys(READINESS_WEIGHTS) as ReadinessComponentKey[]; + const present = keys.filter((k) => raw[k] !== null); + const totalBaseWeight = present.reduce((s, k) => s + READINESS_WEIGHTS[k], 0); + const components: ReadinessComponent[] = keys.map((k) => ({ + key: k, + value: raw[k], + weight: + raw[k] === null || totalBaseWeight === 0 + ? 0 + : READINESS_WEIGHTS[k] / totalBaseWeight, + })); + let composite = 0; + for (const c of components) { + if (c.value !== null) composite += c.value * c.weight; + } + return { score: clamp100(composite), components }; +} + +// ── helpers ───────────────────────────────────────────────────────────── + +/** + * The most recent DAY mean for a type within the window — the "today" + * value the deviation is measured against. Bounded raw read; null when no + * reading in the window. + */ +async function readLatestDayMean( + userId: string, + type: MeasurementType, + windowDays: number, + now: Date, +): Promise { + const since = new Date(now.getTime() - windowDays * MS_PER_DAY); + const rows = await prisma.measurement.findMany({ + where: { userId, type, deletedAt: null, measuredAt: { gte: since } }, + orderBy: { measuredAt: "desc" }, + take: 50, + select: { value: true, measuredAt: true }, + }); + if (rows.length === 0) return null; + // Derive the most-recent day defensively (do not assume the DB ordering). + let latestDay = ""; + for (const r of rows) { + const d = r.measuredAt.toISOString().slice(0, 10); + if (d > latestDay) latestDay = d; + } + const sameDay = rows.filter( + (r) => r.measuredAt.toISOString().slice(0, 10) === latestDay, + ); + return sameDay.reduce((s, r) => s + r.value, 0) / sameDay.length; +} + +/** + * Score one baseline-deviation vital component (RHR / HRV / respiratory). + * Returns null when the baseline is insufficient or there is no recent + * reading — the component then drops from the blend. + */ +async function scoreVitalDeviation( + userId: string, + profile: BaselineProfile, + type: MeasurementType, + direction: "lower-better" | "higher-better", + windowDays: number, + now: Date, + coverage: RollupCoverageMap, +): Promise<{ value: number | null; source: DerivedProvenanceSource }> { + const baseline = await computeVitalsBaseline(userId, profile, { + type, + windowDays, + now, + coverage, + }); + if (baseline.status !== "ok") { + return { value: null, source: baseline.provenance.source }; + } + const today = await readLatestDayMean(userId, type, windowDays, now); + if (today == null) { + return { value: null, source: baseline.provenance.source }; + } + const value = scoreDeviation( + today, + baseline.value.center, + baseline.value.spread, + direction, + ); + return { value, source: baseline.provenance.source }; +} + +// ── compute ───────────────────────────────────────────────────────────── + +export interface ReadinessOpts { + windowDays?: number; + now?: Date; + /** Pre-probed coverage (shared across metrics in one request). */ + coverage?: RollupCoverageMap; +} + +/** + * Compute the wellness/readiness index. Below `READINESS_MIN_COMPONENTS` + * present components it returns `insufficient` (no 1-of-N headline). + */ +export async function computeReadiness( + userId: string, + profile: BaselineProfile, + opts: ReadinessOpts = {}, +): Promise> { + const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS; + const now = opts.now ?? new Date(); + const computedAt = nowProvenanceTimestamp(now); + const coverage = opts.coverage ?? (await probeRollupCoverage(userId)); + + const inputNames = [ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "SLEEP_DURATION", + "RESPIRATORY_RATE", + "MOOD", + ]; + + // Vital deviations (shared coverage map → one probe per request). + const [rhr, hrv, resp] = await Promise.all([ + scoreVitalDeviation( + userId, + profile, + "RESTING_HEART_RATE", + "lower-better", + windowDays, + now, + coverage, + ), + scoreVitalDeviation( + userId, + profile, + "HEART_RATE_VARIABILITY", + "higher-better", + windowDays, + now, + coverage, + ), + scoreVitalDeviation( + userId, + profile, + "RESPIRATORY_RATE", + "lower-better", + windowDays, + now, + coverage, + ), + ]); + + // Sleep adequacy — folds the Sleep Score in (stubbed behind coverage: + // null when there is no scorable night, dropping the component). + const sleep = await computeSleepScore(userId, profile, { windowDays, now }); + const sleepValue = sleep.status === "ok" ? sleep.value.score : null; + + // Mood stability — reuse the shipped mood-aggregate stability score over + // the window's daily means. + const moodValue = await readMoodStability(userId, windowDays, now); + + const raw: Record = { + rhr: rhr.value, + hrv: hrv.value, + sleep: sleepValue, + respiratory: resp.value, + mood: moodValue, + }; + + const { score, components } = blendReadinessComponents(raw); + const presentCount = components.filter((c) => c.value !== null).length; + const missing = components + .filter((c) => c.value === null) + .map((c) => c.key); + + // Provenance source: DAY when any vital read the rollup tier, else live. + const anyDay = [rhr.source, hrv.source, resp.source].includes("DAY"); + const source: DerivedProvenanceSource = + presentCount === 0 ? "none" : anyDay ? "DAY" : "live"; + + if (presentCount < READINESS_MIN_COMPONENTS) { + const { coverage: cov } = deriveCoverage({ + requiredInputs: components.length, + presentInputs: presentCount, + historyDays: 0, + missing, + fullHistoryDays: windowDays, + }); + return buildInsufficient({ + coverage: cov, + provenance: { inputs: inputNames, source, windowDays, computedAt }, + reason: "insufficient_components", + }); + } + + const { coverage: cov, confidence } = deriveCoverage({ + requiredInputs: components.length, + presentInputs: presentCount, + historyDays: windowDays, + missing, + fullHistoryDays: windowDays, + }); + + const presentInputNames = inputNames.filter((_, i) => { + const key = ( + ["rhr", "hrv", "sleep", "respiratory", "mood"] as ReadinessComponentKey[] + )[i]; + return raw[key] !== null; + }); + + return buildOk({ + value: { score, band: bandForScore(score), components }, + coverage: cov, + confidence, + provenance: { + inputs: presentInputNames.length > 0 ? presentInputNames : inputNames, + source, + windowDays, + computedAt, + }, + }); +} + +/** + * Mood stability over the window — reads daily-mean mood and feeds the + * shipped `computeMoodStability` (0..100, higher = steadier). Null below + * the stability floor. + */ +async function readMoodStability( + userId: string, + windowDays: number, + now: Date, +): Promise { + const since = new Date(now.getTime() - windowDays * MS_PER_DAY); + const rows = await prisma.moodEntry.findMany({ + where: { userId, deletedAt: null, moodLoggedAt: { gte: since } }, + orderBy: { moodLoggedAt: "asc" }, + take: 2000, + select: { score: true, moodLoggedAt: true }, + }); + if (rows.length === 0) return null; + // Collapse to daily means keyed by dayOffset (newest = 0), matching the + // mood-aggregate DailyPoint shape. + const byDay = new Map(); + for (const r of rows) { + const offset = Math.floor( + (now.getTime() - r.moodLoggedAt.getTime()) / MS_PER_DAY, + ); + const acc = byDay.get(offset) ?? { sum: 0, count: 0 }; + acc.sum += r.score; + acc.count += 1; + byDay.set(offset, acc); + } + const daily = [...byDay.entries()].map(([dayOffset, acc]) => ({ + dayOffset, + value: acc.sum / acc.count, + })); + const stability = computeMoodStability(daily); + return stability ? stability.score : null; +} diff --git a/src/lib/insights/derived/registry.ts b/src/lib/insights/derived/registry.ts index a7b1febe5..e14c5a5b5 100644 --- a/src/lib/insights/derived/registry.ts +++ b/src/lib/insights/derived/registry.ts @@ -133,7 +133,7 @@ const REGISTRY: Record = { inputs: ["SLEEP_DURATION"], minHistoryDays: 1, minInputs: 1, - implemented: false, + implemented: true, }, READINESS: { id: "READINESS", @@ -148,7 +148,7 @@ const REGISTRY: Record = { ], minHistoryDays: 7, minInputs: 2, - implemented: false, + implemented: true, }, COINCIDENT_DEVIATION: { id: "COINCIDENT_DEVIATION", @@ -157,7 +157,7 @@ const REGISTRY: Record = { inputs: VITALS_BASELINE_TYPES, minHistoryDays: 7, minInputs: 2, - implemented: false, + implemented: true, }, HRV_BALANCE: { id: "HRV_BALANCE", diff --git a/src/lib/insights/derived/sleep-score.ts b/src/lib/insights/derived/sleep-score.ts new file mode 100644 index 000000000..600734e31 --- /dev/null +++ b/src/lib/insights/derived/sleep-score.ts @@ -0,0 +1,473 @@ +/** + * v1.10.0 — transparent Sleep Score (catalogue metric #6, COMPOSITE). + * + * A 0–100 sleep-quality composite whose sub-scores are SHOWN, not a black + * box. Built from the per-stage `SLEEP_DURATION` rows HealthKit + Withings + * write (one row per stage per night, `value` in minutes, `sleepStage` ∈ + * { IN_BED, AWAKE, ASLEEP, REM, CORE, DEEP }). Combines: + * + * - **Sufficiency** = total asleep minutes vs an age-based need target + * (Hirshkowitz et al. 2015 National Sleep Foundation recommendations). + * - **Efficiency** = asleep ÷ in-bed minutes (AASM scoring convention; + * ≥ 85 % is the clinical "good" floor). + * - **Consistency** = SD of the sleep midpoint across the window (lower + * is better) — a regularity proxy. + * - **Composition** = REM% + Deep% of total asleep vs Ohayon et al. 2017 + * age norms. + * + * Each sub-score is 0..100 with its own transparent weight; the composite + * **reweights around missing sub-scores** exactly as `health-score.ts` + * null-redistributes (a legacy `ASLEEP`-only night yields Sufficiency + + * Efficiency + Consistency + Timing, dropping Composition gracefully — it + * is never fabricated). Restfulness / Restoration are NOT derivable (no + * per-stage HR, no WASO timestamps) and are omitted, not faked. + * + * Standard: AASM scoring conventions (efficiency); Hirshkowitz et al. 2015, + * Sleep Health 1(1):40–43 (age-based duration need); Ohayon et al. 2017, + * Sleep Health 3(1):6–19 (stage-percentage norms by age). Framing + * discipline: consumer sleep-staging vs PSG is only moderately accurate — + * the copy stays directional, never clinical. + * + * Server-only — reads raw `SLEEP_DURATION` rows via Prisma (per-stage rows + * are not rolled up by stage, so the night reconstruction reads raw, but + * bounded to the window). The pure scorers are exported for unit tests. + */ +import type { MeasurementType, SleepStage } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + buildInsufficient, + buildOk, + deriveCoverage, + nowProvenanceTimestamp, +} from "./coverage"; +import type { BaselineProfile } from "./baseline"; +import type { Derived } from "./types"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +/** Default trailing window for the consistency/timing baseline (days). */ +const DEFAULT_WINDOW_DAYS = 30; +/** A night needs ≥ this many stage rows to score at all. */ +const MIN_STAGE_ROWS_PER_NIGHT = 1; + +/** Transparent sub-score weights (shown to the user, sum to 1 over present). */ +export const SLEEP_SUBSCORE_WEIGHTS = { + sufficiency: 0.3, + efficiency: 0.25, + consistency: 0.2, + timing: 0.1, + composition: 0.15, +} as const; + +export type SleepSubScoreKey = keyof typeof SLEEP_SUBSCORE_WEIGHTS; + +/** One sub-score row the anatomy view renders as a contributor. */ +export interface SleepSubScore { + key: SleepSubScoreKey; + /** 0..100, or null when the night lacks the inputs (drops from the blend). */ + value: number | null; + /** Effective weight after null-redistribution, 0..1. */ + weight: number; +} + +export interface SleepScoreValue { + /** The composite 0..100. */ + score: number; + /** Band derived from the score (green/yellow/red). */ + band: "green" | "yellow" | "red"; + /** The night this score describes (YYYY-MM-DD of the wake day). */ + night: string; + /** Total asleep minutes that night. */ + asleepMinutes: number; + /** Total in-bed minutes that night (asleep + awake-in-bed), when known. */ + inBedMinutes: number | null; + /** The transparent sub-scores (present + dropped). */ + subScores: SleepSubScore[]; + /** Nights in the window that backed the consistency/timing baseline. */ + windowNights: number; +} + +const ASLEEP_STAGES: ReadonlySet = new Set([ + "ASLEEP", + "REM", + "CORE", + "DEEP", +]); + +// ── pure scorers (exported for tests) ───────────────────────────────── + +/** + * Age-based sleep-need target in minutes (National Sleep Foundation, + * Hirshkowitz et al. 2015). Adult default 7–9 h; we anchor the target at + * the lower-recommended bound so hitting it scores 100. + */ +export function sleepNeedMinutes(ageYears: number | null): number { + if (ageYears == null || !Number.isFinite(ageYears)) return 7 * 60; // adult default + if (ageYears < 1) return 14 * 60; + if (ageYears < 3) return 12 * 60; + if (ageYears < 6) return 11 * 60; + if (ageYears < 14) return 10 * 60; + if (ageYears < 18) return 9 * 60; + if (ageYears < 65) return 7 * 60; + return 7 * 60; // 65+ NSF recommends 7–8 h +} + +/** Sufficiency: asleep vs need, capped at 100 (oversleep is not penalised). */ +export function scoreSufficiency( + asleepMinutes: number, + needMinutes: number, +): number | null { + if (needMinutes <= 0) return null; + return clamp100((asleepMinutes / needMinutes) * 100); +} + +/** + * Efficiency: asleep ÷ in-bed, rescaled so the AASM 85 % "good" floor + * maps to ~85 and 100 % maps to 100. Null when in-bed is unknown (legacy + * nights with only asleep stages and no IN_BED row). + */ +export function scoreEfficiency( + asleepMinutes: number, + inBedMinutes: number | null, +): number | null { + if (inBedMinutes == null || inBedMinutes <= 0) return null; + const pct = (asleepMinutes / inBedMinutes) * 100; + return clamp100(pct); +} + +/** + * Composition: how close REM% + Deep% sit to the age-typical band + * (Ohayon et al. 2017). Adults: REM ≈ 20–25 %, Deep ≈ 13–23 % of total + * asleep. We score the combined REM+Deep fraction against a 33–48 % target + * window — inside → 100, falling off linearly outside. Null when no stage + * breakdown exists (a legacy `ASLEEP`-only night). + */ +export function scoreComposition( + remMinutes: number, + deepMinutes: number, + asleepMinutes: number, + hasStageBreakdown: boolean, +): number | null { + if (!hasStageBreakdown || asleepMinutes <= 0) return null; + const fraction = (remMinutes + deepMinutes) / asleepMinutes; + const lo = 0.33; + const hi = 0.48; + if (fraction >= lo && fraction <= hi) return 100; + // Linear fall-off: half a window-width away from the band → 0. + const halfWidth = (hi - lo) / 2 + 0.15; + const dist = fraction < lo ? lo - fraction : fraction - hi; + return clamp100(100 * (1 - dist / halfWidth)); +} + +/** + * Consistency: lower SD of the sleep midpoint (minutes-of-day) across the + * window → higher score. A 90-min SD maps to 0, a 0-min SD to 100. Null + * below 3 nights (no variance signal). + */ +export function scoreConsistency(midpointMinutes: number[]): number | null { + if (midpointMinutes.length < 3) return null; + const mean = + midpointMinutes.reduce((s, v) => s + v, 0) / midpointMinutes.length; + const variance = + midpointMinutes.reduce((s, v) => s + (v - mean) ** 2, 0) / + midpointMinutes.length; + const sd = Math.sqrt(variance); + const FULL_SCALE = 90; // minutes — beyond this the rhythm reads erratic. + return clamp100(100 * (1 - Math.min(sd, FULL_SCALE) / FULL_SCALE)); +} + +/** + * Timing: this night's midpoint vs the user's habitual midpoint (the + * window mean). On-target → 100; 90 min off → 0. Null when the habitual + * window is not yet established (< 3 nights). + */ +export function scoreTiming( + nightMidpoint: number | null, + habitualMidpoint: number | null, + windowNights: number, +): number | null { + if (nightMidpoint == null || habitualMidpoint == null || windowNights < 3) { + return null; + } + const FULL_SCALE = 90; + const dist = Math.abs(nightMidpoint - habitualMidpoint); + return clamp100(100 * (1 - Math.min(dist, FULL_SCALE) / FULL_SCALE)); +} + +function clamp100(v: number): number { + if (!Number.isFinite(v)) return 0; + return Math.max(0, Math.min(100, Math.round(v))); +} + +function bandForScore(score: number): "green" | "yellow" | "red" { + if (score >= 70) return "green"; + if (score >= 40) return "yellow"; + return "red"; +} + +/** + * Blend the present sub-scores with null-redistribution (the + * `health-score.ts` pattern). Returns the composite + the per-sub-score + * effective weights for the anatomy view. Pure. + */ +export function blendSleepSubScores( + raw: Record, +): { score: number; subScores: SleepSubScore[] } { + const keys = Object.keys(SLEEP_SUBSCORE_WEIGHTS) as SleepSubScoreKey[]; + const present = keys.filter((k) => raw[k] !== null); + const totalBaseWeight = present.reduce( + (s, k) => s + SLEEP_SUBSCORE_WEIGHTS[k], + 0, + ); + const subScores: SleepSubScore[] = keys.map((k) => ({ + key: k, + value: raw[k], + weight: + raw[k] === null || totalBaseWeight === 0 + ? 0 + : SLEEP_SUBSCORE_WEIGHTS[k] / totalBaseWeight, + })); + let composite = 0; + for (const s of subScores) { + if (s.value !== null) composite += s.value * s.weight; + } + return { score: clamp100(composite), subScores }; +} + +// ── night reconstruction ─────────────────────────────────────────────── + +interface SleepRow { + value: number; + measuredAt: Date; + sleepStage: SleepStage | null; +} + +export interface NightSummary { + /** Wake-day key (YYYY-MM-DD) the night is filed under. */ + night: string; + asleepMinutes: number; + awakeMinutes: number; + remMinutes: number; + deepMinutes: number; + inBedMinutes: number | null; + hasStageBreakdown: boolean; + /** Midpoint as minutes-of-day (0..1439), or null when timestamps collapse. */ + midpoint: number | null; +} + +/** + * Group raw stage rows into per-night summaries. A "night" is keyed by the + * latest stage row's calendar day (the wake day). Pure — the caller does + * the bounded DB read and passes rows in. + */ +export function reconstructNights(rows: SleepRow[]): NightSummary[] { + // Bucket rows by the wake-day key (UTC day of the row's measuredAt). + const byNight = new Map(); + for (const row of rows) { + const key = row.measuredAt.toISOString().slice(0, 10); + const list = byNight.get(key) ?? []; + list.push(row); + byNight.set(key, list); + } + const nights: NightSummary[] = []; + for (const [night, nightRows] of byNight) { + let asleep = 0; + let awake = 0; + let rem = 0; + let deep = 0; + let inBed = 0; + let sawInBed = false; + let sawStageBreakdown = false; + let earliest = Infinity; + let latest = -Infinity; + for (const r of nightRows) { + const stage = r.sleepStage; + const minutes = Number.isFinite(r.value) ? r.value : 0; + const t = r.measuredAt.getTime(); + if (t < earliest) earliest = t; + if (t > latest) latest = t; + if (stage === "IN_BED") { + inBed += minutes; + sawInBed = true; + continue; + } + if (stage === "AWAKE") { + awake += minutes; + continue; + } + if (stage && ASLEEP_STAGES.has(stage)) { + asleep += minutes; + if (stage === "REM") rem += minutes; + if (stage === "DEEP") deep += minutes; + if (stage === "REM" || stage === "CORE" || stage === "DEEP") { + sawStageBreakdown = true; + } + } else if (stage == null) { + // A bare SLEEP_DURATION row (no stage) is the night's total asleep. + asleep += minutes; + } + } + // In-bed = explicit IN_BED rows when present, else asleep + awake. + const inBedMinutes = sawInBed ? inBed : awake > 0 ? asleep + awake : null; + const midpoint = + Number.isFinite(earliest) && Number.isFinite(latest) && latest > earliest + ? minutesOfDay(new Date((earliest + latest) / 2)) + : null; + nights.push({ + night, + asleepMinutes: asleep, + awakeMinutes: awake, + remMinutes: rem, + deepMinutes: deep, + inBedMinutes, + hasStageBreakdown: sawStageBreakdown, + midpoint, + }); + } + return nights.sort((a, b) => (a.night < b.night ? -1 : 1)); +} + +function minutesOfDay(d: Date): number { + return d.getUTCHours() * 60 + d.getUTCMinutes(); +} + +// ── compute ───────────────────────────────────────────────────────────── + +export interface SleepScoreOpts { + windowDays?: number; + now?: Date; +} + +/** + * Compute the Sleep Score for the most recent night, using the trailing + * window for the consistency/timing baseline. Returns `insufficient` when + * no scorable night exists in the window. + */ +export async function computeSleepScore( + userId: string, + profile: BaselineProfile, + opts: SleepScoreOpts = {}, +): Promise> { + const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS; + const now = opts.now ?? new Date(); + const computedAt = nowProvenanceTimestamp(now); + const inputs = ["SLEEP_DURATION"]; + const required = 1; + const since = new Date(now.getTime() - windowDays * MS_PER_DAY); + + const rows = (await prisma.measurement.findMany({ + where: { + userId, + type: "SLEEP_DURATION" satisfies MeasurementType, + deletedAt: null, + measuredAt: { gte: since }, + }, + orderBy: { measuredAt: "asc" }, + select: { value: true, measuredAt: true, sleepStage: true }, + })) as SleepRow[]; + + if (rows.length === 0) { + const { coverage } = deriveCoverage({ + requiredInputs: required, + presentInputs: 0, + historyDays: 0, + missing: inputs, + fullHistoryDays: windowDays, + }); + return buildInsufficient({ + coverage, + provenance: { inputs, source: "none", windowDays, computedAt }, + reason: "no_sleep_in_window", + }); + } + + const nights = reconstructNights(rows).filter( + (n) => n.asleepMinutes > 0, + ); + const scorableNights = nights.filter( + (n) => countNightRows(rows, n.night) >= MIN_STAGE_ROWS_PER_NIGHT, + ); + + if (scorableNights.length === 0) { + const { coverage } = deriveCoverage({ + requiredInputs: required, + presentInputs: 0, + historyDays: 0, + missing: inputs, + fullHistoryDays: windowDays, + }); + return buildInsufficient({ + coverage, + provenance: { inputs, source: "live", windowDays, computedAt }, + reason: "no_scorable_night", + }); + } + + const latest = scorableNights[scorableNights.length - 1]; + const midpoints = scorableNights + .map((n) => n.midpoint) + .filter((m): m is number => m != null); + const habitualMidpoint = + midpoints.length > 0 + ? midpoints.reduce((s, v) => s + v, 0) / midpoints.length + : null; + + const needMinutes = sleepNeedMinutes(profile.ageYears); + + const raw: Record = { + sufficiency: scoreSufficiency(latest.asleepMinutes, needMinutes), + efficiency: scoreEfficiency(latest.asleepMinutes, latest.inBedMinutes), + consistency: scoreConsistency(midpoints), + timing: scoreTiming(latest.midpoint, habitualMidpoint, midpoints.length), + composition: scoreComposition( + latest.remMinutes, + latest.deepMinutes, + latest.asleepMinutes, + latest.hasStageBreakdown, + ), + }; + + const { score, subScores } = blendSleepSubScores(raw); + const presentCount = subScores.filter((s) => s.value !== null).length; + const missing = subScores + .filter((s) => s.value === null) + .map((s) => s.key); + + const { coverage, confidence } = deriveCoverage({ + // Coverage here is "sub-scores present / total possible" — the + // composite's input axis is its five sub-scores, not the single + // SLEEP_DURATION stream. + requiredInputs: subScores.length, + presentInputs: presentCount, + historyDays: scorableNights.length, + missing, + fullHistoryDays: windowDays, + }); + + return buildOk({ + value: { + score, + band: bandForScore(score), + night: latest.night, + asleepMinutes: latest.asleepMinutes, + inBedMinutes: latest.inBedMinutes, + subScores, + windowNights: scorableNights.length, + }, + coverage, + confidence, + provenance: { + inputs, + source: "live", + windowDays, + computedAt, + }, + }); +} + +function countNightRows(rows: SleepRow[], night: string): number { + let c = 0; + for (const r of rows) { + if (r.measuredAt.toISOString().slice(0, 10) === night) c += 1; + } + return c; +} diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index d65909d77..e322b886c 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -1385,6 +1385,47 @@ const derivedMetricResponse = z "Flat `Derived` envelope for one derived wellness metric. Pure compute over the rollup tier (no LLM, no narrative). iOS decodes one stable shape and combines values across metrics; coverage/confidence/provenance let it render the same honesty chips.", }); +// v1.10.0 — FDR-controlled correlation discovery result. One discovered, +// statistically-defensible behaviour → next-day-outcome pair. +const discoveredCorrelation = z + .object({ + behaviour: z + .string() + .describe("Behaviour channel (lag source), e.g. TIME_IN_DAYLIGHT, MOOD."), + outcome: z + .string() + .describe("Outcome channel (lag target), e.g. SLEEP_DURATION, HEART_RATE_VARIABILITY."), + n: z.number().int().describe("Paired-day count after the day+1 lag join (≥ 20)."), + r: z.number().describe("Pearson r over the lag-joined daily series."), + pValue: z.number().describe("Two-sided exact Student-t p-value (< 0.05)."), + qValue: z + .number() + .describe("Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold)."), + interpretation: z + .string() + .describe("Conservative, descriptive interpretation — never causal."), + lagDays: z.number().int().describe("Lag in days applied (1)."), + }) + .meta({ id: "DiscoveredCorrelation" }); + +const correlationDiscoveryResponse = z + .object({ + discovered: z + .array(discoveredCorrelation) + .describe("Pairs surviving n ≥ 20, p < 0.05, AND the BH-FDR control."), + pairsTested: z + .number() + .int() + .describe("Behaviour × outcome pairs assessed (for the honest footer)."), + fdrQ: z.number().describe("The FDR target the surface used."), + minPairs: z.number().int().describe("Minimum paired-day count enforced per pair."), + }) + .meta({ + id: "CorrelationDiscoveryResponse", + description: + "v1.10.0 — FDR-controlled correlation discovery over a curated behaviour × outcome matrix, lagged behaviour → next-day outcome. Only statistically-defensible pairs surface; descriptive, never causal.", + }); + // The seven specialised `*-status` routes accept an optional locale // override (the metric is fixed by the route path, unlike the generic // metric-status route which carries it as a query field). @@ -3128,6 +3169,28 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/insights/correlations": { + get: { + tags: ["Insights"], + summary: "Correlation discovery (FDR-controlled)", + description: + "v1.10.0 — scans a curated behaviour × outcome matrix (daylight / mood / glucose / BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour day to the next day's outcome, runs Pearson with the exact Student-t p-value, and applies Benjamini-Hochberg FDR control across every tested pair. Only statistically-defensible pairs surface, each carrying n, r, p, and the BH-adjusted q. Descriptive, never causal. Gated by the operator `correlations` assistant surface. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The discovered correlations + the tested-pair count.", + content: { + "application/json": { + schema: dataEnvelope( + correlationDiscoveryResponse, + "CorrelationDiscoveryResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, }; export const openApiComponents: NonNullable = { diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index b2c3f78a4..2f3e9652a 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -143,6 +143,24 @@ export const queryKeys = { */ insightsDerived: (metric: string, type?: string | null) => ["insights", "derived", metric, type ?? null] as const, + * v1.10.0 — a derived wellness metric (`/api/insights/derived?metric=…`). + * One generic route backs every derived composite (sleep score, + * readiness, coincident-deviation, vitals baseline, …) so the key is + * parameterised by the metric id alongside the optional sub-type. Pure + * compute over the rollup tier — a measurement write keeps it fresh by + * construction (next read sees new buckets), so the `["insights"]` prefix + * is enough for invalidation fan-out. + */ + insightsDerivedMetric: (metric: string, type?: string) => + type + ? (["insights", "derived", metric, type] as const) + : (["insights", "derived", metric] as const), + /** + * v1.10.0 — FDR-controlled correlation discovery + * (`/api/insights/correlations`). Read-only descriptive surface; the + * `["insights"]` prefix keeps it in the existing invalidation fan-out. + */ + insightsCorrelations: () => ["insights", "correlations"] as const, medications: () => ["medications"] as const, medicationDetail: (id: string) => ["medications", id] as const,