Files
HealthLog/src/lib/analytics/__tests__/bp-grade.test.ts
T
Marc-André Bombeck cf7bb13ee8 Explain a blood-pressure score in its own popover
Three lines now sit above the reference band: which axis set the number
and how far it was from its ceiling, what the scale means at the ceiling
and at 100, and that the pair shown is a recency-weighted average rather
than a reading anyone took. In all six languages.
2026-07-31 11:08:54 +02:00

529 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.15.12 A1 — unit pins for the graded BP pillar score.
*
* The maintainer's apps01 profile (under-65 ceiling 129/79, avg 134/87)
* is the canonical regression: the binary all-time in-target rate read
* ~10-16/100; the graded score must land in the borderline-stage-1 band
* (low-to-mid 50s), not in the catastrophic band.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
gradeBpScore,
gradeBpScoreFromSeries,
type BpPairPoint,
} from "../bp-grade";
import type { BpTargets } from "../bp-targets";
// Under-65 ESH band — the maintainer's age band.
const UNDER65: BpTargets = {
sysLow: 120,
sysHigh: 129,
diaLow: 70,
diaHigh: 79,
};
/**
* The score alone, for the arithmetic pins below. `gradeBpScore` returns
* the score together with the basis that produced it; the basis has its
* own describe block further down.
*/
function bpScore(input: { sys: number; dia: number; target: BpTargets }) {
return gradeBpScore(input).score;
}
describe("gradeBpScore", () => {
it("grades the maintainer's 134/87 into the borderline band [45,60]", () => {
const score = bpScore({ sys: 134, dia: 87, target: UNDER65 });
expect(score).toBeGreaterThanOrEqual(45);
expect(score).toBeLessThanOrEqual(60);
});
it("grades a well-controlled 120/78 at or above 85", () => {
expect(
bpScore({ sys: 120, dia: 78, target: UNDER65 }),
).toBeGreaterThanOrEqual(85);
});
it("grades a textbook-normal 118/76 very high (≈100)", () => {
expect(
bpScore({ sys: 118, dia: 76, target: UNDER65 }),
).toBeGreaterThanOrEqual(88);
});
it("grades an uncontrolled 160/100 at or below 30", () => {
expect(
bpScore({ sys: 160, dia: 100, target: UNDER65 }),
).toBeLessThanOrEqual(30);
});
it("takes the WORSE axis — a single high diastolic drags the score down", () => {
// sys perfect (118), dia far over (95): the worst axis should win.
const sysOk = bpScore({ sys: 118, dia: 78, target: UNDER65 });
const diaHigh = bpScore({ sys: 118, dia: 95, target: UNDER65 });
expect(diaHigh).toBeLessThan(sysOk);
});
it("is monotonic — higher BP never scores better", () => {
const a = bpScore({ sys: 130, dia: 82, target: UNDER65 });
const b = bpScore({ sys: 140, dia: 88, target: UNDER65 });
const c = bpScore({ sys: 150, dia: 95, target: UNDER65 });
expect(a).toBeGreaterThan(b);
expect(b).toBeGreaterThan(c);
});
it("has no cliff — a one-mmHg change moves the score by a small amount", () => {
const at = bpScore({ sys: 129, dia: 79, target: UNDER65 });
const justOver = bpScore({ sys: 130, dia: 79, target: UNDER65 });
expect(at - justOver).toBeLessThanOrEqual(5);
expect(at).toBeGreaterThanOrEqual(justOver);
});
it("penalises hypotension below the clinical floor (no longer reads ~100)", () => {
// 85/45 is 5 mmHg below both floors (sys 90 / dia 50). The continuous
// hypo curve gives a gentle penalty near the floor (5 below ≈ 85),
// clearly below the optimal plateau of 100.
const low = bpScore({ sys: 85, dia: 45, target: UNDER65 });
expect(low).toBeLessThan(100);
expect(low).toBeGreaterThanOrEqual(80);
// A markedly low reading is penalised much harder.
const veryLow = bpScore({ sys: 70, dia: 40, target: UNDER65 });
expect(veryLow).toBeLessThan(low);
expect(veryLow).toBeLessThanOrEqual(45);
});
it("has NO hypotension cliff — the floor boundary is continuous on both axes", () => {
// Audit HIGH-1: above the floor the score sits on the optimal plateau
// (100); the old below-floor branch jumped to ~81 at 1 mmHg under,
// a 19-point cliff. The dedicated hypo curve starts at 100 AT the
// floor and descends smoothly. Walk across the systolic floor (90):
// dia 66 sits on the optimal plateau (offset −13 → 100) so the
// systolic axis is the worst-of(sys,dia) winner across the walk.
const sysWalk = [92, 91, 90, 89, 88].map((sys) =>
bpScore({ sys, dia: 66, target: UNDER65 }),
);
// AT and ABOVE the floor sit on the plateau (100).
expect(sysWalk[2]).toBe(100); // sys 90
expect(sysWalk[1]).toBe(100); // sys 91
expect(sysWalk[0]).toBe(100); // sys 92
// Every per-step delta is small (no >5-point boundary jump) and the
// score is monotonic non-increasing as BP drops below the floor.
for (let i = 0; i < sysWalk.length - 1; i++) {
const delta = sysWalk[i] - sysWalk[i + 1];
expect(delta).toBeGreaterThanOrEqual(0); // non-increasing as BP drops
expect(delta).toBeLessThanOrEqual(5); // no cliff
}
// Walk across the diastolic floor (50). Hold sys comfortably normal so
// the diastolic axis is the worst-of(sys,dia) winner throughout.
// sys 117 sits on the optimal plateau (offset −12 → 100) so the
// diastolic axis is the worst-of(sys,dia) winner across the walk.
const diaWalk = [52, 51, 50, 49, 48].map((dia) =>
bpScore({ sys: 117, dia, target: UNDER65 }),
);
expect(diaWalk[2]).toBe(100); // dia 50
expect(diaWalk[1]).toBe(100); // dia 51
expect(diaWalk[0]).toBe(100); // dia 52
for (let i = 0; i < diaWalk.length - 1; i++) {
const delta = diaWalk[i] - diaWalk[i + 1];
expect(delta).toBeGreaterThanOrEqual(0);
expect(delta).toBeLessThanOrEqual(5);
}
});
it("descends smoothly further below the floor (mirrors over-target steepness)", () => {
// floor → 100, floor−10 → ~70, floor−20 → ~45, floor−30 → ~20.
// dia 66 stays on the plateau so the systolic axis is the winner.
expect(bpScore({ sys: 90, dia: 66, target: UNDER65 })).toBe(100);
expect(bpScore({ sys: 80, dia: 66, target: UNDER65 })).toBe(70);
expect(bpScore({ sys: 70, dia: 66, target: UNDER65 })).toBe(45);
expect(bpScore({ sys: 60, dia: 66, target: UNDER65 })).toBe(20);
// Never negative on an extreme low.
expect(
bpScore({ sys: 30, dia: 20, target: UNDER65 }),
).toBeGreaterThanOrEqual(0);
});
it("preserves the audit fairness anchors (134/87 → 57, 165/105 → 22)", () => {
expect(bpScore({ sys: 134, dia: 87, target: UNDER65 })).toBe(57);
expect(bpScore({ sys: 165, dia: 105, target: UNDER65 })).toBe(22);
});
});
describe("gradeBpScoreFromSeries", () => {
const NOW = new Date("2026-06-07T12:00:00.000Z");
const daysAgo = (n: number): Date =>
new Date(NOW.getTime() - n * 24 * 60 * 60 * 1000);
it("returns null for an empty series", () => {
expect(
gradeBpScoreFromSeries({ pairs: [], target: UNDER65, now: NOW }),
).toBeNull();
});
it("weights recent readings more — recent improvement lifts the score", () => {
// Old readings high, recent readings well-controlled.
const pairs: BpPairPoint[] = [
{ at: daysAgo(300), sys: 160, dia: 100 },
{ at: daysAgo(280), sys: 158, dia: 98 },
{ at: daysAgo(5), sys: 120, dia: 76 },
{ at: daysAgo(2), sys: 119, dia: 75 },
{ at: daysAgo(0), sys: 121, dia: 77 },
];
const recencyWeighted = gradeBpScoreFromSeries({
pairs,
target: UNDER65,
now: NOW,
});
// A flat (unweighted) mean would be dragged toward ~145/90; the
// recency-weighted representative should read close to the recent
// well-controlled cluster.
expect(recencyWeighted).not.toBeNull();
expect(recencyWeighted!.score).toBeGreaterThanOrEqual(80);
});
it("matches the single-reading grade when every pair is today", () => {
const pairs: BpPairPoint[] = [
{ at: NOW, sys: 134, dia: 87 },
{ at: NOW, sys: 134, dia: 87 },
];
expect(
gradeBpScoreFromSeries({ pairs, target: UNDER65, now: NOW }),
).toEqual(gradeBpScore({ sys: 134, dia: 87, target: UNDER65 }));
});
it("rollup (per-day-mean + count) and live (per-event) agree on the same data", () => {
// Audit HIGH-2 regression guard. A multi-reading high day today
// (4× 150/95) plus a calm day yesterday (1× 115/72). The live path
// grades the 5 per-event pairs; the rollup path grades the two per-day
// MEAN pairs but weights today's mean by its count (4). Both must
// produce the same graded score, otherwise the BP pillar diverges by
// up to ~20 points depending on DAY-bucket warmth.
const today = daysAgo(0);
const yesterday = daysAgo(1);
// Live: one pair per event (count defaults to 1).
const livePairs: BpPairPoint[] = [
{ at: today, sys: 150, dia: 95 },
{ at: today, sys: 150, dia: 95 },
{ at: today, sys: 150, dia: 95 },
{ at: today, sys: 150, dia: 95 },
{ at: yesterday, sys: 115, dia: 72 },
];
// Rollup: one per-day-MEAN pair, weighted by perDayPairCount.
const rollupPairs: BpPairPoint[] = [
{ at: today, sys: 150, dia: 95, count: 4 },
{ at: yesterday, sys: 115, dia: 72, count: 1 },
];
const live = gradeBpScoreFromSeries({
pairs: livePairs,
target: UNDER65,
now: NOW,
});
const rollup = gradeBpScoreFromSeries({
pairs: rollupPairs,
target: UNDER65,
now: NOW,
});
expect(live).not.toBeNull();
expect(rollup).not.toBeNull();
expect(Math.abs(live!.score - rollup!.score)).toBeLessThanOrEqual(1);
});
});
/**
* v1.34.5 — the basis published with the score. The pillar scores the
* WORSE of the two axes; these pin which axis that was, what it was
* measured against, and that the three relations the popover can render
* are the only three that exist.
*/
describe("gradeBpScore — the basis", () => {
// Hypotension floors, mirrored from `bp-in-target` so the sweep below
// can reconstruct the boundary the grader must have used.
const SYS_FLOOR = 90;
const DIA_FLOOR = 50;
it("names the systolic axis when it is the worse one", () => {
// 137/84: sys 8 over its ceiling, dia 5 over its own — systolic worse.
const { score, basis } = gradeBpScore({
sys: 137,
dia: 84,
target: UNDER65,
});
expect(basis).toEqual({
axis: "systolic",
relation: "above_ceiling",
offsetMmHg: 8,
boundaryMmHg: 129,
});
expect(score).toBe(57);
});
it("names the diastolic axis when it is the worse one", () => {
// 132/88: sys 3 over, dia 9 over — diastolic worse, and the basis
// must say so rather than defaulting to the axis people read first.
const { score, basis } = gradeBpScore({
sys: 132,
dia: 88,
target: UNDER65,
});
expect(basis).toEqual({
axis: "diastolic",
relation: "above_ceiling",
offsetMmHg: 9,
boundaryMmHg: 79,
});
expect(score).toBe(55);
});
it("breaks a tie toward systolic", () => {
// Both axes sit exactly 5 mmHg over their own ceiling, so both grade
// 65 and the score cannot say which one it came from. The documented
// rule picks systolic.
const { basis } = gradeBpScore({ sys: 134, dia: 84, target: UNDER65 });
expect(basis.axis).toBe("systolic");
expect(basis.boundaryMmHg).toBe(129);
// …and at the ceiling itself, where both axes grade 85.
expect(gradeBpScore({ sys: 129, dia: 79, target: UNDER65 }).basis).toEqual({
axis: "systolic",
relation: "in_band",
offsetMmHg: 0,
boundaryMmHg: 129,
});
});
it("reports a reading inside the band as in_band, measured from the ceiling", () => {
const { score, basis } = gradeBpScore({
sys: 124,
dia: 74,
target: UNDER65,
});
expect(basis.relation).toBe("in_band");
expect(basis.boundaryMmHg).toBe(129);
expect(basis.offsetMmHg).toBe(5); // five below the ceiling
expect(score).toBeGreaterThanOrEqual(85);
});
it("reports a hypotensive reading as below_floor, measured from the floor", () => {
// 86/70: systolic 4 under the 90 floor; diastolic comfortably in band.
const { basis } = gradeBpScore({ sys: 86, dia: 70, target: UNDER65 });
expect(basis).toEqual({
axis: "systolic",
relation: "below_floor",
offsetMmHg: 4,
boundaryMmHg: SYS_FLOOR,
});
});
it("keeps below_floor even when the score is still high — the curve is gentle near the floor", () => {
// Worth pinning because it is easy to assume "below the floor" implies
// a poor score: two mmHg under reads 94, comfortably green. The
// relation is decided by where the value sits, never by the score.
const { score, basis } = gradeBpScore({
sys: 88,
dia: 70,
target: UNDER65,
});
expect(basis.relation).toBe("below_floor");
expect(score).toBeGreaterThanOrEqual(85);
});
it("keeps the relation with the score when the rounded offset reads zero", () => {
// 129.4 is above the ceiling and scores below 85, but rounds to a
// 0 mmHg offset. The relation follows the unrounded value, so the
// sentence can never claim "in band" about a sub-85 number.
const { score, basis } = gradeBpScore({
sys: 129.4,
dia: 70,
target: UNDER65,
});
expect(basis.relation).toBe("above_ceiling");
expect(basis.offsetMmHg).toBe(0);
expect(score).toBeLessThan(85);
});
it("reproduces the worked 137.5/87 example — systolic, ~9 over, 56", () => {
const { score, basis } = gradeBpScore({
sys: 137.5,
dia: 87,
target: UNDER65,
});
expect(score).toBe(56);
expect(basis).toEqual({
axis: "systolic",
relation: "above_ceiling",
offsetMmHg: 9,
boundaryMmHg: 129,
});
});
it("uses the targets it was handed, not a fixed band", () => {
const over65: BpTargets = {
sysLow: 130,
sysHigh: 139,
diaLow: 70,
diaHigh: 79,
};
const { basis } = gradeBpScore({ sys: 137, dia: 70, target: over65 });
expect(basis).toEqual({
axis: "systolic",
relation: "in_band",
offsetMmHg: 2,
boundaryMmHg: 139,
});
});
it("has exactly three relations, and each one reconstructs its own reading", () => {
// The design asserts the three relations are exhaustive; this pins it
// against the anchors instead of trusting the prose. Every integer
// reading across the whole plausible range must land in exactly one
// relation, and axis + relation + boundary + offset together must
// reconstruct the value that produced the score.
const seen = new Set<string>();
for (let sys = 60; sys <= 220; sys += 1) {
for (let dia = 30; dia <= 140; dia += 1) {
const { score, basis } = gradeBpScore({ sys, dia, target: UNDER65 });
seen.add(basis.relation);
const value = basis.axis === "systolic" ? sys : dia;
const floor = basis.axis === "systolic" ? SYS_FLOOR : DIA_FLOOR;
const ceiling =
basis.axis === "systolic" ? UNDER65.sysHigh : UNDER65.diaHigh;
if (basis.relation === "below_floor") {
expect(value).toBeLessThan(floor);
expect(basis.boundaryMmHg).toBe(floor);
expect(floor - value).toBe(basis.offsetMmHg);
} else {
expect(value).toBeGreaterThanOrEqual(floor);
expect(basis.boundaryMmHg).toBe(ceiling);
expect(Math.abs(value - ceiling)).toBe(basis.offsetMmHg);
if (basis.relation === "in_band") {
expect(value).toBeLessThanOrEqual(ceiling);
// The scale line only makes sense if in_band never reads red.
expect(score).toBeGreaterThanOrEqual(85);
} else {
expect(basis.relation).toBe("above_ceiling");
expect(value).toBeGreaterThan(ceiling);
expect(score).toBeLessThan(85);
}
}
// The named axis alone carries the score: replace the other axis
// with an optimal value and the number must not move.
const optimalOther = gradeBpScore(
basis.axis === "systolic"
? { sys, dia: UNDER65.diaHigh - 12, target: UNDER65 }
: { sys: UNDER65.sysHigh - 12, dia, target: UNDER65 },
);
expect(optimalOther.score).toBe(score);
}
}
expect([...seen].sort()).toEqual([
"above_ceiling",
"below_floor",
"in_band",
]);
});
});
describe("gradeBpScoreFromSeries — the basis travels with the score", () => {
const NOW = new Date("2026-06-07T12:00:00.000Z");
it("returns the basis of the representative it graded, not of any single pair", () => {
// Two readings, one at each end. The representative is their mean
// (both same-day, so equal weight): 130/85. The basis must describe
// that mean, which matches neither reading.
const graded = gradeBpScoreFromSeries({
pairs: [
{ at: NOW, sys: 120, dia: 80 },
{ at: NOW, sys: 140, dia: 90 },
],
target: UNDER65,
now: NOW,
});
expect(graded).not.toBeNull();
expect(graded!.basis).toEqual({
axis: "diastolic",
relation: "above_ceiling",
offsetMmHg: 6,
boundaryMmHg: 79,
});
expect(graded!.score).toBe(
gradeBpScore({ sys: 130, dia: 85, target: UNDER65 }).score,
);
});
it("grades the basis against the targets it was given", () => {
// The analytics route re-runs this helper with the CLINICAL targets
// when the user keeps a personal one. Same series, two bands: both
// the score and the basis must move together.
const pairs: BpPairPoint[] = [{ at: NOW, sys: 132, dia: 88 }];
const clinical = gradeBpScoreFromSeries({
pairs,
target: UNDER65,
now: NOW,
})!;
const personal = gradeBpScoreFromSeries({
pairs,
target: { sysLow: 120, sysHigh: 135, diaLow: 70, diaHigh: 90 },
now: NOW,
})!;
expect(clinical.basis.boundaryMmHg).toBe(79);
expect(clinical.basis.relation).toBe("above_ceiling");
expect(personal.basis.relation).toBe("in_band");
expect(personal.score).toBeGreaterThan(clinical.score);
});
});
/**
* The popover's scale line quotes two numbers out of this curve ("at the
* ceiling the score is 85; 100 means roughly 12 mmHg below it") because
* the sentence has to read naturally in six languages. That makes the
* copy and the anchors two places that must agree, so this pins them to
* each other: re-anchoring the curve turns this red and the six strings
* have to follow.
*/
describe("the scale the popover quotes", () => {
const AT_CEILING_SCORE = 85;
const OPTIMAL_OFFSET_MMHG = 12;
it("still scores 85 at the ceiling and 100 at twelve below it", () => {
expect(
bpScore({ sys: UNDER65.sysHigh, dia: UNDER65.diaHigh, target: UNDER65 }),
).toBe(AT_CEILING_SCORE);
expect(
bpScore({
sys: UNDER65.sysHigh - OPTIMAL_OFFSET_MMHG,
dia: UNDER65.diaHigh - OPTIMAL_OFFSET_MMHG,
target: UNDER65,
}),
).toBe(100);
// Twelve is the SMALLEST distance that reaches 100 — one less does not.
expect(
bpScore({
sys: UNDER65.sysHigh - (OPTIMAL_OFFSET_MMHG - 1),
dia: UNDER65.diaHigh - (OPTIMAL_OFFSET_MMHG - 1),
target: UNDER65,
}),
).toBeLessThan(100);
});
it.each(["en", "de", "fr", "es", "it", "pl"])(
"quotes those same two numbers in %s",
(locale) => {
const bundle = JSON.parse(
readFileSync(
join(__dirname, "../../../../messages", `${locale}.json`),
"utf8",
),
) as { insights: { healthScore: Record<string, string> } };
const line = bundle.insights.healthScore.bpScale;
expect(line).toContain(String(AT_CEILING_SCORE));
expect(line).toContain(String(OPTIMAL_OFFSET_MMHG));
expect(line).toContain("100");
},
);
});