Files
HealthLog/src/lib/insights/graded-series.ts
T
Marc-André Bombeck 9c63d3ce20 chore(release): v1.28.25 — every integration held to the same standard
Platform-wide hardening: the failure classes found live this week hunted down
across every integration.

Fitbit carried the same tombstone wedge fixed for Google — probe now matches
soft-deleted rows and the update resurrects them; the comment describing a
partial unique index that never existed is corrected. Sleep segment ids are
stable across re-scoring for Withings (session+segment-start), WHOOP/Polar
(one segment per stage, no positional index) and Oura (run-start keyed), each
with a session-scoped sweep that soft-deletes rows a re-score orphaned — so
legacy and affected nights heal as they are re-read.

One platform rule for deleted rows: source-owned readings resurrect on
re-import (Withings, WHOOP, Oura, Polar, Nightscout, mood webhook, CSV
re-imports, Apple stats day-totals); Apple sample-grain deletions stick per
the paired-client contract, and the mood bulk route's tombstone match is now
a true no-op reported as duplicate. Import counts reconcile against actual
createMany counts.

Google lifecycle: a dead token registers on the cycle's hard-fail ledger, so
an expired connection parks instead of stamping success hourly; backfill and
sleep-repair markers stamp only after a clean pass (pg-boss retries are now
real); failed measurement writes hold the watermark; no-op updates are
skipped; mapping.md tells the current truth.

Scale: doctor-report reads only consumed columns and enabled sections;
exports and backups keyset-paginate; achievements tally via SQL buckets;
series charts day-bucket dense kinds beyond 90 days; BP pairing is a linear
two-pointer; distinct-type scans run server-side; the graded-series cold
fallback is bounded; the fitbit defer tracker loops instead of spreading.
2026-07-11 01:54:12 +02:00

620 lines
20 KiB
TypeScript

