mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
The recovery-gap engine reasoned only over passive vitals and ignored the journal's own per-day symptom series. Add the functional-impact (symptom-burden) return track: read functionalImpact per episode day (falling back to the strongest linked symptom severity) and treat it as one more return against the known-constant baseline (healthy = 0). The symptom track needs none of the MAD-band / contamination machinery the passive vitals carry — the band is the constant 0 — so it is more reliable to fold in than any passive vital. It is adverse by construction, so it contributes to adverseCoverageDays and the headline gap median, giving the qualifying-days floor a genuinely illness-relevant contributor where a weight-only episode had none. Stability is logged-days-only: a sparse journal that logs impact-0 once and stops withholds the return rather than fabricating an all-clear from the absence of further logs. gapDriverType names the dominant return track, with the symptom curve winning ties as the most illness-specific signal.
829 lines
32 KiB
TypeScript
829 lines
32 KiB
TypeScript
/**
|
||
* Illness correlation + recovery-gap engine (v1.18.1, Workstream B / P3).
|
||
*
|
||
* Server-authoritative, reliability-first. Given ONE illness episode this
|
||
* assembles three retrospective findings — never a prediction, never a
|
||
* diagnosis — each gated by the shared coverage/confidence model so a thin
|
||
* signal asserts NOTHING:
|
||
*
|
||
* 1. **Pre-onset anomaly scan** — the N days BEFORE onset, each tracked
|
||
* vital's deviation in robust SDs from the user's OWN baseline (median
|
||
* ± MAD, the same `computeVitalsBaseline` engine), the baseline computed
|
||
* from a window that ENDS before the pre-onset lookback so the episode's
|
||
* own physiology can never poison the baseline it is measured against.
|
||
* "How did it announce itself?"
|
||
* 2. **Nadir / what-dropped** — across the episode's active span, the
|
||
* furthest each vital strayed from baseline (the nadir day + magnitude +
|
||
* direction). "What dropped."
|
||
* 3. **Recovery-gap** — the intellectual core: physiological return-to-
|
||
* baseline (first day a previously-deviated vital re-enters its band and
|
||
* STAYS in for `RETURN_STABILITY_DAYS`) vs the felt-better marker
|
||
* (`resolvedAt`). The gap is `physiologicalReturn − feltBetter` in days:
|
||
* positive = the body lagged the feeling (you felt better before your
|
||
* numbers did), negative = numbers normalised first. CHRONIC_ONGOING is
|
||
* excluded (no recovered date).
|
||
*
|
||
* The directionality of "worse" is metric-specific (a HRV/recovery DROP and a
|
||
* resting-HR/temperature RISE both mean "stressed"); `ADVERSE_DIRECTION`
|
||
* pins it per metric. Red-flag patterns (a sustained large oxygen-saturation
|
||
* drop, a sustained fever) ESCALATE to a "seek care" flag — never reassure.
|
||
*
|
||
* Pure given its inputs: the caller does the bounded reads (baseline series +
|
||
* per-day vital means within the episode window) and passes them in, so the
|
||
* whole engine is unit-testable against golden fixtures with no DB. The route
|
||
* wires the reads; the parity test asserts the rollup-fed path and a raw-SQL
|
||
* path agree.
|
||
*/
|
||
import type { MeasurementType } from "@/generated/prisma/client";
|
||
import { buildBaselineBand, median } from "@/lib/insights/derived/baseline";
|
||
import {
|
||
buildInsufficient,
|
||
buildOk,
|
||
deriveCoverage,
|
||
nowProvenanceTimestamp,
|
||
} from "@/lib/insights/derived/coverage";
|
||
import type { Derived } from "@/lib/insights/derived/types";
|
||
|
||
/* ── tunables (documented invariants) ────────────────────────────────── */
|
||
|
||
/** Days before onset scanned for the "how did it announce itself" signal. */
|
||
export const PRE_ONSET_LOOKBACK_DAYS = 7;
|
||
/** Trailing baseline window (days), ending BEFORE the pre-onset lookback. */
|
||
export const BASELINE_WINDOW_DAYS = 30;
|
||
/**
|
||
* Minimum distinct baseline days a vital needs before we trust its band.
|
||
* Mirrors `computeVitalsBaseline`'s 7-day floor — below this the vital is
|
||
* dropped from the scan rather than asserted on a fragile band.
|
||
*/
|
||
export const MIN_BASELINE_DAYS = 7;
|
||
/**
|
||
* A deviation of ≥ this many robust SDs (k·MAD·scale units) from baseline
|
||
* counts as "notable" for the pre-onset + nadir surfaces. ~2σ-equivalent.
|
||
*/
|
||
export const NOTABLE_SD = 2;
|
||
/**
|
||
* Consecutive in-band days required to call a vital "returned to baseline"
|
||
* for the recovery-gap (one lucky in-band day is not a recovery).
|
||
*/
|
||
export const RETURN_STABILITY_DAYS = 3;
|
||
/**
|
||
* Sentinel `type` for the functional-impact return track. The symptom curve is
|
||
* NOT a `MeasurementType` — it is the user's own logged daily-impact slider —
|
||
* so it rides `returns[]` under a stable string key the surface keys copy off
|
||
* (the typed `VitalReturnFinding.type` is widened to accept it). Tallied like
|
||
* any vital type in `gapReturnTypes` so it can win `gapDriverType`.
|
||
*/
|
||
export const FUNCTIONAL_IMPACT_RETURN_KEY = "FUNCTIONAL_IMPACT" as const;
|
||
/**
|
||
* Functional-impact threshold (0–3 slider) at/above which a logged day counts
|
||
* as ADVERSE (symptomatic). The baseline is the known constant 0 (healthy), so
|
||
* the symptom curve needs none of the MAD-band / contamination machinery the
|
||
* passive vitals carry — "return" is simply impact back to below this floor.
|
||
*/
|
||
export const FUNCTIONAL_IMPACT_ADVERSE_FLOOR = 1;
|
||
/** Minimum episode days with ANY vital coverage before the gap is asserted. */
|
||
export const MIN_EPISODE_COVERAGE_DAYS = 4;
|
||
|
||
/**
|
||
* Per-metric adverse direction — which way a reading moves when the body is
|
||
* under illness stress. `up` = a RISE is adverse (resting HR, temperature,
|
||
* respiratory rate, BP); `down` = a DROP is adverse (HRV, recovery, SpO2).
|
||
* Weight/glucose have no single illness-adverse direction → omitted (their
|
||
* deviation magnitude is still surfaced, direction stays neutral).
|
||
*/
|
||
export const ADVERSE_DIRECTION: Partial<
|
||
Record<MeasurementType, "up" | "down">
|
||
> = {
|
||
RESTING_HEART_RATE: "up",
|
||
PULSE: "up",
|
||
BODY_TEMPERATURE: "up",
|
||
SKIN_TEMPERATURE: "up",
|
||
RESPIRATORY_RATE: "up",
|
||
BLOOD_PRESSURE_SYS: "up",
|
||
BLOOD_PRESSURE_DIA: "up",
|
||
HEART_RATE_VARIABILITY: "down",
|
||
RECOVERY_SCORE: "down",
|
||
OXYGEN_SATURATION: "down",
|
||
};
|
||
|
||
/**
|
||
* Vitals scanned by the illness engine. Superset of the typical-range set
|
||
* plus RECOVERY_SCORE (a canonical wellness signal). The caller only passes
|
||
* series for the vitals the user actually tracks; the rest are skipped.
|
||
*/
|
||
export const ILLNESS_SCAN_TYPES: MeasurementType[] = [
|
||
"RESTING_HEART_RATE",
|
||
"HEART_RATE_VARIABILITY",
|
||
"RECOVERY_SCORE",
|
||
"OXYGEN_SATURATION",
|
||
"BODY_TEMPERATURE",
|
||
"RESPIRATORY_RATE",
|
||
"BLOOD_PRESSURE_SYS",
|
||
"BLOOD_PRESSURE_DIA",
|
||
"PULSE",
|
||
"WEIGHT",
|
||
];
|
||
|
||
/* ── inputs the caller resolves and passes in (pure engine) ──────────── */
|
||
|
||
/** A per-day mean for one vital (YYYY-MM-DD local day → mean). */
|
||
export interface VitalDayPoint {
|
||
/** Local day key, `YYYY-MM-DD`. */
|
||
day: string;
|
||
/** Mean reading for the day. */
|
||
mean: number;
|
||
}
|
||
|
||
/**
|
||
* Everything the engine needs for ONE vital: the baseline-window series
|
||
* (ENDS before the pre-onset lookback — contamination guard lives in the
|
||
* caller's read window) and the episode-window series (pre-onset lookback
|
||
* through the felt-better marker, inclusive).
|
||
*/
|
||
export interface VitalSeries {
|
||
type: MeasurementType;
|
||
/** Per-day means over the clean baseline window (no episode-span days). */
|
||
baselineDays: VitalDayPoint[];
|
||
/** Per-day means from `onset − lookback` through `resolvedAt` (or today). */
|
||
episodeDays: VitalDayPoint[];
|
||
/**
|
||
* Per-day MAX readings over the episode window, used by the fever red-flag
|
||
* so an evening spike averaged toward normal in `episodeDays` (which carries
|
||
* means) is not masked. Optional — when omitted the flag falls back to the
|
||
* mean series. SpO2 stays mean/min-based (its `worst` is `min`).
|
||
*/
|
||
episodeDayMax?: VitalDayPoint[];
|
||
}
|
||
|
||
/**
|
||
* A user-logged fever reading from the illness day-log (`feverC`), keyed by
|
||
* the same local day key as the vital series. The fever red-flag unions this
|
||
* with any passive BODY_TEMPERATURE series so a user logging 39.2 °C for days
|
||
* with no HealthKit thermometer still escalates. Per-day MAX semantics.
|
||
*/
|
||
export interface DayLogFeverPoint {
|
||
/** Local day key, `YYYY-MM-DD`. */
|
||
day: string;
|
||
/** Logged fever in °C (the day's max when several were logged). */
|
||
feverC: number;
|
||
}
|
||
|
||
/**
|
||
* A user-logged symptom-burden reading from the illness day-log, keyed by the
|
||
* stored `date` (already the user-tz local day). `impact` is the day's
|
||
* `functionalImpact` slider (0–3, 0 = fully functional … 3 = bedbound); when
|
||
* NULL it is the day's max linked `IllnessSymptomLink.severity` (0–3) as a
|
||
* fallback corroborator, or null when neither was logged. Present days only —
|
||
* never zero-filled (mirrors the fever read), so sparse logging WITHHOLDS the
|
||
* symptom return rather than fabricating an all-clear from absence of rows.
|
||
*/
|
||
export interface SymptomBurdenPoint {
|
||
/** Local day key, `YYYY-MM-DD`. */
|
||
day: string;
|
||
/** The day's symptom burden (0–3): functionalImpact, else max severity. */
|
||
impact: number;
|
||
}
|
||
|
||
/** The dates the engine reasons over, all `YYYY-MM-DD` local day keys. */
|
||
export interface EpisodeWindow {
|
||
/** Onset day. */
|
||
onsetDay: string;
|
||
/** Felt-better marker (resolvedAt local day), or null when still active. */
|
||
feltBetterDay: string | null;
|
||
/** Lifecycle — CHRONIC_ONGOING suppresses the recovery-gap. */
|
||
lifecycle: string;
|
||
}
|
||
|
||
export interface IllnessCorrelationInput {
|
||
episodeId: string;
|
||
window: EpisodeWindow;
|
||
/** One entry per vital the user tracks (others omitted by the caller). */
|
||
series: VitalSeries[];
|
||
/**
|
||
* Per-day fever readings from the illness day-log (`feverC`). Unioned into
|
||
* the fever red-flag so the canonical journaling-fever path is visible to
|
||
* escalation even with no passive BODY_TEMPERATURE rows. Optional.
|
||
*/
|
||
dayLogFever?: DayLogFeverPoint[];
|
||
/**
|
||
* Per-day symptom-burden readings from the illness day-log (`functionalImpact`
|
||
* primary, max linked symptom `severity` as fallback). Folded into the
|
||
* recovery-gap as ONE more return track against the known-constant baseline
|
||
* (healthy = 0): a "symptom return" is the burden back below the adverse floor
|
||
* and held for `RETURN_STABILITY_DAYS` LOGGED days. Always `adverse:true` (it
|
||
* is illness-relevant by construction), so it contributes to
|
||
* `adverseCoverageDays` and can drive the headline gap even on a
|
||
* passive-vital-thin episode. Optional.
|
||
*/
|
||
symptomBurden?: SymptomBurdenPoint[];
|
||
/** Provenance source the dominant read resolved against. */
|
||
source: "DAY" | "live" | "none";
|
||
/** Compute time (injected for deterministic tests). */
|
||
now?: Date;
|
||
}
|
||
|
||
/* ── output value (the server-authoritative DTO payload) ─────────────── */
|
||
|
||
/** One vital's deviation finding on a given day. */
|
||
export interface VitalDeviationFinding {
|
||
type: MeasurementType;
|
||
/** The day this finding describes (`YYYY-MM-DD`). */
|
||
day: string;
|
||
/** The day's mean reading. */
|
||
value: number;
|
||
/** Robust baseline center the deviation is measured from. */
|
||
baselineCenter: number;
|
||
/** Signed deviation in robust-SD (k·MAD·scale) units; +above / −below. */
|
||
deviationSd: number;
|
||
/** Which way the reading moved. */
|
||
direction: "above" | "below";
|
||
/** True when this move is the illness-adverse direction for the metric. */
|
||
adverse: boolean;
|
||
}
|
||
|
||
/** A vital's recovery timing within the episode. */
|
||
export interface VitalReturnFinding {
|
||
/**
|
||
* The metric the return describes — a `MeasurementType` for a passive vital,
|
||
* or `FUNCTIONAL_IMPACT_RETURN_KEY` for the user-logged symptom-burden track.
|
||
*/
|
||
type: MeasurementType | typeof FUNCTIONAL_IMPACT_RETURN_KEY;
|
||
/** First day the vital re-entered its band AND stayed in for the window. */
|
||
returnedDay: string | null;
|
||
/** Days from felt-better to physiological return (signed); null if N/A. */
|
||
gapDays: number | null;
|
||
/** Whether this vital deviated in the illness-adverse direction during the
|
||
* active span — a neutral move (e.g. weight drift with no adverse signal)
|
||
* still produces a return but must not name the recovery driver. */
|
||
adverse: boolean;
|
||
}
|
||
|
||
/**
|
||
* Whether a banded reading is the illness-adverse move for its metric — a
|
||
* RISE for an "up"-adverse vital (resting HR, temperature, BP, …), a DROP for
|
||
* a "down"-adverse one (HRV, recovery, SpO2). The single adverse-direction
|
||
* predicate the engine reasons with; reused by the qualifying-days floor so a
|
||
* neutral-direction vital (WEIGHT, glucose) never counts a day toward it.
|
||
*/
|
||
export function isAdverseDeviation(
|
||
type: MeasurementType,
|
||
direction: "above" | "below",
|
||
): boolean {
|
||
const adverseDir = ADVERSE_DIRECTION[type];
|
||
return (
|
||
(adverseDir === "up" && direction === "above") ||
|
||
(adverseDir === "down" && direction === "below")
|
||
);
|
||
}
|
||
|
||
export interface IllnessCorrelationValue {
|
||
episodeId: string;
|
||
/** "How did it announce itself" — notable pre-onset deviations. */
|
||
preOnset: VitalDeviationFinding[];
|
||
/** "What dropped" — each vital's worst (nadir) deviation in the episode. */
|
||
nadir: VitalDeviationFinding[];
|
||
/** Per-vital physiological-return timing. */
|
||
returns: VitalReturnFinding[];
|
||
/**
|
||
* The headline recovery-gap in days (median of the per-vital gaps), or
|
||
* null when CHRONIC_ONGOING / still active / no vital returned. Positive
|
||
* = the body lagged the felt-better marker.
|
||
*/
|
||
recoveryGapDays: number | null;
|
||
/**
|
||
* Distinct episode days carrying at least one ADVERSE-direction banded
|
||
* reading (a vital moving the illness-adverse way for its metric). This is
|
||
* the qualifying-days floor the cross-episode aggregate gates the typical-
|
||
* gap median on — a WEIGHT-only (no adverse direction) episode contributes
|
||
* 0 here even when `coverage.historyDays` (any banded reading) is high, so
|
||
* an episode with no illness-relevant signal cannot tip the typical gap.
|
||
*/
|
||
adverseCoverageDays: number;
|
||
/** Felt-better marker echoed for the surface. */
|
||
feltBetterDay: string | null;
|
||
/**
|
||
* The metric whose physiological return dominates the headline gap — the
|
||
* adverse return-track whose own gap is closest to the median, with the
|
||
* functional-impact symptom track winning ties (it is the most
|
||
* illness-specific signal). A `MeasurementType`,
|
||
* `FUNCTIONAL_IMPACT_RETURN_KEY`, or null when no adverse return drove a gap.
|
||
* The card names it ("…before your symptoms eased" vs "…before your resting
|
||
* heart rate settled"). Retrospective only.
|
||
*/
|
||
gapDriverType: string | null;
|
||
/**
|
||
* Red-flag escalation — a sustained adverse SpO2 drop or sustained fever
|
||
* during the episode. Retrospective, but the copy must escalate ("if this
|
||
* recurs, seek care"), NEVER reassure. Empty when nothing tripped.
|
||
*/
|
||
redFlags: IllnessRedFlag[];
|
||
}
|
||
|
||
export interface IllnessRedFlag {
|
||
type: MeasurementType;
|
||
/** A short stable reason key the i18n layer renders (not free text). */
|
||
reason: "sustained_low_spo2" | "sustained_fever";
|
||
/** The worst observed value during the flagged run. */
|
||
worstValue: number;
|
||
/** Consecutive days the adverse threshold held. */
|
||
days: number;
|
||
}
|
||
|
||
/* ── red-flag thresholds (clinical floors, conservative) ─────────────── */
|
||
|
||
/** SpO2 at/below this for ≥ RED_FLAG_RUN_DAYS escalates. */
|
||
const SPO2_RED_FLAG = 92;
|
||
/** Body temperature at/above this (°C) for ≥ RED_FLAG_RUN_DAYS escalates. */
|
||
const FEVER_RED_FLAG = 38.5;
|
||
/** Consecutive days an adverse clinical threshold must hold to escalate. */
|
||
const RED_FLAG_RUN_DAYS = 3;
|
||
|
||
/* ── pure helpers ─────────────────────────────────────────────────────── */
|
||
|
||
/** Robust band for a vital, or null below the min-baseline-days floor. */
|
||
function bandFor(series: VitalSeries): {
|
||
center: number;
|
||
spread: number;
|
||
} | null {
|
||
if (series.baselineDays.length < MIN_BASELINE_DAYS) return null;
|
||
const band = buildBaselineBand(series.baselineDays.map((p) => p.mean));
|
||
if (!band || band.spread <= 0) return null;
|
||
return { center: band.center, spread: band.spread };
|
||
}
|
||
|
||
/** Signed deviation of a value in robust-SD units (spread = k·MAD·scale). */
|
||
function deviationSd(value: number, center: number, spread: number): number {
|
||
return (value - center) / spread;
|
||
}
|
||
|
||
/** Build a deviation finding for one day/value against a band. */
|
||
function finding(
|
||
type: MeasurementType,
|
||
day: string,
|
||
value: number,
|
||
center: number,
|
||
spread: number,
|
||
): VitalDeviationFinding {
|
||
const sd = deviationSd(value, center, spread);
|
||
const direction: "above" | "below" = sd >= 0 ? "above" : "below";
|
||
const adverse = isAdverseDeviation(type, direction);
|
||
return {
|
||
type,
|
||
day,
|
||
value,
|
||
baselineCenter: center,
|
||
deviationSd: Math.round(sd * 100) / 100,
|
||
direction,
|
||
adverse,
|
||
};
|
||
}
|
||
|
||
/** Order day keys chronologically. */
|
||
function byDay(a: { day: string }, b: { day: string }): number {
|
||
return a.day < b.day ? -1 : a.day > b.day ? 1 : 0;
|
||
}
|
||
|
||
/* ── the engine ───────────────────────────────────────────────────────── */
|
||
|
||
/**
|
||
* Compute the retrospective correlation findings for one episode. Pure given
|
||
* its inputs — the caller resolves the reads (rollup tier with a live-SQL
|
||
* fallback) and passes the series in. Returns a gated `Derived<T>`: below the
|
||
* coverage floor it returns `insufficient` (never a fabricated finding).
|
||
*/
|
||
export function computeIllnessCorrelation(
|
||
input: IllnessCorrelationInput,
|
||
): Derived<IllnessCorrelationValue> {
|
||
const now = input.now ?? new Date();
|
||
const computedAt = nowProvenanceTimestamp(now);
|
||
const { window } = input;
|
||
|
||
// Only vitals with a trustworthy own-baseline band participate.
|
||
const banded = input.series
|
||
.map((s) => ({ series: s, band: bandFor(s) }))
|
||
.filter(
|
||
(
|
||
b,
|
||
): b is {
|
||
series: VitalSeries;
|
||
band: { center: number; spread: number };
|
||
} => b.band !== null,
|
||
);
|
||
|
||
const inputs = banded.map((b) => String(b.series.type));
|
||
const provenance = {
|
||
inputs,
|
||
source: input.source,
|
||
windowDays: BASELINE_WINDOW_DAYS,
|
||
computedAt,
|
||
};
|
||
|
||
// Coverage: how many distinct episode days carry ANY banded vital reading.
|
||
const episodeDayKeys = new Set<string>();
|
||
for (const b of banded) {
|
||
for (const p of b.series.episodeDays) {
|
||
if (p.day >= window.onsetDay) episodeDayKeys.add(p.day);
|
||
}
|
||
}
|
||
const coverageDays = episodeDayKeys.size;
|
||
|
||
if (banded.length === 0 || coverageDays < MIN_EPISODE_COVERAGE_DAYS) {
|
||
const { coverage } = deriveCoverage({
|
||
requiredInputs: 1,
|
||
presentInputs: banded.length,
|
||
historyDays: coverageDays,
|
||
missing: banded.length === 0 ? ILLNESS_SCAN_TYPES.map(String) : [],
|
||
fullHistoryDays: MIN_EPISODE_COVERAGE_DAYS,
|
||
});
|
||
return buildInsufficient<IllnessCorrelationValue>({
|
||
coverage,
|
||
provenance,
|
||
reason:
|
||
banded.length === 0
|
||
? "no_baselined_vitals"
|
||
: "insufficient_episode_coverage",
|
||
});
|
||
}
|
||
|
||
const preOnset: VitalDeviationFinding[] = [];
|
||
const nadir: VitalDeviationFinding[] = [];
|
||
const returns: VitalReturnFinding[] = [];
|
||
// Distinct active-span days with at least one notable ADVERSE-direction
|
||
// banded reading. This — not raw `coverageDays` (any banded reading) — is
|
||
// the qualifying-days floor the cross-episode aggregate gates on, so a
|
||
// neutral-direction-only episode (e.g. WEIGHT) never feeds the typical gap.
|
||
const adverseDayKeys = new Set<string>();
|
||
|
||
for (const { series, band } of banded) {
|
||
const { type } = series;
|
||
const { center, spread } = band;
|
||
const episode = [...series.episodeDays].sort(byDay);
|
||
|
||
// 1) Pre-onset scan: lookback window strictly before onset.
|
||
const pre = episode.filter((p) => p.day < window.onsetDay);
|
||
let worstPre: VitalDeviationFinding | null = null;
|
||
for (const p of pre) {
|
||
const f = finding(type, p.day, p.mean, center, spread);
|
||
if (
|
||
Math.abs(f.deviationSd) >= NOTABLE_SD &&
|
||
(worstPre === null ||
|
||
Math.abs(f.deviationSd) > Math.abs(worstPre.deviationSd))
|
||
) {
|
||
worstPre = f;
|
||
}
|
||
}
|
||
if (worstPre) preOnset.push(worstPre);
|
||
|
||
// 2) Nadir: worst deviation across the active span (onset → end).
|
||
const active = episode.filter((p) => p.day >= window.onsetDay);
|
||
let worstActive: VitalDeviationFinding | null = null;
|
||
// The chronological index of the FIRST notably-deviated active day — the
|
||
// anchor the recovery search must start AFTER (an early in-band run before
|
||
// the vital ever deviated is not a "return", it is the run-up).
|
||
let firstDeviationIndex = -1;
|
||
// Whether THIS vital ever deviated adversely in the active span — gates it
|
||
// out of the recovery-driver tally even if it shows a return.
|
||
let vitalDeviatedAdversely = false;
|
||
active.forEach((p, i) => {
|
||
const f = finding(type, p.day, p.mean, center, spread);
|
||
if (Math.abs(f.deviationSd) >= NOTABLE_SD) {
|
||
if (firstDeviationIndex === -1) firstDeviationIndex = i;
|
||
if (f.adverse) {
|
||
adverseDayKeys.add(p.day);
|
||
vitalDeviatedAdversely = true;
|
||
}
|
||
if (
|
||
worstActive === null ||
|
||
Math.abs(f.deviationSd) > Math.abs(worstActive.deviationSd)
|
||
) {
|
||
worstActive = f;
|
||
}
|
||
}
|
||
});
|
||
if (worstActive) nadir.push(worstActive);
|
||
|
||
// 3) Physiological return: only meaningful once the vital has actually
|
||
// deviated. Anchor the search AFTER the first notable deviation so an
|
||
// early in-band run can never be reported as a return BEFORE the
|
||
// deviation (which yielded a spurious negative gap). Excluded for
|
||
// CHRONIC_ONGOING (no recovered date by design).
|
||
if (firstDeviationIndex >= 0 && window.lifecycle !== "CHRONIC_ONGOING") {
|
||
const returnedDay = firstStableReturn(
|
||
active,
|
||
center,
|
||
spread,
|
||
firstDeviationIndex,
|
||
);
|
||
const gapDays =
|
||
returnedDay && window.feltBetterDay
|
||
? dayDiff(window.feltBetterDay, returnedDay)
|
||
: null;
|
||
returns.push({
|
||
type,
|
||
returnedDay,
|
||
gapDays,
|
||
adverse: vitalDeviatedAdversely,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Symptom-burden return track — the user's OWN logged daily-impact curve,
|
||
// folded in as one more return against the KNOWN-CONSTANT baseline (healthy
|
||
// = 0). No MAD band, no contamination guard, no min-baseline floor: the band
|
||
// IS the constant 0, so it sidesteps every reliability caveat the passive
|
||
// vitals carry. Always adverse by construction (a logged impact is illness-
|
||
// relevant), so it contributes to `adverseCoverageDays` and the headline gap
|
||
// even when the passive vitals are thin — the genuine illness-relevant
|
||
// contributor the WEIGHT-only floor lacked. Logged-days-only stability:
|
||
// sparse logging WITHHOLDS the return rather than fabricating an all-clear.
|
||
const symptomReturn = computeSymptomReturn(input.symptomBurden ?? [], window);
|
||
if (symptomReturn) {
|
||
returns.push(symptomReturn.finding);
|
||
for (const day of symptomReturn.adverseDays) adverseDayKeys.add(day);
|
||
}
|
||
|
||
// Red-flag escalation runs DECOUPLED from the banded loop: it scans the raw
|
||
// episode days for SpO2 + temperature against ABSOLUTE clinical floors, so a
|
||
// rock-steady (MAD=0) vital that drops the band filter still escalates — the
|
||
// safety-critical output must fire for exactly the low-variance population it
|
||
// matters for. The fever path additionally unions the day-log `feverC`
|
||
// series so a user journaling fever with no thermometer is still seen.
|
||
const redFlags = detectRedFlags(input);
|
||
|
||
// Headline gap = median of the per-vital signed gaps (robust to one vital
|
||
// recovering oddly). Null for CHRONIC_ONGOING / still active / no returns.
|
||
const gaps = returns
|
||
.map((r) => r.gapDays)
|
||
.filter((g): g is number => g !== null);
|
||
const recoveryGapDays =
|
||
window.lifecycle === "CHRONIC_ONGOING" || gaps.length === 0
|
||
? null
|
||
: Math.round(median(gaps));
|
||
|
||
// Name the per-episode driver: among the ADVERSE return tracks that produced
|
||
// a real gap, the one whose own gap sits closest to the headline median (it
|
||
// is the return that "explains" the headline). The functional-impact symptom
|
||
// track wins ties — it is the most illness-specific signal the engine has, so
|
||
// when it co-explains the gap it gets to name it ("…before your symptoms
|
||
// eased"). Null when no adverse return drove a gap.
|
||
const gapDriverType =
|
||
recoveryGapDays === null
|
||
? null
|
||
: (returns
|
||
.filter(
|
||
(r): r is VitalReturnFinding & { gapDays: number } =>
|
||
r.gapDays !== null && r.adverse,
|
||
)
|
||
.sort((a, b) => {
|
||
const da = Math.abs(a.gapDays - recoveryGapDays);
|
||
const db = Math.abs(b.gapDays - recoveryGapDays);
|
||
if (da !== db) return da - db;
|
||
// Tie: prefer the functional-impact symptom track, then by name.
|
||
const fa = a.type === FUNCTIONAL_IMPACT_RETURN_KEY ? 0 : 1;
|
||
const fb = b.type === FUNCTIONAL_IMPACT_RETURN_KEY ? 0 : 1;
|
||
if (fa !== fb) return fa - fb;
|
||
return String(a.type) < String(b.type) ? -1 : 1;
|
||
})[0]?.type ?? null);
|
||
|
||
const { coverage, confidence } = deriveCoverage({
|
||
requiredInputs: 1,
|
||
presentInputs: banded.length,
|
||
historyDays: coverageDays,
|
||
missing: [],
|
||
fullHistoryDays: Math.max(MIN_EPISODE_COVERAGE_DAYS, 14),
|
||
});
|
||
|
||
return buildOk<IllnessCorrelationValue>({
|
||
value: {
|
||
episodeId: input.episodeId,
|
||
preOnset: preOnset.sort(
|
||
(a, b) => Math.abs(b.deviationSd) - Math.abs(a.deviationSd),
|
||
),
|
||
nadir: nadir.sort(
|
||
(a, b) => Math.abs(b.deviationSd) - Math.abs(a.deviationSd),
|
||
),
|
||
returns,
|
||
recoveryGapDays,
|
||
adverseCoverageDays: adverseDayKeys.size,
|
||
feltBetterDay: window.feltBetterDay,
|
||
gapDriverType: gapDriverType === null ? null : String(gapDriverType),
|
||
redFlags,
|
||
},
|
||
coverage,
|
||
confidence,
|
||
provenance,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* First day in `active` (chronological), starting at `fromIndex`, where the
|
||
* vital is in-band AND stays in-band for the next `RETURN_STABILITY_DAYS`
|
||
* observed days. Null when it never settles. "In-band" = |deviation| <
|
||
* NOTABLE_SD. `fromIndex` pins the search to AFTER the first deviation so an
|
||
* early in-band run (the run-up to the illness) is never mistaken for a return.
|
||
*/
|
||
function firstStableReturn(
|
||
active: VitalDayPoint[],
|
||
center: number,
|
||
spread: number,
|
||
fromIndex: number,
|
||
): string | null {
|
||
const inBand = active.map((p) => ({
|
||
day: p.day,
|
||
in: Math.abs(deviationSd(p.mean, center, spread)) < NOTABLE_SD,
|
||
}));
|
||
for (let i = Math.max(0, fromIndex); i < inBand.length; i++) {
|
||
if (!inBand[i].in) continue;
|
||
let run = 0;
|
||
for (let j = i; j < inBand.length && inBand[j].in; j++) run++;
|
||
if (run >= RETURN_STABILITY_DAYS) return inBand[i].day;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Compute the functional-impact (symptom-burden) return track for one episode.
|
||
*
|
||
* The symptom curve's baseline is the KNOWN CONSTANT 0 (healthy), so this needs
|
||
* none of the passive vitals' MAD-band / contamination machinery — "adverse" is
|
||
* simply `impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR`, "in band" is below it.
|
||
*
|
||
* - Only LOGGED days (present in `burden`) participate — never zero-filled.
|
||
* - The anchor is the first logged adverse day (same role as the vital
|
||
* `firstDeviationIndex`): a return search starts AFTER it, so a run-up is
|
||
* never read as a return.
|
||
* - A "return" is the first logged day at/after the anchor that is in-band AND
|
||
* stays in-band for the next `RETURN_STABILITY_DAYS` LOGGED days. LOGGED-days
|
||
* stability (not calendar days) is the honest-withholding handler for sparse
|
||
* journals: a single trailing impact-0 log never satisfies the run, so the
|
||
* track WITHHOLDS rather than fabricating recovery from absence of rows.
|
||
* - The gap is `dayDiff(feltBetterDay, returnedDay)`, same signed semantic.
|
||
* - Always `adverse:true` (a logged symptom curve is illness-relevant), so it
|
||
* feeds `adverseCoverageDays` and can drive the headline gap.
|
||
*
|
||
* Returns null (contributes nothing) when no adverse day was logged or the
|
||
* episode is CHRONIC_ONGOING. `adverseDays` is the set of logged adverse days in
|
||
* the active span, for the coverage tally.
|
||
*/
|
||
function computeSymptomReturn(
|
||
burden: SymptomBurdenPoint[],
|
||
window: EpisodeWindow,
|
||
): { finding: VitalReturnFinding; adverseDays: string[] } | null {
|
||
if (window.lifecycle === "CHRONIC_ONGOING") return null;
|
||
// Active span only, chronological. (Logged days are sparse and arbitrary, so
|
||
// a stable sort by the day key is the canonical order.)
|
||
const active = burden
|
||
.filter((p) => p.day >= window.onsetDay)
|
||
.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
|
||
if (active.length === 0) return null;
|
||
|
||
const adverseDays = active
|
||
.filter((p) => p.impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR)
|
||
.map((p) => p.day);
|
||
// No adverse day logged → there is nothing to "return" from.
|
||
if (adverseDays.length === 0) return null;
|
||
|
||
// Anchor: first logged adverse day. The return search starts there so an
|
||
// early in-band log (impact 0 before symptoms appeared) is never a return.
|
||
const firstAdverseIndex = active.findIndex(
|
||
(p) => p.impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR,
|
||
);
|
||
let returnedDay: string | null = null;
|
||
for (let i = firstAdverseIndex; i < active.length; i++) {
|
||
if (active[i].impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR) continue;
|
||
// Count consecutive in-band LOGGED days from here.
|
||
let run = 0;
|
||
for (
|
||
let j = i;
|
||
j < active.length && active[j].impact < FUNCTIONAL_IMPACT_ADVERSE_FLOOR;
|
||
j++
|
||
) {
|
||
run++;
|
||
}
|
||
if (run >= RETURN_STABILITY_DAYS) {
|
||
returnedDay = active[i].day;
|
||
break;
|
||
}
|
||
}
|
||
|
||
const gapDays =
|
||
returnedDay && window.feltBetterDay
|
||
? dayDiff(window.feltBetterDay, returnedDay)
|
||
: null;
|
||
|
||
return {
|
||
finding: {
|
||
type: FUNCTIONAL_IMPACT_RETURN_KEY,
|
||
returnedDay,
|
||
gapDays,
|
||
adverse: true,
|
||
},
|
||
adverseDays,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Detect the retrospective red flags — DECOUPLED from the banded loop and the
|
||
* own-baseline band entirely. SpO2 and temperature escalate against ABSOLUTE
|
||
* clinical floors over the RAW episode days (active span), so a rock-steady
|
||
* (MAD=0) vital whose band was dropped still escalates. The fever path unions
|
||
* the passive BODY_TEMPERATURE series (per-day max when available) with the
|
||
* day-log `feverC` series so the canonical journaling-fever path is visible.
|
||
* Conservative; escalation copy lives in the i18n layer.
|
||
*/
|
||
function detectRedFlags(input: IllnessCorrelationInput): IllnessRedFlag[] {
|
||
const { window } = input;
|
||
const flags: IllnessRedFlag[] = [];
|
||
const inActive = (p: { day: string }) => p.day >= window.onsetDay;
|
||
|
||
// SpO2 — sustained low. Mean series is the floor; use per-day-max only when
|
||
// it would HELP (SpO2's worst is the min, so means are conservative enough).
|
||
const spo2 = input.series.find((s) => s.type === "OXYGEN_SATURATION");
|
||
if (spo2) {
|
||
const flag = runFlag(
|
||
spo2.episodeDays.filter(inActive),
|
||
(v) => v <= SPO2_RED_FLAG,
|
||
"sustained_low_spo2",
|
||
"OXYGEN_SATURATION",
|
||
"min",
|
||
);
|
||
if (flag) flags.push(flag);
|
||
}
|
||
|
||
// Fever — union the passive temperature series (per-day MAX preferred so an
|
||
// evening spike is not masked by a daily mean) with the day-log feverC.
|
||
const temp = input.series.find((s) => s.type === "BODY_TEMPERATURE");
|
||
const tempPoints = temp
|
||
? (temp.episodeDayMax ?? temp.episodeDays).filter(inActive)
|
||
: [];
|
||
const feverByDay = new Map<string, number>();
|
||
for (const p of tempPoints) {
|
||
feverByDay.set(p.day, Math.max(feverByDay.get(p.day) ?? -Infinity, p.mean));
|
||
}
|
||
for (const p of input.dayLogFever ?? []) {
|
||
if (!inActive(p)) continue;
|
||
feverByDay.set(
|
||
p.day,
|
||
Math.max(feverByDay.get(p.day) ?? -Infinity, p.feverC),
|
||
);
|
||
}
|
||
if (feverByDay.size > 0) {
|
||
const fevPoints: VitalDayPoint[] = [...feverByDay.entries()].map(
|
||
([day, mean]) => ({ day, mean }),
|
||
);
|
||
const flag = runFlag(
|
||
fevPoints,
|
||
(v) => v >= FEVER_RED_FLAG,
|
||
"sustained_fever",
|
||
"BODY_TEMPERATURE",
|
||
"max",
|
||
);
|
||
if (flag) flags.push(flag);
|
||
}
|
||
|
||
return flags;
|
||
}
|
||
|
||
/** Longest consecutive run matching `predicate`; flag when ≥ RED_FLAG_RUN_DAYS. */
|
||
function runFlag(
|
||
sorted: VitalDayPoint[],
|
||
predicate: (v: number) => boolean,
|
||
reason: IllnessRedFlag["reason"],
|
||
type: MeasurementType,
|
||
worst: "min" | "max",
|
||
): IllnessRedFlag | null {
|
||
let bestRun = 0;
|
||
let bestWorst: number | null = null;
|
||
let run = 0;
|
||
let runWorst: number | null = null;
|
||
for (const p of sorted) {
|
||
if (predicate(p.mean)) {
|
||
run++;
|
||
runWorst =
|
||
runWorst === null
|
||
? p.mean
|
||
: worst === "min"
|
||
? Math.min(runWorst, p.mean)
|
||
: Math.max(runWorst, p.mean);
|
||
if (run > bestRun) {
|
||
bestRun = run;
|
||
bestWorst = runWorst;
|
||
}
|
||
} else {
|
||
run = 0;
|
||
runWorst = null;
|
||
}
|
||
}
|
||
if (bestRun >= RED_FLAG_RUN_DAYS && bestWorst !== null) {
|
||
return { type, reason, worstValue: bestWorst, days: bestRun };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Whole-day signed difference `to − from` for two `YYYY-MM-DD` keys. */
|
||
export function dayDiff(from: string, to: string): number {
|
||
const a = Date.parse(`${from}T00:00:00Z`);
|
||
const b = Date.parse(`${to}T00:00:00Z`);
|
||
return Math.round((b - a) / (24 * 60 * 60 * 1000));
|
||
}
|