Files
HealthLog/src/lib/rollups/tiered-context.ts
T
Marc-André Bombeck 3fe728c20b fix(tz): key the tiered raw daily band on the user's local day
The 0–14d raw band folded same-day readings on a UTC calendar key, so a
late-evening reading slipped onto the wrong day for non-UTC users and
across DST transitions. Key it on userDayKey(tz) instead and make the
timezone a required option so a caller cannot silently fall back to UTC.

The coarser DAY/WEEK/MONTH/YEAR bands stay UTC-anchored on purpose: the
rollup writer and the live aggregator both bucket on the UTC calendar
day and are parity-locked for the read-swap fallback. Document the split
and thread the resolved user timezone from the coach snapshot caller.
2026-06-21 10:39:11 +02:00

419 lines
13 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.18.7 — progressive / tiered data-context builder for the AI surfaces.
*
* The principle (research/coach-prompt-tiering.md §3): feed the model the
* RIGHT RESOLUTION of history — recent dense, older progressively coarser —
* plus a preserved peak/anomaly envelope so a sharp reading from months ago
* survives even when its month is a single coarse bucket. Downsampling
* silently eats peaks unless you keep the min/max envelope; our rollup
* buckets already carry `minValue` / `maxValue`, so the envelope is free.
*
* Five progressive bands per metric:
*
* | Band | Source | Resolution |
* |-----------|-----------------------|----------------------|
* | 0–14d | raw `Measurement` | daily points |
* | 14–30d | DAY rollup | daily mean + min/max |
* | 30–90d | WEEK rollup | weekly mean + slope |
* | 90d–1y | MONTH rollup | monthly mean + min/max |
* | >1y | YEAR rollup | yearly mean + min/max |
*
* plus a per-metric `anomalies` list (≤5) ranked by `|deltaSd|`.
*
* Coverage is "replace, fall back on miss": `probeRollupCoverage` /
* `ensureUserRollupsFresh` run first; on a per-type coverage miss the band
* falls back to live SQL exactly as the existing readers do (the readers
* themselves never throw — an empty result simply yields an empty band).
*
* ~600 tokens / metric at ~4 chars/token: ≈14 raw + ≈16 DAY + ≈9 WEEK +
* ≈12 MONTH + ≤5 YEAR + ≤5 anomalies.
*/
import { prisma } from "@/lib/db";
import type {
MeasurementType,
RollupGranularity,
} from "@/generated/prisma/client";
import { userDayKey } from "@/lib/tz/format";
import {
ensureUserRollupsFresh,
readRollupBuckets,
} from "./measurement-rollups";
import { probeRollupCoverage } from "./measurement-coverage";
const DAY_MS = 24 * 60 * 60 * 1000;
/** Band cut points in days-ago. */
export const TIERED_BANDS = {
rawDays: 14,
dayUntil: 30,
weekUntil: 90,
monthUntil: 365,
yearUntil: 5 * 365,
} as const;
/** One raw daily point in the 0–14d band. */
export interface TieredRawPoint {
/**
* `YYYY-MM-DD` of the day the point summarises, keyed in the user's
* timezone (`options.tz`). A 23:30-local reading on a DST-changeover
* night lands on the correct local calendar day, not the UTC day.
*/
date: string;
/** Mean of that day's readings (a single point for most metrics). */
value: number;
count: number;
}
/** One coarser bucket carrying the min/max envelope that keeps peaks alive. */
export interface TieredBucket {
/** ISO timestamp anchored to the bucket start (date_trunc semantics). */
bucketStart: string;
mean: number;
min: number;
max: number;
count: number;
/** Units-per-day trend slope when the rollup carried one. */
slope?: number | null;
}
/** A peak/trough preserved from the envelope so a coarse band cannot hide it. */
export interface TieredAnomaly {
band: "raw" | "day" | "week" | "month" | "year";
/** ISO date of the extreme bucket. */
date: string;
kind: "peak" | "trough";
value: number;
/** Standard deviations from the window mean, signed. */
deltaSd: number;
}
export interface TieredSeries {
type: MeasurementType;
/** 0–14d raw daily points. */
recentDaily: TieredRawPoint[];
/** 14–30d DAY-rollup buckets. */
dayBand: TieredBucket[];
/** 30–90d WEEK-rollup buckets. */
weekBand: TieredBucket[];
/** 90d–1y MONTH-rollup buckets. */
monthBand: TieredBucket[];
/** >1y YEAR-rollup buckets. */
yearBand: TieredBucket[];
/** Preserved peaks/troughs, ≤5, ranked by |deltaSd|. */
anomalies: TieredAnomaly[];
/** Window-level stats used for anomaly scoring + a quick baseline read. */
windowStats: {
mean: number | null;
sd: number | null;
n: number;
};
}
export interface BuildTieredSeriesOptions {
/**
* The user's IANA timezone. REQUIRED — the raw 0–14d band keys its
* daily fold on the user's local calendar day (`userDayKey`) so a
* late-evening reading lands on the right day across DST boundaries
* and for non-UTC users. The coarser DAY/WEEK/MONTH/YEAR bands read
* straight from the rollup table, whose `bucketStart` is UTC-anchored
* by system-wide design (the live aggregator's `date_trunc` and the
* rollup writer agree on UTC day boundaries — see
* `start-of-utc-day.ts`); re-keying those into `tz` here would diverge
* from the parity-locked read-swap fallback, so they are intentionally
* left UTC. Callers without a resolved zone should pass
* `DEFAULT_TIMEZONE`.
*/
tz: string;
/** Clock override for tests; defaults to `Date.now()`. */
now?: number;
/**
* Skip the `ensureUserRollupsFresh` recompute (the caller already ran it
* for the batch, or is on a hot path). Coverage is still probed.
*/
skipEnsureFresh?: boolean;
/**
* Cap on anomalies per metric. Defaults to 5. The list is ranked by
* `|deltaSd|` before the cap.
*/
maxAnomalies?: number;
/**
* v1.18.7 — skip the raw 0–14d `Measurement` read and return an empty
* `recentDaily`. For callers (the Coach snapshot) that already hold the
* recent daily rows from their own query and only need the coarser
* MONTH/YEAR bands + anomaly envelope; avoids a duplicate raw round-trip.
*/
skipRecentDaily?: boolean;
/**
* v1.18.7 — skip the raw 0–14d read AND the DAY (14–30d) / WEEK (30–90d)
* band reads, returning only the MONTH / YEAR bands plus the anomaly
* envelope. For the Coach's `buildCoarseTimelineTail`, which consumes only
* month/year + anomalies; the skipped near-term bands would otherwise be
* read and discarded. The anomaly window-stats then span the coarse bands
* only, which is the right baseline for the deep-history tail. Implies
* `skipRecentDaily`.
*/
coarseOnly?: boolean;
}
interface RollupBucket {
bucketStart: Date;
count: number;
mean: number;
minValue: number;
maxValue: number;
sd: number | null;
slope: number | null;
r2: number | null;
}
function toBucket(r: RollupBucket): TieredBucket {
return {
bucketStart: r.bucketStart.toISOString(),
mean: round(r.mean),
min: round(r.minValue),
max: round(r.maxValue),
count: r.count,
slope: r.slope,
};
}
function round(v: number, digits = 2): number {
const f = 10 ** digits;
return Math.round(v * f) / f;
}
/** Window mean + population SD over every bucket's mean. */
function windowStats(buckets: RollupBucket[]): {
mean: number | null;
sd: number | null;
n: number;
} {
const usable = buckets.filter((b) => b.count >= 1);
const n = usable.reduce((s, b) => s + b.count, 0);
if (usable.length === 0) return { mean: null, sd: null, n: 0 };
const means = usable.map((b) => b.mean);
const mean = means.reduce((s, m) => s + m, 0) / means.length;
if (means.length < 2) return { mean: round(mean), sd: null, n };
const variance =
means.reduce((s, m) => s + (m - mean) ** 2, 0) / means.length;
return { mean: round(mean), sd: round(Math.sqrt(variance)), n };
}
/**
* Scan every bucket's min/max envelope and emit the buckets whose extreme
* exceeds `mean ± 2·sd`. The min/max-envelope technique guarantees a spike
* from months ago survives a coarse MONTH/YEAR bucket. Requires `count ≥ 3`
* and a usable spread to suppress sparse-metric false positives.
*/
function extractAnomalies(
bands: Array<{ band: TieredAnomaly["band"]; buckets: RollupBucket[] }>,
stats: { mean: number | null; sd: number | null },
cap: number,
): TieredAnomaly[] {
if (stats.mean === null || stats.sd === null || stats.sd <= 0) return [];
const { mean, sd } = stats;
const out: TieredAnomaly[] = [];
for (const { band, buckets } of bands) {
for (const b of buckets) {
if (b.count < 3) continue;
const highSd = (b.maxValue - mean) / sd;
const lowSd = (b.minValue - mean) / sd;
if (highSd >= 2) {
out.push({
band,
date: b.bucketStart.toISOString(),
kind: "peak",
value: round(b.maxValue),
deltaSd: round(highSd, 1),
});
}
if (lowSd <= -2) {
out.push({
band,
date: b.bucketStart.toISOString(),
kind: "trough",
value: round(b.minValue),
deltaSd: round(lowSd, 1),
});
}
}
}
return out
.sort((a, b) => Math.abs(b.deltaSd) - Math.abs(a.deltaSd))
.slice(0, cap);
}
/**
* Read the raw 0–14d daily points for one type, folding same-day rows.
* Days are keyed on the user's local calendar via `userDayKey(tz)`, so a
* 23:30-local reading folds onto its local day rather than the UTC day —
* correct across DST transitions and for non-UTC users.
*/
async function readRecentDaily(
userId: string,
type: MeasurementType,
now: number,
tz: string,
): Promise<TieredRawPoint[]> {
const since = new Date(now - TIERED_BANDS.rawDays * DAY_MS);
const rows = await prisma.measurement.findMany({
where: { userId, type, measuredAt: { gte: since }, deletedAt: null },
select: { value: true, measuredAt: true },
orderBy: { measuredAt: "asc" },
});
const byDay = new Map<string, { sum: number; count: number }>();
for (const r of rows) {
const key = userDayKey(r.measuredAt, tz);
const entry = byDay.get(key) ?? { sum: 0, count: 0 };
entry.sum += r.value;
entry.count += 1;
byDay.set(key, entry);
}
return [...byDay.entries()]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([date, { sum, count }]) => ({
date,
value: round(sum / count),
count,
}));
}
async function readBand(
userId: string,
type: MeasurementType,
granularity: RollupGranularity,
fromDaysAgo: number,
toDaysAgo: number,
now: number,
): Promise<RollupBucket[]> {
const from = new Date(now - fromDaysAgo * DAY_MS);
const to = new Date(now - toDaysAgo * DAY_MS);
// `readRollupBuckets` never throws — an empty / missing partition yields
// an empty array, which is the "fall back to nothing for this band"
// behaviour the coverage miss path relies on.
return readRollupBuckets(userId, type, granularity, from, to);
}
/**
* Build the 5-band progressive context + anomaly envelope for one metric.
*
* Reuses `probeRollupCoverage` / `ensureUserRollupsFresh` (replace, fall
* back on miss). The coarse bands read straight from the rollup table; the
* 0–14d band reads raw rows so the most recent days are exact.
*/
export async function buildTieredSeries(
userId: string,
type: MeasurementType,
options: BuildTieredSeriesOptions,
): Promise<TieredSeries> {
const now = options.now ?? Date.now();
const cap = options.maxAnomalies ?? 5;
if (!options.skipEnsureFresh) {
// Recompute a stale trailing-90-day DAY window before reading; the helper
// is in-flight-deduped and swallows its own errors.
await ensureUserRollupsFresh(userId);
}
const coarseOnly = options.coarseOnly ?? false;
const emptyBand = Promise.resolve<RollupBucket[]>([]);
const [recentDaily, dayBand, weekBand, monthBand, yearBand] =
await Promise.all([
options.skipRecentDaily || coarseOnly
? Promise.resolve<TieredRawPoint[]>([])
: readRecentDaily(userId, type, now, options.tz),
coarseOnly
? emptyBand
: readBand(
userId,
type,
"DAY",
TIERED_BANDS.dayUntil,
TIERED_BANDS.rawDays,
now,
),
coarseOnly
? emptyBand
: readBand(
userId,
type,
"WEEK",
TIERED_BANDS.weekUntil,
TIERED_BANDS.dayUntil,
now,
),
readBand(
userId,
type,
"MONTH",
TIERED_BANDS.monthUntil,
TIERED_BANDS.weekUntil,
now,
),
readBand(
userId,
type,
"YEAR",
TIERED_BANDS.yearUntil,
TIERED_BANDS.monthUntil,
now,
),
]);
// Window stats span every coarse bucket so the anomaly threshold reflects
// the user's whole logged history, not just one band.
const allBuckets = [...dayBand, ...weekBand, ...monthBand, ...yearBand];
const stats = windowStats(allBuckets);
const anomalies = extractAnomalies(
[
{ band: "day", buckets: dayBand },
{ band: "week", buckets: weekBand },
{ band: "month", buckets: monthBand },
{ band: "year", buckets: yearBand },
],
stats,
cap,
);
return {
type,
recentDaily,
dayBand: dayBand.map(toBucket),
weekBand: weekBand.map(toBucket),
monthBand: monthBand.map(toBucket),
yearBand: yearBand.map(toBucket),
anomalies,
windowStats: stats,
};
}
/**
* Convenience batch — build tiered series for several metrics, probing
* coverage once and recomputing freshness once for the whole set. Types with
* no rollup coverage AND no raw rows simply come back with empty bands.
*/
export async function buildTieredSeriesForTypes(
userId: string,
types: MeasurementType[],
options: BuildTieredSeriesOptions,
): Promise<TieredSeries[]> {
const now = options.now ?? Date.now();
if (!options.skipEnsureFresh) {
await ensureUserRollupsFresh(userId);
}
// Probe coverage once so the caller can reason about which bands are
// rollup-backed vs raw-only; the per-type builder still falls back on miss.
const coverage = await probeRollupCoverage(userId);
const present = types.filter((t) => coverage.has(t));
return Promise.all(
present.map((type) =>
buildTieredSeries(userId, type, {
...options,
now,
skipEnsureFresh: true,
}),
),
);
}