From 2723ac5b989bb2f41f3c439a10e29c49e2f4e72c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 23:03:03 +0200 Subject: [PATCH] fix(insights): keep plateaus and drifts out of the level-shift scan Each scanned vital gets a minimum level change and a spread floor at about its clinical and device resolution, so a VO2 max estimate that steps by 0.3 or a clean 2 bpm change no longer reads as a shift. A series a straight line explains at least as well as a step is a drift, not a dated shift. The magnitude is capped at 10 so the flattest series cannot win the ranking. --- .../__tests__/changepoint-extended.test.ts | 92 +++++++++++++++++ src/lib/insights/derived/changepoint.ts | 98 ++++++++++++++++++- 2 files changed, 187 insertions(+), 3 deletions(-) diff --git a/src/lib/insights/derived/__tests__/changepoint-extended.test.ts b/src/lib/insights/derived/__tests__/changepoint-extended.test.ts index edfcfe85b..f5314f533 100644 --- a/src/lib/insights/derived/__tests__/changepoint-extended.test.ts +++ b/src/lib/insights/derived/__tests__/changepoint-extended.test.ts @@ -11,7 +11,10 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({ import { buildChangepointSignals, buildExtendedChangepointSignals, + CHANGEPOINT_FLOORS, CHANGEPOINT_WINDOW_DAYS, + detectLevelShift, + MAX_MAGNITUDE, EXTENDED_CHANGEPOINT_METRICS, EXTENDED_CHANGEPOINT_WINDOW_DAYS, } from "../changepoint"; @@ -103,3 +106,92 @@ describe("buildChangepointSignals (the Coach's scan)", () => { ]); }); }); + +/** One metric's series, every other metric empty. */ +function only(metric: string, means: number[]) { + readDayMeanSeries.mockImplementation(async (_u: string, m: string) => + m === metric + ? { + points: means.map((mean, i) => ({ + day: shiftDateKey("2026-04-01", i), + mean, + })), + source: "DAY", + } + : { points: [], source: "DAY" }, + ); +} + +describe("false-positive floors", () => { + it("gives every extended metric a floor", () => { + for (const metric of EXTENDED_CHANGEPOINT_METRICS) { + expect(CHANGEPOINT_FLOORS[metric], metric).toBeDefined(); + } + }); + + it("does not call a VO2 max estimate stepping by 0.3 a shift", async () => { + // 60 days at 40.0, 60 at 40.3: each plateau flat, so the raw detector + // sees a near-infinite magnitude. + const plateau = [ + ...Array(60).fill(40), + ...Array(60).fill(40.3), + ]; + expect(detectLevelShift(plateau)).not.toBeNull(); + only("VO2_MAX", plateau); + expect(await buildExtendedChangepointSignals("u1", NOW, {})).toEqual([]); + // A real change in the estimate still fires. + only("VO2_MAX", [ + ...Array(60).fill(40), + ...Array(60).fill(43), + ]); + // Measured against the 0.5 resolution floor, not a spread of zero: + // 3 / 0.5 = 6 σ, not the cap. + expect(await buildExtendedChangepointSignals("u1", NOW, {})).toEqual([ + expect.objectContaining({ + metric: "VO2_MAX", + direction: "up", + magnitude: 6, + }), + ]); + }); + + it("needs a change worth noting, not just a clean one", async () => { + // Resting heart rate 60 → 62: clean, but under the 3 bpm floor. + only("RESTING_HEART_RATE", [ + ...Array(60).fill(60), + ...Array(60).fill(62), + ]); + expect(await buildExtendedChangepointSignals("u1", NOW, {})).toEqual([]); + only("RESTING_HEART_RATE", [ + ...Array(60).fill(60), + ...Array(60).fill(64), + ]); + expect(await buildExtendedChangepointSignals("u1", NOW, {})).toHaveLength( + 1, + ); + }); + + it("reads a slow drift as a drift, not a dated shift", () => { + // Resting heart rate creeping from 55 to 65 over 120 days, ±0.6 noise. + const drift = Array.from({ length: 120 }, (_, i) => { + const jitter = (((i * 7919) % 100) / 100 - 0.5) * 1.2; + return 55 + (10 * i) / 119 + jitter; + }); + expect( + detectLevelShift(drift, { minAbsDelta: 3, spreadFloor: 1 }), + ).toBeNull(); + // The same rise as a step does fire. + const stepped = drift.map((_, i) => (i < 60 ? 55 : 65) + (i % 3) * 0.3); + expect( + detectLevelShift(stepped, { minAbsDelta: 3, spreadFloor: 1 }), + ).not.toBeNull(); + }); + + it("bounds the magnitude, so the flattest series cannot win the ranking", () => { + const shift = detectLevelShift([ + ...Array(30).fill(14), + ...Array(30).fill(17), + ]); + expect(shift?.magnitude).toBe(MAX_MAGNITUDE); + }); +}); diff --git a/src/lib/insights/derived/changepoint.ts b/src/lib/insights/derived/changepoint.ts index d7505c8c0..abbbdb39c 100644 --- a/src/lib/insights/derived/changepoint.ts +++ b/src/lib/insights/derived/changepoint.ts @@ -16,7 +16,18 @@ * - the post-break level must PERSIST (≥ `minPersistDays` after the break), * - the level change must exceed the metric's own robust spread * (|Δmean| ≥ `magnitudeK` · MAD · 1.4826), the personal yardstick, and - * - the series must be long enough overall. + * - the series must be long enough overall, + * - the step must explain the series better than a straight line does + * (a slow drift splits into two halves with different means, but it is a + * drift, not a shift), and + * - with the per-metric floors the scan passes ({@link CHANGEPOINT_FLOORS}): + * the step must reach a minimum absolute size, and the spread it is + * measured against never drops below the device / clinical resolution, + * so a device that reports in 0.3 steps cannot make a 0.3 plateau change + * look like a many-sigma event. + * + * `magnitude` is capped at {@link MAX_MAGNITUDE} so the cross-metric ranking + * is not won by whichever series happens to be the flattest. * * Below the bar the detector emits `null` and the Coach keeps saying "lately". * One changepoint per metric per window — no multi-break hunting in v1.22. @@ -30,8 +41,11 @@ import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage"; import { readDayMeanSeries } from "@/lib/insights/derived/baseline"; import { moduleForMeasurementType } from "@/lib/modules/measurement-scope"; import type { SurfaceModuleMap } from "@/lib/modules/surface"; +import { linearFit } from "@/lib/stats/linear-fit"; const MAD_TO_SIGMA = 1.4826; +/** Upper bound of the reported (and ranked) magnitude, in robust-σ units. */ +export const MAX_MAGNITUDE = 10; /** A validated level shift in a numeric series. */ export interface LevelShift { @@ -55,6 +69,10 @@ export interface DetectLevelShiftOptions { minPersistDays?: number; /** |Δmean| floor in robust-σ units. Default 1.4 (high firing bar). */ magnitudeK?: number; + /** |Δmean| floor in the metric's own unit. Default 0 (none). */ + minAbsDelta?: number; + /** Lower bound of the pooled spread, in the metric's unit. Default 0. */ + spreadFloor?: number; } function mean(xs: readonly number[]): number { @@ -94,6 +112,8 @@ export function detectLevelShift( const minSegmentLength = opts.minSegmentLength ?? 5; const minPersistDays = opts.minPersistDays ?? 10; const magnitudeK = opts.magnitudeK ?? 1.4; + const minAbsDelta = opts.minAbsDelta ?? 0; + const spreadFloor = opts.spreadFloor ?? 0; const n = series.length; if (n < minSeriesLength) return null; @@ -128,6 +148,7 @@ export function detectLevelShift( const beforeMean = mean(before); const afterMean = mean(after); const delta = afterMean - beforeMean; + if (Math.abs(delta) < minAbsDelta) return null; // Magnitude bar: the step must exceed the WITHIN-segment robust spread (the // metric's day-to-day noise), NOT the whole-series spread — a clean step @@ -138,21 +159,49 @@ export function detectLevelShift( const pooledSpread = Math.max( robustSpread(before), robustSpread(after), + spreadFloor, 1e-9, ); const magnitude = Math.abs(delta) / pooledSpread; if (magnitude < magnitudeK) return null; + if (isDrift(series, before, after, beforeMean, afterMean)) return null; return { breakIndex, beforeMean, afterMean, delta, - magnitude, + magnitude: Math.min(magnitude, MAX_MAGNITUDE), direction: delta >= 0 ? "up" : "down", }; } +/** + * True when a straight line through the whole series fits at least as well as + * the two-level step (same parameter count, so the residual sums compare + * directly). A drift is then the honest reading, not a dated shift. + */ +function isDrift( + series: readonly number[], + before: readonly number[], + after: readonly number[], + beforeMean: number, + afterMean: number, +): boolean { + let sseStep = 0; + for (const x of before) sseStep += (x - beforeMean) ** 2; + for (const x of after) sseStep += (x - afterMean) ** 2; + + const fit = linearFit( + series.map((_, i) => i), + series, + ); + if (!fit) return false; + // Residual sum of the line from the centred sums: SSR = Syy − Sxy² / Sxx. + const sseLine = Math.max(0, fit.syy - (fit.sxy * fit.sxy) / fit.sxx); + return sseLine <= sseStep; +} + /** One detected changepoint, ready for the snapshot. */ export interface ChangepointSignal { metric: MeasurementType; @@ -178,6 +227,42 @@ const CHANGEPOINT_METRICS: readonly MeasurementType[] = [ "BLOOD_GLUCOSE", ]; +/** + * Per-metric floors, in the metric's stored unit: `minDelta` is the smallest + * level change worth calling a shift (about the smallest change a clinician + * would note for that vital), `spreadFloor` the smallest day-to-day spread the + * step is measured against (about the measurement resolution / repeat error of + * a consumer device). A metric without an entry has neither floor. + * + * - heart rates (bpm): a 3 bpm change, 1 bpm resolution. + * - HRV (ms): 5 ms, 2 ms (beat detection jitter). + * - weight (kg): 1 kg, 0.2 kg (scale repeat error). + * - blood pressure (mmHg): 5 / 3 mmHg, 2 mmHg (cuff repeat error). + * - glucose (mg/dL): 10 mg/dL, 3 mg/dL (meter accuracy). + * - body fat (%): 1 point, 0.3 point (impedance repeat error). + * - respiratory rate (breaths/min): 1, 0.3. + * - SpO₂ (%): 1 point, 0.5 point (reported in whole percent). + * - VO₂ max (ml/kg/min): 1.5, 0.5 (a modelled estimate that moves in 0.1–0.3 + * steps; a plateau stepping by 0.3 is the estimator, not the person). + */ +export const CHANGEPOINT_FLOORS: Partial< + Record +> = { + RESTING_HEART_RATE: { minDelta: 3, spreadFloor: 1 }, + PULSE: { minDelta: 3, spreadFloor: 1 }, + WALKING_HEART_RATE_AVERAGE: { minDelta: 3, spreadFloor: 1 }, + HEART_RATE_VARIABILITY: { minDelta: 5, spreadFloor: 2 }, + HRV_RMSSD: { minDelta: 5, spreadFloor: 2 }, + WEIGHT: { minDelta: 1, spreadFloor: 0.2 }, + BLOOD_PRESSURE_SYS: { minDelta: 5, spreadFloor: 2 }, + BLOOD_PRESSURE_DIA: { minDelta: 3, spreadFloor: 2 }, + BLOOD_GLUCOSE: { minDelta: 10, spreadFloor: 3 }, + BODY_FAT: { minDelta: 1, spreadFloor: 0.3 }, + RESPIRATORY_RATE: { minDelta: 1, spreadFloor: 0.3 }, + OXYGEN_SATURATION: { minDelta: 1, spreadFloor: 0.5 }, + VO2_MAX: { minDelta: 1.5, spreadFloor: 0.5 }, +}; + /** Window the reader looks back over (days). */ export const CHANGEPOINT_WINDOW_DAYS = 90; /** At most this many changepoints in one snapshot (strongest first). */ @@ -244,7 +329,14 @@ async function scanChangepoints( coverage, ); if (points.length === 0) continue; - const shift = detectLevelShift(points.map((p) => p.mean)); + const floors = CHANGEPOINT_FLOORS[metric]; + const shift = detectLevelShift( + points.map((p) => p.mean), + { + minAbsDelta: floors?.minDelta, + spreadFloor: floors?.spreadFloor, + }, + ); if (!shift) continue; const breakDate = points[shift.breakIndex + 1]?.day ?? points[shift.breakIndex].day;