mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user