Merge illness correlation fixes into release/v1.20.0

Sort the fever red-flag points chronologically before the consecutive-run
scan so a dual-source episode (thermometer plus journaled fever) can no
longer falsely escalate or hide a sustained-fever flag. Treat a trailing
out-of-band tail shorter than the stability window as transient noise so
a settled vital keeps its recovery day; only a sustained re-deviation
clears it.
This commit is contained in:
Marc-André Bombeck committed 2026-06-22 08:11:05 +02:00
commit b5f0857a14
2 files changed
+254 -37

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
@@ -483,6 +582,93 @@ describe("computeIllnessCorrelation — red-flag escalation", () => {
expect(fever).toBeDefined();
expect(fever?.days).toBe(3);
});
it("does NOT mis-escalate when the thermometer and day-log fever interleave (false-positive guard)", () => {
// The fever red-flag unions two individually-sorted sources — the passive
// BODY_TEMPERATURE series and the day-log feverC — into a Map. Unless the
// unioned points are re-sorted by day, the run scan sees Map-insertion
// order, not chronology. Here the thermometer covers {01-10, 01-12} and the
// day-log covers {01-11, 01-13, 01-14}. True chronology
// 01-10..01-14 = 39,39,37,39,39 → the fever breaks on 01-12, so the longest
// run ≥38.5 is 2 → NO escalation. Read in insertion order (temp first:
// 39,37 then fever: 39,39,39) it would read as a spurious 3-day run.
const temp: MeasurementType = "BODY_TEMPERATURE";
const rhr: MeasurementType = "RESTING_HEART_RATE";
const out = computeIllnessCorrelation(
input({
series: [
{
type: temp,
baselineDays: jitteredBaseline(36.6, 0.2, 21, "2026-01-02"),
episodeDays: [
{ day: "2026-01-10", mean: 39.0 },
{ day: "2026-01-12", mean: 37.0 }, // fever broke here
],
},
{
type: rhr,
baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"),
episodeDays: flatBaseline(55, 5, "2026-01-15"),
},
],
dayLogFever: [
{ day: "2026-01-11", feverC: 39.0 },
{ day: "2026-01-13", feverC: 39.0 },
{ day: "2026-01-14", feverC: 39.0 },
],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
const fever = out.value.redFlags.find(
(f) => f.reason === "sustained_fever",
);
expect(fever).toBeUndefined();
});
it("escalates a genuine cross-source run that insertion order would hide (false-negative guard)", () => {
// The mirror case: a real consecutive 3-day fever run (01-11..01-13) spans
// both sources, but Map-insertion order scatters the days and breaks it.
// The thermometer covers {01-12 (fever), 01-10 (broke)} and the day-log
// covers {01-11, 01-13}. True chronology 01-10..01-13 = 37,39,39,39 → a
// real 3-day run ≥38.5. In Map-insertion order (temp first: 01-12=39,
// 01-10=37; then day-log: 01-11=39, 01-13=39) the 01-10 reset lands BETWEEN
// the fever days → longest run 2 → the genuine escalation is hidden. The
// explicit chronological sort is what recovers the streak.
const temp: MeasurementType = "BODY_TEMPERATURE";
const rhr: MeasurementType = "RESTING_HEART_RATE";
const out = computeIllnessCorrelation(
input({
series: [
{
type: temp,
baselineDays: jitteredBaseline(36.6, 0.2, 21, "2026-01-02"),
episodeDays: [
{ day: "2026-01-12", mean: 39.0 }, // fever, listed first
{ day: "2026-01-10", mean: 37.0 }, // broke, listed second
],
},
{
type: rhr,
baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"),
episodeDays: flatBaseline(55, 5, "2026-01-15"),
},
],
dayLogFever: [
{ day: "2026-01-11", feverC: 39.0 },
{ day: "2026-01-13", feverC: 39.0 },
],
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
const fever = out.value.redFlags.find(
(f) => f.reason === "sustained_fever",
);
expect(fever).toBeDefined();
expect(fever?.days).toBe(3);
expect(fever?.worstValue).toBe(39.0);
});
});
describe("isAdverseDeviation", () => {
+68 -37
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;
}
}
}
@@ -909,9 +935,14 @@ function detectRedFlags(input: IllnessCorrelationInput): IllnessRedFlag[] {
);
}
if (feverByDay.size > 0) {
const fevPoints: VitalDayPoint[] = [...feverByDay.entries()].map(
([day, mean]) => ({ day, mean }),
);
// `runFlag` counts CONSECUTIVE-day runs, so it needs chronological input.
// The Map unions two individually-sorted sources (passive temperature, then
// day-log feverC) in insertion order, NOT global day order — sort by day
// before the run scan or an interleaved dual-source episode mis-counts the
// run (a false sustained_fever escalation, or a real run hidden).
const fevPoints: VitalDayPoint[] = [...feverByDay.entries()]
.map(([day, mean]) => ({ day, mean }))
.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
const flag = runFlag(
fevPoints,
(v) => v >= FEVER_RED_FLAG,