mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
Reference in new issue
Block a user