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;