diff --git a/src/lib/insights/derived/__tests__/baseline.test.ts b/src/lib/insights/derived/__tests__/baseline.test.ts new file mode 100644 index 000000000..98f1c95c3 --- /dev/null +++ b/src/lib/insights/derived/__tests__/baseline.test.ts @@ -0,0 +1,205 @@ +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(), +})); +vi.mock("@/lib/rollups/measurement-read-wmy", () => ({ + readBestGranularityRollups: vi.fn(), +})); + +import { + median, + medianAbsoluteDeviation, + buildBaselineBand, + computeVitalsBaseline, +} from "../baseline"; +import { prisma } from "@/lib/db"; +import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; +import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy"; + +const PROFILE = { ageYears: 40, sex: "MALE" as const }; +const NOW = new Date("2026-06-02T07:00:00Z"); + +function dayRow(day: string, mean: number) { + return { + bucketStart: new Date(`${day}T00:00:00Z`), + count: 1, + mean, + sd: null, + slope: null, + r2: null, + sumValue: null, + minValue: mean, + maxValue: mean, + }; +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("pure statistics", () => { + it("median handles odd + even lengths", () => { + expect(median([3, 1, 2])).toBe(2); + expect(median([4, 1, 3, 2])).toBe(2.5); + }); + + it("MAD is the median of absolute deviations about the median", () => { + // values 1,2,3,4,5 → median 3 → |dev| 2,1,0,1,2 → MAD = 1 + expect(medianAbsoluteDeviation([1, 2, 3, 4, 5])).toBe(1); + }); + + it("buildBaselineBand returns a symmetric MAD band", () => { + const band = buildBaselineBand([50, 52, 54, 56, 58], 3); + expect(band).not.toBeNull(); + expect(band!.center).toBe(54); + expect(band!.low).toBeLessThan(band!.center); + expect(band!.high).toBeGreaterThan(band!.center); + expect(band!.sampleDays).toBe(5); + expect(band!.k).toBe(3); + }); + + it("parity: a composed-DAY-bucket series and the raw-DAY series yield the same band", () => { + // Same per-day means whether they arrive as raw rows or DAY rollups. + const perDayMeans = [60, 61, 62, 59, 63, 58, 64]; + const fromRaw = buildBaselineBand(perDayMeans, 3); + const fromRollup = buildBaselineBand([...perDayMeans], 3); + expect(fromRollup).toEqual(fromRaw); + }); +}); + +describe("computeVitalsBaseline — ok path (rollup tier)", () => { + it("returns an ok band from DAY rollups with provenance source DAY", async () => { + vi.mocked(probeRollupCoverage).mockResolvedValue( + new Map([["RESTING_HEART_RATE", true]]), + ); + const rows = [ + "2026-05-20", + "2026-05-21", + "2026-05-22", + "2026-05-23", + "2026-05-24", + "2026-05-25", + "2026-05-26", + "2026-05-27", + ].map((d, i) => dayRow(d, 55 + i)); + vi.mocked(readBestGranularityRollups).mockResolvedValue({ + granularity: "DAY", + rows, + }); + + const result = await computeVitalsBaseline("u1", PROFILE, { + type: "RESTING_HEART_RATE", + now: NOW, + }); + + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.value.type).toBe("RESTING_HEART_RATE"); + expect(result.provenance.source).toBe("DAY"); + expect(result.coverage.historyDays).toBe(8); + expect(result.confidence.score).toBeGreaterThan(0); + } + // rollup-covered path must not touch raw SQL + expect(prisma.measurement.findMany).not.toHaveBeenCalled(); + }); +}); + +describe("computeVitalsBaseline — coverage-miss live fallback", () => { + it("falls back to raw rows and reports provenance source live", async () => { + // coverage says no buckets for this type → live read + vi.mocked(probeRollupCoverage).mockResolvedValue( + new Map([["RESTING_HEART_RATE", false]]), + ); + const raw = [ + "2026-05-20", + "2026-05-21", + "2026-05-22", + "2026-05-23", + "2026-05-24", + "2026-05-25", + "2026-05-26", + ].map((d, i) => ({ + value: 60 + i, + measuredAt: new Date(`${d}T08:00:00Z`), + })); + vi.mocked(prisma.measurement.findMany).mockResolvedValue(raw as never); + + const result = await computeVitalsBaseline("u1", PROFILE, { + type: "RESTING_HEART_RATE", + now: NOW, + }); + + expect(result.status).toBe("ok"); + if (result.status === "ok") { + expect(result.provenance.source).toBe("live"); + expect(result.coverage.historyDays).toBe(7); + } + expect(readBestGranularityRollups).not.toHaveBeenCalled(); + expect(prisma.measurement.findMany).toHaveBeenCalledTimes(1); + }); + + it("rollup-covered but window resolves coarser → live fallback keeps spread DAY-native", async () => { + vi.mocked(probeRollupCoverage).mockResolvedValue( + new Map([["RESTING_HEART_RATE", true]]), + ); + // resolved at MONTH (coarse) — engine must not band from it + vi.mocked(readBestGranularityRollups).mockResolvedValue({ + granularity: "MONTH", + rows: [dayRow("2026-04-01", 55)], + }); + const raw = Array.from({ length: 7 }, (_, i) => ({ + value: 58 + i, + measuredAt: new Date(`2026-05-2${i}T08:00:00Z`), + })); + vi.mocked(prisma.measurement.findMany).mockResolvedValue(raw as never); + + const result = await computeVitalsBaseline("u1", PROFILE, { + type: "RESTING_HEART_RATE", + now: NOW, + }); + expect(result.status).toBe("ok"); + if (result.status === "ok") expect(result.provenance.source).toBe("live"); + }); +}); + +describe("computeVitalsBaseline — insufficient paths", () => { + it("no data at all → insufficient, source none", async () => { + vi.mocked(probeRollupCoverage).mockResolvedValue(new Map()); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + + const result = await computeVitalsBaseline("u1", PROFILE, { + type: "RESTING_HEART_RATE", + now: NOW, + }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("no_readings_in_window"); + expect(result.provenance.source).toBe("none"); + expect(result.coverage.historyDays).toBe(0); + } + }); + + it("below the 7-day floor → insufficient, value-less, but coverage shows progress", async () => { + vi.mocked(probeRollupCoverage).mockResolvedValue( + new Map([["RESTING_HEART_RATE", true]]), + ); + vi.mocked(readBestGranularityRollups).mockResolvedValue({ + granularity: "DAY", + rows: [dayRow("2026-05-25", 55), dayRow("2026-05-26", 56), dayRow("2026-05-27", 57)], + }); + + const result = await computeVitalsBaseline("u1", PROFILE, { + type: "RESTING_HEART_RATE", + now: NOW, + }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("insufficient_history_for_band"); + expect(result.coverage.historyDays).toBe(3); + } + }); +}); diff --git a/src/lib/insights/derived/__tests__/coverage.test.ts b/src/lib/insights/derived/__tests__/coverage.test.ts new file mode 100644 index 000000000..9fb352008 --- /dev/null +++ b/src/lib/insights/derived/__tests__/coverage.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from "vitest"; +import { + deriveCoverage, + buildOk, + buildInsufficient, + scoreToBand, + nowProvenanceTimestamp, +} from "../coverage"; +import { isDerivedOk, type Derived } from "../types"; + +const PROV = { + inputs: ["RESTING_HEART_RATE"], + source: "DAY" as const, + windowDays: 30, + computedAt: "2026-06-02T07:00:00+02:00", +}; + +describe("scoreToBand", () => { + it("maps score ranges onto the four bands", () => { + expect(scoreToBand(90)).toBe("high"); + expect(scoreToBand(75)).toBe("high"); + expect(scoreToBand(60)).toBe("medium"); + expect(scoreToBand(50)).toBe("medium"); + expect(scoreToBand(30)).toBe("low"); + expect(scoreToBand(25)).toBe("low"); + expect(scoreToBand(10)).toBe("draft"); + }); +}); + +describe("deriveCoverage", () => { + it("clamps presentInputs to requiredInputs and copies missing[]", () => { + const missing = ["HEART_RATE_VARIABILITY"]; + const { coverage } = deriveCoverage({ + requiredInputs: 2, + presentInputs: 5, + historyDays: 14, + missing, + fullHistoryDays: 30, + }); + expect(coverage.requiredInputs).toBe(2); + expect(coverage.presentInputs).toBe(2); + expect(coverage.historyDays).toBe(14); + expect(coverage.missing).toEqual(missing); + // defensive copy, not the same reference + expect(coverage.missing).not.toBe(missing); + }); + + it("scores a fully-covered, full-history metric high", () => { + const { confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: 30, + missing: [], + fullHistoryDays: 30, + }); + expect(confidence.score).toBe(100); + expect(confidence.band).toBe("high"); + }); + + it("drops confidence monotonically as inputs go missing", () => { + const full = deriveCoverage({ + requiredInputs: 4, + presentInputs: 4, + historyDays: 30, + missing: [], + fullHistoryDays: 30, + }).confidence.score; + const partial = deriveCoverage({ + requiredInputs: 4, + presentInputs: 2, + historyDays: 30, + missing: ["a", "b"], + fullHistoryDays: 30, + }).confidence.score; + const sparse = deriveCoverage({ + requiredInputs: 4, + presentInputs: 1, + historyDays: 30, + missing: ["a", "b", "c"], + fullHistoryDays: 30, + }).confidence.score; + expect(full).toBeGreaterThan(partial); + expect(partial).toBeGreaterThan(sparse); + }); + + it("drops confidence as history shrinks", () => { + const long = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: 30, + missing: [], + fullHistoryDays: 30, + }).confidence.score; + const short = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: 7, + missing: [], + fullHistoryDays: 30, + }).confidence.score; + expect(long).toBeGreaterThan(short); + }); + + it("floors a present-but-sparse value at 1 rather than 0", () => { + const { confidence } = deriveCoverage({ + requiredInputs: 100, + presentInputs: 1, + historyDays: 1, + missing: [], + fullHistoryDays: 365, + }); + expect(confidence.score).toBeGreaterThanOrEqual(1); + }); + + it("scores zero when nothing is present", () => { + const { confidence } = deriveCoverage({ + requiredInputs: 2, + presentInputs: 0, + historyDays: 0, + missing: ["a", "b"], + }); + expect(confidence.score).toBe(0); + expect(confidence.band).toBe("draft"); + }); +}); + +describe("buildOk / buildInsufficient — Derived branching", () => { + it("buildOk produces the ok arm with all four facets", () => { + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: 30, + missing: [], + }); + const d: Derived<{ center: number }> = buildOk({ + value: { center: 55 }, + coverage, + confidence, + provenance: PROV, + }); + expect(d.status).toBe("ok"); + expect(isDerivedOk(d)).toBe(true); + if (isDerivedOk(d)) { + expect(d.value.center).toBe(55); + expect(d.confidence.band).toBe("high"); + expect(d.provenance.source).toBe("DAY"); + } + }); + + it("buildInsufficient produces the gated arm with coverage + reason, no value", () => { + const { coverage } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: ["RESTING_HEART_RATE"], + }); + const d: Derived<{ center: number }> = buildInsufficient({ + coverage, + provenance: { ...PROV, source: "none", windowDays: 0 }, + reason: "no_readings_in_window", + }); + expect(d.status).toBe("insufficient"); + expect(isDerivedOk(d)).toBe(false); + if (d.status === "insufficient") { + expect(d.reason).toBe("no_readings_in_window"); + expect(d.coverage.missing).toContain("RESTING_HEART_RATE"); + // the gated arm carries no `value` / `confidence` keys + expect("value" in d).toBe(false); + expect("confidence" in d).toBe(false); + } + }); +}); + +describe("nowProvenanceTimestamp", () => { + it("emits an ISO-8601 string", () => { + const ts = nowProvenanceTimestamp(new Date("2026-06-02T05:00:00Z")); + expect(ts).toBe("2026-06-02T05:00:00.000Z"); + }); +}); diff --git a/src/lib/insights/derived/__tests__/dispatch.test.ts b/src/lib/insights/derived/__tests__/dispatch.test.ts new file mode 100644 index 000000000..416a400d3 --- /dev/null +++ b/src/lib/insights/derived/__tests__/dispatch.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { findMany: vi.fn().mockResolvedValue([]) } }, +})); +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 { computeDerivedMetric } from "../dispatch"; +import { + DERIVED_METRIC_IDS, + getDerivedMetricMeta, + isDerivedMetricId, + isVitalsBaselineType, +} from "../registry"; + +const PROFILE = { ageYears: 40, sex: "MALE" as const }; +const NOW = new Date("2026-06-02T07:00:00Z"); + +beforeEach(() => vi.clearAllMocks()); + +describe("registry", () => { + it("exposes the flagship as implemented and the rest as stubs", () => { + expect(getDerivedMetricMeta("VITALS_BASELINE")?.implemented).toBe(true); + expect(getDerivedMetricMeta("READINESS")?.implemented).toBe(false); + expect(getDerivedMetricMeta("FITNESS_AGE")?.implemented).toBe(false); + }); + + it("isDerivedMetricId rejects unknown ids", () => { + expect(isDerivedMetricId("VITALS_BASELINE")).toBe(true); + expect(isDerivedMetricId("NOPE")).toBe(false); + }); + + it("isVitalsBaselineType gates the supported set", () => { + expect(isVitalsBaselineType("RESTING_HEART_RATE")).toBe(true); + expect(isVitalsBaselineType("STEPS")).toBe(false); + }); + + it("DERIVED_METRIC_IDS is the full closed enum", () => { + expect(DERIVED_METRIC_IDS).toContain("VITALS_BASELINE"); + expect(DERIVED_METRIC_IDS).toContain("READINESS"); + expect(DERIVED_METRIC_IDS.length).toBe(6); + }); +}); + +describe("computeDerivedMetric dispatch", () => { + it("routes VITALS_BASELINE to the engine (no data → insufficient, not a throw)", async () => { + const result = await computeDerivedMetric({ + metric: "VITALS_BASELINE", + userId: "u1", + profile: PROFILE, + type: "RESTING_HEART_RATE", + now: NOW, + }); + expect(result.status).toBe("insufficient"); + }); + + it("returns not_implemented for a stubbed metric (never a 500)", 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("not_implemented"); + } + }); + + it("returns unsupported_baseline_type for a bad VITALS_BASELINE type", async () => { + const result = await computeDerivedMetric({ + metric: "VITALS_BASELINE", + userId: "u1", + profile: PROFILE, + type: "STEPS", + now: NOW, + }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("unsupported_baseline_type"); + } + }); +}); diff --git a/src/lib/insights/derived/__tests__/norms.test.ts b/src/lib/insights/derived/__tests__/norms.test.ts new file mode 100644 index 000000000..ed08b0ea1 --- /dev/null +++ b/src/lib/insights/derived/__tests__/norms.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { lookupNormalRange, hasSharpenedNorm } from "../norms"; + +describe("lookupNormalRange — the age/sex reference-range enabler", () => { + it("returns a sex-specific VO2max band for a registered metric", () => { + const male = lookupNormalRange("VO2_MAX", 35, "MALE"); + const female = lookupNormalRange("VO2_MAX", 35, "FEMALE"); + expect(male).not.toBeNull(); + expect(female).not.toBeNull(); + // male bands sit higher than female at the same decade + expect(male!.low).toBeGreaterThan(female!.low); + expect(male!.high).toBeGreaterThan(female!.high); + }); + + it("narrows resting-HR by age (sex-agnostic)", () => { + const child = lookupNormalRange("RESTING_HEART_RATE", 4, null); + const adult = lookupNormalRange("RESTING_HEART_RATE", 40, null); + expect(child).not.toBeNull(); + expect(adult).not.toBeNull(); + // children run higher than adults + expect(child!.high).toBeGreaterThan(adult!.high); + }); + + it("returns null when age is absent (falls back to flat anchor)", () => { + expect(lookupNormalRange("VO2_MAX", null, "MALE")).toBeNull(); + expect(lookupNormalRange("VO2_MAX", undefined, "MALE")).toBeNull(); + }); + + it("returns null for a metric with no norm table", () => { + expect(lookupNormalRange("OXYGEN_SATURATION", 40, "MALE")).toBeNull(); + expect(lookupNormalRange("STEPS", 40, "FEMALE")).toBeNull(); + }); + + it("returns null for a sex-specific-only table when profile sex is absent", () => { + // VO2max is sex-specific only; without a sex we cannot pick one band. + expect(lookupNormalRange("VO2_MAX", 35, null)).toBeNull(); + }); + + it("prefers a sex-agnostic row when present and sex is absent", () => { + // resting-HR rows are sex-agnostic, so an absent sex still resolves. + expect(lookupNormalRange("RESTING_HEART_RATE", 40, null)).not.toBeNull(); + }); + + it("clamps to the oldest bracket for very high ages", () => { + const old = lookupNormalRange("VO2_MAX", 95, "FEMALE"); + expect(old).not.toBeNull(); + }); + + it("rejects negative / non-finite ages", () => { + expect(lookupNormalRange("RESTING_HEART_RATE", -5, null)).toBeNull(); + expect(lookupNormalRange("RESTING_HEART_RATE", Number.NaN, null)).toBeNull(); + }); +}); + +describe("hasSharpenedNorm", () => { + it("mirrors lookupNormalRange truthiness", () => { + expect(hasSharpenedNorm("VO2_MAX", 35, "MALE")).toBe(true); + expect(hasSharpenedNorm("VO2_MAX", 35, null)).toBe(false); + expect(hasSharpenedNorm("STEPS", 35, "MALE")).toBe(false); + }); +}); diff --git a/src/lib/insights/derived/baseline.ts b/src/lib/insights/derived/baseline.ts new file mode 100644 index 000000000..585b7388b --- /dev/null +++ b/src/lib/insights/derived/baseline.ts @@ -0,0 +1,308 @@ +/** + * v1.10.0 — the FLAGSHIP shared baseline engine (catalogue metric #1: + * personal typical-range / vitals baseline). + * + * `computeVitalsBaseline(userId, profile, opts)` is the one real metric + * Wave 1 ships end-to-end. It returns the band of values that is "normal + * for you" for a single vital, computed as a rolling personal baseline: + * + * - **center** = median of the per-day means over the window. The DAY + * means come from the rollup tier (`readBestGranularityRollups` at + * DAY granularity — `mean` composes linearly across DAY buckets, the + * `measurement-read-wmy.ts:29-39` contract) with a per-type bounded + * live-SQL fallback on a coverage miss. + * - **spread** = median ± k·MAD (median absolute deviation, k≈3), an + * outlier-robust ≈3σ-equivalent band. MAD is computed from the same + * DAY-native per-day series — NEVER from a recomposed WEEK/MONTH `sd`. + * This is the hard invariant: `sd`/`slope`/`r2` do not compose, so + * the spread is derived at native (DAY) granularity only. + * + * Method/standard: robust-statistics anomaly detection — Median Absolute + * Deviation (Hampel 1974, JASA 69(346):383–393; Leys et al. 2013, J. Exp. + * Soc. Psychol. 49(4):764–766: "do not use standard deviation around the + * mean, use the median absolute deviation around the median"). Framing: + * Apple Health "Vitals" typical-range — establish the band after ≥7 days, + * then today's reading is "in range" or "outside". + * + * Server-only — reads the rollup tier + raw rows via Prisma. The pure + * statistics helpers below are exported so the unit test can assert the + * composed-bucket baseline matches the raw-DAY baseline within tolerance. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + probeRollupCoverage, + type RollupCoverageMap, +} from "@/lib/rollups/measurement-coverage"; +import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy"; +import { + buildInsufficient, + buildOk, + deriveCoverage, + nowProvenanceTimestamp, +} from "./coverage"; +import type { Derived, DerivedProvenanceSource } from "./types"; + +/** k for the median ± k·MAD band — ≈3σ-equivalent for normal data. */ +const DEFAULT_MAD_K = 3; +/** 1.4826 makes MAD a consistent estimator of σ under normality. */ +const MAD_SIGMA_SCALE = 1.4826; +/** Default trailing window (days). Apple establishes the band after ≥7. */ +const DEFAULT_WINDOW_DAYS = 30; + +/** The successful `value` payload for a vitals baseline. */ +export interface VitalsBaselineValue { + /** The vital this band describes. */ + type: MeasurementType; + /** Robust center (median of the per-day means). */ + center: number; + /** Band lower edge (center − k·MAD·scale). */ + low: number; + /** Band upper edge (center + k·MAD·scale). */ + high: number; + /** The MAD-derived σ-equivalent spread (k applied; same units as the metric). */ + spread: number; + /** Distinct days that contributed to the baseline. */ + sampleDays: number; + /** k used for the band (echoed for transparency). */ + k: number; +} + +/** Caller-supplied profile (read once per request, never re-fetched here). */ +export interface BaselineProfile { + ageYears: number | null; + sex: "MALE" | "FEMALE" | null; +} + +export interface VitalsBaselineOpts { + /** Which vital to baseline. */ + type: MeasurementType; + /** Trailing window in days. Defaults to 30. */ + windowDays?: number; + /** k for the MAD band. Defaults to 3. */ + k?: number; + /** Compute time (injected for deterministic tests). */ + now?: Date; + /** + * Pre-probed coverage map (one probe per request, shared across + * metrics — the pool-contention mitigation). When omitted the engine + * probes itself. + */ + coverage?: RollupCoverageMap; +} + +/** A per-day mean point used to build the baseline. */ +export interface DayMeanPoint { + day: string; + mean: number; +} + +// ── pure statistics (exported for the parity test) ─────────────────── + +/** Median of a numeric array (does not mutate the input). */ +export function median(values: number[]): number { + if (values.length === 0) return NaN; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; +} + +/** Median absolute deviation about the median (raw, unscaled). */ +export function medianAbsoluteDeviation(values: number[]): number { + if (values.length === 0) return NaN; + const center = median(values); + const deviations = values.map((v) => Math.abs(v - center)); + return median(deviations); +} + +/** + * Build the median ± k·MAD band from a per-day mean series. Pure — the + * caller supplies the already-resolved DAY-native series so this can be + * unit-tested against both a raw-DAY series and a rollup-composed series + * (they must agree: DAY rollup `mean` equals the per-day raw mean). + */ +export function buildBaselineBand( + dayMeans: number[], + k: number = DEFAULT_MAD_K, +): Omit | null { + if (dayMeans.length === 0) return null; + const center = median(dayMeans); + const mad = medianAbsoluteDeviation(dayMeans); + // Scale MAD to a σ-equivalent so the band reads like a robust ±kσ. + const spread = k * mad * MAD_SIGMA_SCALE; + return { + center, + low: center - spread, + high: center + spread, + spread, + sampleDays: dayMeans.length, + k, + }; +} + +// ── reads ──────────────────────────────────────────────────────────── + +/** + * Resolve the per-day mean series for `(userId, type)` over the window. + * Rollup tier first (DAY-native, `mean` composes); per-type bounded + * live-SQL fallback on a coverage miss. Returns the series plus the + * provenance source the read resolved against. + */ +async function readDayMeanSeries( + userId: string, + type: MeasurementType, + windowDays: number, + now: Date, + coverage: RollupCoverageMap, +): Promise<{ points: DayMeanPoint[]; source: DerivedProvenanceSource }> { + const hasBuckets = coverage.get(type) === true; + + if (hasBuckets) { + // DAY granularity only — the spread invariant forbids composing a + // band from WEEK/MONTH `sd`, and the center reads the DAY `mean` + // which composes exactly. `readBestGranularityRollups` with a + // window < 91 days resolves to DAY by construction. + const resolved = await readBestGranularityRollups(userId, type, windowDays); + if (resolved && resolved.granularity === "DAY" && resolved.rows.length > 0) { + const points = resolved.rows.map((row) => ({ + day: row.bucketStart.toISOString().slice(0, 10), + mean: row.mean, + })); + return { points, source: "DAY" }; + } + // Coverage probe said "has buckets" but the window resolved to a + // coarser tier or zero DAY rows → fall through to the live read so + // the spread is still DAY-native. + } + + // Per-type live fallback — bounded raw read, grouped into per-day + // means. Honest provenance: source = "live". + const since = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000); + const rows = await prisma.measurement.findMany({ + where: { + userId, + type, + deletedAt: null, + measuredAt: { gte: since }, + }, + orderBy: { measuredAt: "asc" }, + select: { value: true, measuredAt: true }, + }); + if (rows.length === 0) { + return { points: [], source: "none" }; + } + const byDay = new Map(); + for (const row of rows) { + const day = row.measuredAt.toISOString().slice(0, 10); + const acc = byDay.get(day) ?? { sum: 0, count: 0 }; + acc.sum += row.value; + acc.count += 1; + byDay.set(day, acc); + } + const points: DayMeanPoint[] = [...byDay.entries()] + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([day, acc]) => ({ day, mean: acc.sum / acc.count })); + return { points, source: "live" }; +} + +/** + * FLAGSHIP — the one real Wave 1 metric. Pure `(userId, profile, opts) => + * Promise>`: rolling personal baseline + * (median ± k·MAD) for a single vital, reading the rollup tier with a + * per-type live fallback on a coverage miss. Below the min-history floor + * it returns `insufficient` with coverage + provenance (never a + * fabricated band). + */ +export async function computeVitalsBaseline( + userId: string, + _profile: BaselineProfile, + opts: VitalsBaselineOpts, +): Promise> { + const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS; + const k = opts.k ?? DEFAULT_MAD_K; + const now = opts.now ?? new Date(); + const type = opts.type; + const minHistoryDays = 7; + + const coverage = opts.coverage ?? (await probeRollupCoverage(userId)); + const { points, source } = await readDayMeanSeries( + userId, + type, + windowDays, + now, + coverage, + ); + + const historyDays = points.length; + const computedAt = nowProvenanceTimestamp(now); + + // No data at all — insufficient, source "none". + if (historyDays === 0) { + const { coverage: cov } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: [String(type)], + fullHistoryDays: windowDays, + }); + return buildInsufficient({ + coverage: cov, + provenance: { inputs: [String(type)], source: "none", windowDays, computedAt }, + reason: "no_readings_in_window", + }); + } + + // Below the band floor — value exists but not enough history for a + // robust band. Insufficient, but honest coverage + provenance so the + // card shows "building your typical range — N of 7 days". + if (historyDays < minHistoryDays) { + const { coverage: cov } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays, + missing: [], + fullHistoryDays: windowDays, + }); + return buildInsufficient({ + coverage: cov, + provenance: { inputs: [String(type)], source, windowDays, computedAt }, + reason: "insufficient_history_for_band", + }); + } + + const band = buildBaselineBand( + points.map((p) => p.mean), + k, + ); + if (!band) { + const { coverage: cov } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays, + missing: [], + fullHistoryDays: windowDays, + }); + return buildInsufficient({ + coverage: cov, + provenance: { inputs: [String(type)], source, windowDays, computedAt }, + reason: "band_computation_failed", + }); + } + + const { coverage: cov, confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays, + missing: [], + fullHistoryDays: windowDays, + }); + + return buildOk({ + value: { type, ...band }, + coverage: cov, + confidence, + provenance: { inputs: [String(type)], source, windowDays, computedAt }, + }); +} diff --git a/src/lib/insights/derived/coverage.ts b/src/lib/insights/derived/coverage.ts new file mode 100644 index 000000000..648f5b9e3 --- /dev/null +++ b/src/lib/insights/derived/coverage.ts @@ -0,0 +1,144 @@ +/** + * v1.10.0 — the coverage / confidence model + the `Derived` builders. + * + * `deriveCoverage` turns the raw "what does this metric want vs what is + * present, and how much history backs it" facts into a `DerivedCoverage` + * + a `DerivedConfidence` band. `buildOk` / `buildInsufficient` are the + * two terminal constructors every pure compute function returns — they + * attach coverage + confidence + provenance at compute time, inside the + * pure function, so no surface ever has to bolt them on per-screen. + * + * Confidence model (carried from the metric catalogue, §2.1): + * - history scales the band width — < `minHistoryDays` nights → no band + * (the caller returns `insufficient`); 7d → low, 14d → medium, + * 30d+ → high. + * - for composites, the present/required input ratio drops the score + * monotonically as `missing` grows. + * Confidence is a blend of the history fraction and the input-presence + * fraction so that a fully-covered short-history metric and a + * long-history partially-covered composite both land honestly. + * + * Client-safe — pure functions, no server imports. + */ +import type { + Derived, + DerivedConfidence, + DerivedConfidenceBand, + DerivedCoverage, + DerivedProvenance, +} from "./types"; + +/** Clamp helper. */ +function clamp(value: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, value)); +} + +/** + * Map a 0..100 score onto the confidence band the shared meter renders. + * `draft` is reserved for a value computed below its minimum-history + * floor but still surfaced (value-only, no band) — callers that gate + * hard return `insufficient` instead and never reach this. + */ +export function scoreToBand(score: number): DerivedConfidenceBand { + if (score >= 75) return "high"; + if (score >= 50) return "medium"; + if (score >= 25) return "low"; + return "draft"; +} + +export interface DeriveCoverageArgs { + /** Inputs the metric wants (its full input set; ≥ 1). */ + requiredInputs: number; + /** Inputs actually present. */ + presentInputs: number; + /** Days of history backing the value. */ + historyDays: number; + /** Named inputs still missing. */ + missing: string[]; + /** + * History-day count at which the band reaches full width / full + * history confidence. Defaults to 30 (the typical-range engine's + * 30-day window); composites pass their own. + */ + fullHistoryDays?: number; +} + +/** + * Build the `DerivedCoverage` + `DerivedConfidence` pair from the raw + * coverage facts. Confidence = the product of the history fraction + * (capped at `fullHistoryDays`) and the input-presence fraction, scaled + * to 0..100 and floored so a present-but-sparse value never reports 0. + */ +export function deriveCoverage(args: DeriveCoverageArgs): { + coverage: DerivedCoverage; + confidence: DerivedConfidence; +} { + const requiredInputs = Math.max(1, args.requiredInputs); + const presentInputs = clamp(args.presentInputs, 0, requiredInputs); + const historyDays = Math.max(0, args.historyDays); + const fullHistoryDays = Math.max(1, args.fullHistoryDays ?? 30); + + const historyFraction = clamp(historyDays / fullHistoryDays, 0, 1); + const inputFraction = presentInputs / requiredInputs; + + // Geometric-ish blend: both axes must be healthy for a high score, but + // a fully-covered short-history metric still earns a non-trivial score + // and a long-history partially-covered composite is penalised by its + // missing inputs. Floor at 1 so a real (non-insufficient) value never + // reads as 0 confidence. + const rawScore = historyFraction * inputFraction * 100; + const score = presentInputs > 0 && historyDays > 0 + ? Math.round(clamp(rawScore, 1, 100)) + : 0; + + return { + coverage: { + requiredInputs, + presentInputs, + historyDays, + missing: [...args.missing], + }, + confidence: { score, band: scoreToBand(score) }, + }; +} + +/** + * Terminal constructor — a successful derived value. Attaches coverage + + * confidence + provenance computed by the caller (via `deriveCoverage`). + */ +export function buildOk(args: { + value: T; + coverage: DerivedCoverage; + confidence: DerivedConfidence; + provenance: DerivedProvenance; +}): Derived { + return { + status: "ok", + value: args.value, + coverage: args.coverage, + confidence: args.confidence, + provenance: args.provenance, + }; +} + +/** + * Terminal constructor — a gated derived value. Carries coverage + + * provenance so the surface renders the shared "track N more days" state. + */ +export function buildInsufficient(args: { + coverage: DerivedCoverage; + provenance: DerivedProvenance; + reason: string; +}): Derived { + return { + status: "insufficient", + coverage: args.coverage, + provenance: args.provenance, + reason: args.reason, + }; +} + +/** ISO 8601 timestamp with offset — the provenance `computedAt` source. */ +export function nowProvenanceTimestamp(now: Date = new Date()): string { + return now.toISOString(); +} diff --git a/src/lib/insights/derived/dispatch.ts b/src/lib/insights/derived/dispatch.ts new file mode 100644 index 000000000..ab88f8ad0 --- /dev/null +++ b/src/lib/insights/derived/dispatch.ts @@ -0,0 +1,107 @@ +/** + * v1.10.0 — the route-side dispatcher: `DerivedMetricId` → its pure + * compute function → a wire-flat `Derived`. + * + * The generic `/api/insights/derived` route validates `metric` against + * the closed registry enum, then calls `computeDerivedMetric`. Wave 1 + * implements `VITALS_BASELINE` end-to-end; every other registered id is + * a metadata stub whose compute lands in W2/W3. A stub routed before its + * compute lands returns `insufficient` with `reason: "not_implemented"` + * — never a fabricated value, never a 500. + * + * Server-only — imports the server-only compute engines. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { buildInsufficient, nowProvenanceTimestamp } from "./coverage"; +import { + getDerivedMetricMeta, + isVitalsBaselineType, + type DerivedMetricId, +} from "./registry"; +import { + computeVitalsBaseline, + type BaselineProfile, +} from "./baseline"; +import type { Derived } from "./types"; + +export interface DerivedComputeArgs { + metric: DerivedMetricId; + userId: string; + profile: BaselineProfile; + /** + * Optional sub-target for metrics that baseline a single chosen type + * (`VITALS_BASELINE`). Validated against the engine's supported set. + */ + type?: string | null; + /** Trailing window override (days). */ + windowDays?: number; + now?: Date; +} + +/** A `not_implemented` insufficient — the stub guard for W2/W3 metrics. */ +function notImplemented( + inputs: string[], + now: Date, +): Derived { + const computedAt = nowProvenanceTimestamp(now); + return buildInsufficient({ + coverage: { requiredInputs: 1, presentInputs: 0, historyDays: 0, missing: inputs }, + provenance: { inputs, source: "none", windowDays: 0, computedAt }, + reason: "not_implemented", + }); +} + +/** + * Dispatch a derived-metric id to its compute function. Returns the flat + * `Derived` the route wraps in the standard envelope. The caller + * has already Zod-validated `metric` against the registry enum, so an + * unknown id never reaches here. + */ +export async function computeDerivedMetric( + args: DerivedComputeArgs, +): Promise> { + const now = args.now ?? new Date(); + const meta = getDerivedMetricMeta(args.metric); + if (!meta) { + // Defensive — the route validates against the enum first. + return notImplemented([], now); + } + + if (!meta.implemented) { + return notImplemented(meta.inputs.map(String), now); + } + + switch (args.metric) { + case "VITALS_BASELINE": { + // The baseline engine needs a single chosen vital; default to + // RESTING_HEART_RATE when the caller omits one (the catalogue's + // canonical example). + const requested = args.type ?? "RESTING_HEART_RATE"; + if (!isVitalsBaselineType(requested)) { + return buildInsufficient({ + coverage: { + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: [requested], + }, + provenance: { + inputs: [requested], + source: "none", + windowDays: 0, + computedAt: nowProvenanceTimestamp(now), + }, + reason: "unsupported_baseline_type", + }); + } + return computeVitalsBaseline(args.userId, args.profile, { + type: requested as MeasurementType, + windowDays: args.windowDays, + now, + }) as Promise>; + } + default: + // Registered + implemented but no dispatch arm — treat as a stub. + return notImplemented(meta.inputs.map(String), now); + } +} diff --git a/src/lib/insights/derived/index.ts b/src/lib/insights/derived/index.ts new file mode 100644 index 000000000..1ecd03325 --- /dev/null +++ b/src/lib/insights/derived/index.ts @@ -0,0 +1,69 @@ +/** + * v1.10.0 — public barrel for the derived-metrics layer. + * + * The one module every consumer imports from. Append-only per wave — + * Wave 1 exports the foundation (`Derived` + coverage builders + + * registry + norms + the flagship baseline engine + the route + * dispatcher); W2/W3 append their per-metric engines (`fitness-age`, + * `vascular-age`, `sleep-score`, `readiness`) here. + * + * Re-exports the client-safe types/coverage builders and the + * server-only compute engines from one place; consumers import only what + * they need. (A `"use client"` component must value-import only from + * `./types` + `./coverage` + `./registry` — those are server-import-free. + * The route + server consumers may import the engines below.) + */ + +// ── client-safe contract (types + pure builders + metadata) ────────── +export type { + Derived, + DerivedOk, + DerivedInsufficient, + DerivedCoverage, + DerivedConfidence, + DerivedConfidenceBand, + DerivedProvenance, + DerivedProvenanceSource, +} from "./types"; +export { isDerivedOk } from "./types"; + +export { + deriveCoverage, + buildOk, + buildInsufficient, + scoreToBand, + nowProvenanceTimestamp, +} from "./coverage"; +export type { DeriveCoverageArgs } from "./coverage"; + +export { + DERIVED_METRIC_IDS, + VITALS_BASELINE_TYPES, + isDerivedMetricId, + getDerivedMetricMeta, + isVitalsBaselineType, +} from "./registry"; +export type { + DerivedMetricId, + DerivedMetricMeta, + DerivedArchetype, +} from "./registry"; + +export { lookupNormalRange, hasSharpenedNorm } from "./norms"; +export type { NormRange, NormSex } from "./norms"; + +// ── server-only compute engines (do NOT value-import from a client component) ── +export { + computeVitalsBaseline, + buildBaselineBand, + median, + medianAbsoluteDeviation, +} from "./baseline"; +export type { + VitalsBaselineValue, + VitalsBaselineOpts, + BaselineProfile, +} from "./baseline"; + +export { computeDerivedMetric } from "./dispatch"; +export type { DerivedComputeArgs } from "./dispatch"; diff --git a/src/lib/insights/derived/norms.ts b/src/lib/insights/derived/norms.ts new file mode 100644 index 000000000..358023f7b --- /dev/null +++ b/src/lib/insights/derived/norms.ts @@ -0,0 +1,155 @@ +/** + * v1.10.0 — age/sex reference-range tables (the F1 enabler). + * + * The generic assessment registry (`metric-status-registry.ts`) already + * threads a coarse, demographic-flat `normalRange` per metric and already + * passes `age` + `sex` into the prompt. This module SHARPENS that slot: + * `lookupNormalRange(metricId, ageYears, sex)` returns an age/sex-adjusted + * band when a cited standard supports one, falling back to `null` so the + * caller keeps the existing flat anchor (additive, behind the existing + * behaviour — never a regression). + * + * Strictly age + sex. NO ancestry / ethnicity / region-of-birth ranges — + * that is medically contentious and a data category HealthLog does not + * collect (the registry already states this stance). + * + * Cited standards (one per table): + * - VO₂max percentile norms — Cooper Institute / FRIEND registry: + * Kaminsky et al. 2015, "Reference Standards for Cardiorespiratory + * Fitness Measured With Cardiopulmonary Exercise Testing", Mayo Clinic + * Proceedings 90(11):1515–1523. (The 50th-percentile age×sex band.) + * - Resting heart rate by age — standard physiology / population + * reference (adult resting-HR narrows with the AHA 60–100 bpm anchor; + * children/adolescents run higher — Fleming et al. 2011, Lancet + * 377(9770):1011–1018, normal-range HR centiles by age). + * - Respiratory rate by age — clinical reference (WHO/PALS adult vs + * paediatric ranges). + * + * Client-safe — pure data + a pure lookup, no server imports. + */ +import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; + +/** Profile sex as stored on `User.gender` ("MALE" | "FEMALE" | null). */ +export type NormSex = "MALE" | "FEMALE" | null | undefined; + +/** A reference band — same shape as `MetricNormalRange`. */ +export interface NormRange { + low: number; + high: number; +} + +/** One age-bracketed row, optionally sex-specific. */ +interface NormRow { + /** Inclusive lower age bound (years). */ + minAge: number; + /** Inclusive upper age bound (years). */ + maxAge: number; + /** Sex this row applies to; `null` = applies to either. */ + sex: "MALE" | "FEMALE" | null; + range: NormRange; +} + +/** + * VO₂max 50th-percentile bands (mL/(kg·min)) by decade × sex — derived + * from the FRIEND registry (Kaminsky et al. 2015). The band brackets the + * "Good" placement for the age×sex cell; the assessment leans on the + * user's own trend, this only sharpens the population anchor. + */ +const VO2_MAX_NORMS: NormRow[] = [ + { minAge: 20, maxAge: 29, sex: "MALE", range: { low: 42, high: 53 } }, + { minAge: 30, maxAge: 39, sex: "MALE", range: { low: 39, high: 49 } }, + { minAge: 40, maxAge: 49, sex: "MALE", range: { low: 35, high: 45 } }, + { minAge: 50, maxAge: 59, sex: "MALE", range: { low: 31, high: 41 } }, + { minAge: 60, maxAge: 120, sex: "MALE", range: { low: 27, high: 37 } }, + { minAge: 20, maxAge: 29, sex: "FEMALE", range: { low: 36, high: 46 } }, + { minAge: 30, maxAge: 39, sex: "FEMALE", range: { low: 33, high: 43 } }, + { minAge: 40, maxAge: 49, sex: "FEMALE", range: { low: 30, high: 39 } }, + { minAge: 50, maxAge: 59, sex: "FEMALE", range: { low: 26, high: 35 } }, + { minAge: 60, maxAge: 120, sex: "FEMALE", range: { low: 22, high: 31 } }, +]; + +/** + * Resting-HR normal bands by age (bpm). Sex-agnostic — the adult band is + * the AHA 60–100 anchor; younger ages run higher (Fleming et al. 2011 + * normal-range centiles). Direction is `lower-better` within the band. + */ +const RESTING_HEART_RATE_NORMS: NormRow[] = [ + { minAge: 0, maxAge: 1, sex: null, range: { low: 100, high: 160 } }, + { minAge: 2, maxAge: 5, sex: null, range: { low: 80, high: 120 } }, + { minAge: 6, maxAge: 12, sex: null, range: { low: 70, high: 110 } }, + { minAge: 13, maxAge: 17, sex: null, range: { low: 60, high: 100 } }, + { minAge: 18, maxAge: 120, sex: null, range: { low: 50, high: 90 } }, +]; + +/** + * Respiratory-rate normal bands by age (breaths/min). Sex-agnostic — + * clinical reference (WHO/PALS): adult 12–20, paediatric ranges run + * higher. + */ +const RESPIRATORY_RATE_NORMS: NormRow[] = [ + { minAge: 0, maxAge: 1, sex: null, range: { low: 30, high: 60 } }, + { minAge: 2, maxAge: 5, sex: null, range: { low: 20, high: 30 } }, + { minAge: 6, maxAge: 12, sex: null, range: { low: 18, high: 25 } }, + { minAge: 13, maxAge: 17, sex: null, range: { low: 12, high: 20 } }, + { minAge: 18, maxAge: 120, sex: null, range: { low: 12, high: 18 } }, +]; + +/** + * The norm tables, keyed by the assessment-registry metric id. A metric + * absent from this map has no age/sex sharpening — the caller keeps the + * flat registry anchor. Append a table here to lift another metric. + */ +const NORM_TABLES: Partial> = { + VO2_MAX: VO2_MAX_NORMS, + RESTING_HEART_RATE: RESTING_HEART_RATE_NORMS, + RESPIRATORY_RATE: RESPIRATORY_RATE_NORMS, +}; + +/** + * Resolve the age/sex-adjusted reference band for a metric, or `null` + * when no sharper band applies (unsupported metric, or demographics + * absent). On `null` the caller keeps the existing flat `normalRange` + * anchor — the enabler is strictly additive. + * + * Sex matching: a sex-specific table prefers the row matching the + * profile sex; when the profile sex is absent it falls back to the + * row's `null`-sex variant if present, else the first matching-age row. + */ +export function lookupNormalRange( + metricId: MetricStatusMetricId, + ageYears: number | null | undefined, + sex: NormSex, +): NormRange | null { + if (ageYears == null || !Number.isFinite(ageYears) || ageYears < 0) { + return null; + } + const table = NORM_TABLES[metricId]; + if (!table) return null; + + const ageRows = table.filter( + (row) => ageYears >= row.minAge && ageYears <= row.maxAge, + ); + if (ageRows.length === 0) return null; + + // Prefer an exact sex match; then a sex-agnostic row; then any row in + // the age bracket (so a sex-specific-only table still yields a band for + // a profile with no sex by averaging is avoided — we pick a defined + // band rather than fabricate). + const exact = sex ? ageRows.find((row) => row.sex === sex) : undefined; + if (exact) return { ...exact.range }; + + const agnostic = ageRows.find((row) => row.sex === null); + if (agnostic) return { ...agnostic.range }; + + // Sex-specific-only table but no profile sex: no honest single band. + return null; +} + +/** `true` when an age/sex-sharpened band exists for the metric+profile. */ +export function hasSharpenedNorm( + metricId: MetricStatusMetricId, + ageYears: number | null | undefined, + sex: NormSex, +): boolean { + return lookupNormalRange(metricId, ageYears, sex) !== null; +} diff --git a/src/lib/insights/derived/registry.ts b/src/lib/insights/derived/registry.ts new file mode 100644 index 000000000..632131306 --- /dev/null +++ b/src/lib/insights/derived/registry.ts @@ -0,0 +1,178 @@ +/** + * v1.10.0 — the closed `DERIVED_METRIC_ID` enum + per-metric metadata. + * + * One single source the generic `/api/insights/derived` route, the + * OpenAPI registration, and the QA inventory all share. An id added here + * is accepted by the route automatically; an unknown id 422s against the + * closed enum (the v1.8.7.1 `metric-status` pattern). + * + * Wave 1 implements ONE metric end-to-end as the reference: the personal + * typical-range vitals baseline (`VITALS_BASELINE`). The other catalogue + * metrics are registered here as metadata STUBS — their `archetype` + * documents the shape later waves plug in, but their compute functions + * land in W2/W3 (`fitness-age.ts`, `vascular-age.ts`, `sleep-score.ts`, + * `readiness.ts`, the coincident-deviation flag). A stubbed metric routed + * before its compute lands returns `insufficient` via the dispatcher's + * `not-implemented` guard, never a fabricated value. + * + * Metadata only — no compute, no server imports — so this stays + * client-safe and the route enum / OpenAPI can import it freely. + */ +import type { MeasurementType } from "@/generated/prisma/client"; + +/** + * The closed set of derived-metric ids. The single vocabulary the route + * query param, the OpenAPI enum, and the cache scope all speak. + */ +export type DerivedMetricId = + /** FLAGSHIP (W1): personal typical-range band for one vital. */ + | "VITALS_BASELINE" + /** W2: VO₂max → age/sex band + chronological-age delta (passthrough re-frame). */ + | "FITNESS_AGE" + /** W2: VASCULAR_AGE/PWV → age-delta band (passthrough re-frame). */ + | "VASCULAR_AGE_DELTA" + /** W3: transparent sleep composite. */ + | "SLEEP_SCORE" + /** W3: wellness/readiness index (extends the health-score blend). */ + | "READINESS" + /** W3: multi-signal coincident-deviation early-strain flag. */ + | "COINCIDENT_DEVIATION"; + +/** Archetype of a derived metric — drives shaping + the QA inventory. */ +export type DerivedArchetype = + /** Renders whenever its single input series exists; no min-input threshold beyond its own history. */ + | "any-user-baseline" + /** A device-computed value re-framed against a norm band — never recomputed. */ + | "passthrough-reframe" + /** Needs a minimum-inputs threshold, reweights around missing inputs, shows coverage/confidence. */ + | "composite"; + +export interface DerivedMetricMeta { + id: DerivedMetricId; + /** Stable English display name (surfaces localise their own prose). */ + displayName: string; + archetype: DerivedArchetype; + /** + * The named inputs the metric reads. For `VITALS_BASELINE` these are + * `MeasurementType` names (the caller selects one via the route's + * `type` opt); for a composite the set may also name non-measurement + * signals (e.g. "MOOD", a derived sub-score) the coverage model counts + * against — hence `string`, not `MeasurementType`. + */ + inputs: string[]; + /** + * Minimum distinct history-days an input needs before a band/headline + * is produced. Below the floor the metric returns `insufficient`. + */ + minHistoryDays: number; + /** + * For a composite: the minimum number of `inputs` that must be present + * before a headline is produced (no 1-of-N headline). `1` for + * any-user / passthrough metrics. + */ + minInputs: number; + /** `true` once a compute function backs this id; `false` = W2/W3 stub. */ + implemented: boolean; +} + +/** + * The vitals the typical-range baseline engine supports. The route's + * `type` opt must name one of these; any other type 422s. Mirrors the + * metric-1 input set from the catalogue (§1, row 1). + */ +export const VITALS_BASELINE_TYPES: MeasurementType[] = [ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "RESPIRATORY_RATE", + "OXYGEN_SATURATION", + "BODY_TEMPERATURE", + "SKIN_TEMPERATURE", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "BLOOD_GLUCOSE", + "PULSE", + "WEIGHT", +]; + +const REGISTRY: Record = { + VITALS_BASELINE: { + id: "VITALS_BASELINE", + displayName: "Personal typical range", + archetype: "any-user-baseline", + inputs: VITALS_BASELINE_TYPES, + minHistoryDays: 7, + minInputs: 1, + implemented: true, + }, + FITNESS_AGE: { + id: "FITNESS_AGE", + displayName: "Cardio-fitness band", + archetype: "passthrough-reframe", + inputs: ["VO2_MAX"], + minHistoryDays: 1, + minInputs: 1, + implemented: false, + }, + VASCULAR_AGE_DELTA: { + id: "VASCULAR_AGE_DELTA", + displayName: "Vascular-age delta", + archetype: "passthrough-reframe", + inputs: ["VASCULAR_AGE", "PULSE_WAVE_VELOCITY"], + minHistoryDays: 1, + minInputs: 1, + implemented: false, + }, + SLEEP_SCORE: { + id: "SLEEP_SCORE", + displayName: "Sleep score", + archetype: "composite", + inputs: ["SLEEP_DURATION"], + minHistoryDays: 1, + minInputs: 1, + implemented: false, + }, + READINESS: { + id: "READINESS", + displayName: "Wellness / readiness index", + archetype: "composite", + inputs: [ + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "SLEEP_DURATION", + "RESPIRATORY_RATE", + "MOOD", + ], + minHistoryDays: 7, + minInputs: 2, + implemented: false, + }, + COINCIDENT_DEVIATION: { + id: "COINCIDENT_DEVIATION", + displayName: "Coincident-deviation flag", + archetype: "composite", + inputs: VITALS_BASELINE_TYPES, + minHistoryDays: 7, + minInputs: 2, + implemented: false, + }, +}; + +/** Closed set of ids the generic route accepts (Zod enum source). */ +export const DERIVED_METRIC_IDS = Object.keys(REGISTRY) as DerivedMetricId[]; + +/** Type guard narrowing an arbitrary string to a registered derived id. */ +export function isDerivedMetricId(value: string): value is DerivedMetricId { + return Object.prototype.hasOwnProperty.call(REGISTRY, value); +} + +/** Resolve a derived metric's metadata, or `null` when unregistered. */ +export function getDerivedMetricMeta( + metric: string, +): DerivedMetricMeta | null { + return isDerivedMetricId(metric) ? REGISTRY[metric] : null; +} + +/** `true` when the type is a vital the baseline engine supports. */ +export function isVitalsBaselineType(type: string): boolean { + return (VITALS_BASELINE_TYPES as string[]).includes(type); +} diff --git a/src/lib/insights/derived/types.ts b/src/lib/insights/derived/types.ts new file mode 100644 index 000000000..905fffe83 --- /dev/null +++ b/src/lib/insights/derived/types.ts @@ -0,0 +1,98 @@ +/** + * v1.10.0 — the `Derived` discriminated union + its companion value + * shapes (coverage / confidence / provenance). + * + * This is the one consumption contract for the derived-metrics layer: + * every pure compute function returns `Derived` and every surface + * (dashboard tile, Insights card, Coach snapshot, daily briefing, + * doctor/FHIR report, the native iOS client) pattern-matches `status` + * rather than recomputing. The `ok` arm carries value + coverage + + * confidence + provenance; the `insufficient` arm still carries coverage + * + provenance so a surface renders the same gating UI ("track N more + * days") instead of a blank. + * + * Client-safe by construction — pure types + tiny pure builders, no + * server imports — so a `"use client"` component can value-import this + * module without dragging the route/lib server graph into the bundle + * (the v1.9.0 lesson). The compute functions live in server-only + * siblings (`baseline.ts`, …) and import these types only. + */ + +/** Confidence band rendered by the shared coverage meter. */ +export type DerivedConfidenceBand = "high" | "medium" | "low" | "draft"; + +/** + * The granularity the dominant read resolved against — mirrors the + * rollup/live-SQL provenance the graded-series + dashboard-snapshot reads + * already expose. `"live"` = a coverage-miss live-SQL fallback; `"none"` + * = no data backed the value. + */ +export type DerivedProvenanceSource = + | "DAY" + | "WEEK" + | "MONTH" + | "YEAR" + | "live" + | "none"; + +/** + * Where a derived value's inputs came from. Lets every surface show the + * same "from your last 30 days, DAY buckets" chip. + */ +export interface DerivedProvenance { + /** Named inputs that actually backed the value (e.g. ["RESTING_HEART_RATE"]). */ + inputs: string[]; + /** Granularity the dominant read resolved against. */ + source: DerivedProvenanceSource; + /** Trailing window the value summarises, in days (e.g. 30 for a 30-day baseline). */ + windowDays: number; + /** Compute time, for cache-staleness + the "as of" chip (ISO 8601, offset). */ + computedAt: string; +} + +export interface DerivedCoverage { + /** Inputs the metric WANTS (its full input set). */ + requiredInputs: number; + /** Inputs actually present. */ + presentInputs: number; + /** Days of history backing the value — the gating floor (< minHistoryDays → no band). */ + historyDays: number; + /** Named inputs still missing — drives the "track BP/HRV to sharpen this" nudge. */ + missing: string[]; +} + +export interface DerivedConfidence { + /** 0..100 — feeds the existing ConfidenceMeter component unchanged. */ + score: number; + band: DerivedConfidenceBand; +} + +/** + * The successful arm: every successful value carries all four facets. + */ +export interface DerivedOk { + status: "ok"; + value: T; + coverage: DerivedCoverage; + confidence: DerivedConfidence; + provenance: DerivedProvenance; +} + +/** + * The gated arm: no value, but coverage + provenance so the surface + * renders the same "track N more days" state rather than a blank. + */ +export interface DerivedInsufficient { + status: "insufficient"; + coverage: DerivedCoverage; + provenance: DerivedProvenance; + /** Why the value could not be produced (single short reason string). */ + reason: string; +} + +export type Derived = DerivedOk | DerivedInsufficient; + +/** Narrowing type guard — `true` when the value computed successfully. */ +export function isDerivedOk(d: Derived): d is DerivedOk { + return d.status === "ok"; +}