mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(illness): gate the recovery-gap floor on adverse-direction days
The cross-episode typical-gap median counts an episode toward its qualifying-days floor via the per-episode coverage `historyDays`, which counts a day if ANY banded vital has a reading. A WEIGHT-only episode (no illness-adverse direction) could therefore clear the floor and feed the median despite carrying no illness-relevant signal, weakening the honest- sample claim. Compute a distinct-adverse-day count in the engine — days with at least one notable reading in the metric's adverse direction — and feed that as the qualifying-days floor instead. Reuses the engine's single adverse- direction predicate (now exported as `isAdverseDeviation`); a neutral- direction vital never counts a day. The insufficient arm contributes 0, which is correct since its gap is null and never reaches the median.
This commit is contained in:
1 parent
62a4dfdf7e
commit
36affd5daf
6 files changed
+151
-7
No files matched your search
@@ -20279,6 +20279,10 @@ components:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
adverseCoverageDays:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
feltBetterDay:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -20293,6 +20297,7 @@ components:
|
||||
- nadir
|
||||
- returns
|
||||
- recoveryGapDays
|
||||
- adverseCoverageDays
|
||||
- feltBetterDay
|
||||
- redFlags
|
||||
additionalProperties: false
|
||||
|
||||
@@ -119,9 +119,17 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
derived.status === "ok"
|
||||
? derived.value.recoveryGapDays
|
||||
: null,
|
||||
// `historyDays` = distinct episode days with a banded vital,
|
||||
// present on both the `ok` and `insufficient` arms.
|
||||
gapMeasurementDays: derived.coverage.historyDays,
|
||||
// Qualifying-days floor: distinct episode days carrying an
|
||||
// ADVERSE-direction banded reading — NOT raw coverage
|
||||
// (`historyDays` counts ANY banded vital, so a WEIGHT-only
|
||||
// episode with no illness-adverse signal could otherwise clear
|
||||
// the floor and tip the typical-gap median). Only the `ok` arm
|
||||
// ever contributes a gap; the `insufficient` arm's gap is null,
|
||||
// so 0 here is correct (it never reaches the median anyway).
|
||||
gapMeasurementDays:
|
||||
derived.status === "ok"
|
||||
? derived.value.adverseCoverageDays
|
||||
: 0,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
@@ -115,6 +115,7 @@ export interface IllnessCorrelationValue {
|
||||
nadir: IllnessVitalDeviation[];
|
||||
returns: IllnessVitalReturn[];
|
||||
recoveryGapDays: number | null;
|
||||
adverseCoverageDays: number;
|
||||
feltBetterDay: string | null;
|
||||
redFlags: IllnessRedFlag[];
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import {
|
||||
computeIllnessCorrelation,
|
||||
dayDiff,
|
||||
isAdverseDeviation,
|
||||
MIN_BASELINE_DAYS,
|
||||
MIN_EPISODE_COVERAGE_DAYS,
|
||||
type IllnessCorrelationInput,
|
||||
type VitalDayPoint,
|
||||
} from "../correlation";
|
||||
@@ -431,6 +433,102 @@ describe("computeIllnessCorrelation — red-flag escalation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAdverseDeviation", () => {
|
||||
it("pins per-metric adverse direction (up for RHR, down for HRV)", () => {
|
||||
expect(isAdverseDeviation("RESTING_HEART_RATE", "above")).toBe(true);
|
||||
expect(isAdverseDeviation("RESTING_HEART_RATE", "below")).toBe(false);
|
||||
expect(isAdverseDeviation("HEART_RATE_VARIABILITY", "below")).toBe(true);
|
||||
expect(isAdverseDeviation("HEART_RATE_VARIABILITY", "above")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats neutral-direction vitals (WEIGHT) as never adverse", () => {
|
||||
expect(isAdverseDeviation("WEIGHT", "above")).toBe(false);
|
||||
expect(isAdverseDeviation("WEIGHT", "below")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeIllnessCorrelation — adverse-coverage qualifying floor", () => {
|
||||
it("counts only days with a notable ADVERSE-direction reading", () => {
|
||||
// RHR (up = adverse) spikes out-of-band on 3 distinct active days, then
|
||||
// settles. Those 3 days — and only those — count toward the floor.
|
||||
const type: MeasurementType = "RESTING_HEART_RATE";
|
||||
const baselineDays = jitteredBaseline(55, 1, 21, "2026-01-02");
|
||||
const episodeDays: VitalDayPoint[] = [
|
||||
{ day: "2026-01-10", mean: 75 }, // adverse spike
|
||||
{ day: "2026-01-11", mean: 74 }, // adverse spike
|
||||
{ day: "2026-01-12", mean: 73 }, // adverse spike
|
||||
{ day: "2026-01-15", mean: 55 }, // in band
|
||||
{ day: "2026-01-16", mean: 55 },
|
||||
{ day: "2026-01-17", mean: 55 },
|
||||
];
|
||||
const out = computeIllnessCorrelation(
|
||||
input({ series: [{ type, baselineDays, episodeDays }] }),
|
||||
);
|
||||
expect(out.status).toBe("ok");
|
||||
if (out.status !== "ok") return;
|
||||
expect(out.value.adverseCoverageDays).toBe(3);
|
||||
});
|
||||
|
||||
it("does NOT count a WEIGHT-only (no adverse direction) episode toward the floor", () => {
|
||||
// WEIGHT has no illness-adverse direction. Even with plenty of out-of-band
|
||||
// WEIGHT days (so raw coverage `historyDays` clears the engine floor), the
|
||||
// adverse-coverage floor stays 0 — a WEIGHT-only episode must not feed the
|
||||
// cross-episode typical-gap median.
|
||||
const type: MeasurementType = "WEIGHT";
|
||||
const baselineDays = jitteredBaseline(80, 1, 21, "2026-01-02");
|
||||
const episodeDays: VitalDayPoint[] = [
|
||||
{ day: "2026-01-10", mean: 90 }, // far out of band, but neutral direction
|
||||
{ day: "2026-01-11", mean: 91 },
|
||||
{ day: "2026-01-12", mean: 92 },
|
||||
{ day: "2026-01-13", mean: 70 }, // out of band the other way — still neutral
|
||||
{ day: "2026-01-14", mean: 69 },
|
||||
];
|
||||
const out = computeIllnessCorrelation(
|
||||
input({ series: [{ type, baselineDays, episodeDays }] }),
|
||||
);
|
||||
// Coverage clears the engine floor (5 banded days) so the engine returns ok…
|
||||
expect(out.status).toBe("ok");
|
||||
if (out.status !== "ok") return;
|
||||
expect(out.coverage.historyDays).toBeGreaterThanOrEqual(
|
||||
MIN_EPISODE_COVERAGE_DAYS,
|
||||
);
|
||||
// …but NO day counts toward the adverse-coverage qualifying floor.
|
||||
expect(out.value.adverseCoverageDays).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores a non-adverse-direction move on an adverse-typed vital", () => {
|
||||
// RHR is up-adverse. A big DROP (below band) is notable but NOT adverse, so
|
||||
// it must not count toward the floor. Pair with a banded neutral vital to
|
||||
// clear the engine coverage floor without adding adverse days.
|
||||
const rhr: MeasurementType = "RESTING_HEART_RATE";
|
||||
const weight: MeasurementType = "WEIGHT";
|
||||
const out = computeIllnessCorrelation(
|
||||
input({
|
||||
series: [
|
||||
{
|
||||
type: rhr,
|
||||
baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"),
|
||||
episodeDays: [
|
||||
{ day: "2026-01-10", mean: 40 }, // far BELOW band — not adverse for RHR
|
||||
{ day: "2026-01-11", mean: 41 },
|
||||
{ day: "2026-01-12", mean: 55 },
|
||||
{ day: "2026-01-13", mean: 55 },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: weight,
|
||||
baselineDays: jitteredBaseline(80, 1, 21, "2026-01-02"),
|
||||
episodeDays: flatBaseline(80, 5, "2026-01-15"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(out.status).toBe("ok");
|
||||
if (out.status !== "ok") return;
|
||||
expect(out.value.adverseCoverageDays).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeIllnessCorrelation — return anchor", () => {
|
||||
it("never reports a return BEFORE the first deviation (no spurious gap)", () => {
|
||||
// The vital is IN BAND for the first 4 active days (the run-up), then
|
||||
|
||||
@@ -208,6 +208,24 @@ export interface VitalReturnFinding {
|
||||
gapDays: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a banded reading is the illness-adverse move for its metric — a
|
||||
* RISE for an "up"-adverse vital (resting HR, temperature, BP, …), a DROP for
|
||||
* a "down"-adverse one (HRV, recovery, SpO2). The single adverse-direction
|
||||
* predicate the engine reasons with; reused by the qualifying-days floor so a
|
||||
* neutral-direction vital (WEIGHT, glucose) never counts a day toward it.
|
||||
*/
|
||||
export function isAdverseDeviation(
|
||||
type: MeasurementType,
|
||||
direction: "above" | "below",
|
||||
): boolean {
|
||||
const adverseDir = ADVERSE_DIRECTION[type];
|
||||
return (
|
||||
(adverseDir === "up" && direction === "above") ||
|
||||
(adverseDir === "down" && direction === "below")
|
||||
);
|
||||
}
|
||||
|
||||
export interface IllnessCorrelationValue {
|
||||
episodeId: string;
|
||||
/** "How did it announce itself" — notable pre-onset deviations. */
|
||||
@@ -222,6 +240,15 @@ export interface IllnessCorrelationValue {
|
||||
* = the body lagged the felt-better marker.
|
||||
*/
|
||||
recoveryGapDays: number | null;
|
||||
/**
|
||||
* Distinct episode days carrying at least one ADVERSE-direction banded
|
||||
* reading (a vital moving the illness-adverse way for its metric). This is
|
||||
* the qualifying-days floor the cross-episode aggregate gates the typical-
|
||||
* gap median on — a WEIGHT-only (no adverse direction) episode contributes
|
||||
* 0 here even when `coverage.historyDays` (any banded reading) is high, so
|
||||
* an episode with no illness-relevant signal cannot tip the typical gap.
|
||||
*/
|
||||
adverseCoverageDays: number;
|
||||
/** Felt-better marker echoed for the surface. */
|
||||
feltBetterDay: string | null;
|
||||
/**
|
||||
@@ -279,10 +306,7 @@ function finding(
|
||||
): VitalDeviationFinding {
|
||||
const sd = deviationSd(value, center, spread);
|
||||
const direction: "above" | "below" = sd >= 0 ? "above" : "below";
|
||||
const adverseDir = ADVERSE_DIRECTION[type];
|
||||
const adverse =
|
||||
(adverseDir === "up" && direction === "above") ||
|
||||
(adverseDir === "down" && direction === "below");
|
||||
const adverse = isAdverseDeviation(type, direction);
|
||||
return {
|
||||
type,
|
||||
day,
|
||||
@@ -364,6 +388,11 @@ export function computeIllnessCorrelation(
|
||||
const preOnset: VitalDeviationFinding[] = [];
|
||||
const nadir: VitalDeviationFinding[] = [];
|
||||
const returns: VitalReturnFinding[] = [];
|
||||
// Distinct active-span days with at least one notable ADVERSE-direction
|
||||
// banded reading. This — not raw `coverageDays` (any banded reading) — is
|
||||
// the qualifying-days floor the cross-episode aggregate gates on, so a
|
||||
// neutral-direction-only episode (e.g. WEIGHT) never feeds the typical gap.
|
||||
const adverseDayKeys = new Set<string>();
|
||||
|
||||
for (const { series, band } of banded) {
|
||||
const { type } = series;
|
||||
@@ -396,6 +425,7 @@ export function computeIllnessCorrelation(
|
||||
const f = finding(type, p.day, p.mean, center, spread);
|
||||
if (Math.abs(f.deviationSd) >= NOTABLE_SD) {
|
||||
if (firstDeviationIndex === -1) firstDeviationIndex = i;
|
||||
if (f.adverse) adverseDayKeys.add(p.day);
|
||||
if (
|
||||
worstActive === null ||
|
||||
Math.abs(f.deviationSd) > Math.abs(worstActive.deviationSd)
|
||||
@@ -463,6 +493,7 @@ export function computeIllnessCorrelation(
|
||||
),
|
||||
returns,
|
||||
recoveryGapDays,
|
||||
adverseCoverageDays: adverseDayKeys.size,
|
||||
feltBetterDay: window.feltBetterDay,
|
||||
redFlags,
|
||||
},
|
||||
|
||||
@@ -208,6 +208,7 @@ const illnessCorrelationValue = z
|
||||
nadir: z.array(illnessVitalDeviation),
|
||||
returns: z.array(illnessVitalReturn),
|
||||
recoveryGapDays: z.number().nullable(),
|
||||
adverseCoverageDays: z.number().int(),
|
||||
feltBetterDay: z.string().nullable(),
|
||||
redFlags: z.array(illnessRedFlag),
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user