merge(ILLNESSFIX): calendar-adjacency symptom return + SpO2 local sort

This commit is contained in:
Marc-André Bombeck committed 2026-06-26 13:22:57 +02:00
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", () => {
+31 -3
View File
@@ -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",