Files
HealthLog/src/lib/ai/coach/derived-snapshot.ts
T
Marc-André Bombeck 87cf32bc67 fix(coach): leave derived scores out when their window does not fit the limit
The derived block shortened each engine's window to the lookback limit,
so a 7-day limit produced a readiness computed over 7 days instead of the
30 the ring on the dashboard uses, under the same name. A block whose own
window does not fit is now left out, as the lookback rule says, and the
coincident-deviation flag follows its 30-day baseline the same way.
2026-10-05 08:34:27 +02:00

397 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.10.0 — derived-metrics block for the Coach prompt.
*
* Folds the v1.10 derived wellness layer into the Coach snapshot as
* COMPACT summaries — one small object per metric carrying value + band +
* coverage, never the raw series. The Coach narrates the same numbers the
* dashboard rings render (single source of truth) without ballooning the
* ~6k-token snapshot budget: a fully-populated block is ~8 tiny objects.
*
* Every entry reads the one `computeDerivedMetric` contract — no recompute.
* A metric whose value is `insufficient` is OMITTED entirely (we never feed
* the model "no data" noise; the snapshot's compactSections pass would drop
* an empty key anyway).
*
* Server-only — calls the derived dispatcher, which reads `@/lib/db`.
*/
import {
computeDerivedMetric,
computeCoincidentDeviation,
isDerivedOk,
type DerivedMetricId,
type BaselineProfile,
} from "@/lib/insights/derived";
import { prisma } from "@/lib/db";
import {
UNBOUNDED_REACH,
fitsReach,
type CoachHistoryReach,
} from "./history-reach";
/**
* Trailing window the recovery dedup probes for a WHOOP-native row — matches
* the wellness reader's default trend window so "is the resolved recovery the
* COMPUTED proxy?" is answered against the same set of days.
*/
const RECOVERY_DEDUP_WINDOW_DAYS = 14;
/**
* The window each engine reads (its own `DEFAULT_WINDOW_DAYS`). Under a
* lookback limit a metric whose window does not fit inside it is left out,
* never computed over a shortened window: a 7-day readiness would disagree
* with the 30-day ring the dashboard shows under the same name.
*/
const DERIVED_DEFAULT_WINDOW_DAYS: Readonly<
Partial<Record<DerivedMetricId, number>>
> = {
READINESS: 30,
RECOVERY_SCORE: 14,
STRESS_SCORE: 14,
STRAIN_SCORE: 14,
SLEEP_SCORE: 30,
HRV_BALANCE: 30,
FITNESS_AGE: 180,
VASCULAR_AGE_DELTA: 365,
};
/** The coincident-deviation flag's own default window. */
const COINCIDENT_DEFAULT_WINDOW_DAYS = 30;
/** The high-signal derived metrics worth a Coach prompt slot. The vitals
* baseline is omitted — the per-vital aggregate block already carries those
* numbers; here we add the composites + scores the aggregates can't express. */
const SNAPSHOT_METRICS: DerivedMetricId[] = [
"READINESS",
"RECOVERY_SCORE",
"STRESS_SCORE",
"STRAIN_SCORE",
"SLEEP_SCORE",
"HRV_BALANCE",
"FITNESS_AGE",
"VASCULAR_AGE_DELTA",
];
/** One compact line per derived metric the model can ground a reply in. */
interface DerivedSnapshotEntry {
/** 0–100 score, the recent average, or the re-framed device value. */
value: number;
/** The metric's band/category in plain words (green/yellow/red, etc.). */
band?: string;
/** Confidence 0–100 from the coverage model. */
confidence?: number;
/** Days of history backing the value. */
historyDays: number;
}
/**
* v1.21.0 (C3 / D3) — the coincident-deviation flag, compactly. "Two or more
* of your vitals are outside their usual band today." Descriptive multi-signal
* co-movement, never a cause; carries the illness-explained reframe. Folded
* into the derived block so BOTH Coach paths (the tool path via
* get_illness_recovery/get_correlations AND the no-tools snapshot floor) can
* narrate it. Omitted entirely when it did not fire (no noise).
*/
interface CoincidentSnapshotEntry {
fired: true;
/** The out-of-band vitals (possible factors), e.g. "resting heart rate above". */
contributing: string[];
/** The day the flag was evaluated (YYYY-MM-DD). */
day: string;
/** True when an active illness episode explains the deviations. */
illnessExplained: boolean;
}
/**
* v1.21.2 (A5) — the Tension Verdict flag, compactly. The readiness/recovery
* composite blends several contributors; when they DISAGREE (e.g. good sleep
* but a suppressed HRV-balance or an elevated resting pulse) the single band
* hides the conflict. This flag names the honest "internal read" the model
* narrates on the health-score card — "good sleep, but resting pulse is up — the
* composite lands yellow" — instead of papering the conflict over.
*
* DESCRIPTIVE multi-signal disagreement, never a cause. Computed deterministically
* server-side off the readiness contributors (each 0..100, higher = better); the
* model narrates the flag, it never invents it. Omitted entirely when the
* contributors agree (no tension to surface).
*
* CLINICAL-FLOORS OVERRIDE: `clinicalOverride` is true when a clinical red-flag
* is in play (the coincident-deviation flag fired WITHOUT an illness explanation).
* The narration must NOT reconcile a real red-flag away into a calm verdict — the
* flag carries the bit so the prompt clause can refuse the soft landing.
*/
interface TensionSnapshotEntry {
fired: true;
/** The composite band the disagreement resolved to (green/yellow/red). */
band: string;
/** Contributors reading FAVOURABLY, e.g. "sleep". */
positive: string[];
/** Contributors reading UNFAVOURABLY, e.g. "resting heart rate", "HRV balance". */
negative: string[];
/** True when a real clinical red-flag is in play — never reconcile it away. */
clinicalOverride: boolean;
}
/**
* The derived block: per-metric compact score entries (keyed by metric id),
* plus the optional fired-only coincident-deviation flag under its own reserved
* `COINCIDENT_DEVIATION` key. The score entries keep their `DerivedSnapshotEntry`
* shape (so callers + tests read `block.READINESS.value` directly); the
* coincident + tension flags ride alongside without widening the score index
* signature.
*/
type DerivedSnapshotBlock = Record<string, DerivedSnapshotEntry> & {
COINCIDENT_DEVIATION?: CoincidentSnapshotEntry;
TENSION?: TensionSnapshotEntry;
};
/**
* v1.21.2 (A5) — a contributor reading at/above this 0..100 score is FAVOURABLE;
* at/below `TENSION_LOW_SCORE` it is UNFAVOURABLE. The gap in the middle is
* "neutral" — neither side of a disagreement. A genuine tension needs at least
* one contributor on EACH side.
*/
export const TENSION_HIGH_SCORE = 70;
export const TENSION_LOW_SCORE = 45;
/** Human-readable contributor labels (no internal enum names reach the model). */
const READINESS_CONTRIBUTOR_LABEL: Record<string, string> = {
rhr: "resting heart rate",
hrv: "HRV balance",
sleep: "sleep",
respiratory: "respiratory rate",
mood: "mood stability",
};
/**
* v1.21.2 (A5) — derive the Tension Verdict from the readiness contributors.
* Pure: a list of `{ key, value }` contributors (value 0..100, higher = better,
* null = absent) plus the composite band and the clinical-override bit. Returns
* the fired flag ONLY when at least one contributor reads favourably AND at least
* one reads unfavourably — a genuine internal disagreement. Null otherwise.
*/
export function deriveTension(
contributors: Array<{ key: string; value: number | null }>,
band: string | undefined,
clinicalOverride: boolean,
): TensionSnapshotEntry | null {
const positive: string[] = [];
const negative: string[] = [];
for (const c of contributors) {
if (c.value === null) continue;
const label = READINESS_CONTRIBUTOR_LABEL[c.key] ?? c.key;
if (c.value >= TENSION_HIGH_SCORE) positive.push(label);
else if (c.value <= TENSION_LOW_SCORE) negative.push(label);
}
if (positive.length === 0 || negative.length === 0) return null;
return {
fired: true,
band: band ?? "yellow",
positive,
negative,
clinicalOverride,
};
}
/** Pull the headline number + band off each metric's value shape. */
function summariseValue(
metric: DerivedMetricId,
value: unknown,
): { value: number; band?: string } | null {
const v = value as Record<string, unknown>;
switch (metric) {
case "READINESS":
case "SLEEP_SCORE":
case "RECOVERY_SCORE":
case "STRESS_SCORE":
case "STRAIN_SCORE":
return typeof v.score === "number"
? {
value: v.score,
band: typeof v.band === "string" ? v.band : undefined,
}
: null;
case "HRV_BALANCE":
return typeof v.recentAvg === "number"
? {
value: Math.round(v.recentAvg),
band: typeof v.band === "string" ? v.band : undefined,
}
: null;
case "FITNESS_AGE":
return typeof v.vo2Max === "number"
? {
value: Math.round(v.vo2Max * 10) / 10,
band:
typeof v.fitnessAgeDeltaYears === "number"
? `${v.fitnessAgeDeltaYears >= 0 ? "+" : ""}${v.fitnessAgeDeltaYears}yr vs age`
: undefined,
}
: null;
case "VASCULAR_AGE_DELTA":
return typeof v.vascularAge === "number"
? {
value: Math.round(v.vascularAge),
band:
typeof v.deltaYears === "number"
? `${v.deltaYears >= 0 ? "+" : ""}${Math.round(v.deltaYears)}yr vs age`
: undefined,
}
: null;
default:
return null;
}
}
/**
* Build the compact derived block, or `null` when no derived metric resolved
* to `ok`. Each entry is value + band + confidence + historyDays — the model
* never sees the underlying series. Computes the metrics sequentially off the
* one shared profile (no per-metric profile re-read).
*/
export async function buildDerivedSnapshotBlock(
userId: string,
profile: BaselineProfile,
now: Date,
tz?: string,
reach: CoachHistoryReach = UNBOUNDED_REACH,
): Promise<DerivedSnapshotBlock | null> {
const block: Record<string, DerivedSnapshotEntry> = {};
// Every engine keeps its own window; one that does not fit is not run.
const fits = (metric: DerivedMetricId) =>
fitsReach(
DERIVED_DEFAULT_WINDOW_DAYS[metric] ?? COINCIDENT_DEFAULT_WINDOW_DAYS,
reach,
);
// v1.21.0 (C3 / D3) — the coincident-deviation flag, fired-only. Computed
// off the one shared profile alongside the scores; fail-soft to null so a
// baseline hiccup never sinks the derived block. Only attached when it
// FIRED (≥2 vitals out of band today) — a quiet day adds no entry, keeping
// the snapshot noise-free. D2-8: pass the user's tz so the "today" grouping
// matches the user's calendar day, not UTC's.
// Its 30-day baseline is left out, like any block, under a shorter limit.
const coincidentPromise = fitsReach(COINCIDENT_DEFAULT_WINDOW_DAYS, reach)
? computeCoincidentDeviation(userId, profile, {
now,
...(tz ? { tz } : {}),
}).catch(() => null)
: Promise.resolve(null);
// The metrics are independent passthrough reads off the one shared profile —
// no ordering dependency — so compute them concurrently. Per-metric fault
// isolation: a transient failure on one must never sink the whole Coach turn,
// so each compute resolves to null on throw rather than rejecting the batch.
const computed = await Promise.all(
SNAPSHOT_METRICS.filter(fits).map(async (metric) => {
try {
return {
metric,
derived: await computeDerivedMetric({
metric,
userId,
profile,
now,
}),
};
} catch {
return { metric, derived: null };
}
}),
);
// v1.21.2 (A5) — capture the readiness contributors so the Tension Verdict can
// read the per-contributor directions the composite band hides. Only the
// readiness blend exposes them; recovery resolves to the same proxy when no
// device-native row exists, so reading readiness once is enough.
let readinessContributors: Array<{ key: string; value: number | null }> = [];
let readinessBand: string | undefined;
for (const { metric, derived } of computed) {
if (derived === null) continue;
if (!isDerivedOk(derived)) continue; // omit insufficient — no noise
if (metric === "READINESS") {
const v = derived.value as {
band?: string;
components?: Array<{ key: string; value: number | null }>;
};
readinessBand = typeof v.band === "string" ? v.band : undefined;
readinessContributors = Array.isArray(v.components)
? v.components.map((c) => ({ key: c.key, value: c.value }))
: [];
}
const summary = summariseValue(metric, derived.value);
if (!summary) continue;
block[metric] = {
value: summary.value,
band: summary.band,
confidence: derived.confidence.score,
historyDays: derived.coverage.historyDays,
};
}
// Recovery dedup (one number, one engine). The COMPUTED recovery proxy IS the
// readiness blend verbatim, so when no WHOOP-native row exists the resolved
// recovery equals readiness — feeding both labels would hand the model the
// identical score under two names. Drop the redundant recovery line ONLY when
// the resolved recovery is that COMPUTED proxy (no WHOOP-native row present).
// A genuine WHOOP recovery stays even if it coincidentally equals readiness —
// a numeric tie must never silence the device's ground-truth number.
if (block.READINESS !== undefined && block.RECOVERY_SCORE !== undefined) {
const whoopRecovery = await prisma.measurement.findFirst({
where: {
userId,
type: "RECOVERY_SCORE",
source: "WHOOP",
deletedAt: null,
measuredAt: {
// Both lines exist only when their windows fit, so this 14-day
// probe lies inside the limit too.
gte: new Date(
now.getTime() - RECOVERY_DEDUP_WINDOW_DAYS * 24 * 60 * 60 * 1000,
),
lte: now,
},
},
select: { id: true },
});
if (whoopRecovery === null) {
delete block.RECOVERY_SCORE;
}
}
// Attach the coincident-deviation flag (fired-only) — the await happens here
// so the score computes above run concurrently with it. It rides under its
// own reserved key alongside the score entries.
const out: DerivedSnapshotBlock = block;
const coincident = await coincidentPromise;
const coincidentFired =
coincident !== null && isDerivedOk(coincident) && coincident.value.fired;
if (coincidentFired) {
out.COINCIDENT_DEVIATION = {
fired: true,
contributing: coincident.value.contributing.map(
(c) =>
`${String(c.type).replace(/_/g, " ").toLowerCase()} ${c.direction}`,
),
day: coincident.value.day,
illnessExplained: coincident.value.illnessExplained,
};
}
// v1.21.2 (A5) — the Tension Verdict. A real clinical red-flag is in play when
// the coincident-deviation flag fired WITHOUT an illness explanation; the flag
// carries that bit so the narration never reconciles a red-flag into a calm
// verdict (clinical-floors override). Only attached when the contributors
// genuinely disagree — a coherent readiness adds no entry.
const clinicalOverride =
coincidentFired && !coincident.value.illnessExplained;
const tension = deriveTension(
readinessContributors,
readinessBand,
clinicalOverride,
);
if (tension) out.TENSION = tension;
return Object.keys(out).length > 0 ? out : null;
}