From b31def38c0d2b7dc64e0a8b77e0f216c22192cd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 22 Jun 2026 08:10:10 +0200 Subject: [PATCH] fix(illness): tolerate a transient trailing reading in the recovery return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last-sustained-return walk nulled the recovery day whenever the final reading sat out of band, even after a vital had settled for several days. A lone noisy sample on the last logged day — common in resting-HR and HRV curves — then erased a real recovery gap that had previously been reported. Treat a trailing out-of-band tail shorter than the stability window as transient noise and keep the settle day; only a sustained re-deviation (a tail at least as long as the stability window, a genuine relapse with no re-settle) clears the return. Apply the same noise tolerance to the symptom-burden track for parity. Cover the trailing-outlier, genuine-relapse-then-re-settle, and sustained-no-re-settle cases. --- src/lib/illness/__tests__/correlation.test.ts | 99 +++++++++++++++++++ src/lib/illness/correlation.ts | 94 +++++++++++------- 2 files changed, 159 insertions(+), 34 deletions(-) diff --git a/src/lib/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts index b288740f7..e52963ed7 100644 --- a/src/lib/illness/__tests__/correlation.test.ts +++ b/src/lib/illness/__tests__/correlation.test.ts @@ -231,6 +231,105 @@ describe("computeIllnessCorrelation — golden recovery-gap", () => { expect(out.value.returns[0]?.returnedDay).toBeNull(); }); + it("still reports the settle day when a single transient out-of-band reading trails the recovery", () => { + // R-REG-1: a non-relapse episode that settles for ≥ RETURN_STABILITY_DAYS + // and then has ONE noisy out-of-band reading on the last logged day. A lone + // trailing outlier is measurement noise, not a relapse, so the recovery day + // must NOT be dropped to null. RHR settles 01-18..01-21 (4 in-band days), + // then a single 63 spike on 01-22. + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 70 }, // onset, out of band + { day: "2026-01-12", mean: 75 }, // nadir + { day: "2026-01-17", mean: 64 }, // felt-better day, still elevated + { day: "2026-01-18", mean: 55 }, // settle starts + { day: "2026-01-19", mean: 55 }, + { day: "2026-01-20", mean: 55 }, + { day: "2026-01-21", mean: 55 }, // 4 stable in-band days + { day: "2026-01-22", mean: 63 }, // lone trailing outlier (transient) + ]; + const out = computeIllnessCorrelation( + input({ series: [{ type, baselineDays, episodeDays }] }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.returns[0]?.returnedDay).toBe("2026-01-18"); + // returned 01-18, felt better 01-17 → gap +1 (a non-null number) + expect(out.value.recoveryGapDays).toBe(dayDiff("2026-01-17", "2026-01-18")); + expect(out.value.recoveryGapDays).toBe(1); + }); + + it("reports the FINAL settle day after a genuine relapse that re-settles", () => { + // R-REG-1: settle → genuine sustained re-deviation (a real flare, not noise) + // → re-settle. The LAST sustained in-band run wins. RHR settles 01-13..01-15, + // flares out-of-band 01-16..01-18 (3 days = a genuine relapse), then settles + // again 01-19..01-21. The return is the SECOND settle, 01-19. + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 72 }, // onset, out of band + { day: "2026-01-12", mean: 75 }, // nadir + { day: "2026-01-13", mean: 55 }, // first settle starts + { day: "2026-01-14", mean: 55 }, + { day: "2026-01-15", mean: 55 }, // 3 in-band days + { day: "2026-01-16", mean: 68 }, // genuine relapse (sustained) + { day: "2026-01-17", mean: 67 }, + { day: "2026-01-18", mean: 66 }, // 3 out-of-band days + { day: "2026-01-19", mean: 55 }, // second settle starts + { day: "2026-01-20", mean: 55 }, + { day: "2026-01-21", mean: 55 }, // 3 in-band days + ]; + 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-19"); + expect(out.value.recoveryGapDays).toBe(dayDiff("2026-01-14", "2026-01-19")); + expect(out.value.recoveryGapDays).toBe(5); + }); + + it("returns null when a sustained re-deviation follows the settle with no re-settle (still elevated)", () => { + // R-REG-1 boundary: the noise tolerance must NOT swallow a genuine ongoing + // relapse. Settle 01-13..01-15, then a SUSTAINED out-of-band tail + // (01-16..01-18, ≥ RETURN_STABILITY_DAYS) with no re-settle → the vital is + // still elevated at the end → null, exactly as a still-active episode. + const type: MeasurementType = "RESTING_HEART_RATE"; + const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02"); + const episodeDays: VitalDayPoint[] = [ + { day: "2026-01-10", mean: 72 }, + { day: "2026-01-12", mean: 75 }, + { day: "2026-01-13", mean: 55 }, // settle + { day: "2026-01-14", mean: 55 }, + { day: "2026-01-15", mean: 55 }, + { day: "2026-01-16", mean: 68 }, // sustained re-deviation, no re-settle + { day: "2026-01-17", mean: 67 }, + { day: "2026-01-18", mean: 66 }, + ]; + 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("flags a return adverse only when the vital deviated in the adverse direction", () => { // A neutral-direction vital (WEIGHT) can deviate out of band and stably // return — it produces a return finding, but `adverse` must stay false so diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index c0b1adeea..112790c99 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -696,19 +696,23 @@ export function computeIllnessCorrelation( /** * 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. + * `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. * - * 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. + * 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[], @@ -720,13 +724,24 @@ function lastStableReturn( day: p.day, in: Math.abs(deviationSd(p.mean, center, spread)) < NOTABLE_SD, })); - // 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; + 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. + const end = inBand.length - 1 - tailOut; + let start = end; 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. + 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; @@ -744,14 +759,15 @@ function lastStableReturn( * `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 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, 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 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. @@ -784,16 +800,26 @@ function computeSymptomReturn( (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). + // 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 && 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; + 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; + while (start - 1 >= 0 && inBand[start - 1]) start--; + const run = end - start + 1; + if (run >= RETURN_STABILITY_DAYS && start >= firstAdverseIndex) { + returnedDay = active[start].day; + } } }