mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
dd1071158f
commit
0be484c8a3
16 files changed
+1080
-2
No files matched your search
@@ -17,6 +17,7 @@ import { MeasurementDiversityNudge } from "@/components/insights/measurement-div
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory-forecast-card";
|
||||
import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands";
|
||||
|
||||
/**
|
||||
@@ -103,6 +104,13 @@ export default function InsightsGewichtPage() {
|
||||
|
||||
<MetricTargetSummary slug="weight" />
|
||||
|
||||
<TrajectoryForecastCard
|
||||
type="WEIGHT"
|
||||
unit="kg"
|
||||
color="#bd93f9"
|
||||
enabled={!isEmpty}
|
||||
/>
|
||||
|
||||
<InsightStatusCard
|
||||
title={t("insights.assessmentTitle")}
|
||||
icon={<Scale className="h-5 w-5" />}
|
||||
|
||||
@@ -164,4 +164,13 @@ export const METRIC_PROVENANCE: Record<DerivedMetricId, MetricProvenanceMeta> =
|
||||
},
|
||||
caveatKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.caveat",
|
||||
},
|
||||
TRAJECTORY: {
|
||||
methodKey: "insights.derived.composite.TRAJECTORY.method",
|
||||
standard: {
|
||||
// Ordinary least squares + the textbook OLS prediction interval.
|
||||
name: "Montgomery, Peck & Vining 2012, Intro. to Linear Regression Analysis",
|
||||
url: "https://www.wiley.com/en-us/Introduction+to+Linear+Regression+Analysis%2C+5th+Edition-p-9780470542811",
|
||||
},
|
||||
caveatKey: "insights.derived.composite.TRAJECTORY.caveat",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
ComposedChart,
|
||||
Area,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CoverageMeter } from "@/components/insights/derived/coverage-meter";
|
||||
import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer";
|
||||
import { METRIC_PROVENANCE } from "@/components/insights/derived/standards";
|
||||
import { useDerivedMetric } from "@/components/insights/derived/use-derived-metric";
|
||||
// Type-only — the compute payload never drags the server graph into the
|
||||
// client bundle (the v1.9.0 lesson, mirrored across the derived surfaces).
|
||||
import type { TrajectoryValue } from "@/lib/insights/derived/trajectory";
|
||||
import type { DerivedProvenance } from "@/lib/insights/derived/types";
|
||||
|
||||
/**
|
||||
* v1.11.0 (Epic B, Pillar 3) — the short-horizon projection card on the
|
||||
* metric detail page.
|
||||
*
|
||||
* Surfaces the deterministic `TRAJECTORY` derived metric as a small band
|
||||
* chart: the recent observed line, then the projected line (dashed) with a
|
||||
* widening prediction-interval fan. The fan VISIBLY widens with the horizon
|
||||
* — that is the uncertainty communication, no words needed. The number is
|
||||
* computed server-side; this card only draws it. It is explicitly labelled
|
||||
* a PROJECTION ("if this pattern continues"), never a prediction or a dated
|
||||
* event, and the cited method (OLS + prediction interval) rides the ⓘ
|
||||
* provenance affordance.
|
||||
*
|
||||
* Below the engine's R² / history / staleness floor the engine returns
|
||||
* `insufficient`, and the card renders the same calm "not enough of a trend
|
||||
* to project yet" + coverage state every derived surface uses — never a weak
|
||||
* line, never a blank.
|
||||
*
|
||||
* The card mounts as a sibling beneath the main metric chart (mirroring
|
||||
* `MetricStatusCard`), so the main chart's visual identity is untouched.
|
||||
*/
|
||||
|
||||
const TRAJECTORY_METRIC = "TRAJECTORY";
|
||||
|
||||
/** A single value-scale used to render stored values in the display unit. */
|
||||
function scaled(value: number, scale: number): number {
|
||||
return value * scale;
|
||||
}
|
||||
|
||||
interface TrajectoryForecastCardProps {
|
||||
/** The MeasurementType to project (the page's canonical type). */
|
||||
type: string;
|
||||
/** Display unit suffix (e.g. "kg"). */
|
||||
unit?: string;
|
||||
/** Display scale folded into every rendered value (defaults to 1). */
|
||||
valueScale?: number;
|
||||
/** Canonical chart colour for the metric's line. */
|
||||
color?: string;
|
||||
/** Gate the underlying derived read (e.g. on the auth/empty flag). */
|
||||
enabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** The provenance ⓘ explainer for the projection, wired from the map. */
|
||||
function TrajectoryProvenance({
|
||||
provenance,
|
||||
}: {
|
||||
provenance: DerivedProvenance;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const meta = METRIC_PROVENANCE.TRAJECTORY;
|
||||
const method = (
|
||||
<>
|
||||
{meta.caveatKey ? (
|
||||
<span className="text-warning block font-medium">
|
||||
{t(meta.caveatKey)}
|
||||
</span>
|
||||
) : null}
|
||||
{t(meta.methodKey)}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<ProvenanceExplainer
|
||||
provenance={provenance}
|
||||
method={method}
|
||||
standard={meta.standard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Card shell — uppercase label + provenance affordance + body children. */
|
||||
function CardShell({
|
||||
provenance,
|
||||
children,
|
||||
}: {
|
||||
provenance?: DerivedProvenance;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
data-slot="trajectory-forecast-card"
|
||||
className="bg-card border-border flex min-h-48 w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground truncate text-xs font-medium tracking-wide uppercase">
|
||||
{t("insights.derived.trajectory.cardTitle")}
|
||||
</span>
|
||||
{provenance ? <TrajectoryProvenance provenance={provenance} /> : null}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** CLS-safe skeleton matching the resolved card geometry. */
|
||||
function CardSkeleton() {
|
||||
return (
|
||||
<div
|
||||
data-slot="trajectory-forecast-card-skeleton"
|
||||
aria-hidden="true"
|
||||
className="bg-card border-border flex min-h-48 w-full min-w-0 flex-col gap-2 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<div className="flex h-11 items-center justify-between gap-2">
|
||||
<div className="bg-muted/40 h-3 w-32 rounded" />
|
||||
<div className="bg-muted/40 h-5 w-5 rounded-full" />
|
||||
</div>
|
||||
<div className="bg-muted/40 h-24 w-full rounded" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrajectoryForecastCard({
|
||||
type,
|
||||
unit,
|
||||
valueScale = 1,
|
||||
color = "var(--chart-1)",
|
||||
enabled = true,
|
||||
className,
|
||||
}: TrajectoryForecastCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { data } = useDerivedMetric<TrajectoryValue>(TRAJECTORY_METRIC, {
|
||||
type,
|
||||
enabled,
|
||||
});
|
||||
|
||||
// Assemble the band chart series: the projected fan only (the engine
|
||||
// returns the projection points; the recent observed value anchors it as
|
||||
// the dashed line's left edge). Each projection point carries the band
|
||||
// `low`/`base` (= bandLow) + `range` (= bandHigh − bandLow) so a stacked
|
||||
// Area renders the fan, plus the projected line.
|
||||
const chart = useMemo(() => {
|
||||
if (!data || data.status !== "ok" || !data.value) return null;
|
||||
const v = data.value;
|
||||
const points = v.projection.map((p) => ({
|
||||
date: p.date,
|
||||
projected: scaled(p.projected, valueScale),
|
||||
base: scaled(p.bandLow, valueScale),
|
||||
range: scaled(p.bandHigh - p.bandLow, valueScale),
|
||||
}));
|
||||
// Anchor the fan at the last observed value so the dashed line departs
|
||||
// from "today" rather than floating.
|
||||
const anchor = {
|
||||
date: "now",
|
||||
projected: scaled(v.lastValue, valueScale),
|
||||
base: scaled(v.lastValue, valueScale),
|
||||
range: 0,
|
||||
};
|
||||
return { points: [anchor, ...points], lastValue: scaled(v.lastValue, valueScale) };
|
||||
}, [data, valueScale]);
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<CardSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Below the fit / history / staleness floor — calm "no trend to project"
|
||||
// state + coverage, never a weak line.
|
||||
if (data.status === "insufficient" || !chart) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<CardShell provenance={data.provenance}>
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="trajectory-insufficient"
|
||||
>
|
||||
{t("insights.derived.trajectory.insufficient")}
|
||||
</p>
|
||||
<CoverageMeter coverage={data.coverage} />
|
||||
</CardShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const v = data.value!;
|
||||
const DirectionIcon =
|
||||
v.direction === "up" ? TrendingUp : v.direction === "down" ? TrendingDown : Minus;
|
||||
const last = chart.points[chart.points.length - 1];
|
||||
const projectedLow = last.base;
|
||||
const projectedHigh = last.base + last.range;
|
||||
|
||||
// The headline is conditional by construction — "if this pattern
|
||||
// continues" — and quotes the RANGE, never a single certain number.
|
||||
const rangeText = t("insights.derived.trajectory.range", {
|
||||
low: Math.round(projectedLow * 10) / 10,
|
||||
high: Math.round(projectedHigh * 10) / 10,
|
||||
unit: unit ?? "",
|
||||
days: v.horizonDays,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<CardShell provenance={data.provenance}>
|
||||
<div className="flex items-start gap-2">
|
||||
<DirectionIcon
|
||||
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p
|
||||
className="text-foreground text-sm font-medium"
|
||||
data-slot="trajectory-headline"
|
||||
>
|
||||
{t("insights.derived.trajectory.headline", {
|
||||
days: v.horizonDays,
|
||||
})}
|
||||
</p>
|
||||
<p
|
||||
className="text-muted-foreground text-xs leading-snug"
|
||||
data-slot="trajectory-range"
|
||||
>
|
||||
{rangeText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The fan: a stacked transparent base + a shaded range Area, then
|
||||
the dashed projected line. The shaded band visibly widens toward
|
||||
the horizon — the uncertainty signal. */}
|
||||
<div className="h-28 w-full" data-slot="trajectory-chart">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={chart.points}
|
||||
margin={{ top: 4, right: 4, bottom: 0, left: 0 }}
|
||||
>
|
||||
<XAxis dataKey="date" hide />
|
||||
<YAxis domain={["auto", "auto"]} hide />
|
||||
<Area
|
||||
dataKey="base"
|
||||
stackId="band"
|
||||
stroke="none"
|
||||
fill="none"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Area
|
||||
dataKey="range"
|
||||
stackId="band"
|
||||
stroke="none"
|
||||
fill={color}
|
||||
fillOpacity={0.15}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Line
|
||||
dataKey="projected"
|
||||
type="monotone"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 3"
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<ReferenceLine
|
||||
x="now"
|
||||
stroke="var(--border)"
|
||||
strokeDasharray="2 2"
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* The mandatory conditional caveat — never a certainty, never a
|
||||
dated clinical event. */}
|
||||
<p
|
||||
className={cn("text-muted-foreground text-xs leading-snug")}
|
||||
data-slot="trajectory-caveat"
|
||||
>
|
||||
{t("insights.derived.trajectory.caveat")}
|
||||
</p>
|
||||
</CardShell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import type { ChartOverlayKey } from "@/lib/dashboard-layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { MetricStatusCard } from "@/components/insights/metric-status-card";
|
||||
import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory-forecast-card";
|
||||
import { isTrajectoryType } from "@/lib/insights/derived/registry";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
@@ -118,6 +120,17 @@ export interface HealthKitMetricPageProps {
|
||||
* is a compile error, not a silent 422.
|
||||
*/
|
||||
statusMetric?: MetricStatusMetricId;
|
||||
/**
|
||||
* v1.11.0 (Epic B, Pillar 3) — when `true`, mounts the short-horizon
|
||||
* `<TrajectoryForecastCard>` beneath the chart for this metric (the page's
|
||||
* `measurementType`). Opt-in: a page sets it only for a slow daily
|
||||
* physiological series where a conservative 7–14-day OLS projection reads
|
||||
* honestly. The card is only rendered on the data-bearing branch and only
|
||||
* when the type is a supported trajectory metric; the engine still gates
|
||||
* on R² / history / staleness, so a flat or noisy series shows the calm
|
||||
* "no trend to project yet" state rather than a line.
|
||||
*/
|
||||
forecast?: boolean;
|
||||
}
|
||||
|
||||
export function HealthKitMetricPage({
|
||||
@@ -136,6 +149,7 @@ export function HealthKitMetricPage({
|
||||
explainerMetric,
|
||||
targetSummarySlug,
|
||||
statusMetric,
|
||||
forecast = false,
|
||||
}: HealthKitMetricPageProps) {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
@@ -236,6 +250,15 @@ export function HealthKitMetricPage({
|
||||
enabled={!isEmpty}
|
||||
/>
|
||||
) : null}
|
||||
{forecast && isTrajectoryType(measurementType) ? (
|
||||
<TrajectoryForecastCard
|
||||
type={measurementType}
|
||||
unit={yAxisUnit ?? unit}
|
||||
valueScale={valueScale}
|
||||
color={color}
|
||||
enabled={!isEmpty}
|
||||
/>
|
||||
) : null}
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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":
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user