From 82cabff8d0a4adabfa36f0d2258d54fe27f060b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 16 Jun 2026 23:17:48 +0200 Subject: [PATCH] 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. --- .../__tests__/correlation-read.test.ts | 65 +++++++ src/lib/illness/__tests__/correlation.test.ts | 139 +++++++++++++++ src/lib/illness/correlation-read.ts | 137 +++++++++++---- src/lib/illness/correlation.ts | 159 +++++++++++++----- 4 files changed, 425 insertions(+), 75 deletions(-) diff --git a/src/lib/illness/__tests__/correlation-read.test.ts b/src/lib/illness/__tests__/correlation-read.test.ts index 01c1625a5..5ba0da5b7 100644 --- a/src/lib/illness/__tests__/correlation-read.test.ts +++ b/src/lib/illness/__tests__/correlation-read.test.ts @@ -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( 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(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( diff --git a/src/lib/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts index 5c627d887..be0b29720 100644 --- a/src/lib/illness/__tests__/correlation.test.ts +++ b/src/lib/illness/__tests__/correlation.test.ts @@ -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); + }); }); diff --git a/src/lib/illness/correlation-read.ts b/src/lib/illness/correlation-read.ts index 29667cefb..d37c42240 100644 --- a/src/lib/illness/correlation-read.ts +++ b/src/lib/illness/correlation-read.ts @@ -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>, + 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 { 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(); + const byDay = new Map(); 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 { + 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(); + 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 })); +} diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index af7ad174d..3a8518f11 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -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(); + 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. */