From e7641288fea822ca617cda45aee5b95de889bee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 18:16:38 +0200 Subject: [PATCH] fix(health-score): smooth the hypotension curve and align rollup and live grading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/lib/analytics/__tests__/bp-grade.test.ts | 109 +++++++++++++++++- .../analytics/__tests__/resting-pulse.test.ts | 26 +++++ src/lib/analytics/bp-grade.ts | 93 +++++++++++---- src/lib/analytics/bp-in-target-fast-path.ts | 14 ++- src/lib/analytics/resting-pulse.ts | 14 +++ src/lib/insights/pulse-status.ts | 11 +- 6 files changed, 240 insertions(+), 27 deletions(-) diff --git a/src/lib/analytics/__tests__/bp-grade.test.ts b/src/lib/analytics/__tests__/bp-grade.test.ts index 19f9be680..8161069a8 100644 --- a/src/lib/analytics/__tests__/bp-grade.test.ts +++ b/src/lib/analytics/__tests__/bp-grade.test.ts @@ -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); + }); }); diff --git a/src/lib/analytics/__tests__/resting-pulse.test.ts b/src/lib/analytics/__tests__/resting-pulse.test.ts index 0304209ff..7142dae47 100644 --- a/src/lib/analytics/__tests__/resting-pulse.test.ts +++ b/src/lib/analytics/__tests__/resting-pulse.test.ts @@ -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([]); }); diff --git a/src/lib/analytics/bp-grade.ts b/src/lib/analytics/bp-grade.ts index 4411c1b15..8a9fe11c3 100644 --- a/src/lib/analytics/bp-grade.ts +++ b/src/lib/analytics/bp-grade.ts @@ -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 = [ [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 = [ + [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, + 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; diff --git a/src/lib/analytics/bp-in-target-fast-path.ts b/src/lib/analytics/bp-in-target-fast-path.ts index 8aa90a244..a901cf2da 100644 --- a/src/lib/analytics/bp-in-target-fast-path.ts +++ b/src/lib/analytics/bp-in-target-fast-path.ts @@ -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, }); diff --git a/src/lib/analytics/resting-pulse.ts b/src/lib/analytics/resting-pulse.ts index 2bb1e725a..eb6c203bc 100644 --- a/src/lib/analytics/resting-pulse.ts +++ b/src/lib/analytics/resting-pulse.ts @@ -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)), diff --git a/src/lib/insights/pulse-status.ts b/src/lib/insights/pulse-status.ts index 1a6d38e69..4eb63d8a7 100644 --- a/src/lib/insights/pulse-status.ts +++ b/src/lib/insights/pulse-status.ts @@ -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(