mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
`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.
336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
/**
|
||
* 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 },
|
||
});
|
||
}
|