fix(safety): unify clinical floors into one source of truth

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-26 12:58:50 +02:00
1 parent 8ed1d27614
commit f2a9716671
7 files changed
+218 -22

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);
});
});
+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
+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: {