From f2a97166712b95214cdb3484939a95105da23cb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 12:58:50 +0200 Subject: [PATCH] fix(safety): unify clinical floors into one source of truth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hypertensive-crisis floors were defined in three modules with a divergent diastolic value: the notification engine and the Coach acute clause used 180/120, but the dashboard hero used 180/110. A reading like 170/112 lit the red critical-BP banner on the hero yet never tripped the notification alarm or matched the Coach's stated acute number — one surface said crisis, the other two stayed calm on the same row. Promote the absolute floors into a dependency-free clinical-floors leaf and have the hero, the safety-floor engine, the status registry's fever band, and the illness escalation all import from it. The crisis diastolic floor is the guideline-correct 120 (ACC/AHA hypertensive urgency); the wider 110 hero net is dropped so the surfaces tell one story. A coverage test pins that every consumer resolves the same constants. --- src/lib/__tests__/clinical-floors.test.ts | 69 ++++++++++++++++++ src/lib/clinical-floors.ts | 81 +++++++++++++++++++++ src/lib/dashboard/__tests__/verdict.test.ts | 23 ++++-- src/lib/dashboard/verdict.ts | 17 +++-- src/lib/illness/correlation.ts | 8 +- src/lib/illness/safety-floors.ts | 27 +++++-- src/lib/insights/metric-status-registry.ts | 15 +++- 7 files changed, 218 insertions(+), 22 deletions(-) create mode 100644 src/lib/__tests__/clinical-floors.test.ts create mode 100644 src/lib/clinical-floors.ts diff --git a/src/lib/__tests__/clinical-floors.test.ts b/src/lib/__tests__/clinical-floors.test.ts new file mode 100644 index 000000000..c22b4e4b8 --- /dev/null +++ b/src/lib/__tests__/clinical-floors.test.ts @@ -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); + }); +}); diff --git a/src/lib/clinical-floors.ts b/src/lib/clinical-floors.ts new file mode 100644 index 000000000..c31282d56 --- /dev/null +++ b/src/lib/clinical-floors.ts @@ -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; diff --git a/src/lib/dashboard/__tests__/verdict.test.ts b/src/lib/dashboard/__tests__/verdict.test.ts index 3aec74909..c768586ce 100644 --- a/src/lib/dashboard/__tests__/verdict.test.ts +++ b/src/lib/dashboard/__tests__/verdict.test.ts @@ -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"); diff --git a/src/lib/dashboard/verdict.ts b/src/lib/dashboard/verdict.ts index 101667268..f693ae19b 100644 --- a/src/lib/dashboard/verdict.ts +++ b/src/lib/dashboard/verdict.ts @@ -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; diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index 583726981..363b3daa0 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -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; diff --git a/src/lib/illness/safety-floors.ts b/src/lib/illness/safety-floors.ts index 609e9c38e..7d35aeebf 100644 --- a/src/lib/illness/safety-floors.ts +++ b/src/lib/illness/safety-floors.ts @@ -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 diff --git a/src/lib/insights/metric-status-registry.ts b/src/lib/insights/metric-status-registry.ts index ce655dbf4..df8ea7737 100644 --- a/src/lib/insights/metric-status-registry.ts +++ b/src/lib/insights/metric-status-registry.ts @@ -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 = { 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 = { unit: "°C", direction: "target-band", normalRange: { low: 36.1, high: 37.2 }, + feverBandC: FEVER_BAND_C, archetype: "physiological-vital", }, SKIN_TEMPERATURE: {