feat(illness): correlation + recovery-gap engine

Add the retrospective correlation engine for illness episodes: a pre-onset
anomaly scan (deviation in robust SDs from the user's own median±MAD
baseline), the nadir / what-dropped across the active span, and the
recovery-gap (physiological return-to-baseline vs the felt-better marker).
CHRONIC_ONGOING is excluded from the gap. Every derived number is gated by
the shared coverage/confidence model — a thin signal asserts nothing rather
than showing a wrong figure.

The baseline window ends strictly before the pre-onset lookback, so an
episode's own days can never contaminate the baseline it is measured against.
Reads are DAY-native (rollup tier with a live-SQL fallback), reusing the
baseline engine's read path so the rollup-vs-live parity carries over.

Surfaces:
- GET /api/illness/episodes/{id}/correlation — per-episode findings (gated
  Derived wire shape; red-flag escalation for sustained low SpO2 / fever).
- GET /api/illness/insights — cross-episode retrospective ("sick N times ·
  typical recovery gap X days"), with the typical-gap withheld below the
  min-sample floor. Retrospective only — never a predictor or diagnoser.

Reliability battery: golden-fixture recovery-gap math, rollup-vs-live parity,
baseline-contamination guard, thin-data / coverage-gate, red-flag escalation.
Born-gated + owner-scoped; server-authoritative DTO (iOS renders). i18n ×6.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 22:16:19 +02:00
1 parent a7b2b61b99
commit 745fa28c8b
16 files changed
+1737

No files matched your search

@@ -0,0 +1,87 @@
/**
* `GET /api/illness/episodes/{id}/correlation` — the retrospective correlation
* findings for one episode: the pre-onset anomaly scan ("how did it announce
* itself"), the nadir / what-dropped, and the recovery-gap (physiological
* return-to-baseline vs the felt-better marker).
*
* Server-authoritative + coverage-gated: the response is a `Derived<T>` flat
* wire shape (`status: "ok" | "insufficient"`). When the signal is thin the
* engine returns `insufficient` with coverage + a reason — the surface renders
* "we're still learning", never a fabricated number. The findings are derived
* from the user's OWN baseline (median ± MAD), never a population constant, and
* the baseline window is contamination-guarded (ends before the pre-onset
* lookback). Retrospective ONLY — never a predictor or diagnoser. Born-gated +
* owner-scoped.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import { requireIllnessEnabled } from "@/lib/illness/gate";
import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const { id } = await params;
const episode = await prisma.illnessEpisode.findUnique({
where: { id },
select: {
id: true,
userId: true,
deletedAt: true,
onsetAt: true,
resolvedAt: true,
lifecycle: true,
},
});
if (!episode || episode.userId !== user.id || episode.deletedAt !== null) {
return apiError("Episode not found", 404);
}
const tz = user.timezone ?? DEFAULT_TIMEZONE;
const derived = await computeEpisodeCorrelation(
user.id,
{
id: episode.id,
onsetAt: episode.onsetAt,
resolvedAt: episode.resolvedAt,
lifecycle: episode.lifecycle,
},
tz,
);
annotate({
action: {
name: "illness.correlation.read",
entity_type: "illness_episode",
entity_id: id,
},
meta: {
status: derived.status,
recovery_gap_days:
derived.status === "ok" ? derived.value.recoveryGapDays : null,
red_flags: derived.status === "ok" ? derived.value.redFlags.length : 0,
},
});
return apiSuccess({
episodeId: id,
status: derived.status,
value: derived.status === "ok" ? derived.value : null,
coverage: derived.coverage,
confidence: derived.status === "ok" ? derived.confidence : null,
provenance: derived.provenance,
reason: derived.status === "insufficient" ? derived.reason : null,
});
},
);
+107
View File
@@ -0,0 +1,107 @@
/**
* `GET /api/illness/insights?windowDays=365` — the cross-episode retrospective
* summary: "sick N times this year · typical recovery gap X days", with a
* recurrence-by-month tally and a per-type breakdown.
*
* RETROSPECTIVE ONLY — it summarises past episodes. It is NEVER a predictor or
* diagnoser; the recurrence figure is a count, not a forecast. Server-
* authoritative + gated: the "typical gap" is withheld below the min-sample
* floor (asserts nothing thin). Per-episode gaps come from the same coverage-
* gated correlation engine the per-episode route uses, so a thin episode
* contributes no gap rather than a wrong one. Born-gated + owner-scoped.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { requireIllnessEnabled } from "@/lib/illness/gate";
import { illnessInsightsQuerySchema } from "@/lib/validations/illness";
import { computeEpisodeCorrelation } from "@/lib/illness/correlation-read";
import {
summarizeIllnessRetrospective,
type RetrospectiveEpisode,
} from "@/lib/illness/retrospective";
import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
import { resolveUserTimezone } from "@/lib/measurements/consolidation-base";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** Cap the per-episode gap computations so a heavy history can't fan out. */
const MAX_GAP_COMPUTATIONS = 24;
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const parsed = illnessInsightsQuerySchema.safeParse({
windowDays: new URL(request.url).searchParams.get("windowDays") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "illness.insights.invalid",
});
}
const windowDays = parsed.data.windowDays ?? 365;
const now = new Date();
const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
const tz = resolveUserTimezone(user.timezone ?? null);
const rows = await prisma.illnessEpisode.findMany({
where: { userId: user.id, deletedAt: null, onsetAt: { gte: since } },
orderBy: { onsetAt: "desc" },
select: {
id: true,
type: true,
lifecycle: true,
onsetAt: true,
resolvedAt: true,
},
});
// Compute the recovery-gap only for resolved, non-chronic episodes (the
// only ones that CAN have a gap), bounded to MAX_GAP_COMPUTATIONS most
// recent. Each gap comes from the coverage-gated engine — a thin episode
// yields null and simply doesn't contribute to the typical-gap median.
const gapCandidates = rows
.filter((r) => r.resolvedAt !== null && r.lifecycle !== "CHRONIC_ONGOING")
.slice(0, MAX_GAP_COMPUTATIONS);
const gapById = new Map<string, number | null>();
for (const r of gapCandidates) {
const derived = await computeEpisodeCorrelation(
user.id,
{ id: r.id, onsetAt: r.onsetAt, resolvedAt: r.resolvedAt, lifecycle: r.lifecycle },
tz,
now,
);
gapById.set(
r.id,
derived.status === "ok" ? derived.value.recoveryGapDays : null,
);
}
const episodes: RetrospectiveEpisode[] = rows.map((r) => ({
id: r.id,
type: r.type,
onsetDay: dayKeyForUserTz(r.onsetAt, tz),
resolved: r.resolvedAt !== null,
recoveryGapDays: gapById.get(r.id) ?? null,
lifecycle: r.lifecycle,
}));
const summary = summarizeIllnessRetrospective(episodes);
annotate({
action: { name: "illness.insights.read", entity_type: "illness_episode" },
meta: {
episode_count: summary.episodeCount,
gap_sample_size: summary.gapSampleSize,
typical_gap_days: summary.typicalRecoveryGapDays,
},
});
return apiSuccess({ windowDays, ...summary });
});