Files
HealthLog/src/lib/insights/derived/baseline.ts
T
Marc-André Bombeck a9c2e25099 fix(rollups): trail the rollup window from the caller's moment
readBestGranularityRollups opened its window at the wall clock, while the
live fallback beside it used the caller's now. A read anchored on another
moment saw different days depending on whether the account already had
DAY buckets; the Today overview's integration test turned red once its
fixed morning fell out of the wall-clock window. Every caller that holds a
now passes it through.
2026-10-29 10:13:22 +01:00

459 lines
17 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.10.0 — the FLAGSHIP shared baseline engine (catalogue metric #1:
* personal typical-range / vitals baseline).
*
* `computeVitalsBaseline(userId, profile, opts)` is the one real metric
* Wave 1 ships end-to-end. It returns the band of values that is "normal
* for you" for a single vital, computed as a rolling personal baseline:
*
* - **center** = median of the per-day means over the window. The DAY
* means come from the rollup tier (`readBestGranularityRollups` at
* DAY granularity — `mean` composes linearly across DAY buckets, the
* `measurement-read-wmy.ts:29-39` contract) with a per-type bounded
* live-SQL fallback on a coverage miss.
* - **spread** = median ± k·MAD (median absolute deviation, k≈3), an
* outlier-robust ≈3σ-equivalent band. MAD is computed from the same
* DAY-native per-day series — NEVER from a recomposed WEEK/MONTH `sd`.
* This is the hard invariant: `sd`/`slope`/`r2` do not compose, so
* the spread is derived at native (DAY) granularity only.
*
* Method/standard: robust-statistics anomaly detection — Median Absolute
* Deviation (Hampel 1974, JASA 69(346):383–393; Leys et al. 2013, J. Exp.
* Soc. Psychol. 49(4):764–766: "do not use standard deviation around the
* mean, use the median absolute deviation around the median"). Framing:
* Apple Health "Vitals" typical-range — establish the band after ≥7 days,
* then today's reading is "in range" or "outside".
*
* Server-only — reads the rollup tier + raw rows via Prisma. The pure
* statistics helpers below are exported so the unit test can assert the
* composed-bucket baseline matches the raw-DAY baseline within tolerance.
*/
import {
resolveTrendVerdict,
type TrendVerdict,
} from "@/lib/targets/trend-verdict";
import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
import { readDayAggregates } from "@/lib/measurements/day-aggregates";
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
import { toProfileSex } from "@/lib/profile/sex";
import {
clampDerivedLowerBound,
minimumBandHalfWidth,
isPlausibleMetricValue,
plausibleMetricRange,
} from "@/lib/measurements/value-domain";
import type { ProfileSex } from "@/lib/profile/sex";
import {
probeRollupCoverage,
type RollupCoverageMap,
} from "@/lib/rollups/measurement-coverage";
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
import {
buildInsufficient,
buildOk,
deriveCoverage,
nowProvenanceTimestamp,
} from "./coverage";
import {
SPARKLINE_MAX_POINTS,
type Derived,
type DerivedProvenanceSource,
} from "./types";
import { dateOnlyKey } from "@/lib/tz/date-only";
/** k for the median ± k·MAD band — ≈3σ-equivalent for normal data. */
const DEFAULT_MAD_K = 3;
/** 1.4826 makes MAD a consistent estimator of σ under normality. */
const MAD_SIGMA_SCALE = 1.4826;
/** Default trailing window (days). Apple establishes the band after ≥7. */
const DEFAULT_WINDOW_DAYS = 30;
/** The successful `value` payload for a vitals baseline. */
export interface VitalsBaselineValue {
/** The vital this band describes. */
type: MeasurementType;
/** Robust center (median of the per-day means). */
center: number;
/**
* Band lower edge (center − k·MAD·scale, at least the metric's
* `minimumBandHalfWidth` below center), floored at 0 for a metric whose
* plausibility domain forbids negative values. `spread` below stays the
* unclamped dispersion, so a deviation-in-σ calculation is unaffected.
*/
low: number;
/** Band upper edge (center + k·MAD·scale, with the same minimum width). */
high: number;
/** The MAD-derived σ-equivalent spread (k applied; same units as the metric). */
spread: number;
/** Distinct days that contributed to the baseline. */
sampleDays: number;
/** k used for the band (echoed for transparency). */
k: number;
/**
* Trailing per-day mean series (oldest → newest), capped to the last
* `SPARKLINE_MAX_POINTS`. Drives the tile sparkline; reuses the rows the
* band is already computed from (no extra read).
*/
series: number[];
/**
* v1.43 (ios #116 item 3) — which way a change of this vital counts for
* the person, with the band's centre placed against their own target or a
* profile default (`src/lib/targets/trend-verdict.ts`). The tile colours
* its delta from this instead of a fixed polarity. Absent when the caller
* passed no profile facts to judge with.
*/
direction?: TrendVerdict;
}
/** Caller-supplied profile (read once per request, never re-fetched here). */
export interface BaselineProfile {
ageYears: number | null;
/**
* Sex as stored, `OTHER` included. The sex-split reference tables have
* no `OTHER` row, so `resolveSexRows` falls through to the sex-agnostic
* band or reports none — an honest omission. Narrowing the value away
* HERE instead would hand every consumer a `null` it cannot tell apart
* from "no answer".
*/
sex: ProfileSex;
/**
* Height in cm from `User.heightCm`, when set. Consumed by the BMI
* metric (weight ÷ height²); `null` when the profile has no height.
*/
heightCm?: number | null;
/**
* v1.43 — the date of birth and the person's own targets, which the trend
* verdict on `VITALS_BASELINE` is judged with. Optional: a caller that does
* not read them gets no verdict rather than a wrong one.
*/
dateOfBirth?: Date | null;
thresholdsJson?: unknown;
}
/**
* Build the `BaselineProfile` the baseline engine + readiness blend need from
* the `User` row. This is the one loader the persisting score engines and the
* live `/api/insights/derived` route share — previously each inlined a
* byte-identical `findUnique` + gender-narrow + `getAgeFromDateOfBirth`. The
* `prisma` arg is the caller's client (the nightly worker passes its shared
* connection; the route passes the request client) so both reads hit one
* pool. `now` is unused here but accepted so a future age-as-of-date refinement
* is a one-line change in this single place.
*/
export async function loadBaselineProfile(
prisma: PrismaClient,
userId: string,
): Promise<BaselineProfile> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
dateOfBirth: true,
gender: true,
heightCm: true,
thresholdsJson: true,
},
});
const sex = toProfileSex(user?.gender);
return {
ageYears: getAgeFromDateOfBirth(user?.dateOfBirth ?? null),
sex,
heightCm: user?.heightCm ?? null,
dateOfBirth: user?.dateOfBirth ?? null,
thresholdsJson: user?.thresholdsJson ?? null,
};
}
export interface VitalsBaselineOpts {
/** Which vital to baseline. */
type: MeasurementType;
/** Trailing window in days. Defaults to 30. */
windowDays?: number;
/** k for the MAD band. Defaults to 3. */
k?: number;
/** Compute time (injected for deterministic tests). */
now?: Date;
/**
* Pre-probed coverage map (one probe per request, shared across
* metrics — the pool-contention mitigation). When omitted the engine
* probes itself.
*/
coverage?: RollupCoverageMap;
}
/** A per-day mean point used to build the baseline. */
export interface DayMeanPoint {
day: string;
mean: number;
}
// ── pure statistics (exported for the parity test) ───────────────────
/** Median of a numeric array (does not mutate the input). */
export function median(values: number[]): number {
if (values.length === 0) return NaN;
const sorted = [...values].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
}
/** Median absolute deviation about the median (raw, unscaled). */
export function medianAbsoluteDeviation(values: number[]): number {
if (values.length === 0) return NaN;
const center = median(values);
const deviations = values.map((v) => Math.abs(v - center));
return median(deviations);
}
/**
* Build the median ± k·MAD band from a per-day mean series. Pure — the
* caller supplies the already-resolved DAY-native series so this can be
* unit-tested against both a raw-DAY series and a rollup-composed series
* (they must agree: DAY rollup `mean` equals the per-day raw mean).
*
* `type` names the metric the series belongs to and is REQUIRED, not
* optional: `center − spread` is arithmetic, and arithmetic does not know
* that a step, a flight, or a kilocalorie cannot be owed. On a metric with a
* wide relative spread the lower edge crosses zero and the strip underneath
* the chart reads "your usual range is −5,595.6–13,387.6 steps". The floor
* comes from `clampDerivedLowerBound`, which reads the metric's declared
* plausibility domain — the same fact the chart's y-axis clamps against, now
* answered in one place instead of two. Pass `null` only for a series with no
* metric identity; the band then keeps the raw arithmetic edge.
*/
export function buildBaselineBand(
dayMeans: number[],
type: MeasurementType | null,
k: number = DEFAULT_MAD_K,
): Omit<VitalsBaselineValue, "type" | "series"> | null {
if (dayMeans.length === 0) return null;
const center = median(dayMeans);
const mad = medianAbsoluteDeviation(dayMeans);
// Scale MAD to a σ-equivalent so the band reads like a robust ±kσ.
const spread = k * mad * MAD_SIGMA_SCALE;
// The edges never sit closer than the measurement's own day-to-day wobble:
// a run of identical days has no dispersion, and a zero-width band read
// "61–61 bpm" and put the next ordinary reading outside it. `spread` stays
// the measured dispersion for anything that reasons in σ.
const halfWidth = Math.max(spread, minimumBandHalfWidth(type));
return {
center,
low: clampDerivedLowerBound(type, center - halfWidth),
high: center + halfWidth,
spread,
sampleDays: dayMeans.length,
k,
};
}
// ── reads ────────────────────────────────────────────────────────────
/**
* Resolve the per-day mean series for `(userId, type)` over the window.
* Rollup tier first (DAY-native, `mean` composes); per-type bounded
* live-SQL fallback on a coverage miss. Returns the series plus the
* provenance source the read resolved against.
*
* Days are UTC days, the DAY rollup grain. A caller that compares the days
* with the user's own calendar (today's key, a record's local day) passes
* `timeZone`; the series is then folded live on the user's local days.
*/
export async function readDayMeanSeries(
userId: string,
type: MeasurementType,
windowDays: number,
now: Date,
coverage: RollupCoverageMap,
timeZone?: string,
): Promise<{ points: DayMeanPoint[]; source: DerivedProvenanceSource }> {
const hasBuckets = timeZone === undefined && coverage.get(type) === true;
if (hasBuckets) {
// DAY granularity only — the spread invariant forbids composing a
// band from WEEK/MONTH `sd`, and the center reads the DAY `mean`
// which composes exactly. `readBestGranularityRollups` with a
// window < 91 days resolves to DAY by construction.
const resolved = await readBestGranularityRollups(
userId,
type,
windowDays,
undefined,
now,
);
if (
resolved &&
resolved.granularity === "DAY" &&
resolved.rows.length > 0
) {
// A bucket mean is only as good as the rows it averaged. The rollup
// writer aggregates whatever is stored, so a reading outside the
// metric's plausibility domain rides straight through into the bucket
// and from there into the band. Drop those buckets on the same rule the
// live read applies to its rows, so the two paths agree about what
// counts as a measurement.
const points = resolved.rows
.filter((row) => isPlausibleMetricValue(type, row.mean))
.map((row) => ({
day: dateOnlyKey(row.bucketStart),
mean: row.mean,
}));
if (points.length > 0) return { points, source: "DAY" };
}
// Coverage probe said "has buckets" but the window resolved to a
// coarser tier or zero DAY rows → fall through to the live read so
// the spread is still DAY-native.
}
// Per-type live fallback — per-day means folded in SQL, so the read is
// one row per day however densely the metric is sampled (#1023). Only
// values inside the metric's own plausibility domain count: a single
// stored value the application declares impossible would drag the day's
// mean with it and the median would carry it into the band. A day whose
// every reading is implausible produces no point at all. Days are UTC
// days, matching the DAY rollup path above, unless the caller asked for
// the user's own days.
const since = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
const days = await readDayAggregates({
userId,
type,
since,
timeZone: timeZone ?? "UTC",
valueRange: plausibleMetricRange(type),
});
// A pulse day is the mean of its local hours' means (`dayMean`, see
// `day-mean.ts`); every other type's day is the mean of its readings.
const points = days.map((d) => ({
day: d.day,
mean: d.dayMean ?? d.sum / d.n,
}));
if (points.length === 0) {
return { points: [], source: "none" };
}
return { points, source: "live" };
}
/**
* FLAGSHIP — the one real Wave 1 metric. Pure `(userId, profile, opts) =>
* Promise<Derived<VitalsBaselineValue>>`: rolling personal baseline
* (median ± k·MAD) for a single vital, reading the rollup tier with a
* per-type live fallback on a coverage miss. Below the min-history floor
* it returns `insufficient` with coverage + provenance (never a
* fabricated band).
*/
export async function computeVitalsBaseline(
userId: string,
profile: BaselineProfile,
opts: VitalsBaselineOpts,
): Promise<Derived<VitalsBaselineValue>> {
const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS;
const k = opts.k ?? DEFAULT_MAD_K;
const now = opts.now ?? new Date();
const type = opts.type;
const minHistoryDays = 7;
const coverage = opts.coverage ?? (await probeRollupCoverage(userId));
const { points, source } = await readDayMeanSeries(
userId,
type,
windowDays,
now,
coverage,
);
const historyDays = points.length;
const computedAt = nowProvenanceTimestamp(now);
// No data at all — insufficient, source "none".
if (historyDays === 0) {
const { coverage: cov } = deriveCoverage({
requiredInputs: 1,
presentInputs: 0,
historyDays: 0,
missing: [String(type)],
fullHistoryDays: windowDays,
});
return buildInsufficient<VitalsBaselineValue>({
coverage: cov,
provenance: {
inputs: [String(type)],
source: "none",
windowDays,
computedAt,
},
reason: "no_readings_in_window",
});
}
// Below the band floor — value exists but not enough history for a
// robust band. Insufficient, but honest coverage + provenance so the
// card shows "building your typical range — N of 7 days".
if (historyDays < minHistoryDays) {
const { coverage: cov } = deriveCoverage({
requiredInputs: 1,
presentInputs: 1,
historyDays,
missing: [],
fullHistoryDays: windowDays,
});
return buildInsufficient<VitalsBaselineValue>({
coverage: cov,
provenance: { inputs: [String(type)], source, windowDays, computedAt },
reason: "insufficient_history_for_band",
});
}
const band = buildBaselineBand(
points.map((p) => p.mean),
type,
k,
);
if (!band) {
const { coverage: cov } = deriveCoverage({
requiredInputs: 1,
presentInputs: 1,
historyDays,
missing: [],
fullHistoryDays: windowDays,
});
return buildInsufficient<VitalsBaselineValue>({
coverage: cov,
provenance: { inputs: [String(type)], source, windowDays, computedAt },
reason: "band_computation_failed",
});
}
const { coverage: cov, confidence } = deriveCoverage({
requiredInputs: 1,
presentInputs: 1,
historyDays,
missing: [],
fullHistoryDays: windowDays,
});
// Trailing per-day mean series for the inline sparkline — the same DAY
// means the band is built from, capped to the recent window.
const series = points.slice(-SPARKLINE_MAX_POINTS).map((p) => p.mean);
// Judged only when the caller read the facts it is judged with; the
// nightly engines that never show a delta pass a bare profile.
const direction =
profile.dateOfBirth !== undefined
? resolveTrendVerdict(type, {
profile: {
dateOfBirth: profile.dateOfBirth,
gender: profile.sex,
heightCm: profile.heightCm ?? null,
},
thresholdsJson: profile.thresholdsJson ?? null,
referenceValue: band.center,
})
: undefined;
return buildOk<VitalsBaselineValue>({
value: { type, ...band, series, ...(direction ? { direction } : {}) },
coverage: cov,
confidence,
provenance: { inputs: [String(type)], source, windowDays, computedAt },
});
}