From e432b53e0df34bd87f26da94285295f113b04e0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 22 Jun 2026 06:59:02 +0200 Subject: [PATCH] fix(illness): report the last sustained recovery return, not the first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A vital or symptom curve that settles, re-deviates mid-episode, and settles again now reports the FINAL sustained in-band run as the recovery return rather than the first. The earlier first-return rule locked onto an interim settle and under-reported the gap whenever a relapse followed. The change is monotone — a last return is always at or after the first — so no existing recovery number shifts; it only extends the gap for episodes that genuinely relapsed. The anchor-after-first-deviation guard is unchanged: an early in-band run-up is still never read as a return. The symptom-burden track honours relapse identically over logged days so the headline median never mixes first- and last-return semantics. Adds relapse, multiple-relapse, too-short-final-run, ends-out-of-band, and symptom-relapse engine fixtures plus a cross-episode integration fixture. --- src/lib/illness/__tests__/correlation.test.ts | 185 ++++++++++++++++++ .../illness/__tests__/retrospective.test.ts | 88 +++++++++ src/lib/illness/correlation.ts | 76 ++++--- 3 files changed, 317 insertions(+), 32 deletions(-) diff --git a/src/lib/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts index fd5a855b3..5f0293784 100644 --- a/src/lib/illness/__tests__/correlation.test.ts +++ b/src/lib/illness/__tests__/correlation.test.ts @@ -810,3 +810,188 @@ describe("computeIllnessCorrelation — symptom-burden return track", () => { ).toBeUndefined(); }); }); + +describe("computeIllnessCorrelation — relapse-aware return (last sustained)", () => { + it("reports the LAST sustained return when the vital relapses mid-episode", () => { + // Deviate 01-10, settle in-band 01-13..01-15 (a 3-day run that the OLD + // first-return rule would have called the return), re-deviate 01-16..01-18, + // then settle again 01-20..01-22 and hold. felt-better 01-14. The honest + // answer is the SECOND settle (01-20), not the first — the relapse pushes + // the return later and extends the gap. + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 75 }, // first deviation + { day: "2026-01-12", mean: 74 }, + { day: "2026-01-13", mean: 55 }, // first settle (old return) + { day: "2026-01-14", mean: 55 }, + { day: "2026-01-15", mean: 55 }, // 3-day in-band run + { day: "2026-01-16", mean: 75 }, // RELAPSE + { day: "2026-01-17", mean: 74 }, + { day: "2026-01-18", mean: 73 }, + { day: "2026-01-20", mean: 55 }, // final settle start + { day: "2026-01-21", mean: 55 }, + { day: "2026-01-22", mean: 55 }, // holds to end + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [{ type, baselineDays, episodeDays }], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.returns[0]?.returnedDay).toBe("2026-01-20"); + // Gap = 01-20 − 01-14 = +6 (the old first-return would have given −1). + expect(out.value.recoveryGapDays).toBe(dayDiff("2026-01-14", "2026-01-20")); + expect(out.value.recoveryGapDays).toBe(6); + }); + + it("reports the LAST sustained symptom return when the curve relapses", () => { + // Parity with the vital track on the symptom curve: impact eases to 0 for 3 + // logged days, flares back to 2, eases to 0 again for 3 logged days → the + // return is the SECOND easing, not the first. + const rhr: MeasurementType = "RESTING_HEART_RATE"; + const rhrBaseline = jitteredBaseline(55, 1, 21, "2026-01-02"); + const rhrFlat: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 55 }, + { day: "2026-01-11", mean: 55 }, + { day: "2026-01-12", mean: 55 }, + { day: "2026-01-13", mean: 55 }, + { day: "2026-01-14", mean: 55 }, + ]; + const symptomBurden: SymptomBurdenPoint[] = [ + { day: "2026-01-10", impact: 3 }, + { day: "2026-01-12", impact: 2 }, + { day: "2026-01-13", impact: 0 }, // first easing + { day: "2026-01-14", impact: 0 }, + { day: "2026-01-15", impact: 0 }, // 3 logged in-band days + { day: "2026-01-16", impact: 2 }, // FLARE + { day: "2026-01-18", impact: 0 }, // final easing start + { day: "2026-01-19", impact: 0 }, + { day: "2026-01-20", impact: 0 }, // 3 logged in-band days, holds + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [ + { type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat }, + ], + symptomBurden, + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const sym = out.value.returns.find( + (r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY, + ); + expect(sym?.returnedDay).toBe("2026-01-18"); + expect(sym?.gapDays).toBe(dayDiff("2026-01-14", "2026-01-18")); + expect(out.value.recoveryGapDays).toBe(4); + }); + + it("withholds when the final in-band run is too short after a relapse", () => { + // Deviate, settle, relapse, then only 2 in-band days to the window end — + // the final run is shorter than RETURN_STABILITY_DAYS, so no return is + // fabricated off the too-short tail. + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 75 }, // deviation + { day: "2026-01-12", mean: 55 }, // settle + { day: "2026-01-13", mean: 55 }, + { day: "2026-01-14", mean: 55 }, // 3-day run + { day: "2026-01-16", mean: 75 }, // RELAPSE + { day: "2026-01-17", mean: 74 }, + { day: "2026-01-19", mean: 55 }, // only 2 in-band days to end + { day: "2026-01-20", mean: 55 }, + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [{ type, baselineDays, episodeDays }], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.returns[0]?.returnedDay).toBeNull(); + expect(out.value.recoveryGapDays).toBeNull(); + }); + + it("picks the FINAL settle across multiple relapses", () => { + // settle → flare → settle → flare → settle: the last contiguous in-band + // span wins regardless of how many intervening cycles. + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 75 }, // deviation + { day: "2026-01-11", mean: 55 }, // settle 1 + { day: "2026-01-12", mean: 55 }, + { day: "2026-01-13", mean: 55 }, + { day: "2026-01-14", mean: 75 }, // flare 1 + { day: "2026-01-15", mean: 55 }, // settle 2 + { day: "2026-01-16", mean: 55 }, + { day: "2026-01-17", mean: 55 }, + { day: "2026-01-18", mean: 75 }, // flare 2 + { day: "2026-01-20", mean: 55 }, // FINAL settle start + { day: "2026-01-21", mean: 55 }, + { day: "2026-01-22", mean: 55 }, // holds to end + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [{ type, baselineDays, episodeDays }], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.returns[0]?.returnedDay).toBe("2026-01-20"); + expect(out.value.recoveryGapDays).toBe(6); + }); + + it("returns null when the episode ends out-of-band after a relapse", () => { + // Deviate, settle, relapse, never resolve (ends elevated) → no sustained + // final return → null gap. Honest: it did not finally settle in-window. + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 75 }, // deviation + { day: "2026-01-12", mean: 55 }, // settle + { day: "2026-01-13", mean: 55 }, + { day: "2026-01-14", mean: 55 }, // 3-day run + { day: "2026-01-16", mean: 75 }, // RELAPSE + { day: "2026-01-18", mean: 74 }, + { day: "2026-01-20", mean: 73 }, // still elevated at the end + ]; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [{ type, baselineDays, episodeDays }], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.returns[0]?.returnedDay).toBeNull(); + expect(out.value.recoveryGapDays).toBeNull(); + }); +}); diff --git a/src/lib/illness/__tests__/retrospective.test.ts b/src/lib/illness/__tests__/retrospective.test.ts index f9dc38125..3cb4d062e 100644 --- a/src/lib/illness/__tests__/retrospective.test.ts +++ b/src/lib/illness/__tests__/retrospective.test.ts @@ -15,6 +15,30 @@ import { MIN_TYPICAL_GAP_MAGNITUDE_DAYS, type RetrospectiveEpisode, } from "../retrospective"; +import { computeIllnessCorrelation, type VitalDayPoint } from "../correlation"; + +/** A baseline with a non-zero, stable MAD (mirrors the correlation suite). */ +function jitteredBaseline( + center: number, + jitter: number, + days: number, + endDay: string, +): VitalDayPoint[] { + const out: VitalDayPoint[] = []; + let cursor = Date.parse(`${endDay}T00:00:00Z`); + const offsets = [-jitter, -jitter / 2, 0, jitter / 2, jitter]; + for (let i = 0; i < days; i++) { + out.unshift({ + day: new Date(cursor).toISOString().slice(0, 10), + mean: center, + }); + cursor -= 24 * 60 * 60 * 1000; + } + return out.map((p, i) => ({ + ...p, + mean: center + offsets[i % offsets.length], + })); +} function ep(over: Partial): RetrospectiveEpisode { return { @@ -207,4 +231,68 @@ describe("summarizeIllnessRetrospective", () => { ]); expect(out.typicalRecoveryGapDays).toBe(-3); }); + + it("threads the larger last-return gap through the cross-episode median", () => { + // Integration guard for the relapse-aware (last-sustained) return change: + // one episode relapses mid-course, so its ENGINE-computed gap grows from + // the old first-return value (−1) to the last-return value (+6). The + // retrospective consumes that engine number verbatim — confirm the larger + // value pulls the cross-episode median up rather than the stale first + // return. No retrospective code changes; this guards the integration. + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const relapseEpisode: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 75 }, // first deviation + { day: "2026-01-13", mean: 55 }, // first settle (old return) + { day: "2026-01-14", mean: 55 }, + { day: "2026-01-15", mean: 55 }, // 3-day run + { day: "2026-01-16", mean: 75 }, // RELAPSE + { day: "2026-01-18", mean: 73 }, + { day: "2026-01-20", mean: 55 }, // final settle start + { day: "2026-01-21", mean: 55 }, + { day: "2026-01-22", mean: 55 }, // holds to end + ]; + const engine = computeIllnessCorrelation({ + episodeId: "relapse", + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [ + { + type: "RESTING_HEART_RATE", + baselineDays, + episodeDays: relapseEpisode, + }, + ], + source: "DAY", + now: new Date("2026-02-01T00:00:00Z"), + }); + expect(engine.status).toBe("ok"); + if (engine.status !== "ok") return; + // Last-return = 01-20, gap = +6 (not the old first-return −1). + expect(engine.value.recoveryGapDays).toBe(6); + + const out = summarizeIllnessRetrospective([ + ep({ + id: "relapse", + recoveryGapDays: engine.value.recoveryGapDays, + gapMeasurementDays: engine.coverage.historyDays, + gapReturnTypes: ["RESTING_HEART_RATE"], + }), + ep({ + id: "b", + recoveryGapDays: 4, + gapReturnTypes: ["RESTING_HEART_RATE"], + }), + ep({ + id: "c", + recoveryGapDays: 5, + gapReturnTypes: ["RESTING_HEART_RATE"], + }), + ]); + // Median of [6, 4, 5] = 5 — the relapse episode's grown gap is in the mix. + expect(out.typicalRecoveryGapDays).toBe(5); + expect(out.gapDriverType).toBe("RESTING_HEART_RATE"); + }); }); diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index 7756c7c33..09c12618f 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -508,7 +508,7 @@ export function computeIllnessCorrelation( // 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( + const returnedDay = lastStableReturn( active, center, spread, @@ -616,13 +616,22 @@ export function computeIllnessCorrelation( } /** - * 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. + * Start of the FINAL sustained in-band run in `active` (chronological) at/after + * `fromIndex` — the day the vital *finally* settled and held to the end of the + * observed span. "In-band" = |deviation| < NOTABLE_SD. Null when it never + * settles, or when it ends out-of-band (still elevated at the last reading), or + * when the final in-band run is shorter than `RETURN_STABILITY_DAYS` observed + * days. `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. + * + * This is the relapse-aware "last sustained return": an episode that settles, + * re-deviates (a mid-episode flare), and settles again reports the LAST settle. + * The change is monotone — a last return is always >= the first return day — so + * any series that settles ONCE (first == last) is unaffected. Multiple relapses + * collapse to the same rule: the final contiguous in-band span wins by + * construction, no special-casing. */ -function firstStableReturn( +function lastStableReturn( active: VitalDayPoint[], center: number, spread: number, @@ -632,13 +641,16 @@ function firstStableReturn( day: p.day, in: Math.abs(deviationSd(p.mean, center, spread)) < NOTABLE_SD, })); - for (let i = Math.max(0, fromIndex); i < inBand.length; i++) { - if (!inBand[i].in) continue; - let run = 0; - for (let j = i; j < inBand.length && inBand[j].in; j++) run++; - if (run >= RETURN_STABILITY_DAYS) return inBand[i].day; - } - return null; + // Series ends out-of-band → no sustained final return (still elevated). + if (inBand.length === 0 || !inBand[inBand.length - 1].in) return null; + // Walk back from the end to the start of the final maximal in-band run. + let start = inBand.length - 1; + while (start - 1 >= 0 && inBand[start - 1].in) start--; + const run = inBand.length - start; + // The final run 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; } /** @@ -652,11 +664,15 @@ function firstStableReturn( * - The anchor is the first logged adverse day (same role as the vital * `firstDeviationIndex`): a return search starts AFTER it, so a run-up is * never read as a return. - * - A "return" is the first logged day at/after the anchor that is in-band AND - * stays in-band for the next `RETURN_STABILITY_DAYS` LOGGED days. LOGGED-days + * - 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 through the + * last logged day, requiring `RETURN_STABILITY_DAYS` LOGGED days. Relapse- + * aware (parity with the vital `lastStableReturn`): if symptoms ease, flare + * again, then ease again, the SECOND easing is 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, so the - * track WITHHOLDS rather than fabricating recovery from absence of rows. + * journals: a single trailing impact-0 log never satisfies the run, and a + * journal that ends on an adverse log has no final in-band run, so the track + * WITHHOLDS rather than fabricating recovery from absence of rows. * - The gap is `dayDiff(feltBetterDay, returnedDay)`, same signed semantic. * - Always `adverse:true` (a logged symptom curve is illness-relevant), so it * feeds `adverseCoverageDays` and can drive the headline gap. @@ -688,21 +704,17 @@ function computeSymptomReturn( 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. A journal ending on an + // adverse log has no final in-band run → null (honest withholding). + const inBand = active.map((p) => p.impact < FUNCTIONAL_IMPACT_ADVERSE_FLOOR); let returnedDay: string | null = null; - for (let i = firstAdverseIndex; i < active.length; i++) { - if (active[i].impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR) continue; - // Count consecutive in-band LOGGED days from here. - let run = 0; - for ( - let j = i; - j < active.length && active[j].impact < FUNCTIONAL_IMPACT_ADVERSE_FLOOR; - j++ - ) { - run++; - } - if (run >= RETURN_STABILITY_DAYS) { - returnedDay = active[i].day; - break; + if (inBand.length > 0 && inBand[inBand.length - 1]) { + let start = inBand.length - 1; + while (start - 1 >= 0 && inBand[start - 1]) start--; + const run = inBand.length - start; + if (run >= RETURN_STABILITY_DAYS && start >= firstAdverseIndex) { + returnedDay = active[start].day; } }