fix(illness): correct the recovery-gap and red-flag engine

Anchor the physiological-return search after the first notable deviation so
an early in-band run-up can no longer be reported as a return before the
vital ever deviated (the spurious-negative-gap bug). Decouple the red-flag
escalation from the baseline-band loop: it now scans the raw episode days for
SpO2 and temperature against absolute clinical floors, so a rock-steady
(MAD=0) vital still escalates a low SpO2 or sustained fever. Union the day-log
feverC series into the fever flag and prefer per-day max so an evening spike
is not masked by the daily mean. Key every per-day series in the user's
timezone (rollup buckets and the raw fallback) so the vital series lines up
with the tz-keyed onset and felt-better markers instead of an off-by-one for
non-UTC accounts.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 23:17:48 +02:00
1 parent b28fa4d4a5
commit 82cabff8d0
4 files changed
+425 -75

No files matched your search

@@ -51,6 +51,7 @@ const NOW = new Date("2026-01-21T12:00:00Z");
const db = vi.hoisted(() => ({
measurement: { findMany: vi.fn() },
illnessDayLog: { findMany: vi.fn() },
}));
const rollup = vi.hoisted(() => ({
probeRollupCoverage: vi.fn(),
@@ -93,6 +94,8 @@ function rawRowsWithin(from: Date, to: Date) {
beforeEach(() => {
vi.clearAllMocks();
// No day-log fever rows in the parity/contamination fixtures.
db.illnessDayLog.findMany.mockResolvedValue([]);
});
afterEach(() => {
vi.restoreAllMocks();
@@ -141,6 +144,7 @@ describe("computeEpisodeCorrelation — rollup-vs-live parity", () => {
// baseline engine's live fallback groups raw rows itself for the
// trailing read; we serve the same DAILY universe for any window. ──
vi.clearAllMocks();
db.illnessDayLog.findMany.mockResolvedValue([]);
const noCoverage = new Map<string, boolean>(
ILLNESS_SCAN_TYPES.map((t) => [String(t), false]),
);
@@ -181,6 +185,67 @@ describe("computeEpisodeCorrelation — rollup-vs-live parity", () => {
});
});
describe("computeEpisodeCorrelation — user-tz day keying", () => {
it("keys a 03:00Z reading to the user's LOCAL day (negative-offset tz)", async () => {
// America/Los_Angeles is UTC−8 (−7 DST). A 2026-01-12T03:00Z reading is
// 2026-01-11 19:00 local → local day 2026-01-11, NOT the UTC 2026-01-12.
// The vital series must key by the user's day so it lines up with the
// tz-keyed onset/feltBetter markers (the off-by-one this fix closes).
rollup.probeRollupCoverage.mockResolvedValue(
new Map<string, boolean>(ILLNESS_SCAN_TYPES.map((t) => [String(t), false])),
);
rollup.readBestGranularityRollups.mockResolvedValue(null);
// A simple RHR universe with a notable spike whose UTC timestamp crosses
// the local midnight. Baseline window is flat-ish at 55.
const rows: Array<{ value: number; measuredAt: Date }> = [];
// 20 baseline days at 55 ± small jitter, ending well before the episode.
let cursor = Date.parse("2025-12-20T20:00:00Z");
const jit = [-2, -1, 0, 1, 2];
for (let i = 0; i < 20; i++) {
rows.push({ value: 55 + jit[i % jit.length], measuredAt: new Date(cursor) });
cursor += 24 * 60 * 60 * 1000;
}
// Episode-span readings, including the boundary-crossing 03:00Z spike.
rows.push({ value: 56, measuredAt: new Date("2026-01-10T20:00:00Z") });
rows.push({ value: 76, measuredAt: new Date("2026-01-12T03:00:00Z") }); // local 01-11
rows.push({ value: 74, measuredAt: new Date("2026-01-13T20:00:00Z") });
rows.push({ value: 70, measuredAt: new Date("2026-01-15T20:00:00Z") });
rows.push({ value: 60, measuredAt: new Date("2026-01-16T20:00:00Z") });
rows.push({ value: 58, measuredAt: new Date("2026-01-17T20:00:00Z") });
db.measurement.findMany.mockImplementation(
async ({ where }: { where: { type: string; measuredAt: { gte: Date; lt?: Date } } }) => {
if (where.type !== "RESTING_HEART_RATE") return [];
const from = where.measuredAt.gte.getTime();
const to = where.measuredAt.lt?.getTime() ?? NOW.getTime();
return rows.filter(
(r) => r.measuredAt.getTime() >= from && r.measuredAt.getTime() < to,
);
},
);
const episode = {
id: "ep-tz",
onsetAt: new Date("2026-01-11T08:00:00Z"), // local 01-11 00:00 LA
resolvedAt: null as Date | null,
lifecycle: "ACUTE",
};
const out = await computeEpisodeCorrelation(
"u1",
episode,
"America/Los_Angeles",
NOW,
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
// The 76 spike lands on the user-local day 2026-01-11, not the UTC 01-12.
expect(out.value.nadir[0]?.value).toBe(76);
expect(out.value.nadir[0]?.day).toBe("2026-01-11");
});
});
describe("computeEpisodeCorrelation — contamination-guard read window", () => {
it("never queries baseline rows on or after the pre-onset lookback start", async () => {
rollup.probeRollupCoverage.mockResolvedValue(
@@ -291,4 +291,143 @@ describe("computeIllnessCorrelation — red-flag escalation", () => {
if (out.status !== "ok") return;
expect(out.value.redFlags).toHaveLength(0);
});
it("escalates a MAD=0 (rock-steady) SpO2 that drops — decoupled from the band", () => {
// A perfectly steady 98 baseline → MAD=0 → the band is DROPPED, so this
// vital never participates in the banded loop. A 90% SpO2 run must STILL
// escalate (the safety-critical case the band filter previously hid). To
// satisfy the episode-coverage floor we add a separate banded RHR vital.
const spo2: MeasurementType = "OXYGEN_SATURATION";
const rhr: MeasurementType = "RESTING_HEART_RATE";
const out = computeIllnessCorrelation(
input({
series: [
{
type: spo2,
baselineDays: flatBaseline(98, 21, "2026-01-02"), // MAD = 0 → no band
episodeDays: [
{ day: "2026-01-10", mean: 90 },
{ day: "2026-01-11", mean: 89 },
{ day: "2026-01-12", mean: 90 }, // 3 days ≤ 92
{ day: "2026-01-13", mean: 97 },
],
},
{
type: rhr,
baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"),
episodeDays: flatBaseline(55, 5, "2026-01-15"),
},
],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
const spo2Flag = out.value.redFlags.find(
(f) => f.reason === "sustained_low_spo2",
);
expect(spo2Flag).toBeDefined();
expect(spo2Flag?.worstValue).toBe(89);
expect(spo2Flag?.days).toBe(3);
});
it("escalates a sustained fever logged via the day-log feverC (no temp series)", () => {
// No BODY_TEMPERATURE vital at all — the canonical journaling-fever path.
// A banded RHR vital satisfies the coverage floor; the fever escalates
// purely from the day-log feverC union.
const rhr: MeasurementType = "RESTING_HEART_RATE";
const out = computeIllnessCorrelation(
input({
series: [
{
type: rhr,
baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"),
episodeDays: flatBaseline(55, 5, "2026-01-15"),
},
],
dayLogFever: [
{ day: "2026-01-10", feverC: 39.2 },
{ day: "2026-01-11", feverC: 38.9 },
{ day: "2026-01-12", feverC: 38.6 }, // 3 days ≥ 38.5
{ day: "2026-01-13", feverC: 37.4 },
],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
const fever = out.value.redFlags.find((f) => f.reason === "sustained_fever");
expect(fever).toBeDefined();
expect(fever?.days).toBe(3);
expect(fever?.worstValue).toBe(39.2);
});
it("uses per-day MAX for fever so an evening spike is not masked by the mean", () => {
// The mean episode series stays sub-fever; the per-day MAX crosses 38.5.
const temp: MeasurementType = "BODY_TEMPERATURE";
const rhr: MeasurementType = "RESTING_HEART_RATE";
const out = computeIllnessCorrelation(
input({
series: [
{
type: temp,
baselineDays: jitteredBaseline(36.6, 0.2, 21, "2026-01-02"),
episodeDays: [
{ day: "2026-01-10", mean: 37.4 },
{ day: "2026-01-11", mean: 37.5 },
{ day: "2026-01-12", mean: 37.6 },
],
episodeDayMax: [
{ day: "2026-01-10", mean: 38.8 },
{ day: "2026-01-11", mean: 38.7 },
{ day: "2026-01-12", mean: 38.9 }, // 3 days max ≥ 38.5
],
},
{
type: rhr,
baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"),
episodeDays: flatBaseline(55, 5, "2026-01-15"),
},
],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
const fever = out.value.redFlags.find((f) => f.reason === "sustained_fever");
expect(fever).toBeDefined();
expect(fever?.days).toBe(3);
});
});
describe("computeIllnessCorrelation — return anchor", () => {
it("never reports a return BEFORE the first deviation (no spurious gap)", () => {
// The vital is IN BAND for the first 4 active days (the run-up), then
// spikes, then settles. A naive scan from i=0 would call the early in-band
// run the 'return' and yield a negative gap. The anchored search must pick
// the post-deviation settle (01-19), giving a positive gap.
const type: MeasurementType = "RESTING_HEART_RATE";
const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02");
const episodeDays: VitalDayPoint[] = [
{ day: "2026-01-10", mean: 55 }, // in band (run-up)
{ day: "2026-01-11", mean: 55 },
{ day: "2026-01-12", mean: 55 },
{ day: "2026-01-13", mean: 75 }, // FIRST deviation
{ day: "2026-01-14", mean: 74 },
{ day: "2026-01-15", mean: 70 },
{ day: "2026-01-19", mean: 55 }, // settle start
{ day: "2026-01-20", mean: 55 },
{ day: "2026-01-21", mean: 55 }, // stable 3 days
];
const out = computeIllnessCorrelation(
input({
window: { onsetDay: "2026-01-10", feltBetterDay: "2026-01-15", lifecycle: "ACUTE" },
series: [{ type, baselineDays, episodeDays }],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
// The return is the post-deviation settle, NOT the 01-10 run-up.
expect(out.value.returns[0]?.returnedDay).toBe("2026-01-19");
// Gap = 01-19 − 01-15 = +4 (positive — body lagged), never negative.
expect(out.value.recoveryGapDays).toBe(4);
expect(out.value.recoveryGapDays).toBeGreaterThan(0);
});
});
+101 -36
View File
@@ -17,13 +17,16 @@
*
* Reads are DAY-native (the spread invariant forbids composing a band from a
* coarser tier's `sd`): the rollup DAY tier when covered, a bounded per-type
* live-SQL fallback on a coverage miss. The `readDayMeanSeries` helper the
* baseline engine already exports does exactly this and is reused verbatim, so
* the rollup-vs-live parity the baseline tests pin carries over for free.
* live-SQL fallback on a coverage miss. EVERY per-day key is the USER'S local
* day (`dayKeyForUserTz`), never a raw UTC slice — the onset/feltBetter markers
* are already keyed in the user's tz, so the vital series must match or a
* non-UTC user gets an off-by-one (a 03:00Z reading is local D−1 but UTC D).
* Rollup DAY buckets are minted at the UTC midnight of the user-tz day, so
* keying their `bucketStart` through `dayKeyForUserTz` is exact.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
import { resolveUserTimezone } from "@/lib/measurements/consolidation-base";
@@ -33,6 +36,7 @@ import {
ILLNESS_SCAN_TYPES,
PRE_ONSET_LOOKBACK_DAYS,
computeIllnessCorrelation,
type DayLogFeverPoint,
type IllnessCorrelationValue,
type VitalSeries,
} from "./correlation";
@@ -47,6 +51,12 @@ export interface EpisodeForCorrelation {
lifecycle: string;
}
/** A windowed per-day read: mean + max per local day, with provenance. */
interface WindowRead {
points: { day: string; mean: number; max: number }[];
source: "DAY" | "live" | "none";
}
/**
* Resolve the per-vital series for one episode (contamination-guarded
* baseline window) and run the pure engine. Returns the same gated
@@ -82,13 +92,12 @@ export async function computeEpisodeCorrelation(
let anyLive = false;
let anyDay = false;
// We attempt every scan type. The read helper reuses the baseline engine's
// rollup-first path (live fallback on a coverage miss); an untracked vital
// returns empty points and is dropped below. Each read is a single windowed
// query, so attempting all types is bounded.
// We attempt every scan type. An untracked vital returns empty points and
// is dropped below. Each read is a single windowed query, so attempting all
// types is bounded.
for (const type of ILLNESS_SCAN_TYPES) {
const baseline = await readWindowMeans(userId, type, baselineStart, baselineEnd, coverage, now);
const episodeWin = await readWindowMeans(userId, type, preOnsetStart, end, coverage, now);
const baseline = await readWindowMeans(userId, type, baselineStart, baselineEnd, coverage, tz, now);
const episodeWin = await readWindowMeans(userId, type, preOnsetStart, end, coverage, tz, now);
if (baseline.points.length === 0 && episodeWin.points.length === 0) continue;
if (baseline.source === "live" || episodeWin.source === "live") anyLive = true;
if (baseline.source === "DAY" || episodeWin.source === "DAY") anyDay = true;
@@ -96,9 +105,17 @@ export async function computeEpisodeCorrelation(
type,
baselineDays: baseline.points.map((p) => ({ day: p.day, mean: p.mean })),
episodeDays: episodeWin.points.map((p) => ({ day: p.day, mean: p.mean })),
// Per-day max over the episode window — the fever red-flag prefers it so
// an evening spike averaged toward normal in the daily mean is not masked.
episodeDayMax: episodeWin.points.map((p) => ({ day: p.day, mean: p.max })),
});
}
// Union the canonical journaling-fever path: the illness day-log `feverC`
// series over the episode window. A user logging 39.2 °C for days with no
// HealthKit thermometer must still escalate. Per-day MAX.
const dayLogFever = await readDayLogFever(episode.id, preOnsetStart, end, tz);
const source: "DAY" | "live" | "none" = anyDay
? "DAY"
: anyLive
@@ -109,18 +126,18 @@ export async function computeEpisodeCorrelation(
episodeId: episode.id,
window: { onsetDay, feltBetterDay, lifecycle: episode.lifecycle },
series,
dayLogFever,
source,
now,
});
}
/**
* Per-day means for `(userId, type)` over an explicit [from, to) window.
* Reuses the baseline engine's `readDayMeanSeries` (DAY-native rollup with a
* live fallback) for the whole-window read, then trims to the window so the
* baseline and episode windows can share the one cached read path while
* keeping their seams exact. The trim is on the local day key so the
* contamination guard holds in the user's timezone.
* Per-day mean + max for `(userId, type)` over an explicit [from, to) window,
* keyed by the USER'S local day. The DAY rollup serves the trailing
* (`to ≈ now`) case; otherwise a bounded raw read groups per local day. The
* tz-keying is what keeps the contamination-guard seam and the onset/felt-
* better markers in the same calendar for a non-UTC user.
*/
async function readWindowMeans(
userId: string,
@@ -128,41 +145,89 @@ async function readWindowMeans(
from: Date,
to: Date,
coverage: Awaited<ReturnType<typeof probeRollupCoverage>>,
tz: string,
now: Date,
): Promise<{ points: { day: string; mean: number }[]; source: "DAY" | "live" | "none" }> {
// The baseline helper reads a trailing window ending at `now`. We need an
// arbitrary [from, to) window, so read directly here: DAY rollup first via
// the helper for the trailing case, else a bounded raw read. To keep the
// seam exact we always do the bounded raw per-day grouping for the bounded
// window (a single indexed query), and only use the helper's rollup path
// when `to` is effectively `now` (the common active-episode case).
): Promise<WindowRead> {
const toIsNow = Math.abs(to.getTime() - now.getTime()) < MS_PER_DAY;
if (toIsNow) {
const windowDays = Math.max(1, Math.ceil((to.getTime() - from.getTime()) / MS_PER_DAY));
const resolved = await readDayMeanSeries(userId, type, windowDays, now, coverage);
const fromKey = from.toISOString().slice(0, 10);
return {
points: resolved.points.filter((p) => p.day >= fromKey),
source: resolved.source === "DAY" ? "DAY" : resolved.source === "none" ? "none" : "live",
};
if (toIsNow && coverage.get(type) === true) {
const windowDays = Math.max(
1,
Math.ceil((to.getTime() - from.getTime()) / MS_PER_DAY),
);
const resolved = await readBestGranularityRollups(userId, type, windowDays);
if (resolved && resolved.granularity === "DAY" && resolved.rows.length > 0) {
const fromKey = dayKeyForUserTz(from, tz);
// DAY buckets are minted at the UTC midnight of the user-tz day, so
// keying `bucketStart` through the user tz is exact.
const points = resolved.rows
.map((row) => ({
day: dayKeyForUserTz(row.bucketStart, tz),
mean: row.mean,
max: row.maxValue ?? row.mean,
}))
.filter((p) => p.day >= fromKey)
.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
if (points.length > 0) return { points, source: "DAY" };
}
// Coverage said "has buckets" but the window resolved coarser / empty →
// fall through to the live read so the series stays DAY-native.
}
// Bounded raw fallback — group per the USER'S local day (never a UTC slice).
// A trailing window (`to ≈ now`) reads open-ended (gte only) so it stays a
// "trailing" read; a bounded window pins `lt` so it can never reach past its
// seam (the contamination guard for the baseline window).
const measuredAt = toIsNow ? { gte: from } : { gte: from, lt: to };
const rows = await prisma.measurement.findMany({
where: { userId, type, deletedAt: null, measuredAt: { gte: from, lt: to } },
where: { userId, type, deletedAt: null, measuredAt },
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true },
});
if (rows.length === 0) return { points: [], source: "none" };
const byDay = new Map<string, { sum: number; count: number }>();
const byDay = new Map<string, { sum: number; count: number; max: number }>();
for (const row of rows) {
const day = row.measuredAt.toISOString().slice(0, 10);
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
const day = dayKeyForUserTz(row.measuredAt, tz);
const acc = byDay.get(day) ?? { sum: 0, count: 0, max: -Infinity };
acc.sum += row.value;
acc.count += 1;
acc.max = Math.max(acc.max, row.value);
byDay.set(day, acc);
}
const points = [...byDay.entries()]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([day, acc]) => ({ day, mean: acc.sum / acc.count }));
.map(([day, acc]) => ({ day, mean: acc.sum / acc.count, max: acc.max }));
return { points, source: "live" };
}
/**
* Read the illness day-log `feverC` series over [from, to) as per-day MAX
* fever points (keyed by the stored `date`, which is already the user-tz day
* string the day-log was logged under). Drops null fevers.
*/
async function readDayLogFever(
episodeId: string,
from: Date,
to: Date,
tz: string,
): Promise<DayLogFeverPoint[]> {
const fromKey = dayKeyForUserTz(from, tz);
const toKey = dayKeyForUserTz(to, tz);
const rows = await prisma.illnessDayLog.findMany({
where: {
episodeId,
deletedAt: null,
feverC: { not: null },
date: { gte: fromKey, lte: toKey },
},
select: { date: true, feverC: true },
});
const byDay = new Map<string, number>();
for (const row of rows) {
if (row.feverC == null) continue;
byDay.set(row.date, Math.max(byDay.get(row.date) ?? -Infinity, row.feverC));
}
return [...byDay.entries()]
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([day, feverC]) => ({ day, feverC }));
}
+120 -39
View File
@@ -130,6 +130,26 @@ export interface VitalSeries {
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;
}
/** The dates the engine reasons over, all `YYYY-MM-DD` local day keys. */
@@ -147,6 +167,12 @@ export interface IllnessCorrelationInput {
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[];
/** Provenance source the dominant read resolved against. */
source: "DAY" | "live" | "none";
/** Compute time (injected for deterministic tests). */
@@ -330,7 +356,6 @@ export function computeIllnessCorrelation(
const preOnset: VitalDeviationFinding[] = [];
const nadir: VitalDeviationFinding[] = [];
const returns: VitalReturnFinding[] = [];
const redFlags: IllnessRedFlag[] = [];
for (const { series, band } of banded) {
const { type } = series;
@@ -355,39 +380,52 @@ export function computeIllnessCorrelation(
// 2) Nadir: worst deviation across the active span (onset → end).
const active = episode.filter((p) => p.day >= window.onsetDay);
let worstActive: VitalDeviationFinding | null = null;
for (const p of active) {
// 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;
active.forEach((p, i) => {
const f = finding(type, p.day, p.mean, center, spread);
if (
Math.abs(f.deviationSd) >= NOTABLE_SD &&
(worstActive === null ||
Math.abs(f.deviationSd) > Math.abs(worstActive.deviationSd))
) {
worstActive = f;
if (Math.abs(f.deviationSd) >= NOTABLE_SD) {
if (firstDeviationIndex === -1) firstDeviationIndex = i;
if (
worstActive === null ||
Math.abs(f.deviationSd) > Math.abs(worstActive.deviationSd)
) {
worstActive = f;
}
}
}
});
if (worstActive) nadir.push(worstActive);
// 3) Physiological return: first active day this vital deviated
// notably-adverse, then later re-entered the band and STAYED in for
// RETURN_STABILITY_DAYS. Only meaningful if it actually deviated.
const deviatedActive = active.some((p) => {
const f = finding(type, p.day, p.mean, center, spread);
return Math.abs(f.deviationSd) >= NOTABLE_SD;
});
if (deviatedActive && window.lifecycle !== "CHRONIC_ONGOING") {
const returnedDay = firstStableReturn(active, center, spread);
// 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 });
}
// Red-flag escalation (clinical floors, retrospective).
const flag = detectRedFlag(type, active);
if (flag) redFlags.push(flag);
}
// 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
@@ -423,20 +461,23 @@ export function computeIllnessCorrelation(
}
/**
* First day in `active` (chronological) 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.
* 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 = 0; i < inBand.length; i++) {
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++;
@@ -446,22 +487,62 @@ function firstStableReturn(
}
/**
* Detect a retrospective red flag: a sustained adverse SpO2 drop or a
* sustained fever during the active span. Conservative clinical floors;
* escalation copy lives in the i18n layer.
* 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 detectRedFlag(
type: MeasurementType,
active: VitalDayPoint[],
): IllnessRedFlag | null {
const sorted = [...active].sort(byDay);
if (type === "OXYGEN_SATURATION") {
return runFlag(sorted, (v) => v <= SPO2_RED_FLAG, "sustained_low_spo2", type, "min");
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);
}
if (type === "BODY_TEMPERATURE") {
return runFlag(sorted, (v) => v >= FEVER_RED_FLAG, "sustained_fever", type, "max");
// 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));
}
return null;
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. */