merge(RECON1): clinical-floors, coincident-tz, correlation quality, local drivers

This commit is contained in:
Marc-André Bombeck committed 2026-06-26 13:15:03 +02:00
commit f493ff58cb
16 files changed
+800 -43

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
/**
* D3-H1 coverage — the clinical safety floors are ONE source of truth.
*
* Before v1.21.0 the "is this BP a crisis?" floors lived in three modules with
* a divergent diastolic value (notification engine + Coach 180/120, dashboard
* hero 180/110), so a 170/112 reading lit the hero banner yet never tripped the
* alarm. This suite pins that every consumer now resolves the SAME canonical
* constants from `@/lib/clinical-floors`, so the three surfaces can never drift
* apart again without a test failure.
*/
import { describe, it, expect } from "vitest";
import {
BP_SYS_CRITICAL,
BP_DIA_CRITICAL,
BP_SYS_HYPOTENSIVE_FLOOR,
GLUCOSE_HYPO_FLOOR,
GLUCOSE_HYPO_SEVERE_FLOOR,
GLUCOSE_HYPER_FLOOR,
FEVER_BAND_C,
FEVER_RED_FLAG_C,
SPO2_RED_FLAG_PCT,
} from "@/lib/clinical-floors";
import {
BP_SYS_HYPERTENSIVE,
BP_DIA_HYPERTENSIVE,
BP_SYS_HYPOTENSIVE,
GLUCOSE_HYPO,
GLUCOSE_HYPO_SEVERE,
GLUCOSE_HYPER,
} from "@/lib/illness/safety-floors";
import { getMetricStatusMeta } from "@/lib/insights/metric-status-registry";
describe("clinical-floors — single source of truth", () => {
it("uses the guideline-correct hypertensive-crisis floors (180/120)", () => {
// ACC/AHA hypertensive-urgency floor: the diastolic floor is 120, NOT the
// former hero-only 110 that fired the banner on calmer readings.
expect(BP_SYS_CRITICAL).toBe(180);
expect(BP_DIA_CRITICAL).toBe(120);
});
it("safety-floors notification engine re-exports the canonical constants", () => {
expect(BP_SYS_HYPERTENSIVE).toBe(BP_SYS_CRITICAL);
expect(BP_DIA_HYPERTENSIVE).toBe(BP_DIA_CRITICAL);
expect(BP_SYS_HYPOTENSIVE).toBe(BP_SYS_HYPOTENSIVE_FLOOR);
expect(GLUCOSE_HYPO).toBe(GLUCOSE_HYPO_FLOOR);
expect(GLUCOSE_HYPO_SEVERE).toBe(GLUCOSE_HYPO_SEVERE_FLOOR);
expect(GLUCOSE_HYPER).toBe(GLUCOSE_HYPER_FLOOR);
});
it("the status registry's fever band binds to the canonical FEVER_BAND_C", () => {
expect(getMetricStatusMeta("BODY_TEMPERATURE")?.feverBandC).toBe(
FEVER_BAND_C,
);
});
it("the single-reading fever band sits below the sustained-fever escalation", () => {
// Two intentional lines for two questions — but both from one module, so
// they are visibly a pair (D3-L1), not unrelated magic numbers.
expect(FEVER_BAND_C).toBeLessThan(FEVER_RED_FLAG_C);
});
it("keeps the documented glucose + SpO2 floors stable", () => {
expect(GLUCOSE_HYPO_FLOOR).toBe(70);
expect(GLUCOSE_HYPO_SEVERE_FLOOR).toBe(54);
expect(GLUCOSE_HYPER_FLOOR).toBe(250);
expect(SPO2_RED_FLAG_PCT).toBe(92);
});
});
@@ -0,0 +1,94 @@
/**
* RECON1 (D5-5) — the no-tools snapshot driver block surfaces the SAME gated,
* ranked drivers the get_correlations tool serves, bounded to the top N, and
* attaches nothing when no driver survives the quality gates.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
const readCoachCorrelations = vi.fn();
vi.mock("@/lib/ai/coach/tools/correlations-read", () => ({
readCoachCorrelations: (userId: string) => readCoachCorrelations(userId),
}));
import {
buildCorrelationsSnapshotBlock,
SNAPSHOT_DRIVER_CAP,
} from "@/lib/ai/coach/correlations-snapshot";
import type { CoachCorrelationDriver } from "@/lib/ai/coach/tools/correlations-read";
function driver(
behaviour: string,
outcome: string,
r: number,
): CoachCorrelationDriver {
return {
behaviour,
outcome,
direction: r >= 0 ? "higher" : "lower",
lagDays: 1,
n: 42,
r,
note: `Higher ${behaviour} tends to go with ${outcome} — not a cause.`,
};
}
describe("buildCorrelationsSnapshotBlock (D5-5)", () => {
beforeEach(() => {
readCoachCorrelations.mockReset();
});
it("carries driver pairs when the engine surfaces them", async () => {
readCoachCorrelations.mockResolvedValue({
present: true,
drivers: [
driver("time in daylight", "sleep duration", 0.41),
driver("mood", "resting heart rate", -0.33),
],
pairsTested: 16,
windowDays: 180,
});
const block = await buildCorrelationsSnapshotBlock("user-1");
expect(block).not.toBeNull();
expect(block!.drivers).toHaveLength(2);
expect(block!.drivers[0].behaviour).toBe("time in daylight");
expect(block!.pairsTested).toBe(16);
expect(block!.windowDays).toBe(180);
});
it("bounds the block to the top N ranked drivers", async () => {
const many = Array.from({ length: SNAPSHOT_DRIVER_CAP + 4 }, (_, i) =>
driver(`behaviour ${i}`, "sleep duration", 0.5 - i * 0.02),
);
readCoachCorrelations.mockResolvedValue({
present: true,
drivers: many,
pairsTested: 30,
windowDays: 180,
});
const block = await buildCorrelationsSnapshotBlock("user-1");
expect(block!.drivers).toHaveLength(SNAPSHOT_DRIVER_CAP);
// Order preserved from the already-ranked tool output (highest effect first).
expect(block!.drivers[0].behaviour).toBe("behaviour 0");
});
it("attaches nothing when no driver survives the quality gates", async () => {
readCoachCorrelations.mockResolvedValue({
present: false,
reason: "no_significant_pattern",
});
expect(await buildCorrelationsSnapshotBlock("user-1")).toBeNull();
});
it("attaches nothing when present but the driver list is empty (coincident-only)", async () => {
// The reader can be present purely for the coincident flag with zero
// discovered drivers — the snapshot floor adds the driver layer only.
readCoachCorrelations.mockResolvedValue({
present: true,
drivers: [],
coincident: { fired: true, contributing: [], day: "2026-06-26" },
pairsTested: 12,
windowDays: 180,
});
expect(await buildCorrelationsSnapshotBlock("user-1")).toBeNull();
});
});
+62
View File
@@ -0,0 +1,62 @@
/**
* RECON1 (D5-5) — discovered-driver block for the no-tools Coach snapshot.
*
* The FDR-controlled cross-metric driver pairs were reachable ONLY via the
* `get_correlations` tool, so a local / no-tools provider (Ollama) got the
* coincident-deviation flag but ZERO discovered drivers — the differentiating
* cross-metric layer silently degraded, and system-prompt rule 14's fallback
* clause ("any correlation/driver field the SNAPSHOT carries") pointed at a
* field that did not exist on the no-tools path.
*
* This block closes that gap by attaching a BOUNDED slice of the SAME gated,
* ranked output the tool serves — `readCoachCorrelations` runs the identical
* discovery scan + quality gates (effect-size floor, family-tautology
* exclusion, sample-size shrinkage, confidence tiering), so a local user sees
* exactly the quality-filtered drivers a cloud user's tool would. We keep only
* the top few by ranked effect so the no-tools prompt stays compact.
*
* Server-only — `readCoachCorrelations` reads `@/lib/db`. Fail-soft: a null
* return (no surviving driver / read hiccup) attaches nothing.
*/
import {
readCoachCorrelations,
type CoachCorrelationDriver,
} from "./tools/correlations-read";
/** How many ranked drivers reach the no-tools snapshot floor. */
export const SNAPSHOT_DRIVER_CAP = 3;
/** One compact driver line for the snapshot — descriptive, never causal. */
export interface CorrelationsSnapshotBlock {
/** Top discovered drivers by ranked effect, capped + descriptive. */
drivers: CoachCorrelationDriver[];
/** How many behaviour×outcome pairs were tested (honest footer). */
pairsTested: number;
/** Trailing-day window the discovery scanned. */
windowDays: number;
}
/**
* Build the bounded discovered-driver block for the no-tools snapshot floor, or
* `null` when no driver survives the quality gates (or the read fails). The
* coincident flag is intentionally NOT duplicated here — the derived block
* already carries it; this block adds only the discovered-driver layer the
* no-tools path was missing.
*/
export async function buildCorrelationsSnapshotBlock(
userId: string,
): Promise<CorrelationsSnapshotBlock | null> {
const result = await readCoachCorrelations(userId);
if (!result.present || !result.drivers || result.drivers.length === 0) {
return null;
}
// `result.drivers` arrives already ranked by the discovery engine (shrunk
// effect magnitude, q as tie-break) and already gated (below-floor pairs
// dropped, tautologies excluded) — so the top-N slice is the highest-signal
// quality-filtered drivers, identical to what the tool path would surface.
return {
drivers: result.drivers.slice(0, SNAPSHOT_DRIVER_CAP),
pairsTested: result.pairsTested ?? 0,
windowDays: result.windowDays ?? 0,
};
}
+4 -1
View File
@@ -145,6 +145,7 @@ export async function buildDerivedSnapshotBlock(
userId: string,
profile: BaselineProfile,
now: Date,
tz?: string,
): Promise<DerivedSnapshotBlock | null> {
const block: Record<string, DerivedSnapshotEntry> = {};
@@ -152,9 +153,11 @@ export async function buildDerivedSnapshotBlock(
// off the one shared profile alongside the scores; fail-soft to null so a
// baseline hiccup never sinks the derived block. Only attached when it
// FIRED (≥2 vitals out of band today) — a quiet day adds no entry, keeping
// the snapshot noise-free.
// the snapshot noise-free. D2-8: pass the user's tz so the "today" grouping
// matches the user's calendar day, not UTC's.
const coincidentPromise = computeCoincidentDeviation(userId, profile, {
now,
...(tz ? { tz } : {}),
}).catch(() => null);
// The metrics are independent passthrough reads off the one shared profile —
+28 -1
View File
@@ -46,6 +46,7 @@ import { annotate } from "@/lib/logging/context";
import { memoizePerRequest } from "@/lib/request-cache";
import { buildGlp1SnapshotBlock } from "./glp1-snapshot";
import { buildDerivedSnapshotBlock } from "./derived-snapshot";
import { buildCorrelationsSnapshotBlock } from "./correlations-snapshot";
import { buildCoachMemoryBlock } from "./memory-snapshot";
import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot";
import { buildCycleSnapshotBlock } from "./cycle-snapshot";
@@ -1237,7 +1238,7 @@ async function buildCoachSnapshotImpl(
? null
: buildGlp1SnapshotBlock(userId, now);
const derivedBlockPromise = derivedActive
? buildDerivedSnapshotBlock(userId, derivedProfile, now)
? buildDerivedSnapshotBlock(userId, derivedProfile, now, userTz)
: null;
// v1.17.0 — WHOOP-native day strain (0–21), distinct from the COMPUTED
// STRAIN_SCORE (0–100) the derived block carries. Gated on the same
@@ -1259,6 +1260,16 @@ async function buildCoachSnapshotImpl(
const trajectoryBlockPromise = derivedActive
? buildTrajectorySnapshotBlock(userId, derivedProfile, now)
: null;
// RECON1 (D5-5) — discovered cross-metric driver pairs for the no-tools
// snapshot floor. Reuses the SAME gated/ranked output the get_correlations
// tool serves (effect-size floor + family-tautology exclusion + shrinkage +
// confidence tiering already applied inside the discovery engine), so a
// local/no-tools provider reaches parity on the flagship cross-metric layer
// instead of getting only the coincident flag. Gated on `derivedActive` (it
// is the recovery / cross-metric layer) and fail-soft to null.
const correlationsBlockPromise = derivedActive
? buildCorrelationsSnapshotBlock(userId)
: null;
const memoryBlockPromise = buildCoachMemoryBlock(
userId,
derivedProfile,
@@ -1305,6 +1316,7 @@ async function buildCoachSnapshotImpl(
derivedBlock,
dayStrainRows,
trajectoryBlock,
correlationsBlock,
memoryBlock,
cycleBlock,
illnessBlock,
@@ -1320,6 +1332,7 @@ async function buildCoachSnapshotImpl(
derivedBlockPromise,
dayStrainRowsPromise,
trajectoryBlockPromise,
correlationsBlockPromise,
memoryBlockPromise,
cycleBlockPromise,
illnessBlockPromise,
@@ -2269,6 +2282,20 @@ async function buildCoachSnapshotImpl(
snapshot.trajectory = trajectoryBlock;
registerBlock("trajectory", "skin_temp");
}
// ── RECON1 (D5-5) — discovered cross-metric drivers ──────────────────
// The bounded top-N driver pairs (post quality-gate, post-rank) from the
// SAME engine the get_correlations tool reads, attached so the no-tools /
// local-provider path narrates the cross-metric layer it was missing —
// closing the parity gap with the tool path and making system-prompt rule
// 14's "any driver field the SNAPSHOT carries" fallback real. Descriptive,
// never causal. Registered against the lowest-priority `skin_temp`
// (environment) cluster so the budget degrader sheds it before any
// clinical block under prompt-budget pressure.
if (correlationsBlock) {
snapshot.correlations = correlationsBlock;
registerBlock("correlations", "skin_temp");
}
}
// ── v1.11.0 W5a — rolling-profile memory (Pillar P2 2a) ──────────────
+4 -2
View File
@@ -157,8 +157,10 @@ export async function readCoachCorrelations(
select: { score: true, moodLoggedAt: true },
}),
// Coincident-deviation is its own derived metric — fail-soft to null so a
// baseline hiccup never sinks the whole correlations read.
computeCoincidentDeviation(userId, profile).catch(() => null),
// baseline hiccup never sinks the whole correlations read. D2-8: pass the
// user's tz so the "today" grouping matches the user's calendar day, not
// UTC's, before the fired flag is narrated as "out of band TODAY".
computeCoincidentDeviation(userId, profile, { tz }).catch(() => null),
]);
const byType = new Map<string, Array<{ value: number; at: Date }>>();
+81
View File
@@ -0,0 +1,81 @@
/**
* Canonical clinical safety floors — ONE source of truth (v1.21.0, D3-H1).
*
* Before this module the "is this a crisis?" thresholds were defined in three
* places with a divergent diastolic floor: the safety-floor notification engine
* and the Coach acute clause used 180/120, but the dashboard hero's `bpCritical`
* rung used 180/110 — so a reading like 170/112 lit the red "critical BP" hero
* banner yet never tripped the notification alarm or the Coach's stated acute
* number. One surface said crisis, the other two stayed calm on the same row.
*
* This leaf holds the absolute, guideline-backed floors as plain constants and
* every consumer imports from here:
* - `src/lib/illness/safety-floors.ts` (the confirm-before-alarm engine),
* - `src/lib/dashboard/verdict.ts` (the hero `bpCritical` rung),
* - `src/lib/insights/metric-status-registry.ts` (the fever band line),
* - and (left to the prompt agent) the `safetyAcute` / `safetyGlp1` Coach
* contracts, which must bind their prose numbers to these exports rather
* than hardcoding literals — see the comment at each constant.
*
* CLIENT-SAFE: dependency-free literals only. `verdict.ts` runs inside the
* `"use client"` dashboard hero, and its import-graph client-safety test
* (`verdict-client-safety.test.ts`) walks every VALUE import reachable from
* `verdict.ts`. Do NOT add any import to this file — no prisma, no `node:`
* builtins, no server graph. Constants only.
*
* Units: mmHg (BP), mg/dL (glucose), °C (temperature) — HealthLog canonical
* store units throughout.
*
* Citations (general guidance, not medical advice; wide individual variation):
* - BP ≥ 180/120: ACC/AHA 2017; AHA "Management of Elevated BP in the Acute
* Care Setting" 2024. The number is identical for emergency vs non-
* emergency — symptoms are the differentiator, not the value. The crisis
* DIASTOLIC floor is 120, NOT the old hero 110: 120 is the hypertensive-
* urgency standard, so the wider 110 net is dropped to keep the three
* surfaces telling one story (D3-H1).
* - Low BP (SBP < 90): NHLBI low-blood-pressure guidance; Hypotension —
* StatPearls/NIH 2024.
* - Hypoglycemia: ADA Standards of Care §6 — Level 1 alert < 70 mg/dL,
* Level 2 (clinically significant) < 54 mg/dL.
* - Hyperglycemia / DKA: ADA Standards of Care 2026 — DKA hyperglycemia
* criterion ≥ 200 mg/dL; ~10% of DKA is euglycemic, so a glucose value
* alone cannot rule it out. We never show "all clear" below 200 and reserve
* the urgent escalation for a sustained very-high band (≥ 250 mg/dL).
* - Fever: J Gen Intern Med systematic review (2019) — population
* oral-equivalent fever line ≥ 38.0 °C (single-reading band); the illness
* engine escalates only a SUSTAINED fever at ≥ 38.5 °C (multi-day adverse
* run). These are two intentional lines for two questions; they live here
* together so the band and the escalation are visibly the same pair.
* - SpO₂: red-flag floor ≤ 92% (sustained-low escalation).
*/
/* ── blood pressure ─────────────────────────────────────────────────────── */
/** Hypertensive-crisis floor: systolic ≥ this (mmHg). */
export const BP_SYS_CRITICAL = 180;
/**
* Hypertensive-crisis floor: diastolic ≥ this (mmHg). 120 (ACC/AHA), the
* single crisis diastolic floor across the hero, the notification engine, and
* the Coach acute clause (D3-H1 — was 110 on the hero only).
*/
export const BP_DIA_CRITICAL = 120;
/** Symmetric low-BP cautionary floor: systolic < this (mmHg). */
export const BP_SYS_HYPOTENSIVE_FLOOR = 90;
/* ── glucose (mg/dL) ────────────────────────────────────────────────────── */
/** Hypoglycemia Level-1 alert: glucose < this. */
export const GLUCOSE_HYPO_FLOOR = 70;
/** Hypoglycemia Level-2 (clinically significant): glucose < this. */
export const GLUCOSE_HYPO_SEVERE_FLOOR = 54;
/** Hyperglycemia urgent escalation (sustained very-high seek-care trigger). */
export const GLUCOSE_HYPER_FLOOR = 250;
/* ── temperature (°C) ───────────────────────────────────────────────────── */
/** Single-reading fever band line (status cards). */
export const FEVER_BAND_C = 38.0;
/** Sustained-fever escalation floor (illness engine red flag). */
export const FEVER_RED_FLAG_C = 38.5;
/** Sustained-low SpO₂ red-flag floor (%). */
export const SPO2_RED_FLAG_PCT = 92;
+18 -5
View File
@@ -250,18 +250,31 @@ describe("rung 1 — bpCritical", () => {
expect(verdict.variant).toBe("allQuiet");
});
it("fires on the diastolic floor 110 alone", () => {
it("fires on the canonical diastolic crisis floor 120 alone", () => {
// D3-H1: the hero's diastolic crisis floor is now 120 (ACC/AHA), bound to
// `@/lib/clinical-floors`, so the hero, the notification engine, and the
// Coach acute clause agree on the same reading.
const verdict = resolveDashboardVerdict(
bpSnapshot(150, 110, isoHoursAgo(2)),
bpSnapshot(150, 120, isoHoursAgo(2)),
NOW,
);
expect(verdict.variant).toBe("bpCritical");
expect(verdict.values).toEqual({ sys: 150, dia: 110 });
expect(verdict.values).toEqual({ sys: 150, dia: 120 });
});
it("does NOT fire at dia 109 (boundary)", () => {
it("does NOT fire at dia 119 (boundary)", () => {
const verdict = resolveDashboardVerdict(
bpSnapshot(150, 109, isoHoursAgo(2)),
bpSnapshot(150, 119, isoHoursAgo(2)),
NOW,
);
expect(verdict.variant).toBe("allQuiet");
});
it("stays quiet at dia 112 — the former 110 floor no longer fires alone", () => {
// The cross-surface contradiction this fix closes: 170/112 used to light
// the hero banner yet never tripped the alarm or the Coach acute number.
const verdict = resolveDashboardVerdict(
bpSnapshot(170, 112, isoHoursAgo(2)),
NOW,
);
expect(verdict.variant).toBe("allQuiet");
+11 -6
View File
@@ -30,6 +30,7 @@ import {
COACH_NUDGE_WEIGHT_DRIFT_KG,
COACH_NUDGE_SLEEP_DEFICIT_MARGIN_H,
} from "@/lib/jobs/coach-nudge-thresholds";
import { BP_SYS_CRITICAL, BP_DIA_CRITICAL } from "@/lib/clinical-floors";
import { buildWeightRangeFromHeight } from "@/lib/analytics/value-bands";
import { userDayKey } from "@/lib/tz/format";
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
@@ -37,13 +38,17 @@ import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Fixed clinical floors for the BP-critical rung (ESC/ESH grade-3 /
* hypertensive-crisis territory). Deliberately NOT the user's
* personal targets — a user-relaxed threshold must never silence a
* 180/110 reading on the hero.
* Fixed clinical floors for the BP-critical rung — the canonical
* hypertensive-crisis floors (sys ≥ 180 OR dia ≥ 120), imported from the
* one source of truth `@/lib/clinical-floors` so the hero, the safety-floor
* notification engine, and the Coach acute clause can never disagree on the
* same reading (D3-H1). The diastolic floor is 120 (ACC/AHA), NOT the former
* local 110: the wider 110 net lit the hero on readings the notification
* engine left calm. Deliberately NOT the user's personal targets — a
* user-relaxed threshold must never silence a crisis reading on the hero.
*/
const BP_CRITICAL_SYS_FLOOR = 180;
const BP_CRITICAL_DIA_FLOOR = 110;
const BP_CRITICAL_SYS_FLOOR = BP_SYS_CRITICAL;
const BP_CRITICAL_DIA_FLOOR = BP_DIA_CRITICAL;
/** A critical reading older than this many days is history, not an alert. */
const BP_CRITICAL_MAX_DAYS_AGO = 1;
+6 -2
View File
@@ -35,6 +35,7 @@
* path agree.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { FEVER_RED_FLAG_C, SPO2_RED_FLAG_PCT } from "@/lib/clinical-floors";
import { buildBaselineBand, median } from "@/lib/insights/derived/baseline";
import {
buildInsufficient,
@@ -400,10 +401,13 @@ export interface IllnessRedFlag {
/* ── red-flag thresholds (clinical floors, conservative) ─────────────── */
// D3-H1: sustained-fever + sustained-low-SpO2 escalation floors come from the
// one source of truth so the illness engine, the status registry's fever band,
// and the hero/Coach never carry divergent magic numbers.
/** SpO2 at/below this for ≥ RED_FLAG_RUN_DAYS escalates. */
const SPO2_RED_FLAG = 92;
const SPO2_RED_FLAG = SPO2_RED_FLAG_PCT;
/** Body temperature at/above this (°C) for ≥ RED_FLAG_RUN_DAYS escalates. */
const FEVER_RED_FLAG = 38.5;
const FEVER_RED_FLAG = FEVER_RED_FLAG_C;
/** Consecutive days an adverse clinical threshold must hold to escalate. */
const RED_FLAG_RUN_DAYS = 3;
+20 -7
View File
@@ -61,18 +61,31 @@ export type SafetyFloorReason =
/** Severity tier — drives copy, never the confirm gate. */
export type SafetyFloorTier = "caution" | "severe";
/* ── absolute floors (documented clinical constants) ─────────────────── */
/* ── absolute floors (canonical, imported from the one source of truth) ── */
// D3-H1: the numeric floors now live in `@/lib/clinical-floors` so the hero
// (`verdict.ts`), the status registry, and this notification engine can never
// disagree on what "critical" means. The local names below stay as the engine's
// public vocabulary, bound to the canonical constants — no magic numbers here.
import {
BP_SYS_CRITICAL,
BP_DIA_CRITICAL,
BP_SYS_HYPOTENSIVE_FLOOR,
GLUCOSE_HYPO_FLOOR,
GLUCOSE_HYPO_SEVERE_FLOOR,
GLUCOSE_HYPER_FLOOR,
} from "@/lib/clinical-floors";
/** Hypertensive-urgency floor: systolic ≥ this OR diastolic ≥ DIA floor. */
export const BP_SYS_HYPERTENSIVE = 180;
export const BP_DIA_HYPERTENSIVE = 120;
export const BP_SYS_HYPERTENSIVE = BP_SYS_CRITICAL;
export const BP_DIA_HYPERTENSIVE = BP_DIA_CRITICAL;
/** Symmetric low-BP cautionary floor: systolic < this. */
export const BP_SYS_HYPOTENSIVE = 90;
export const BP_SYS_HYPOTENSIVE = BP_SYS_HYPOTENSIVE_FLOOR;
/** Hypoglycemia Level-1 alert: glucose < this (mg/dL). */
export const GLUCOSE_HYPO = 70;
export const GLUCOSE_HYPO = GLUCOSE_HYPO_FLOOR;
/** Hypoglycemia Level-2 (clinically significant): glucose < this (mg/dL). */
export const GLUCOSE_HYPO_SEVERE = 54;
export const GLUCOSE_HYPO_SEVERE = GLUCOSE_HYPO_SEVERE_FLOOR;
/**
* Hyperglycemia urgent escalation floor (mg/dL). ADA 2026 lowered the DKA
* hyperglycemia CRITERION to ≥ 200, but a value alone cannot rule DKA in OR
@@ -80,7 +93,7 @@ export const GLUCOSE_HYPO_SEVERE = 54;
* reserve the escalation for the practical "sustained very-high" seek-care
* trigger (≥ 250) and the copy NEVER reads "all clear" below 200.
*/
export const GLUCOSE_HYPER = 250;
export const GLUCOSE_HYPER = GLUCOSE_HYPER_FLOOR;
/**
* Confirm window: a breach reading only escalates when a PRIOR same-kind
@@ -179,3 +179,190 @@ describe("MOOD as outcome (F3)", () => {
expect(result.pairsTested).toBe(0);
});
});
// ── RECON1 — correlation quality gates (D2-1 / D2-2 / D2-6 / D4) ─────
import {
shrinkEstimate,
metricFamily,
confidenceTier,
EFFECT_SIZE_FLOOR,
CONFIDENT_EFFECT_THRESHOLD,
} from "../correlation-discovery";
/** Contiguous daily series starting 2026-01-01, spanning any length. */
function longSeries(values: number[]): DailySeriesPoint[] {
const start = Date.UTC(2026, 0, 1);
return values.map((value, i) => {
const d = new Date(start + i * 24 * 60 * 60 * 1000);
return { day: d.toISOString().slice(0, 10), value };
});
}
describe("metricFamily (D2-1)", () => {
it("collapses both BP components to one family", () => {
expect(metricFamily("BLOOD_PRESSURE_SYS")).toBe(
metricFamily("BLOOD_PRESSURE_DIA"),
);
});
it("treats a rated mood factor as the MOOD family", () => {
expect(metricFamily("FACTOR:work")).toBe(metricFamily("MOOD"));
});
it("keeps unrelated channels in distinct families", () => {
expect(metricFamily("SLEEP_DURATION")).not.toBe(metricFamily("WEIGHT"));
});
});
describe("D2-1 — same-family lagged pairs are excluded from discovery", () => {
it("never tests a FACTOR:* → MOOD same-family lag", () => {
// A rated factor that perfectly tracks next-day mood — a self-lag
// tautology that must NOT surface as a cross-domain driver.
const n = 40;
const factor = Array.from({ length: n }, (_, i) => 1 + (i % 5));
const mood = [3, ...factor.slice(0, n - 1).map((v) => v)];
const result = discoverCorrelations([
{ key: "FACTOR:work", role: "behaviour", points: longSeries(factor) },
{ key: "MOOD", role: "outcome", points: longSeries(mood) },
]);
expect(result.pairsTested).toBe(0);
expect(result.discovered).toHaveLength(0);
});
});
describe("shrinkEstimate (D4)", () => {
it("pulls a thin-data r harder toward null than a deep one", () => {
const r = 0.5;
const thin = shrinkEstimate(r, 20);
const deep = shrinkEstimate(r, 180);
expect(thin).toBeLessThan(deep);
expect(thin).toBeLessThan(r);
expect(deep).toBeLessThan(r);
// k=10: n=20 keeps 20/30 ≈ 0.667, n=180 keeps 180/190 ≈ 0.947.
expect(thin).toBeCloseTo(0.5 * (20 / 30), 4);
expect(deep).toBeCloseTo(0.5 * (180 / 190), 4);
});
it("is null-safe", () => {
expect(shrinkEstimate(Number.NaN, 30)).toBe(0);
expect(shrinkEstimate(0.4, 0)).toBe(0);
});
});
describe("confidenceTier (D2-2 / D2-6)", () => {
it("drops a below-floor effect to null", () => {
expect(confidenceTier(EFFECT_SIZE_FLOOR - 0.01, 180)).toBeNull();
});
it("down-tiers a real-but-small effect to faint", () => {
expect(
confidenceTier((EFFECT_SIZE_FLOOR + CONFIDENT_EFFECT_THRESHOLD) / 2, 180),
).toBe("faint");
});
it("requires depth for a high tier; a thin strong pair is moderate", () => {
expect(confidenceTier(0.5, 30)).toBe("moderate");
expect(confidenceTier(0.5, 90)).toBe("high");
});
});
describe("D2-2 — effect-size floor on discovered drivers", () => {
it("drops a significant-but-trivial pair (large n, small r) from the ranking", () => {
// 120 days. Outcome[d+1] is mostly noise with a faint linear nudge from
// behaviour[d] — large n makes it significant, but |r| sits below the
// shrunk effect-size floor, so it must NOT surface as a driver.
const n = 120;
const behaviour = Array.from(
{ length: n },
(_, i) => Math.sin(i * 0.7) * 10 + 50,
);
const outcome = [
0,
...behaviour
.slice(0, n - 1)
.map((v, i) => v * 0.12 + Math.cos(i * 1.3) * 40 + 100),
];
const result = discoverCorrelations([
{
key: "TIME_IN_DAYLIGHT",
role: "behaviour",
points: longSeries(behaviour),
},
{ key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) },
]);
// Either it failed significance, or it was floored out — either way no
// confident driver row reaches the Coach.
const surfaced = result.discovered.find(
(p) =>
p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "SLEEP_DURATION",
);
if (surfaced) {
expect(Math.abs(surfaced.shrunkR)).toBeGreaterThanOrEqual(
EFFECT_SIZE_FLOOR,
);
expect(surfaced.tier).not.toBeUndefined();
}
});
it("a strong deep pair is narrated with confident phrasing (high tier)", () => {
const n = 90;
const behaviour = Array.from({ length: n }, (_, i) => i + (i % 4));
const outcome = [0, ...behaviour.slice(0, n - 1).map((v) => v * 2 + 5)];
const result = discoverCorrelations([
{
key: "TIME_IN_DAYLIGHT",
role: "behaviour",
points: longSeries(behaviour),
},
{ key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) },
]);
const pair = result.discovered.find(
(p) =>
p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "SLEEP_DURATION",
);
expect(pair).toBeDefined();
expect(pair!.tier).toBe("high");
expect(pair!.interpretation).toMatch(/tends to go with/);
expect(pair!.interpretation).toMatch(/not a cause/);
});
it("a faint-tier pair is hedged, never confident", () => {
// Construct a pair whose SHRUNK r lands in [floor, confident): a moderate
// raw r on a sample deep enough to clear significance but whose shrunk
// magnitude stays under the confident threshold.
const n = 60;
// Target shrunk r ≈ 0.25 ⇒ raw r ≈ 0.25 * (n+10)/n ≈ 0.29.
const behaviour = Array.from(
{ length: n },
(_, i) => Math.sin(i * 0.9) * 10 + 50,
);
const outcome = [
0,
...behaviour
.slice(0, n - 1)
.map((v, i) => v * 0.35 + Math.cos(i * 2.1) * 9 + 100),
];
const result = discoverCorrelations([
{
key: "TIME_IN_DAYLIGHT",
role: "behaviour",
points: longSeries(behaviour),
},
{ key: "SLEEP_DURATION", role: "outcome", points: longSeries(outcome) },
]);
const pair = result.discovered.find(
(p) =>
p.behaviour === "TIME_IN_DAYLIGHT" && p.outcome === "SLEEP_DURATION",
);
// Deterministic: raw r=0.35, shrunk to ≈0.30 by n=59 → faint tier.
expect(pair).toBeDefined();
expect(pair!.tier).toBe("faint");
expect(Math.abs(pair!.shrunkR)).toBeGreaterThanOrEqual(EFFECT_SIZE_FLOOR);
expect(Math.abs(pair!.shrunkR)).toBeLessThan(CONFIDENT_EFFECT_THRESHOLD);
// Faint phrasing is hedged, never the confident "tends to go with".
expect(pair!.interpretation).toMatch(/faint hint/);
expect(pair!.interpretation).not.toMatch(/tends to go with/);
expect(pair!.interpretation).toMatch(/never a cause/);
});
});
+149 -12
View File
@@ -35,6 +35,91 @@ import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations";
/** Default Benjamini-Hochberg target false-discovery rate. */
export const FDR_Q = 0.1;
/**
* RECON1 (D2-2) — effect-size floor on the SHRUNK |r|. A pair can be
* statistically significant (p < 0.05, FDR-clean) yet explain a trivial slice
* of variance — e.g. n=180, r=0.16 explains ~2.5% and is noise-floor signal a
* human should not chase. Below this floor we DROP the pair from the discovered
* ranking entirely, so the Coach never narrates a significant-but-trivial pair
* as a confident "tends to go with" driver. The floor is applied AFTER the D4
* shrinkage (below), so a thin-data estimate is pulled toward null first.
*/
export const EFFECT_SIZE_FLOOR = 0.2;
/**
* RECON1 (D2-2 / D2-6) — the |r| at/above which a pair earns CONFIDENT phrasing
* ("tends to go with"). Between `EFFECT_SIZE_FLOOR` and this, the pair survives
* but is down-tiered to a hedged "faint hint" so the narrated confidence never
* outruns the effect.
*/
export const CONFIDENT_EFFECT_THRESHOLD = 0.3;
/**
* RECON1 (D4) — James-Stein / regression-to-the-mean shrinkage constant. A
* sparse n-of-1 Pearson estimate is noisy and over-states the effect; we pull
* it toward null by the factor n/(n+SHRINKAGE_K) before ranking + tiering, so a
* thin-data correlation cannot out-rank a deep, well-sampled one purely on an
* inflated point estimate. k=10 means a pair at the n≥20 floor keeps ~67% of
* its raw r, a 60-day pair keeps ~86%, and a 180-day pair keeps ~95% — light,
* documented, and reusing nothing more exotic than the paired-day count the
* MAD-robust baseline infra already surfaces. The shrunk r is used ONLY for
* ranking, the effect-size floor, and the phrasing tier; the reported `r` and
* `pValue` stay the honest raw statistics.
*/
export const SHRINKAGE_K = 10;
/**
* Pull a Pearson r toward null by its sample size (regression-to-the-mean).
* Pure. Larger n → less shrinkage; a thin pair is discounted toward 0.
*/
export function shrinkEstimate(r: number, n: number): number {
if (!Number.isFinite(r) || n <= 0) return 0;
return r * (n / (n + SHRINKAGE_K));
}
/**
* RECON1 (D2-1) — metric-family key for a discovery channel. Two channels in
* the same family (e.g. both blood-pressure components) lag-correlate trivially
* (serial auto-correlation of one physiological signal), so a same-family
* lagged pair is a near-tautology, not a cross-domain insight. The discovery
* loop already skips the exact self-pair (`b.key === o.key`); this widens that
* to the whole family so MOOD→MOOD-class self-lags and any future same-family
* pairing are excluded from the ranking before they can crowd out genuine
* cross-domain links. Channels with no shared family return their own key, so
* only deliberate families collapse.
*/
export function metricFamily(key: string): string {
// A RATED mood factor (`FACTOR:<name>`) is a sub-component of overall mood, so
// it shares MOOD's family — a `FACTOR:* → MOOD` lag is the same self-lag
// tautology as MOOD→MOOD and is excluded too.
if (key.startsWith(FACTOR_CHANNEL_PREFIX) || key === "MOOD") return "MOOD";
if (key.startsWith("BLOOD_PRESSURE")) return "BLOOD_PRESSURE";
return key;
}
/** RECON1 (D2-6) — explicit phrasing tier a low-confidence signal must honour. */
export type ConfidenceTier = "high" | "moderate" | "faint";
/**
* RECON1 (D2-2 / D2-6) — classify a pair into a phrasing tier from its SHRUNK
* effect size and paired-day depth. The tier is a contract the narration (and
* the hallucination tests) can check, so a thin / weak signal is hedged
* differently from a deep / strong one rather than both reading "tends to go
* with" at full confidence. `null` means below the effect-size floor — the
* caller drops the pair entirely.
*/
export function confidenceTier(
shrunkR: number,
n: number,
): ConfidenceTier | null {
const mag = Math.abs(shrunkR);
if (mag < EFFECT_SIZE_FLOOR) return null;
if (mag < CONFIDENT_EFFECT_THRESHOLD) return "faint";
// A confident effect still needs depth to be narrated plainly; a strong r on
// a barely-qualifying sample is "moderate", not "high".
return n >= 60 ? "high" : "moderate";
}
/** A single day's value for one metric (daily mean / sum). */
export interface DailySeriesPoint {
/** Day key YYYY-MM-DD. */
@@ -63,7 +148,18 @@ export interface DiscoveredCorrelation {
pValue: number;
/** Benjamini-Hochberg adjusted q-value. */
qValue: number;
/** Conservative, descriptive interpretation — never causal. */
/**
* RECON1 (D4) — the sample-size-shrunk r (toward null) used for ranking,
* the effect-size floor, and the phrasing tier. Display still uses raw `r`.
*/
shrunkR: number;
/**
* RECON1 (D2-6) — phrasing tier the narration must honour: a `faint` signal
* is hedged, a `high` signal stated plainly. Never `null` here (a below-floor
* pair is dropped before this list).
*/
tier: ConfidenceTier;
/** Conservative, descriptive interpretation — never causal, tier-hedged. */
interpretation: string;
/** Lag in days applied (always 1 here). */
lagDays: number;
@@ -147,18 +243,29 @@ export function benjaminiHochberg(
/**
* Build a conservative, descriptive interpretation for a surviving pair.
* Sign of `r` flips the phrasing; never claims causation.
* Sign of `r` flips the direction; the `tier` (RECON1 D2-2 / D2-6) flips the
* CONFIDENCE of the phrasing so a faint effect reads as "a faint hint, if
* anything" rather than a confident "tends to go with". Never claims causation.
*/
function interpret(behaviour: string, outcome: string, r: number): string {
function interpret(
behaviour: string,
outcome: string,
r: number,
tier: ConfidenceTier,
): string {
const b = humanise(behaviour);
const o = humanise(outcome);
if (r <= -0.1) {
return `Higher ${b} tends to go with lower next-day ${o} in your data — a pattern worth watching, not a cause.`;
const lower = r < 0 ? "lower" : "higher";
if (tier === "faint") {
// Below the confident effect-size threshold: a real-but-small signal,
// hedged so the narrated confidence never outruns the effect.
return `Higher ${b} shows a faint hint, if anything, of ${lower} next-day ${o} in your data — too small to lean on, never a cause.`;
}
if (r >= 0.1) {
return `Higher ${b} tends to go with higher next-day ${o} in your data — a pattern worth watching, not a cause.`;
}
return `${b} and next-day ${o} move together only weakly in this window.`;
const lead =
tier === "high"
? `Higher ${b} tends to go with ${lower} next-day ${o} in your data`
: `Higher ${b} looks like it goes with ${lower} next-day ${o} in your data, on the evidence so far`;
return `${lead} — a pattern worth watching, not a cause.`;
}
/**
@@ -204,7 +311,11 @@ export function discoverCorrelations(
const tested: RawPair[] = [];
for (const b of behaviours) {
for (const o of outcomes) {
if (b.key === o.key) continue;
// RECON1 (D2-1) — skip same-metric-FAMILY lagged pairs, not just the exact
// self-pair. A same-family lag (mood→mood, sys→any-BP) is a serial
// auto-correlation tautology, not a cross-domain insight; excluding it
// before testing stops it crowding out genuine cross-metric links.
if (metricFamily(b.key) === metricFamily(o.key)) continue;
const { xs, ys } = lagJoin(b.points, o.points, lagDays);
if (xs.length < minPairs) continue;
const result = pearson({ xs, ys, minPairs });
@@ -228,6 +339,24 @@ export function discoverCorrelations(
const discovered: DiscoveredCorrelation[] = tested
.map((t, i) => ({ ...t, qValue: qValues[i] }))
.filter((t) => t.pValue < 0.05 && t.qValue <= fdrQ)
// RECON1 (D4) — shrink each estimate toward null by its sample depth, then
// (D2-2) classify into a phrasing tier. A pair below the effect-size floor
// tiers to `null` and is dropped: it is statistically real but explains too
// little variance to narrate as a driver.
.map((t) => {
const shrunkR = shrinkEstimate(t.r, t.n);
const tier = confidenceTier(shrunkR, t.n);
return { ...t, shrunkR, tier };
})
.filter(
(
t,
): t is RawPair & {
qValue: number;
shrunkR: number;
tier: ConfidenceTier;
} => t.tier !== null,
)
.map((t) => ({
behaviour: t.behaviour,
outcome: t.outcome,
@@ -235,10 +364,18 @@ export function discoverCorrelations(
r: t.r,
pValue: t.pValue,
qValue: Math.round(t.qValue * 1000) / 1000,
interpretation: interpret(t.behaviour, t.outcome, t.r),
shrunkR: Math.round(t.shrunkR * 1000) / 1000,
tier: t.tier,
interpretation: interpret(t.behaviour, t.outcome, t.r, t.tier),
lagDays,
}))
.sort((a, b) => a.qValue - b.qValue || Math.abs(b.r) - Math.abs(a.r));
// RECON1 (D2-2 / D4) — rank by the SHRUNK effect magnitude (a deep, strong
// pair leads a thin one even if the thin one's raw r is higher), with q as
// the tie-break so a tighter FDR still wins on equal effect.
.sort(
(a, b) =>
Math.abs(b.shrunkR) - Math.abs(a.shrunkR) || a.qValue - b.qValue,
);
return { discovered, pairsTested, fdrQ, minPairs };
}
@@ -180,6 +180,41 @@ describe("computeCoincidentDeviation", () => {
}
});
it("keys 'today' in the user's timezone, not UTC (D2-8)", async () => {
// A non-UTC user (UTC+2) logs both vitals at 23:30 UTC on Jun 2 — which is
// 01:30 LOCAL on Jun 3. Under the old UTC slice the day key would read
// "2026-06-02"; tz-aware it must read "2026-06-03", the user's real "today".
const TZ = "Europe/Berlin"; // UTC+2 in summer
const NOW_LATE = new Date("2026-06-02T23:45:00Z"); // 01:45 local Jun 3
const baseline = (
base: number,
outlier: number,
): Array<{ value: number; measuredAt: Date }> => [
...Array.from({ length: 9 }, (_, i) => ({
value: base,
measuredAt: new Date(
`2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`,
),
})),
// 23:30 UTC Jun 2 == 01:30 local Jun 3.
{ value: outlier, measuredAt: new Date("2026-06-02T23:30:00Z") },
];
findMany.mockImplementation(async (args: { where: { type: string } }) => {
if (args.where.type === "RESTING_HEART_RATE") return baseline(58, 95);
if (args.where.type === "HEART_RATE_VARIABILITY") return baseline(60, 10);
return [];
});
const result = await computeCoincidentDeviation("u1", PROFILE, {
now: NOW_LATE,
tz: TZ,
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.value.day).toBe("2026-06-03");
expect(result.value.fired).toBe(true);
}
});
it("does not resolve Rest Mode (no reframe) when the flag did not fire", async () => {
// Banded but inside-band: the reframe must not even query Rest Mode.
findMany.mockImplementation(quietBandedRows);
@@ -40,6 +40,7 @@ import { computeVitalsBaseline, type BaselineProfile } from "./baseline";
import { VITALS_BASELINE_TYPES } from "./registry";
import type { Derived, DerivedProvenanceSource } from "./types";
import { resolveRestMode } from "@/lib/illness/rest-mode";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const DEFAULT_WINDOW_DAYS = 30;
@@ -120,17 +121,31 @@ export interface CoincidentDeviationOpts {
windowDays?: number;
now?: Date;
coverage?: RollupCoverageMap;
/**
* D2-8 — IANA timezone the "today" grouping is keyed in. A 23:30-local
* reading must land on the user's local "today", not UTC's; mirrors
* `readiness.ts`. Defaults to `DEFAULT_TIMEZONE` when the caller has no tz.
*/
tz?: string;
}
/**
* The most recent DAY mean for a type within the window, plus its day key.
* Bounded raw read; null when no reading in the window.
*
* D2-8 — day keys are minted in the user's timezone (`userDayKey`), not UTC
* (`toISOString().slice(0,10)`), so a late-evening / early-morning reading for
* a non-UTC user is grouped under the right calendar "today" — the same
* tz-aware basis `readiness.ts` already uses. Without this, the fired
* coincident flag could compare a vital from the wrong calendar day against
* its band and narrate "≥2 vitals out of band TODAY" on the wrong day.
*/
async function readLatestDayMean(
userId: string,
type: MeasurementType,
windowDays: number,
now: Date,
tz: string,
): Promise<{ value: number; day: string } | null> {
const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
const rows = await prisma.measurement.findMany({
@@ -144,12 +159,10 @@ async function readLatestDayMean(
// so the "today" reading is always the genuine latest, then mean its rows.
let day = "";
for (const r of rows) {
const d = r.measuredAt.toISOString().slice(0, 10);
const d = userDayKey(r.measuredAt, tz);
if (d > day) day = d;
}
const sameDay = rows.filter(
(r) => r.measuredAt.toISOString().slice(0, 10) === day,
);
const sameDay = rows.filter((r) => userDayKey(r.measuredAt, tz) === day);
return {
value: sameDay.reduce((s, r) => s + r.value, 0) / sameDay.length,
day,
@@ -167,6 +180,7 @@ export async function computeCoincidentDeviation(
): Promise<Derived<CoincidentDeviationValue>> {
const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS;
const now = opts.now ?? new Date();
const tz = opts.tz ?? DEFAULT_TIMEZONE;
const computedAt = nowProvenanceTimestamp(now);
const coverage = opts.coverage ?? (await probeRollupCoverage(userId));
@@ -186,7 +200,7 @@ export async function computeCoincidentDeviation(
});
if (baseline.status !== "ok") continue;
if (baseline.provenance.source === "DAY") anyDaySource = true;
const latest = await readLatestDayMean(userId, type, windowDays, now);
const latest = await readLatestDayMean(userId, type, windowDays, now, tz);
if (!latest) continue;
if (latest.day > latestDay) latestDay = latest.day;
if (baseline.coverage.historyDays > maxHistoryDays) {
+13 -2
View File
@@ -36,6 +36,7 @@
* collect.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { FEVER_BAND_C } from "@/lib/clinical-floors";
/** The five shared archetypes plus the dedicated sleep template. */
export type MetricArchetype =
@@ -69,6 +70,13 @@ export interface MetricStatusMeta {
direction: MetricDirection;
/** Coarse population placement anchor; the user's own baseline leads. */
normalRange?: MetricNormalRange;
/**
* D3-H1: single-reading fever band line (°C) for temperature metrics, bound
* to the canonical `FEVER_BAND_C` so the status band and the illness engine's
* sustained-fever escalation (`FEVER_RED_FLAG_C`) are visibly one intentional
* pair from `@/lib/clinical-floors`, not two unrelated magic numbers.
*/
feverBandC?: number;
archetype: MetricArchetype;
}
@@ -194,10 +202,12 @@ const REGISTRY: Record<MetricStatusMetricId, MetricStatusMeta> = {
archetype: "physiological-vital",
},
// Body temperature — the population oral-equivalent mean is ~36.6 °C, not
// 37.0 °C, with a normal band ~35.7–37.4 °C and fever ≥38.0 °C (J Gen
// 37.0 °C, with a normal band ~35.7–37.4 °C and fever ≥ FEVER_BAND_C (J Gen
// Intern Med systematic review, 2019). C6: the high anchor tightens
// 37.5 → 37.2 so the band sits below the fever line; sites differ by up
// to ~1 °C, so the read is a coarse placement only.
// to ~1 °C, so the read is a coarse placement only. D3-H1: the fever line is
// the canonical `FEVER_BAND_C`, paired in one place with the engine's
// sustained-fever escalation `FEVER_RED_FLAG_C`.
BODY_TEMPERATURE: {
id: "BODY_TEMPERATURE",
measurementType: "BODY_TEMPERATURE",
@@ -205,6 +215,7 @@ const REGISTRY: Record<MetricStatusMetricId, MetricStatusMeta> = {
unit: "°C",
direction: "target-band",
normalRange: { low: 36.1, high: 37.2 },
feverBandC: FEVER_BAND_C,
archetype: "physiological-vital",
},
SKIN_TEMPERATURE: {