mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
9ac5df42c8
commit
e7641288fe
6 files changed
+240
-27
No files matched your search
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user