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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 01:40:27 +02:00
1 parent dd1071158f
commit 0be484c8a3
16 files changed
+1080 -2

No files matched your search

@@ -52,6 +52,7 @@ describe("registry", () => {
getDerivedMetricMeta("STAIR_DESCENT_SPEED_BASELINE")?.implemented,
).toBe(true);
expect(getDerivedMetricMeta("SIX_MINUTE_WALK_BAND")?.implemented).toBe(true);
expect(getDerivedMetricMeta("TRAJECTORY")?.implemented).toBe(true);
});
it("isDerivedMetricId rejects unknown ids", () => {
@@ -76,7 +77,8 @@ describe("registry", () => {
expect(DERIVED_METRIC_IDS).toContain("STAIR_ASCENT_SPEED_BASELINE");
expect(DERIVED_METRIC_IDS).toContain("STAIR_DESCENT_SPEED_BASELINE");
expect(DERIVED_METRIC_IDS).toContain("SIX_MINUTE_WALK_BAND");
expect(DERIVED_METRIC_IDS.length).toBe(15);
expect(DERIVED_METRIC_IDS).toContain("TRAJECTORY");
expect(DERIVED_METRIC_IDS.length).toBe(16);
});
});
@@ -181,6 +183,34 @@ describe("computeDerivedMetric dispatch", () => {
}
});
it("routes TRAJECTORY to its engine (no data → insufficient, not not_implemented)", async () => {
const result = await computeDerivedMetric({
metric: "TRAJECTORY",
userId: "u1",
profile: PROFILE,
type: "WEIGHT",
now: NOW,
});
expect(result.status).toBe("insufficient");
if (result.status === "insufficient") {
expect(result.reason).toBe("no_readings_in_window");
}
});
it("returns unsupported_trajectory_type for a bad TRAJECTORY type", async () => {
const result = await computeDerivedMetric({
metric: "TRAJECTORY",
userId: "u1",
profile: PROFILE,
type: "STEPS",
now: NOW,
});
expect(result.status).toBe("insufficient");
if (result.status === "insufficient") {
expect(result.reason).toBe("unsupported_trajectory_type");
}
});
it("returns unsupported_baseline_type for a bad VITALS_BASELINE type", async () => {
const result = await computeDerivedMetric({
metric: "VITALS_BASELINE",
@@ -0,0 +1,224 @@
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");
}
});
});
+31
View File
@@ -16,6 +16,7 @@ import { buildInsufficient, nowProvenanceTimestamp } from "./coverage";
import {
getDerivedMetricMeta,
isVitalsBaselineType,
isTrajectoryType,
type DerivedMetricId,
} from "./registry";
import {
@@ -30,6 +31,7 @@ import { computeBmi } from "./bmi";
import { computeSleepScore } from "./sleep-score";
import { computeReadiness } from "./readiness";
import { computeCoincidentDeviation } from "./coincident-deviation";
import { computeTrajectory } from "./trajectory";
import {
computeWellnessScore,
type WellnessScoreType,
@@ -177,6 +179,35 @@ export async function computeDerivedMetric(
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
case "TRAJECTORY": {
// Short-horizon OLS projection over a single chosen metric; defaults
// to RESTING_HEART_RATE (the catalogue's canonical example) when the
// caller omits one. Validated against the supported projection set —
// any other type 422s the same way VITALS_BASELINE does.
const requested = args.type ?? "RESTING_HEART_RATE";
if (!isTrajectoryType(requested)) {
return buildInsufficient<unknown>({
coverage: {
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: [requested],
},
provenance: {
inputs: [requested],
source: "none",
windowDays: 0,
computedAt: nowProvenanceTimestamp(now),
},
reason: "unsupported_trajectory_type",
});
}
return computeTrajectory(args.userId, args.profile, {
type: requested as MeasurementType,
windowDays: args.windowDays,
now,
}) as Promise<Derived<unknown>>;
}
case "RECOVERY_SCORE":
case "STRESS_SCORE":
case "STRAIN_SCORE":
+17
View File
@@ -39,9 +39,11 @@ export type { DeriveCoverageArgs } from "./coverage";
export {
DERIVED_METRIC_IDS,
VITALS_BASELINE_TYPES,
TRAJECTORY_TYPES,
isDerivedMetricId,
getDerivedMetricMeta,
isVitalsBaselineType,
isTrajectoryType,
} from "./registry";
export type {
DerivedMetricId,
@@ -157,3 +159,18 @@ export type {
WellnessScoreType,
WellnessScoreOpts,
} from "./wellness-scores";
// ── v1.11.0 (Epic B, Pillar 3) forecasting engine (server-only) ──────
export {
computeTrajectory,
fitOls,
predictionIntervalHalfWidth,
TRAJECTORY_MIN_R2,
TRAJECTORY_MIN_HISTORY_DAYS,
} from "./trajectory";
export type {
TrajectoryValue,
TrajectoryPoint,
TrajectoryOpts,
OlsFit,
} from "./trajectory";
+30 -1
View File
@@ -54,7 +54,9 @@ export type DerivedMetricId =
/** v1.10.3: stair-descent-speed personal trend band (baseline engine). */
| "STAIR_DESCENT_SPEED_BASELINE"
/** v1.10.3: estimated 6-minute-walk distance vs Enright-predicted (passthrough re-frame). */
| "SIX_MINUTE_WALK_BAND";
| "SIX_MINUTE_WALK_BAND"
/** v1.11.0 (Epic B): short-horizon OLS projection with a widening prediction band. */
| "TRAJECTORY";
// Documented-as-omitted (v1.10.3): two additive HealthKit signals stay
// trend-only with NO derived band, on purpose —
@@ -266,6 +268,20 @@ const REGISTRY: Record<DerivedMetricId, DerivedMetricMeta> = {
minInputs: 1,
implemented: true,
},
TRAJECTORY: {
id: "TRAJECTORY",
// Short-horizon OLS projection over a single chosen metric. The caller
// selects the projected type via the route's `type` opt (validated
// against `TRAJECTORY_TYPES`); below the R²/history/staleness floor the
// engine returns `insufficient`, never a weak line — so the
// `minHistoryDays` here documents the engine's own 14-day fit floor.
displayName: "Short-horizon projection",
archetype: "any-user-baseline",
inputs: VITALS_BASELINE_TYPES,
minHistoryDays: 14,
minInputs: 1,
implemented: true,
},
};
/** Closed set of ids the generic route accepts (Zod enum source). */
@@ -287,3 +303,16 @@ export function getDerivedMetricMeta(
export function isVitalsBaselineType(type: string): boolean {
return (VITALS_BASELINE_TYPES as string[]).includes(type);
}
/**
* The metrics the short-horizon `TRAJECTORY` engine supports. Reuses the
* vitals baseline set — slow, daily physiological day-series for which a
* conservative 7–14-day OLS projection is honest; the engine gates anything
* without a real trend out to `insufficient` regardless.
*/
export const TRAJECTORY_TYPES: MeasurementType[] = VITALS_BASELINE_TYPES;
/** `true` when the type is a metric the trajectory engine supports. */
export function isTrajectoryType(type: string): boolean {
return (TRAJECTORY_TYPES as string[]).includes(type);
}
+341
View File
@@ -0,0 +1,341 @@
/**
* v1.11.0 (Epic B, Pillar 3) — the `TRAJECTORY` forecasting derived metric.
*
* `computeTrajectory(userId, profile, opts)` projects a single metric's
* recent trend a SHORT horizon forward with an HONEST, widening
* prediction-interval band. It is the deterministic-compute extension of
* the existing `trendSlope` (`src/lib/analytics/trends.ts:29`) which
* already returns `slope` + `direction` + R² (as `confidence`):
*
* - **fit** = ordinary least squares on the DAY-native per-day mean
* series (x = days since the window start, y = the per-day mean). The
* series is read exactly like the baseline engine
* (`readDayMeanSeries`) — rollup DAY buckets (`mean` composes) with a
* per-type bounded live-SQL fallback. NEVER from a composed WEEK/MONTH
* `sd`/`slope`: the rollup invariant forbids it (`baseline.ts:14-21`),
* so the fit + residual spread are computed at DAY granularity only.
* - **projection** = the fitted line evaluated at the next
* `horizonDays` day-offsets past the last observation.
* - **band** = the textbook OLS prediction interval
* `ŷ ± t·s·sqrt(1 + 1/n + (x−x̄)²/Sxx)`, which VISIBLY WIDENS the
* further the horizon strays from the data centre `x̄`. NOT a flat ±.
* The fanning band IS the uncertainty communication.
*
* Honesty gates (overclaiming a forecast is the top Epic-B risk). A
* projection is produced ONLY when all hold; otherwise `insufficient`
* (never a weak line, never extrapolated noise):
* - R² ≥ `TRAJECTORY_MIN_R2` (a weak-but-real trend, not noise).
* - `sampleDays ≥ TRAJECTORY_MIN_HISTORY_DAYS` (enough fit support).
* - the series is not stale: the window is anchored on `now` (like
* `trendSlope`) so a series with no recent points yields too few
* in-window days and gates out the same way the dashboard tile hides a
* stale average.
*
* The displayed confidence is server-computed from n + fit + recency
* (`deriveCoverage`), never the model's self-confidence — the LLM never
* invents the number; it only narrates a band that is already here.
*
* Server-only — reads the rollup tier + raw rows via the baseline reader.
* The pure OLS + prediction-interval helpers are exported so the unit
* test asserts the projection on a seeded trend and the band's widening.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import {
probeRollupCoverage,
type RollupCoverageMap,
} from "@/lib/rollups/measurement-coverage";
import {
buildInsufficient,
buildOk,
deriveCoverage,
nowProvenanceTimestamp,
} from "./coverage";
import { readDayMeanSeries, type BaselineProfile } from "./baseline";
import type { Derived, DerivedProvenanceSource } from "./types";
/** Trailing window the fit reads (days). Matches the baseline engine. */
const DEFAULT_WINDOW_DAYS = 30;
/** Default short projection horizon (days). Deliberately conservative. */
const DEFAULT_HORIZON_DAYS = 14;
/** Hard ceiling on the horizon — the literature warns off long horizons. */
const MAX_HORIZON_DAYS = 14;
/** Minimum distinct in-window days before a projection is produced. */
export const TRAJECTORY_MIN_HISTORY_DAYS = 14;
/** Minimum R² (fit quality) before a projection is produced. */
export const TRAJECTORY_MIN_R2 = 0.3;
/**
* Two-sided t critical value at 95% used for the prediction band. A fixed
* conservative constant (≈ the large-n z) rather than a per-n t-table
* lookup: the band is an honesty signal, not an inferential claim, and a
* constant keeps the math pure + dependency-free. With the ≥14-day floor
* the true t is within ~5% of this, and erring slightly wide is the safe
* direction for a forecast band.
*/
const T_CRITICAL_95 = 1.96;
/** One projected day on the forecast fan. */
export interface TrajectoryPoint {
/** Days past the last observation (1..horizonDays). */
dayOffset: number;
/** ISO date (YYYY-MM-DD) the offset lands on. */
date: string;
/** Fitted projection ŷ at this offset. */
projected: number;
/** Prediction-interval lower edge at this offset. */
bandLow: number;
/** Prediction-interval upper edge at this offset. */
bandHigh: number;
}
/** The successful `value` payload for a trajectory projection. */
export interface TrajectoryValue {
/** The metric this projection describes. */
type: MeasurementType;
/** OLS slope (units per day). */
slopePerDay: number;
/** Trend direction, mirroring `trendSlope`. */
direction: "up" | "down" | "stable";
/** Horizon the projection covers (days). */
horizonDays: number;
/** R² of the fit (0..1) — the confidence the band rides. */
r2: number;
/** Residual standard error of the fit (same units as the metric). */
residualStdError: number;
/** Distinct in-window days that backed the fit. */
sampleDays: number;
/** Last observed per-day mean (the fan's anchor). */
lastValue: number;
/** Projected fan (oldest → newest offset), each with a widening band. */
projection: TrajectoryPoint[];
/** Method tag — always "ols" for v1. */
method: "ols";
}
export interface TrajectoryOpts {
/** Which metric to project. */
type: MeasurementType;
/** Trailing fit window in days. Defaults to 30. */
windowDays?: number;
/** Projection horizon in days. Clamped to [1, MAX_HORIZON_DAYS]. */
horizonDays?: number;
/** Compute time (injected for deterministic tests). */
now?: Date;
/** Pre-probed coverage map (one probe per request). */
coverage?: RollupCoverageMap;
}
// ── pure OLS + prediction interval (exported for the unit test) ───────
/** A fitted OLS line plus the spread terms the band needs. */
export interface OlsFit {
slope: number;
intercept: number;
r2: number;
/** Number of points. */
n: number;
/** Mean of x (the data centre the band widens away from). */
meanX: number;
/** Σ(x − x̄)² — the denominator in the prediction-interval term. */
sxx: number;
/** Residual standard error s = sqrt(SSres / (n − 2)). */
residualStdError: number;
}
/**
* Fit an OLS line `y = intercept + slope·x` and return the spread terms
* the prediction interval needs. Pure. Returns `null` when the fit is
* degenerate (< 3 points, or all x identical → Sxx = 0). Three points is
* the floor for a meaningful residual standard error (n − 2 ≥ 1).
*/
export function fitOls(
xs: number[],
ys: number[],
): OlsFit | null {
const n = xs.length;
if (n < 3 || ys.length !== n) return null;
const sumX = xs.reduce((s, x) => s + x, 0);
const sumY = ys.reduce((s, y) => s + y, 0);
const meanX = sumX / n;
const meanY = sumY / n;
let sxx = 0;
let sxy = 0;
for (let i = 0; i < n; i++) {
const dx = xs[i] - meanX;
sxx += dx * dx;
sxy += dx * (ys[i] - meanY);
}
if (sxx === 0) return null; // all x identical → no slope
const slope = sxy / sxx;
const intercept = meanY - slope * meanX;
let ssRes = 0;
let ssTot = 0;
for (let i = 0; i < n; i++) {
const fitted = intercept + slope * xs[i];
ssRes += (ys[i] - fitted) ** 2;
ssTot += (ys[i] - meanY) ** 2;
}
const r2 = ssTot === 0 ? 0 : 1 - ssRes / ssTot;
// n − 2 degrees of freedom; n ≥ 3 guarantees a positive denominator.
const residualStdError = Math.sqrt(ssRes / (n - 2));
return { slope, intercept, r2, n, meanX, sxx, residualStdError };
}
/**
* The half-width of the OLS prediction interval at a single x:
* t·s·sqrt(1 + 1/n + (x − x̄)²/Sxx)
* Pure. This is the term that makes the band FAN OUT — the `(x − x̄)²/Sxx`
* summand grows quadratically as x leaves the data centre, so a point far
* into the horizon carries a visibly wider band than one near the data.
*/
export function predictionIntervalHalfWidth(
fit: OlsFit,
x: number,
): number {
const { n, meanX, sxx, residualStdError } = fit;
const leverage = 1 + 1 / n + (x - meanX) ** 2 / sxx;
return T_CRITICAL_95 * residualStdError * Math.sqrt(leverage);
}
// ── compute ───────────────────────────────────────────────────────────
/**
* Short-horizon OLS trajectory with a widening prediction band, gated on
* fit quality + history + staleness. Reads the DAY-native per-day mean
* series (rollup tier + bounded live fallback) — never a composed
* WEEK/MONTH slope. Below any gate it returns `insufficient` (never a
* weak line, never noise extrapolated).
*/
export async function computeTrajectory(
userId: string,
_profile: BaselineProfile,
opts: TrajectoryOpts,
): Promise<Derived<TrajectoryValue>> {
const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS;
const horizonDays = Math.max(
1,
Math.min(MAX_HORIZON_DAYS, opts.horizonDays ?? DEFAULT_HORIZON_DAYS),
);
const now = opts.now ?? new Date();
const type = opts.type;
const computedAt = nowProvenanceTimestamp(now);
const coverage = opts.coverage ?? (await probeRollupCoverage(userId));
const { points, source } = await readDayMeanSeries(
userId,
type,
windowDays,
now,
coverage,
);
const sampleDays = points.length;
const insufficient = (
reason: string,
src: DerivedProvenanceSource,
presentInputs: number,
): Derived<TrajectoryValue> => {
const { coverage: cov } = deriveCoverage({
requiredInputs: 1,
presentInputs,
historyDays: sampleDays,
missing: presentInputs === 0 ? [String(type)] : [],
fullHistoryDays: windowDays,
});
return buildInsufficient<TrajectoryValue>({
coverage: cov,
provenance: { inputs: [String(type)], source: src, windowDays, computedAt },
reason,
});
};
// No data at all — insufficient, source "none".
if (sampleDays === 0) {
return insufficient("no_readings_in_window", "none", 0);
}
// Too little history for an honest fit. The window is anchored on `now`
// (via the reader), so a STALE series (no recent points) lands here too
// — the same staleness gate `trendSlope` enforces.
if (sampleDays < TRAJECTORY_MIN_HISTORY_DAYS) {
return insufficient("insufficient_history_for_projection", source, 1);
}
// x = days since the FIRST in-window day; y = the per-day mean.
const dayMs = 24 * 60 * 60 * 1000;
const startMs = new Date(`${points[0].day}T00:00:00Z`).getTime();
const xs = points.map(
(p) => (new Date(`${p.day}T00:00:00Z`).getTime() - startMs) / dayMs,
);
const ys = points.map((p) => p.mean);
const fit = fitOls(xs, ys);
if (!fit) {
return insufficient("fit_computation_failed", source, 1);
}
// Gate on fit quality — a weak-but-real trend, never noise.
if (fit.r2 < TRAJECTORY_MIN_R2) {
return insufficient("insufficient_fit_for_projection", source, 1);
}
// Project from the last observed day-offset forward over the horizon,
// each point carrying the widening prediction band.
const lastX = xs[xs.length - 1];
const lastDayMs = new Date(
`${points[points.length - 1].day}T00:00:00Z`,
).getTime();
const projection: TrajectoryPoint[] = [];
for (let h = 1; h <= horizonDays; h++) {
const x = lastX + h;
const projected = fit.intercept + fit.slope * x;
const half = predictionIntervalHalfWidth(fit, x);
projection.push({
dayOffset: h,
date: new Date(lastDayMs + h * dayMs).toISOString().slice(0, 10),
projected,
bandLow: projected - half,
bandHigh: projected + half,
});
}
const slopePerDay = fit.slope;
const threshold = 0.01;
const direction: "up" | "down" | "stable" =
Math.abs(slopePerDay) < threshold
? "stable"
: slopePerDay > 0
? "up"
: "down";
const { coverage: cov, confidence } = deriveCoverage({
requiredInputs: 1,
presentInputs: 1,
historyDays: sampleDays,
missing: [],
fullHistoryDays: windowDays,
});
return buildOk<TrajectoryValue>({
value: {
type,
slopePerDay,
direction,
horizonDays,
r2: fit.r2,
residualStdError: fit.residualStdError,
sampleDays,
lastValue: ys[ys.length - 1],
projection,
method: "ols",
},
coverage: cov,
confidence,
provenance: { inputs: [String(type)], source, windowDays, computedAt },
});
}