fix(illness): tolerate a transient trailing reading in the recovery return

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-22 08:10:10 +02:00
1 parent c8ebdba43e
commit b31def38c0
2 files changed
+159 -34

No files matched your search

@@ -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
+60 -34
View File
@@ -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;
}
}
}