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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 20:41:29 +02:00
1 parent 22ffb19fa5
commit 9265ab89c6
4 files changed
+175 -33

No files matched your search

@@ -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");
@@ -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 }],
+14 -3
View File
@@ -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;
+59 -30
View File
@@ -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<ReferenceMetric, ReferenceRange> = {
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<ReferenceMetric, ReferenceRange> = {
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<ReferenceMetric, ReferenceRange> = {
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<ReferenceMetric, ReferenceRange> = {
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<ReferenceMetric, ReferenceRange> = {
},
// ── 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<ReferenceMetric, ReferenceRange> = {
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<ReferenceMetric, ReferenceRange> = {
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<ReferenceMetric, ReferenceRange> = {
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<ReferenceMetric, ReferenceRange> = {
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<ReferenceMetric, ReferenceRange> = {
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<ReferenceMetric, ReferenceRange> = {
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<ReferenceMetric, ReferenceRange> = {
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<ReferenceMetric, ReferenceRange> = {
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<ReferenceMetric, ReferenceRange> = {
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";
}