feat(insights): add composite scores, score-anatomy view, and correlation discovery

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/<metric>: 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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 21:19:56 +02:00
1 parent 663a3ee09d
commit 95a2a7692f
29 files changed
+3087 -30

No files matched your search

+107
View File
@@ -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:
+34
View File
@@ -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"
}
}
}
}
},
+34
View File
@@ -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"
}
}
}
}
},
+34
View File
@@ -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"
}
}
}
}
},
+34
View File
@@ -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"
}
}
}
}
},
+34
View File
@@ -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"
}
}
}
}
},
+34
View File
@@ -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"
}
}
}
}
},
@@ -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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue({
timezone: "Europe/Berlin",
});
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -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 () => {
+125 -12
View File
@@ -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<string, { sum: number; count: number }>();
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<string, Array<{ value: number; at: Date }>>();
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);
});
+43
View File
@@ -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/<metric>` — 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<string, CompositeMetricId> = {
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 (
<SubPageShell
title={t(`insights.derived.composite.${metric}.title`)}
description={t(`insights.derived.composite.${metric}.description`)}
coachLaunch
>
<CompositeScoreAnatomy metric={metric} />
</SubPageShell>
);
}
@@ -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(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
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("<ScoreAnatomyView>", () => {
it("renders the ok state: ring + contributor rows + provenance", () => {
const html = render(
<ScoreAnatomyView
title="Readiness"
score={74}
contributors={CONTRIBUTORS}
coverage={COVERAGE}
confidence={CONFIDENCE}
provenance={PROVENANCE}
method="A blend of deviation components."
standard={{ name: "Plews 2013", url: "https://example.org" }}
/>,
);
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(
<ScoreAnatomyView
title="Sleep score"
score={null}
contributors={[]}
coverage={{
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: [],
}}
confidence={null}
provenance={{
inputs: [],
source: "none",
windowDays: 0,
computedAt: "2026-06-02T07:00:00+02:00",
}}
method="A transparent blend."
insufficient
/>,
);
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"');
});
});
@@ -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<CompositeMetricId, ProvenanceStandard> = {
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<SleepScoreValue>("SLEEP_SCORE", {
enabled: metric === "SLEEP_SCORE",
});
const readiness = useDerivedMetric<ReadinessValue>("READINESS", {
enabled: metric === "READINESS",
});
const query = metric === "SLEEP_SCORE" ? sleep : readiness;
if (query.isLoading) {
return (
<div data-slot="composite-anatomy-loading" className={className}>
<Skeleton className="h-[28rem] w-full rounded-xl" />
</div>
);
}
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 (
<ScoreAnatomyView
title={title}
score={null}
contributors={[]}
coverage={{
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: [],
}}
confidence={null}
provenance={{
inputs: [],
source: "none",
windowDays: 0,
computedAt: new Date().toISOString(),
}}
method={t(`insights.derived.composite.${metric}.method`)}
standard={standard}
insufficient
className={className}
/>
);
}
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 (
<ScoreAnatomyView
title={title}
score={score}
caption={caption}
contributors={contributors}
coverage={data.coverage}
confidence={data.confidence}
provenance={data.provenance}
method={t(`insights.derived.composite.${metric}.method`)}
standard={standard}
insufficient={insufficient}
className={className}
/>
);
}
/** 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;
}
+5
View File
@@ -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,
@@ -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 (
<section
data-slot="score-anatomy-view"
data-status={insufficient ? "insufficient" : "ok"}
className={cn(
"border-border bg-card flex flex-col gap-5 rounded-xl border p-5",
className,
)}
aria-label={typeof title === "string" ? title : undefined}
>
{/* Hero: title + ring + caption */}
<div className="flex flex-col items-center gap-3 text-center">
<h2
data-slot="score-anatomy-title"
className="text-foreground text-sm font-semibold tracking-wide uppercase"
>
{title}
</h2>
<ScoreRing
score={effectiveScore}
band={band}
label={t("insights.derived.anatomy.outOf")}
size="lg"
/>
{caption && !insufficient ? (
<p
data-slot="score-anatomy-caption"
className="text-muted-foreground text-xs"
>
{caption}
</p>
) : null}
<CoverageMeter
coverage={coverage}
confidence={confidence ?? undefined}
size="md"
/>
</div>
{insufficient ? (
<p
data-slot="score-anatomy-insufficient"
className="text-muted-foreground rounded-lg border border-dashed px-4 py-6 text-center text-xs"
>
{insufficientNote ??
t("insights.derived.anatomy.insufficient")}
</p>
) : (
<div data-slot="score-anatomy-contributors" className="space-y-3">
<p className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase">
{t("insights.derived.anatomy.contributorsLabel")}
</p>
<ul className="space-y-2.5">
{contributors.map((c) => (
<ContributorRow key={c.key} contributor={c} />
))}
</ul>
</div>
)}
{/* Provenance + cited standard + the non-clinical disclaimer */}
<div className="flex items-center justify-between gap-2 border-t pt-3">
<p
data-slot="score-anatomy-disclaimer"
className="text-muted-foreground text-[10px] leading-snug"
>
{t("insights.derived.anatomy.disclaimer")}
</p>
<ProvenanceExplainer
provenance={provenance}
method={method}
standard={standard}
/>
</div>
</section>
);
}
/** 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 (
<li
data-slot="anatomy-contributor-row"
data-contributor={contributor.key}
data-present={present ? "true" : "false"}
className="space-y-1"
>
<div className="flex items-center justify-between gap-2">
<span className="text-foreground text-xs font-medium">
{contributor.label}
</span>
<span
data-slot="anatomy-contributor-value"
className="text-muted-foreground text-[11px] tabular-nums"
>
{present
? t("insights.derived.anatomy.contributorValue", {
value: Math.round(clamped),
weight: weightPct,
})
: t("insights.derived.anatomy.contributorMissing")}
</span>
</div>
<div
className="bg-muted/40 h-1.5 w-full overflow-hidden rounded-full"
role="presentation"
>
<div
data-slot="anatomy-contributor-bar"
className={cn(
"h-full rounded-full transition-all",
present ? BAND_PROGRESS_CLASS[rowBand] : "bg-muted",
)}
style={{ width: present ? `${clamped}%` : "0%" }}
/>
</div>
</li>
);
}
+72
View File
@@ -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<T>` 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<T> {
metric: string;
status: "ok" | "insufficient";
value: T | null;
coverage: DerivedCoverage;
confidence: DerivedConfidence | null;
provenance: DerivedProvenance;
reason: string | null;
}
export function useDerivedMetric<T>(
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<DerivedMetricEnvelope<T>> => {
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<T> };
return json.data;
} finally {
clearTimeout(timeoutHandle);
}
},
enabled,
staleTime: 60 * 1000,
retry: 0,
});
}
@@ -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);
});
});
+252
View File
@@ -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<number>(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;
@@ -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<typeof vi.fn>;
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);
}
});
});
@@ -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",
@@ -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<typeof vi.fn>;
const moodFindMany = prisma.moodEntry.findMany as ReturnType<typeof vi.fn>;
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<ReadinessComponentKey, number | null> = {
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);
}
});
});
@@ -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<typeof vi.fn>;
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<SleepSubScoreKey, number | null> = {
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");
}
});
});
@@ -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<Derived<CoincidentDeviationValue>> {
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<CoincidentDeviationValue>({
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<CoincidentDeviationValue>({
value: {
fired,
vitals,
contributing,
day: latestDay,
},
coverage: cov,
confidence,
provenance: { inputs, source, windowDays, computedAt },
});
}
+15
View File
@@ -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<Derived<unknown>>;
case "BMI":
return computeBmi(args.userId, args.profile, {
case "SLEEP_SCORE":
return computeSleepScore(args.userId, args.profile, {
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
case "READINESS":
return computeReadiness(args.userId, args.profile, {
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
case "COINCIDENT_DEVIATION":
return computeCoincidentDeviation(args.userId, args.profile, {
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
+46
View File
@@ -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";
+391
View File
@@ -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<ReadinessComponentKey, number | null>,
): { 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<number | 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).
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<Derived<ReadinessValue>> {
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<ReadinessComponentKey, number | null> = {
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<ReadinessValue>({
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<ReadinessValue>({
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<number | null> {
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<number, { sum: number; count: number }>();
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;
}
+3 -3
View File
@@ -133,7 +133,7 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
inputs: ["SLEEP_DURATION"],
minHistoryDays: 1,
minInputs: 1,
implemented: false,
implemented: true,
},
READINESS: {
id: "READINESS",
@@ -148,7 +148,7 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
],
minHistoryDays: 7,
minInputs: 2,
implemented: false,
implemented: true,
},
COINCIDENT_DEVIATION: {
id: "COINCIDENT_DEVIATION",
@@ -157,7 +157,7 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
inputs: VITALS_BASELINE_TYPES,
minHistoryDays: 7,
minInputs: 2,
implemented: false,
implemented: true,
},
HRV_BALANCE: {
id: "HRV_BALANCE",
+473
View File
@@ -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<SleepStage> = new Set<SleepStage>([
"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<SleepSubScoreKey, number | null>,
): { 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<string, SleepRow[]>();
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<Derived<SleepScoreValue>> {
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<SleepScoreValue>({
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<SleepScoreValue>({
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<SleepSubScoreKey, number | null> = {
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<SleepScoreValue>({
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;
}
+63
View File
@@ -1385,6 +1385,47 @@ const derivedMetricResponse = z
"Flat `Derived<T>` 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<ZodOpenApiObject["paths"]> = {
},
},
},
"/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<ZodOpenApiObject["components"]> = {
+18
View File
@@ -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,