fix(mcp): run detect_changepoints on the insights level-shift detector

The tool kept its own CUSUM with a plain standard-deviation bar, so the
per-metric floors and the drift rejection the health-status scan gained
never reached it, nor the medication-effect note that reads it: a 0.6 kg
weight step or a steady climb still came back as a dated shift. The tool
now segments the series with the shared detector, recursing on each side
of a shift with the same bar and the metric's floors. The output shape
is unchanged.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 00:29:19 +02:00
1 parent 4fbbdb12c2
commit 6330908619
3 files changed
+116 -70

No files matched your search

+36 -5
View File
@@ -176,6 +176,30 @@ export function detectLevelShift(
};
}
/**
* Every validated level shift in a series, by binary segmentation: find one
* with {@link detectLevelShift}, then look again on each side of it with the
* same bar, floors and drift rejection. `breakIndex` is into `series`.
* Callers that want one dated shift per metric use `detectLevelShift`; this
* is for a reader that lists several (the MCP changepoint tool).
*/
export function detectLevelShifts(
series: readonly number[],
opts: DetectLevelShiftOptions = {},
): LevelShift[] {
const out: LevelShift[] = [];
const walk = (lo: number, hi: number) => {
const shift = detectLevelShift(series.slice(lo, hi), opts);
if (!shift) return;
const split = lo + shift.breakIndex + 1;
out.push({ ...shift, breakIndex: split - 1 });
walk(lo, split);
walk(split, hi);
};
walk(0, series.length);
return out;
}
/**
* 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
@@ -263,6 +287,17 @@ export const CHANGEPOINT_FLOORS: Partial<
VO2_MAX: { minDelta: 1.5, spreadFloor: 0.5 },
};
/** The {@link CHANGEPOINT_FLOORS} of a metric as detector options. */
export function levelShiftFloors(
metric: MeasurementType,
): Pick<DetectLevelShiftOptions, "minAbsDelta" | "spreadFloor"> {
const floors = CHANGEPOINT_FLOORS[metric];
return {
minAbsDelta: floors?.minDelta,
spreadFloor: floors?.spreadFloor,
};
}
/** Window the reader looks back over (days). */
export const CHANGEPOINT_WINDOW_DAYS = 90;
/** At most this many changepoints in one snapshot (strongest first). */
@@ -329,13 +364,9 @@ async function scanChangepoints(
coverage,
);
if (points.length === 0) continue;
const floors = CHANGEPOINT_FLOORS[metric];
const shift = detectLevelShift(
points.map((p) => p.mean),
{
minAbsDelta: floors?.minDelta,
spreadFloor: floors?.spreadFloor,
},
levelShiftFloors(metric),
);
if (!shift) continue;
const breakDate =
+52
View File
@@ -654,6 +654,58 @@ describe("detect_changepoints", () => {
expect(res.bucketsAnalysed).toBe(12);
});
it("applies the metric floors and rejects a drift, as the insights scan does", async () => {
const days = (n: number) =>
Array.from(
{ length: n },
(_, i) => `2026-03-${String(i + 1).padStart(2, "0")}`,
);
// A clean 0.6 kg step: many deviations of its own noise, but below the
// 1 kg a weight change has to reach.
vi.mocked(readBestGranularityRollups).mockResolvedValueOnce({
granularity: "DAY",
rows: days(12).map((d, i) =>
row(d, (i < 6 ? 80 : 80.6) + (i % 2) * 0.05),
),
});
const small = await detectChangepoints(USER, { metric: "weight" });
expect(small.present).toBe(false);
expect(small.reason).toBe("no_changepoint");
// A steady climb of 1 kg a day: two halves with different means, but a
// straight line explains it, so it is a drift and not a dated shift.
vi.mocked(readBestGranularityRollups).mockResolvedValueOnce({
granularity: "DAY",
rows: days(20).map((d, i) => row(d, 80 + i)),
});
const drift = await detectChangepoints(USER, { metric: "weight" });
expect(drift.present).toBe(false);
expect(drift.reason).toBe("no_changepoint");
});
it("lists more than one shift with the same bar on each side", async () => {
const values = [
...Array.from({ length: 10 }, (_, i) => 70 + (i % 2) * 0.2),
...Array.from({ length: 10 }, (_, i) => 90 + (i % 2) * 0.2),
...Array.from({ length: 10 }, (_, i) => 95 + (i % 2) * 0.2),
];
vi.mocked(readBestGranularityRollups).mockResolvedValueOnce({
granularity: "DAY",
rows: values.map((v, i) =>
row(new Date(Date.UTC(2026, 2, 1 + i)).toISOString(), v),
),
});
const res = await detectChangepoints(USER, { metric: "weight" });
expect(res.present).toBe(true);
expect(res.changepoints?.map((cp) => cp.at)).toEqual([
new Date(Date.UTC(2026, 2, 11)).toISOString(),
new Date(Date.UTC(2026, 2, 21)).toISOString(),
]);
expect(res.changepoints?.map((cp) => cp.direction)).toEqual([
"increase",
"increase",
]);
});
it("honest-null when too few buckets exist", async () => {
vi.mocked(readBestGranularityRollups).mockResolvedValue({
granularity: "DAY",
+28 -65
View File
@@ -63,7 +63,11 @@ import { sanitizeValueText } from "@/lib/ai/coach/labs-snapshot";
import { scrubFenceMarkers } from "@/lib/ai/coach/data-fence";
import { encodeOffsetCursor } from "@/lib/mcp/pagination";
import type { CoachScopeWindow } from "@/lib/ai/coach/types";
import { standardDeviation } from "@/lib/stats/spread";
import {
detectLevelShifts,
levelShiftFloors,
type DetectLevelShiftOptions,
} from "@/lib/insights/derived/changepoint";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -1151,77 +1155,30 @@ export interface ChangepointsResult {
}
const MIN_SEGMENT = 5;
/** Mean shift must clear this many series standard deviations to fire. */
const SHIFT_SD_FLOOR = 1.5;
const MAX_CHANGEPOINTS = 5;
function mean(xs: number[]): number {
return xs.reduce((s, x) => s + x, 0) / xs.length;
}
/** The CUSUM threshold estimates the series' noise, so it is the sample SD. */
function stdDev(xs: number[]): number {
return standardDeviation(xs, "sample") ?? 0;
}
/**
* Minimal CUSUM binary-segmentation changepoint detector over a 1-D series.
*
* For a segment, the cumulative sum of mean-centred deviations peaks at the most
* likely split. The split fires ONLY when the resulting mean shift clears a
* conservative effect-size floor (≥ SHIFT_SD_FLOOR · series SD) and both halves
* are at least MIN_SEGMENT long — a deliberately high bar so noise does not
* register (honest-null over false positives). Accepted splits recurse on each
* half; the result is capped to the strongest MAX_CHANGEPOINTS shifts by the
* caller.
*
* Indices are into the original series. Pure / dependency-free.
* The bar the tool's segmentation runs at: the insights level-shift detector
* with the metric's floors, sized for buckets rather than days. A bucket may
* be a week or a month, so a segment of five buckets is enough to persist.
*/
function cusumSegment(
values: number[],
lo: number,
hi: number,
out: Array<{ index: number; beforeMean: number; afterMean: number }>,
): void {
const n = hi - lo;
if (n < 2 * MIN_SEGMENT) return;
const segment = values.slice(lo, hi);
const segMean = mean(segment);
const sd = stdDev(segment);
if (sd <= 0) return;
// Cumulative sum of mean-centred deviations; track the extreme position.
let cusum = 0;
let maxAbs = 0;
let splitRel = -1;
for (let i = 0; i < n; i++) {
cusum += segment[i] - segMean;
if (Math.abs(cusum) > maxAbs) {
maxAbs = Math.abs(cusum);
// The new regime starts at the bucket AFTER the accumulation peak.
splitRel = i + 1;
}
}
if (splitRel < MIN_SEGMENT || n - splitRel < MIN_SEGMENT) return;
const before = segment.slice(0, splitRel);
const after = segment.slice(splitRel);
const beforeMean = mean(before);
const afterMean = mean(after);
if (Math.abs(afterMean - beforeMean) < SHIFT_SD_FLOOR * sd) return;
out.push({ index: lo + splitRel, beforeMean, afterMean });
cusumSegment(values, lo, lo + splitRel, out);
cusumSegment(values, lo + splitRel, hi, out);
function changepointOptions(type: MeasurementType): DetectLevelShiftOptions {
return {
minSeriesLength: 2 * MIN_SEGMENT,
minSegmentLength: MIN_SEGMENT,
minPersistDays: MIN_SEGMENT,
...levelShiftFloors(type),
};
}
/**
* Surface level shifts in a metric over the rollup tier. Reads the trailing
* window's rollup buckets (`readBestGranularityRollups` — DAY for ≤90 days,
* coarser for longer windows; the resolved granularity is reported) and runs
* the minimal CUSUM above over the bucket means. High firing bar — returns
* `{ present: false }` when too few buckets exist or no shift clears the floor.
* the insights level-shift detector over the bucket means by binary
* segmentation (`detectLevelShifts`): the same robust bar, per-metric floors
* and drift rejection as the health-status scan. Returns `{ present: false }`
* when too few buckets exist or no shift clears the bar.
* Re-uses the rollup tier's already-computed bucket means; adds no new
* persisted analytics.
*/
@@ -1291,9 +1248,15 @@ export async function detectChangepoints(
const rows = rangeRows;
const values = rows.map((r) => r.mean);
const found: Array<{ index: number; beforeMean: number; afterMean: number }> =
[];
cusumSegment(values, 0, values.length, found);
const found = detectLevelShifts(
values,
changepointOptions(metric.measurementType),
).map((shift) => ({
// The new regime starts at the bucket after the before segment.
index: shift.breakIndex + 1,
beforeMean: shift.beforeMean,
afterMean: shift.afterMean,
}));
if (found.length === 0) {
return {