mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Three defects on one card of the step sub-page, all of them a surface answering a question that belongs to the metric. "Your usual range is -5,595.6-13,387.6 steps", under a chart whose own y-axis refuses to draw a negative step. Same app, same question, two answers: the axis clamped and the sentence beneath it did not. Both now read the same fact. `VALUE_RANGES` already declares every metric's plausibility floor for ingest; `value-domain.ts` reads it and answers "can this metric be negative" for the padded y-domain and for the median +/- k.MAD band alike. The band's lower edge floors at zero for a non-negative metric; `spread` stays the unclamped dispersion so a deviation-in-sigma calculation is unaffected. `buildBaselineBand` now takes the metric it is describing as a required argument, so a new caller cannot quietly reintroduce a negative edge. Two types carried no plausibility range at all, which let any number through ingest and read as "unknown" on the negativity question: walking steadiness gets the same 0-100 percent band as its two gait siblings, the loud-listening event a floor of 1 with a generous ceiling. "13,387.6 steps" also said steps to a reader of German. Five sub-page units are English words, not symbols - steps, flights, falls, years, and a "count" that was a poor label in any language. They now travel as i18n keys through a new `unitKey` on the metric-page scaffold and resolve in all six locales. The thirteen real symbols (bpm, mmHg, %, m/s, kg/m2, ...) stay literals; translating a symbol would be the opposite mistake. And the Min read "104.0 steps". Precision was a per-page prop defaulting to one decimal, so every page had to remember that its metric is a count - and the step page did not. It follows the metric now: a discrete metric (tallies, category events, closed integer instrument scores, ordinal levels) resolves zero decimals wherever it is rendered, and a page only passes the prop to override that.
1057 lines
43 KiB
TypeScript
1057 lines
43 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 { FEVER_RED_FLAG_C, SPO2_RED_FLAG_PCT } from "@/lib/clinical-floors";
|
||
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;
|
||
/**
|
||
* Minimum scorable baseline nights before the sleep-context observation is
|
||
* trusted (mirrors the sleep-debt engine's `minNights` floor). Below this the
|
||
* "usual" asleep figure is too fragile to compare against.
|
||
*/
|
||
export const SLEEP_CONTEXT_MIN_BASELINE_NIGHTS = 4;
|
||
/** Minimum scorable episode nights before the sleep-context line surfaces. */
|
||
export const SLEEP_CONTEXT_MIN_EPISODE_NIGHTS = 3;
|
||
/**
|
||
* Minimum |episode − baseline| asleep-minutes delta before the sleep-context
|
||
* line is shown. Day-to-day jitter below this floor is not narrated as a
|
||
* finding (honest withholding, never a fabricated observation).
|
||
*/
|
||
export const SLEEP_CONTEXT_MIN_DELTA_MINUTES = 30;
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
/**
|
||
* One reconstructed night's asleep total, keyed by the local WAKE-DAY (the same
|
||
* `YYYY-MM-DD` day-space the engine's onset/feltBetter markers live in — a
|
||
* canonical `NightSummary.night`). The caller reconstructs nights from the raw
|
||
* per-stage SLEEP_DURATION rows (writer-deduped, session-clustered) and passes
|
||
* the asleep totals in; the engine never touches raw rows. Used ONLY for the
|
||
* sleep-context observation, NEVER as a recovery-gap return track.
|
||
*/
|
||
export interface SleepNightPoint {
|
||
/** Local wake-day key, `YYYY-MM-DD`. */
|
||
day: string;
|
||
/** Asleep minutes for the night (canonical, multi-source-deduped). */
|
||
asleepMinutes: 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[];
|
||
/**
|
||
* Reconstructed per-night asleep totals over the user's WELL baseline window
|
||
* (the span ending before the pre-onset lookback). Their median is the user's
|
||
* usual asleep figure the episode is compared against. Optional — absent /
|
||
* thin → no sleep-context observation. NEVER feeds the recovery-gap.
|
||
*/
|
||
baselineNights?: SleepNightPoint[];
|
||
/**
|
||
* Reconstructed per-night asleep totals over the episode window (`onset −
|
||
* lookback` through end). Compared against the baseline median as a neutral
|
||
* CONTEXT observation (slept more / less than usual), never a return track and
|
||
* never in the gap median. Optional — absent / thin → no observation.
|
||
*/
|
||
episodeNights?: SleepNightPoint[];
|
||
/** 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")
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A neutral sleep-context observation for the episode — the user's median
|
||
* asleep minutes over the well baseline vs the mean over the episode window. A
|
||
* pure OBSERVATION ("you slept more / less than usual"), never a recovery
|
||
* return, never in the gap median, never a causal/recovery claim. Null when the
|
||
* sleep data is too thin (below the night-count floors) or the delta is below
|
||
* the magnitude floor — honest withholding, never a fabricated finding.
|
||
*/
|
||
export interface SleepContext {
|
||
/** Median asleep minutes over the well baseline window. */
|
||
baselineMeanMinutes: number;
|
||
/** Mean asleep minutes over the episode window. */
|
||
episodeMeanMinutes: number;
|
||
/** `episode − baseline` asleep minutes (positive = slept more than usual). */
|
||
deltaMinutes: number;
|
||
/** Scorable episode nights the observation is built from (transparency). */
|
||
nightsCounted: number;
|
||
}
|
||
|
||
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[];
|
||
/**
|
||
* Sleep-as-context observation — slept more / less than usual during the
|
||
* episode — or null when the sleep data is too thin or the delta sub-floor.
|
||
* A neutral observation surfaced ALONGSIDE the gap, NEVER a return track and
|
||
* NEVER part of the headline recovery-gap number. Optional/additive.
|
||
*/
|
||
sleepContext: SleepContext | null;
|
||
}
|
||
|
||
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) ─────────────── */
|
||
|
||
// D3-H1: sustained-fever + sustained-low-SpO2 escalation floors come from the
|
||
// one source of truth so the illness engine, the status registry's fever band,
|
||
// and the hero/Coach never carry divergent magic numbers.
|
||
/** SpO2 at/below this for ≥ RED_FLAG_RUN_DAYS escalates. */
|
||
const SPO2_RED_FLAG = SPO2_RED_FLAG_PCT;
|
||
/** Body temperature at/above this (°C) for ≥ RED_FLAG_RUN_DAYS escalates. */
|
||
const FEVER_RED_FLAG = FEVER_RED_FLAG_C;
|
||
/**
|
||
* Consecutive days an adverse clinical threshold must hold to escalate.
|
||
* Exported so the on-write safety seam (`safety-floor-check.ts`) escalates a
|
||
* sustained fever / low-SpO2 run on ingest using the SAME run length as this
|
||
* retrospective detector — the two paths can never disagree on what "sustained"
|
||
* means.
|
||
*/
|
||
export 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),
|
||
series.type,
|
||
);
|
||
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 = lastStableReturn(
|
||
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),
|
||
});
|
||
|
||
// Sleep-as-context — a neutral observation surfaced alongside the gap, NEVER a
|
||
// return track and NEVER in the recovery-gap median. Withholds (null) on thin
|
||
// data or a sub-floor delta.
|
||
const sleepContext = computeSleepContext(
|
||
input.baselineNights ?? [],
|
||
input.episodeNights ?? [],
|
||
window,
|
||
);
|
||
|
||
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,
|
||
sleepContext,
|
||
},
|
||
coverage,
|
||
confidence,
|
||
provenance,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Start of the FINAL sustained in-band run in `active` (chronological) at/after
|
||
* `fromIndex` — the day the vital *finally* settled and held. "In-band" =
|
||
* |deviation| < NOTABLE_SD. Null when it never settles, when the final settle
|
||
* is shorter than `RETURN_STABILITY_DAYS` observed days, or when a GENUINE
|
||
* sustained re-deviation (≥ `RETURN_STABILITY_DAYS` out-of-band days) follows
|
||
* the last settle with no re-settle (the vital is still elevated at the end).
|
||
* `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.
|
||
*
|
||
* Relapse-aware AND noise-tolerant: an episode that settles, re-deviates (a
|
||
* mid-episode flare), and settles again reports the LAST settle. A LONE
|
||
* trailing out-of-band reading (or any tail shorter than the stability window)
|
||
* after a real settle is transient measurement noise, NOT a relapse — it does
|
||
* not null the return. Only a sustained out-of-band tail (≥ the stability
|
||
* window) is read as a genuine unrecovered re-deviation and clears the return.
|
||
* This keeps the change monotone for clean single-settle series (first == last,
|
||
* unaffected) while not regressing a settle that happens to end on one noisy
|
||
* out-of-band sample.
|
||
*/
|
||
function lastStableReturn(
|
||
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,
|
||
}));
|
||
if (inBand.length === 0) return null;
|
||
|
||
// A sustained out-of-band TAIL (≥ stability window) means the vital genuinely
|
||
// re-deviated after the last settle and never came back — still elevated, no
|
||
// final return. A shorter trailing out-of-band tail is transient noise and is
|
||
// ignored so a real settle that ends on a noisy sample is not lost.
|
||
let tailOut = 0;
|
||
while (tailOut < inBand.length && !inBand[inBand.length - 1 - tailOut].in) {
|
||
tailOut++;
|
||
}
|
||
if (tailOut >= RETURN_STABILITY_DAYS) return null;
|
||
|
||
// Walk back from the last IN-band reading to the start of its maximal run.
|
||
// The run is CALENDAR-consecutive: extend only while the prior reading is both
|
||
// in-band AND the calendar day immediately before this one. Points are
|
||
// present-days-only (never zero-filled), so a sparse in-band series (e.g. days
|
||
// 01/08/15) must NOT register as one long settle — that mirrors the `runFlag`
|
||
// calendar-adjacency fix and bounds the run's span to its day count.
|
||
const end = inBand.length - 1 - tailOut;
|
||
let start = end;
|
||
while (
|
||
start - 1 >= 0 &&
|
||
inBand[start - 1].in &&
|
||
dayDiff(inBand[start - 1].day, inBand[start].day) === 1
|
||
)
|
||
start--;
|
||
const run = end - start + 1;
|
||
// The final settle must be long enough AND begin at/after the first deviation.
|
||
if (run < RETURN_STABILITY_DAYS || start < Math.max(0, fromIndex))
|
||
return null;
|
||
return inBand[start].day;
|
||
}
|
||
|
||
/**
|
||
* 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 START of the FINAL sustained in-band logged run at/after
|
||
* the anchor — the day symptoms *finally* eased and stayed eased, requiring
|
||
* `RETURN_STABILITY_DAYS` LOGGED days. Relapse-aware AND noise-tolerant
|
||
* (parity with the vital `lastStableReturn`): if symptoms ease, flare again,
|
||
* then ease again, the SECOND easing is the return; a trailing adverse tail
|
||
* SHORTER than the stability window is a single noisy late log and is
|
||
* tolerated, while a sustained adverse tail (≥ the window) is a genuine
|
||
* re-flare with no re-settle and WITHHOLDS the return. LOGGED-days stability
|
||
* (not calendar days) is the honest-withholding handler for sparse journals:
|
||
* a single trailing impact-0 log never satisfies the run on its own.
|
||
* - 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,
|
||
);
|
||
// Start of the FINAL sustained in-band logged run (relapse-aware "last
|
||
// return"): walk back from the last logged day. Mirrors the vital
|
||
// `lastStableReturn` — a trailing adverse tail SHORTER than the stability
|
||
// window is tolerated (a single noisy late log does not null a real ease),
|
||
// while a sustained adverse tail (≥ the window) is a genuine re-flare with no
|
||
// re-settle → null (honest withholding).
|
||
const inBand = active.map((p) => p.impact < FUNCTIONAL_IMPACT_ADVERSE_FLOOR);
|
||
let returnedDay: string | null = null;
|
||
if (inBand.length > 0) {
|
||
let tailOut = 0;
|
||
while (tailOut < inBand.length && !inBand[inBand.length - 1 - tailOut]) {
|
||
tailOut++;
|
||
}
|
||
if (tailOut < RETURN_STABILITY_DAYS) {
|
||
const end = inBand.length - 1 - tailOut;
|
||
let start = end;
|
||
// CALENDAR-consecutive run (parity with `lastStableReturn` / `runFlag`):
|
||
// extend only while the prior LOGGED day is in-band AND the calendar day
|
||
// immediately before this one. Sparse impact-0 logs (e.g. days 1/8/15)
|
||
// must NOT stamp a "stable symptom return" across a multi-day span and
|
||
// move the headline gap — the run's calendar span is bounded to its
|
||
// logged-day count.
|
||
while (
|
||
start - 1 >= 0 &&
|
||
inBand[start - 1] &&
|
||
dayDiff(active[start - 1].day, active[start].day) === 1
|
||
)
|
||
start--;
|
||
const run = end - start + 1;
|
||
if (run >= RETURN_STABILITY_DAYS && start >= firstAdverseIndex) {
|
||
returnedDay = active[start].day;
|
||
}
|
||
}
|
||
}
|
||
|
||
const gapDays =
|
||
returnedDay && window.feltBetterDay
|
||
? dayDiff(window.feltBetterDay, returnedDay)
|
||
: null;
|
||
|
||
return {
|
||
finding: {
|
||
type: FUNCTIONAL_IMPACT_RETURN_KEY,
|
||
returnedDay,
|
||
gapDays,
|
||
adverse: true,
|
||
},
|
||
adverseDays,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Compute the sleep-as-context observation: the user's MEDIAN asleep minutes
|
||
* over the well baseline vs the MEAN over the episode's active span. A neutral
|
||
* OBSERVATION only — never a recovery return, never folded into the gap median,
|
||
* never a causal claim. Mirrors the engine's withholding ethos:
|
||
*
|
||
* - Baseline needs ≥ `SLEEP_CONTEXT_MIN_BASELINE_NIGHTS` scorable nights AND
|
||
* the episode needs ≥ `SLEEP_CONTEXT_MIN_EPISODE_NIGHTS` (thin sleep data
|
||
* produces no observation — say nothing rather than guess).
|
||
* - The |delta| must reach `SLEEP_CONTEXT_MIN_DELTA_MINUTES` so day-to-day
|
||
* jitter is never narrated as a finding.
|
||
*
|
||
* Episode nights are clipped to the active span (`>= onsetDay`) so the
|
||
* comparison reflects the illness, not the pre-onset lookback. Returns null
|
||
* (says nothing) below either floor.
|
||
*/
|
||
function computeSleepContext(
|
||
baselineNights: SleepNightPoint[],
|
||
episodeNights: SleepNightPoint[],
|
||
window: EpisodeWindow,
|
||
): SleepContext | null {
|
||
const baseMinutes = baselineNights.map((n) => n.asleepMinutes);
|
||
const episodeMinutes = episodeNights
|
||
.filter((n) => n.day >= window.onsetDay)
|
||
.map((n) => n.asleepMinutes);
|
||
|
||
if (
|
||
baseMinutes.length < SLEEP_CONTEXT_MIN_BASELINE_NIGHTS ||
|
||
episodeMinutes.length < SLEEP_CONTEXT_MIN_EPISODE_NIGHTS
|
||
) {
|
||
return null;
|
||
}
|
||
|
||
const baselineMeanMinutes = Math.round(median(baseMinutes));
|
||
const episodeMeanMinutes = Math.round(
|
||
episodeMinutes.reduce((s, v) => s + v, 0) / episodeMinutes.length,
|
||
);
|
||
const deltaMinutes = episodeMeanMinutes - baselineMeanMinutes;
|
||
|
||
// Sub-floor delta is jitter, not a finding → say nothing.
|
||
if (Math.abs(deltaMinutes) < SLEEP_CONTEXT_MIN_DELTA_MINUTES) return null;
|
||
|
||
return {
|
||
baselineMeanMinutes,
|
||
episodeMeanMinutes,
|
||
deltaMinutes,
|
||
nightsCounted: episodeMinutes.length,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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) {
|
||
// `runFlag` counts CALENDAR-consecutive runs, so it needs chronological
|
||
// input. Sort locally rather than trusting an upstream sort invariant — the
|
||
// fever path already sorts its unioned series, and this keeps the SpO2 scan
|
||
// order-independent by construction if a future reader feeds unsorted days.
|
||
const spo2Points = spo2.episodeDays
|
||
.filter(inActive)
|
||
.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
|
||
const flag = runFlag(
|
||
spo2Points,
|
||
(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) {
|
||
// `runFlag` counts CONSECUTIVE-day runs, so it needs chronological input.
|
||
// The Map unions two individually-sorted sources (passive temperature, then
|
||
// day-log feverC) in insertion order, NOT global day order — sort by day
|
||
// before the run scan or an interleaved dual-source episode mis-counts the
|
||
// run (a false sustained_fever escalation, or a real run hidden).
|
||
const fevPoints: VitalDayPoint[] = [...feverByDay.entries()]
|
||
.map(([day, mean]) => ({ day, mean }))
|
||
.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
|
||
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;
|
||
let prevDay: string | null = null;
|
||
for (const p of sorted) {
|
||
if (predicate(p.mean)) {
|
||
// A run is CALENDAR-consecutive days. Symptom/vital points are
|
||
// present-days-only (never zero-filled), so a sparse febrile series
|
||
// (e.g. days 01/10/15/20) must NOT count as one long run — that fires a
|
||
// false sustained_fever escalation. Continue the run only when this
|
||
// point is the calendar day immediately after the previous matching one;
|
||
// otherwise the current point starts a fresh run of 1.
|
||
const adjacent = prevDay !== null && dayDiff(prevDay, p.day) === 1;
|
||
if (adjacent) {
|
||
run++;
|
||
runWorst =
|
||
runWorst === null
|
||
? p.mean
|
||
: worst === "min"
|
||
? Math.min(runWorst, p.mean)
|
||
: Math.max(runWorst, p.mean);
|
||
} else {
|
||
run = 1;
|
||
runWorst = p.mean;
|
||
}
|
||
if (run > bestRun) {
|
||
bestRun = run;
|
||
bestWorst = runWorst;
|
||
}
|
||
prevDay = p.day;
|
||
} else {
|
||
run = 0;
|
||
runWorst = null;
|
||
prevDay = 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));
|
||
}
|