From 22ffb19fa5dca5f6bba8de00d8ae583ff636c4fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 20:32:45 +0200 Subject: [PATCH 1/2] feat(coach): ground replies in cited reference ranges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the population reference bands behind the metrics a Coach turn carries as a compact, citation-aware grounding block appended after the SNAPSHOT. For each present metric the block states the headline band, its source (blood pressure on the ESH 2023 line, US/ESC framings as context only), and where the user's value sits via the shared four-state placement contract. Framed strictly as general guidance: the block opens and closes with a not-a-diagnosis caveat, never overrides the existing refusal/dose/drug- level safety discipline, and carries no commercial brand name. Glucose respects the diabetes opt-in — the tighter ADA management goal only when the user has declared it, otherwise the general non-diabetic band. The block is deterministic and inspectable: built purely from the reference-range backbone over the same scalars the snapshot already surfaces, so it can never cite a value or band the snapshot does not carry. --- src/app/api/insights/chat/route.ts | 11 +- .../__tests__/reference-grounding.test.ts | 202 ++++++++++++++ src/lib/ai/coach/__tests__/snapshot.test.ts | 93 +++++++ src/lib/ai/coach/reference-grounding.ts | 252 ++++++++++++++++++ src/lib/ai/coach/snapshot.ts | 122 +++++++++ src/lib/ai/coach/system-prompt.ts | 35 +++ 6 files changed, 714 insertions(+), 1 deletion(-) create mode 100644 src/lib/ai/coach/__tests__/reference-grounding.test.ts create mode 100644 src/lib/ai/coach/reference-grounding.ts diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 812c38b80..93311440c 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -393,9 +393,18 @@ The user's message answers this clarifying question from their self-context ques React briefly and personally to the answer; do not repeat the question and do not ask it again. ` : ""; + // v1.18.6 (W7) — citation-aware reference-range grounding for the metrics + // present in this snapshot. Deterministic + brand-free; framed as general + // guidance, never a diagnosis. Appended after the SNAPSHOT so it is fully + // inspectable and the model reads the published population bands + the + // user's placement. Omitted entirely when no present metric is covered by + // the reference backbone (the builder returns null). + const groundingBlock = snapshot.referenceGrounding + ? `\n${snapshot.referenceGrounding}\n` + : ""; const userPrompt = `SNAPSHOT ${snapshot.snapshotJson || "(no metric data in this user's log yet)"} -${guidedBlock} +${groundingBlock}${guidedBlock} CONVERSATION ${transcript} diff --git a/src/lib/ai/coach/__tests__/reference-grounding.test.ts b/src/lib/ai/coach/__tests__/reference-grounding.test.ts new file mode 100644 index 000000000..c04540a83 --- /dev/null +++ b/src/lib/ai/coach/__tests__/reference-grounding.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "vitest"; + +import { buildReferenceGroundingBlock } from "../reference-grounding"; +import { CITATIONS } from "@/lib/medical-citations"; + +/** + * W7 — the Coach reference-grounding block. These tests pin the + * deterministic shape the hallucination-QA pass audits: the right band per + * metric, the right four-state placement, the general-guidance caveat, the + * ESH-2023 blood-pressure label, the diabetes opt-in gating, and the hard + * no-commercial-brand rule. + */ + +/** + * Every commercial brand the Coach prompt already names elsewhere (the + * GLP-1 block / dose-deferral rule). The grounding block must contain NONE + * of them — it is brand-free by construction, and this list is the guard. + */ +const BRAND_TOKENS = [ + "Mounjaro", + "Ozempic", + "Wegovy", + "Zepbound", + "Trulicity", + "Saxenda", + "Rybelsus", + "Eli Lilly", + "Novo Nordisk", + "Withings", + "WHOOP", + "Oura", + "Fitbit", + "Garmin", + "Apple", + "Polar", + "Nightscout", + "Dexcom", + "Libre", +]; + +describe("buildReferenceGroundingBlock", () => { + it("returns null when no metric is covered by the backbone", () => { + expect(buildReferenceGroundingBlock({ metrics: [], hasDiabetes: false })).toBeNull(); + }); + + it("cites the ESH 2023 source for blood pressure and never the US/ESC line", () => { + const block = buildReferenceGroundingBlock({ + metrics: [{ metric: "BLOOD_PRESSURE", value: 118 }], + hasDiabetes: false, + }); + expect(block).not.toBeNull(); + expect(block).toContain("ESH 2023"); + // The headline normal band edge (≤120 mmHg) is surfaced. + expect(block).toContain("120 mmHg"); + // The conflicting US/ESC framings stay out of the per-line copy; only + // the preamble's neutral "(ACC/AHA)" context mentions them. + expect(block).not.toContain("Stage 1"); + // 118 sits inside the optimal band → "within" placement phrasing. + expect(block).toContain("sits inside the general reference band"); + }); + + it("places an out-of-band value as outside", () => { + const block = buildReferenceGroundingBlock({ + metrics: [{ metric: "BLOOD_PRESSURE", value: 165 }], + hasDiabetes: false, + }); + expect(block).toContain("sits outside the general reference band"); + }); + + it("opens and closes with a general-guidance, not-a-diagnosis caveat", () => { + const block = buildReferenceGroundingBlock({ + metrics: [{ metric: "BMI", value: 22 }], + hasDiabetes: false, + })!; + expect(block).toContain("general guidance"); + expect(block).toContain("not a diagnosis"); + expect(block.toLowerCase()).toContain( + "not personal medical advice", + ); + // The closing reminder line is present. + expect(block).toMatch(/general guidance, not personal medical advice\.$/); + }); + + it("grounds glucose against the general non-diabetic band when the opt-in is off", () => { + const block = buildReferenceGroundingBlock({ + metrics: [{ metric: "BLOOD_GLUCOSE", value: 92 }], + hasDiabetes: false, + })!; + expect(block).toContain("general non-diabetic normal"); + // The non-diabetic fasting normal ceiling is 100 mg/dL. + expect(block).toContain("100 mg/dL"); + expect(block).not.toContain("80–130"); + expect(block).toContain("sits inside the general reference band"); + }); + + it("grounds glucose against the tighter ADA goal band when diabetes is opted in", () => { + const inGoal = buildReferenceGroundingBlock({ + metrics: [{ metric: "BLOOD_GLUCOSE", value: 110 }], + hasDiabetes: true, + })!; + // The ADA diabetic fasting goal band, framed as a clinician-set goal. + expect(inGoal).toContain("80–130 mg/dL"); + expect(inGoal).toContain("management goal"); + expect(inGoal).toContain("clinician-set goal"); + // The line explicitly frames the band as a goal, NOT a screening line. + expect(inGoal).toContain("not a screening line"); + expect(inGoal).toContain("inside the typical diabetes management goal"); + + const outOfGoal = buildReferenceGroundingBlock({ + metrics: [{ metric: "BLOOD_GLUCOSE", value: 200 }], + hasDiabetes: true, + })!; + expect(outOfGoal).toContain("outside the typical diabetes management goal"); + }); + + it("treats a band-less metric (HRV) as baseline-led, never out-of-band", () => { + const block = buildReferenceGroundingBlock({ + metrics: [{ metric: "HEART_RATE_VARIABILITY", value: 45 }], + hasDiabetes: false, + })!; + expect(block).toContain("no fixed population band"); + expect(block).not.toContain("sits outside"); + }); + + it("is deterministic — identical inputs produce byte-identical output", () => { + const input = { + metrics: [ + { metric: "BLOOD_PRESSURE" as const, value: 134 }, + { metric: "BLOOD_GLUCOSE" as const, value: 105 }, + { metric: "SLEEP_DURATION" as const, value: 7.5 }, + ], + hasDiabetes: false, + }; + expect(buildReferenceGroundingBlock(input)).toEqual( + buildReferenceGroundingBlock(input), + ); + }); + + it("caps the number of grounded lines and de-dupes metrics", () => { + const block = buildReferenceGroundingBlock({ + metrics: [ + { metric: "BMI", value: 22 }, + { metric: "BMI", value: 23 }, + ], + hasDiabetes: false, + })!; + // De-duped: only one BMI line. + const bmiLines = block.split("\n").filter((l) => l.startsWith("- BMI:")); + expect(bmiLines).toHaveLength(1); + }); + + it("contains NO commercial brand token in any branch", () => { + // Exercise both the non-diabetic and diabetic glucose branches plus a + // broad metric spread so every code path's text is scanned. + const blocks = [ + buildReferenceGroundingBlock({ + metrics: [ + { metric: "BLOOD_PRESSURE", value: 130 }, + { metric: "BLOOD_GLUCOSE", value: 95 }, + { metric: "RESTING_HEART_RATE", value: 58 }, + { metric: "OXYGEN_SATURATION", value: 97 }, + { metric: "RESPIRATORY_RATE", value: 16 }, + { metric: "BODY_TEMPERATURE", value: 36.8 }, + { metric: "BMI", value: 27 }, + { metric: "STEPS", value: 9000 }, + { metric: "VISCERAL_FAT", value: 8 }, + { metric: "SLEEP_DURATION", value: 8 }, + { metric: "HEART_RATE_VARIABILITY", value: 40 }, + ], + hasDiabetes: false, + }), + buildReferenceGroundingBlock({ + metrics: [{ metric: "BLOOD_GLUCOSE", value: 140 }], + hasDiabetes: true, + }), + ]; + for (const block of blocks) { + expect(block).not.toBeNull(); + for (const brand of BRAND_TOKENS) { + expect(block!.toLowerCase()).not.toContain(brand.toLowerCase()); + } + } + }); + + it("only cites real entries from the medical-citations registry", () => { + const block = buildReferenceGroundingBlock({ + metrics: [ + { metric: "BLOOD_PRESSURE", value: 120 }, + { metric: "STEPS", value: 9000 }, + { metric: "SLEEP_DURATION", value: 8 }, + ], + hasDiabetes: false, + })!; + // Each cited source label ("NAME (YEAR)") corresponds to a real + // registry entry — no fabricated guideline names. + const citationNames = Object.values(CITATIONS).map((c) => c.name); + // The BP / steps / sleep headline sources must all appear by name. + expect(citationNames.some((n) => block.includes(n))).toBe(true); + expect(block).toContain("ESH 2023"); + expect(block).toContain("AASM"); + }); +}); diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index 4000f8ca9..c94c65bb6 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -897,3 +897,96 @@ describe("Coach snapshot — free-text fields wrap through sanitizeForPrompt (L- expect(hit).toBe(true); }); }); + +// v1.18.6 (W7) — the citation-aware reference-range grounding rides through +// buildCoachSnapshot for the metrics present, gated on the W6 diabetes opt-in. +describe("buildCoachSnapshot — reference grounding (W7)", () => { + beforeEach(() => { + vi.clearAllMocks(); + __resetCoachSnapshotCacheForTests(); + prismaMock.measurement.findMany.mockResolvedValue([]); + prismaMock.moodEntry.findMany.mockResolvedValue([]); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + prismaMock.medication.findMany.mockResolvedValue([]); + prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null }); + resolveModuleMapMock.mockResolvedValue(allModulesEnabled()); + featuresMock.mockResolvedValue({}); + }); + + it("attaches an ESH-2023-cited grounding block when blood pressure is present", async () => { + featuresMock.mockResolvedValue({ + bloodPressure: { avgSys30: 138, avgDia30: 85, coverage: { count: 4 } }, + }); + prismaMock.measurement.findMany.mockResolvedValue([ + daysAgo(2, 138, "BLOOD_PRESSURE_SYS"), + daysAgo(2, 85, "BLOOD_PRESSURE_DIA"), + ]); + + const out = await buildCoachSnapshot("user-1", { sources: ["bp"] }); + expect(out.referenceGrounding).not.toBeNull(); + expect(out.referenceGrounding).toContain("REFERENCE GROUNDING"); + expect(out.referenceGrounding).toContain("ESH 2023"); + // 138 sits in the high-normal band (130–139), two tiers past optimal → + // the four-state contract reads that as "outside". + expect(out.referenceGrounding).toContain( + "sits outside the general reference band", + ); + expect(out.referenceGrounding).toContain("not a diagnosis"); + }); + + it("returns null grounding when no present metric is covered", async () => { + const out = await buildCoachSnapshot("user-1"); + // Empty snapshot — no reference-covered scalar collected. + expect(out.referenceGrounding).toBeNull(); + }); + + it("uses the general non-diabetic glucose band when hasDiabetes is unset", async () => { + featuresMock.mockResolvedValue({}); + prismaMock.measurement.findMany.mockResolvedValue([ + { ...daysAgo(2, 95, "BLOOD_GLUCOSE"), glucoseContext: "FASTING" }, + ]); + prismaMock.user.findUnique.mockResolvedValue({ + coachPrefsJson: null, + hasDiabetes: false, + }); + + const out = await buildCoachSnapshot("user-1", { sources: ["glucose"] }); + expect(out.referenceGrounding).toContain("general non-diabetic normal"); + expect(out.referenceGrounding).not.toContain("management goal"); + }); + + it("uses the tighter ADA goal band when hasDiabetes is opted in", async () => { + featuresMock.mockResolvedValue({}); + prismaMock.measurement.findMany.mockResolvedValue([ + { ...daysAgo(2, 150, "BLOOD_GLUCOSE"), glucoseContext: "FASTING" }, + ]); + prismaMock.user.findUnique.mockResolvedValue({ + coachPrefsJson: null, + hasDiabetes: true, + }); + + const out = await buildCoachSnapshot("user-1", { sources: ["glucose"] }); + expect(out.referenceGrounding).toContain("80–130 mg/dL"); + expect(out.referenceGrounding).toContain("management goal"); + // 150 fasting is outside the diabetic goal band. + expect(out.referenceGrounding).toContain( + "outside the typical diabetes management goal", + ); + }); + + it("never emits a commercial brand token in the grounding block", async () => { + featuresMock.mockResolvedValue({ + bloodPressure: { avgSys30: 120, coverage: { count: 4 } }, + }); + prismaMock.measurement.findMany.mockResolvedValue([ + daysAgo(2, 120, "BLOOD_PRESSURE_SYS"), + daysAgo(2, 78, "BLOOD_PRESSURE_DIA"), + ]); + const out = await buildCoachSnapshot("user-1", { sources: ["bp"] }); + for (const brand of ["Mounjaro", "Withings", "WHOOP", "Oura", "Dexcom"]) { + expect(out.referenceGrounding!.toLowerCase()).not.toContain( + brand.toLowerCase(), + ); + } + }); +}); diff --git a/src/lib/ai/coach/reference-grounding.ts b/src/lib/ai/coach/reference-grounding.ts new file mode 100644 index 000000000..7c1ff6a94 --- /dev/null +++ b/src/lib/ai/coach/reference-grounding.ts @@ -0,0 +1,252 @@ +/** + * v1.18.6 — Coach reference-range grounding (W7). + * + * Turns the population reference bands W5 landed in + * `src/lib/reference-ranges.ts` into a compact, citation-aware text block + * the Coach prompt can read so a reply is GROUNDED in published guidance + * rather than the model's parametric memory of "what's normal". For each + * metric the snapshot actually carries, the block states: + * - the metric's headline reference band(s), cited to the publishing + * body + year (via `referenceLabel` → `medical-citations`), and + * - where the user's current value sits against those bands, using the + * SAME deterministic `classifyReference()` four-state contract the UI + * tiles use — never a recomputed placement. + * + * GUARDRAILS (firm — a hallucination-QA pass audits this exact text): + * - GENERAL guidance only. The block opens AND closes with a caveat that + * these are population anchors, not personal medical advice or a + * diagnosis. The Coach safety discipline (refusal rules, dose-deferral) + * is unchanged — this is read-only context, never an instruction. + * - NO commercial brand names. Every string is sourced from + * `reference-ranges.ts` (brand-free by construction) + the metric's + * natural-language label here. The unit test asserts the rendered + * block contains no brand token. + * - Blood pressure is labelled to ESH 2023 (the SHIPPED European + * default); the ACC/AHA + ESC framings ride only as `conflicts` + * CONTEXT, exactly as the band data authored them — never as the + * placement. + * - GLUCOSE respects the W6 `hasDiabetes` opt-in. When the user has NOT + * opted in, glucose is grounded against the GENERAL non-diabetic + * fasting bands. When they HAVE, the block states the tighter ADA + * glycemic GOAL band (fasting 80–130 mg/dL) as a clinician-set target, + * never a screening threshold and never a diagnosis. + * + * Deterministic + pure: same inputs → byte-identical block, no DB, no LLM, + * no per-call randomness. The block is surfaced verbatim in the Coach + * user-prompt after the SNAPSHOT so it is fully inspectable. + * + * Token budget: the block is bounded by the number of metrics present + * (one short line each) and a hard `MAX_GROUNDING_METRICS` cap, so even a + * 15-metric snapshot stays a few hundred tokens. + */ +import { + classifyReference, + getReferenceRange, + referenceLabel, + type ReferenceMetric, + type ReferencePlacement, +} from "@/lib/reference-ranges"; + +/** + * One metric the snapshot carries, paired with the user's current + * representative value in the metric's reference unit. The caller + * (`snapshot.ts`) resolves the value from the same aggregates it already + * built, so the grounding reads the SAME number the rest of the snapshot + * does. + */ +export interface GroundingMetricInput { + readonly metric: ReferenceMetric; + /** + * Current representative value in the metric's reference unit (e.g. the + * 30-day mean systolic in mmHg, the fasting-glucose mean in mg/dL). Null + * when the snapshot has the metric block but no usable scalar — the line + * still cites the band, with an `insufficient` placement. + */ + readonly value: number | null; +} + +export interface ReferenceGroundingInput { + readonly metrics: readonly GroundingMetricInput[]; + /** + * The user's explicit, declared diabetes opt-in (`User.hasDiabetes`, + * resolved by the W6 path). Selects the tighter ADA glucose GOAL band + * for the glucose line ONLY. Never inferred from a reading. + */ + readonly hasDiabetes: boolean; +} + +/** + * Hard cap on grounding lines so a maxed-out cluster set cannot balloon + * the block. Metrics beyond the cap are dropped silently (the snapshot + * still carries their data; only the extra grounding line is shed). The + * cap sits above the universal-metric count so a normal account is never + * trimmed. + */ +const MAX_GROUNDING_METRICS = 14; + +/** + * Natural-language label for each grounded metric. Deliberately the + * user-facing phrasing the Coach prompt already mandates (GROUND RULE 8 — + * never the DB enum). No brand names by construction. + */ +const METRIC_LABEL: Record = { + BLOOD_PRESSURE: "blood pressure (systolic)", + PULSE_PRESSURE: "pulse pressure", + MEAN_ARTERIAL_PRESSURE: "mean arterial pressure", + PULSE_WAVE_VELOCITY: "pulse-wave velocity", + RESTING_HEART_RATE: "resting heart rate", + HEART_RATE_VARIABILITY: "heart-rate variability", + OXYGEN_SATURATION: "oxygen saturation", + RESPIRATORY_RATE: "respiratory rate", + BODY_TEMPERATURE: "body temperature", + BLOOD_GLUCOSE: "fasting glucose", + HBA1C: "HbA1c", + BMI: "BMI", + STEPS: "daily steps", + VISCERAL_FAT: "visceral-fat rating", + SLEEP_DURATION: "sleep duration", +}; + +/** + * The ADA diabetic glycemic GOAL fasting band (mg/dL), kept in lock-step + * with `src/lib/targets/glucose-targets.ts` `DIABETIC_GOAL_BANDS.FASTING`. + * Only used to phrase the glucose grounding line when `hasDiabetes` is set; + * the placement still defers to the user's clinician, never to a band edge. + */ +const ADA_DIABETIC_FASTING_GOAL = { low: 80, high: 130 } as const; + +/** Map a four-state placement to a short, general-guidance phrase. */ +function placementPhrase(placement: ReferencePlacement): string { + switch (placement) { + case "within": + return "sits inside the general reference band"; + case "slightly-outside": + return "sits just outside the general reference band"; + case "outside": + return "sits outside the general reference band"; + case "insufficient": + // No scalar, or a metric with no fixed population band (e.g. HRV) — + // the band is cited for education, the placement is left to the + // user's own baseline. + return "has no fixed population band — your own trend leads"; + } +} + +/** + * Render the half-open band as a compact "low–high unit" descriptor for the + * metric's headline (first/normal) band. Open-ended bounds render with a + * leading/trailing ≤ / ≥. + */ +function headlineBandText(metric: ReferenceMetric): string | null { + const range = getReferenceRange(metric); + if (!range || range.bands.length === 0) return null; + const band = range.bands[0]; + const unit = range.unit; + if (band.low != null && band.high != null) { + return `${band.low}–${band.high} ${unit}`; + } + if (band.high != null) return `≤${band.high} ${unit}`; + if (band.low != null) return `≥${band.low} ${unit}`; + return null; +} + +/** + * Build the glucose grounding line. Branches on the W6 diabetes opt-in: + * general non-diabetic fasting band by default, the tighter ADA glycemic + * GOAL band when the user declared diabetes. The diabetic branch frames the + * band as a clinician-set GOAL, never a screening threshold. + */ +function glucoseLine(value: number | null, hasDiabetes: boolean): string { + const label = METRIC_LABEL.BLOOD_GLUCOSE; + if (hasDiabetes) { + const { low, high } = ADA_DIABETIC_FASTING_GOAL; + // Placement against the diabetic GOAL band, framed as a target the + // user's clinician individualises — never a diagnostic call. + let where: string; + if (value == null || !Number.isFinite(value)) { + where = "no usable value this window"; + } else if (value >= low && value <= high) { + where = "is inside the typical diabetes management goal"; + } else { + where = "is outside the typical diabetes management goal"; + } + return `- ${label}: a common diabetes management goal is ${low}–${high} mg/dL fasting (${referenceLabel( + "BLOOD_GLUCOSE", + )} — clinician-set goal, individualised, not a screening line). Yours ${where}.`; + } + const band = headlineBandText("BLOOD_GLUCOSE"); + const placement = classifyReference("BLOOD_GLUCOSE", value); + return `- ${label}: general non-diabetic normal is ${band} (${referenceLabel( + "BLOOD_GLUCOSE", + )}). Yours ${placementPhrase(placement)}.`; +} + +/** Build one standard (non-glucose) grounding line for a metric. */ +function standardLine(metric: ReferenceMetric, value: number | null): string { + const label = METRIC_LABEL[metric]; + const band = headlineBandText(metric); + const placement = classifyReference(metric, value); + const cite = referenceLabel(metric); + // Blood pressure cites ESH 2023 by construction (the reference-range + // headline band is sourced to ESH_2023_HYPERTENSION); the conflicts + // (ACC/AHA, ESC) stay out of the per-line copy and live in the block + // preamble's "guidelines disagree" note so the Coach never mislabels the + // shipped European line. + if (band) { + return `- ${label}: general reference ${band} (${cite}). Yours ${placementPhrase( + placement, + )}.`; + } + // No fixed band (HRV) — cite the source, defer wholly to the baseline. + return `- ${label}: ${placementPhrase(placement)} (${cite}).`; +} + +/** + * Build the Coach reference-grounding block, or null when no metric in the + * input is covered by the reference backbone (so the prompt carries no + * empty section). The returned string is plain text with a stable shape: + * a header, a general-guidance preamble, one line per metric, and a + * closing caveat. The route appends it verbatim after the SNAPSHOT. + */ +export function buildReferenceGroundingBlock( + input: ReferenceGroundingInput, +): string | null { + // De-dupe + keep input order; only metrics the backbone covers. + const seen = new Set(); + const ordered: GroundingMetricInput[] = []; + for (const m of input.metrics) { + if (seen.has(m.metric)) continue; + if (!getReferenceRange(m.metric)) continue; + seen.add(m.metric); + ordered.push(m); + if (ordered.length >= MAX_GROUNDING_METRICS) break; + } + if (ordered.length === 0) return null; + + const lines = ordered.map((m) => + m.metric === "BLOOD_GLUCOSE" + ? glucoseLine(m.value, input.hasDiabetes) + : standardLine(m.metric, m.value), + ); + + // The preamble + closing caveat carry the firm general-guidance framing. + // Blood-pressure guideline disagreement is surfaced once here (context), + // never as a per-line placement, so the Coach reads ESH 2023 as the + // shipped line. + const header = "REFERENCE GROUNDING (general guidance — not a diagnosis)"; + const preamble = [ + "These are published POPULATION reference bands for context only — they", + "are not personal medical advice, not a diagnosis, and not a target set", + "for this user. The user's own baseline always leads the read. Cite a", + "band only to orient a reply (\"the general reference range is …\"); never", + "tell the user they \"have\" or \"don't have\" a condition from a band.", + "Blood pressure follows the European ESH 2023 line; US (ACC/AHA) and", + "ESC framings differ and are context, not the call. This block does not", + "change any safety rule above — keep deferring dose / diagnosis / drug-", + "level questions to the user's clinician exactly as instructed.", + ].join("\n"); + const closing = + "Reminder: ranges are general guidance, not personal medical advice."; + + return `${header}\n${preamble}\n\n${lines.join("\n")}\n\n${closing}`; +} diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index f8e82811a..339482f58 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -49,6 +49,11 @@ import { buildCoachMemoryBlock } from "./memory-snapshot"; import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot"; import { buildCycleSnapshotBlock } from "./cycle-snapshot"; import { buildIllnessSnapshotBlock } from "./illness-snapshot"; +import { + buildReferenceGroundingBlock, + type GroundingMetricInput, +} from "./reference-grounding"; +import type { ReferenceMetric } from "@/lib/reference-ranges"; import { isCycleAvailableForUser } from "@/lib/cycle/gate"; import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate"; import { @@ -81,6 +86,15 @@ export interface CoachSnapshotResult { * the model could see. */ provenance: CoachProvenance; + /** + * v1.18.6 (W7) — citation-aware reference-range grounding for the + * metrics present in this snapshot, or null when none is covered by the + * reference backbone. The Coach route appends it verbatim after the + * SNAPSHOT so the model reads published population bands + the user's + * placement (general guidance, never a diagnosis). Built deterministically + * from `src/lib/reference-ranges.ts`; carries no commercial brand name. + */ + referenceGrounding: string | null; } /** @@ -565,6 +579,11 @@ async function buildCoachSnapshotImpl( // display unit so the Coach reads the same number every other surface // shows. Read it on this existing prefs hop (no extra round-trip). glucoseUnit: true, + // v1.18.6 (W7) — the explicit, user-declared diabetes opt-in. Selects + // the tighter ADA glycemic GOAL band for the glucose reference-grounding + // line only; never inferred from a reading, never a diagnosis. Read on + // this existing prefs hop (no extra round-trip). + hasDiabetes: true, }, }); const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson); @@ -652,6 +671,37 @@ async function buildCoachSnapshotImpl( const metrics = new Set(); const counts: NonNullable = {}; + // v1.18.6 (W7) — representative scalar per reference-covered metric, in the + // metric's reference unit, collected as the blocks below build. Feeds the + // citation-aware grounding block (population band + the user's placement). + // Each entry reads the SAME number the snapshot already surfaces — no + // independent recompute — so the grounding can never cite a value the + // snapshot doesn't carry. A metric with a block but no clean scalar is left + // unset (the grounding line still cites its band with an insufficient + // placement only if it is added with a null value; we omit such metrics). + const groundingValues = new Map(); + // Additive `MeasurementType` → reference metric, for the value-block loop. + const TYPE_TO_REFERENCE_METRIC: Record = { + RESTING_HEART_RATE: "RESTING_HEART_RATE", + OXYGEN_SATURATION: "OXYGEN_SATURATION", + RESPIRATORY_RATE: "RESPIRATORY_RATE", + PULSE_WAVE_VELOCITY: "PULSE_WAVE_VELOCITY", + BODY_TEMPERATURE: "BODY_TEMPERATURE", + BODY_MASS_INDEX: "BMI", + VISCERAL_FAT: "VISCERAL_FAT", + ACTIVITY_STEPS: "STEPS", + }; + /** Mean of the most recent N daily-value rows (already user-tz day means). */ + const recentRowsMean = ( + rows: DailyValueRow[], + take = DAILY_TIMELINE_DAYS, + ): number | null => { + if (rows.length === 0) return null; + const slice = rows.slice(-take); + const sum = slice.reduce((s, r) => s + r.value, 0); + return sum / slice.length; + }; + // v1.7.0 — block registry. Maps each emitted snapshot top-level key // to the cluster it belongs to so the soft-cap degradation pass // (below) can walk blocks in reverse cluster-priority order and shed @@ -1119,6 +1169,10 @@ async function buildCoachSnapshotImpl( windows.add("last90days"); counts.bp = features.bloodPressure.coverage?.count ?? undefined; registerBlock("bloodPressure", "bp"); + // W7 grounding: systolic 30-day mean against the ESH 2023 office bands. + const sys30 = + features.bloodPressure.avgSys30 ?? features.bloodPressure.allTimeAvgSys; + if (sys30 != null) groundingValues.set("BLOOD_PRESSURE", sys30); } if (wantsWeight && features.weight) { const rows = byType("WEIGHT"); @@ -1137,6 +1191,13 @@ async function buildCoachSnapshotImpl( windows.add("last30days"); counts.weight = features.weight.coverage?.count ?? undefined; registerBlock("weight", "weight"); + // W7 grounding: weight has no population band, but the derived BMI does. + // Use the features-computed BMI (same value the weight tile shows) so the + // grounding reads the WHO band. A dedicated BODY_MASS_INDEX block (below) + // wins if the user also syncs a measured BMI series. + if (features.weight.bmi != null) { + groundingValues.set("BMI", features.weight.bmi); + } } if (wantsPulse && features.pulse) { const rows = byType("PULSE"); @@ -1412,6 +1473,14 @@ async function buildCoachSnapshotImpl( metrics.add(block.metric); counts[block.metric] = rows.length; registerBlock(block.snapshotKey, block.source); + // W7 grounding: when this additive series maps to a reference metric, + // record the recent daily mean (same per-day means the timeline shows). + const refMetric = TYPE_TO_REFERENCE_METRIC[block.type]; + if (refMetric) { + const recentRows = buildDailyValueRows(rows, recentCutoff, userTz); + const mean = recentRowsMean(recentRows); + if (mean != null) groundingValues.set(refMetric, mean); + } } // ── v1.7.0 sleep block (with optional per-stage enrichment) ─────── @@ -1480,6 +1549,19 @@ async function buildCoachSnapshotImpl( metrics.add("sleep"); counts.sleep = sleepRows.length; registerBlock("sleep", "sleep"); + // W7 grounding: recent nightly asleep duration in HOURS against the + // AASM 7–9 h band (the reference unit is hours; the snapshot stores + // minutes). Mean over the recent nights the block already reconstructed. + if (recentNights.length > 0) { + const meanMin = + recentNights.reduce( + (s, n) => s + (typeof n.minutes === "number" ? n.minutes : 0), + 0, + ) / recentNights.length; + if (meanMin > 0) { + groundingValues.set("SLEEP_DURATION", meanMin / 60); + } + } } } @@ -1653,6 +1735,22 @@ async function buildCoachSnapshotImpl( metrics.add("glucose"); counts.glucose = glucoseRows.length; registerBlock("glucose", "glucose"); + // W7 grounding: fasting glucose mean in RAW mg/dL (the reference band's + // unit), independent of the user's mmol/L display preference. The + // grounding line's band selection respects the W6 `hasDiabetes` opt-in; + // here we only feed the representative fasting value. Fall back to the + // overall mean when no row is tagged FASTING so the band is still cited. + const fastingRows = glucoseRows.filter( + (r) => String(r.glucoseContext).toUpperCase() === "FASTING", + ); + const glucoseScalarRows = + fastingRows.length > 0 ? fastingRows : glucoseRows; + const glucoseMeanMgdl = + glucoseScalarRows.reduce((s, r) => s + r.value, 0) / + glucoseScalarRows.length; + if (Number.isFinite(glucoseMeanMgdl)) { + groundingValues.set("BLOOD_GLUCOSE", glucoseMeanMgdl); + } } } @@ -1894,6 +1992,29 @@ async function buildCoachSnapshotImpl( // `coach.snapshot.truncated` annotation when it sheds anything. degradeToBudget(compactSnapshot, blockClusters); + // v1.18.6 (W7) — build the citation-aware reference-grounding block from the + // representative scalars collected above. Deterministic + pure; the route + // appends it verbatim after the SNAPSHOT. Null when no present metric is + // covered by the reference backbone. Insertion order follows the block-build + // order (BP first), giving a stable, inspectable block for the + // hallucination-QA pass. + const groundingMetrics: GroundingMetricInput[] = Array.from( + groundingValues.entries(), + ).map(([metric, value]) => ({ metric, value })); + const referenceGrounding = buildReferenceGroundingBlock({ + metrics: groundingMetrics, + hasDiabetes: prefsRow?.hasDiabetes ?? false, + }); + if (referenceGrounding) { + annotate({ + action: { name: "coach.grounding.attached" }, + meta: { + metrics: groundingMetrics.map((m) => m.metric).sort(), + hasDiabetes: prefsRow?.hasDiabetes ?? false, + }, + }); + } + return { snapshotJson: JSON.stringify(compactSnapshot, null, 2), provenance: { @@ -1901,6 +2022,7 @@ async function buildCoachSnapshotImpl( metrics: Array.from(metrics), counts: Object.keys(counts).length > 0 ? counts : undefined, }, + referenceGrounding, }; } diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index 608aaf262..0c94d276b 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -189,6 +189,22 @@ GROUND RULES day, decline and say the forecast is a descriptive estimate, not a contraceptive method. When NO "cycle" block is present, do not infer or guess a phase. +13. The user prompt MAY carry a "REFERENCE GROUNDING" block after the + SNAPSHOT. It lists published POPULATION reference bands for the + metrics present plus where the user's value sits, each cited to its + source (e.g. ESH 2023 for blood pressure). Use it ONLY as general + orientation — "the general reference range is …", "that sits a little + above the usual band". It is NOT personal medical advice, NOT a + diagnosis, and NOT a target for this user; the user's own baseline + still leads. Never tell the user they "have" or "don't have" a + condition from a band, never convert a band into a risk score, and + never let the grounding soften the safety rules above — keep deferring + dose / diagnosis / drug-level questions to their clinician. Blood + pressure reads on the European ESH 2023 line; the US (ACC/AHA) and + ESC framings differ and are context only. If the user has declared + diabetes, the glucose line states a clinician-set management GOAL, not + a screening threshold. When no REFERENCE GROUNDING block is present, + do not invent a band — pivot to the user's own trend. DAY-LEVEL READINGS — USE THE TIMELINE @@ -580,6 +596,25 @@ GRUNDREGELN dass die Prognose eine beschreibende Schätzung ist, keine Verhütungsmethode. Wenn KEIN "cycle"-Block vorhanden ist, leite oder rate keine Phase ab. +13. Der User-Prompt kann nach dem SNAPSHOT einen "REFERENCE + GROUNDING"-Block mitbringen. Er nennt publizierte POPULATIONS- + Referenzbereiche für die vorhandenen Metriken plus die Einordnung des + Nutzerwerts, jeweils mit Quelle zitiert (z. B. ESH 2023 für den + Blutdruck). Nutze ihn AUSSCHLIESSLICH zur groben Orientierung — "der + allgemeine Referenzbereich liegt bei …", "das liegt etwas über dem + üblichen Bereich". Er ist KEINE persönliche medizinische Beratung, + KEINE Diagnose und KEIN Zielwert für diesen Nutzer; die eigene + Ausgangslage des Nutzers führt weiterhin. Sag dem Nutzer nie, er + "habe" oder "habe nicht" eine Erkrankung aus einem Bereich, mach aus + einem Bereich nie einen Risiko-Score, und lass das Grounding nie die + Sicherheitsregeln oben aufweichen — verweise Dosis-/Diagnose-/ + Wirkstoffspiegel-Fragen weiter an die behandelnde Ärztin. Der + Blutdruck liest sich auf der europäischen ESH-2023-Linie; die US- + (ACC/AHA) und ESC-Einordnungen weichen ab und sind nur Kontext. Hat + der Nutzer Diabetes angegeben, nennt die Glukose-Zeile ein + klinisch gesetztes Management-ZIEL, keinen Screening-Schwellwert. + Ist kein REFERENCE-GROUNDING-Block vorhanden, erfinde keinen Bereich + — wechsle zum eigenen Trend des Nutzers. TAGES-LEVEL-MESSWERTE — NUTZE DIE TIMELINE From 9265ab89c630f9a59c281db69aac20869e640e9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 20:41:29 +0200 Subject: [PATCH 2/2] fix(reference): resolve the normal band by marker, not index 0 The reference-range classifier assumed bands[0] was always the normal/target band, so any metric whose first band is abnormal was placed wrong: a healthy BMI 22 read as just outside the reference and an underweight BMI 17 read as inside; pulse pressure had the same flaw (Narrow is its first band). The same classifier drives the in-app four-state explainer, so both surfaces were affected. Mark the population-normal band explicitly with a `normal: true` flag on every metric, add `normalBandIndex`, and place values by distance from the marked band rather than from index 0. `headlineBandText` now renders the marked band, so BMI grounds against 18.5-24.9 kg/m2. Exclude daily steps from the Coach grounding: it is fed the intra-day mean of the step rows, which averages a multi-row day down and can misplace it below the 8,000 floor. Grounding it on a per-day sum is the correct fix and is left as a follow-up; until then drop the line rather than ship a wrong placement. Extend the classifier and grounding tests for BMI 17/22/27, a pulse pressure case, the normal-band-not-at-index-0 contract, and that steps is not grounded. --- src/lib/__tests__/reference-ranges.test.ts | 37 ++++++++ .../__tests__/reference-grounding.test.ts | 65 ++++++++++++++ src/lib/ai/coach/reference-grounding.ts | 17 +++- src/lib/reference-ranges.ts | 89 ++++++++++++------- 4 files changed, 175 insertions(+), 33 deletions(-) diff --git a/src/lib/__tests__/reference-ranges.test.ts b/src/lib/__tests__/reference-ranges.test.ts index 910e80e05..730739d9c 100644 --- a/src/lib/__tests__/reference-ranges.test.ts +++ b/src/lib/__tests__/reference-ranges.test.ts @@ -7,6 +7,7 @@ import { classifyReference, getReferenceRange, isReferenceMetric, + normalBandIndex, referenceCitation, referenceLabel, type ReferenceMetric, @@ -54,6 +55,42 @@ describe("reference-ranges backbone", () => { expect(classifyReference("OXYGEN_SATURATION", 98)).toBe("within"); }); + it("every banded metric marks exactly one normal band", () => { + for (const metric of REFERENCE_METRICS) { + const bands = REFERENCE_RANGES[metric].bands; + if (bands.length === 0) continue; + const marked = bands.filter((b) => b.normal === true); + expect(marked, `${metric} normal-band count`).toHaveLength(1); + // `normalBandIndex` points at that marked band. + expect(bands[normalBandIndex(metric)].normal, `${metric} index`).toBe(true); + } + }); + + it("resolves the normal band by marker, not by index 0", () => { + // BMI authors Underweight FIRST; the normal band is the 18.5–24.9 entry. + expect(REFERENCE_RANGES.BMI.bands[0].label).toBe("Underweight"); + expect(REFERENCE_RANGES.BMI.bands[normalBandIndex("BMI")].label).toBe("Normal"); + // BMI 22 is healthy → within (the index-0 bug reported it slightly-outside). + expect(classifyReference("BMI", 22)).toBe("within"); + // BMI 17 is underweight → one band below normal → slightly-outside + // (the index-0 bug reported it as within). + expect(classifyReference("BMI", 17)).toBe("slightly-outside"); + // BMI 27 overweight (one above) → slightly-outside; 35 obesity (two + // above) → outside. + expect(classifyReference("BMI", 27)).toBe("slightly-outside"); + expect(classifyReference("BMI", 35)).toBe("outside"); + }); + + it("classifies pulse pressure around its interior normal band", () => { + // Narrow is the FIRST band; the normal band is "Typical at rest". + expect(REFERENCE_RANGES.PULSE_PRESSURE.bands[0].label).toBe("Narrow"); + expect(classifyReference("PULSE_PRESSURE", 40)).toBe("within"); + // Narrow (≤25) is one band below normal → slightly-outside. + expect(classifyReference("PULSE_PRESSURE", 20)).toBe("slightly-outside"); + // Wide (≥60) is one band above normal → slightly-outside. + expect(classifyReference("PULSE_PRESSURE", 75)).toBe("slightly-outside"); + }); + it("returns insufficient for absent values and band-less metrics", () => { expect(classifyReference("RESTING_HEART_RATE", null)).toBe("insufficient"); expect(classifyReference("RESTING_HEART_RATE", Number.NaN)).toBe("insufficient"); diff --git a/src/lib/ai/coach/__tests__/reference-grounding.test.ts b/src/lib/ai/coach/__tests__/reference-grounding.test.ts index c04540a83..d010e8b37 100644 --- a/src/lib/ai/coach/__tests__/reference-grounding.test.ts +++ b/src/lib/ai/coach/__tests__/reference-grounding.test.ts @@ -113,6 +113,71 @@ describe("buildReferenceGroundingBlock", () => { expect(outOfGoal).toContain("outside the typical diabetes management goal"); }); + it("grounds BMI against the WHO normal band, not the first (Underweight) band", () => { + // The headline band rendered must be the 18.5–24.9 normal range — the + // index-0 bug surfaced the Underweight "≤18.5" ceiling instead. + const normal = buildReferenceGroundingBlock({ + metrics: [{ metric: "BMI", value: 22 }], + hasDiabetes: false, + })!; + const bmiLine = normal.split("\n").find((l) => l.startsWith("- BMI:"))!; + expect(bmiLine).toContain("18.5–24.9 kg/m²"); + expect(bmiLine).not.toContain("≤18.5"); + // A healthy 22 sits INSIDE the band (was wrongly "just outside"). + expect(bmiLine).toContain("sits inside the general reference band"); + + // Underweight 17 sits just outside (was wrongly "inside"). + const under = buildReferenceGroundingBlock({ + metrics: [{ metric: "BMI", value: 17 }], + hasDiabetes: false, + })!; + expect(under.split("\n").find((l) => l.startsWith("- BMI:"))).toContain( + "sits just outside the general reference band", + ); + + // Overweight 27 sits just outside. + const over = buildReferenceGroundingBlock({ + metrics: [{ metric: "BMI", value: 27 }], + hasDiabetes: false, + })!; + expect(over.split("\n").find((l) => l.startsWith("- BMI:"))).toContain( + "sits just outside the general reference band", + ); + }); + + it("grounds pulse pressure against its interior normal band", () => { + const block = buildReferenceGroundingBlock({ + metrics: [{ metric: "PULSE_PRESSURE", value: 40 }], + hasDiabetes: false, + })!; + const line = block.split("\n").find((l) => l.startsWith("- pulse pressure:"))!; + // The normal "Typical at rest" band (25–60), not the first "Narrow" band. + expect(line).toContain("25–60 mmHg"); + expect(line).toContain("sits inside the general reference band"); + }); + + it("does not ground steps (mean-aggregation misplacement; follow-up)", () => { + const block = buildReferenceGroundingBlock({ + metrics: [ + { metric: "STEPS", value: 9000 }, + { metric: "BMI", value: 22 }, + ], + hasDiabetes: false, + })!; + // STEPS is dropped; the BMI line still renders. + expect(block).not.toContain("daily steps"); + expect(block.split("\n").some((l) => l.startsWith("- BMI:"))).toBe(true); + }); + + it("returns null when steps is the only metric (it is not grounded)", () => { + expect( + buildReferenceGroundingBlock({ + metrics: [{ metric: "STEPS", value: 9000 }], + hasDiabetes: false, + }), + ).toBeNull(); + }); + it("treats a band-less metric (HRV) as baseline-led, never out-of-band", () => { const block = buildReferenceGroundingBlock({ metrics: [{ metric: "HEART_RATE_VARIABILITY", value: 45 }], diff --git a/src/lib/ai/coach/reference-grounding.ts b/src/lib/ai/coach/reference-grounding.ts index 7c1ff6a94..9263fa7b6 100644 --- a/src/lib/ai/coach/reference-grounding.ts +++ b/src/lib/ai/coach/reference-grounding.ts @@ -42,6 +42,7 @@ import { classifyReference, getReferenceRange, + normalBandIndex, referenceLabel, type ReferenceMetric, type ReferencePlacement, @@ -134,13 +135,16 @@ function placementPhrase(placement: ReferencePlacement): string { /** * Render the half-open band as a compact "low–high unit" descriptor for the - * metric's headline (first/normal) band. Open-ended bounds render with a - * leading/trailing ≤ / ≥. + * metric's NORMAL band — resolved by the `normal` marker, NOT by index 0 + * (some metrics author an abnormal band first, e.g. BMI Underweight or + * pulse-pressure Narrow). Open-ended bounds render with a leading/trailing + * ≤ / ≥. */ function headlineBandText(metric: ReferenceMetric): string | null { const range = getReferenceRange(metric); if (!range || range.bands.length === 0) return null; - const band = range.bands[0]; + const idx = normalBandIndex(metric); + const band = range.bands[idx]; const unit = range.unit; if (band.low != null && band.high != null) { return `${band.low}–${band.high} ${unit}`; @@ -217,6 +221,13 @@ export function buildReferenceGroundingBlock( for (const m of input.metrics) { if (seen.has(m.metric)) continue; if (!getReferenceRange(m.metric)) continue; + // STEPS is excluded from grounding for now: the caller feeds the + // intra-day MEAN of the ACTIVITY_STEPS rows, so a day logged across + // several rows is averaged DOWN and can be misclassified below the + // 8,000-step floor. Grounding it on a sum-per-day aggregate is the + // correct fix and is tracked as a follow-up; until then we drop the + // line rather than ship a wrong placement. + if (m.metric === "STEPS") continue; seen.add(m.metric); ordered.push(m); if (ordered.length >= MAX_GROUNDING_METRICS) break; diff --git a/src/lib/reference-ranges.ts b/src/lib/reference-ranges.ts index 88e8c0581..288507a45 100644 --- a/src/lib/reference-ranges.ts +++ b/src/lib/reference-ranges.ts @@ -39,6 +39,16 @@ export interface ReferenceBand { readonly high?: number; /** The body + year that publishes this band. */ readonly citation: CitationId; + /** + * Marks THE population-normal / target band — the placement + * `classifyReference` treats as "within" and `headlineBandText` renders + * as the headline. Exactly one band per metric (with at least one band) + * carries this. Bands are NOT assumed normal by index: a metric whose + * first band is abnormal (BMI → Underweight first; pulse pressure → + * Narrow first) marks the correct interior band instead. A test asserts + * the one-marker-per-banded-metric invariant. + */ + readonly normal?: boolean; } /** @@ -55,7 +65,12 @@ export interface ReferenceConflict { export interface ReferenceRange { /** Canonical display unit (matches the metric tile + registry). */ readonly unit: string; - /** Ordered population bands; the first is the "good"/normal placement. */ + /** + * Ordered population bands. The "good"/normal placement is the band + * flagged `normal: true` — NOT necessarily the first entry. Some metrics + * (BMI, pulse pressure) author an abnormal band first, so consumers must + * resolve the normal band by the marker, never by index. + */ readonly bands: readonly ReferenceBand[]; /** The body the headline band is sourced to (drives the cited sentence). */ readonly referenceId: CitationId; @@ -95,7 +110,7 @@ export const REFERENCE_RANGES: Record = { unit: "mmHg", bands: [ // ESH grade bands (office, mmHg, systolic anchors shown). - { label: "Optimal", high: 120, citation: "ESH_2023_HYPERTENSION" }, + { label: "Optimal", high: 120, citation: "ESH_2023_HYPERTENSION", normal: true }, { label: "Normal", low: 120, high: 129, citation: "ESH_2023_HYPERTENSION" }, { label: "High-normal", low: 130, high: 139, citation: "ESH_2023_HYPERTENSION" }, { label: "Hypertension", low: 140, citation: "ESH_2023_HYPERTENSION" }, @@ -120,7 +135,7 @@ export const REFERENCE_RANGES: Record = { unit: "mmHg", bands: [ { label: "Narrow", high: 25, citation: "STATPEARLS_PULSE_PRESSURE" }, - { label: "Typical at rest", low: 25, high: 60, citation: "STATPEARLS_PULSE_PRESSURE" }, + { label: "Typical at rest", low: 25, high: 60, citation: "STATPEARLS_PULSE_PRESSURE", normal: true }, { label: "Wide", low: 60, citation: "STATPEARLS_PULSE_PRESSURE" }, ], referenceId: "STATPEARLS_PULSE_PRESSURE", @@ -132,7 +147,7 @@ export const REFERENCE_RANGES: Record = { MEAN_ARTERIAL_PRESSURE: { unit: "mmHg", bands: [ - { label: "Resting band", low: 70, high: 100, citation: "STATPEARLS_MAP" }, + { label: "Resting band", low: 70, high: 100, citation: "STATPEARLS_MAP", normal: true }, ], referenceId: "STATPEARLS_MAP", guidanceCaveat: @@ -142,7 +157,7 @@ export const REFERENCE_RANGES: Record = { PULSE_WAVE_VELOCITY: { unit: "m/s", bands: [ - { label: "Reference", high: 10, citation: "ESC_ESH_2018_PWV" }, + { label: "Reference", high: 10, citation: "ESC_ESH_2018_PWV", normal: true }, { label: "Elevated stiffness", low: 10, citation: "ESC_ESH_2018_PWV" }, ], referenceId: "ESC_ESH_2018_PWV", @@ -151,14 +166,14 @@ export const REFERENCE_RANGES: Record = { }, // ── Resting heart rate — uses RESTING_HEART_RATE, never workout pulse. ── - // Bands are authored normal-first so `classifyReference` treats the - // population-normal band as the "within" placement. The athletic band + // The 60–100 band carries `normal: true`, so `classifyReference` treats it + // as the "within" placement regardless of band order. The athletic band // (40–60) is benign for a trained heart but sits below the standard // normal range — the caveat copy carries that nuance. RESTING_HEART_RATE: { unit: "bpm", bands: [ - { label: "Normal adult resting", low: 60, high: 100, citation: "AHA_2024_RHR" }, + { label: "Normal adult resting", low: 60, high: 100, citation: "AHA_2024_RHR", normal: true }, { label: "Athletic (benign)", low: 40, high: 60, citation: "AHA_2024_RHR" }, ], referenceId: "AHA_2024_RHR", @@ -178,7 +193,7 @@ export const REFERENCE_RANGES: Record = { OXYGEN_SATURATION: { unit: "%", bands: [ - { label: "Healthy room air", low: 95, high: 100, citation: "STATPEARLS_PULSE_OX" }, + { label: "Healthy room air", low: 95, high: 100, citation: "STATPEARLS_PULSE_OX", normal: true }, { label: "Mild hypoxaemia", low: 91, high: 94, citation: "STATPEARLS_PULSE_OX" }, ], referenceId: "STATPEARLS_PULSE_OX", @@ -189,7 +204,7 @@ export const REFERENCE_RANGES: Record = { RESPIRATORY_RATE: { unit: "breaths/min", bands: [ - { label: "Normal resting adult", low: 12, high: 20, citation: "ALA_RESPIRATORY_RATE" }, + { label: "Normal resting adult", low: 12, high: 20, citation: "ALA_RESPIRATORY_RATE", normal: true }, { label: "Worth attention", low: 22, citation: "RCP_2017_NEWS2" }, ], referenceId: "ALA_RESPIRATORY_RATE", @@ -201,7 +216,7 @@ export const REFERENCE_RANGES: Record = { BODY_TEMPERATURE: { unit: "°C", bands: [ - { label: "Normal oral range", low: 35.7, high: 37.4, citation: "JGIM_2019_TEMPERATURE" }, + { label: "Normal oral range", low: 35.7, high: 37.4, citation: "JGIM_2019_TEMPERATURE", normal: true }, { label: "Fever", low: 38.0, citation: "JGIM_2019_TEMPERATURE" }, ], referenceId: "JGIM_2019_TEMPERATURE", @@ -213,7 +228,7 @@ export const REFERENCE_RANGES: Record = { BLOOD_GLUCOSE: { unit: "mg/dL", bands: [ - { label: "Normal fasting", high: 100, citation: "ADA_2024_GLYCEMIC" }, + { label: "Normal fasting", high: 100, citation: "ADA_2024_GLYCEMIC", normal: true }, { label: "Prediabetes (fasting)", low: 100, high: 125, citation: "ADA_2024_GLYCEMIC" }, { label: "Diabetes range (fasting)", low: 126, citation: "ADA_2024_GLYCEMIC" }, ], @@ -231,7 +246,7 @@ export const REFERENCE_RANGES: Record = { HBA1C: { unit: "%", bands: [ - { label: "Normal", high: 5.7, citation: "ADA_2024_GLYCEMIC" }, + { label: "Normal", high: 5.7, citation: "ADA_2024_GLYCEMIC", normal: true }, { label: "Prediabetes", low: 5.7, high: 6.4, citation: "ADA_2024_GLYCEMIC" }, { label: "Diabetes", low: 6.5, citation: "ADA_2024_GLYCEMIC" }, ], @@ -244,7 +259,7 @@ export const REFERENCE_RANGES: Record = { unit: "kg/m²", bands: [ { label: "Underweight", high: 18.5, citation: "WHO_2020_PA" }, - { label: "Normal", low: 18.5, high: 24.9, citation: "WHO_2020_PA" }, + { label: "Normal", low: 18.5, high: 24.9, citation: "WHO_2020_PA", normal: true }, { label: "Overweight", low: 25.0, high: 29.9, citation: "WHO_2020_PA" }, { label: "Obesity", low: 30.0, citation: "WHO_2020_PA" }, ], @@ -257,7 +272,7 @@ export const REFERENCE_RANGES: Record = { STEPS: { unit: "steps/day", bands: [ - { label: "Higher mortality benefit", low: 8000, citation: "STEPS_SAINT_MAURICE_2020" }, + { label: "Higher mortality benefit", low: 8000, citation: "STEPS_SAINT_MAURICE_2020", normal: true }, ], referenceId: "STEPS_SAINT_MAURICE_2020", guidanceCaveat: @@ -267,7 +282,7 @@ export const REFERENCE_RANGES: Record = { VISCERAL_FAT: { unit: "rating", bands: [ - { label: "Healthy rating", low: 1, high: 12, citation: "VAT_IMAGING_THRESHOLD" }, + { label: "Healthy rating", low: 1, high: 12, citation: "VAT_IMAGING_THRESHOLD", normal: true }, { label: "Elevated rating", low: 13, citation: "VAT_IMAGING_THRESHOLD" }, ], referenceId: "VAT_IMAGING_THRESHOLD", @@ -279,7 +294,7 @@ export const REFERENCE_RANGES: Record = { SLEEP_DURATION: { unit: "h", bands: [ - { label: "Adult 18–64", low: 7, high: 9, citation: "AASM_2015_SLEEP" }, + { label: "Adult 18–64", low: 7, high: 9, citation: "AASM_2015_SLEEP", normal: true }, { label: "Older adult 65+", low: 7, high: 8, citation: "AASM_2015_SLEEP" }, ], referenceId: "AASM_2015_SLEEP", @@ -337,6 +352,20 @@ export type ReferencePlacement = * baseline). Pure + side-effect free; the user's own baseline still leads * the read — this is only a coarse placement aid. */ +/** + * Resolve the index of a metric's population-normal band — the band the + * placement contract treats as "within". Returns the index of the band + * flagged `normal: true`; falls back to band 0 only for the legacy + * (untagged) shape so an unmarked metric degrades to the old normal-first + * assumption rather than throwing. A metric with no bands returns -1. + */ +export function normalBandIndex(metric: ReferenceMetric): number { + const bands = REFERENCE_RANGES[metric].bands; + if (bands.length === 0) return -1; + const marked = bands.findIndex((b) => b.normal === true); + return marked === -1 ? 0 : marked; +} + export function classifyReference( metric: ReferenceMetric, value: number | null | undefined, @@ -345,26 +374,26 @@ export function classifyReference( if (value == null || !Number.isFinite(value) || range.bands.length === 0) { return "insufficient"; } - // Find the first band whose half-open interval contains the value. - // Bands are authored in ascending order; the FIRST band is the - // normal/good placement, so its membership maps to `within`. + const normalIdx = normalBandIndex(metric); + // Find the band whose half-open interval contains the value. Bands are + // authored in ascending order; the placement is the band's DISTANCE from + // the normal band (resolved by the `normal` marker, NOT by index 0 — + // some metrics author an abnormal band first, e.g. BMI Underweight, + // pulse-pressure Narrow). One step from normal = watch tier; two or more + // = attention tier. for (let i = 0; i < range.bands.length; i++) { const band = range.bands[i]; const aboveLow = band.low == null || value >= band.low; const belowHigh = band.high == null || value <= band.high; if (aboveLow && belowHigh) { - if (i === 0) return "within"; - // The band immediately adjacent to normal is the watch tier; any - // band beyond it is the attention tier. - return i === 1 ? "slightly-outside" : "outside"; + const distance = Math.abs(i - normalIdx); + if (distance === 0) return "within"; + return distance === 1 ? "slightly-outside" : "outside"; } } - // Value sits beyond every authored band (e.g. below an open-low normal, - // or above the top band's open-high). Treat an under-shoot of the first - // band's low as within when the first band is open-low; otherwise the - // value is outside the covered range → attention. - const first = range.bands[0]; - if (first.low != null && value < first.low) return "outside"; + // Value sits beyond every authored band (below an open-low first band, or + // above an open-high last band). Either way it is outside the covered + // bands → attention. return "outside"; }