mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(illness): sort the SpO2 red-flag day-points locally
The SpO2 sustained-low scan passed episodeDays straight to runFlag, relying on the reader to deliver them in day order, while the fever path already sorts its unioned series. The run scan is calendar-consecutive, so feed it chronological input by construction: sort the filtered SpO2 points before the scan, matching the fever path and removing the latent ordering coupling.
This commit is contained in:
1 parent
f37d935e54
commit
db7306f9a5
2 files changed
+68
-1
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
|
||||
|
||||
@@ -932,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