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/ai/coach/__tests__/correlations-snapshot.test.ts b/src/lib/ai/coach/__tests__/correlations-snapshot.test.ts new file mode 100644 index 000000000..ef0ae83f2 --- /dev/null +++ b/src/lib/ai/coach/__tests__/correlations-snapshot.test.ts @@ -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(); + }); +}); diff --git a/src/lib/ai/coach/correlations-snapshot.ts b/src/lib/ai/coach/correlations-snapshot.ts new file mode 100644 index 000000000..8f80545b7 --- /dev/null +++ b/src/lib/ai/coach/correlations-snapshot.ts @@ -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 { + 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, + }; +} diff --git a/src/lib/ai/coach/derived-snapshot.ts b/src/lib/ai/coach/derived-snapshot.ts index 34422b03b..ad007f456 100644 --- a/src/lib/ai/coach/derived-snapshot.ts +++ b/src/lib/ai/coach/derived-snapshot.ts @@ -145,6 +145,7 @@ export async function buildDerivedSnapshotBlock( userId: string, profile: BaselineProfile, now: Date, + tz?: string, ): Promise { const block: Record = {}; @@ -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 — diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index a94714540..ff9e8f1e7 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -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) ────────────── diff --git a/src/lib/ai/coach/tools/correlations-read.ts b/src/lib/ai/coach/tools/correlations-read.ts index 1d1424ab3..131fb5890 100644 --- a/src/lib/ai/coach/tools/correlations-read.ts +++ b/src/lib/ai/coach/tools/correlations-read.ts @@ -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>(); 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/__tests__/correlation-discovery.test.ts b/src/lib/insights/__tests__/correlation-discovery.test.ts index c349ec4de..18845fcc5 100644 --- a/src/lib/insights/__tests__/correlation-discovery.test.ts +++ b/src/lib/insights/__tests__/correlation-discovery.test.ts @@ -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/); + }); +}); diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index 318dc23c4..9ebc9cf0c 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -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:`) 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 }; } diff --git a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts index 75e21b1be..c101ef457 100644 --- a/src/lib/insights/derived/__tests__/coincident-deviation.test.ts +++ b/src/lib/insights/derived/__tests__/coincident-deviation.test.ts @@ -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); diff --git a/src/lib/insights/derived/coincident-deviation.ts b/src/lib/insights/derived/coincident-deviation.ts index 9c6ab4dcc..8b1bfdc18 100644 --- a/src/lib/insights/derived/coincident-deviation.ts +++ b/src/lib/insights/derived/coincident-deviation.ts @@ -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> { 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) { 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: {