fix(health-score): smooth the hypotension curve and align rollup and live grading

Close four findings from the health-score audit:

- Hypotension cliff: the below-floor branch reused the over-target table,
  so 1 mmHg under the clinical floor dropped from 100 to ~81 — a 19-point
  discontinuity. A dedicated under-floor curve now starts at 100 AT the
  floor and descends smoothly (floor-10->70, -20->45, -30->20), so the
  score is continuous on both sides of every boundary on both axes.
- Rollup vs live divergence: the graded BP score weighed a high-variance
  day 1:1 on the rollup path (one per-day-mean pair) but N:1 on the live
  path (N per-event pairs), drifting the pillar by up to ~20 points
  depending on DAY-bucket warmth. gradeBpScoreFromSeries now takes an
  optional per-pair count; the rollup path passes perDayPairCount so both
  paths produce the same score for the same data.
- Resting proxy degenerate days: a day with fewer than three PULSE
  samples no longer contributes a resting estimate, so a lone workout
  reading can't masquerade as resting HR.
- Pulse-status proxy day boundary: thread the user's timezone into the
  resting-proxy day-key so it buckets identically to the targets route.

Fairness anchors unchanged (134/87 -> 57, 165/105 -> 22).
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 18:16:38 +02:00
1 parent 9ac5df42c8
commit e7641288fe
6 files changed
+240 -27

No files matched your search

