mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Merge recovery-gap deepening into release/v1.20.0
Make the physiological return relapse-aware (the start of the final sustained in-band run, not the first), so a vital that relapsed and recovered again reports its true recovery. Add an optional sleep-context sub-line (baseline vs episode asleep-minutes), withheld on thin data and never feeding the gap median.
This commit is contained in:
commit
7859ca55b3
14 files changed
+879
-36
No files matched your search
@@ -6891,6 +6891,8 @@
|
||||
"recoveryGapLagSymptom": "Dir ging es {days} Tage besser, bevor deine erfassten Symptome abklangen.",
|
||||
"recoveryGapLeadSymptom": "Deine erfassten Symptome klangen {days} Tage ab, bevor du Genesung vermerktest.",
|
||||
"recoveryGapSame": "Deine Werte normalisierten sich am selben Tag, an dem es dir besser ging.",
|
||||
"sleepContextMore": "Während dieser Episode hast du deutlich mehr geschlafen als sonst (etwa {hours} Std./Nacht).",
|
||||
"sleepContextLess": "Während dieser Episode hast du deutlich weniger geschlafen als sonst (etwa {hours} Std./Nacht).",
|
||||
"redFlagTitle": "Genauer ansehen",
|
||||
"redFlagSpo2": "Deine Sauerstoffsättigung blieb mehrere Tage niedrig. Wenn das erneut auftritt, suche ärztliche Hilfe.",
|
||||
"redFlagFever": "Du hattest anhaltend Fieber. Wenn das erneut auftritt, suche ärztliche Hilfe.",
|
||||
|
||||
@@ -6891,6 +6891,8 @@
|
||||
"recoveryGapLagSymptom": "You felt better {days} days before your logged symptoms eased.",
|
||||
"recoveryGapLeadSymptom": "Your logged symptoms eased {days} days before you marked recovery.",
|
||||
"recoveryGapSame": "Your numbers settled the same day you felt better.",
|
||||
"sleepContextMore": "During this episode you slept noticeably more than usual (about {hours}h/night).",
|
||||
"sleepContextLess": "During this episode you slept noticeably less than usual (about {hours}h/night).",
|
||||
"redFlagTitle": "Worth a closer look",
|
||||
"redFlagSpo2": "Your oxygen saturation stayed low for several days. If this recurs, seek medical care.",
|
||||
"redFlagFever": "You ran a sustained fever. If this recurs, seek medical care.",
|
||||
|
||||
@@ -6891,6 +6891,8 @@
|
||||
"recoveryGapLagSymptom": "Te sentiste mejor {days} días antes de que cedieran tus síntomas registrados.",
|
||||
"recoveryGapLeadSymptom": "Tus síntomas registrados cedieron {days} días antes de marcar la recuperación.",
|
||||
"recoveryGapSame": "Tus valores se estabilizaron el mismo día en que te sentiste mejor.",
|
||||
"sleepContextMore": "Durante este episodio dormiste notablemente más de lo habitual (unas {hours} h/noche).",
|
||||
"sleepContextLess": "Durante este episodio dormiste notablemente menos de lo habitual (unas {hours} h/noche).",
|
||||
"redFlagTitle": "Conviene revisarlo",
|
||||
"redFlagSpo2": "Tu saturación de oxígeno se mantuvo baja varios días. Si se repite, busca atención médica.",
|
||||
"redFlagFever": "Tuviste fiebre sostenida. Si se repite, busca atención médica.",
|
||||
|
||||
@@ -6891,6 +6891,8 @@
|
||||
"recoveryGapLagSymptom": "Tu t'es senti mieux {days} jours avant que tes symptômes consignés s'apaisent.",
|
||||
"recoveryGapLeadSymptom": "Tes symptômes consignés se sont apaisés {days} jours avant que tu notes la guérison.",
|
||||
"recoveryGapSame": "Tes valeurs se sont stabilisées le jour même où tu t'es senti mieux.",
|
||||
"sleepContextMore": "Pendant cet épisode, tu as dormi nettement plus que d'habitude (environ {hours} h/nuit).",
|
||||
"sleepContextLess": "Pendant cet épisode, tu as dormi nettement moins que d'habitude (environ {hours} h/nuit).",
|
||||
"redFlagTitle": "À examiner de plus près",
|
||||
"redFlagSpo2": "Ta saturation en oxygène est restée basse plusieurs jours. Si cela se reproduit, consulte un médecin.",
|
||||
"redFlagFever": "Tu as eu une fièvre persistante. Si cela se reproduit, consulte un médecin.",
|
||||
|
||||
@@ -6891,6 +6891,8 @@
|
||||
"recoveryGapLagSymptom": "Ti sei sentito meglio {days} giorni prima che i sintomi registrati si attenuassero.",
|
||||
"recoveryGapLeadSymptom": "I tuoi sintomi registrati si sono attenuati {days} giorni prima di segnare la guarigione.",
|
||||
"recoveryGapSame": "I tuoi valori si sono stabilizzati lo stesso giorno in cui ti sei sentito meglio.",
|
||||
"sleepContextMore": "Durante questo episodio hai dormito molto più del solito (circa {hours} h/notte).",
|
||||
"sleepContextLess": "Durante questo episodio hai dormito molto meno del solito (circa {hours} h/notte).",
|
||||
"redFlagTitle": "Da guardare più da vicino",
|
||||
"redFlagSpo2": "La tua saturazione di ossigeno è rimasta bassa per diversi giorni. Se si ripete, rivolgiti a un medico.",
|
||||
"redFlagFever": "Hai avuto febbre persistente. Se si ripete, rivolgiti a un medico.",
|
||||
|
||||
@@ -6891,6 +6891,8 @@
|
||||
"recoveryGapLagSymptom": "Poczułeś się lepiej {days} dni przed ustąpieniem zapisanych objawów.",
|
||||
"recoveryGapLeadSymptom": "Twoje zapisane objawy ustąpiły {days} dni przed oznaczeniem wyzdrowienia.",
|
||||
"recoveryGapSame": "Twoje wartości ustabilizowały się tego samego dnia, gdy poczułeś się lepiej.",
|
||||
"sleepContextMore": "Podczas tego epizodu spałeś zauważalnie więcej niż zwykle (około {hours} godz./noc).",
|
||||
"sleepContextLess": "Podczas tego epizodu spałeś zauważalnie mniej niż zwykle (około {hours} godz./noc).",
|
||||
"redFlagTitle": "Warto przyjrzeć się bliżej",
|
||||
"redFlagSpo2": "Twoje wysycenie tlenem pozostawało niskie przez kilka dni. Jeśli się powtórzy, zasięgnij pomocy medycznej.",
|
||||
"redFlagFever": "Miałeś utrzymującą się gorączkę. Jeśli się powtórzy, zasięgnij pomocy medycznej.",
|
||||
|
||||
@@ -41,6 +41,11 @@ function DeviationRow({ d }: { d: IllnessVitalDeviation }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Round asleep-minutes to a friendly hours figure (one decimal). */
|
||||
function hoursFromMinutes(minutes: number): string {
|
||||
return (Math.round((minutes / 60) * 10) / 10).toString();
|
||||
}
|
||||
|
||||
function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
|
||||
const { t } = useTranslations();
|
||||
const gap = value.recoveryGapDays;
|
||||
@@ -48,6 +53,9 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
|
||||
// the symptoms ("…before your symptoms eased"); otherwise the existing
|
||||
// vital-driven phrasing. Clinically calm — observation, never prediction.
|
||||
const symptomDriven = value.gapDriverType === "FUNCTIONAL_IMPACT";
|
||||
// Sleep-as-context: a neutral observation subordinate to the gap, withheld by
|
||||
// the engine on thin data — never a recovery claim, never a second gap.
|
||||
const sleep = value.sleepContext;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -90,6 +98,16 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
|
||||
)
|
||||
: t("illness.correlation.recoveryGapSame")}
|
||||
</p>
|
||||
{sleep ? (
|
||||
<p className="text-muted-foreground mt-1.5 text-xs">
|
||||
{t(
|
||||
sleep.deltaMinutes > 0
|
||||
? "illness.correlation.sleepContextMore"
|
||||
: "illness.correlation.sleepContextLess",
|
||||
{ hours: hoursFromMinutes(Math.abs(sleep.deltaMinutes)) },
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -111,6 +111,19 @@ export interface IllnessRedFlag {
|
||||
days: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A neutral sleep-as-context observation for the episode — slept more / less
|
||||
* than usual — surfaced ALONGSIDE the gap, never part of the recovery-gap
|
||||
* number. Null when the sleep data is too thin or the delta sub-floor.
|
||||
*/
|
||||
export interface SleepContext {
|
||||
baselineMeanMinutes: number;
|
||||
episodeMeanMinutes: number;
|
||||
/** `episode − baseline` asleep minutes (positive = slept more than usual). */
|
||||
deltaMinutes: number;
|
||||
nightsCounted: number;
|
||||
}
|
||||
|
||||
export interface IllnessCorrelationValue {
|
||||
episodeId: string;
|
||||
preOnset: IllnessVitalDeviation[];
|
||||
@@ -126,6 +139,11 @@ export interface IllnessCorrelationValue {
|
||||
*/
|
||||
gapDriverType: string | null;
|
||||
redFlags: IllnessRedFlag[];
|
||||
/**
|
||||
* Sleep-as-context observation, or null when withheld (thin data / sub-floor
|
||||
* delta). Additive — never part of the headline recovery-gap.
|
||||
*/
|
||||
sleepContext: SleepContext | null;
|
||||
}
|
||||
|
||||
/** The flat `Derived<T>` wire shape the correlation route returns. */
|
||||
|
||||
@@ -65,6 +65,9 @@ vi.mock("@/lib/rollups/measurement-coverage", () => ({
|
||||
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
|
||||
readBestGranularityRollups: rollup.readBestGranularityRollups,
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-read", () => ({
|
||||
loadUserSourcePriority: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
import { computeEpisodeCorrelation } from "../correlation-read";
|
||||
import { ILLNESS_SCAN_TYPES } from "../correlation";
|
||||
@@ -302,3 +305,120 @@ describe("computeEpisodeCorrelation — contamination-guard read window", () =>
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeEpisodeCorrelation — sleep-as-context read", () => {
|
||||
it("reconstructs baseline + episode nights and surfaces the observation, deduping multi-source nights", async () => {
|
||||
rollup.probeRollupCoverage.mockResolvedValue(
|
||||
new Map<string, boolean>(
|
||||
ILLNESS_SCAN_TYPES.map((t) => [String(t), false]),
|
||||
),
|
||||
);
|
||||
rollup.readBestGranularityRollups.mockResolvedValue(null);
|
||||
|
||||
// A banded RHR vital (flat-ish at 55) clears the engine coverage floor.
|
||||
const rhrRows: Array<{ value: number; measuredAt: Date }> = [];
|
||||
{
|
||||
let cursor = Date.parse("2025-12-05T12:00:00Z");
|
||||
const jit = [-2, -1, 0, 1, 2];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
rhrRows.push({
|
||||
value: 55 + jit[i % jit.length],
|
||||
measuredAt: new Date(cursor),
|
||||
});
|
||||
cursor += 24 * 60 * 60 * 1000;
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep rows. Baseline nights ~420 min (7h); episode nights ~510 min
|
||||
// (8.5h) → +90 min. The episode 2026-01-11 night is DOUBLE-WRITTEN by two
|
||||
// sources (WHOOP + Apple Health); the canonical reconstruction must count
|
||||
// it ONCE, not sum it to ~1020 min and inflate the delta.
|
||||
// A "night" is one asleep block whose wake-day is the local calendar day.
|
||||
type SleepRow = {
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
sleepStage: null;
|
||||
source: string;
|
||||
deviceType: string | null;
|
||||
};
|
||||
const sleepRows: SleepRow[] = [];
|
||||
const asleepNight = (
|
||||
wakeDay: string,
|
||||
minutes: number,
|
||||
source: string,
|
||||
): SleepRow => ({
|
||||
// The block ENDS at 07:00Z on the wake-day; one ASLEEP sample of `minutes`.
|
||||
value: minutes,
|
||||
measuredAt: new Date(`${wakeDay}T07:00:00Z`),
|
||||
sleepStage: null,
|
||||
source,
|
||||
deviceType: source,
|
||||
});
|
||||
// Baseline nights (well before the pre-onset lookback start 2026-01-03).
|
||||
for (const day of [
|
||||
"2025-12-20",
|
||||
"2025-12-21",
|
||||
"2025-12-22",
|
||||
"2025-12-23",
|
||||
"2025-12-24",
|
||||
"2025-12-25",
|
||||
]) {
|
||||
sleepRows.push(asleepNight(day, 420, "WHOOP"));
|
||||
}
|
||||
// Episode nights (active span ≥ onset 2026-01-10).
|
||||
sleepRows.push(asleepNight("2026-01-10", 510, "WHOOP"));
|
||||
// Double-written night — same wake-day, two writers.
|
||||
sleepRows.push(asleepNight("2026-01-11", 510, "WHOOP"));
|
||||
sleepRows.push(asleepNight("2026-01-11", 510, "APPLE_HEALTH"));
|
||||
sleepRows.push(asleepNight("2026-01-12", 510, "WHOOP"));
|
||||
|
||||
db.measurement.findMany.mockImplementation(
|
||||
async ({
|
||||
where,
|
||||
}: {
|
||||
where: {
|
||||
type: string;
|
||||
measuredAt: { gte: Date; lt?: Date; lte?: Date };
|
||||
};
|
||||
}) => {
|
||||
const from = where.measuredAt.gte.getTime();
|
||||
const to =
|
||||
where.measuredAt.lt?.getTime() ??
|
||||
where.measuredAt.lte?.getTime() ??
|
||||
NOW.getTime();
|
||||
if (where.type === "RESTING_HEART_RATE") {
|
||||
return rhrRows.filter(
|
||||
(r) =>
|
||||
r.measuredAt.getTime() >= from && r.measuredAt.getTime() <= to,
|
||||
);
|
||||
}
|
||||
if (where.type === "SLEEP_DURATION") {
|
||||
return sleepRows.filter(
|
||||
(r) =>
|
||||
r.measuredAt.getTime() >= from && r.measuredAt.getTime() <= to,
|
||||
);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
);
|
||||
|
||||
const episode = {
|
||||
id: "ep-sleep",
|
||||
onsetAt: new Date("2026-01-10T08:00:00Z"),
|
||||
resolvedAt: null as Date | null,
|
||||
lifecycle: "ACUTE",
|
||||
};
|
||||
|
||||
const out = await computeEpisodeCorrelation("u1", episode, "UTC", NOW);
|
||||
expect(out.status).toBe("ok");
|
||||
if (out.status !== "ok") return;
|
||||
const sleep = out.value.sleepContext;
|
||||
expect(sleep).not.toBeNull();
|
||||
expect(sleep?.baselineMeanMinutes).toBe(420);
|
||||
// The double-written 01-11 night is counted ONCE → episode mean ≈ 510, not
|
||||
// a doubled ~680 that summing two writers would produce.
|
||||
expect(sleep?.episodeMeanMinutes).toBe(510);
|
||||
expect(sleep?.deltaMinutes).toBe(90);
|
||||
expect(sleep?.nightsCounted).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
isAdverseDeviation,
|
||||
MIN_BASELINE_DAYS,
|
||||
MIN_EPISODE_COVERAGE_DAYS,
|
||||
SLEEP_CONTEXT_MIN_DELTA_MINUTES,
|
||||
type IllnessCorrelationInput,
|
||||
type SleepNightPoint,
|
||||
type SymptomBurdenPoint,
|
||||
type VitalDayPoint,
|
||||
} from "../correlation";
|
||||
@@ -810,3 +812,343 @@ describe("computeIllnessCorrelation — symptom-burden return track", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeIllnessCorrelation — relapse-aware return (last sustained)", () => {
|
||||
it("reports the LAST sustained return when the vital relapses mid-episode", () => {
|
||||
// Deviate 01-10, settle in-band 01-13..01-15 (a 3-day run that the OLD
|
||||
// first-return rule would have called the return), re-deviate 01-16..01-18,
|
||||
// then settle again 01-20..01-22 and hold. felt-better 01-14. The honest
|
||||
// answer is the SECOND settle (01-20), not the first — the relapse pushes
|
||||
// the return later and extends the gap.
|
||||
const type: MeasurementType = "RESTING_HEART_RATE";
|
||||
const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02");
|
||||
const episodeDays: VitalDayPoint[] = [
|
||||
{ day: "2026-01-10", mean: 75 }, // first deviation
|
||||
{ day: "2026-01-12", mean: 74 },
|
||||
{ day: "2026-01-13", mean: 55 }, // first settle (old return)
|
||||
{ day: "2026-01-14", mean: 55 },
|
||||
{ day: "2026-01-15", mean: 55 }, // 3-day in-band run
|
||||
{ day: "2026-01-16", mean: 75 }, // RELAPSE
|
||||
{ day: "2026-01-17", mean: 74 },
|
||||
{ day: "2026-01-18", mean: 73 },
|
||||
{ day: "2026-01-20", mean: 55 }, // final settle start
|
||||
{ day: "2026-01-21", mean: 55 },
|
||||
{ day: "2026-01-22", mean: 55 }, // holds to end
|
||||
];
|
||||
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-20");
|
||||
// Gap = 01-20 − 01-14 = +6 (the old first-return would have given −1).
|
||||
expect(out.value.recoveryGapDays).toBe(dayDiff("2026-01-14", "2026-01-20"));
|
||||
expect(out.value.recoveryGapDays).toBe(6);
|
||||
});
|
||||
|
||||
it("reports the LAST sustained symptom return when the curve relapses", () => {
|
||||
// Parity with the vital track on the symptom curve: impact eases to 0 for 3
|
||||
// logged days, flares back to 2, eases to 0 again for 3 logged days → the
|
||||
// return is the SECOND easing, not the first.
|
||||
const rhr: MeasurementType = "RESTING_HEART_RATE";
|
||||
const rhrBaseline = jitteredBaseline(55, 1, 21, "2026-01-02");
|
||||
const rhrFlat: VitalDayPoint[] = [
|
||||
{ day: "2026-01-10", mean: 55 },
|
||||
{ day: "2026-01-11", mean: 55 },
|
||||
{ day: "2026-01-12", mean: 55 },
|
||||
{ day: "2026-01-13", mean: 55 },
|
||||
{ day: "2026-01-14", mean: 55 },
|
||||
];
|
||||
const symptomBurden: SymptomBurdenPoint[] = [
|
||||
{ day: "2026-01-10", impact: 3 },
|
||||
{ day: "2026-01-12", impact: 2 },
|
||||
{ day: "2026-01-13", impact: 0 }, // first easing
|
||||
{ day: "2026-01-14", impact: 0 },
|
||||
{ day: "2026-01-15", impact: 0 }, // 3 logged in-band days
|
||||
{ day: "2026-01-16", impact: 2 }, // FLARE
|
||||
{ day: "2026-01-18", impact: 0 }, // final easing start
|
||||
{ day: "2026-01-19", impact: 0 },
|
||||
{ day: "2026-01-20", impact: 0 }, // 3 logged in-band days, holds
|
||||
];
|
||||
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-18");
|
||||
expect(sym?.gapDays).toBe(dayDiff("2026-01-14", "2026-01-18"));
|
||||
expect(out.value.recoveryGapDays).toBe(4);
|
||||
});
|
||||
|
||||
it("withholds when the final in-band run is too short after a relapse", () => {
|
||||
// Deviate, settle, relapse, then only 2 in-band days to the window end —
|
||||
// the final run is shorter than RETURN_STABILITY_DAYS, so no return is
|
||||
// fabricated off the too-short tail.
|
||||
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: 55 }, // settle
|
||||
{ day: "2026-01-13", mean: 55 },
|
||||
{ day: "2026-01-14", mean: 55 }, // 3-day run
|
||||
{ day: "2026-01-16", mean: 75 }, // RELAPSE
|
||||
{ day: "2026-01-17", mean: 74 },
|
||||
{ day: "2026-01-19", mean: 55 }, // only 2 in-band days to end
|
||||
{ day: "2026-01-20", 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();
|
||||
});
|
||||
|
||||
it("picks the FINAL settle across multiple relapses", () => {
|
||||
// settle → flare → settle → flare → settle: the last contiguous in-band
|
||||
// span wins regardless of how many intervening cycles.
|
||||
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-11", mean: 55 }, // settle 1
|
||||
{ day: "2026-01-12", mean: 55 },
|
||||
{ day: "2026-01-13", mean: 55 },
|
||||
{ day: "2026-01-14", mean: 75 }, // flare 1
|
||||
{ day: "2026-01-15", mean: 55 }, // settle 2
|
||||
{ day: "2026-01-16", mean: 55 },
|
||||
{ day: "2026-01-17", mean: 55 },
|
||||
{ day: "2026-01-18", mean: 75 }, // flare 2
|
||||
{ day: "2026-01-20", mean: 55 }, // FINAL settle start
|
||||
{ day: "2026-01-21", mean: 55 },
|
||||
{ day: "2026-01-22", mean: 55 }, // holds to end
|
||||
];
|
||||
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-20");
|
||||
expect(out.value.recoveryGapDays).toBe(6);
|
||||
});
|
||||
|
||||
it("returns null when the episode ends out-of-band after a relapse", () => {
|
||||
// Deviate, settle, relapse, never resolve (ends elevated) → no sustained
|
||||
// final return → null gap. Honest: it did not finally settle in-window.
|
||||
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: 55 }, // settle
|
||||
{ day: "2026-01-13", mean: 55 },
|
||||
{ day: "2026-01-14", mean: 55 }, // 3-day run
|
||||
{ day: "2026-01-16", mean: 75 }, // RELAPSE
|
||||
{ day: "2026-01-18", mean: 74 },
|
||||
{ day: "2026-01-20", mean: 73 }, // still elevated at the end
|
||||
];
|
||||
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", () => {
|
||||
// A banded RHR vital clears the engine coverage floor in every case; the
|
||||
// sleep context is computed independently from the night arrays.
|
||||
const rhr: MeasurementType = "RESTING_HEART_RATE";
|
||||
const rhrBaseline = jitteredBaseline(55, 1, 21, "2026-01-02");
|
||||
const rhrFlat = flatBaseline(55, 5, "2026-01-15");
|
||||
|
||||
/** N baseline nights at `minutes`, ending the day before onset. */
|
||||
function nights(
|
||||
minutes: number,
|
||||
count: number,
|
||||
endDay: string,
|
||||
): SleepNightPoint[] {
|
||||
const out: SleepNightPoint[] = [];
|
||||
let cursor = Date.parse(`${endDay}T00:00:00Z`);
|
||||
for (let i = 0; i < count; i++) {
|
||||
out.unshift({
|
||||
day: new Date(cursor).toISOString().slice(0, 10),
|
||||
asleepMinutes: minutes,
|
||||
});
|
||||
cursor -= 24 * 60 * 60 * 1000;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
it("surfaces a slept-more observation when the episode delta clears the floor", () => {
|
||||
// Baseline ~420 min (7h), episode ~510 min (8.5h) → +90 min, well past the
|
||||
// 30-min floor. Episode nights are inside the active span.
|
||||
const out = computeIllnessCorrelation(
|
||||
input({
|
||||
series: [
|
||||
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
|
||||
],
|
||||
baselineNights: nights(420, 6, "2026-01-02"),
|
||||
episodeNights: [
|
||||
{ day: "2026-01-10", asleepMinutes: 510 },
|
||||
{ day: "2026-01-11", asleepMinutes: 505 },
|
||||
{ day: "2026-01-12", asleepMinutes: 515 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(out.status).toBe("ok");
|
||||
if (out.status !== "ok") return;
|
||||
expect(out.value.sleepContext).not.toBeNull();
|
||||
expect(out.value.sleepContext?.baselineMeanMinutes).toBe(420);
|
||||
expect(out.value.sleepContext?.deltaMinutes).toBeGreaterThanOrEqual(
|
||||
SLEEP_CONTEXT_MIN_DELTA_MINUTES,
|
||||
);
|
||||
expect(out.value.sleepContext?.nightsCounted).toBe(3);
|
||||
// It never touches the recovery-gap.
|
||||
expect(out.value.recoveryGapDays).toBeNull();
|
||||
});
|
||||
|
||||
it("withholds when the delta is below the magnitude floor", () => {
|
||||
// Baseline 420, episode 430 → +10 min, sub-floor jitter → no observation.
|
||||
const out = computeIllnessCorrelation(
|
||||
input({
|
||||
series: [
|
||||
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
|
||||
],
|
||||
baselineNights: nights(420, 6, "2026-01-02"),
|
||||
episodeNights: [
|
||||
{ day: "2026-01-10", asleepMinutes: 430 },
|
||||
{ day: "2026-01-11", asleepMinutes: 425 },
|
||||
{ day: "2026-01-12", asleepMinutes: 435 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(out.status).toBe("ok");
|
||||
if (out.status !== "ok") return;
|
||||
expect(out.value.sleepContext).toBeNull();
|
||||
});
|
||||
|
||||
it("withholds when too few episode nights are scorable", () => {
|
||||
const out = computeIllnessCorrelation(
|
||||
input({
|
||||
series: [
|
||||
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
|
||||
],
|
||||
baselineNights: nights(420, 6, "2026-01-02"),
|
||||
episodeNights: [
|
||||
{ day: "2026-01-10", asleepMinutes: 520 },
|
||||
{ day: "2026-01-11", asleepMinutes: 525 }, // only 2 episode nights
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(out.status).toBe("ok");
|
||||
if (out.status !== "ok") return;
|
||||
expect(out.value.sleepContext).toBeNull();
|
||||
});
|
||||
|
||||
it("withholds when the baseline night count is too thin", () => {
|
||||
const out = computeIllnessCorrelation(
|
||||
input({
|
||||
series: [
|
||||
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
|
||||
],
|
||||
baselineNights: nights(420, 3, "2026-01-02"), // only 3 baseline nights
|
||||
episodeNights: [
|
||||
{ day: "2026-01-10", asleepMinutes: 520 },
|
||||
{ day: "2026-01-11", asleepMinutes: 525 },
|
||||
{ day: "2026-01-12", asleepMinutes: 515 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(out.status).toBe("ok");
|
||||
if (out.status !== "ok") return;
|
||||
expect(out.value.sleepContext).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces a slept-less observation with a negative delta", () => {
|
||||
const out = computeIllnessCorrelation(
|
||||
input({
|
||||
series: [
|
||||
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
|
||||
],
|
||||
baselineNights: nights(480, 6, "2026-01-02"),
|
||||
episodeNights: [
|
||||
{ day: "2026-01-10", asleepMinutes: 360 },
|
||||
{ day: "2026-01-11", asleepMinutes: 370 },
|
||||
{ day: "2026-01-12", asleepMinutes: 350 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(out.status).toBe("ok");
|
||||
if (out.status !== "ok") return;
|
||||
expect(out.value.sleepContext?.deltaMinutes).toBeLessThanOrEqual(
|
||||
-SLEEP_CONTEXT_MIN_DELTA_MINUTES,
|
||||
);
|
||||
});
|
||||
|
||||
it("excludes pre-onset nights from the episode mean", () => {
|
||||
// A pre-onset night (before onsetDay 2026-01-10) must not count toward the
|
||||
// episode mean — only the active-span nights do.
|
||||
const out = computeIllnessCorrelation(
|
||||
input({
|
||||
series: [
|
||||
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
|
||||
],
|
||||
baselineNights: nights(420, 6, "2026-01-02"),
|
||||
episodeNights: [
|
||||
{ day: "2026-01-08", asleepMinutes: 999 }, // pre-onset — must be dropped
|
||||
{ day: "2026-01-10", asleepMinutes: 510 },
|
||||
{ day: "2026-01-11", asleepMinutes: 510 },
|
||||
{ day: "2026-01-12", asleepMinutes: 510 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(out.status).toBe("ok");
|
||||
if (out.status !== "ok") return;
|
||||
expect(out.value.sleepContext?.episodeMeanMinutes).toBe(510);
|
||||
expect(out.value.sleepContext?.nightsCounted).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,30 @@ import {
|
||||
MIN_TYPICAL_GAP_MAGNITUDE_DAYS,
|
||||
type RetrospectiveEpisode,
|
||||
} from "../retrospective";
|
||||
import { computeIllnessCorrelation, type VitalDayPoint } from "../correlation";
|
||||
|
||||
/** A baseline with a non-zero, stable MAD (mirrors the correlation suite). */
|
||||
function jitteredBaseline(
|
||||
center: number,
|
||||
jitter: number,
|
||||
days: number,
|
||||
endDay: string,
|
||||
): VitalDayPoint[] {
|
||||
const out: VitalDayPoint[] = [];
|
||||
let cursor = Date.parse(`${endDay}T00:00:00Z`);
|
||||
const offsets = [-jitter, -jitter / 2, 0, jitter / 2, jitter];
|
||||
for (let i = 0; i < days; i++) {
|
||||
out.unshift({
|
||||
day: new Date(cursor).toISOString().slice(0, 10),
|
||||
mean: center,
|
||||
});
|
||||
cursor -= 24 * 60 * 60 * 1000;
|
||||
}
|
||||
return out.map((p, i) => ({
|
||||
...p,
|
||||
mean: center + offsets[i % offsets.length],
|
||||
}));
|
||||
}
|
||||
|
||||
function ep(over: Partial<RetrospectiveEpisode>): RetrospectiveEpisode {
|
||||
return {
|
||||
@@ -207,4 +231,68 @@ describe("summarizeIllnessRetrospective", () => {
|
||||
]);
|
||||
expect(out.typicalRecoveryGapDays).toBe(-3);
|
||||
});
|
||||
|
||||
it("threads the larger last-return gap through the cross-episode median", () => {
|
||||
// Integration guard for the relapse-aware (last-sustained) return change:
|
||||
// one episode relapses mid-course, so its ENGINE-computed gap grows from
|
||||
// the old first-return value (−1) to the last-return value (+6). The
|
||||
// retrospective consumes that engine number verbatim — confirm the larger
|
||||
// value pulls the cross-episode median up rather than the stale first
|
||||
// return. No retrospective code changes; this guards the integration.
|
||||
const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02");
|
||||
const relapseEpisode: VitalDayPoint[] = [
|
||||
{ day: "2026-01-10", mean: 75 }, // first deviation
|
||||
{ day: "2026-01-13", mean: 55 }, // first settle (old return)
|
||||
{ day: "2026-01-14", mean: 55 },
|
||||
{ day: "2026-01-15", mean: 55 }, // 3-day run
|
||||
{ day: "2026-01-16", mean: 75 }, // RELAPSE
|
||||
{ day: "2026-01-18", mean: 73 },
|
||||
{ day: "2026-01-20", mean: 55 }, // final settle start
|
||||
{ day: "2026-01-21", mean: 55 },
|
||||
{ day: "2026-01-22", mean: 55 }, // holds to end
|
||||
];
|
||||
const engine = computeIllnessCorrelation({
|
||||
episodeId: "relapse",
|
||||
window: {
|
||||
onsetDay: "2026-01-10",
|
||||
feltBetterDay: "2026-01-14",
|
||||
lifecycle: "ACUTE",
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: "RESTING_HEART_RATE",
|
||||
baselineDays,
|
||||
episodeDays: relapseEpisode,
|
||||
},
|
||||
],
|
||||
source: "DAY",
|
||||
now: new Date("2026-02-01T00:00:00Z"),
|
||||
});
|
||||
expect(engine.status).toBe("ok");
|
||||
if (engine.status !== "ok") return;
|
||||
// Last-return = 01-20, gap = +6 (not the old first-return −1).
|
||||
expect(engine.value.recoveryGapDays).toBe(6);
|
||||
|
||||
const out = summarizeIllnessRetrospective([
|
||||
ep({
|
||||
id: "relapse",
|
||||
recoveryGapDays: engine.value.recoveryGapDays,
|
||||
gapMeasurementDays: engine.coverage.historyDays,
|
||||
gapReturnTypes: ["RESTING_HEART_RATE"],
|
||||
}),
|
||||
ep({
|
||||
id: "b",
|
||||
recoveryGapDays: 4,
|
||||
gapReturnTypes: ["RESTING_HEART_RATE"],
|
||||
}),
|
||||
ep({
|
||||
id: "c",
|
||||
recoveryGapDays: 5,
|
||||
gapReturnTypes: ["RESTING_HEART_RATE"],
|
||||
}),
|
||||
]);
|
||||
// Median of [6, 4, 5] = 5 — the relapse episode's grown gap is in the mix.
|
||||
expect(out.typicalRecoveryGapDays).toBe(5);
|
||||
expect(out.gapDriverType).toBe("RESTING_HEART_RATE");
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,8 @@ import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
|
||||
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
|
||||
import { resolveUserTimezone } from "@/lib/measurements/consolidation-base";
|
||||
import { reconstructNights } from "@/lib/insights/derived/sleep-score";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import type { Derived } from "@/lib/insights/derived/types";
|
||||
import {
|
||||
BASELINE_WINDOW_DAYS,
|
||||
@@ -39,6 +41,7 @@ import {
|
||||
computeIllnessCorrelation,
|
||||
type DayLogFeverPoint,
|
||||
type IllnessCorrelationValue,
|
||||
type SleepNightPoint,
|
||||
type SymptomBurdenPoint,
|
||||
type VitalSeries,
|
||||
} from "./correlation";
|
||||
@@ -96,15 +99,20 @@ export async function computeEpisodeCorrelation(
|
||||
: null;
|
||||
|
||||
// One coverage probe shared across every vital read (pool-contention
|
||||
// mitigation — the same pattern the derived route uses).
|
||||
const coverage = await probeRollupCoverage(userId);
|
||||
// mitigation — the same pattern the derived route uses). The source priority
|
||||
// feeds the canonical sleep-night writer-dedup (same ladder every sleep
|
||||
// surface uses), resolved once.
|
||||
const [coverage, sleepPriorityJson] = await Promise.all([
|
||||
probeRollupCoverage(userId),
|
||||
loadUserSourcePriority(userId),
|
||||
]);
|
||||
|
||||
// Attempt every scan type under a bounded concurrency cap. The two
|
||||
// windows per type (baseline + episode) are independent reads, so they
|
||||
// run paired rather than serially (halving the per-type round-trips).
|
||||
// The day-log fever read shares the same fan-out budget.
|
||||
const limit = pLimit(VITAL_SCAN_CONCURRENCY);
|
||||
const [perType, dayLogFever, symptomBurden] = await Promise.all([
|
||||
const [perType, dayLogFever, symptomBurden, sleepNights] = await Promise.all([
|
||||
Promise.all(
|
||||
ILLNESS_SCAN_TYPES.map((type) =>
|
||||
limit(async () => {
|
||||
@@ -134,6 +142,14 @@ export async function computeEpisodeCorrelation(
|
||||
),
|
||||
limit(() => readDayLogFever(episode.id, preOnsetStart, end, tz)),
|
||||
limit(() => readDayLogSymptomBurden(episode.id, preOnsetStart, end, tz)),
|
||||
limit(() =>
|
||||
readEpisodeSleepNights(
|
||||
userId,
|
||||
{ baselineStart, baselineEnd, preOnsetStart, end },
|
||||
tz,
|
||||
sleepPriorityJson,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const series: VitalSeries[] = [];
|
||||
@@ -170,6 +186,8 @@ export async function computeEpisodeCorrelation(
|
||||
series,
|
||||
dayLogFever,
|
||||
symptomBurden,
|
||||
baselineNights: sleepNights.baselineNights,
|
||||
episodeNights: sleepNights.episodeNights,
|
||||
source,
|
||||
now,
|
||||
});
|
||||
@@ -331,3 +349,75 @@ async function readDayLogSymptomBurden(
|
||||
}
|
||||
return points.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct per-night asleep totals over the well baseline window AND the
|
||||
* episode window for the sleep-as-context observation. Reuses the canonical
|
||||
* `reconstructNights` engine wholesale (session-clustering, local-wake-day
|
||||
* keying, multi-source writer-dedup — a WHOOP + Apple Health night is counted
|
||||
* ONCE) so the figures match every other sleep surface. The night's `night`
|
||||
* key is ALREADY the local wake-day string, so it joins the engine's
|
||||
* onset/feltBetter day-space directly — no extra tz conversion.
|
||||
*
|
||||
* Two windows mirror the vital reads' contamination-guard seam:
|
||||
* baselineNights : [baselineStart, baselineEnd) → the user's well baseline
|
||||
* episodeNights : [preOnsetStart, end] → the episode span
|
||||
*
|
||||
* Each is a single bounded `findMany` over the raw per-stage SLEEP_DURATION
|
||||
* rows; the engine withholds the observation entirely on thin data.
|
||||
*/
|
||||
async function readEpisodeSleepNights(
|
||||
userId: string,
|
||||
windows: {
|
||||
baselineStart: Date;
|
||||
baselineEnd: Date;
|
||||
preOnsetStart: Date;
|
||||
end: Date;
|
||||
},
|
||||
tz: string,
|
||||
priorityJson: unknown,
|
||||
): Promise<{
|
||||
baselineNights: SleepNightPoint[];
|
||||
episodeNights: SleepNightPoint[];
|
||||
}> {
|
||||
const select = {
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
deviceType: true,
|
||||
} as const;
|
||||
|
||||
const [baselineRows, episodeRows] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "SLEEP_DURATION" satisfies MeasurementType,
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: windows.baselineStart, lt: windows.baselineEnd },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select,
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "SLEEP_DURATION" satisfies MeasurementType,
|
||||
deletedAt: null,
|
||||
measuredAt: { gte: windows.preOnsetStart, lte: windows.end },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select,
|
||||
}),
|
||||
]);
|
||||
|
||||
const toPoints = (rows: typeof baselineRows): SleepNightPoint[] =>
|
||||
reconstructNights(rows, tz, priorityJson)
|
||||
.filter((n) => n.asleepMinutes > 0)
|
||||
.map((n) => ({ day: n.night, asleepMinutes: n.asleepMinutes }));
|
||||
|
||||
return {
|
||||
baselineNights: toPoints(baselineRows),
|
||||
episodeNights: toPoints(episodeRows),
|
||||
};
|
||||
}
|
||||
+173
-32
@@ -83,6 +83,20 @@ export const FUNCTIONAL_IMPACT_RETURN_KEY = "FUNCTIONAL_IMPACT" as const;
|
||||
export const FUNCTIONAL_IMPACT_ADVERSE_FLOOR = 1;
|
||||
/** Minimum episode days with ANY vital coverage before the gap is asserted. */
|
||||
export const MIN_EPISODE_COVERAGE_DAYS = 4;
|
||||
/**
|
||||
* Minimum scorable baseline nights before the sleep-context observation is
|
||||
* trusted (mirrors the sleep-debt engine's `minNights` floor). Below this the
|
||||
* "usual" asleep figure is too fragile to compare against.
|
||||
*/
|
||||
export const SLEEP_CONTEXT_MIN_BASELINE_NIGHTS = 4;
|
||||
/** Minimum scorable episode nights before the sleep-context line surfaces. */
|
||||
export const SLEEP_CONTEXT_MIN_EPISODE_NIGHTS = 3;
|
||||
/**
|
||||
* Minimum |episode − baseline| asleep-minutes delta before the sleep-context
|
||||
* line is shown. Day-to-day jitter below this floor is not narrated as a
|
||||
* finding (honest withholding, never a fabricated observation).
|
||||
*/
|
||||
export const SLEEP_CONTEXT_MIN_DELTA_MINUTES = 30;
|
||||
|
||||
/**
|
||||
* Per-metric adverse direction — which way a reading moves when the body is
|
||||
@@ -184,6 +198,21 @@ export interface SymptomBurdenPoint {
|
||||
impact: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One reconstructed night's asleep total, keyed by the local WAKE-DAY (the same
|
||||
* `YYYY-MM-DD` day-space the engine's onset/feltBetter markers live in — a
|
||||
* canonical `NightSummary.night`). The caller reconstructs nights from the raw
|
||||
* per-stage SLEEP_DURATION rows (writer-deduped, session-clustered) and passes
|
||||
* the asleep totals in; the engine never touches raw rows. Used ONLY for the
|
||||
* sleep-context observation, NEVER as a recovery-gap return track.
|
||||
*/
|
||||
export interface SleepNightPoint {
|
||||
/** Local wake-day key, `YYYY-MM-DD`. */
|
||||
day: string;
|
||||
/** Asleep minutes for the night (canonical, multi-source-deduped). */
|
||||
asleepMinutes: number;
|
||||
}
|
||||
|
||||
/** The dates the engine reasons over, all `YYYY-MM-DD` local day keys. */
|
||||
export interface EpisodeWindow {
|
||||
/** Onset day. */
|
||||
@@ -216,6 +245,20 @@ export interface IllnessCorrelationInput {
|
||||
* passive-vital-thin episode. Optional.
|
||||
*/
|
||||
symptomBurden?: SymptomBurdenPoint[];
|
||||
/**
|
||||
* Reconstructed per-night asleep totals over the user's WELL baseline window
|
||||
* (the span ending before the pre-onset lookback). Their median is the user's
|
||||
* usual asleep figure the episode is compared against. Optional — absent /
|
||||
* thin → no sleep-context observation. NEVER feeds the recovery-gap.
|
||||
*/
|
||||
baselineNights?: SleepNightPoint[];
|
||||
/**
|
||||
* Reconstructed per-night asleep totals over the episode window (`onset −
|
||||
* lookback` through end). Compared against the baseline median as a neutral
|
||||
* CONTEXT observation (slept more / less than usual), never a return track and
|
||||
* never in the gap median. Optional — absent / thin → no observation.
|
||||
*/
|
||||
episodeNights?: SleepNightPoint[];
|
||||
/** Provenance source the dominant read resolved against. */
|
||||
source: "DAY" | "live" | "none";
|
||||
/** Compute time (injected for deterministic tests). */
|
||||
@@ -276,6 +319,25 @@ export function isAdverseDeviation(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A neutral sleep-context observation for the episode — the user's median
|
||||
* asleep minutes over the well baseline vs the mean over the episode window. A
|
||||
* pure OBSERVATION ("you slept more / less than usual"), never a recovery
|
||||
* return, never in the gap median, never a causal/recovery claim. Null when the
|
||||
* sleep data is too thin (below the night-count floors) or the delta is below
|
||||
* the magnitude floor — honest withholding, never a fabricated finding.
|
||||
*/
|
||||
export interface SleepContext {
|
||||
/** Median asleep minutes over the well baseline window. */
|
||||
baselineMeanMinutes: number;
|
||||
/** Mean asleep minutes over the episode window. */
|
||||
episodeMeanMinutes: number;
|
||||
/** `episode − baseline` asleep minutes (positive = slept more than usual). */
|
||||
deltaMinutes: number;
|
||||
/** Scorable episode nights the observation is built from (transparency). */
|
||||
nightsCounted: number;
|
||||
}
|
||||
|
||||
export interface IllnessCorrelationValue {
|
||||
episodeId: string;
|
||||
/** "How did it announce itself" — notable pre-onset deviations. */
|
||||
@@ -317,6 +379,13 @@ export interface IllnessCorrelationValue {
|
||||
* recurs, seek care"), NEVER reassure. Empty when nothing tripped.
|
||||
*/
|
||||
redFlags: IllnessRedFlag[];
|
||||
/**
|
||||
* Sleep-as-context observation — slept more / less than usual during the
|
||||
* episode — or null when the sleep data is too thin or the delta sub-floor.
|
||||
* A neutral observation surfaced ALONGSIDE the gap, NEVER a return track and
|
||||
* NEVER part of the headline recovery-gap number. Optional/additive.
|
||||
*/
|
||||
sleepContext: SleepContext | null;
|
||||
}
|
||||
|
||||
export interface IllnessRedFlag {
|
||||
@@ -508,7 +577,7 @@ export function computeIllnessCorrelation(
|
||||
// deviation (which yielded a spurious negative gap). Excluded for
|
||||
// CHRONIC_ONGOING (no recovered date by design).
|
||||
if (firstDeviationIndex >= 0 && window.lifecycle !== "CHRONIC_ONGOING") {
|
||||
const returnedDay = firstStableReturn(
|
||||
const returnedDay = lastStableReturn(
|
||||
active,
|
||||
center,
|
||||
spread,
|
||||
@@ -593,6 +662,15 @@ export function computeIllnessCorrelation(
|
||||
fullHistoryDays: Math.max(MIN_EPISODE_COVERAGE_DAYS, 14),
|
||||
});
|
||||
|
||||
// Sleep-as-context — a neutral observation surfaced alongside the gap, NEVER a
|
||||
// return track and NEVER in the recovery-gap median. Withholds (null) on thin
|
||||
// data or a sub-floor delta.
|
||||
const sleepContext = computeSleepContext(
|
||||
input.baselineNights ?? [],
|
||||
input.episodeNights ?? [],
|
||||
window,
|
||||
);
|
||||
|
||||
return buildOk<IllnessCorrelationValue>({
|
||||
value: {
|
||||
episodeId: input.episodeId,
|
||||
@@ -608,6 +686,7 @@ export function computeIllnessCorrelation(
|
||||
feltBetterDay: window.feltBetterDay,
|
||||
gapDriverType: gapDriverType === null ? null : String(gapDriverType),
|
||||
redFlags,
|
||||
sleepContext,
|
||||
},
|
||||
coverage,
|
||||
confidence,
|
||||
@@ -616,13 +695,22 @@ export function computeIllnessCorrelation(
|
||||
}
|
||||
|
||||
/**
|
||||
* First day in `active` (chronological), starting at `fromIndex`, where the
|
||||
* vital is in-band AND stays in-band for the next `RETURN_STABILITY_DAYS`
|
||||
* observed days. Null when it never settles. "In-band" = |deviation| <
|
||||
* NOTABLE_SD. `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.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
function firstStableReturn(
|
||||
function lastStableReturn(
|
||||
active: VitalDayPoint[],
|
||||
center: number,
|
||||
spread: number,
|
||||
@@ -632,13 +720,16 @@ function firstStableReturn(
|
||||
day: p.day,
|
||||
in: Math.abs(deviationSd(p.mean, center, spread)) < NOTABLE_SD,
|
||||
}));
|
||||
for (let i = Math.max(0, fromIndex); i < inBand.length; i++) {
|
||||
if (!inBand[i].in) continue;
|
||||
let run = 0;
|
||||
for (let j = i; j < inBand.length && inBand[j].in; j++) run++;
|
||||
if (run >= RETURN_STABILITY_DAYS) return inBand[i].day;
|
||||
}
|
||||
return null;
|
||||
// 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;
|
||||
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.
|
||||
if (run < RETURN_STABILITY_DAYS || start < Math.max(0, fromIndex))
|
||||
return null;
|
||||
return inBand[start].day;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -652,11 +743,15 @@ function firstStableReturn(
|
||||
* - The anchor is the first logged adverse day (same role as the vital
|
||||
* `firstDeviationIndex`): a return search starts AFTER it, so a run-up is
|
||||
* never read as a return.
|
||||
* - A "return" is the first logged day at/after the anchor that is in-band AND
|
||||
* stays in-band for the next `RETURN_STABILITY_DAYS` LOGGED days. LOGGED-days
|
||||
* - 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, so the
|
||||
* track WITHHOLDS rather than fabricating recovery from absence of rows.
|
||||
* 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 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.
|
||||
@@ -688,21 +783,17 @@ function computeSymptomReturn(
|
||||
const firstAdverseIndex = active.findIndex(
|
||||
(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).
|
||||
const inBand = active.map((p) => p.impact < FUNCTIONAL_IMPACT_ADVERSE_FLOOR);
|
||||
let returnedDay: string | null = null;
|
||||
for (let i = firstAdverseIndex; i < active.length; i++) {
|
||||
if (active[i].impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR) continue;
|
||||
// Count consecutive in-band LOGGED days from here.
|
||||
let run = 0;
|
||||
for (
|
||||
let j = i;
|
||||
j < active.length && active[j].impact < FUNCTIONAL_IMPACT_ADVERSE_FLOOR;
|
||||
j++
|
||||
) {
|
||||
run++;
|
||||
}
|
||||
if (run >= RETURN_STABILITY_DAYS) {
|
||||
returnedDay = active[i].day;
|
||||
break;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -722,6 +813,56 @@ function computeSymptomReturn(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the sleep-as-context observation: the user's MEDIAN asleep minutes
|
||||
* over the well baseline vs the MEAN over the episode's active span. A neutral
|
||||
* OBSERVATION only — never a recovery return, never folded into the gap median,
|
||||
* never a causal claim. Mirrors the engine's withholding ethos:
|
||||
*
|
||||
* - Baseline needs ≥ `SLEEP_CONTEXT_MIN_BASELINE_NIGHTS` scorable nights AND
|
||||
* the episode needs ≥ `SLEEP_CONTEXT_MIN_EPISODE_NIGHTS` (thin sleep data
|
||||
* produces no observation — say nothing rather than guess).
|
||||
* - The |delta| must reach `SLEEP_CONTEXT_MIN_DELTA_MINUTES` so day-to-day
|
||||
* jitter is never narrated as a finding.
|
||||
*
|
||||
* Episode nights are clipped to the active span (`>= onsetDay`) so the
|
||||
* comparison reflects the illness, not the pre-onset lookback. Returns null
|
||||
* (says nothing) below either floor.
|
||||
*/
|
||||
function computeSleepContext(
|
||||
baselineNights: SleepNightPoint[],
|
||||
episodeNights: SleepNightPoint[],
|
||||
window: EpisodeWindow,
|
||||
): SleepContext | null {
|
||||
const baseMinutes = baselineNights.map((n) => n.asleepMinutes);
|
||||
const episodeMinutes = episodeNights
|
||||
.filter((n) => n.day >= window.onsetDay)
|
||||
.map((n) => n.asleepMinutes);
|
||||
|
||||
if (
|
||||
baseMinutes.length < SLEEP_CONTEXT_MIN_BASELINE_NIGHTS ||
|
||||
episodeMinutes.length < SLEEP_CONTEXT_MIN_EPISODE_NIGHTS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baselineMeanMinutes = Math.round(median(baseMinutes));
|
||||
const episodeMeanMinutes = Math.round(
|
||||
episodeMinutes.reduce((s, v) => s + v, 0) / episodeMinutes.length,
|
||||
);
|
||||
const deltaMinutes = episodeMeanMinutes - baselineMeanMinutes;
|
||||
|
||||
// Sub-floor delta is jitter, not a finding → say nothing.
|
||||
if (Math.abs(deltaMinutes) < SLEEP_CONTEXT_MIN_DELTA_MINUTES) return null;
|
||||
|
||||
return {
|
||||
baselineMeanMinutes,
|
||||
episodeMeanMinutes,
|
||||
deltaMinutes,
|
||||
nightsCounted: episodeMinutes.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the retrospective red flags — DECOUPLED from the banded loop and the
|
||||
* own-baseline band entirely. SpO2 and temperature escalate against ABSOLUTE
|
||||
|
||||
@@ -202,6 +202,19 @@ const illnessRedFlag = z
|
||||
"A retrospective red-flag escalation (sustained low SpO2 or sustained fever) against absolute clinical floors. Copy must escalate ('seek care if this recurs'), never reassure.",
|
||||
});
|
||||
|
||||
const illnessSleepContext = z
|
||||
.object({
|
||||
baselineMeanMinutes: z.number(),
|
||||
episodeMeanMinutes: z.number(),
|
||||
deltaMinutes: z.number(),
|
||||
nightsCounted: z.number().int(),
|
||||
})
|
||||
.meta({
|
||||
id: "IllnessSleepContext",
|
||||
description:
|
||||
"A neutral sleep-as-context observation: the user's median asleep minutes over the well baseline vs the mean over the episode window, and their signed delta (positive = slept more than usual). A pure OBSERVATION surfaced alongside the gap — NEVER a recovery return and NEVER part of the headline recovery-gap. Withheld (the field is null) on thin sleep data or a sub-floor delta.",
|
||||
});
|
||||
|
||||
const illnessCorrelationValue = z
|
||||
.object({
|
||||
episodeId: z.string(),
|
||||
@@ -213,11 +226,12 @@ const illnessCorrelationValue = z
|
||||
feltBetterDay: z.string().nullable(),
|
||||
gapDriverType: z.string().nullable(),
|
||||
redFlags: z.array(illnessRedFlag),
|
||||
sleepContext: illnessSleepContext.nullable(),
|
||||
})
|
||||
.meta({
|
||||
id: "IllnessCorrelationValue",
|
||||
description:
|
||||
"The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital physiological returns (incl. the user-logged symptom-burden track, `type:FUNCTIONAL_IMPACT`), the headline recovery-gap (median of per-track gaps), the driving metric (`gapDriverType`), and any red flags.",
|
||||
"The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital physiological returns (incl. the user-logged symptom-burden track, `type:FUNCTIONAL_IMPACT`), the headline recovery-gap (median of per-track gaps), the driving metric (`gapDriverType`), any red flags, and an optional sleep-as-context observation (`sleepContext`, never in the gap).",
|
||||
});
|
||||
|
||||
const illnessCorrelationResponse = z
|
||||
|
||||
Reference in new issue
Block a user