mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
merge(ILLNESSFIX): calendar-adjacency symptom return + SpO2 local sort
This commit is contained in:
commit
5b22d69362
2 files changed
+196
-3
No files matched your search
@@ -513,6 +513,66 @@ describe("computeIllnessCorrelation — red-flag escalation", () => {
|
||||
expect(spo2Flag?.days).toBe(3);
|
||||
});
|
||||
|
||||
it("escalates the same SpO2 run regardless of input order (order-independence)", () => {
|
||||
// The SpO2 red-flag scan is calendar-consecutive and so depends on
|
||||
// chronological input. It must sort defensively rather than trust an
|
||||
// upstream sort — a shuffled `episodeDays` yields the SAME verdict as a
|
||||
// sorted one (parity with the fever path's local sort). Days 01-10/01-11/
|
||||
// 01-12 are all ≤92 → a genuine 3-day run; the order they arrive in is noise.
|
||||
const type: MeasurementType = "OXYGEN_SATURATION";
|
||||
const baselineDays = jitteredBaseline(98, 1, 21, "2026-01-02");
|
||||
const shuffled: VitalDayPoint[] = [
|
||||
{ day: "2026-01-13", mean: 96 },
|
||||
{ day: "2026-01-11", mean: 90 },
|
||||
{ day: "2026-01-14", mean: 98 },
|
||||
{ day: "2026-01-10", mean: 91 },
|
||||
{ day: "2026-01-12", mean: 89 },
|
||||
];
|
||||
const out = computeIllnessCorrelation(
|
||||
input({ series: [{ type, baselineDays, episodeDays: shuffled }] }),
|
||||
);
|
||||
expect(out.status).toBe("ok");
|
||||
if (out.status !== "ok") return;
|
||||
expect(out.value.redFlags).toHaveLength(1);
|
||||
expect(out.value.redFlags[0].reason).toBe("sustained_low_spo2");
|
||||
expect(out.value.redFlags[0].worstValue).toBe(89);
|
||||
expect(out.value.redFlags[0].days).toBe(3);
|
||||
});
|
||||
|
||||
it("does NOT escalate a sparse low-SpO2 series with calendar GAPS (cries-wolf guard)", () => {
|
||||
// Mirror of the fever gap guard for SpO2: three isolated low readings on
|
||||
// 01-10/01-15/01-20 with multi-day gaps are NOT a sustained run. The scan
|
||||
// compares calendar deltas, not array adjacency → longest run 1 → no flag.
|
||||
// A banded RHR vital clears the episode-coverage floor.
|
||||
const spo2: MeasurementType = "OXYGEN_SATURATION";
|
||||
const rhr: MeasurementType = "RESTING_HEART_RATE";
|
||||
const out = computeIllnessCorrelation(
|
||||
input({
|
||||
series: [
|
||||
{
|
||||
type: spo2,
|
||||
baselineDays: jitteredBaseline(98, 1, 21, "2026-01-02"),
|
||||
episodeDays: [
|
||||
{ day: "2026-01-10", mean: 91 },
|
||||
{ day: "2026-01-15", mean: 90 },
|
||||
{ day: "2026-01-20", mean: 89 },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: rhr,
|
||||
baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"),
|
||||
episodeDays: flatBaseline(55, 5, "2026-01-15"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(out.status).toBe("ok");
|
||||
if (out.status !== "ok") return;
|
||||
expect(
|
||||
out.value.redFlags.find((f) => f.reason === "sustained_low_spo2"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("escalates a sustained fever logged via the day-log feverC (no temp series)", () => {
|
||||
// No BODY_TEMPERATURE vital at all — the canonical journaling-fever path.
|
||||
// A banded RHR vital satisfies the coverage floor; the fever escalates
|
||||
@@ -964,6 +1024,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 +1380,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", () => {
|
||||
|
||||
@@ -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;
|
||||
@@ -911,8 +932,15 @@ function detectRedFlags(input: IllnessCorrelationInput): IllnessRedFlag[] {
|
||||
// it would HELP (SpO2's worst is the min, so means are conservative enough).
|
||||
const spo2 = input.series.find((s) => s.type === "OXYGEN_SATURATION");
|
||||
if (spo2) {
|
||||
// `runFlag` counts CALENDAR-consecutive runs, so it needs chronological
|
||||
// input. Sort locally rather than trusting an upstream sort invariant — the
|
||||
// fever path already sorts its unioned series, and this keeps the SpO2 scan
|
||||
// order-independent by construction if a future reader feeds unsorted days.
|
||||
const spo2Points = spo2.episodeDays
|
||||
.filter(inActive)
|
||||
.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
|
||||
const flag = runFlag(
|
||||
spo2.episodeDays.filter(inActive),
|
||||
spo2Points,
|
||||
(v) => v <= SPO2_RED_FLAG,
|
||||
"sustained_low_spo2",
|
||||
"OXYGEN_SATURATION",
|
||||
|
||||
Reference in new issue
Block a user