mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(illness): require calendar-adjacency for the stable-return run
Both lastStableReturn and computeSymptomReturn counted the in-band settle run by array-index adjacency instead of calendar days, so three sparse logs spread over weeks could register a stable return and move the headline recovery gap. Extend the run only while the prior point is in-band and the calendar day immediately before, matching the runFlag rule, bounding the run's span to its logged-day count.
This commit is contained in:
1 parent
f493ff58cb
commit
f37d935e54
2 files changed
+128
-2
No files matched your search
@@ -964,6 +964,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 +1320,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;
|
||||
|
||||
Reference in new issue
Block a user