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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-26 13:21:45 +02:00
1 parent f493ff58cb
commit f37d935e54
2 files changed
+128 -2

No files matched your search

@@ -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", () => {
+23 -2
View File
@@ -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;