+106 -3
View File
@@ -69,10 +69,74 @@ describe("gradeBpScore", () => {
expect(at).toBeGreaterThanOrEqual(justOver);
});
it("penalises hypotension symmetrically (below the clinical floor)", () => {
// 85/45 is below both floors (sys 90 / dia 50) — should not read ~100.
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 = gradeBpScore({ sys: 85, dia: 45, target: UNDER65 });
expect(low).toBeLessThan(85);
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 });
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) => gradeBpScore({ 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) => gradeBpScore({ 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(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);
// Never negative on an extreme low.
expect(
gradeBpScore({ 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);
});
});
@@ -117,4 +181,43 @@ describe("gradeBpScoreFromSeries", () => {
gradeBpScoreFromSeries({ pairs, target: UNDER65, now: NOW }),
).toBe(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 as number) - (rollup as number))).toBeLessThanOrEqual(1);
});
});
@@ -61,10 +61,15 @@ describe("deriveRestingProxyFromPulse", () => {
});
it("buckets by day and returns one proxy point per day, sorted", () => {
// Each day needs enough samples (≥ 3) to clear the degenerate-day
// guard so it can contribute a proxy point.
const samples: PulseSample[] = [
day("2026-06-03T08:00:00", 72),
day("2026-06-03T12:00:00", 80),
day("2026-06-03T20:00:00", 90),
day("2026-06-01T08:00:00", 60),
day("2026-06-01T12:00:00", 64),
day("2026-06-01T20:00:00", 68),
];
const proxy = deriveRestingProxyFromPulse(samples);
expect(proxy).toHaveLength(2);
@@ -73,6 +78,27 @@ describe("deriveRestingProxyFromPulse", () => {
);
});
it("excludes a single-sample workout day — no resting estimate from one high reading", () => {
// Audit LOW-1: a day with only one PULSE sample (e.g. a lone workout
// reading) must NOT emit that value verbatim as "resting".
const oneSampleWorkoutDay: PulseSample[] = [
day("2026-06-05T18:00:00", 165),
];
expect(deriveRestingProxyFromPulse(oneSampleWorkoutDay)).toEqual([]);
// A day below the min-sample floor is dropped; only the day with enough
// samples contributes.
const mixed: PulseSample[] = [
day("2026-06-05T18:00:00", 165), // single workout sample → dropped
day("2026-06-06T07:00:00", 60),
day("2026-06-06T08:00:00", 62),
day("2026-06-06T09:00:00", 64),
];
const proxy = deriveRestingProxyFromPulse(mixed);
expect(proxy).toHaveLength(1);
expect(proxy[0].value).toBeLessThan(150);
});
it("returns empty for no samples", () => {
expect(deriveRestingProxyFromPulse([])).toEqual([]);
});
+71 -22
View File
@@ -37,11 +37,16 @@
* and systolic axes share the same offset→score table, so each axis is
* judged against its own ceiling.
*
* Hypotension is penalised symmetrically: below the clinical floors
* (`sys ≥ 90`, `dia ≥ 50`, mirroring `isBpReadingInTarget`) the distance
* below the floor is fed through the same over-target curve, so a
* symptomatically low reading scores like an equally-distant high one
* rather than reading as "perfect".
* Hypotension is penalised on a SEPARATE under-floor curve that is
* CONTINUOUS with the optimal plateau at the boundary: at exactly the
* clinical floor (`sys = 90`, `dia = 50`, mirroring `isBpReadingInTarget`)
* the score is 100, and it descends smoothly as the value drops further
* below the floor. The earlier implementation fed `floor − value` through
* the over-target table, which made 1 mmHg under the floor jump to ~81 —
* a 19-point cliff against the 100 the plateau holds AT the floor. The
* dedicated anchors below (floor→100, floor−10→70, floor−20→45,
* floor−30→20) mirror the over-target steepness without the cliff, so the
* curve is continuous on BOTH sides of every boundary.
*
* Pure & deterministic — fully unit-tested in `__tests__/bp-grade.test.ts`.
*/
@@ -66,25 +71,52 @@ const AXIS_ANCHORS: ReadonlyArray<readonly [offset: number, score: number]> = [
[60, 5],
] as const;
/**
* Below-floor hypotension anchors. `below` is mmHg the value sits UNDER
* the clinical floor; the score starts at 100 AT the floor (`below = 0`)
* so the curve is continuous with the optimal plateau the over-target
* branch holds at the floor, then descends to mirror the over-target
* steepness. Interpolated piecewise-linearly; clamps below the last
* anchor.
*/
const HYPO_ANCHORS: ReadonlyArray<readonly [below: number, score: number]> = [
[0, 100],
[10, 70],
[20, 45],
[30, 20],
[40, 5],
] as const;
/**
* Generic piecewise-linear interpolation over a monotonic-x anchor table.
* Clamps to the first / last anchor outside the table range.
*/
function interpolateAnchors(
anchors: ReadonlyArray<readonly [number, number]>,
x: number,
): number {
const first = anchors[0];
const last = anchors[anchors.length - 1];
if (x <= first[0]) return first[1];
if (x >= last[0]) return last[1];
for (let i = 0; i < anchors.length - 1; i++) {
const [x1, y1] = anchors[i];
const [x2, y2] = anchors[i + 1];
if (x >= x1 && x <= x2) {
const fraction = (x - x1) / (x2 - x1);
return y1 + fraction * (y2 - y1);
}
}
return last[1];
}
/**
* Piecewise-linear interpolation of an over-/under-target `offset`
* (mmHg relative to the axis ceiling) onto the 0-100 axis score.
* Clamps to the first / last anchor outside the table range.
*/
function interpolateAxisScore(offset: number): number {
const first = AXIS_ANCHORS[0];
const last = AXIS_ANCHORS[AXIS_ANCHORS.length - 1];
if (offset <= first[0]) return first[1];
if (offset >= last[0]) return last[1];
for (let i = 0; i < AXIS_ANCHORS.length - 1; i++) {
const [o1, s1] = AXIS_ANCHORS[i];
const [o2, s2] = AXIS_ANCHORS[i + 1];
if (offset >= o1 && offset <= o2) {
const fraction = (offset - o1) / (o2 - o1);
return s1 + fraction * (s2 - s1);
}
}
return last[1];
return interpolateAnchors(AXIS_ANCHORS, offset);
}
/**
@@ -93,12 +125,14 @@ function interpolateAxisScore(offset: number): number {
* - Above the floor: offset = `value - ceiling`. Negative offsets sit in
* the optimal plateau, positive ones climb the over-target curve.
* - Below the hypotension floor: the distance below the floor is fed
* through the same over-target curve so a low excursion is penalised
* symmetrically to an equally-distant high one.
* through the dedicated `HYPO_ANCHORS` curve, which starts at 100 AT
* the floor (`below = 0`) so the score is continuous with the plateau
* the over-target branch holds at the floor, then descends smoothly —
* no boundary cliff.
*/
function gradeAxis(value: number, ceiling: number, floor: number): number {
if (value < floor) {
return interpolateAxisScore(floor - value);
return interpolateAnchors(HYPO_ANCHORS, floor - value);
}
return interpolateAxisScore(value - ceiling);
}
@@ -138,6 +172,17 @@ export interface BpPairPoint {
at: Date;
sys: number;
dia: number;
/**
* v1.15.12 — how many underlying readings this point stands for.
* Defaults to 1 (one per-event pair). The rollup path collapses a day
* into a single per-day-MEAN pair and passes `perDayPairCount` here so
* a 4-reading day's mean counts 4× in the recency-weighted grade,
* matching the live per-event behaviour. Without it a high-variance
* day weighed 1:1 on rollup but N:1 on live, diverging the BP pillar by
* up to ~20 points for the same data. The recency weight MULTIPLIES
* this count.
*/
count?: number;
}
/**
@@ -171,7 +216,11 @@ export function gradeBpScoreFromSeries(input: {
let diaWeighted = 0;
for (const p of pairs) {
const ageDays = Math.max(0, (nowMs - p.at.getTime()) / DAY_MS);
const weight = Math.pow(0.5, ageDays / HALF_LIFE_DAYS);
// The recency decay multiplies the per-point reading count so a
// per-day-mean pair (rollup path) carries the same weight as the N
// per-event pairs it stands for (live path).
const count = p.count ?? 1;
const weight = Math.pow(0.5, ageDays / HALF_LIFE_DAYS) * count;
weightSum += weight;
sysWeighted += weight * p.sys;
diaWeighted += weight * p.dia;
+13 -1
View File
@@ -318,8 +318,20 @@ async function computeFromRollups(
// contributes weighted by its age; recent readings dominate so genuine
// improvement surfaces in the score instead of being diluted by years
// of older readings.
//
// v1.15.12 (audit HIGH-2) — pass `perDayPairCount` as the per-point
// `count` weight so a high-variance day weighs N× (matching the live
// per-event grade) instead of 1×. Without it the rollup and live paths
// produced different graded scores for the same data depending on
// DAY-bucket warmth (the in-target RATE already multiplied by
// perDayPairCount; the graded score now does the equivalent).
const gradedScore = gradeBpScoreFromSeries({
pairs: pairsByDay.map((p) => ({ at: p.day, sys: p.sys, dia: p.dia })),
pairs: pairsByDay.map((p) => ({
at: p.day,
sys: p.sys,
dia: p.dia,
count: p.perDayPairCount,
})),
target: targets,
now,
});
+14
View File
@@ -54,6 +54,16 @@ export interface PulseSample {
*/
export const RESTING_PROXY_DAILY_PERCENTILE = 20;
/**
* Minimum PULSE samples a day needs before it can contribute a resting
* proxy. A workout-only or single-sample day (n = 1) would otherwise emit
* that one sample verbatim — a workout-level value masquerading as
* "resting". Below this floor the day is skipped rather than poisoning the
* proxy series with a high reading; a real resting day has many ambient
* samples and clears it easily.
*/
export const RESTING_PROXY_MIN_DAILY_SAMPLES = 3;
/**
* Collapse raw `PULSE` samples into a per-Berlin-day resting-proxy
* series: one `{ measuredAt, value }` per day where `value` is the day's
@@ -87,6 +97,10 @@ export function deriveRestingProxyFromPulse(
}
const out: PulseSample[] = [];
for (const { firstAt, values } of byDay.values()) {
// Skip degenerate days: too few samples to distinguish a resting floor
// from a lone workout reading. Emitting the verbatim value would leak a
// workout-level number into the "resting" series.
if (values.length < RESTING_PROXY_MIN_DAILY_SAMPLES) continue;
out.push({
measuredAt: firstAt,
value: Math.round(percentile(values, RESTING_PROXY_DAILY_PERCENTILE)),
+10 -1
View File
@@ -42,7 +42,7 @@ import {
} from "@/lib/insights/status-cache";
import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
import { toBerlinDayKey, userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
export async function generatePulseStatusForUser(
userId: string,
@@ -114,9 +114,17 @@ export async function generatePulseStatusForUser(
select: {
dateOfBirth: true,
gender: true,
timezone: true,
},
});
// v1.15.12 (audit LOW-2) — bucket the resting-pulse proxy on the user's
// own day boundary so this surface aligns with the targets route, which
// passes `userDayKey(d, userTz)`. Without it the proxy bucketed on
// Berlin-day here and on the user's TZ there, drifting the resting
// estimate for non-Berlin users.
const userTz = user?.timezone ?? DEFAULT_TIMEZONE;
// v1.4.28 FB-D2 — cap the snapshot input. The downstream
// `applyPayloadBudget` trims further but the unbounded findMany was
// pulling tens of thousands of rows for Apple-Health-rich accounts
@@ -213,6 +221,7 @@ export async function generatePulseStatusForUser(
measuredAt: m.measuredAt,
value: m.value,
})),
dayKeyOf: (d: Date) => userDayKey(d, userTz),
});
const thirtyDaysAgoMs = now.getTime() - 30 * 24 * 60 * 60 * 1000;
const recentResting = restingResolved.series.filter(