From f37d935e54ef5a8488a4c996fcb10309e976cb19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 13:21:45 +0200 Subject: [PATCH 1/2] fix(illness): require calendar-adjacency for the stable-return run Both lastStableReturn and computeSymptomReturn counted the in-band settle run by array-index adjacency instead of calendar days, so three sparse logs spread over weeks could register a stable return and move the headline recovery gap. Extend the run only while the prior point is in-band and the calendar day immediately before, matching the runFlag rule, bounding the run's span to its logged-day count. --- src/lib/illness/__tests__/correlation.test.ts | 105 ++++++++++++++++++ src/lib/illness/correlation.ts | 25 ++++- 2 files changed, 128 insertions(+), 2 deletions(-) diff --git a/src/lib/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts index ce2032b3b..09ec71ea6 100644 --- a/src/lib/illness/__tests__/correlation.test.ts +++ b/src/lib/illness/__tests__/correlation.test.ts @@ -964,6 +964,80 @@ describe("computeIllnessCorrelation — symptom-burden return track", () => { expect(out.value.adverseCoverageDays).toBeGreaterThanOrEqual(2); }); + it("WITHHOLDS the symptom return when the in-band logs span calendar GAPS", () => { + // Three impact-0 logs exist, but on 01-16/01-23/01-30 — spread across two + // weeks, not three consecutive days. The stability run is CALENDAR-adjacent, + // so this sparse settle must NOT register as a "stable symptom return" and + // must NOT move the headline gap (parity with the vital `lastStableReturn` + // and `runFlag` calendar-adjacency rule). Without the fix the array-adjacent + // walk would stamp a return on 01-16 across a 14-day span. + const symptomBurden: SymptomBurdenPoint[] = [ + { day: "2026-01-10", impact: 3 }, + { day: "2026-01-12", impact: 2 }, + { day: "2026-01-16", impact: 0 }, + { day: "2026-01-23", impact: 0 }, + { day: "2026-01-30", impact: 0 }, + ]; + 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).toBeDefined(); + expect(sym?.returnedDay).toBeNull(); + expect(sym?.gapDays).toBeNull(); + expect(out.value.recoveryGapDays).toBeNull(); + expect(out.value.gapDriverType).toBeNull(); + }); + + it("DOES fold a symptom return when the in-band logs are calendar-consecutive", () => { + // The mirror: three impact-0 logs on truly adjacent days 01-16/01-17/01-18 + // → a genuine stable return on 01-16, gap +2. Confirms the calendar- + // adjacency requirement does not regress the honest dense-logging case. + const symptomBurden: SymptomBurdenPoint[] = [ + { day: "2026-01-10", impact: 3 }, + { day: "2026-01-12", impact: 2 }, + { day: "2026-01-16", impact: 0 }, + { day: "2026-01-17", impact: 0 }, + { day: "2026-01-18", impact: 0 }, + ]; + 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-16"); + expect(sym?.gapDays).toBe(dayDiff("2026-01-14", "2026-01-16")); + expect(out.value.recoveryGapDays).toBe(2); + expect(out.value.gapDriverType).toBe(FUNCTIONAL_IMPACT_RETURN_KEY); + }); + it("lets the symptom track win the driver over a co-returning vital", () => { // Both the symptom curve AND an adverse RHR spike return and produce gaps; // the functional-impact track must win `gapDriverType` on a tie (it is the @@ -1246,6 +1320,37 @@ describe("computeIllnessCorrelation — relapse-aware return (last sustained)", expect(out.value.returns[0]?.returnedDay).toBeNull(); expect(out.value.recoveryGapDays).toBeNull(); }); + + it("WITHHOLDS a vital return when the in-band settle spans calendar GAPS", () => { + // Three in-band readings exist after the deviation, but on 01-13/01-20/01-27 + // — a sparse settle across two weeks, not a sustained 3-day run. The settle + // walk is calendar-adjacent (parity with `runFlag`), so this must NOT count + // as a stable return and must not fabricate a gap. Array-adjacent walking + // would have stamped a return on 01-13. + 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: 74 }, + { day: "2026-01-13", mean: 55 }, // in-band, then a 7-day gap + { day: "2026-01-20", mean: 55 }, + { day: "2026-01-27", 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(); + }); }); describe("computeIllnessCorrelation — sleep-as-context observation", () => { diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index 363b3daa0..eea4a67d5 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -741,9 +741,19 @@ function lastStableReturn( 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) start--; + 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)) @@ -819,7 +829,18 @@ function computeSymptomReturn( if (tailOut < RETURN_STABILITY_DAYS) { const end = inBand.length - 1 - tailOut; let start = end; - while (start - 1 >= 0 && inBand[start - 1]) start--; + // 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; From db7306f9a50f2dfc76944edaa5fe6889d71724d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 13:22:02 +0200 Subject: [PATCH 2/2] fix(illness): sort the SpO2 red-flag day-points locally The SpO2 sustained-low scan passed episodeDays straight to runFlag, relying on the reader to deliver them in day order, while the fever path already sorts its unioned series. The run scan is calendar-consecutive, so feed it chronological input by construction: sort the filtered SpO2 points before the scan, matching the fever path and removing the latent ordering coupling. --- src/lib/illness/__tests__/correlation.test.ts | 60 +++++++++++++++++++ src/lib/illness/correlation.ts | 9 ++- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/lib/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts index 09ec71ea6..a2b31352e 100644 --- a/src/lib/illness/__tests__/correlation.test.ts +++ b/src/lib/illness/__tests__/correlation.test.ts @@ -513,6 +513,66 @@ describe("computeIllnessCorrelation — red-flag escalation", () => { expect(spo2Flag?.days).toBe(3); }); + it("escalates the same SpO2 run regardless of input order (order-independence)", () => { + // The SpO2 red-flag scan is calendar-consecutive and so depends on + // chronological input. It must sort defensively rather than trust an + // upstream sort — a shuffled `episodeDays` yields the SAME verdict as a + // sorted one (parity with the fever path's local sort). Days 01-10/01-11/ + // 01-12 are all ≤92 → a genuine 3-day run; the order they arrive in is noise. + const type: MeasurementType = "OXYGEN_SATURATION"; + const baselineDays = jitteredBaseline(98, 1, 21, "2026-01-02"); + const shuffled: VitalDayPoint[] = [ + { day: "2026-01-13", mean: 96 }, + { day: "2026-01-11", mean: 90 }, + { day: "2026-01-14", mean: 98 }, + { day: "2026-01-10", mean: 91 }, + { day: "2026-01-12", mean: 89 }, + ]; + const out = computeIllnessCorrelation( + input({ series: [{ type, baselineDays, episodeDays: shuffled }] }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + expect(out.value.redFlags).toHaveLength(1); + expect(out.value.redFlags[0].reason).toBe("sustained_low_spo2"); + expect(out.value.redFlags[0].worstValue).toBe(89); + expect(out.value.redFlags[0].days).toBe(3); + }); + + it("does NOT escalate a sparse low-SpO2 series with calendar GAPS (cries-wolf guard)", () => { + // Mirror of the fever gap guard for SpO2: three isolated low readings on + // 01-10/01-15/01-20 with multi-day gaps are NOT a sustained run. The scan + // compares calendar deltas, not array adjacency → longest run 1 → no flag. + // A banded RHR vital clears the episode-coverage floor. + const spo2: MeasurementType = "OXYGEN_SATURATION"; + const rhr: MeasurementType = "RESTING_HEART_RATE"; + const out = computeIllnessCorrelation( + input({ + series: [ + { + type: spo2, + baselineDays: jitteredBaseline(98, 1, 21, "2026-01-02"), + episodeDays: [ + { day: "2026-01-10", mean: 91 }, + { day: "2026-01-15", mean: 90 }, + { day: "2026-01-20", mean: 89 }, + ], + }, + { + 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; + expect( + out.value.redFlags.find((f) => f.reason === "sustained_low_spo2"), + ).toBeUndefined(); + }); + 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 diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index eea4a67d5..4cb561d16 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -932,8 +932,15 @@ function detectRedFlags(input: IllnessCorrelationInput): IllnessRedFlag[] { // 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( - spo2.episodeDays.filter(inActive), + spo2Points, (v) => v <= SPO2_RED_FLAG, "sustained_low_spo2", "OXYGEN_SATURATION",