diff --git a/src/lib/insights/derived/changepoint.ts b/src/lib/insights/derived/changepoint.ts index abbbdb39c..c993186b4 100644 --- a/src/lib/insights/derived/changepoint.ts +++ b/src/lib/insights/derived/changepoint.ts @@ -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 { + 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 = diff --git a/src/lib/mcp/__tests__/rich-reads.test.ts b/src/lib/mcp/__tests__/rich-reads.test.ts index 5a93d9a74..92f9e1a8b 100644 --- a/src/lib/mcp/__tests__/rich-reads.test.ts +++ b/src/lib/mcp/__tests__/rich-reads.test.ts @@ -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", diff --git a/src/lib/mcp/rich-reads.ts b/src/lib/mcp/rich-reads.ts index e32f4ba55..9d15570fa 100644 --- a/src/lib/mcp/rich-reads.ts +++ b/src/lib/mcp/rich-reads.ts @@ -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 {