perf(illness): compute the recovery gap lazily and gate it on signal density

The illness list load triggered the cross-episode correlation fan-out on
every mount, so the page waited on up to 24 per-episode reads before it
painted. Move that work behind an explicit opt-in: the insights endpoint
takes an includeRecoveryGap query param (default off), the list reads the
count breakdown on a single fast query, and the recovery gap is computed
only when the viewer opens the Analyse expansion.

Tighten the typical-gap gate against coincidental data. A gap now counts
only from an episode that cleared a per-episode measurement floor
(MIN_GAP_MEASUREMENT_DAYS), at least MIN_EPISODES_FOR_TYPICAL_GAP such
episodes must qualify, and the median magnitude must reach
MIN_TYPICAL_GAP_MAGNITUDE_DAYS — a 0/+-1-day gap is the coincidental
baseline and is withheld as noise.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 11:58:39 +02:00
1 parent a5b3715a46
commit 1de63f2b5c
15 files changed
+313 -84

No files matched your search

+81 -43
View File
@@ -5,10 +5,18 @@
*
* 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.
* authoritative + gated: the "typical gap" is withheld below the signal-
* density 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.
*
* LAZY BY DEFAULT — the recovery-gap is the only expensive part (a bounded
* per-episode correlation fan-out). The illness LIST loads with
* `includeRecoveryGap` unset, so it pays only for a single count query and
* paints fast; the gap is computed solely when the user opens the explicit
* "Analyse" expansion (`includeRecoveryGap=true`). With it off the summary
* returns the count breakdown and a null typical-gap.
*/
import { NextRequest } from "next/server";
import pLimit from "p-limit";
@@ -40,9 +48,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
const gate = await requireIllnessEnabled(user.id);
if (!gate.enabled) return gate.response;
const params = new URL(request.url).searchParams;
const parsed = illnessInsightsQuerySchema.safeParse({
windowDays:
new URL(request.url).searchParams.get("windowDays") ?? undefined,
windowDays: params.get("windowDays") ?? undefined,
includeRecoveryGap: params.get("includeRecoveryGap") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
@@ -51,6 +60,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const windowDays = parsed.data.windowDays ?? 365;
const includeRecoveryGap = parsed.data.includeRecoveryGap ?? false;
const now = new Date();
const since = new Date(now.getTime() - windowDays * MS_PER_DAY);
const tz = resolveUserTimezone(user.timezone ?? null);
@@ -67,45 +77,72 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
});
// 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 limit = pLimit(EPISODE_GAP_CONCURRENCY);
const gapEntries = await Promise.all(
gapCandidates.map((r) =>
limit(async (): Promise<[string, number | null]> => {
const derived = await computeEpisodeCorrelation(
user.id,
{
id: r.id,
onsetAt: r.onsetAt,
resolvedAt: r.resolvedAt,
lifecycle: r.lifecycle,
// The recovery-gap is the only expensive part. LIST loads skip it
// entirely (`includeRecoveryGap` off) so the page paints on the single
// `findMany` above; the explicit "Analyse" expansion turns it on and pays
// the bounded fan-out. Per-episode gap + coverage come from the coverage-
// gated engine — a thin episode yields null/low coverage and is filtered
// out of the typical-gap median by the aggregate's signal-density gates.
const gapById = new Map<
string,
{ recoveryGapDays: number | null; gapMeasurementDays: number }
>();
if (includeRecoveryGap) {
const gapCandidates = rows
.filter((r) => r.resolvedAt !== null && r.lifecycle !== "CHRONIC_ONGOING")
.slice(0, MAX_GAP_COMPUTATIONS);
const limit = pLimit(EPISODE_GAP_CONCURRENCY);
const gapEntries = await Promise.all(
gapCandidates.map((r) =>
limit(
async (): Promise<
[
string,
{ recoveryGapDays: number | null; gapMeasurementDays: number },
]
> => {
const derived = await computeEpisodeCorrelation(
user.id,
{
id: r.id,
onsetAt: r.onsetAt,
resolvedAt: r.resolvedAt,
lifecycle: r.lifecycle,
},
tz,
now,
);
return [
r.id,
{
recoveryGapDays:
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,
},
];
},
tz,
now,
);
return [
r.id,
derived.status === "ok" ? derived.value.recoveryGapDays : null,
];
}),
),
);
const gapById = new Map<string, number | null>(gapEntries);
),
),
);
for (const [id, entry] of gapEntries) gapById.set(id, entry);
}
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 episodes: RetrospectiveEpisode[] = rows.map((r) => {
const gap = gapById.get(r.id);
return {
id: r.id,
type: r.type,
onsetDay: dayKeyForUserTz(r.onsetAt, tz),
resolved: r.resolvedAt !== null,
recoveryGapDays: gap?.recoveryGapDays ?? null,
gapMeasurementDays: gap?.gapMeasurementDays ?? 0,
lifecycle: r.lifecycle,
};
});
const summary = summarizeIllnessRetrospective(episodes);
@@ -115,6 +152,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
episode_count: summary.episodeCount,
gap_sample_size: summary.gapSampleSize,
typical_gap_days: summary.typicalRecoveryGapDays,
include_recovery_gap: includeRecoveryGap,
},
});