feat(insights): add the derived-metrics compute foundation

Introduce src/lib/insights/derived/ — the shared, compute-once layer for
derived wellness metrics. Core pieces:

- types.ts: the Derived<T> discriminated union (ok carries value +
  coverage + confidence + provenance; insufficient carries coverage +
  provenance + reason). Client-safe — pure types, no server imports.
- coverage.ts: deriveCoverage() + the buildOk/buildInsufficient terminal
  constructors. Confidence blends the history fraction and the
  input-presence fraction so it drops monotonically as inputs go missing.
- registry.ts: the closed DERIVED_METRIC_ID enum + per-metric metadata
  (inputs, archetype, min-history / min-inputs floors, implemented flag).
- norms.ts: age/sex reference-range tables (VO2max FRIEND registry,
  resting-HR + respiratory-rate by age) behind lookupNormalRange().
  Age + sex only — no ancestry/region ranges.
- baseline.ts: the flagship engine — computeVitalsBaseline() returns the
  personal typical-range band (median +/- k*MAD) for one vital. Center
  reads the composable DAY rollup mean; the spread is DAY-native only
  (the sd/slope/r2-don't-compose invariant), with a per-type bounded
  live-SQL fallback on a coverage miss.
- dispatch.ts: maps a DERIVED_METRIC_ID to its compute function; stubbed
  metrics return an insufficient with reason not_implemented.
- index.ts: the append-only public barrel.

Unit tests cover the coverage model, the Derived<T> ok/insufficient
branching, the norms lookup, the baseline ok / insufficient /
coverage-miss-fallback paths + the composed-vs-raw-DAY band parity, and
the dispatch stub guard.
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 20:01:34 +02:00
1 parent 744e8e37ec
commit 6dac56b659
11 files changed
+1592

No files matched your search

@@ -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);
}
});
});
@@ -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<T> 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");
});
});
@@ -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");
}
});
});
@@ -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);
});
});
+308
View File
@@ -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<VitalsBaselineValue, "type"> | 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<string, { sum: number; count: number }>();
for (const row of rows) {
const day = row.measuredAt.toISOString().slice(0, 10);
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
acc.sum += row.value;
acc.count += 1;
byDay.set(day, acc);
}
const points: 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<Derived<VitalsBaselineValue>>`: 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<Derived<VitalsBaselineValue>> {
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<VitalsBaselineValue>({
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<VitalsBaselineValue>({
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<VitalsBaselineValue>({
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<VitalsBaselineValue>({
value: { type, ...band },
coverage: cov,
confidence,
provenance: { inputs: [String(type)], source, windowDays, computedAt },
});
}
+144
View File
@@ -0,0 +1,144 @@
/**
* v1.10.0 — the coverage / confidence model + the `Derived<T>` 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<T>(args: {
value: T;
coverage: DerivedCoverage;
confidence: DerivedConfidence;
provenance: DerivedProvenance;
}): Derived<T> {
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<T>(args: {
coverage: DerivedCoverage;
provenance: DerivedProvenance;
reason: string;
}): Derived<T> {
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();
}
+107
View File
@@ -0,0 +1,107 @@
/**
* v1.10.0 — the route-side dispatcher: `DerivedMetricId` → its pure
* compute function → a wire-flat `Derived<unknown>`.
*
* 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<unknown> {
const computedAt = nowProvenanceTimestamp(now);
return buildInsufficient<unknown>({
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<unknown>` 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<Derived<unknown>> {
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<unknown>({
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<Derived<unknown>>;
}
default:
// Registered + implemented but no dispatch arm — treat as a stub.
return notImplemented(meta.inputs.map(String), now);
}
}
+69
View File
@@ -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<T>` + 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";
+155
View File
@@ -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<Record<MetricStatusMetricId, NormRow[]>> = {
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;
}
+178
View File
@@ -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<DerivedMetricId, DerivedMetricMeta> = {
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);
}
+98
View File
@@ -0,0 +1,98 @@
/**
* v1.10.0 — the `Derived<T>` 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<T>` 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<T> {
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<T> = DerivedOk<T> | DerivedInsufficient;
/** Narrowing type guard — `true` when the value computed successfully. */
export function isDerivedOk<T>(d: Derived<T>): d is DerivedOk<T> {
return d.status === "ok";
}