Files
HealthLog/src/lib/insights/derived/coincident-deviation.ts
T
Marc-André Bombeck c59cf0b652 feat(illness): Rest Mode — annotate score/recovery/streaks during an active episode + FHIR/doctor-report
An active illness/condition episode annotates, it never penalises: the
measured number is untouched, only the narrative changes.

- rest-mode.ts: one server-authoritative resolver (module-gated, fail-soft)
  for "is the account in Rest Mode + which episodes back it". iOS mirrors the
  resolved context, never recomputes the suppression.
- health-score: /api/analytics attaches a value-free Rest Mode annotation;
  computeHealthScore stays pure (score never adjusted).
- recovery-score: computeRecoveryScore carries the Rest Mode context as of the
  scored day; the persisted blend is unchanged.
- coach-nudge: pause cadence-nudges during an active episode (new
  skippedDuringIllness counter, gate sits before the trigger search).
- achievements: bridgeFrozenStreakGaps freezes (does not break) every
  day-streak across illness days; never invents a streak, a non-illness gap
  still breaks.
- coincident-deviation: reframe a fired flag as illness-explained; vital
  numbers untouched. Surfaced as a neutral line on the insights card.
- coach snapshot: illness history + active illness as load-bearing context so
  the coach knows about Rest Mode (no decrypted note reaches the prompt).
- FHIR: illness episodes map to Condition (generic disease root, label on
  code.text, patient-reported/unconfirmed) + a bounding Encounter.
- doctor-report: module-gated illness section in the aggregator + PDF table
  (retrospective, neutral palette); FHIR Condition section in the bundle.

i18n x6 for the report + reframe copy. Tests: score-annotation-not-
penalisation, streak-freeze, coach-nudge pause, FHIR Condition/Encounter
mapping, doctor-report inclusion, Rest Mode resolver. typecheck/lint/test
green, openapi in sync, knip clean.
2026-06-16 22:25:50 +02:00

264 lines
9.3 KiB
TypeScript

