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

@@ -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,