mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in new issue
Block a user