Files
HealthLog/src/lib/insights/derived/trajectory.ts
T
Marc-André Bombeck 7c5ff89def feat(lint): refuse UTC day keys outside the time zone module
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC
midnight are the idioms behind every day-boundary fix this year: the
evening west of UTC and the small hours east of it land on the
neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in
src/ outside src/lib/tz and tests, and refuses a disable directive for
itself that is not next-line or gives no reason.

The time zone module now names every legitimate form: userDayKey for a
person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for
date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey /
isCalendarDateKey / statedDateKey for key arithmetic and parsing.

Of the 133 sites the rule found, 84 are gone. Most moved onto a named
helper with no change in behaviour; these changed behaviour:

- cycle calendar, insights, Coach snapshot and prediction refresh start
  their lookbacks at the first instant of the user's day
- a model-transcribed date like "June 10, 2026" no longer lands a day
  early in a process east of UTC (documents and lab scans)
- a custom report range covers exactly the picked days in the profile
  zone instead of starting the evening before west of UTC
- date-of-birth pickers cap at the user's today, not the UTC one
- an allergy onset date is stored at noon UTC like other date-only values
- the report's cycle summary, the Coach's usual BP range and the intake
  projector's rollup recompute use the user's day
- vaccination dates render as dates instead of the previous evening

49 remain, each with its reason on the line above: 39 UTC by design (15
file name stamps, rollup and object keys, the UTC-day score engines) and
10 marked baseline. A guard test requires a classified reason on every
directive and holds the baseline to a ceiling that only goes down.
2026-10-01 09:59:57 +02:00

336 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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";
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
/** 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 xs = points.map((p) => daysBetweenDateKeys(points[0].day, p.day));
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 lastDay = points[points.length - 1].day;
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: shiftDateKey(lastDay, h),
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 },
});
}