/**
* Shared graded-series compressor for AI insight snapshots.
*
* Background — see `.planning/research/ai-payload-compression-audit.md`.
* The seven per-card status generators embedded the FULL daily array
* per metric (`bucketSeries`, up to ~360 daily buckets + ~24 monthly)
* plus correlation pair arrays, and originally never touched the WEEK /
* MONTH / YEAR rollup tier. A daily-weigher's `weight-status` snapshot ran
* ~23 KB for weight alone; `general-status` across many Apple-Health
* types could blow past 100 K tokens. The Coach is the one path that
* already grades its timeline (recent days verbatim → weekly means →
* bounded window).
*
* As of v1.8.0 the focused single-metric cards (weight / pulse / bmi /
* blood-pressure) source their monthly / yearly slices from the MONTH /
* YEAR rollup tier via `buildGradedSeriesWithRollups`, so the coarse
* tiers finally earn their write amplification. The multi-metric
* `general-status` and the tier-less mood / adherence series stay on the
* bounded in-memory fold (`buildGradedSeriesFromPoints`).
*
* This module is the single graded shape every status path uses:
*
* recent last ~21 days daily min/max/mean/n (~14-21 rows)
* weekly the ~10 weeks before ISO wk min/max/mean/n (~8-10 rows)
* monthly the ~12 months before month min/max/mean (~9-12 rows)
* yearly everything older year mean/slope (~1-3 rows)
*
* ~30-45 rows per metric regardless of history depth, never an
* individual reading beyond `recent`.
*
* Two entry points:
* - `buildGradedSeriesFromPoints(points, now)` — pure, in-memory. The
* recent / weekly / monthly slices fold raw points; the yearly slice
* carries a least-squares slope. Used where the caller already holds
* the rows (and by the tests).
* - `buildGradedSeriesWithRollups(userId, type, now)` — sources the
* monthly / yearly slices from the pre-aggregated MONTH / YEAR
* rollup tier (`readBestGranularityRollups`) and the recent / weekly
* slices from a bounded raw read, so the coarse tiers finally earn
* their write amplification.
*
* The recent / weekly bucketing mirrors the Coach
* (`coach/snapshot.ts:bucketWeekly` + `buildDailyValueRows`). Those
* helpers are intentionally duplicated rather than lifted out of the
* Coach: the Coach contract must not move under it, and the shapes here
* carry min/max (the Coach only needs the mean). The two are small and
* independently testable.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
readBestGranularityRollups,
type RollupBucketRow,
} from "@/lib/rollups/measurement-read-wmy";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { toBerlinYmd } from "@/lib/tz/resolver";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** Days kept verbatim at daily granularity. */
const RECENT_DAYS = 21;
/** ISO weeks folded after the recent window. */
const WEEKLY_WEEKS = 10;
/** Calendar months folded after the weekly window. */
const MONTHLY_MONTHS = 12;
const RECENT_WINDOW_MS = RECENT_DAYS * MS_PER_DAY;
const WEEKLY_WINDOW_MS = (RECENT_DAYS + WEEKLY_WEEKS * 7) * MS_PER_DAY;
// Approximate the monthly horizon in days; the precise calendar-month
// boundary is handled by the YMD key, this only separates monthly from
// yearly buckets.
const MONTHLY_WINDOW_MS = WEEKLY_WINDOW_MS + MONTHLY_MONTHS * 30 * MS_PER_DAY;
/**
* v1.28.25 — bound on the cold-tier fallback read. The coverage-miss
* fallback used to `findMany` the FULL raw history with no window or
* take; on a CGM / per-sample-pulse account that had just imported years
* of data (exactly the state where the rollup tier is still cold) it
* pulled six-figure row sets into JS on a request path. 1095 days
* matches the yearly rollup router window below, so the cold path now
* covers the same horizon the warm path serves.
*/
const COLD_FALLBACK_WINDOW_DAYS = 1095;
/**
* Sample-dense types whose cold-tier fallback is day-bucketed in SQL
* (one AVG row per day) instead of read raw — same dense set the series
* route caps (CGM glucose ~288 rows/day, PULSE per-sample / hourly).
* The fold's output shape is unchanged; on the cold path only, a dense
* type's monthly/yearly min/max/n derive from day means rather than
* individual samples — the warm rollup tier restores sample-exact
* figures as soon as the backfill lands.
*/
const DENSE_FALLBACK_TYPES: ReadonlySet<MeasurementType> =
new Set<MeasurementType>(["BLOOD_GLUCOSE", "PULSE"]);
export interface RecentDayBucket {
/** Berlin YYYY-MM-DD. */
date: string;
min: number;
max: number;
mean: number;
n: number;
}
export interface WeeklyBucket {
/** ISO week key like 2026-W19 (Berlin-anchored). */
weekISO: string;
min: number;
max: number;
mean: number;
n: number;
}
export interface MonthlyBucket {
/** Berlin YYYY-MM. */
month: string;
min: number;
max: number;
mean: number;
n: number;
}
export interface YearlyBucket {
/** Berlin YYYY. */
year: string;
min: number;
max: number;
mean: number;
n: number;
/** Least-squares slope per reading order (null when < 2 points). */
slope: number | null;
}
export interface GradedSeries {
recent: RecentDayBucket[];
weekly: WeeklyBucket[];
monthly: MonthlyBucket[];
yearly: YearlyBucket[];
}
interface Point {
measuredAt: Date;
value: number;
}
function round(value: number, digits = 2): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
function berlinDayKey(date: Date): string {
const { year, month, day } = toBerlinYmd(date);
return `${year}-${month}-${day}`;
}
function berlinMonthKey(date: Date): string {
const { year, month } = toBerlinYmd(date);
return `${year}-${month}`;
}
function berlinYearKey(date: Date): string {
return toBerlinYmd(date).year;
}
/**
* ISO week key (Berlin-anchored). Mirrors the Coach's `isoWeekKey` but
* fixed to the user's Berlin display day so the week label agrees with
* the rest of the snapshot.
*/
function berlinIsoWeekKey(date: Date): string {
const { year, month, day } = toBerlinYmd(date);
const localMidnight = new Date(
Date.UTC(Number(year), Number(month) - 1, Number(day)),
);
const dayNum = localMidnight.getUTCDay() || 7;
localMidnight.setUTCDate(localMidnight.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(localMidnight.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil(
((localMidnight.getTime() - yearStart.getTime()) / MS_PER_DAY + 1) / 7,
);
return `${localMidnight.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
}
interface Agg {
sum: number;
min: number;
max: number;
n: number;
}
function foldInto(map: Map<string, Agg>, key: string, value: number): void {
const existing = map.get(key);
if (existing) {
existing.sum += value;
existing.n += 1;
if (value < existing.min) existing.min = value;
if (value > existing.max) existing.max = value;
} else {
map.set(key, { sum: value, min: value, max: value, n: 1 });
}
}
/**
* Least-squares slope of `values` against their index (0..n-1). Returns
* null for fewer than two points. The yearly trend signal — direction
* over the order the readings landed.
*/
function leastSquaresSlope(values: number[]): number | null {
const n = values.length;
if (n < 2) return null;
let sumX = 0;
let sumY = 0;
let sumXY = 0;
let sumXX = 0;
for (let i = 0; i < n; i++) {
sumX += i;
sumY += values[i];
sumXY += i * values[i];
sumXX += i * i;
}
const denom = n * sumXX - sumX * sumX;
if (denom === 0) return null;
return round((n * sumXY - sumX * sumY) / denom, 4);
}
/**
* Build the graded series purely in memory. `points` may carry several
* readings per day; same-period readings are folded into one bucket.
*/
export function buildGradedSeriesFromPoints(
points: Point[],
now: Date,
): GradedSeries {
const nowMs = now.getTime();
const recentAgg = new Map<string, Agg>();
const weeklyAgg = new Map<string, Agg>();
const monthlyAgg = new Map<string, Agg>();
// Yearly keeps ordered values for the slope, plus min/max/sum.
const yearlyValues = new Map<string, number[]>();
const yearlyAgg = new Map<string, Agg>();
for (const p of points) {
const ageMs = nowMs - p.measuredAt.getTime();
if (ageMs < 0) continue; // future reading — ignore
if (ageMs < RECENT_WINDOW_MS) {
foldInto(recentAgg, berlinDayKey(p.measuredAt), p.value);
} else if (ageMs < WEEKLY_WINDOW_MS) {
foldInto(weeklyAgg, berlinIsoWeekKey(p.measuredAt), p.value);
} else if (ageMs < MONTHLY_WINDOW_MS) {
foldInto(monthlyAgg, berlinMonthKey(p.measuredAt), p.value);
} else {
const yk = berlinYearKey(p.measuredAt);
foldInto(yearlyAgg, yk, p.value);
const list = yearlyValues.get(yk);
if (list) list.push(p.value);
else yearlyValues.set(yk, [p.value]);
}
}
const recent: RecentDayBucket[] = Array.from(recentAgg.entries())
.map(([date, a]) => ({
date,
min: round(a.min),
max: round(a.max),
mean: round(a.sum / a.n),
n: a.n,
}))
.sort((x, y) => x.date.localeCompare(y.date));
const weekly: WeeklyBucket[] = Array.from(weeklyAgg.entries())
.map(([weekISO, a]) => ({
weekISO,
min: round(a.min),
max: round(a.max),
mean: round(a.sum / a.n),
n: a.n,
}))
.sort((x, y) => x.weekISO.localeCompare(y.weekISO));
const monthly: MonthlyBucket[] = Array.from(monthlyAgg.entries())
.map(([month, a]) => ({
month,
min: round(a.min),
max: round(a.max),
mean: round(a.sum / a.n),
n: a.n,
}))
.sort((x, y) => x.month.localeCompare(y.month));
const yearly: YearlyBucket[] = Array.from(yearlyAgg.entries())
.map(([year, a]) => ({
year,
min: round(a.min),
max: round(a.max),
mean: round(a.sum / a.n),
n: a.n,
// Order the readings oldest → newest for the slope. The map kept
// insertion order; `points` arrives caller-ordered, so re-sort by
// pushing in chronological order is unnecessary — the slope is a
// direction signal, not a calibrated rate.
slope: leastSquaresSlope(yearlyValues.get(year) ?? []),
}))
.sort((x, y) => x.year.localeCompare(y.year));
return { recent, weekly, monthly, yearly };
}
/**
* Project a rollup bucket row to a monthly/yearly graded bucket. The
* tier already carries min/max/mean/slope per bucket — no JS folding.
*/
function rollupMonthly(rows: RollupBucketRow[]): MonthlyBucket[] {
return rows.map((r) => ({
month: berlinMonthKey(r.bucketStart),
min: round(r.minValue),
max: round(r.maxValue),
mean: round(r.mean),
n: r.count,
}));
}
function rollupYearly(rows: RollupBucketRow[]): YearlyBucket[] {
return rows.map((r) => ({
year: berlinYearKey(r.bucketStart),
min: round(r.minValue),
max: round(r.maxValue),
mean: round(r.mean),
n: r.count,
slope: r.slope === null ? null : round(r.slope, 4),
}));
}
/**
* Build the graded series with the recent / weekly slices folded from a
* bounded raw read and the monthly / yearly slices read from the
* pre-aggregated rollup tier.
*
* The recent / weekly read is capped to the recent + weekly horizon
* (last ~90 days), so the common warm-tier path never pulls the full
* multi-year history into memory. The rollup router picks MONTH for the
* ~1-year window and YEAR for the multi-year tail.
*
* Coverage-miss fallback: when the tier has no MONTH (resp. YEAR)
* coverage for a metric — a fresh account the boot-backfill has not yet
* caught up on — the bounded ~90-day read can NOT supply the monthly /
* yearly slices (every row in it folds into recent / weekly), so naively
* reusing it would ship empty monthly / yearly arrays even when years of
* raw history exist. On that miss this falls back to an in-memory fold
* over a bounded window (1095 days — the yearly router's own horizon;
* sample-dense types are additionally day-bucketed in SQL) so the coarse
* slices are populated from the raw rows rather than left falsely empty.
* The fallback read only happens when the tier is cold, which is exactly
* the case the write-amplified tier was meant to avoid — so the hot path
* stays bounded and the cold path stays correct.
*/
export async function buildGradedSeriesWithRollups(
userId: string,
type: MeasurementType,
now: Date,
): Promise<GradedSeries> {
const since = new Date(now.getTime() - WEEKLY_WINDOW_MS);
const rawRecent = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null, measuredAt: { gte: since } },
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
const recentGraded = buildGradedSeriesFromPoints(
rawRecent.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
now,
);
// v1.11.2 — load the source-priority blob once and thread it into both
// granularity reads so they don't re-query the user.
const priority = await loadUserSourcePriority(userId);
// Monthly slice: route a ~1-year window through the tier.
const monthlyRouted = await readBestGranularityRollups(
userId,
type,
365,
priority,
);
// Yearly slice: route a multi-year window through the tier.
const yearlyRouted = await readBestGranularityRollups(
userId,
type,
1095,
priority,
);
const monthlyCovered =
monthlyRouted !== null && monthlyRouted.granularity === "MONTH";
const yearlyCovered =
yearlyRouted !== null && yearlyRouted.granularity === "YEAR";
let monthly: MonthlyBucket[];
let yearly: YearlyBucket[];
if (monthlyCovered) {
// Drop the months already covered by recent/weekly (last ~90 d).
const cutoff = new Date(now.getTime() - WEEKLY_WINDOW_MS);
monthly = rollupMonthly(
monthlyRouted.rows.filter((r) => r.bucketStart < cutoff),
).slice(-MONTHLY_MONTHS);
} else {
monthly = [];
}
if (yearlyCovered) {
// Keep only years older than the monthly horizon.
const cutoff = new Date(now.getTime() - MONTHLY_WINDOW_MS);
yearly = rollupYearly(
yearlyRouted.rows.filter((r) => r.bucketStart < cutoff),
);
} else {
yearly = [];
}
// Tier coverage miss for one or both coarse slices: the bounded
// ~90-day read can't supply them, so fold a BOUNDED history window in
// memory and take whichever slices the tier left empty. The fallback
// read is the exception (cold-tier accounts), never the warm-tier
// norm. v1.28.25 — the read is bounded to the same 1095-day horizon
// the yearly rollup router serves (it was previously unwindowed), and
// the sample-dense types are day-bucketed in SQL so a heavy import
// ahead of the rollup backfill can't drag six-figure row sets into JS.
if (!monthlyCovered || !yearlyCovered) {
const fallbackSince = new Date(
now.getTime() - COLD_FALLBACK_WINDOW_DAYS * MS_PER_DAY,
);
let fallbackPoints: Point[];
if (DENSE_FALLBACK_TYPES.has(type)) {
// Parameter-bound day-bucket aggregate, mirroring the rollup tier's
// `date_trunc(...) GROUP BY` (measurement-rollups.ts) — at most
// ~1095 rows regardless of sample density.
const bucketRows = await prisma.$queryRaw<
Array<{ bucket_start: Date; mean: number }>
>`
SELECT
date_trunc('day', m."measured_at") AS bucket_start,
AVG(m."value")::double precision AS mean
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."type" = ${type}::"measurement_type"
AND m."measured_at" >= ${fallbackSince}
AND m."deleted_at" IS NULL
GROUP BY date_trunc('day', m."measured_at")
ORDER BY bucket_start ASC
`;
fallbackPoints = bucketRows.map((r) => ({
measuredAt: r.bucket_start,
value: r.mean,
}));
} else {
const rows = await prisma.measurement.findMany({
where: {
userId,
type,
deletedAt: null,
measuredAt: { gte: fallbackSince },
},
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
fallbackPoints = rows.map((r) => ({
measuredAt: r.measuredAt,
value: r.value,
}));
}
const fullGraded = buildGradedSeriesFromPoints(fallbackPoints, now);
if (!monthlyCovered) monthly = fullGraded.monthly;
if (!yearlyCovered) yearly = fullGraded.yearly;
}
return {
recent: recentGraded.recent,
weekly: recentGraded.weekly,
monthly,
yearly,
};
}
/**
* Apply a constant multiplier to every value in a graded series, keeping
* the bucket keys / counts intact. BMI = weight ÷ height² is a linear
* transform of weight by a per-user constant, so the BMI status card can
* read the WEIGHT rollup tier and scale it rather than maintaining a
* separate (non-existent) BMI tier — `mean / min / max / slope` all carry
* the factor exactly, so the scaled series is identical to folding the
* derived BMI points directly.
*/
export function scaleGradedSeries(
series: GradedSeries,
factor: number,
digits = 2,
): GradedSeries {
const r = (v: number) => round(v * factor, digits);
return {
recent: series.recent.map((b) => ({
...b,
min: r(b.min),
max: r(b.max),
mean: r(b.mean),
})),
weekly: series.weekly.map((b) => ({
...b,
min: r(b.min),
max: r(b.max),
mean: r(b.mean),
})),
monthly: series.monthly.map((b) => ({
...b,
min: r(b.min),
max: r(b.max),
mean: r(b.mean),
})),
yearly: series.yearly.map((b) => ({
...b,
min: r(b.min),
max: r(b.max),
mean: r(b.mean),
slope: b.slope === null ? null : round(b.slope * factor, 4),
})),
};
}
/**
* Assembled-snapshot soft char cap, mirroring the Coach's
* `MAX_SNAPSHOT_CHARS` (~24 000 chars ≈ ~6 000 tokens against the
* pretty-printed form). The status snapshots had no cap at all; a
* many-metric `general-status` could balloon past 100 K tokens.
*/
export const MAX_SNAPSHOT_CHARS = 24_000;
/**
* Walk an arbitrary snapshot object and, while it exceeds
* `MAX_SNAPSHOT_CHARS` (measured against the pretty-printed form the
* prompt ships), shed the lowest-signal slices in order:
* 1. drop every graded `yearly` array,
* 2. drop every graded `weekly` array,
* 3. truncate any other array to its tail `arrayTailCap` entries
* (correlation pair arrays, paired-daily rows, …),
* 4. drop every graded `recent` array (last resort — the coarse
* monthly summary survives).
*
* Mutates `snapshot` in place and returns the slices it shed so the
* caller can annotate. Generic by design: it keys off the graded field
* names + array length, never off a specific generator's shape.
*/
export function degradeStatusSnapshotToBudget(
snapshot: Record<string, unknown>,
arrayTailCap = 30,
): string[] {
const shed: string[] = [];
const size = () => JSON.stringify(snapshot, null, 2).length;
if (size() <= MAX_SNAPSHOT_CHARS) return shed;
const walk = (
node: unknown,
fn: (parent: Record<string, unknown>, key: string, value: unknown) => void,
): void => {
if (Array.isArray(node)) {
for (const item of node) walk(item, fn);
return;
}
if (node && typeof node === "object") {
const obj = node as Record<string, unknown>;
for (const key of Object.keys(obj)) {
fn(obj, key, obj[key]);
walk(obj[key], fn);
}
}
};
const dropField = (field: string) => {
walk(snapshot, (parent, key) => {
if (key === field && Array.isArray(parent[key])) {
delete parent[key];
}
});
};
dropField("yearly");
if (size() <= MAX_SNAPSHOT_CHARS) {
shed.push("yearly");
return shed;
}
shed.push("yearly");
dropField("weekly");
if (size() <= MAX_SNAPSHOT_CHARS) {
shed.push("weekly");
return shed;
}
shed.push("weekly");
// Truncate every remaining non-graded array to its tail.
walk(snapshot, (parent, key, value) => {
if (
Array.isArray(value) &&
key !== "recent" &&
value.length > arrayTailCap
) {
parent[key] = value.slice(-arrayTailCap);
}
});
if (size() <= MAX_SNAPSHOT_CHARS) {
shed.push("array-tails");
return shed;
}
shed.push("array-tails");
dropField("recent");
shed.push("recent");
return shed;
}