/**
* v1.10.0 — Coincident-deviation flag (catalogue metric #2, COMPOSITE).
*
* "2 of your vitals are outside their usual range this morning — possible
* factors: illness, alcohol, altitude, a hard workout." For each vital
* with an established personal band (#1 `VITALS_BASELINE`), check whether
* the latest reading falls outside the band; **≥ 2 outside on the same day
* → fire** a flag listing the contributing vitals. No black-box score — it
* is a transparent COUNT of deviations, mirroring Apple Vitals' ≥2-metric
* next-morning notification.
*
* Coverage gate: ≥ 2 vitals each with an established band. Below that the
* flag is `insufficient` (it cannot coincide). The minimum-inputs floor is
* the composite contract — it never emits a single composite number, and
* never labels a cause ("illness"); it lists the contributing vitals and
* frames them as "possible factors" only.
*
* Standard: the same MAD / personal-baseline basis as #1 (Hampel 1974;
* Leys et al. 2013). Multi-signal coincidence is descriptive, NOT a
* diagnosis. Frame: Apple Vitals / WHOOP Health Monitor / Fitbit Health
* Metrics / Oura Symptom Radar — all descriptive, none continuous.
*
* Server-only — fans the baseline engine across the supported vitals with
* one shared coverage probe (the pool-contention mitigation). The pure
* classifier is exported for tests.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
probeRollupCoverage,
type RollupCoverageMap,
} from "@/lib/rollups/measurement-coverage";
import {
buildInsufficient,
buildOk,
deriveCoverage,
nowProvenanceTimestamp,
} from "./coverage";
import { computeVitalsBaseline, type BaselineProfile } from "./baseline";
import { VITALS_BASELINE_TYPES } from "./registry";
import type { Derived, DerivedProvenanceSource } from "./types";
import { resolveRestMode } from "@/lib/illness/rest-mode";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const DEFAULT_WINDOW_DAYS = 30;
/**
* Row cap for the latest-day mean read — see `readiness.ts`. A dense intra-day
* day can hold hundreds of rows; the latest-day mean only needs a bounded
* sample of the most-recent rows (the dense-intraday retention reasoning). The
* common single-reading-per-day case is unaffected.
*/
const MAX_LATEST_DAY_ROWS = 50;
/** ≥ this many out-of-band vitals on a day fires the flag. */
export const COINCIDENT_FIRE_THRESHOLD = 2;
/** Need ≥ this many banded vitals before the flag can even coincide. */
export const COINCIDENT_MIN_BANDS = 2;
/** One vital's standing against its personal band today. */
export interface VitalDeviation {
type: MeasurementType;
/** Today's value. */
value: number;
/** Band center (median). */
center: number;
low: number;
high: number;
/** True when today's value falls outside [low, high]. */
outside: boolean;
/** "above" / "below" the band, or "in" when inside. */
direction: "above" | "below" | "in";
}
export interface CoincidentDeviationValue {
/** True when ≥ COINCIDENT_FIRE_THRESHOLD vitals are outside their band. */
fired: boolean;
/** All banded vitals checked today (the anatomy view lists them). */
vitals: VitalDeviation[];
/** Just the out-of-band vitals (the contributing factors). */
contributing: VitalDeviation[];
/** The day the flag was evaluated (YYYY-MM-DD). */
day: string;
/**
* v1.18.1 P4 — Rest Mode reframe. True when the flag fired AND an
* illness/condition episode is active: the deviations have a known
* explanation (the user is unwell), so the surface frames them as
* illness-explained — "your vitals are off because you're ill" — instead of
* presenting them as an unexplained anomaly. The vital numbers themselves
* are unchanged; only the framing differs. Resolved server-side; iOS
* mirrors it.
*/
illnessExplained: boolean;
}
// ── pure classifier (exported for tests) ───────────────────────────────
/** Classify one vital's latest value against its band. Pure. */
export function classifyDeviation(
type: MeasurementType,
value: number,
low: number,
high: number,
center: number,
): VitalDeviation {
const above = value > high;
const below = value < low;
return {
type,
value,
center,
low,
high,
outside: above || below,
direction: above ? "above" : below ? "below" : "in",
};
}
// ── compute ─────────────────────────────────────────────────────────────
export interface CoincidentDeviationOpts {
windowDays?: number;
now?: Date;
coverage?: RollupCoverageMap;
}
/**
* The most recent DAY mean for a type within the window, plus its day key.
* Bounded raw read; null when no reading in the window.
*/
async function readLatestDayMean(
userId: string,
type: MeasurementType,
windowDays: number,
now: Date,
): Promise<{ value: number; day: string } | null> {
const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
const rows = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null, measuredAt: { gte: since } },
orderBy: { measuredAt: "desc" },
take: MAX_LATEST_DAY_ROWS,
select: { value: true, measuredAt: true },
});
if (rows.length === 0) return null;
// Derive the most-recent day defensively (do not assume the DB ordering)
// so the "today" reading is always the genuine latest, then mean its rows.
let day = "";
for (const r of rows) {
const d = r.measuredAt.toISOString().slice(0, 10);
if (d > day) day = d;
}
const sameDay = rows.filter(
(r) => r.measuredAt.toISOString().slice(0, 10) === day,
);
return { value: sameDay.reduce((s, r) => s + r.value, 0) / sameDay.length, day };
}
/**
* Compute the coincident-deviation flag across the supported vitals. Below
* `COINCIDENT_MIN_BANDS` banded vitals it returns `insufficient`.
*/
export async function computeCoincidentDeviation(
userId: string,
profile: BaselineProfile,
opts: CoincidentDeviationOpts = {},
): Promise<Derived<CoincidentDeviationValue>> {
const windowDays = opts.windowDays ?? DEFAULT_WINDOW_DAYS;
const now = opts.now ?? new Date();
const computedAt = nowProvenanceTimestamp(now);
const coverage = opts.coverage ?? (await probeRollupCoverage(userId));
const vitals: VitalDeviation[] = [];
let latestDay = "";
let anyDaySource = false;
let maxHistoryDays = 0;
// Fan the baseline engine across the supported vitals. Each baseline read
// shares the one coverage probe (no per-vital re-probe).
for (const type of VITALS_BASELINE_TYPES) {
const baseline = await computeVitalsBaseline(userId, profile, {
type,
windowDays,
now,
coverage,
});
if (baseline.status !== "ok") continue;
if (baseline.provenance.source === "DAY") anyDaySource = true;
const latest = await readLatestDayMean(userId, type, windowDays, now);
if (!latest) continue;
if (latest.day > latestDay) latestDay = latest.day;
if (baseline.coverage.historyDays > maxHistoryDays) {
maxHistoryDays = baseline.coverage.historyDays;
}
vitals.push(
classifyDeviation(
type,
latest.value,
baseline.value.low,
baseline.value.high,
baseline.value.center,
),
);
}
const inputs = vitals.map((v) => String(v.type));
const source: DerivedProvenanceSource =
vitals.length === 0 ? "none" : anyDaySource ? "DAY" : "live";
if (vitals.length < COINCIDENT_MIN_BANDS) {
const { coverage: cov } = deriveCoverage({
requiredInputs: COINCIDENT_MIN_BANDS,
presentInputs: vitals.length,
historyDays: 0,
missing: [],
fullHistoryDays: windowDays,
});
return buildInsufficient<CoincidentDeviationValue>({
coverage: cov,
provenance: {
inputs: inputs.length > 0 ? inputs : VITALS_BASELINE_TYPES.map(String),
source,
windowDays,
computedAt,
},
reason: "too_few_banded_vitals",
});
}
const contributing = vitals.filter((v) => v.outside);
const fired = contributing.length >= COINCIDENT_FIRE_THRESHOLD;
// v1.18.1 P4 — when the flag fired, reframe it as illness-explained if an
// episode is active. Only resolved when the flag fired (no read on the
// common quiet day). Annotation only — the vital deviations are unchanged.
const illnessExplained = fired
? (await resolveRestMode(userId, now)).active
: false;
const { coverage: cov, confidence } = deriveCoverage({
// The coverage axis is "how many vitals could have coincided".
requiredInputs: vitals.length,
presentInputs: vitals.length,
// v1.10.0 QA: the REAL distinct-history-days backing the deepest
// contributing vital, not the constant `windowDays` (which pinned
// `historyFraction` to 1 so a 7-day and a 30-day blend reported the same
// confidence). The composite is at least as well-backed as its
// best-supported vital.
historyDays: maxHistoryDays,
missing: [],
fullHistoryDays: windowDays,
});
return buildOk<CoincidentDeviationValue>({
value: {
fired,
vitals,
contributing,
day: latestDay,
illnessExplained,
},
coverage: cov,
confidence,
provenance: { inputs, source, windowDays, computedAt },
});
}