diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 14d3a2fe2..65115f9f1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -11860,9 +11860,38 @@ components: - workNightsCounted - freeNightsUntilReady additionalProperties: false + averagePerNight: + type: object + properties: + state: + type: string + enum: + - partial + - ready + averageMinutes: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Mean asleep minutes per scorable night (0 while partial). + nightsCounted: + type: integer + minimum: 0 + maximum: 9007199254740991 + nightsUntilReady: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Scorable nights still needed before the average is asserted. + required: + - state + - averageMinutes + - nightsCounted + - nightsUntilReady + additionalProperties: false required: - sleepDebt - chronotype + - averagePerNight additionalProperties: false description: "Server-authoritative sleep-rhythm read: cumulative sleep debt over the rolling window + MCTQ chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work diff --git a/src/app/api/illness/insights/route.ts b/src/app/api/illness/insights/route.ts index 0e31fb6d3..c56fbf9c2 100644 --- a/src/app/api/illness/insights/route.ts +++ b/src/app/api/illness/insights/route.ts @@ -138,13 +138,15 @@ export const GET = apiHandler(async (request: NextRequest) => { derived.status === "ok" ? derived.value.adverseCoverageDays : 0, - // The vitals that produced a real physiological return — - // tallied across the qualifying episodes to name the dominant - // driving vital in the summary copy. + // The vitals that produced a real physiological return in the + // illness-ADVERSE direction — tallied across the qualifying + // episodes to name the dominant driving vital in the summary + // copy. A neutral move (e.g. weight drift with no adverse + // signal) shows a return but must never be named the driver. gapReturnTypes: derived.status === "ok" ? derived.value.returns - .filter((r) => r.gapDays !== null) + .filter((r) => r.gapDays !== null && r.adverse) .map((r) => String(r.type)) : [], }, diff --git a/src/lib/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts index e3366fe99..27bc955b3 100644 --- a/src/lib/illness/__tests__/correlation.test.ts +++ b/src/lib/illness/__tests__/correlation.test.ts @@ -226,6 +226,54 @@ describe("computeIllnessCorrelation — golden recovery-gap", () => { expect(out.value.recoveryGapDays).toBeNull(); expect(out.value.returns[0]?.returnedDay).toBeNull(); }); + + it("flags a return adverse only when the vital deviated in the adverse direction", () => { + // A neutral-direction vital (WEIGHT) can deviate out of band and stably + // return — it produces a return finding, but `adverse` must stay false so + // it never gets named the recovery driver. An up-adverse vital (RHR) that + // spikes and returns must read adverse: true. + const weight: MeasurementType = "WEIGHT"; + const rhr: MeasurementType = "RESTING_HEART_RATE"; + const out = computeIllnessCorrelation( + input({ + window: { + onsetDay: "2026-01-10", + feltBetterDay: "2026-01-14", + lifecycle: "ACUTE", + }, + series: [ + { + type: weight, + baselineDays: jitteredBaseline(80, 1, 21, "2026-01-02"), + episodeDays: [ + { day: "2026-01-10", mean: 90 }, // far out of band, neutral + { day: "2026-01-11", mean: 91 }, + { day: "2026-01-15", mean: 80 }, // back in band + { day: "2026-01-16", mean: 80 }, + { day: "2026-01-17", mean: 80 }, + ], + }, + { + type: rhr, + baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"), + episodeDays: [ + { day: "2026-01-10", mean: 75 }, // up = adverse + { day: "2026-01-11", mean: 74 }, + { day: "2026-01-15", mean: 55 }, // back in band + { day: "2026-01-16", mean: 55 }, + { day: "2026-01-17", mean: 55 }, + ], + }, + ], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const weightReturn = out.value.returns.find((r) => r.type === weight); + const rhrReturn = out.value.returns.find((r) => r.type === rhr); + expect(weightReturn?.adverse).toBe(false); + expect(rhrReturn?.adverse).toBe(true); + }); }); describe("computeIllnessCorrelation — baseline-contamination guard", () => { diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index ae8676a10..e220b0f14 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -206,6 +206,10 @@ export interface VitalReturnFinding { returnedDay: string | null; /** Days from felt-better to physiological return (signed); null if N/A. */ gapDays: number | null; + /** Whether this vital deviated in the illness-adverse direction during the + * active span — a neutral move (e.g. weight drift with no adverse signal) + * still produces a return but must not name the recovery driver. */ + adverse: boolean; } /** @@ -421,11 +425,17 @@ export function computeIllnessCorrelation( // anchor the recovery search must start AFTER (an early in-band run before // the vital ever deviated is not a "return", it is the run-up). let firstDeviationIndex = -1; + // Whether THIS vital ever deviated adversely in the active span — gates it + // out of the recovery-driver tally even if it shows a return. + let vitalDeviatedAdversely = false; active.forEach((p, i) => { 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 (f.adverse) { + adverseDayKeys.add(p.day); + vitalDeviatedAdversely = true; + } if ( worstActive === null || Math.abs(f.deviationSd) > Math.abs(worstActive.deviationSd) @@ -452,7 +462,12 @@ export function computeIllnessCorrelation( returnedDay && window.feltBetterDay ? dayDiff(window.feltBetterDay, returnedDay) : null; - returns.push({ type, returnedDay, gapDays }); + returns.push({ + type, + returnedDay, + gapDays, + adverse: vitalDeviatedAdversely, + }); } } diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index d1b1f1ef0..9af53ec26 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -322,10 +322,26 @@ const chronotypeResource = z.object({ .describe("Free-day nights still needed before a band is asserted."), }); +const averageSleepResource = z.object({ + state: z.enum(["partial", "ready"]), + averageMinutes: z + .number() + .int() + .nonnegative() + .describe("Mean asleep minutes per scorable night (0 while partial)."), + nightsCounted: z.number().int().nonnegative(), + nightsUntilReady: z + .number() + .int() + .nonnegative() + .describe("Scorable nights still needed before the average is asserted."), +}); + const sleepRhythmResponse = z .object({ sleepDebt: sleepDebtResource, chronotype: chronotypeResource, + averagePerNight: averageSleepResource, }) .meta({ id: "SleepRhythmResource",