Publish which blood-pressure axis set the pillar score

The score is the worse of the systolic and diastolic sub-scores, and
until now nothing said which one it was or how far over its ceiling the
reading sat. The grader now returns that basis together with the score,
as one value, and it is carried as one value all the way to the pillar
so an explanation can never belong to a different grading run than the
number it explains.
This commit is contained in:
Marc-André Bombeck committed 2026-07-31 11:04:20 +02:00
1 parent 4d6091ec45
commit 64a92fd392
8 files changed
+207 -65

No files matched your search

+11
View File
@@ -360,6 +360,17 @@ async function buildAnalyticsResponse(user: AuthedUser, locale: Locale) {
}),
],
);
// The pillar is graded against the CLINICAL band even when the user
// keeps a personal target (that band is the number's identity; the
// personal one is displayed beside it and scores nothing). So when the
// two differ we re-run the whole helper against the clinical targets
// and the score comes from that second run.
//
// v1.34.5 — the "why this number" basis rides the same envelope, so it
// is the same run, the same representative pair and the same ceilings
// by construction. Assign envelopes WHOLE here; picking `graded.score`
// off one run and a basis off the other would let the popover explain
// a number nobody is looking at, and it would look correct in review.
if (bpTargetsEqual(bpTargets, clinicalBpTargets)) {
bpEnvelope = windows;
bpEnvelopePriorWeek = windowsPriorWeek;
+36 -29
View File
@@ -22,49 +22,58 @@ const UNDER65: BpTargets = {
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 = gradeBpScore({ sys: 134, dia: 87, target: UNDER65 });
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(
gradeBpScore({ sys: 120, dia: 78, target: UNDER65 }),
bpScore({ sys: 120, dia: 78, target: UNDER65 }),
).toBeGreaterThanOrEqual(85);
});
it("grades a textbook-normal 118/76 very high (≈100)", () => {
expect(
gradeBpScore({ sys: 118, dia: 76, target: UNDER65 }),
bpScore({ sys: 118, dia: 76, target: UNDER65 }),
).toBeGreaterThanOrEqual(88);
});
it("grades an uncontrolled 160/100 at or below 30", () => {
expect(
gradeBpScore({ sys: 160, dia: 100, target: UNDER65 }),
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 = gradeBpScore({ sys: 118, dia: 78, target: UNDER65 });
const diaHigh = gradeBpScore({ sys: 118, dia: 95, target: UNDER65 });
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 = gradeBpScore({ sys: 130, dia: 82, target: UNDER65 });
const b = gradeBpScore({ sys: 140, dia: 88, target: UNDER65 });
const c = gradeBpScore({ sys: 150, dia: 95, target: UNDER65 });
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 = gradeBpScore({ sys: 129, dia: 79, target: UNDER65 });
const justOver = gradeBpScore({ sys: 130, dia: 79, target: UNDER65 });
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);
});
@@ -73,11 +82,11 @@ describe("gradeBpScore", () => {
// 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 = gradeBpScore({ sys: 85, dia: 45, target: UNDER65 });
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 = gradeBpScore({ sys: 70, dia: 40, target: UNDER65 });
const veryLow = bpScore({ sys: 70, dia: 40, target: UNDER65 });
expect(veryLow).toBeLessThan(low);
expect(veryLow).toBeLessThanOrEqual(45);
});
@@ -90,7 +99,7 @@ describe("gradeBpScore", () => {
// 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) =>
gradeBpScore({ sys, dia: 66, target: UNDER65 }),
bpScore({ sys, dia: 66, target: UNDER65 }),
);
// AT and ABOVE the floor sit on the plateau (100).
expect(sysWalk[2]).toBe(100); // sys 90
@@ -109,7 +118,7 @@ describe("gradeBpScore", () => {
// 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) =>
gradeBpScore({ sys: 117, dia, target: UNDER65 }),
bpScore({ sys: 117, dia, target: UNDER65 }),
);
expect(diaWalk[2]).toBe(100); // dia 50
expect(diaWalk[1]).toBe(100); // dia 51
@@ -124,19 +133,19 @@ describe("gradeBpScore", () => {
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(gradeBpScore({ sys: 90, dia: 66, target: UNDER65 })).toBe(100);
expect(gradeBpScore({ sys: 80, dia: 66, target: UNDER65 })).toBe(70);
expect(gradeBpScore({ sys: 70, dia: 66, target: UNDER65 })).toBe(45);
expect(gradeBpScore({ sys: 60, dia: 66, target: UNDER65 })).toBe(20);
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(
gradeBpScore({ sys: 30, dia: 20, target: UNDER65 }),
bpScore({ sys: 30, dia: 20, target: UNDER65 }),
).toBeGreaterThanOrEqual(0);
});
it("preserves the audit fairness anchors (134/87 → 57, 165/105 → 22)", () => {
expect(gradeBpScore({ sys: 134, dia: 87, target: UNDER65 })).toBe(57);
expect(gradeBpScore({ sys: 165, dia: 105, target: UNDER65 })).toBe(22);
expect(bpScore({ sys: 134, dia: 87, target: UNDER65 })).toBe(57);
expect(bpScore({ sys: 165, dia: 105, target: UNDER65 })).toBe(22);
});
});
@@ -169,7 +178,7 @@ describe("gradeBpScoreFromSeries", () => {
// recency-weighted representative should read close to the recent
// well-controlled cluster.
expect(recencyWeighted).not.toBeNull();
expect(recencyWeighted as number).toBeGreaterThanOrEqual(80);
expect(recencyWeighted!.score).toBeGreaterThanOrEqual(80);
});
it("matches the single-reading grade when every pair is today", () => {
@@ -177,9 +186,9 @@ describe("gradeBpScoreFromSeries", () => {
{ at: NOW, sys: 134, dia: 87 },
{ at: NOW, sys: 134, dia: 87 },
];
expect(gradeBpScoreFromSeries({ pairs, target: UNDER65, now: NOW })).toBe(
gradeBpScore({ sys: 134, dia: 87, target: UNDER65 }),
);
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", () => {
@@ -218,8 +227,6 @@ describe("gradeBpScoreFromSeries", () => {
});
expect(live).not.toBeNull();
expect(rollup).not.toBeNull();
expect(Math.abs((live as number) - (rollup as number))).toBeLessThanOrEqual(
1,
);
expect(Math.abs(live!.score - rollup!.score)).toBeLessThanOrEqual(1);
});
});
+96 -18
View File
@@ -84,6 +84,49 @@ const HYPO_ANCHORS: ReadonlyArray<readonly [below: number, score: number]> = [
[40, 5],
] as const;
/**
* Which axis set the score, measured against which boundary, and by how
* far. Published with the score (never beside it) so the surface can
* answer "why this number" without recomputing anything client-side.
*
* `relation` is a trichotomy on the binding axis' own value:
*
* value < floor → `below_floor` (boundary = floor)
* value <= ceiling → `in_band` (boundary = ceiling)
* otherwise → `above_ceiling` (boundary = ceiling)
*
* so the three relations are exhaustive and mutually exclusive by
* construction, whatever the anchors say. `offsetMmHg` is the distance
* from that boundary as a NON-NEGATIVE integer (rounded); the relation
* carries the direction, so the number never needs a sign. The relation
* is decided on the unrounded value, so it always agrees with the score
* even when the rounded offset reads 0.
*/
export type BpScoreAxis = "systolic" | "diastolic";
export type BpScoreRelation = "above_ceiling" | "below_floor" | "in_band";
export interface BpScoreBasis {
/** The axis whose sub-score the pillar took — the worse of the two. */
axis: BpScoreAxis;
relation: BpScoreRelation;
/** Rounded, non-negative distance from `boundaryMmHg`. */
offsetMmHg: number;
/** The ceiling the axis was graded against, or its hypotension floor. */
boundaryMmHg: number;
}
/**
* A graded BP reading: the score AND the basis that produced it, as one
* value. They are returned together, and carried together through every
* envelope down to the pillar, precisely so no caller can ever pair a
* score from one run with a basis from another (different representative
* pair, or — the live trap — different targets).
*/
export interface BpGrade {
score: number;
basis: BpScoreBasis;
}
/**
* Generic piecewise-linear interpolation over a monotonic-x anchor table.
* Clamps to the first / last anchor outside the table range.
@@ -127,38 +170,63 @@ function interpolateAxisScore(offset: number): number {
* the over-target branch holds at the floor, then descends smoothly —
* no boundary cliff.
*/
function gradeAxis(value: number, ceiling: number, floor: number): number {
function gradeAxis(
value: number,
ceiling: number,
floor: number,
): { score: number; relation: BpScoreRelation; boundary: number } {
if (value < floor) {
return interpolateAnchors(HYPO_ANCHORS, floor - value);
return {
score: interpolateAnchors(HYPO_ANCHORS, floor - value),
relation: "below_floor",
boundary: floor,
};
}
return interpolateAxisScore(value - ceiling);
const offset = value - ceiling;
return {
score: interpolateAxisScore(offset),
relation: offset > 0 ? "above_ceiling" : "in_band",
boundary: ceiling,
};
}
/**
* Map a representative blood-pressure reading to a smooth 0-100 score.
* Map a representative blood-pressure reading to a smooth 0-100 score,
* together with the basis that produced it.
*
* Takes the WORSE of the systolic and diastolic axis sub-scores so a
* high diastolic counts against the score (it should) without
* binary-zeroing the result the way the all-time in-target rate did.
*
* @returns integer 0-100.
* **Tie rule**: when both axes grade identically, the basis names the
* SYSTOLIC axis. The two are interchangeable for the number, so the
* choice is arbitrary in arithmetic but must not be arbitrary in the
* text; systolic is the axis clinicians lead with and the one the app
* shows first everywhere else.
*
* @returns integer score 0-100 plus the binding axis, its relation to
* its boundary, and the distance to it.
*/
export function gradeBpScore(input: {
sys: number;
dia: number;
target: BpTargets;
}): number {
const sysScore = gradeAxis(
input.sys,
input.target.sysHigh,
SYS_HYPOTENSION_FLOOR,
);
const diaScore = gradeAxis(
input.dia,
input.target.diaHigh,
DIA_HYPOTENSION_FLOOR,
);
return Math.round(Math.max(0, Math.min(100, Math.min(sysScore, diaScore))));
}): BpGrade {
const sys = gradeAxis(input.sys, input.target.sysHigh, SYS_HYPOTENSION_FLOOR);
const dia = gradeAxis(input.dia, input.target.diaHigh, DIA_HYPOTENSION_FLOOR);
// `<=` puts a tie on systolic — see the tie rule above.
const bindingIsSystolic = sys.score <= dia.score;
const binding = bindingIsSystolic ? sys : dia;
const bindingValue = bindingIsSystolic ? input.sys : input.dia;
return {
score: Math.round(Math.max(0, Math.min(100, binding.score))),
basis: {
axis: bindingIsSystolic ? "systolic" : "diastolic",
relation: binding.relation,
offsetMmHg: Math.round(Math.abs(bindingValue - binding.boundary)),
boundaryMmHg: binding.boundary,
},
};
}
/**
@@ -228,11 +296,21 @@ export function representativeBpFromSeries(input: {
};
}
/**
* Grade the recency-weighted representative of a pair series.
*
* Returns the score and its basis as ONE value. Callers must carry that
* value on, never split it: a score stored beside a basis that came from
* a second evaluation (or from a second set of targets — the analytics
* route deliberately re-runs this with the CLINICAL targets when the user
* has a personal one) would let the surface explain a number that is not
* the number on screen.
*/
export function gradeBpScoreFromSeries(input: {
pairs: ReadonlyArray<BpPairPoint>;
target: BpTargets;
now: Date;
}): number | null {
}): BpGrade | null {
const representative = representativeBpFromSeries(input);
return representative
? gradeBpScore({ ...representative, target: input.target })
+18 -7
View File
@@ -69,7 +69,11 @@ import {
isBpReadingInTarget,
type BpReading,
} from "./bp-in-target";
import { gradeBpScoreFromSeries, representativeBpFromSeries } from "./bp-grade";
import {
gradeBpScoreFromSeries,
representativeBpFromSeries,
type BpGrade,
} from "./bp-grade";
import type { BpTargets } from "./bp-targets";
/**
@@ -109,9 +113,16 @@ export interface BpInTargetEnvelope {
* recency-weighted representative reading over the trailing window.
* Feeds the Health-Score BP pillar value, replacing the binary
* in-target rate AS THE SCORE. `null` when no pairs formed.
*
* v1.34.5 — the score and the basis that explains it travel as ONE
* object, from the one `gradeBpScoreFromSeries` call that produced
* both. The route re-runs this whole helper against the clinical
* targets when the user has a personal target; carrying the pair
* together is what makes it impossible for the explanation to belong
* to a different run than the number.
*/
gradedScore: number | null;
/** Same recency-weighted pair that `gradedScore` grades. */
graded: BpGrade | null;
/** Same recency-weighted pair that `graded` grades. */
representative: { sys: number; dia: number } | null;
/** Freshest paired reading in the trailing 90-day score window. */
last90LatestAt: Date | null;
@@ -377,7 +388,7 @@ async function computeFromRollups(
dia: pair.dia,
count: pair.perDayPairCount,
}));
const gradedScore = gradeBpScoreFromSeries({
const graded = gradeBpScoreFromSeries({
pairs: scorePairs,
target: targets,
now,
@@ -398,7 +409,7 @@ async function computeFromRollups(
priorYear,
path: "rollup",
rowCount,
gradedScore,
graded,
representative,
};
}
@@ -545,7 +556,7 @@ async function computeFromLive(
const scorePairs = collectBpPairs(sysData, diaData, userTz).filter(
(pair) => pair.at >= new Date(now.getTime() - 90 * DAY_MS) && pair.at < now,
);
const gradedScore = gradeBpScoreFromSeries({
const graded = gradeBpScoreFromSeries({
pairs: scorePairs,
target: targets,
now,
@@ -568,7 +579,7 @@ async function computeFromLive(
priorYear: windows.priorYear,
path: "live",
rowCount,
gradedScore,
graded,
representative,
};
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { gradeBpScore } from "@/lib/analytics/bp-grade";
import { computeActivityPillar } from "../activity";
import { computeAdiposityPillar } from "../adiposity";
import { computeBloodPressurePillar } from "../blood-pressure";
@@ -30,7 +31,11 @@ describe("reference-score pillars", () => {
const below = computeBloodPressurePillar({
...live,
pairCount: 11,
gradedScore: 85,
graded: gradeBpScore({
sys: 129,
dia: 79,
target: { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 },
}),
representative: { sys: 129, dia: 79 },
oldestAt: new Date("2026-07-01T08:00:00Z"),
latestAt: new Date("2026-07-27T08:00:00Z"),
@@ -44,7 +49,11 @@ describe("reference-score pillars", () => {
const eligible = computeBloodPressurePillar({
...live,
pairCount: 12,
gradedScore: 85,
graded: gradeBpScore({
sys: 129,
dia: 79,
target: { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 },
}),
representative: { sys: 129, dia: 79 },
oldestAt: new Date("2026-07-01T08:00:00Z"),
latestAt: new Date("2026-07-27T08:00:00Z"),
@@ -63,7 +72,11 @@ describe("reference-score pillars", () => {
const result = computeBloodPressurePillar({
...live,
pairCount: 12,
gradedScore: 85,
graded: gradeBpScore({
sys: 129,
dia: 79,
target: { sysLow: 120, sysHigh: 129, diaLow: 70, diaHigh: 79 },
}),
representative: { sys: 129, dia: 79 },
oldestAt: new Date("2026-07-01T08:00:00Z"),
latestAt: new Date("2026-07-27T08:00:00Z"),
+5 -2
View File
@@ -48,7 +48,7 @@ export function computeBloodPressurePillar(
if (
presentPairs < BLOOD_PRESSURE_MIN_PAIRS ||
input.gradedScore == null ||
input.graded == null ||
input.representative == null ||
input.latestAt == null
) {
@@ -81,7 +81,10 @@ export function computeBloodPressurePillar(
historyDays,
}),
value: {
score: input.gradedScore,
score: input.graded.score,
// Same object the score came out of, so the popover can only ever
// explain the number beside it.
scoreBasis: input.graded.basis,
observed: {
value: representativeSys,
unit: "mmHg",
+11 -5
View File
@@ -11,6 +11,7 @@ import { collectBpPairs } from "@/lib/analytics/bp-in-target";
import {
gradeBpScoreFromSeries,
representativeBpFromSeries,
type BpGrade,
} from "@/lib/analytics/bp-grade";
import type { BpTargets } from "@/lib/analytics/bp-targets";
import { resolveWeightTargetOverride } from "@/lib/analytics/effective-range";
@@ -86,7 +87,12 @@ interface ActivityRead {
interface ScoreBpEnvelope {
path: "rollup" | "live" | undefined;
last90Days: { pairs: number } | null;
gradedScore: number | null;
/**
* v1.34.5 — score AND basis as one value, straight off the envelope
* the route built. Never re-graded here: a second evaluation is how
* the popover would end up explaining a different number.
*/
graded: BpGrade | null;
representative: { sys: number; dia: number } | null;
last90EarliestAt: Date | null;
last90LatestAt: Date | null;
@@ -329,7 +335,7 @@ function scoreBpEnvelope(args: {
envelope: {
path: args.base.path,
last90Days: args.base.last90Days,
gradedScore: args.base.gradedScore,
graded: args.base.graded,
representative: args.base.representative,
last90EarliestAt: args.base.last90EarliestAt,
last90LatestAt: args.base.last90LatestAt,
@@ -352,7 +358,7 @@ function scoreBpEnvelope(args: {
envelope: {
path: undefined,
last90Days: null,
gradedScore: null,
graded: null,
representative: null,
last90EarliestAt: null,
last90LatestAt: null,
@@ -385,7 +391,7 @@ function scoreBpEnvelope(args: {
envelope: {
path: "live",
last90Days: { pairs: pairs.length },
gradedScore: target
graded: target
? gradeBpScoreFromSeries({
pairs,
target,
@@ -492,7 +498,7 @@ function scoreInputsFor(args: {
readFailed: args.bp.readFailed,
asOf,
pairCount: args.bp.last90Days?.pairs ?? 0,
gradedScore: args.bp.gradedScore,
graded: args.bp.graded,
representative: args.bp.representative,
oldestAt: args.bp.last90EarliestAt,
latestAt: args.bp.last90LatestAt,
+14 -1
View File
@@ -1,4 +1,5 @@
import type { BpTargets } from "@/lib/analytics/bp-targets";
import type { BpGrade, BpScoreBasis } from "@/lib/analytics/bp-grade";
import type {
Derived,
DerivedProvenanceSource,
@@ -56,6 +57,14 @@ export interface PillarValue {
reference: PillarReference;
/** Optional user-authored yardstick, displayed beside the scored reference. */
personalReference?: PillarReference;
/**
* Which input set this score and by how much, resolved on the server so
* no client ever re-derives it. Blood pressure only for now: it is the
* one pillar that scores the WORSE of two axes, so the number alone
* cannot say which one it came from. Optional by design — a pillar that
* has nothing of the kind to say says nothing.
*/
scoreBasis?: BpScoreBasis;
/** Score points that a weekly move must clear before it is narrated. */
noiseFloor: number;
/** Slow markers contribute to level but never to the weekly delta. */
@@ -125,7 +134,11 @@ export interface DomainReadState {
export interface BloodPressurePillarInput extends DomainReadState {
asOf: Date;
pairCount: number;
gradedScore: number | null;
/**
* The graded score together with the basis that produced it — one
* value, from one grading run against one set of targets.
*/
graded: BpGrade | null;
representative: { sys: number; dia: number } | null;
oldestAt: Date | null;
latestAt: Date | null;