Merge symptom-severity recovery track into release/v1.19.2

Fold the illness journal's per-day functional-impact curve (falling back
to linked symptom severity) into the recovery-gap as a return track
against the known-constant healthy baseline. It is adverse by
construction, feeds the qualifying-days floor and the gap median, and
withholds on sparse logging. The card names the driver when symptoms
lead ('…before your logged symptoms eased').
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 21:07:02 +02:00
commit 5a2d759e50
12 files changed
+471 -8

No files matched your search

+2
View File
@@ -6882,6 +6882,8 @@
"recoveryGapTitle": "Erholungslücke",
"recoveryGapLag": "Deine Werte normalisierten sich {days} Tage, nachdem es dir besser ging.",
"recoveryGapLead": "Deine Werte normalisierten sich {days} Tage, bevor du Genesung vermerktest.",
"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.",
"redFlagTitle": "Genauer ansehen",
"redFlagSpo2": "Deine Sauerstoffsättigung blieb mehrere Tage niedrig. Wenn das erneut auftritt, suche ärztliche Hilfe.",
+2
View File
@@ -6882,6 +6882,8 @@
"recoveryGapTitle": "Recovery gap",
"recoveryGapLag": "Your numbers settled {days} days after you felt better.",
"recoveryGapLead": "Your numbers settled {days} days before you marked recovery.",
"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.",
"redFlagTitle": "Worth a closer look",
"redFlagSpo2": "Your oxygen saturation stayed low for several days. If this recurs, seek medical care.",
+2
View File
@@ -6882,6 +6882,8 @@
"recoveryGapTitle": "Brecha de recuperación",
"recoveryGapLag": "Tus valores se estabilizaron {days} días después de sentirte mejor.",
"recoveryGapLead": "Tus valores se estabilizaron {days} días antes de marcar la recuperación.",
"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.",
"redFlagTitle": "Conviene revisarlo",
"redFlagSpo2": "Tu saturación de oxígeno se mantuvo baja varios días. Si se repite, busca atención médica.",
+2
View File
@@ -6882,6 +6882,8 @@
"recoveryGapTitle": "Écart de récupération",
"recoveryGapLag": "Tes valeurs se sont stabilisées {days} jours après que tu te sois senti mieux.",
"recoveryGapLead": "Tes valeurs se sont stabilisées {days} jours avant que tu notes la guérison.",
"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.",
"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.",
+2
View File
@@ -6882,6 +6882,8 @@
"recoveryGapTitle": "Divario di recupero",
"recoveryGapLag": "I tuoi valori si sono stabilizzati {days} giorni dopo esserti sentito meglio.",
"recoveryGapLead": "I tuoi valori si sono stabilizzati {days} giorni prima di segnare la guarigione.",
"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.",
"redFlagTitle": "Da guardare più da vicino",
"redFlagSpo2": "La tua saturazione di ossigeno è rimasta bassa per diversi giorni. Se si ripete, rivolgiti a un medico.",
+2
View File
@@ -6882,6 +6882,8 @@
"recoveryGapTitle": "Luka rekonwalescencji",
"recoveryGapLag": "Twoje wartości ustabilizowały się {days} dni po tym, jak poczułeś się lepiej.",
"recoveryGapLead": "Twoje wartości ustabilizowały się {days} dni przed oznaczeniem wyzdrowienia.",
"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.",
"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.",
@@ -44,6 +44,10 @@ function DeviationRow({ d }: { d: IllnessVitalDeviation }) {
function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
const { t } = useTranslations();
const gap = value.recoveryGapDays;
// Driver-aware copy: when the user's logged symptom curve drove the gap, name
// the symptoms ("…before your symptoms eased"); otherwise the existing
// vital-driven phrasing. Clinically calm — observation, never prediction.
const symptomDriven = value.gapDriverType === "FUNCTIONAL_IMPACT";
return (
<div className="space-y-5">
@@ -71,11 +75,19 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
</p>
<p className="text-muted-foreground mt-1 text-sm">
{gap > 0
? t("illness.correlation.recoveryGapLag", { days: gap })
? t(
symptomDriven
? "illness.correlation.recoveryGapLagSymptom"
: "illness.correlation.recoveryGapLag",
{ days: gap },
)
: gap < 0
? t("illness.correlation.recoveryGapLead", {
days: Math.abs(gap),
})
? t(
symptomDriven
? "illness.correlation.recoveryGapLeadSymptom"
: "illness.correlation.recoveryGapLead",
{ days: Math.abs(gap) },
)
: t("illness.correlation.recoveryGapSame")}
</p>
</div>
+8
View File
@@ -97,9 +97,11 @@ export interface IllnessVitalDeviation {
}
export interface IllnessVitalReturn {
/** A `MeasurementType`, or `"FUNCTIONAL_IMPACT"` for the symptom-burden track. */
type: string;
returnedDay: string | null;
gapDays: number | null;
adverse: boolean;
}
export interface IllnessRedFlag {
@@ -117,6 +119,12 @@ export interface IllnessCorrelationValue {
recoveryGapDays: number | null;
adverseCoverageDays: number;
feltBetterDay: string | null;
/**
* The metric that drove the headline gap — a `MeasurementType`,
* `"FUNCTIONAL_IMPACT"` (the symptom-burden track), or null. Drives the
* driver-aware recovery-gap copy.
*/
gapDriverType: string | null;
redFlags: IllnessRedFlag[];
}
@@ -16,10 +16,12 @@ import type { MeasurementType } from "@/generated/prisma/client";
import {
computeIllnessCorrelation,
dayDiff,
FUNCTIONAL_IMPACT_RETURN_KEY,
isAdverseDeviation,
MIN_BASELINE_DAYS,
MIN_EPISODE_COVERAGE_DAYS,
type IllnessCorrelationInput,
type SymptomBurdenPoint,
type VitalDayPoint,
} from "../correlation";
@@ -615,3 +617,196 @@ describe("computeIllnessCorrelation — return anchor", () => {
expect(out.value.recoveryGapDays).toBeGreaterThan(0);
});
});
describe("computeIllnessCorrelation — symptom-burden return track", () => {
// A banded RHR vital present in EVERY case below clears the engine's
// episode-coverage floor (the gate counts banded-vital days, not symptom
// days). Its band stays IN throughout so it produces no adverse signal and
// never competes with the symptom track for the driver — it is scaffolding.
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 },
];
it("folds a functional-impact return into the gap with a constant-0 baseline", () => {
// Onset 01-10, felt better 01-14. Logged impact: bedbound early, eases to 0
// on 01-16 and HOLDS for 3 logged days → symptom return 01-16, gap +2.
const symptomBurden: SymptomBurdenPoint[] = [
{ day: "2026-01-10", impact: 3 },
{ day: "2026-01-12", impact: 2 },
{ day: "2026-01-14", impact: 1 }, // felt-better day, still symptomatic
{ day: "2026-01-16", impact: 0 }, // return start
{ day: "2026-01-17", impact: 0 },
{ day: "2026-01-18", impact: 0 }, // 3 logged in-band days → stable
];
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).toBe("2026-01-16");
expect(sym?.adverse).toBe(true);
// Gap = 01-16 − 01-14 = +2. RHR stayed flat (no gap), so the symptom track
// is the only contributor → headline gap = +2.
expect(sym?.gapDays).toBe(dayDiff("2026-01-14", "2026-01-16"));
expect(out.value.recoveryGapDays).toBe(2);
// It contributes to the adverse-coverage floor (3 logged adverse days).
expect(out.value.adverseCoverageDays).toBeGreaterThanOrEqual(3);
expect(out.value.gapDriverType).toBe(FUNCTIONAL_IMPACT_RETURN_KEY);
});
it("WITHHOLDS the symptom return when logging is too sparse to stabilise", () => {
// The user logs while sick, then logs impact-0 ONCE and stops — the
// 3-logged-day stability run can never fire. The symptom track must
// contribute NO gap (honest withholding), never fabricate recovery from the
// absence of further logs.
const symptomBurden: SymptomBurdenPoint[] = [
{ day: "2026-01-10", impact: 3 },
{ day: "2026-01-12", impact: 2 },
{ day: "2026-01-16", impact: 0 }, // single trailing in-band log
];
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();
// No vital returned either → no headline gap fabricated.
expect(out.value.recoveryGapDays).toBeNull();
expect(out.value.gapDriverType).toBeNull();
// The logged adverse days still count toward coverage (honest).
expect(out.value.adverseCoverageDays).toBeGreaterThanOrEqual(2);
});
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
// most illness-specific signal).
const rhrSpike: VitalDayPoint[] = [
{ day: "2026-01-10", mean: 75 }, // adverse up
{ day: "2026-01-11", mean: 74 },
{ day: "2026-01-16", mean: 55 }, // back in band
{ day: "2026-01-17", mean: 55 },
{ day: "2026-01-18", mean: 55 }, // 3 stable
];
const symptomBurden: SymptomBurdenPoint[] = [
{ day: "2026-01-10", impact: 3 },
{ day: "2026-01-12", impact: 2 },
{ day: "2026-01-16", impact: 0 }, // same return day → same gap as RHR
{ 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: rhrSpike },
],
symptomBurden,
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
const sym = out.value.returns.find(
(r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY,
);
const rhrReturn = out.value.returns.find((r) => r.type === rhr);
expect(sym?.gapDays).toBe(2);
expect(rhrReturn?.gapDays).toBe(2);
expect(out.value.recoveryGapDays).toBe(2);
// Tie on |gap − median| → functional-impact wins.
expect(out.value.gapDriverType).toBe(FUNCTIONAL_IMPACT_RETURN_KEY);
});
it("ignores a symptom curve with no adverse day (nothing to return from)", () => {
const symptomBurden: SymptomBurdenPoint[] = [
{ day: "2026-01-10", impact: 0 },
{ day: "2026-01-12", 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;
expect(
out.value.returns.find((r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY),
).toBeUndefined();
});
it("never produces a symptom return for CHRONIC_ONGOING", () => {
const symptomBurden: SymptomBurdenPoint[] = [
{ day: "2026-01-10", impact: 3 },
{ 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: "CHRONIC_ONGOING",
},
series: [
{ type: rhr, baselineDays: rhrBaseline, episodeDays: rhrFlat },
],
symptomBurden,
}),
);
expect(out.status).toBe("ok");
if (out.status !== "ok") return;
expect(out.value.recoveryGapDays).toBeNull();
expect(out.value.gapDriverType).toBeNull();
expect(
out.value.returns.find((r) => r.type === FUNCTIONAL_IMPACT_RETURN_KEY),
).toBeUndefined();
});
});
+57 -1
View File
@@ -39,6 +39,7 @@ import {
computeIllnessCorrelation,
type DayLogFeverPoint,
type IllnessCorrelationValue,
type SymptomBurdenPoint,
type VitalSeries,
} from "./correlation";
@@ -103,7 +104,7 @@ export async function computeEpisodeCorrelation(
// 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] = await Promise.all([
const [perType, dayLogFever, symptomBurden] = await Promise.all([
Promise.all(
ILLNESS_SCAN_TYPES.map((type) =>
limit(async () => {
@@ -132,6 +133,7 @@ export async function computeEpisodeCorrelation(
),
),
limit(() => readDayLogFever(episode.id, preOnsetStart, end, tz)),
limit(() => readDayLogSymptomBurden(episode.id, preOnsetStart, end, tz)),
]);
const series: VitalSeries[] = [];
@@ -167,6 +169,7 @@ export async function computeEpisodeCorrelation(
window: { onsetDay, feltBetterDay, lifecycle: episode.lifecycle },
series,
dayLogFever,
symptomBurden,
source,
now,
});
@@ -275,3 +278,56 @@ async function readDayLogFever(
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([day, feverC]) => ({ day, feverC }));
}
/**
* Read the illness day-log symptom-burden series over [from, to) — the user's
* own logged daily-impact curve, keyed by the stored `date` (already the
* user-tz day string, so NO tz conversion). Mirrors `readDayLogFever`: one
* `findMany`, present days only, no zero-fill.
*
* Per day the burden is `functionalImpact` (0–3, the deliberate daily slider)
* when logged; else the day's MAX linked `IllnessSymptomLink.severity` (0–3) as
* a fallback corroborator. A day with neither is dropped (absent from the
* series — the engine treats a missing day as "not logged", never an
* implicit 0).
*/
async function readDayLogSymptomBurden(
episodeId: string,
from: Date,
to: Date,
tz: string,
): Promise<SymptomBurdenPoint[]> {
const fromKey = dayKeyForUserTz(from, tz);
const toKey = dayKeyForUserTz(to, tz);
const rows = await prisma.illnessDayLog.findMany({
where: {
episodeId,
deletedAt: null,
date: { gte: fromKey, lte: toKey },
},
select: {
date: true,
functionalImpact: true,
symptomLinks: { select: { severity: true } },
},
});
const points: SymptomBurdenPoint[] = [];
for (const row of rows) {
if (row.functionalImpact != null) {
points.push({ day: row.date, impact: row.functionalImpact });
continue;
}
// Fallback corroborator: the day's strongest linked symptom severity.
let maxSeverity: number | null = null;
for (const link of row.symptomLinks) {
if (link.severity == null) continue;
maxSeverity =
maxSeverity === null
? link.severity
: Math.max(maxSeverity, link.severity);
}
if (maxSeverity != null)
points.push({ day: row.date, impact: maxSeverity });
}
return points.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
}
+179 -1
View File
@@ -66,6 +66,21 @@ export const NOTABLE_SD = 2;
* for the recovery-gap (one lucky in-band day is not a recovery).
*/
export const RETURN_STABILITY_DAYS = 3;
/**
* Sentinel `type` for the functional-impact return track. The symptom curve is
* NOT a `MeasurementType` — it is the user's own logged daily-impact slider —
* so it rides `returns[]` under a stable string key the surface keys copy off
* (the typed `VitalReturnFinding.type` is widened to accept it). Tallied like
* any vital type in `gapReturnTypes` so it can win `gapDriverType`.
*/
export const FUNCTIONAL_IMPACT_RETURN_KEY = "FUNCTIONAL_IMPACT" as const;
/**
* Functional-impact threshold (0–3 slider) at/above which a logged day counts
* as ADVERSE (symptomatic). The baseline is the known constant 0 (healthy), so
* the symptom curve needs none of the MAD-band / contamination machinery the
* passive vitals carry — "return" is simply impact back to below this floor.
*/
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;
@@ -153,6 +168,22 @@ export interface DayLogFeverPoint {
feverC: number;
}
/**
* A user-logged symptom-burden reading from the illness day-log, keyed by the
* stored `date` (already the user-tz local day). `impact` is the day's
* `functionalImpact` slider (0–3, 0 = fully functional … 3 = bedbound); when
* NULL it is the day's max linked `IllnessSymptomLink.severity` (0–3) as a
* fallback corroborator, or null when neither was logged. Present days only —
* never zero-filled (mirrors the fever read), so sparse logging WITHHOLDS the
* symptom return rather than fabricating an all-clear from absence of rows.
*/
export interface SymptomBurdenPoint {
/** Local day key, `YYYY-MM-DD`. */
day: string;
/** The day's symptom burden (0–3): functionalImpact, else max severity. */
impact: number;
}
/** The dates the engine reasons over, all `YYYY-MM-DD` local day keys. */
export interface EpisodeWindow {
/** Onset day. */
@@ -174,6 +205,17 @@ export interface IllnessCorrelationInput {
* escalation even with no passive BODY_TEMPERATURE rows. Optional.
*/
dayLogFever?: DayLogFeverPoint[];
/**
* Per-day symptom-burden readings from the illness day-log (`functionalImpact`
* primary, max linked symptom `severity` as fallback). Folded into the
* recovery-gap as ONE more return track against the known-constant baseline
* (healthy = 0): a "symptom return" is the burden back below the adverse floor
* and held for `RETURN_STABILITY_DAYS` LOGGED days. Always `adverse:true` (it
* is illness-relevant by construction), so it contributes to
* `adverseCoverageDays` and can drive the headline gap even on a
* passive-vital-thin episode. Optional.
*/
symptomBurden?: SymptomBurdenPoint[];
/** Provenance source the dominant read resolved against. */
source: "DAY" | "live" | "none";
/** Compute time (injected for deterministic tests). */
@@ -201,7 +243,11 @@ export interface VitalDeviationFinding {
/** A vital's recovery timing within the episode. */
export interface VitalReturnFinding {
type: MeasurementType;
/**
* The metric the return describes — a `MeasurementType` for a passive vital,
* or `FUNCTIONAL_IMPACT_RETURN_KEY` for the user-logged symptom-burden track.
*/
type: MeasurementType | typeof FUNCTIONAL_IMPACT_RETURN_KEY;
/** First day the vital re-entered its band AND stayed in for the window. */
returnedDay: string | null;
/** Days from felt-better to physiological return (signed); null if N/A. */
@@ -255,6 +301,16 @@ export interface IllnessCorrelationValue {
adverseCoverageDays: number;
/** Felt-better marker echoed for the surface. */
feltBetterDay: string | null;
/**
* The metric whose physiological return dominates the headline gap — the
* adverse return-track whose own gap is closest to the median, with the
* functional-impact symptom track winning ties (it is the most
* illness-specific signal). A `MeasurementType`,
* `FUNCTIONAL_IMPACT_RETURN_KEY`, or null when no adverse return drove a gap.
* The card names it ("…before your symptoms eased" vs "…before your resting
* heart rate settled"). Retrospective only.
*/
gapDriverType: string | null;
/**
* Red-flag escalation — a sustained adverse SpO2 drop or sustained fever
* during the episode. Retrospective, but the copy must escalate ("if this
@@ -471,6 +527,21 @@ export function computeIllnessCorrelation(
}
}
// Symptom-burden return track — the user's OWN logged daily-impact curve,
// folded in as one more return against the KNOWN-CONSTANT baseline (healthy
// = 0). No MAD band, no contamination guard, no min-baseline floor: the band
// IS the constant 0, so it sidesteps every reliability caveat the passive
// vitals carry. Always adverse by construction (a logged impact is illness-
// relevant), so it contributes to `adverseCoverageDays` and the headline gap
// even when the passive vitals are thin — the genuine illness-relevant
// contributor the WEIGHT-only floor lacked. Logged-days-only stability:
// sparse logging WITHHOLDS the return rather than fabricating an all-clear.
const symptomReturn = computeSymptomReturn(input.symptomBurden ?? [], window);
if (symptomReturn) {
returns.push(symptomReturn.finding);
for (const day of symptomReturn.adverseDays) adverseDayKeys.add(day);
}
// Red-flag escalation runs DECOUPLED from the banded loop: it scans the raw
// episode days for SpO2 + temperature against ABSOLUTE clinical floors, so a
// rock-steady (MAD=0) vital that drops the band filter still escalates — the
@@ -489,6 +560,31 @@ export function computeIllnessCorrelation(
? null
: Math.round(median(gaps));
// Name the per-episode driver: among the ADVERSE return tracks that produced
// a real gap, the one whose own gap sits closest to the headline median (it
// is the return that "explains" the headline). The functional-impact symptom
// track wins ties — it is the most illness-specific signal the engine has, so
// when it co-explains the gap it gets to name it ("…before your symptoms
// eased"). Null when no adverse return drove a gap.
const gapDriverType =
recoveryGapDays === null
? null
: (returns
.filter(
(r): r is VitalReturnFinding & { gapDays: number } =>
r.gapDays !== null && r.adverse,
)
.sort((a, b) => {
const da = Math.abs(a.gapDays - recoveryGapDays);
const db = Math.abs(b.gapDays - recoveryGapDays);
if (da !== db) return da - db;
// Tie: prefer the functional-impact symptom track, then by name.
const fa = a.type === FUNCTIONAL_IMPACT_RETURN_KEY ? 0 : 1;
const fb = b.type === FUNCTIONAL_IMPACT_RETURN_KEY ? 0 : 1;
if (fa !== fb) return fa - fb;
return String(a.type) < String(b.type) ? -1 : 1;
})[0]?.type ?? null);
const { coverage, confidence } = deriveCoverage({
requiredInputs: 1,
presentInputs: banded.length,
@@ -510,6 +606,7 @@ export function computeIllnessCorrelation(
recoveryGapDays,
adverseCoverageDays: adverseDayKeys.size,
feltBetterDay: window.feltBetterDay,
gapDriverType: gapDriverType === null ? null : String(gapDriverType),
redFlags,
},
coverage,
@@ -544,6 +641,87 @@ function firstStableReturn(
return null;
}
/**
* Compute the functional-impact (symptom-burden) return track for one episode.
*
* The symptom curve's baseline is the KNOWN CONSTANT 0 (healthy), so this needs
* none of the passive vitals' MAD-band / contamination machinery — "adverse" is
* simply `impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR`, "in band" is below it.
*
* - Only LOGGED days (present in `burden`) participate — never zero-filled.
* - 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
* 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.
* - 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.
*
* Returns null (contributes nothing) when no adverse day was logged or the
* episode is CHRONIC_ONGOING. `adverseDays` is the set of logged adverse days in
* the active span, for the coverage tally.
*/
function computeSymptomReturn(
burden: SymptomBurdenPoint[],
window: EpisodeWindow,
): { finding: VitalReturnFinding; adverseDays: string[] } | null {
if (window.lifecycle === "CHRONIC_ONGOING") return null;
// Active span only, chronological. (Logged days are sparse and arbitrary, so
// a stable sort by the day key is the canonical order.)
const active = burden
.filter((p) => p.day >= window.onsetDay)
.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0));
if (active.length === 0) return null;
const adverseDays = active
.filter((p) => p.impact >= FUNCTIONAL_IMPACT_ADVERSE_FLOOR)
.map((p) => p.day);
// No adverse day logged → there is nothing to "return" from.
if (adverseDays.length === 0) return null;
// Anchor: first logged adverse day. The return search starts there so an
// early in-band log (impact 0 before symptoms appeared) is never a return.
const firstAdverseIndex = active.findIndex(
(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;
}
}
const gapDays =
returnedDay && window.feltBetterDay
? dayDiff(window.feltBetterDay, returnedDay)
: null;
return {
finding: {
type: FUNCTIONAL_IMPACT_RETURN_KEY,
returnedDay,
gapDays,
adverse: true,
},
adverseDays,
};
}
/**
* Detect the retrospective red flags — DECOUPLED from the banded loop and the
* own-baseline band entirely. SpO2 and temperature escalate against ABSOLUTE
+4 -2
View File
@@ -181,11 +181,12 @@ const illnessVitalReturn = z
type: z.string(),
returnedDay: z.string().nullable(),
gapDays: z.number().nullable(),
adverse: z.boolean(),
})
.meta({
id: "IllnessVitalReturn",
description:
"A vital's physiological return: the first day it re-entered its band AND held, and the signed gap (days) from the felt-better marker (positive = the body lagged the feeling).",
"A physiological / symptom return: the first day the metric re-entered its band AND held, the signed gap (days) from the felt-better marker (positive = the body lagged the feeling), and whether the move was illness-adverse (only adverse returns name the driver). `type` is a MeasurementType or `FUNCTIONAL_IMPACT` for the user-logged symptom-burden track (functionalImpact, baseline = constant 0).",
});
const illnessRedFlag = z
@@ -210,12 +211,13 @@ const illnessCorrelationValue = z
recoveryGapDays: z.number().nullable(),
adverseCoverageDays: z.number().int(),
feltBetterDay: z.string().nullable(),
gapDriverType: z.string().nullable(),
redFlags: z.array(illnessRedFlag),
})
.meta({
id: "IllnessCorrelationValue",
description:
"The retrospective correlation findings for one episode: pre-onset anomaly scan, nadir, per-vital physiological returns, the headline recovery-gap (median of per-vital gaps), 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`), and any red flags.",
});
const illnessCorrelationResponse = z