mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
merge(RECON1): clinical-floors, coincident-tz, correlation quality, local drivers
This commit is contained in:
commit
f493ff58cb
16 files changed
+800
-43
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 —
|
||||
|
||||
@@ -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) ──────────────
|
||||
|
||||
@@ -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 }>>();
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user