Files
HealthLog/src/lib/ai/coach/snapshot.ts
T
Marc-André Bombeck b0be36c55b feat(coach): carry discovered drivers in the no-tools snapshot
The FDR cross-metric driver pairs were reachable only through the
get_correlations tool, so a local/no-tools provider got the coincident
flag but zero discovered drivers and the cross-metric layer silently
degraded. The system prompt's fallback to a snapshot driver field
pointed at a field that did not exist on that path.

Attach a bounded top-3 slice of the same gated, ranked driver output the
tool serves to the no-tools snapshot floor, gated on the derived layer
and shed first under prompt-budget pressure. Local users now reach
parity on the flagship cross-metric capability.
2026-06-26 13:12:34 +02:00

2589 lines
103 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.
/**
* Snapshot builder for the Coach prompt.
*
* Reuses the analytics features pipeline so the Coach narrates the
* exact same numbers the dashboard tiles render — single source of
* truth for every "your avg30 BP is …" claim. The output is a compact
* JSON block the system + user prompt frame as the SNAPSHOT for the
* model to ground its reply in.
*
* v1.4.20.1 — extended with a day-level `timeline` block. The earlier
* shipping shape carried only aggregated statistics (mean, slope, SD,
* range, count) per metric, so a Coach turn could not answer questions
* keyed to a specific day or weekday ("why was BP higher last
* Monday?"). The timeline now ships the last 14 days as raw daily
* values with weekday labels and aggregates the older window into
* weekly buckets so the prompt budget stays tight.
*/
import { prisma } from "@/lib/db";
import { extractFeatures } from "@/lib/insights/features";
import {
parseCoachPrefs,
type CoachExcludeMetric,
type CoachDataCluster,
} from "@/lib/validations/coach-prefs";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { userDayKey } from "@/lib/tz/format";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import {
computeGlucoseClinicalMetrics,
GLUCOSE_PANEL_WINDOW_DAYS,
} from "@/lib/analytics/glucose-metrics";
import {
reconstructSleepNights,
type SleepStageRow,
} from "@/lib/analytics/sleep-night";
import {
reconstructNights,
sleepNeedMinutes,
} from "@/lib/insights/derived/sleep-score";
import {
computeSleepRhythmFromNights,
type SleepRhythmDto,
} from "@/lib/insights/derived/sleep-rhythm";
import { compactSections } from "@/lib/ai/prompts/compact-sections";
import { annotate } from "@/lib/logging/context";
import { memoizePerRequest } from "@/lib/request-cache";
import { buildGlp1SnapshotBlock } from "./glp1-snapshot";
import { buildDerivedSnapshotBlock } from "./derived-snapshot";
import { buildCorrelationsSnapshotBlock } from "./correlations-snapshot";
import { buildCoachMemoryBlock } from "./memory-snapshot";
import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot";
import { buildCycleSnapshotBlock } from "./cycle-snapshot";
import { buildIllnessSnapshotBlock } from "./illness-snapshot";
import { buildLabsSnapshotBlock } from "./labs-snapshot";
import {
buildReferenceGroundingBlock,
type GroundingMetricInput,
} from "./reference-grounding";
import { buildTieredSeries } from "@/lib/rollups/tiered-context";
import type { MeasurementType } from "@/generated/prisma/client";
import type { ReferenceMetric } from "@/lib/reference-ranges";
import { isCycleAvailableForUser } from "@/lib/cycle/gate";
import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate";
import {
buildComplianceLedgerRows,
buildComplianceMedicationContext,
calculateCompliance,
lastNonSkippedTakenAt,
SCHEDULE_COMPLIANCE_SELECT,
} from "@/lib/analytics/compliance";
import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history";
import type { BaselineProfile } from "@/lib/insights/derived";
import {
CLUSTER_PRIORITY,
clusterSourcesFromPrefs,
sourceCluster,
} from "./clusters";
import type {
CoachProvenance,
CoachProvenanceMetric,
CoachScope,
CoachScopeSource,
CoachScopeWindow,
} from "./types";
export interface CoachSnapshotResult {
snapshotJson: string;
/**
* v1.20.0 (F1) — the structured, post-degrade snapshot record `snapshotJson`
* is serialised from. Keyed by domain block (`bloodPressure`, `weight`,
* `pulse`, `mood`, `compliance`, `glucose`, `sleep`, `sleepRhythm`, `labs`,
* `illness`, `derived`, `dayStrain`, `trajectory`, `weeklyContext`, …) plus a
* `scope` block. The coach tool executor reads a single domain block out of
* this so an on-demand retrieval tool returns exactly the numbers the legacy
* snapshot-stuffing path would have shown — identical builder, gates, and I/O.
*/
sections: Record<string, unknown>;
/**
* Provenance built from snapshot keys actually present. Stays in
* sync with the SNAPSHOT block so the source-chip row mirrors what
* the model could see.
*/
provenance: CoachProvenance;
/**
* v1.18.6 (W7) — citation-aware reference-range grounding for the
* metrics present in this snapshot, or null when none is covered by the
* reference backbone. The Coach route appends it verbatim after the
* SNAPSHOT so the model reads published population bands + the user's
* placement (general guidance, never a diagnosis). Built deterministically
* from `src/lib/reference-ranges.ts`; carries no commercial brand name.
*/
referenceGrounding: string | null;
}
/**
* Day-level cap for the raw timeline. Days within this window are kept
* verbatim (one entry per day with weekday). Older days inside the
* snapshot window are folded into weekly means so a 90-day window
* lands at ~14 day-rows + ~11 week-rows ≈ 25 rows per metric — well
* under the 3 000-token Coach turn budget on a 5-metric snapshot.
*/
const DAILY_TIMELINE_DAYS = 14;
/** Default window when the caller doesn't pass a scope. */
const DEFAULT_WINDOW: CoachScopeWindow = "last30days";
/**
* v1.18.10 (P-2) — newest-first cap on the single multi-type measurement read
* that feeds the Coach snapshot timelines. The window read can reach 365 days
* (`lastYear` / `allTime`) across high-frequency types (PULSE / glucose are
* 200k-row-class), but the prompt only renders ~21 daily + ~10 weekly buckets
* per metric, so an uncapped read loaded a year of rows to discard almost all
* of them. 6000 keeps the recent-daily + weekly window exact even with several
* dense types active (≈ a year of multi-daily readings on one type, or a
* handful of types at a few readings/day) while bounding the worst case; the
* coarse MONTH/YEAR tail comes from the rollup tier, not this read.
*/
const SNAPSHOT_MEASUREMENT_ROW_CAP = 6000;
/**
* v1.17.0 — the glucose clinical panel is a fixed trailing-30-day artifact,
* identical across the insights panel, the dashboard snapshot, the doctor
* report, and (here) the coach. Pinned independently of the coach's variable
* narration window so the coach's TIR/GMI/CV% always equals what the panel
* renders.
*/
const GLUCOSE_CLINICAL_WINDOW_DAYS = GLUCOSE_PANEL_WINDOW_DAYS;
/**
* v1.17.0 — the sleep-rhythm read (sleep-debt + chronotype) is a fixed
* trailing-window artifact, identical across the Sleep page, the dashboard
* summary, and (here) the coach. Pinned independently of the coach's variable
* narration window (7/30/90/365) so the coach's debt + chronotype band always
* equal what the page renders. Mirrors `DEFAULT_WINDOW_DAYS` in
* `sleep-rhythm.ts`: 42 days gives the 14-night debt window full coverage and
* ~12 weekend nights for a stable MSF — the assembler self-caps each signal to
* its own window, so feeding the same 42-day rows yields the page's DTO.
*/
const SLEEP_RHYTHM_WINDOW_DAYS = 42;
/**
* v1.7.0 — assembled-snapshot soft char cap. After the snapshot is
* built we measure `JSON.stringify(snapshot).length` as a ~4-chars-per-
* token proxy and, if it exceeds this cap, progressively degrade the
* lowest-priority clusters (drop `timeline.recent`, then collapse the
* weekly buckets) until it fits. ~24 000 chars ≈ ~6 000 tokens, which
* sits comfortably inside every provider's context alongside the system
* prompt + history window. The daily token ledger (`budget.ts`) stays
* the per-day cost backstop; this is the per-prompt shape backstop.
*/
const MAX_SNAPSHOT_CHARS = 24_000;
/**
* v1.7.0 — when more than this many clusters are active, cap the
* additive (non-core) clusters' timeline window so a 10-cluster,
* allTime request can't fan the timeline out across every series at
* once. The core clinical clusters keep the user-chosen window.
*/
const MULTI_CLUSTER_THRESHOLD = 6;
const MULTI_CLUSTER_WINDOW_CAP: CoachScopeWindow = "last90days";
/**
* v1.7.0 — the workouts block never dumps every session. It carries
* the most-recent N sessions verbatim plus a per-sport rollup for the
* tail, so a heavy-training account at a long window stays bounded.
*/
const WORKOUT_RECENT_CAP = 15;
/**
* Clusters that keep the user-chosen window even under the multi-cluster
* cap — the high-signal clinical series.
*/
const CORE_CLUSTERS: ReadonlySet<CoachDataCluster> = new Set<CoachDataCluster>([
"medication",
"cardio",
"glucose",
]);
/**
* v1.18.0 — module enable/disable → coach-snapshot domain map.
*
* When a toggleable data-domain module is disabled for the account, the
* domains it owns must never enter the coach context. We reuse the
* existing `excludeMetrics` filtering path (the `excluded` set narrows
* `sources` before any row is read) by folding the disabled modules into
* a SYSTEM-side exclusion that unions with the user's `excludeMetrics`.
*
* Each toggleable data domain maps to the `CoachScopeSource` token(s)
* its snapshot block(s) gate on:
* - `mood` → the mood block (`mood` source).
* - `sleep` → the per-night sleep block + the sleep-rhythm block
* (both gate on the `sleep` source).
* - `glucose` → the glucose per-context + clinical block.
* - `workouts` → the workouts block.
* - `recovery` → the recovery / strain composites. These are the
* derived block (READINESS / RECOVERY_SCORE / STRAIN_SCORE
* / …), the WHOOP-native dayStrain block, and the
* trajectory block — all gated on `derivedActive`, which
* reads HRV / resting-HR / VO₂max. Dropping those source
* tokens drops the raw additive timelines too; the
* composites are additionally gated below so they never
* build off the sleep signal alone.
*
* `cycle` is intentionally absent: its block already resolves through the
* fully two-layer cycle gate (`isCycleAvailableForUser` — the per-user
* toggle AND the operator server-wide kill-switch) below, exactly as the
* W1 foundation prescribes. `coach` is the surface being narrated, not
* a data domain. `labs` / `achievements` / `insights` / `doctorReport`
* own no coach-snapshot data domain.
*/
const MODULE_EXCLUDED_SOURCES: Partial<Record<ModuleKey, CoachScopeSource[]>> =
{
mood: ["mood"],
sleep: ["sleep"],
glucose: ["glucose"],
workouts: ["workouts"],
recovery: ["hrv", "resting_hr", "vo2_max"],
};
const WEEKDAY_KEYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
function windowToDays(window: CoachScopeWindow): number {
switch (window) {
case "last7days":
return 7;
case "last30days":
return 30;
case "last90days":
return 90;
case "lastYear":
// v1.4.27 B7 / BL-P6-4 — year-in-review window. Same 365-day
// ceiling as the historical `allTime` cap so the prompt budget
// stays bounded, but with explicit semantics — the Coach knows
// the user is asking about a long-horizon view rather than the
// unbounded "all time" backfill.
return 365;
case "allTime":
// Cap "allTime" at one year for the timeline. The aggregate
// section already cites multi-year ranges via the features
// pipeline; the timeline stays tight to keep token budget sane.
return 365;
}
}
/**
* YYYY-MM-DD day key anchored to the user's display timezone. The
* prompt asks the Coach "did you read at 23:50 last night?" — that
* "last night" answer has to use the user's clock, not UTC. Up to
* v1.4.24 the day-key was UTC, so a 23:50 Pacific/Auckland reading
* landed in the next UTC day's bucket and the Coach couldn't pair it
* with the user's mental model. Delegates to the canonical
* `userDayKey` so every day-bucket surface stays byte-aligned.
*/
function tzDayKey(date: Date, tz: string): string {
return userDayKey(date, tz);
}
/**
* 0..6 → "Sun".."Sat" using the user's tz so "last Monday" in the
* prompt agrees with the calendar the user is looking at.
*/
function tzWeekday(date: Date, tz: string): string {
const wk = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
weekday: "short",
}).format(date);
// Normalise "Mon" / "Mon," — modern engines drop the comma but
// older ones may include it.
const canon = wk.slice(0, 3) as (typeof WEEKDAY_KEYS)[number];
return WEEKDAY_KEYS.includes(canon) ? canon : WEEKDAY_KEYS[date.getUTCDay()];
}
/**
* ISO week key like 2026-W19. We derive the week from the user-tz
* day-key so a Sunday-evening reading in Auckland (which is already
* Monday UTC) labels under the Auckland week.
*/
function isoWeekKey(date: Date, tz: string): string {
// Resolve the wall-clock date in the user's tz, then compute the ISO
// week from that calendar date. The Thursday-alignment math is the
// standard ISO 8601 algorithm.
const dayKey = tzDayKey(date, tz);
const [y, m, d] = dayKey.split("-").map(Number);
const localMidnight = new Date(Date.UTC(y, m - 1, d));
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()) / 86400000 + 1) / 7,
);
return `${localMidnight.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`;
}
interface DailyValueRow {
date: string;
weekday: string;
value: number;
}
interface DailyBpRow {
date: string;
weekday: string;
sys: number;
dia: number;
}
interface WeeklyBucket {
weekISO: string;
mean: number;
count: number;
}
/**
* Group raw measurements (one per row, possibly several per day) into
* one entry per user-tz day. Multiple readings on the same day are
* folded into the daily mean — clinically the morning reading is what
* the Coach is usually asked about, but the snapshot stays pre-clinical
* and takes the straight mean to avoid presenting a fabricated number.
*/
function dailyMeans<T extends { measuredAt: Date; value: number }>(
rows: T[],
tz: string,
): Map<string, { date: Date; values: number[] }> {
const grouped = new Map<string, { date: Date; values: number[] }>();
for (const r of rows) {
const key = tzDayKey(r.measuredAt, tz);
const existing = grouped.get(key);
if (existing) {
existing.values.push(r.value);
} else {
grouped.set(key, { date: r.measuredAt, values: [r.value] });
}
}
return grouped;
}
function bucketWeekly(
rows: Array<{ measuredAt: Date; value: number }>,
tz: string,
): WeeklyBucket[] {
const grouped = new Map<string, number[]>();
for (const r of rows) {
const key = isoWeekKey(r.measuredAt, tz);
const list = grouped.get(key);
if (list) {
list.push(r.value);
} else {
grouped.set(key, [r.value]);
}
}
return Array.from(grouped.entries())
.map(([weekISO, values]) => ({
weekISO,
mean:
Math.round((values.reduce((s, v) => s + v, 0) / values.length) * 10) /
10,
count: values.length,
}))
.sort((a, b) => a.weekISO.localeCompare(b.weekISO));
}
function buildDailyValueRows(
rows: Array<{ measuredAt: Date; value: number }>,
recentCutoff: Date,
tz: string,
): DailyValueRow[] {
const recent = rows.filter((r) => r.measuredAt >= recentCutoff);
const grouped = dailyMeans(recent, tz);
return Array.from(grouped.entries())
.map(([date, info]) => {
const mean = info.values.reduce((s, v) => s + v, 0) / info.values.length;
return {
date,
weekday: tzWeekday(info.date, tz),
value: Math.round(mean * 10) / 10,
};
})
.sort((a, b) => a.date.localeCompare(b.date));
}
/**
* v1.18.7 — coarse tail + anomaly envelope for one metric, from the shared
* tiered-context builder. The Coach already ships the 0–14d raw + 30–90d
* weekly band (above); this adds only the bands the weekly-fold CANNOT
* produce — 90d–1y MONTH, >1y YEAR, and the preserved peak/trough envelope —
* so a glucose spike or a BP outlier from months ago survives a coarse
* bucket. Token-bounded by the helper's per-band row caps (~10 month + ≤5
* year + ≤5 anomalies ≈ 240 tokens), and the degrader sheds it first (it is
* the lowest-value, oldest detail). Returns `undefined` when the metric has
* no coarse history at all, so the block is omitted rather than shipped empty.
*/
interface CoarseTimelineTail {
/** 90d–1y monthly buckets: `[bucketStart, mean, min, max]`. */
monthly: Array<[string, number, number, number]>;
/** >1y yearly buckets: `[bucketStart, mean, min, max]`. */
yearly: Array<[string, number, number, number]>;
/** Preserved peaks/troughs, ≤5, ranked by |deltaSd|. */
anomalies: Array<{
band: string;
date: string;
kind: string;
value: number;
deltaSd: number;
}>;
}
async function buildCoarseTimelineTail(
userId: string,
type: MeasurementType,
now: Date,
tz: string,
): Promise<CoarseTimelineTail | undefined> {
// `buildTieredSeries` reads fall back on miss — a coverage miss yields empty
// bands, which collapse to `undefined` here. The try/catch keeps the coarse
// tail a strictly additive enrichment: any unexpected reader failure simply
// drops the tail rather than failing the whole snapshot (matching the
// "fall back on miss" contract the rest of the snapshot honours).
let series: Awaited<ReturnType<typeof buildTieredSeries>>;
try {
// `coarseOnly` — the snapshot already holds the raw 0–14d rows from its
// own window read and this tail consumes only the MONTH/YEAR bands +
// anomaly envelope, so the builder skips the raw read AND the DAY/WEEK
// band reads it would otherwise discard.
series = await buildTieredSeries(userId, type, {
now: now.getTime(),
tz,
coarseOnly: true,
});
} catch {
return undefined;
}
const monthly = series.monthBand.map(
(b) =>
[b.bucketStart, b.mean, b.min, b.max] as [string, number, number, number],
);
const yearly = series.yearBand.map(
(b) =>
[b.bucketStart, b.mean, b.min, b.max] as [string, number, number, number],
);
if (
monthly.length === 0 &&
yearly.length === 0 &&
series.anomalies.length === 0
) {
return undefined;
}
return {
monthly,
yearly,
anomalies: series.anomalies.map((a) => ({
band: a.band,
date: a.date,
kind: a.kind,
value: a.value,
deltaSd: a.deltaSd,
})),
};
}
/**
* Pair systolic + diastolic into one row per day. Days with only one
* side measured are dropped — the Coach is asked about "BP" as a pair,
* and a half-measured day would invite a fabricated complement.
*/
function buildDailyBpRows(
sysRows: Array<{ measuredAt: Date; value: number }>,
diaRows: Array<{ measuredAt: Date; value: number }>,
recentCutoff: Date,
tz: string,
): DailyBpRow[] {
const sysRecent = sysRows.filter((r) => r.measuredAt >= recentCutoff);
const diaRecent = diaRows.filter((r) => r.measuredAt >= recentCutoff);
const sysByDay = dailyMeans(sysRecent, tz);
const diaByDay = dailyMeans(diaRecent, tz);
const out: DailyBpRow[] = [];
for (const [day, info] of sysByDay) {
const dia = diaByDay.get(day);
if (!dia) continue;
const sysMean = info.values.reduce((s, v) => s + v, 0) / info.values.length;
const diaMean = dia.values.reduce((s, v) => s + v, 0) / dia.values.length;
out.push({
date: day,
weekday: tzWeekday(info.date, tz),
sys: Math.round(sysMean),
dia: Math.round(diaMean),
});
}
return out.sort((a, b) => a.date.localeCompare(b.date));
}
/**
* Resolve the working scope. The explicit request `scope.sources`
* always wins as the maximum set (an iOS client can pin an exact
* list). When it is absent the builder expands the user's saved
* `dataClusters` instead — `clusterDefault` carries that expansion.
* When neither is present (no prefs row at all, legacy native client)
* the cluster resolver still returns `DEFAULT_COACH_CLUSTERS`, so the
* legacy five domains stay the floor.
*
* v1.7.0 — replaces the constant `DEFAULT_SOURCES` fallback with the
* cluster expansion threaded in by `buildCoachSnapshotImpl`.
*/
function resolveScope(
scope: CoachScope | undefined,
clusterDefault: ReadonlySet<CoachScopeSource>,
): {
sources: ReadonlySet<CoachScopeSource>;
window: CoachScopeWindow;
} {
const sources =
scope?.sources && scope.sources.length > 0
? new Set(scope.sources)
: clusterDefault;
return {
sources,
window: scope?.window ?? DEFAULT_WINDOW,
};
}
/**
* v1.4.33 — 60-second in-memory cache for `buildCoachSnapshot()`. The
* snapshot reads only persisted data; a single chat conversation sends
* 2-4 turns within a minute and the snapshot would otherwise rebuild
* from the same rows each turn. Caching the result for 60s shaves the
* ~10 measurement reads + the GLP-1 / mood / intake side-fetches off
* every turn after the first, which `.planning/round-v1433-audit-perf.md`
* §3.3 estimates at 200-800 ms of server-side tail.
*
* Scope is part of the cache key so a switch from `last30days` to
* `last7days` (or a different `sources` set) computes fresh. The map
* is bounded at 64 entries — a multi-tenant deployment with a few
* active power users sits well inside that ceiling even if each cycles
* through several scopes per minute.
*/
const SNAPSHOT_TTL_MS = 60_000;
const SNAPSHOT_LRU_MAX = 64;
const snapshotCache = new Map<
string,
{ expiresAt: number; result: CoachSnapshotResult }
>();
function snapshotCacheKey(
userId: string,
scope: CoachScope | undefined,
): string {
// v1.7.0 — when the request pins an explicit source list, key on it.
// Otherwise the source set is derived from the user's saved
// `dataClusters`, which we don't read here (the cache must stay
// I/O-free on a hit) — key on a stable `clusters` marker instead.
// A cluster change is reflected on the next cache miss (≤60 s), the
// same staleness window every other pref change already tolerates.
const window = scope?.window ?? DEFAULT_WINDOW;
const sourceList =
scope?.sources && scope.sources.length > 0
? Array.from(scope.sources).sort().join(",")
: "clusters";
return `${userId}|${window}|${sourceList}`;
}
function readSnapshotCache(key: string): CoachSnapshotResult | null {
const entry = snapshotCache.get(key);
if (!entry) return null;
if (entry.expiresAt <= Date.now()) {
snapshotCache.delete(key);
return null;
}
// Touch for LRU — re-insert moves to the end of the Map's iteration order.
snapshotCache.delete(key);
snapshotCache.set(key, entry);
return entry.result;
}
function writeSnapshotCache(key: string, result: CoachSnapshotResult): void {
if (snapshotCache.size >= SNAPSHOT_LRU_MAX) {
// Evict the oldest entry — JS Map iteration order is insertion order,
// so the first key is the least-recently inserted/touched.
const oldest = snapshotCache.keys().next().value;
if (oldest !== undefined) {
snapshotCache.delete(oldest);
}
}
snapshotCache.set(key, {
expiresAt: Date.now() + SNAPSHOT_TTL_MS,
result,
});
}
/** Clear the snapshot cache. Test-only escape hatch. */
export function __resetCoachSnapshotCacheForTests(): void {
snapshotCache.clear();
}
/**
* Build the Coach prompt snapshot for `userId`. Always uses
* `includeRaw=false` because the Coach replies are conversational and
* the user is asking the model — they should never depend on raw
* measurement timestamps that the privacy mode controls.
*
* The snapshot now carries two sections per active metric:
* - aggregate: the v1.4.20 shape (mean, slope, SD, range, count)
* - timeline.recent: last `DAILY_TIMELINE_DAYS` days as raw daily
* values with weekday labels so the Coach can answer
* day/weekday-specific questions
* - timeline.weekly: ISO-week buckets covering the rest of the
* window so the Coach can still cite older weeks without ballooning
* the prompt
*
* v1.4.25 W7b — every day-key + weekday label is now anchored to the
* user's display timezone (read from `User.timezone`). Falls back to
* Europe/Berlin when the column is missing so the legacy snapshot
* stays byte-identical for the only path the v1.4.24 suite tested.
*
* v1.4.33 — wraps the previous `buildCoachSnapshotImpl` with a 60s
* in-memory LRU keyed on `(userId, window, sources)`. The Coach's
* chat handler calls this once per turn; within the same conversation
* the second+ turn lands a cache hit and skips the row-level reads.
*/
export async function buildCoachSnapshot(
userId: string,
scope?: CoachScope,
): Promise<CoachSnapshotResult> {
const key = snapshotCacheKey(userId, scope);
const cached = readSnapshotCache(key);
if (cached) return cached;
const result = await buildCoachSnapshotImpl(userId, scope);
writeSnapshotCache(key, result);
return result;
}
async function buildCoachSnapshotImpl(
userId: string,
scope?: CoachScope,
): Promise<CoachSnapshotResult> {
// v1.4.23 H4 — apply per-user `excludeMetrics` BEFORE we read any
// measurement rows so the model never sees data the user opted out
// of. The filter intersects with the resolved scope (the explicit
// `scope` argument from the request body still wins for the
// _maximum_ set; prefs only narrow further).
//
// v1.4.25 W7b — the same prefs read also returns the user's
// displayTimezone so the day-key and weekday labels below match the
// calendar the user is looking at. Reading both columns in one
// query keeps the snapshot's read budget the same as before.
//
// v1.7.0 — the prefs read now also drives the source default: when
// the request omits an explicit `scope.sources`, the resolved scope
// expands the user's saved `dataClusters` (legacy default when the
// key is absent). So the prefs read must precede `resolveScope`.
// v1.18.0 — resolve the per-user module map once at build start so a
// disabled data-domain module's data never enters the coach context.
// The map read is memoised per-request by the gate, and runs alongside
// the prefs read (both only need `userId`), so the cold path pays a
// single extra round-trip at most. Disabled modules fold into the same
// SYSTEM-side exclusion the user's `excludeMetrics` flow already drives.
const moduleMapPromise = resolveModuleMap(userId);
// v1.20.0 (H-1) — the F1 coach tools each rebuild a single-source snapshot
// with a distinct LRU key, so the 60s snapshot cache does not share this read
// across the fan-out. Memoise it per-request (the select shape is constant, so
// userId is the only key) so up to 6 concurrent tool builds collapse to one
// prefs round-trip instead of starving the shared Prisma pool.
const prefsRow = await memoizePerRequest(`coach-prefs:${userId}`, () =>
prisma.user.findUnique({
where: { id: userId },
select: {
coachPrefsJson: true,
timezone: true,
locale: true,
// v1.11.5 — needed to collapse a dual-source sleep night to one
// canonical source before reconstructing per-night asleep totals.
sourcePriorityJson: true,
// v1.15 — the cycle snapshot block is gated on the resolved cycle
// toggle (an explicit opt-in/out overrides the gender default). Read
// both columns here so a non-cycle account pays no extra round-trip.
gender: true,
cycleProfile: { select: { cycleTrackingEnabled: true } },
// v1.16.16 — the glucose block converts canonical mg/dL to the user's
// display unit so the Coach reads the same number every other surface
// shows. Read it on this existing prefs hop (no extra round-trip).
glucoseUnit: true,
// v1.18.6 (W7) — the explicit, user-declared diabetes opt-in. Selects
// the tighter ADA glycemic GOAL band for the glucose reference-grounding
// line only; never inferred from a reading, never a diagnosis. Read on
// this existing prefs hop (no extra round-trip).
hasDiabetes: true,
},
}),
);
const prefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
// Resolve the UI locale for the rolling-profile narrative recall. The
// narrative rows are keyed by ("de" | "en"); default to "de" (the app
// default locale) when the user never picked one.
const coachLocale: "de" | "en" = prefsRow?.locale === "en" ? "en" : "de";
const clusterDefault = clusterSourcesFromPrefs(prefs.dataClusters);
const { sources: scopedSources, window } = resolveScope(
scope,
clusterDefault,
);
const userTz = prefsRow?.timezone ?? DEFAULT_TIMEZONE;
const glucoseUnit = resolveGlucoseUnit(prefsRow?.glucoseUnit ?? null);
// v1.18.0 — fold disabled data-domain modules into the system exclusion.
// `moduleMap[key] === false` means the user turned that module off; the
// gate has already resolved every delegation (cycle/coach) so this map
// is authoritative. We union the disabled modules' owned sources into
// `excluded` so the existing source-narrowing path below removes them
// before any row is read — the model never sees a disabled domain.
const moduleMap = await moduleMapPromise;
const recoveryDisabled = moduleMap.recovery === false;
const excluded = new Set<CoachExcludeMetric>(prefs.excludeMetrics);
for (const [key, srcs] of Object.entries(MODULE_EXCLUDED_SOURCES)) {
if (moduleMap[key as ModuleKey] === false) {
for (const src of srcs ?? []) {
// Every entry in MODULE_EXCLUDED_SOURCES is a CoachScopeSource that
// also exists in the CoachExcludeMetric enum overlap the
// source-narrowing loop checks against; the cast mirrors the one the
// loop already uses below.
excluded.add(src as unknown as CoachExcludeMetric);
}
}
}
// v1.4.36 W3 T2 — `medications` and `anthropometrics` are
// exclude-only toggles (not in `CoachScopeSource`); they gate the
// GLP-1 weeklyContext / compliance branch and the anthropometrics
// block respectively. The mapping below treats them as additive to
// the existing source-level exclusions.
const excludesMedications = excluded.has("medications");
const excludesAnthropometrics = excluded.has("anthropometrics");
const sources = new Set<CoachScopeSource>();
for (const src of scopedSources) {
// The `excludeMetrics` enum is a superset of `CoachScopeSource` now
// (medications + anthropometrics live on the exclude-only side);
// the cast was safe before v1.4.36 because the enums matched 1:1,
// and the runtime `excluded.has` check still only catches the
// overlapping members.
if (!excluded.has(src as unknown as CoachExcludeMetric)) {
sources.add(src);
}
}
// `medications` exclusion also drops the compliance source so the
// intake-event branch below short-circuits — keeps the contract
// consistent (excluding medications == no medication data at all).
if (excludesMedications) {
sources.delete("compliance");
}
const windowDays = windowToDays(window);
// v1.11.3 — kick the feature extraction off as a promise now and await
// it alongside the shared measurement read below. `extractFeatures`
// and the measurement `findMany` are independent (each only needs
// `userId` + the resolved window/sources), so running them
// concurrently shaves a round-trip off the cold path. No block reads
// `features` before the shared await, so the deferral is safe.
// v1.20.0 (H-1) — `extractFeatures` is the heaviest read on the cold path
// (user findUnique + a windowed measurement findMany + the all-time extremes).
// The F1 tools rebuild distinct-scope snapshots whose LRU keys differ, so they
// do not share this read; memoise it per-request keyed on the only varying
// input (windowDays) so the fan-out runs it once instead of up to 6× against
// the shared pool.
const featuresPromise = memoizePerRequest(
`coach-features:${userId}:${windowDays}`,
() => extractFeatures(userId, false, { sinceDays: windowDays }),
);
// Trim down to the metrics the Coach narrates. extractFeatures
// returns more (sleep, steps, etc.) — the Coach surface keeps the
// snapshot tight so each turn fits inside the provider's context
// budget for free-tier accounts.
const snapshot: Record<string, unknown> = {};
const windows = new Set<CoachProvenance["windows"][number]>();
// v1.4.27 B7 / BL-P6-4 — seed the provenance window set with the
// user's resolved scope so the year-in-review window surfaces in the
// provenance envelope even when the per-metric branches below only
// emit `last30days` / `last90days` chips. Older windows are added
// by the metric branches as before.
if (window === "lastYear" || window === "allTime") {
windows.add(window);
}
const metrics = new Set<CoachProvenance["metrics"][number]>();
const counts: NonNullable<CoachProvenance["counts"]> = {};
// v1.18.6 (W7) — representative scalar per reference-covered metric, in the
// metric's reference unit, collected as the blocks below build. Feeds the
// citation-aware grounding block (population band + the user's placement).
// Each entry reads the SAME number the snapshot already surfaces — no
// independent recompute — so the grounding can never cite a value the
// snapshot doesn't carry. A metric with a block but no clean scalar is left
// unset (the grounding line still cites its band with an insufficient
// placement only if it is added with a null value; we omit such metrics).
const groundingValues = new Map<ReferenceMetric, number>();
// Additive `MeasurementType` → reference metric, for the value-block loop.
const TYPE_TO_REFERENCE_METRIC: Record<string, ReferenceMetric> = {
RESTING_HEART_RATE: "RESTING_HEART_RATE",
OXYGEN_SATURATION: "OXYGEN_SATURATION",
RESPIRATORY_RATE: "RESPIRATORY_RATE",
PULSE_WAVE_VELOCITY: "PULSE_WAVE_VELOCITY",
BODY_TEMPERATURE: "BODY_TEMPERATURE",
BODY_MASS_INDEX: "BMI",
VISCERAL_FAT: "VISCERAL_FAT",
ACTIVITY_STEPS: "STEPS",
};
/** Mean of the most recent N daily-value rows (already user-tz day means). */
const recentRowsMean = (
rows: DailyValueRow[],
take = DAILY_TIMELINE_DAYS,
): number | null => {
if (rows.length === 0) return null;
const slice = rows.slice(-take);
const sum = slice.reduce((s, r) => s + r.value, 0);
return sum / slice.length;
};
// v1.7.0 — block registry. Maps each emitted snapshot top-level key
// to the cluster it belongs to so the soft-cap degradation pass
// (below) can walk blocks in reverse cluster-priority order and shed
// the lowest-signal detail first. Core legacy blocks register too so
// the degrader can reach them as a last resort.
const blockClusters = new Map<string, CoachDataCluster>();
// Snapshot top-level keys that carry an `aggregate` companion — the
// degrader can drop `timeline.recent` from these and still leave the
// aggregate for the Coach to reason from.
const registerBlock = (key: string, source: CoachScopeSource) => {
const cluster = sourceCluster(source);
if (cluster) blockClusters.set(key, cluster);
};
// Pull raw measurement rows once for the configured window so day
// and week buckets share a single I/O hop. Mood + compliance live in
// separate tables and are loaded conditionally below.
const now = new Date();
const cutoff = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000);
const recentCutoff = new Date(
now.getTime() - DAILY_TIMELINE_DAYS * 24 * 60 * 60 * 1000,
);
// v1.7.0 — when many clusters are active, cap the timeline read
// window for the ADDITIVE (non-core) clusters so a 10-cluster /
// allTime request cannot fan a dense timeline across every series at
// once. The core clinical clusters keep the user-chosen window.
const activeClusters = new Set<CoachDataCluster>();
for (const src of sources) {
const c = sourceCluster(src);
if (c) activeClusters.add(c);
}
const multiClusterCapActive = activeClusters.size > MULTI_CLUSTER_THRESHOLD;
// v1.7.0 — record which clusters resolved active for this build so
// the observability dashboards can track cluster adoption + the
// multi-cluster cap firing rate.
annotate({
action: { name: "coach.clusters.resolved" },
meta: {
active: Array.from(activeClusters).sort(),
window,
multiClusterCap: multiClusterCapActive,
},
});
const additiveCapDays = windowToDays(MULTI_CLUSTER_WINDOW_CAP);
const additiveCapCutoff = new Date(
now.getTime() - additiveCapDays * 24 * 60 * 60 * 1000,
);
// Effective `cutoff` for an additive block under the multi-cluster
// cap — the later of the window cutoff and the cap cutoff. Core
// clusters always use the full window cutoff.
const additiveCutoff = (source: CoachScopeSource): Date => {
const cluster = sourceCluster(source);
if (
multiClusterCapActive &&
cluster !== null &&
!CORE_CLUSTERS.has(cluster) &&
additiveCapCutoff > cutoff
) {
return additiveCapCutoff;
}
return cutoff;
};
const wantsBp = sources.has("bp");
const wantsWeight = sources.has("weight");
const wantsPulse = sources.has("pulse");
const wantsMood = sources.has("mood");
const wantsCompliance = sources.has("compliance");
// v1.18.7 — coarse tail (90d–1y MONTH + >1y YEAR) + anomaly envelope for the
// core clinical metrics, from the shared tiered-context builder. Only the
// bands the in-window weekly-fold cannot produce are fetched here; the recent
// + weekly bands stay as built below. Bounded per-band, so this holds or
// reduces the per-metric token cost while letting the Coach see a spike from
// months ago. Reads never throw — a coverage miss yields `undefined`, the
// block is then omitted. Run in parallel; awaited at the block site.
const coarseTailPromises: Partial<
Record<"bp" | "weight" | "pulse", Promise<CoarseTimelineTail | undefined>>
> = {};
if (wantsBp) {
coarseTailPromises.bp = buildCoarseTimelineTail(
userId,
"BLOOD_PRESSURE_SYS" as MeasurementType,
now,
userTz,
);
}
if (wantsWeight) {
coarseTailPromises.weight = buildCoarseTimelineTail(
userId,
"WEIGHT" as MeasurementType,
now,
userTz,
);
}
if (wantsPulse) {
coarseTailPromises.pulse = buildCoarseTimelineTail(
userId,
"PULSE" as MeasurementType,
now,
userTz,
);
}
const [bpCoarseTail, weightCoarseTail, pulseCoarseTail] = await Promise.all([
coarseTailPromises.bp ?? Promise.resolve(undefined),
coarseTailPromises.weight ?? Promise.resolve(undefined),
coarseTailPromises.pulse ?? Promise.resolve(undefined),
]);
// v1.4.23 W6 (S-04) — single source of truth for the
// CoachScopeSource → MeasurementType[] mapping. Drives both the
// SQL `WHERE type IN (…)` build below and the Apple-Health timeline
// block table downstream, so adding a new metric is one entry
// instead of three (boolean + push + appleHealthBlocks row).
// Default Coach scope leaves the Apple Health rows off; non-iOS
// accounts never pay the type-IN overhead because their `sources`
// set never enables them.
// v1.7.0 — extended with the full clustered taxonomy. `mood`,
// `compliance`, and `workouts` map to no `MeasurementType` because
// they read separate models (MoodEntry / MedicationIntakeEvent /
// Workout) and are handled by their own branches below. `glucose`
// also reads `Measurement` but needs the `glucoseContext` column, so
// its block is built separately rather than from the shared
// `byType()` rows.
const METRIC_TYPES: Record<CoachScopeSource, string[]> = {
bp: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
weight: ["WEIGHT"],
pulse: ["PULSE"],
mood: [],
compliance: [],
hrv: ["HEART_RATE_VARIABILITY"],
sleep: ["SLEEP_DURATION"],
resting_hr: ["RESTING_HEART_RATE"],
steps: ["ACTIVITY_STEPS"],
active_energy: ["ACTIVE_ENERGY_BURNED"],
flights: ["FLIGHTS_CLIMBED"],
distance: ["WALKING_RUNNING_DISTANCE"],
vo2_max: ["VO2_MAX"],
body_temp: ["BODY_TEMPERATURE"],
// ── cardio composition / vascular ──
walking_hr: ["WALKING_HEART_RATE_AVERAGE"],
respiratory_rate: ["RESPIRATORY_RATE"],
spo2: ["OXYGEN_SATURATION"],
pulse_wave_velocity: ["PULSE_WAVE_VELOCITY"],
vascular_age: ["VASCULAR_AGE"],
// ── body composition ──
body_fat: ["BODY_FAT"],
fat_mass: ["FAT_MASS"],
fat_free_mass: ["FAT_FREE_MASS"],
muscle_mass: ["MUSCLE_MASS"],
lean_body_mass: ["LEAN_BODY_MASS"],
bone_mass: ["BONE_MASS"],
total_body_water: ["TOTAL_BODY_WATER"],
bmi: ["BODY_MASS_INDEX"],
visceral_fat: ["VISCERAL_FAT"],
// ── metabolic — built via the dedicated glucose branch ──
glucose: ["BLOOD_GLUCOSE"],
// ── mobility & gait ──
walking_steadiness: ["WALKING_STEADINESS"],
walking_asymmetry: ["WALKING_ASYMMETRY"],
walking_double_support: ["WALKING_DOUBLE_SUPPORT"],
walking_step_length: ["WALKING_STEP_LENGTH"],
walking_speed: ["WALKING_SPEED"],
// ── environment / exposure ──
audio_env: ["AUDIO_EXPOSURE_ENV"],
audio_headphone: ["AUDIO_EXPOSURE_HEADPHONE"],
audio_event: ["AUDIO_EXPOSURE_EVENT"],
daylight: ["TIME_IN_DAYLIGHT"],
skin_temp: ["SKIN_TEMPERATURE"],
// ── workouts — read from the Workout model, not Measurement ──
workouts: [],
};
// Single fetch for all measurement types — Prisma's filter pushes
// the type list into one SQL `WHERE type IN (…)` so we don't pay
// per-metric round-trips.
const wantedTypes = Array.from(sources).flatMap(
(source) => METRIC_TYPES[source] ?? [],
);
const measurementRowsPromise =
wantedTypes.length > 0
? prisma.measurement
.findMany({
where: {
userId,
type: { in: wantedTypes as never[] },
measuredAt: { gte: cutoff },
deletedAt: null,
},
// v1.18.10 (P-2) — read NEWEST-first + cap. PULSE / glucose are
// 200k-row-class types and the window can reach 365 days
// (lastYear / allTime), so an uncapped read pulled the entire
// year of high-frequency rows into memory just to fold them into
// ~21 daily + ~10 weekly buckets the prompt actually shows. The
// newest-first cap keeps the recent-daily timeline exact and only
// sheds the deepest weekly buckets on an extreme-volume account;
// the coarse MONTH/YEAR tail is read separately from the rollup
// tier (`buildCoarseTimelineTail`), so deep history survives.
orderBy: { measuredAt: "desc" },
take: SNAPSHOT_MEASUREMENT_ROW_CAP,
// v1.7.0 — `glucoseContext` rides along so the glucose block
// can split fasting / postprandial / random / bedtime without
// a second query. NULL on every non-glucose row.
select: {
type: true,
value: true,
measuredAt: true,
glucoseContext: true,
},
})
// Downstream bucketers re-sort/group internally, but restore
// ascending order so any order-sensitive consumer sees the same
// shape as before the cap.
.then((rows) => rows.reverse())
: Promise.resolve([]);
// v1.11.3 — `extractFeatures` and the shared measurement read are
// mutually independent and both gate the blocks below (every aggregate
// reads `features`; bp/weight/pulse/glucose read `measurementRows`), so
// run the two concurrently and resolve them in a single hop.
const [features, measurementRows] = await Promise.all([
featuresPromise,
measurementRowsPromise,
]);
// v1.11.3 — the remaining cold-path reads are mutually independent:
// the four conditional table reads (mood / compliance / sleep /
// workouts) and the four helper-block reads (GLP-1 / derived /
// trajectory / memory) each consume only `userId`, the window cutoff,
// or the synchronously-derived `derivedProfile` — none reads another's
// result. Fire them all off concurrently now, KEEPING the original
// `wants…` / `sources.has(…)` guards so a disabled source still issues
// no query (the guard yields `null`/`undefined`, never a wasted
// round-trip), then await the batch in one hop. The synchronous block
// assembly further below consumes the resolved values in the original
// order, so provenance and block-registration order are unchanged.
//
// `derivedProfile` is derived from `features.context` (now resolved)
// and feeds the GLP-1 / derived / trajectory / memory readers; it is
// hoisted here so those reads can start immediately.
const derivedSources: CoachScopeSource[] = [
"hrv",
"resting_hr",
"sleep",
"vo2_max",
];
const derivedCtx = features.context;
const derivedProfile: BaselineProfile = {
ageYears: derivedCtx?.ageYears ?? null,
sex:
derivedCtx?.gender === "MALE" || derivedCtx?.gender === "FEMALE"
? (derivedCtx.gender as "MALE" | "FEMALE")
: null,
heightCm: derivedCtx?.heightCm ?? null,
};
// v1.18.0 — the derived block + WHOOP-native dayStrain + trajectory are
// the `recovery` module's domain (READINESS / RECOVERY_SCORE / STRAIN /
// …). They are gated on `derivedActive`, which still reads the `sleep`
// signal — so when `recovery` is disabled but `sleep` stays on, gate them
// off explicitly here, not just by dropping the recovery source tokens.
const derivedActive =
!recoveryDisabled && derivedSources.some((s) => sources.has(s));
const moodRowsPromise =
wantsMood && features.mood
? prisma.moodEntry.findMany({
// v1.7.0 sync — exclude tombstoned rows from the Coach snapshot.
where: { userId, deletedAt: null, moodLoggedAt: { gte: cutoff } },
orderBy: { moodLoggedAt: "asc" },
select: { moodLoggedAt: true, score: true },
})
: null;
// v1.16.9 — the adherence timeline derives from the LEDGER tally (the
// same band engine the compliance % + dose history consume), not from a
// raw intake-row count. The raw count read a worker-minted pending row
// as "not taken" (today's later doses dragged the day's rate down all
// morning) and double-counted cross-source duplicate rows on one slot.
// Load each medication's schedules + eras + window events so the ledger
// can be reconstructed per medication.
const complianceMedsPromise = wantsCompliance
? prisma.medication.findMany({
// v1.16.11 — as-needed (PRN) medications never reach the Coach
// compliance context (no expected doses, no rate).
where: { userId, asNeeded: false },
select: {
id: true,
startsOn: true,
endsOn: true,
oneShot: true,
createdAt: true,
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
scheduleRevisions: {
orderBy: { validFrom: "asc" },
select: {
id: true,
validFrom: true,
validUntil: true,
payload: true,
supersededByRevisionId: true,
},
},
intakeEvents: {
// Tombstoned intake rows must never reach the Coach snapshot.
where: { deletedAt: null, scheduledFor: { gte: cutoff } },
orderBy: { scheduledFor: "asc" },
select: {
scheduledFor: true,
takenAt: true,
skipped: true,
autoMissed: true,
attributionSource: true,
},
},
},
})
: null;
const sleepRowsPromise = sources.has("sleep")
? prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION" as never,
measuredAt: { gte: additiveCutoff("sleep") },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
// Writer-level collapse: two HealthKit apps behind one source
// (watch stages vs phone in-bed) must not blend into one night.
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
})
: null;
// v1.17.0 — sleep-rhythm rows. The sleep-debt + chronotype DTO is a fixed
// trailing-42-day artifact (the Sleep page + dashboard read the same window),
// so it must NOT ride the coach's variable narration window or the
// multi-cluster timeline cap, or the coach would quote a debt / chronotype
// band the page never shows. Read the rhythm's own trailing-42-day rows
// directly (one indexed query, only when the sleep cluster is active) and
// hand them to the SAME assembler the dashboard route uses. The per-stage
// narration timeline above keeps using the coach-window `sleepRows`.
const sleepRhythmCutoff = new Date(
now.getTime() - SLEEP_RHYTHM_WINDOW_DAYS * 24 * 60 * 60 * 1000,
);
const sleepRhythmRowsPromise = sources.has("sleep")
? prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION" as never,
measuredAt: { gte: sleepRhythmCutoff },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
// source + deviceType feed the canonical writer-dedup so a multi-source
// night is counted ONCE, matching every other sleep surface.
select: {
value: true,
measuredAt: true,
sleepStage: true,
source: true,
deviceType: true,
},
})
: null;
const workoutRowsPromise = sources.has("workouts")
? prisma.workout.findMany({
where: { userId, startedAt: { gte: additiveCutoff("workouts") } },
orderBy: { startedAt: "desc" },
select: {
sportType: true,
startedAt: true,
durationSec: true,
totalEnergyKcal: true,
totalDistanceM: true,
avgHeartRate: true,
maxHeartRate: true,
},
})
: null;
// v1.17.0 — the glucose CLINICAL panel is a fixed 30-day clinical artifact,
// identical to the one the insights panel + doctor report render. It must NOT
// ride the coach's variable narration window (7/30/90/365) or the
// multi-cluster timeline cap, or the coach would quote a TIR/GMI/CV% the panel
// never shows. So read the panel's own trailing-30-day glucose rows directly
// (one indexed query, only when glucose is active) and compute the clinical
// block off THOSE rows. The per-context narration timelines below keep using
// the coach-window rows — only the clinical summary is pinned to 30 days.
const glucoseClinicalCutoff = new Date(
now.getTime() - GLUCOSE_CLINICAL_WINDOW_DAYS * 24 * 60 * 60 * 1000,
);
const glucoseClinicalRowsPromise = sources.has("glucose")
? prisma.measurement.findMany({
where: {
userId,
type: "BLOOD_GLUCOSE" as never,
measuredAt: { gte: glucoseClinicalCutoff },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true },
})
: null;
const glp1BlockPromise = excludesMedications
? null
: buildGlp1SnapshotBlock(userId, now);
const derivedBlockPromise = derivedActive
? buildDerivedSnapshotBlock(userId, derivedProfile, now, userTz)
: null;
// v1.17.0 — WHOOP-native day strain (0–21), distinct from the COMPUTED
// STRAIN_SCORE (0–100) the derived block carries. Gated on the same
// wellness/activity signals so it rides the existing parallel batch; the
// block is omitted when the account has no DAY_STRAIN rows (every
// non-WHOOP account). Native-over-computed mirrors how recovery resolves.
const dayStrainRowsPromise = derivedActive
? prisma.measurement.findMany({
where: {
userId,
type: "DAY_STRAIN",
measuredAt: { gte: cutoff },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true },
})
: null;
const trajectoryBlockPromise = derivedActive
? buildTrajectorySnapshotBlock(userId, derivedProfile, now)
: null;
// RECON1 (D5-5) — discovered cross-metric driver pairs for the no-tools
// snapshot floor. Reuses the SAME gated/ranked output the get_correlations
// tool serves (effect-size floor + family-tautology exclusion + shrinkage +
// confidence tiering already applied inside the discovery engine), so a
// local/no-tools provider reaches parity on the flagship cross-metric layer
// instead of getting only the coincident flag. Gated on `derivedActive` (it
// is the recovery / cross-metric layer) and fail-soft to null.
const correlationsBlockPromise = derivedActive
? buildCorrelationsSnapshotBlock(userId)
: null;
const memoryBlockPromise = buildCoachMemoryBlock(
userId,
derivedProfile,
now,
coachLocale,
);
// v1.15 — cycle/phase block, gated on the resolved cycle module so a
// non-cycle account issues no query (the helper short-circuits to null
// before any read for a disabled account). The block is descriptive only —
// current phase + day-of-cycle, the next predicted event (period range,
// fertile window goal-gated), and the headline phase-correlation finding.
// v1.18.0 — the gate is the FULLY-resolved cycle module
// (`isCycleAvailableForUser` → the per-user toggle AND the operator
// server-wide kill-switch), so an operator-off instance never injects the
// cycle block into the coach prompt.
const cycleEnabled = await isCycleAvailableForUser(userId);
const cycleBlockPromise = cycleEnabled
? buildCycleSnapshotBlock(userId, prefsRow?.gender, now, userTz)
: null;
// v1.18.1 P4 — illness/condition context. Always attempted (the helper is
// module-gated internally and short-circuits to null for a non-illness
// account). It is CONTEXT, not a scope-gated metric: appended like
// anthropometrics/scope below without a `registerBlock` so the budget
// degrader never sheds it — the Coach needs to know about Rest Mode.
const illnessBlockPromise = buildIllnessSnapshotBlock(userId, now);
// v1.18.11 (#65) — lab-result context. Like illness it is CONTEXT, not a
// scope-gated metric: attached without a `registerBlock` so the budget
// degrader never sheds it, and Labs is intentionally not module-gated (the
// helper reads owner-scoped rows directly, mirroring `/api/labs`). The block
// carries the most-recent resolved reading per biomarker (last 12 months,
// capped) so the Coach can answer "what was my LDL" without re-deriving.
const labsBlockPromise = buildLabsSnapshotBlock(userId, now);
const [
moodRows,
complianceMeds,
sleepRows,
sleepRhythmRows,
workoutRows,
glucoseClinicalRows,
glp1Block,
derivedBlock,
dayStrainRows,
trajectoryBlock,
correlationsBlock,
memoryBlock,
cycleBlock,
illnessBlock,
labsBlock,
] = await Promise.all([
moodRowsPromise,
complianceMedsPromise,
sleepRowsPromise,
sleepRhythmRowsPromise,
workoutRowsPromise,
glucoseClinicalRowsPromise,
glp1BlockPromise,
derivedBlockPromise,
dayStrainRowsPromise,
trajectoryBlockPromise,
correlationsBlockPromise,
memoryBlockPromise,
cycleBlockPromise,
illnessBlockPromise,
labsBlockPromise,
]);
const byType = (t: string) =>
measurementRows
.filter((r) => r.type === t)
.map((r) => ({
measuredAt: r.measuredAt,
value: r.value,
}));
if (wantsBp && features.bloodPressure) {
const sysRows = byType("BLOOD_PRESSURE_SYS");
const diaRows = byType("BLOOD_PRESSURE_DIA");
const recentDaily = buildDailyBpRows(
sysRows,
diaRows,
recentCutoff,
userTz,
);
const olderSys = sysRows.filter((r) => r.measuredAt < recentCutoff);
const olderDia = diaRows.filter((r) => r.measuredAt < recentCutoff);
snapshot.bloodPressure = {
aggregate: features.bloodPressure,
timeline: {
recent: recentDaily,
weeklySys: bucketWeekly(olderSys, userTz),
weeklyDia: bucketWeekly(olderDia, userTz),
...(bpCoarseTail ? { coarse: bpCoarseTail } : {}),
},
};
metrics.add("bp");
windows.add("last30days");
windows.add("last90days");
counts.bp = features.bloodPressure.coverage?.count ?? undefined;
registerBlock("bloodPressure", "bp");
// W7 grounding: systolic 30-day mean against the ESH 2023 office bands.
const sys30 =
features.bloodPressure.avgSys30 ?? features.bloodPressure.allTimeAvgSys;
if (sys30 != null) groundingValues.set("BLOOD_PRESSURE", sys30);
}
if (wantsWeight && features.weight) {
const rows = byType("WEIGHT");
snapshot.weight = {
aggregate: features.weight,
timeline: {
recent: buildDailyValueRows(rows, recentCutoff, userTz),
weekly: bucketWeekly(
rows.filter((r) => r.measuredAt < recentCutoff),
userTz,
),
...(weightCoarseTail ? { coarse: weightCoarseTail } : {}),
},
};
metrics.add("weight");
windows.add("last7days");
windows.add("last30days");
counts.weight = features.weight.coverage?.count ?? undefined;
registerBlock("weight", "weight");
// W7 grounding: weight has no population band, but the derived BMI does.
// Use the features-computed BMI (same value the weight tile shows) so the
// grounding reads the WHO band. A dedicated BODY_MASS_INDEX block (below)
// wins if the user also syncs a measured BMI series.
if (features.weight.bmi != null) {
groundingValues.set("BMI", features.weight.bmi);
}
}
if (wantsPulse && features.pulse) {
const rows = byType("PULSE");
snapshot.pulse = {
aggregate: features.pulse,
timeline: {
recent: buildDailyValueRows(rows, recentCutoff, userTz),
weekly: bucketWeekly(
rows.filter((r) => r.measuredAt < recentCutoff),
userTz,
),
...(pulseCoarseTail ? { coarse: pulseCoarseTail } : {}),
},
};
metrics.add("pulse");
windows.add("last7days");
windows.add("last30days");
windows.add("last90days");
counts.pulse = features.pulse.coverage?.count ?? undefined;
registerBlock("pulse", "pulse");
}
if (wantsMood && features.mood && moodRows) {
// Mood entries live on a separate model — read in parallel above.
const normalised = moodRows.map((m) => ({
measuredAt: m.moodLoggedAt,
value: m.score,
}));
snapshot.mood = {
aggregate: features.mood,
timeline: {
recent: buildDailyValueRows(normalised, recentCutoff, userTz),
weekly: bucketWeekly(
normalised.filter((r) => r.measuredAt < recentCutoff),
userTz,
),
},
};
metrics.add("mood");
windows.add("last7days");
windows.add("last30days");
counts.mood = features.mood.coverage?.count ?? undefined;
registerBlock("mood", "mood");
}
if (wantsCompliance && complianceMeds) {
// Medication compliance lives outside the structured features today —
// the legacy Coach surface labelled it as "general" provenance only.
// v1.4.20.1 shipped a per-day adherence row; v1.16.9 derives it from
// the band-engine LEDGER (the same expansion the compliance % and the
// dose-history view consume): a slot counts against the rate only once
// it is genuinely missed, a pending/upcoming slot never reads as "not
// taken", deliberate skips and ad-hoc takes stay out of the
// denominator, and cross-source duplicate rows collapse onto one slot.
const ledgerRows: DoseHistoryRow[] = [];
// v1.17 W1c — the coach's headline adherence figure routes through the
// SAME `calculateCompliance(...).rate` ledger authority the medication
// card shows (the ledger path, `medicationContext` supplied), so the
// coach can never quote a denominator the card doesn't use. Per
// medication we take the ledger numerator (on-time + late takes) and
// denominator (taken + missed) and pool them across the user's
// scheduled medications: for a single medication the headline equals
// that med's card rate exactly; for several it is the dose-weighted
// overall adherence (the same pooling the cross-med timeline below
// already uses), never a per-day / per-week denominator of its own.
let complianceTaken = 0;
let complianceDenominator = 0;
const windowDaysForRate = Math.max(
1,
Math.round((now.getTime() - cutoff.getTime()) / (24 * 60 * 60 * 1000)),
);
for (const med of complianceMeds) {
if (med.schedules.length === 0) continue;
const ctx = buildComplianceMedicationContext(
med,
lastNonSkippedTakenAt(med.intakeEvents),
userTz,
);
ledgerRows.push(
...buildComplianceLedgerRows(
med.intakeEvents,
med.schedules,
ctx,
cutoff,
now,
now,
),
);
// The card's rate IS `calculateCompliance(...).rate` over the ledger;
// aggregate the same taken / (taken + missed) counts here so the
// coach's single headline % equals what the card renders.
const result = calculateCompliance(
med.intakeEvents,
med.schedules,
windowDaysForRate,
med.createdAt,
{ now, medicationContext: ctx },
);
complianceTaken += result.taken;
complianceDenominator += result.taken + result.missed;
}
// Countable rows: taken (on-time or late) or genuinely missed. The
// pending / upcoming / skipped / ad-hoc rows carry no adherence signal.
const countable = ledgerRows.filter(
(r) =>
r.status === "taken_on_time" ||
r.status === "taken_late" ||
r.status === "missed",
);
if (countable.length > 0) {
const recent = countable.filter((r) => r.at >= recentCutoff);
const olderRows = countable.filter((r) => r.at < recentCutoff);
const isTaken = (r: DoseHistoryRow) =>
r.status === "taken_on_time" || r.status === "taken_late";
const recentByDay = new Map<
string,
{ date: Date; total: number; taken: number }
>();
for (const r of recent) {
const key = tzDayKey(r.at, userTz);
const e = recentByDay.get(key) ?? {
date: r.at,
total: 0,
taken: 0,
};
e.total += 1;
if (isTaken(r)) e.taken += 1;
recentByDay.set(key, e);
}
const recentRows = Array.from(recentByDay.entries())
.map(([date, info]) => ({
date,
weekday: tzWeekday(info.date, userTz),
rate: Math.round((info.taken / info.total) * 100) / 100,
taken: info.taken,
total: info.total,
}))
.sort((a, b) => a.date.localeCompare(b.date));
const olderByWeek = new Map<string, { taken: number; total: number }>();
for (const r of olderRows) {
const key = isoWeekKey(r.at, userTz);
const e = olderByWeek.get(key) ?? { taken: 0, total: 0 };
e.total += 1;
if (isTaken(r)) e.taken += 1;
olderByWeek.set(key, e);
}
const weeklyRows = Array.from(olderByWeek.entries())
.map(([weekISO, v]) => ({
weekISO,
rate: v.total > 0 ? Math.round((v.taken / v.total) * 100) / 100 : 0,
taken: v.taken,
total: v.total,
}))
.sort((a, b) => a.weekISO.localeCompare(b.weekISO));
snapshot.compliance = {
// v1.17 W1c — headline adherence % from the SAME ledger authority
// (`calculateCompliance(...).rate`) the medication card shows: the
// dose-weighted pool of taken / (taken + missed) across the user's
// scheduled medications, so the coach quotes the card's figure
// (single med) or its honest overall adherence (several meds) rather
// than a per-day / per-week rate built off a different denominator.
// Integer 0-100 to match the card's rounding; null when no scheduled
// medication has any countable dose in the window.
rate:
complianceDenominator > 0
? Math.round((complianceTaken / complianceDenominator) * 100)
: null,
timeline: { recent: recentRows, weekly: weeklyRows },
};
metrics.add("compliance");
counts.compliance = countable.length;
registerBlock("compliance", "compliance");
} else {
// v1.7.0 — toggled-on cluster with no rows. Annotate so the
// dashboards can distinguish "user has no medication data" from
// "medication cluster was off".
annotate({
action: { name: "coach.cluster.empty_skipped" },
meta: { cluster: "medication" },
});
}
}
// ── v1.4.23 Apple Health additive blocks ─────────────────────────
//
// Each new HealthKit-derived metric ships as a timeline-only block
// (recent day rows + older weekly buckets). The aggregate features
// pipeline doesn't carry them yet — that's a v1.5 follow-up — but
// the timeline alone is enough for the Coach to ground "your HRV
// last Tuesday was X" replies in real numbers without inventing a
// baseline. The block is omitted entirely when the user has no rows
// for that metric, so accounts without iOS data never see a void
// section in the prompt.
type ValueMetric = Exclude<
CoachProvenanceMetric,
"general" | "bp" | "weight" | "pulse" | "mood" | "compliance" | "glucose"
>;
type ValueBlock = {
metric: ValueMetric;
source: CoachScopeSource;
snapshotKey: string;
type: string;
};
// v1.4.23 W6 (S-04) / v1.7.0 — every single-`MeasurementType`
// additive series ships as a timeline-only block (recent day rows +
// older weekly buckets). One entry per source; `source` drives both
// the `sources.has` gate and the cluster lookup (for the
// multi-cluster window cap + degradation priority). `glucose` and
// `workouts` are NOT in this table — they have dedicated branches
// (glucose carries `glucoseContext`, workouts reads the Workout
// model). The block is omitted entirely when the user has no rows,
// so accounts without that data never see a void section.
const valueBlocks: ValueBlock[] = [
// ── cardio ──
{
metric: "hrv",
source: "hrv",
snapshotKey: "heartRateVariability",
type: "HEART_RATE_VARIABILITY",
},
{
metric: "resting_hr",
source: "resting_hr",
snapshotKey: "restingHeartRate",
type: "RESTING_HEART_RATE",
},
{
metric: "walking_hr",
source: "walking_hr",
snapshotKey: "walkingHeartRateAverage",
type: "WALKING_HEART_RATE_AVERAGE",
},
{
metric: "respiratory_rate",
source: "respiratory_rate",
snapshotKey: "respiratoryRate",
type: "RESPIRATORY_RATE",
},
{
metric: "spo2",
source: "spo2",
snapshotKey: "oxygenSaturation",
type: "OXYGEN_SATURATION",
},
{
metric: "pulse_wave_velocity",
source: "pulse_wave_velocity",
snapshotKey: "pulseWaveVelocity",
type: "PULSE_WAVE_VELOCITY",
},
{
metric: "vascular_age",
source: "vascular_age",
snapshotKey: "vascularAge",
type: "VASCULAR_AGE",
},
// ── body composition ──
{
metric: "body_fat",
source: "body_fat",
snapshotKey: "bodyFat",
type: "BODY_FAT",
},
{
metric: "fat_mass",
source: "fat_mass",
snapshotKey: "fatMass",
type: "FAT_MASS",
},
{
metric: "fat_free_mass",
source: "fat_free_mass",
snapshotKey: "fatFreeMass",
type: "FAT_FREE_MASS",
},
{
metric: "muscle_mass",
source: "muscle_mass",
snapshotKey: "muscleMass",
type: "MUSCLE_MASS",
},
{
metric: "lean_body_mass",
source: "lean_body_mass",
snapshotKey: "leanBodyMass",
type: "LEAN_BODY_MASS",
},
{
metric: "bone_mass",
source: "bone_mass",
snapshotKey: "boneMass",
type: "BONE_MASS",
},
{
metric: "total_body_water",
source: "total_body_water",
snapshotKey: "totalBodyWater",
type: "TOTAL_BODY_WATER",
},
{
metric: "bmi",
source: "bmi",
snapshotKey: "bodyMassIndex",
type: "BODY_MASS_INDEX",
},
{
metric: "visceral_fat",
source: "visceral_fat",
snapshotKey: "visceralFat",
type: "VISCERAL_FAT",
},
// ── activity ──
{
metric: "steps",
source: "steps",
snapshotKey: "steps",
type: "ACTIVITY_STEPS",
},
{
metric: "active_energy",
source: "active_energy",
snapshotKey: "activeEnergy",
type: "ACTIVE_ENERGY_BURNED",
},
{
metric: "flights",
source: "flights",
snapshotKey: "flightsClimbed",
type: "FLIGHTS_CLIMBED",
},
{
metric: "distance",
source: "distance",
snapshotKey: "walkingRunningDistance",
type: "WALKING_RUNNING_DISTANCE",
},
{
metric: "vo2_max",
source: "vo2_max",
snapshotKey: "vo2Max",
type: "VO2_MAX",
},
// ── mobility & gait ──
{
metric: "walking_steadiness",
source: "walking_steadiness",
snapshotKey: "walkingSteadiness",
type: "WALKING_STEADINESS",
},
{
metric: "walking_asymmetry",
source: "walking_asymmetry",
snapshotKey: "walkingAsymmetry",
type: "WALKING_ASYMMETRY",
},
{
metric: "walking_double_support",
source: "walking_double_support",
snapshotKey: "walkingDoubleSupport",
type: "WALKING_DOUBLE_SUPPORT",
},
{
metric: "walking_step_length",
source: "walking_step_length",
snapshotKey: "walkingStepLength",
type: "WALKING_STEP_LENGTH",
},
{
metric: "walking_speed",
source: "walking_speed",
snapshotKey: "walkingSpeed",
type: "WALKING_SPEED",
},
// ── environment / exposure ──
{
metric: "audio_env",
source: "audio_env",
snapshotKey: "audioExposureEnvironment",
type: "AUDIO_EXPOSURE_ENV",
},
{
metric: "audio_headphone",
source: "audio_headphone",
snapshotKey: "audioExposureHeadphone",
type: "AUDIO_EXPOSURE_HEADPHONE",
},
{
metric: "audio_event",
source: "audio_event",
snapshotKey: "audioExposureEvent",
type: "AUDIO_EXPOSURE_EVENT",
},
{
metric: "daylight",
source: "daylight",
snapshotKey: "timeInDaylight",
type: "TIME_IN_DAYLIGHT",
},
{
metric: "skin_temp",
source: "skin_temp",
snapshotKey: "skinTemperature",
type: "SKIN_TEMPERATURE",
},
{
metric: "body_temp",
source: "body_temp",
snapshotKey: "bodyTemperature",
type: "BODY_TEMPERATURE",
},
];
for (const block of valueBlocks) {
if (!sources.has(block.source)) continue;
const blockCutoff = additiveCutoff(block.source);
const rows = byType(block.type).filter((r) => r.measuredAt >= blockCutoff);
if (rows.length === 0) {
const cluster = sourceCluster(block.source);
if (cluster) {
annotate({
action: { name: "coach.cluster.empty_skipped" },
meta: { cluster, source: block.source },
});
}
continue;
}
snapshot[block.snapshotKey] = {
timeline: {
recent: buildDailyValueRows(rows, recentCutoff, userTz),
weekly: bucketWeekly(
rows.filter((r) => r.measuredAt < recentCutoff),
userTz,
),
},
};
metrics.add(block.metric);
counts[block.metric] = rows.length;
registerBlock(block.snapshotKey, block.source);
// W7 grounding: when this additive series maps to a reference metric,
// record the recent daily mean (same per-day means the timeline shows).
const refMetric = TYPE_TO_REFERENCE_METRIC[block.type];
if (refMetric) {
const recentRows = buildDailyValueRows(rows, recentCutoff, userTz);
const mean = recentRowsMean(recentRows);
if (mean != null) groundingValues.set(refMetric, mean);
}
}
// ── v1.7.0 sleep block (with optional per-stage enrichment) ───────
//
// Sleep needs the `sleepStage` column so the Coach can narrate REM /
// core / deep / awake minutes per night instead of a flat duration.
// The shared `byType("SLEEP_DURATION")` rows above already cover the
// duration timeline, but they drop the stage label — so the sleep
// branch builds its own block. Per-night stage minutes come from a
// dedicated read of the SLEEP_DURATION rows that carry a non-null
// stage; the duration timeline is built from the same rows summed per
// night (one night = the sum of its per-stage rows).
if (sources.has("sleep") && sleepRows) {
// The SLEEP_DURATION rows (with the `sleepStage` column) are read in
// parallel above.
if (sleepRows.length === 0) {
annotate({
action: { name: "coach.cluster.empty_skipped" },
meta: { cluster: "sleep", source: "sleep" },
});
} else {
// v1.11.5 — reconstruct per-night TIME-ASLEEP totals through the shared
// helper so the Coach narrates the same nightly numbers every other
// sleep surface shows: stages clustered into sessions, a dual-source
// night collapsed to one canonical source, and the granular
// CORE/DEEP/REM partition counted WITHOUT double-counting the bare
// ASLEEP aggregate Apple Health writes alongside it. IN_BED + AWAKE are
// excluded from the asleep total.
const nights = reconstructSleepNights(
sleepRows as SleepStageRow[],
userTz,
prefsRow?.sourcePriorityJson ?? null,
).filter((n) => n.asleepMinutes > 0);
// Recent nights: asleep duration + stage breakdown when present.
const recentNights = nights
.filter((n) => n.measuredAt >= recentCutoff)
.map((n) => {
const row: Record<string, unknown> = {
date: n.measuredAt,
weekday: tzWeekday(n.measuredAt, userTz),
minutes: Math.round(n.asleepMinutes),
};
const stageEntries = Object.entries(n.stages).filter(
([stage]) => stage !== "IN_BED" && stage !== "AWAKE",
);
if (stageEntries.length > 0) {
row.stages = Object.fromEntries(
stageEntries.map(([k, v]) => [
k.toLowerCase(),
Math.round(v as number),
]),
);
}
return row;
})
.sort((a, b) => String(a.date).localeCompare(String(b.date)));
const olderNights = nights
.filter((n) => n.measuredAt < recentCutoff)
.map((n) => ({ measuredAt: n.measuredAt, value: n.asleepMinutes }));
snapshot.sleep = {
timeline: {
recent: recentNights,
weekly: bucketWeekly(olderNights, userTz),
},
};
metrics.add("sleep");
counts.sleep = sleepRows.length;
registerBlock("sleep", "sleep");
// W7 grounding: recent nightly asleep duration in HOURS against the
// AASM 7–9 h band (the reference unit is hours; the snapshot stores
// minutes). Mean over the recent nights the block already reconstructed.
if (recentNights.length > 0) {
const meanMin =
recentNights.reduce(
(s, n) => s + (typeof n.minutes === "number" ? n.minutes : 0),
0,
) / recentNights.length;
if (meanMin > 0) {
groundingValues.set("SLEEP_DURATION", meanMin / 60);
}
}
}
}
// ── v1.17.0 sleep-rhythm block (sleep-debt + chronotype) ──────────
//
// The two server-authoritative timing signals the Sleep page + the
// dashboard summary render — cumulative sleep debt and the MCTQ
// chronotype band + social jetlag. Built from the SAME assembler the
// dashboard route uses (`reconstructNights` → `computeSleepRhythmFromNights`),
// over the rhythm's OWN fixed trailing-42-day rows, so the coach quotes the
// exact debt + band the page shows regardless of the coach's narration
// window. ONE ENGINE: this never recomputes sleep-debt or chronotype inline —
// the math lives in `sleep-debt.ts` / `chronotype.ts`, reached through the
// assembler. The `needMinutes` is the SAME age-resolved need the derived
// block + Sleep Score read (`sleepNeedMinutes(ageYears)`), and the
// `sourcePriorityJson` is the one already loaded for the per-stage sleep
// block above — no extra read beyond the rhythm rows.
//
// LEARNING-GATE HONESTY: both signals carry a calm `partial` / `learning`
// state below their night thresholds. The chronotype `band` is emitted ONLY
// when the state is `ready` — a learning chronotype is surfaced as
// "still calibrating", never asserted as a band the data can't support.
if (sources.has("sleep") && sleepRhythmRows && sleepRhythmRows.length > 0) {
const rhythm: SleepRhythmDto = computeSleepRhythmFromNights(
reconstructNights(
sleepRhythmRows as SleepStageRow[],
userTz,
prefsRow?.sourcePriorityJson ?? null,
),
sleepNeedMinutes(derivedProfile.ageYears),
);
const chronotypeReady = rhythm.chronotype.state === "ready";
snapshot.sleepRhythm = {
sleepDebt: {
state: rhythm.sleepDebt.state,
debtMinutes: rhythm.sleepDebt.debtMinutes,
needMinutes: rhythm.sleepDebt.needMinutes,
},
chronotype: {
state: rhythm.chronotype.state,
// Only assert a band + social jetlag once the chronotype is `ready`.
// A `learning` chronotype carries no band the data supports — the
// model treats it as "still calibrating", never a typed assertion.
band: chronotypeReady ? rhythm.chronotype.band : null,
socialJetlagMinutes: chronotypeReady
? rhythm.chronotype.socialJetlagMinutes
: null,
},
};
metrics.add("sleep");
registerBlock("sleepRhythm", "sleep");
}
// ── v1.7.0 glucose block (per-context daily means) ────────────────
//
// Glucose is summarised per `GlucoseContext` so the Coach can tell
// fasting from postprandial without seeing raw samples. Each context
// gets its own per-day mean timeline; the block omits when no rows.
if (sources.has("glucose")) {
const glucoseCutoff = additiveCutoff("glucose");
const glucoseRows = measurementRows.filter(
(r) => r.type === "BLOOD_GLUCOSE" && r.measuredAt >= glucoseCutoff,
);
if (glucoseRows.length === 0) {
annotate({
action: { name: "coach.cluster.empty_skipped" },
meta: { cluster: "glucose", source: "glucose" },
});
} else {
// Group by context (NULL → "unspecified"), then per-day mean.
const byContext = new Map<
string,
Array<{ measuredAt: Date; value: number }>
>();
for (const r of glucoseRows) {
const ctx = r.glucoseContext
? String(r.glucoseContext).toLowerCase()
: "unspecified";
const list = byContext.get(ctx) ?? [];
list.push({ measuredAt: r.measuredAt, value: r.value });
byContext.set(ctx, list);
}
const contexts: Record<string, unknown> = {};
for (const [ctx, rows] of byContext) {
// v1.16.16 — glucose is stored canonical mg/dL. A mmol/L-preference
// user's Coach must read the same number every other surface shows
// (5.5, not 100). Aggregate the per-day / weekly means in raw mg/dL,
// then convert each resulting figure ONCE (parity with the series
// DTO + detail page + FHIR). mg/dL users stay byte-identical because
// the conversion is skipped entirely.
const recent = buildDailyValueRows(rows, recentCutoff, userTz).map(
(d) =>
glucoseUnit === "mmol/L"
? { ...d, value: convertGlucose(d.value, glucoseUnit) }
: d,
);
const weekly = bucketWeekly(
rows.filter((r) => r.measuredAt < recentCutoff),
userTz,
).map((w) =>
glucoseUnit === "mmol/L"
? { ...w, mean: convertGlucose(w.mean, glucoseUnit) }
: w,
);
contexts[ctx] = { recent, weekly };
}
// v1.17.0 — clinical panel summary from the ONE literature-locked engine
// the insights panel + doctor report also consume, computed over the SAME
// fixed trailing-30-day window + rows the panel uses (`glucoseClinicalRows`,
// not the coach-window / cap-trimmed `glucoseRows`), so the coach can never
// quote a TIR / GMI / CV% figure the panel doesn't show — true numeric
// parity, independent of the user's coach scope. Gated by `stillLearning`
// so a thin spot-data window is offered as a calm "still learning" note
// rather than asserted as a clinical AGP. The headline mean is converted
// ONCE to the user's display unit; the unit-agnostic fractions / indices
// travel as-is.
const clinicalRaw = computeGlucoseClinicalMetrics(
(glucoseClinicalRows ?? []).map((r) => ({
measuredAt: r.measuredAt,
mgdl: r.value,
})),
{ windowDays: GLUCOSE_CLINICAL_WINDOW_DAYS, now },
);
const clinical = clinicalRaw.stillLearning
? {
stillLearning: true as const,
reason: clinicalRaw.stillLearningReason,
readingCount: clinicalRaw.readingCount,
spanDays: Math.round(clinicalRaw.actualSpanDays),
}
: {
stillLearning: false as const,
windowDays: clinicalRaw.windowDays,
spanDays: Math.round(clinicalRaw.actualSpanDays),
readingCount: clinicalRaw.readingCount,
meanInRange:
clinicalRaw.meanMgdl !== null
? Math.round(
convertGlucose(clinicalRaw.meanMgdl, glucoseUnit) *
(glucoseUnit === "mmol/L" ? 10 : 1),
) / (glucoseUnit === "mmol/L" ? 10 : 1)
: null,
tirPercent: clinicalRaw.distribution
? Math.round(clinicalRaw.distribution.tir * 100)
: null,
timeBelowPercent: clinicalRaw.distribution
? Math.round(clinicalRaw.distribution.tbrLevel1 * 100)
: null,
timeAbovePercent: clinicalRaw.distribution
? Math.round(clinicalRaw.distribution.tarLevel1 * 100)
: null,
gmi:
clinicalRaw.gmi !== null
? Math.round(clinicalRaw.gmi * 10) / 10
: null,
estimatedA1c:
clinicalRaw.estimatedA1c !== null
? Math.round(clinicalRaw.estimatedA1c * 10) / 10
: null,
cvPercent: clinicalRaw.variability
? Math.round(clinicalRaw.variability.cv)
: null,
unstable: clinicalRaw.variability?.unstable ?? null,
// Density-derived: a sparse spot series stays a spot-reading
// estimate, a continuous CGM stream (Nightscout) reads false so the
// model can narrate the TIR/GMI as continuous-trace figures.
isSpotEstimate: clinicalRaw.isSpotEstimate,
};
// The display unit travels with the block so the prompt renders
// "<value> <unit>" and the EVIDENCE BLOCK tags glucose lines correctly.
snapshot.glucose = { unit: glucoseUnit, byContext: contexts, clinical };
metrics.add("glucose");
counts.glucose = glucoseRows.length;
registerBlock("glucose", "glucose");
// W7 grounding: fasting glucose mean in RAW mg/dL (the reference band's
// unit), independent of the user's mmol/L display preference. The
// grounding line's band selection respects the W6 `hasDiabetes` opt-in;
// here we only feed the representative fasting value. Fall back to the
// overall mean when no row is tagged FASTING so the band is still cited.
const fastingRows = glucoseRows.filter(
(r) => String(r.glucoseContext).toUpperCase() === "FASTING",
);
const glucoseScalarRows =
fastingRows.length > 0 ? fastingRows : glucoseRows;
const glucoseMeanMgdl =
glucoseScalarRows.reduce((s, r) => s + r.value, 0) /
glucoseScalarRows.length;
if (Number.isFinite(glucoseMeanMgdl)) {
groundingValues.set("BLOOD_GLUCOSE", glucoseMeanMgdl);
}
}
}
// ── v1.7.0 workouts block (capped list + per-sport rollup) ────────
//
// The Workout model is never dumped row-for-row. The block carries
// the most recent `WORKOUT_RECENT_CAP` sessions (sport, duration,
// energy, distance, avg/max HR) plus a per-sport weekly count + total
// duration/energy rollup for the tail so the prompt stays bounded
// even for a heavy-training account at a long window.
if (sources.has("workouts") && workoutRows) {
// The workout sessions are read in parallel above.
if (workoutRows.length === 0) {
annotate({
action: { name: "coach.cluster.empty_skipped" },
meta: { cluster: "workouts", source: "workouts" },
});
} else {
const recentList = workoutRows.slice(0, WORKOUT_RECENT_CAP).map((w) => ({
date: tzDayKey(w.startedAt, userTz),
weekday: tzWeekday(w.startedAt, userTz),
sport: w.sportType,
durationMin: Math.round(w.durationSec / 60),
energyKcal: w.totalEnergyKcal ?? null,
distanceM: w.totalDistanceM ?? null,
avgHr: w.avgHeartRate ?? null,
maxHr: w.maxHeartRate ?? null,
}));
// Per-sport rollup over the whole window.
const bySport = new Map<
string,
{ count: number; durationMin: number; energyKcal: number }
>();
for (const w of workoutRows) {
const e = bySport.get(w.sportType) ?? {
count: 0,
durationMin: 0,
energyKcal: 0,
};
e.count += 1;
e.durationMin += Math.round(w.durationSec / 60);
e.energyKcal += w.totalEnergyKcal ?? 0;
bySport.set(w.sportType, e);
}
const sportRollup = Array.from(bySport.entries())
.map(([sport, v]) => ({
sport,
count: v.count,
totalDurationMin: v.durationMin,
totalEnergyKcal: Math.round(v.energyKcal),
}))
.sort((a, b) => b.count - a.count);
snapshot.workouts = {
recent: recentList,
perSport: sportRollup,
totalInWindow: workoutRows.length,
};
metrics.add("workouts");
counts.workouts = workoutRows.length;
registerBlock("workouts", "workouts");
}
}
// ── v1.4.25 W4d — GLP-1 weeklyContext block ──────────────────────
//
// Only emitted when the user has at least one active GLP-1 medication
// (Medication.treatmentClass = GLP1). Web-only generic accounts never
// pay the read cost — the helper short-circuits to `null` after a
// single indexed Prisma lookup.
//
// The block names the drug, current dose, titration history, last +
// next injection, pen inventory, and recent side-effect tags. The
// Coach's GROUND RULE 9 forbids dose prescriptions — this block
// exists so the reply can SAY "your Mounjaro 7.5 mg" instead of
// "your medication", never to make recommendations.
// v1.4.36 W3 T2 — gated on `medications` exclusion. When excluded
// we skip the Prisma lookup entirely so the read cost vanishes too.
if (!excludesMedications && glp1Block) {
// The GLP-1 block is read in parallel above (null when excluded or
// when the account has no active GLP-1 medication).
snapshot.weeklyContext = { glp1: glp1Block };
metrics.add("compliance");
registerBlock("weeklyContext", "compliance");
}
// v1.4.36 W3 T2 — anthropometrics block (height / age / gender).
// Sourced from `features.context`, which already reads
// `User.heightCm / dateOfBirth / gender`. Gated on the
// `anthropometrics` exclusion AND on at least one non-null field
// — accounts with no profile info never see an empty block. The
// `ctx?` guard tolerates mocked feature shapes in the test suite
// that don't populate the `context` object.
if (!excludesAnthropometrics) {
const ctx = features.context;
if (
ctx &&
(ctx.heightCm !== null || ctx.ageYears !== null || ctx.gender !== null)
) {
snapshot.anthropometrics = {
heightCm: ctx.heightCm,
ageYears: ctx.ageYears,
gender: ctx.gender,
};
}
}
// ── v1.10.0 — derived wellness layer (compact summaries) ─────────────
//
// The composites + persisted scores the dashboard rings render, folded
// in as one tiny object per metric (value + band + coverage), NOT the
// raw series. So the Coach can say "your readiness is 64, low band" and
// ground it in the same number the user sees. Insufficient metrics are
// omitted (no "no data" noise). Reads the same `computeDerivedMetric`
// contract every surface uses — no recompute. Gated on at least one of
// the signals the composites are built from staying in-scope (HRV /
// resting HR / sleep / VO₂max), so a user who excludes those doesn't see
// the block. `derivedActive` + `derivedProfile` are resolved up top so
// the derived / trajectory reads can run in the parallel batch.
if (derivedActive) {
if (derivedBlock) {
snapshot.derived = derivedBlock;
metrics.add("hrv");
registerBlock("derived", "hrv");
}
// ── v1.17.0 — WHOOP-native day strain ────────────────────────────────
// The device's gold-standard strain on its native 0–21 scale, kept
// distinct from the COMPUTED `derived.STRAIN_SCORE` (0–100). When both
// exist we surface the native number AND flag it as the device signal
// so the model prefers it (native-over-computed, mirroring recovery).
// Omitted for every account without a DAY_STRAIN row.
if (dayStrainRows && dayStrainRows.length > 0) {
const latest = dayStrainRows[dayStrainRows.length - 1];
const recent = dayStrainRows.filter((r) => r.measuredAt >= recentCutoff);
const mean =
recent.length > 0
? Math.round(
(recent.reduce((sum, r) => sum + r.value, 0) / recent.length) *
10,
) / 10
: Math.round(latest.value * 10) / 10;
snapshot.dayStrain = {
source: "WHOOP-native",
scale: "0-21",
latest: Math.round(latest.value * 10) / 10,
recentMean: mean,
days: dayStrainRows.length,
note: "Device-native day strain; prefer over derived.STRAIN_SCORE (computed 0-100 proxy).",
};
registerBlock("dayStrain", "hrv");
}
// ── v1.11.0 (Epic B, Pillar 3) — short-horizon trajectory block ──────
// Additive, lowest-signal block: per in-scope metric a compact
// direction + slope + projected horizon-end-with-band, computed by the
// deterministic `computeTrajectory` engine (NEVER recomputed here). The
// Coach narrates the range conditionally (system-prompt rule 11 /
// ground rule 16) only when this block is present. Registered under an
// `environment`-cluster source so the soft-cap degrader sheds it FIRST,
// before any clinical cluster, under prompt-budget pressure. Read in
// the parallel batch above.
if (trajectoryBlock) {
snapshot.trajectory = trajectoryBlock;
registerBlock("trajectory", "skin_temp");
}
// ── RECON1 (D5-5) — discovered cross-metric drivers ──────────────────
// The bounded top-N driver pairs (post quality-gate, post-rank) from the
// SAME engine the get_correlations tool reads, attached so the no-tools /
// local-provider path narrates the cross-metric layer it was missing —
// closing the parity gap with the tool path and making system-prompt rule
// 14's "any driver field the SNAPSHOT carries" fallback real. Descriptive,
// never causal. Registered against the lowest-priority `skin_temp`
// (environment) cluster so the budget degrader sheds it before any
// clinical block under prompt-budget pressure.
if (correlationsBlock) {
snapshot.correlations = correlationsBlock;
registerBlock("correlations", "skin_temp");
}
}
// ── v1.11.0 W5a — rolling-profile memory (Pillar P2 2a) ──────────────
//
// Zero-LLM longitudinal recall: the latest period-narrative headline +
// a per-metric prior-vs-current band memory, assembled from artefacts we
// already persist. Lets the Coach reference "as I noted at the start of
// the month…" instead of re-deriving cold every turn. Folded under the
// `memory` key and registered against the LOWEST-signal cluster
// (`environment`, the tail of CLUSTER_PRIORITY) so `degradeToBudget`
// sheds it FIRST under the char cap — before any clinical cluster. The
// builder is fault-isolated per sub-source and returns null when neither
// a narrative nor any band movement is on file. Read in the parallel
// batch above.
if (memoryBlock) {
snapshot.memory = memoryBlock;
// `skin_temp` maps to the `environment` cluster — the lowest priority
// in CLUSTER_PRIORITY — so this block degrades before everything else.
registerBlock("memory", "skin_temp");
}
// ── v1.15 — cycle/phase block ────────────────────────────────────────
//
// Present only for a cycle-enabled account (the promise is null otherwise,
// so this is byte-for-byte unchanged for everyone else). The block names the
// current phase + day-of-cycle, the next predicted event (period range +
// confidence + method; fertile window goal-gated), and the headline
// phase-correlation finding — all from the same deterministic engine the
// calendar + insights surface use, never re-derived. The Coach's cycle
// ground rule keeps replies descriptive: never contraception-grade, never a
// "safe day" claim. Registered against the lowest-priority `skin_temp`
// source so the soft-cap degrader sheds it before any clinical cluster.
if (cycleBlock) {
snapshot.cycle = cycleBlock;
registerBlock("cycle", "skin_temp");
}
// v1.18.1 P4 — illness/condition context. Small + load-bearing, so it is
// attached WITHOUT a cluster registration (like scope/anthropometrics): the
// budget degrader never sheds it, the Coach always knows whether the user is
// in Rest Mode. Labels + lifecycle + dates only — no decrypted note.
if (illnessBlock) {
snapshot.illness = illnessBlock;
}
// v1.18.11 (#65) — lab-result context. Attached WITHOUT a cluster
// registration (like illness/scope): the budget degrader never sheds it, so
// the Coach can always answer a lab question from the user's own readings.
// Server-authoritative + grounded — resolved name/value/unit/range per
// biomarker, never the decrypted note.
if (labsBlock) {
snapshot.labs = labsBlock;
}
if (Object.keys(snapshot).length === 0) {
metrics.add("general");
}
// Pin the scope onto the snapshot itself so the model knows which
// windows + sources are in-bounds for the reply. The system prompt
// tells the model to read from this block for day-level questions.
snapshot.scope = {
window,
sources: Array.from(sources),
timelineRecentDays: DAILY_TIMELINE_DAYS,
};
// v1.4.36 W3 T4 — compactSections drops any zero-row block before
// serialisation so the prompt never carries a labelled-empty key.
// The snapshot is built conditionally above so most empty paths are
// already skipped, but the helper catches future regressions and
// matches the contract the /insights/generate route applies on its
// side of the prompt.
const compactSnapshot = compactSections(snapshot);
// v1.7.0 — assembled-snapshot soft cap. Enabling every cluster at a
// long window can balloon the prompt; degrade progressively by
// reverse cluster priority until the serialised size fits
// `MAX_SNAPSHOT_CHARS`. The `scope` block is exempt — the model
// needs it to know what is in-bounds. The helper emits its own
// `coach.snapshot.truncated` annotation when it sheds anything.
degradeToBudget(compactSnapshot, blockClusters);
// v1.18.6 (W7) — build the citation-aware reference-grounding block from the
// representative scalars collected above. Deterministic + pure; the route
// appends it verbatim after the SNAPSHOT. Null when no present metric is
// covered by the reference backbone. Insertion order follows the block-build
// order (BP first), giving a stable, inspectable block for the
// hallucination-QA pass.
const groundingMetrics: GroundingMetricInput[] = Array.from(
groundingValues.entries(),
).map(([metric, value]) => ({ metric, value }));
const referenceGrounding = buildReferenceGroundingBlock({
metrics: groundingMetrics,
hasDiabetes: prefsRow?.hasDiabetes ?? false,
});
if (referenceGrounding) {
annotate({
action: { name: "coach.grounding.attached" },
meta: {
metrics: groundingMetrics.map((m) => m.metric).sort(),
hasDiabetes: prefsRow?.hasDiabetes ?? false,
},
});
}
return {
snapshotJson: JSON.stringify(compactSnapshot, null, 2),
// v1.20.0 (F1) — the structured, post-degrade snapshot record keyed by
// domain block (`bloodPressure`, `glucose`, `labs`, …). The coach tool
// executor slices a single domain block out of this so a retrieval tool
// returns exactly the numbers the legacy snapshot path would have shown —
// same builder, same gates, same I/O. `snapshotJson` is this record
// serialised; exposing the record avoids re-parsing it.
sections: compactSnapshot,
provenance: {
windows: Array.from(windows),
metrics: Array.from(metrics),
counts: Object.keys(counts).length > 0 ? counts : undefined,
},
referenceGrounding,
};
}
/**
* v1.7.0 — progressive degradation to the snapshot char budget.
*
* Mutates `snapshot` in place. Walks the blocks in REVERSE cluster
* priority (lowest-signal first) over two passes:
* 1. drop `timeline.recent` (keep `aggregate` + `timeline.weekly`),
* 2. collapse `timeline.weekly` too (keep only `aggregate` / the
* smallest summary the block carries).
* Stops as soon as the serialised size fits. Emits one
* `coach.snapshot.truncated` annotation describing what was shed.
*
* Returns the list of `{ key, cluster, pass }` it degraded — empty when
* the snapshot already fit.
*/
function degradeToBudget(
snapshot: Record<string, unknown>,
blockClusters: Map<string, CoachDataCluster>,
): Array<{ key: string; cluster: CoachDataCluster; pass: number }> {
const degraded: Array<{
key: string;
cluster: CoachDataCluster;
pass: number;
}> = [];
// Measure against the SAME pretty-printed form the prompt ships
// (`JSON.stringify(snapshot, null, 2)`), not the compact form —
// otherwise the cap under-counts by ~2× and the prompt overflows.
const size = () => JSON.stringify(snapshot, null, 2).length;
if (size() <= MAX_SNAPSHOT_CHARS) return degraded;
// Build a degrade order: blocks grouped by cluster, lowest priority
// first. A block with no registered cluster (e.g. anthropometrics,
// scope) is never touched — those are tiny + load-bearing.
const priorityIndex = new Map<CoachDataCluster, number>();
CLUSTER_PRIORITY.forEach((c, i) => priorityIndex.set(c, i));
const orderedKeys = Array.from(blockClusters.entries()).sort((a, b) => {
const pa = priorityIndex.get(a[1]) ?? -1;
const pb = priorityIndex.get(b[1]) ?? -1;
// Higher priority index = lower signal = degrade first.
return pb - pa;
});
const asRecord = (v: unknown): Record<string, unknown> | null =>
typeof v === "object" && v !== null && !Array.isArray(v)
? (v as Record<string, unknown>)
: null;
// Drop the dense per-day detail from a block, leaving the coarser
// weekly / aggregate summary. Handles the three block shapes:
// - timeline-bearing blocks (`timeline.recent`)
// - glucose (`byContext.<ctx>.recent`)
// - workouts (`recent` at the top level)
const dropRecent = (key: string): boolean => {
const block = asRecord(snapshot[key]);
if (!block) return false;
let changed = false;
const timeline = asRecord(block.timeline);
if (timeline && "recent" in timeline) {
delete timeline.recent;
changed = true;
}
// v1.18.7 — the coarse MONTH/YEAR tail + anomaly envelope is the
// lowest-value, oldest detail, so it sheds in the same first pass as the
// dense per-day rows.
if (timeline && "coarse" in timeline) {
delete timeline.coarse;
changed = true;
}
const byContext = asRecord(block.byContext);
if (byContext) {
for (const ctx of Object.keys(byContext)) {
const c = asRecord(byContext[ctx]);
if (c && "recent" in c) {
delete c.recent;
changed = true;
}
}
}
if ("recent" in block) {
delete block.recent;
changed = true;
}
return changed;
};
// Collapse the weekly buckets too — leaves only the aggregate /
// smallest summary the block carries.
const dropWeekly = (key: string): boolean => {
const block = asRecord(snapshot[key]);
if (!block) return false;
let changed = false;
const timeline = asRecord(block.timeline);
if (timeline) {
for (const field of ["weekly", "weeklySys", "weeklyDia"]) {
if (field in timeline) {
delete timeline[field];
changed = true;
}
}
if (Object.keys(timeline).length === 0) {
delete block.timeline;
}
}
const byContext = asRecord(block.byContext);
if (byContext) {
for (const ctx of Object.keys(byContext)) {
const c = asRecord(byContext[ctx]);
if (c && "weekly" in c) {
delete c.weekly;
changed = true;
}
}
}
return changed;
};
// Last resort: replace the whole block with a compact marker so the
// model still knows the cluster exists without paying for its rows.
// v1.16.8 — the `memory` block's `facts` list survives the drop: it
// carries the durable personal facts (a stated allergy, a stated
// condition) and is tiny by construction (top-8, ≤160 chars each).
// Shedding it made the Coach forget a stated allergy exactly on the
// data-heavy accounts that hit the char cap; the bulky narrative +
// trend recall still goes.
const dropBlock = (key: string): boolean => {
if (!(key in snapshot)) return false;
const block = asRecord(snapshot[key]);
const facts =
block && Array.isArray(block.facts) && block.facts.length > 0
? block.facts
: null;
snapshot[key] = facts
? { facts, omitted: "trimmed for prompt budget" }
: { omitted: "trimmed for prompt budget" };
return true;
};
// Degrade per-block, LOWEST priority first, collapsing each block as
// far as needed before advancing to the next (higher-priority) one.
// For each block in turn: drop the dense per-day detail, then the
// weekly buckets, then — only if it still overflows — replace the
// whole block with a marker. A higher-priority block is touched only
// once every lower-priority block is already fully collapsed and the
// prompt still exceeds the cap, so the clinical core keeps its detail
// until it is genuinely the last lever left.
for (const [key, cluster] of orderedKeys) {
if (size() <= MAX_SNAPSHOT_CHARS) break;
if (dropRecent(key)) degraded.push({ key, cluster, pass: 1 });
if (size() <= MAX_SNAPSHOT_CHARS) break;
if (dropWeekly(key)) degraded.push({ key, cluster, pass: 2 });
if (size() <= MAX_SNAPSHOT_CHARS) break;
if (dropBlock(key)) degraded.push({ key, cluster, pass: 3 });
}
if (degraded.length > 0) {
const droppedClusters = Array.from(new Set(degraded.map((d) => d.cluster)));
annotate({
action: { name: "coach.snapshot.truncated" },
meta: {
droppedClusters,
droppedBlocks: degraded.map((d) => d.key),
finalChars: size(),
},
});
}
return degraded;
}