Files
HealthLog/src/lib/insights/derived/__tests__/trajectory.test.ts
T
Marc-André Bombeck 0be484c8a3 feat(insights): TRAJECTORY forecasting derived metric with a widening band
Add a short-horizon OLS projection over the DAY-native per-day mean
series, extending the existing trendSlope. The fit, residual standard
error and prediction interval are computed at DAY granularity only — the
rollup invariant forbids composing a slope/sd from WEEK/MONTH buckets.

The projection is gated on fit quality (R squared), in-window history and
staleness: below any floor the engine returns insufficient rather than a
weak line, so noise is never extrapolated. The prediction-interval band
widens with the horizon distance from the data centre, so the fan
visibly communicates uncertainty.

Register TRAJECTORY in the derived registry and dispatch (additive,
iOS-forward-compatible; the capabilities endpoint and drift-guard pick it
up automatically). Surface it on the weight detail page as a forecast
band beneath the chart, labelled a projection with the cited method and
a conditional, range-only headline — never a prediction or a dated
event. i18n across all six locales.
2026-06-04 01:40:27 +02:00

225 lines
7.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: { measurement: { findMany: vi.fn() } },
}));
vi.mock("@/lib/rollups/measurement-coverage", () => ({
probeRollupCoverage: vi.fn(),
}));
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
readBestGranularityRollups: vi.fn(),
}));
import {
fitOls,
predictionIntervalHalfWidth,
computeTrajectory,
TRAJECTORY_MIN_HISTORY_DAYS,
} from "../trajectory";
import { prisma } from "@/lib/db";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
const PROFILE = { ageYears: 40, sex: "MALE" as const };
// `now` sits just after the last seeded day so the window-anchored reader
// keeps every seeded day in-window (the staleness gate stays satisfied).
const NOW = new Date("2026-06-02T07:00:00Z");
const TYPE = "WEIGHT";
function dayRow(day: string, mean: number) {
return {
bucketStart: new Date(`${day}T00:00:00Z`),
count: 1,
mean,
sd: null,
slope: null,
r2: null,
sumValue: null,
minValue: mean,
maxValue: mean,
};
}
/** Build `count` consecutive DAY rows ending at `2026-06-01`, mean = f(i). */
function seedDays(count: number, f: (i: number) => number) {
const end = new Date("2026-06-01T00:00:00Z").getTime();
const dayMs = 24 * 60 * 60 * 1000;
const rows = [];
for (let i = 0; i < count; i++) {
const day = new Date(end - (count - 1 - i) * dayMs)
.toISOString()
.slice(0, 10);
rows.push(dayRow(day, f(i)));
}
return rows;
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(probeRollupCoverage).mockResolvedValue(new Map([[TYPE, true]]));
});
describe("pure OLS + prediction interval", () => {
it("recovers the slope + intercept of a clean line (R² = 1)", () => {
// y = 80 + 0.5x over x = 0..19.
const xs = Array.from({ length: 20 }, (_, i) => i);
const ys = xs.map((x) => 80 + 0.5 * x);
const fit = fitOls(xs, ys);
expect(fit).not.toBeNull();
expect(fit!.slope).toBeCloseTo(0.5, 6);
expect(fit!.intercept).toBeCloseTo(80, 6);
expect(fit!.r2).toBeCloseTo(1, 6);
// A perfect fit still carries a non-negative (here ~0) residual SE.
expect(fit!.residualStdError).toBeCloseTo(0, 6);
});
it("returns null on a degenerate fit (< 3 points or zero variance in x)", () => {
expect(fitOls([0, 1], [1, 2])).toBeNull();
expect(fitOls([5, 5, 5], [1, 2, 3])).toBeNull();
});
it("prediction band widens monotonically as x leaves the data centre", () => {
// Noisy enough to carry a positive residual SE so the band has width.
const xs = Array.from({ length: 20 }, (_, i) => i);
const ys = xs.map((x) => 80 + 0.5 * x + (x % 2 === 0 ? 1 : -1));
const fit = fitOls(xs, ys)!;
const near = predictionIntervalHalfWidth(fit, fit.meanX); // at the centre
const mid = predictionIntervalHalfWidth(fit, 25);
const far = predictionIntervalHalfWidth(fit, 35);
expect(near).toBeGreaterThan(0);
expect(mid).toBeGreaterThan(near);
expect(far).toBeGreaterThan(mid);
});
});
describe("computeTrajectory — ok path", () => {
it("projects a seeded upward trend with a widening band", async () => {
// 20 days of a clean +0.2/day trend with a touch of noise so the fit is
// strong (R² well over the floor) but the residual SE is non-zero.
const rows = seedDays(20, (i) => 80 + 0.2 * i + (i % 2 === 0 ? 0.1 : -0.1));
vi.mocked(readBestGranularityRollups).mockResolvedValue({
granularity: "DAY",
rows,
});
const result = await computeTrajectory("u1", PROFILE, {
type: TYPE,
horizonDays: 14,
now: NOW,
});
expect(result.status).toBe("ok");
if (result.status !== "ok") return;
const v = result.value;
expect(v.direction).toBe("up");
expect(v.slopePerDay).toBeCloseTo(0.2, 1);
expect(v.r2).toBeGreaterThan(0.9);
expect(v.method).toBe("ols");
expect(v.horizonDays).toBe(14);
expect(v.projection).toHaveLength(14);
expect(result.provenance.source).toBe("DAY");
// The projected line keeps rising over the horizon.
const first = v.projection[0];
const last = v.projection[v.projection.length - 1];
expect(last.projected).toBeGreaterThan(first.projected);
// The honesty signal: the band fans out — the last horizon point is
// strictly wider than the first.
const firstWidth = first.bandHigh - first.bandLow;
const lastWidth = last.bandHigh - last.bandLow;
expect(firstWidth).toBeGreaterThan(0);
expect(lastWidth).toBeGreaterThan(firstWidth);
// The band brackets the projected value at every point.
for (const p of v.projection) {
expect(p.bandLow).toBeLessThan(p.projected);
expect(p.bandHigh).toBeGreaterThan(p.projected);
}
});
it("clamps the horizon to the 14-day ceiling", async () => {
const rows = seedDays(20, (i) => 80 + 0.2 * i + (i % 2 === 0 ? 0.1 : -0.1));
vi.mocked(readBestGranularityRollups).mockResolvedValue({
granularity: "DAY",
rows,
});
const result = await computeTrajectory("u1", PROFILE, {
type: TYPE,
horizonDays: 90,
now: NOW,
});
expect(result.status).toBe("ok");
if (result.status === "ok") {
expect(result.value.horizonDays).toBe(14);
expect(result.value.projection).toHaveLength(14);
}
});
});
describe("computeTrajectory — insufficient gates (no weak line)", () => {
it("no data in window → insufficient (source none)", async () => {
vi.mocked(probeRollupCoverage).mockResolvedValue(new Map());
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
const result = await computeTrajectory("u1", PROFILE, {
type: TYPE,
now: NOW,
});
expect(result.status).toBe("insufficient");
if (result.status === "insufficient") {
expect(result.reason).toBe("no_readings_in_window");
expect(result.provenance.source).toBe("none");
}
});
it("too few history days → insufficient, never a line", async () => {
// One under the floor → gated.
const rows = seedDays(TRAJECTORY_MIN_HISTORY_DAYS - 1, (i) => 80 + 0.2 * i);
vi.mocked(readBestGranularityRollups).mockResolvedValue({
granularity: "DAY",
rows,
});
const result = await computeTrajectory("u1", PROFILE, {
type: TYPE,
now: NOW,
});
expect(result.status).toBe("insufficient");
if (result.status === "insufficient") {
expect(result.reason).toBe("insufficient_history_for_projection");
}
});
it("weak fit (R² below the floor) → insufficient, never a line", async () => {
// A flat-with-alternating-noise series: no real trend → low R².
const rows = seedDays(20, (i) => 80 + (i % 2 === 0 ? 3 : -3));
vi.mocked(readBestGranularityRollups).mockResolvedValue({
granularity: "DAY",
rows,
});
const result = await computeTrajectory("u1", PROFILE, {
type: TYPE,
now: NOW,
});
expect(result.status).toBe("insufficient");
if (result.status === "insufficient") {
expect(result.reason).toBe("insufficient_fit_for_projection");
}
});
it("stale series (all points outside the now-anchored window) → insufficient", async () => {
// Coverage misses → live read; rows are months old, so the
// window-anchored live read returns nothing in-window.
vi.mocked(probeRollupCoverage).mockResolvedValue(new Map([[TYPE, false]]));
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
const result = await computeTrajectory("u1", PROFILE, {
type: TYPE,
windowDays: 30,
now: NOW,
});
expect(result.status).toBe("insufficient");
if (result.status === "insufficient") {
expect(result.reason).toBe("no_readings_in_window");
}
});
});