mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(illness): report the last sustained recovery return, not the first
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.
This commit is contained in:
1 parent
461190e63e
commit
e432b53e0d
3 files changed
+317
-32
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>): 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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user