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] 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"; }