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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 23:03:03 +02:00
1 parent e696d6e526
commit 2723ac5b98
2 files changed
+187 -3

No files matched your search

@@ -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<number>(60).fill(40),
...Array<number>(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<number>(60).fill(40),
...Array<number>(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<number>(60).fill(60),
...Array<number>(60).fill(62),
]);
expect(await buildExtendedChangepointSignals("u1", NOW, {})).toEqual([]);
only("RESTING_HEART_RATE", [
...Array<number>(60).fill(60),
...Array<number>(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<number>(30).fill(14),
...Array<number>(30).fill(17),
]);
expect(shift?.magnitude).toBe(MAX_MAGNITUDE);
});
});
+95 -3
View File
@@ -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<MeasurementType, { minDelta: number; spreadFloor: number }>
> = {
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;