mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
4fbbdb12c2
commit
6330908619
3 files changed
+116
-70
No files matched your search
@@ -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 =
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user