diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 76a9c6868..ea5d940a5 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -10,6 +10,14 @@ vi.mock("@/lib/db", () => ({ }, measurement: { findMany: vi.fn().mockResolvedValue([]) }, moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + // FDREXTEND — the route now folds two non-measurement channels in (built by + // the shared `correlation-channel-series` helpers). Default to empty corpora + // so neither channel produces a point and the discovery stays trivially + // pair-less for the existing assertions. + medication: { findMany: vi.fn().mockResolvedValue([]) }, + medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, + illnessEpisode: { findMany: vi.fn().mockResolvedValue([]) }, + illnessDayLog: { findMany: vi.fn().mockResolvedValue([]) }, }, })); @@ -87,6 +95,20 @@ beforeEach(() => { [], ); (prisma.moodEntry.findMany as ReturnType).mockResolvedValue([]); + // FDREXTEND — the two non-measurement channels read their own models; default + // them empty so neither channel produces a point. + (prisma.medication.findMany as ReturnType).mockResolvedValue( + [], + ); + ( + prisma.medicationIntakeEvent.findMany as ReturnType + ).mockResolvedValue([]); + ( + prisma.illnessEpisode.findMany as ReturnType + ).mockResolvedValue([]); + (prisma.illnessDayLog.findMany as ReturnType).mockResolvedValue( + [], + ); }); const callGet = GET as unknown as (req: NextRequest) => Promise; diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 383c5df54..e9b511687 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -35,18 +35,9 @@ import { type NamedSeries, } from "@/lib/insights/correlation-discovery"; import { - buildComplianceMedicationContext, - buildMedicationComplianceBundle, - lastNonSkippedTakenAt, - SCHEDULE_COMPLIANCE_SELECT, -} from "@/lib/analytics/compliance"; -import { - buildComplianceDailySeries, - buildSymptomSeverityDailySeries, - type SymptomDayLogRow, - type SymptomEpisodeSpan, -} from "@/lib/insights/correlation-series-builders"; -import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; + fetchComplianceSeries, + fetchSymptomSeries, +} from "@/lib/insights/correlation-channel-series"; export const dynamic = "force-dynamic"; @@ -81,148 +72,6 @@ function toDailyMeans( .sort((a, b) => (a.day < b.day ? -1 : 1)); } -/** - * v1.21.0 (FDREXTEND) — build the user's MEDICATION_COMPLIANCE daily series. - * - * Pools every active, non-PRN medication's unified dose-history ledger over the - * window, then collapses to one per-day adherence rate (user-tz day keys). A - * user with no active medications (or no resolved slots) yields an empty - * series, so the channel degrades to absent. - */ -async function buildUserComplianceSeries( - userId: string, - since: Date, - tz: string, -): Promise { - const medications = await prisma.medication.findMany({ - // PRN (as-needed) medications have no expected doses → no defensible rate. - where: { userId, active: true, asNeeded: false }, - include: { - schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, - scheduleRevisions: { orderBy: { validFrom: "asc" } }, - }, - orderBy: { name: "asc" }, - }); - if (medications.length === 0) { - return { - key: MEDICATION_COMPLIANCE_CHANNEL_KEY, - role: "behaviour", - points: [], - }; - } - - const events = await prisma.medicationIntakeEvent.findMany({ - where: { - userId, - deletedAt: null, - medicationId: { in: medications.map((med) => med.id) }, - scheduledFor: { gte: since }, - }, - orderBy: { scheduledFor: "asc" }, - select: { - medicationId: true, - scheduledFor: true, - takenAt: true, - skipped: true, - }, - }); - - const now = new Date(); - const ledgerRows: DoseHistoryRow[] = []; - for (const medication of medications) { - const medEvents = events.filter((e) => e.medicationId === medication.id); - const ctx = buildComplianceMedicationContext( - medication, - lastNonSkippedTakenAt(medEvents), - tz, - ); - const bundle = buildMedicationComplianceBundle( - medEvents, - medication.schedules, - ctx, - now, - ); - ledgerRows.push(...bundle.ledgerRows); - } - - return buildComplianceDailySeries(ledgerRows, tz); -} - -/** - * v1.21.0 (FDREXTEND) — build the user's SYMPTOM_SEVERITY daily series (role set - * by the caller). Reads every in-window illness episode + its day-logs; the - * builder zero-fills healthy days ONLY across real episode spans, so a user - * with no episodes yields an empty series that degrades to absent. - */ -async function buildUserSymptomSeries( - userId: string, - since: Date, - now: Date, - tz: string, -): Promise { - const episodes = await prisma.illnessEpisode.findMany({ - // An episode overlaps the window when it onset before `now` and either is - // ongoing or resolved at/after the window start. - where: { - userId, - deletedAt: null, - onsetAt: { lte: now }, - OR: [{ resolvedAt: null }, { resolvedAt: { gte: since } }], - }, - select: { id: true, onsetAt: true, resolvedAt: true }, - }); - if (episodes.length === 0) { - return { key: SYMPTOM_SEVERITY_CHANNEL_KEY, role: "outcome", points: [] }; - } - - const dayLogRows = await prisma.illnessDayLog.findMany({ - where: { - userId, - deletedAt: null, - episodeId: { in: episodes.map((e) => e.id) }, - }, - select: { - date: true, - functionalImpact: true, - symptomLinks: { select: { severity: true } }, - }, - }); - - // Collapse each day-log to one burden value (functionalImpact, else the day's - // max linked symptom severity) — the same rule the recovery-gap track uses. - const dayLogs: SymptomDayLogRow[] = []; - for (const row of dayLogRows) { - if (row.functionalImpact != null) { - dayLogs.push({ day: row.date, impact: row.functionalImpact }); - continue; - } - 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) - dayLogs.push({ day: row.date, impact: maxSeverity }); - } - - const spans: SymptomEpisodeSpan[] = episodes.map((e) => ({ - onsetAt: e.onsetAt, - resolvedAt: e.resolvedAt, - })); - - return buildSymptomSeverityDailySeries({ - dayLogs, - episodes: spans, - tz, - windowStart: since, - windowEnd: now, - role: "outcome", - }); -} - export const GET = apiHandler(async () => { const { user } = await requireAuth(); @@ -298,8 +147,8 @@ export const GET = apiHandler(async () => { // v1.21.0 (FDREXTEND) — build the two non-measurement, non-mood channels from // their own sources. Each degrades to an empty series when the user has no // data, so the discovery loop drops the channel (it cannot clear n ≥ 20). - const complianceSeries = await buildUserComplianceSeries(user.id, since, tz); - const symptomSeries = await buildUserSymptomSeries(user.id, since, now, tz); + const complianceSeries = await fetchComplianceSeries(user.id, tz, since); + const symptomSeries = await fetchSymptomSeries(user.id, tz, since); const points = (key: string): DailySeriesPoint[] => key === "MOOD" diff --git a/src/lib/ai/coach/__tests__/correlations-read.test.ts b/src/lib/ai/coach/__tests__/correlations-read.test.ts index b6e813f83..fcda4fbed 100644 --- a/src/lib/ai/coach/__tests__/correlations-read.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-read.test.ts @@ -8,11 +8,19 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; const measurementFindMany = vi.fn(); const moodFindMany = vi.fn(); const userFindUnique = vi.fn(); +const medicationFindMany = vi.fn(); +const intakeEventFindMany = vi.fn(); +const illnessEpisodeFindMany = vi.fn(); +const illnessDayLogFindMany = vi.fn(); vi.mock("@/lib/db", () => ({ prisma: { measurement: { findMany: (a: unknown) => measurementFindMany(a) }, moodEntry: { findMany: (a: unknown) => moodFindMany(a) }, user: { findUnique: (a: unknown) => userFindUnique(a) }, + medication: { findMany: (a: unknown) => medicationFindMany(a) }, + medicationIntakeEvent: { findMany: (a: unknown) => intakeEventFindMany(a) }, + illnessEpisode: { findMany: (a: unknown) => illnessEpisodeFindMany(a) }, + illnessDayLog: { findMany: (a: unknown) => illnessDayLogFindMany(a) }, }, })); @@ -34,7 +42,10 @@ vi.mock("@/lib/insights/correlation-discovery", async (importOriginal) => { await importOriginal< typeof import("@/lib/insights/correlation-discovery") >(); - return { ...actual, discoverCorrelations: () => discoverCorrelations() }; + return { + ...actual, + discoverCorrelations: (...args: unknown[]) => discoverCorrelations(...args), + }; }); import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read"; @@ -44,6 +55,10 @@ describe("readCoachCorrelations", () => { measurementFindMany.mockReset().mockResolvedValue([]); moodFindMany.mockReset().mockResolvedValue([]); userFindUnique.mockReset().mockResolvedValue({ timezone: "Europe/Berlin" }); + medicationFindMany.mockReset().mockResolvedValue([]); + intakeEventFindMany.mockReset().mockResolvedValue([]); + illnessEpisodeFindMany.mockReset().mockResolvedValue([]); + illnessDayLogFindMany.mockReset().mockResolvedValue([]); loadBaselineProfile .mockReset() .mockResolvedValue({ ageYears: 40, sex: "MALE", heightCm: 180 }); @@ -143,4 +158,99 @@ describe("readCoachCorrelations", () => { expect(result.present).toBe(false); expect(result.reason).toBe("retrieval_failed"); }); + + // INTEGFIX — the measurement query must only ever carry real MeasurementType + // enum values; the three non-measurement channels (MOOD + the two new ones) + // are dropped, so the Postgres enum cast can never throw on a channel key. + it("never splices a non-measurement channel key into the type IN(...) filter", async () => { + discoverCorrelations.mockReturnValue({ + discovered: [], + pairsTested: 0, + fdrQ: 0.1, + minPairs: 20, + }); + await readCoachCorrelations("u1"); + const where = measurementFindMany.mock.calls[0]?.[0]?.where as { + type: { in: string[] }; + }; + const types = where.type.in; + expect(types).not.toContain("MOOD"); + expect(types).not.toContain("MEDICATION_COMPLIANCE"); + expect(types).not.toContain("SYMPTOM_SEVERITY"); + // The real enum channels are still present. + expect(types).toContain("SLEEP_DURATION"); + }); + + // INTEGFIX — drive the REAL discovery engine end-to-end with illness data so + // the SYMPTOM_SEVERITY channel flows through the non-enum query path and + // surfaces as a descriptive driver (proving the reader no longer throws an + // enum-cast error on a user with symptom data). + it("surfaces a symptom-severity driver through the real engine without an enum-cast error", async () => { + const real = await vi.importActual< + typeof import("@/lib/insights/correlation-discovery") + >("@/lib/insights/correlation-discovery"); + discoverCorrelations.mockImplementation((series) => + real.discoverCorrelations( + series as Parameters[0], + ), + ); + + // 45 consecutive days. Symptom burden (behaviour, day D) drives next-day + // sleep (outcome, day D+1) with a strong, deterministic linear relationship + // so the pair clears n ≥ 20 / p < 0.05 / FDR / effect-size floor. + const DAYS = 45; + const base = Date.UTC(2026, 4, 1, 12, 0, 0); // midday → tz-stable day key + const dayKey = (offset: number): string => { + const d = new Date(base + offset * 86_400_000); + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`; + }; + + const dayLogs: Array<{ + date: string; + functionalImpact: number; + symptomLinks: Array<{ severity: number }>; + }> = []; + const measurements: Array<{ + type: string; + value: number; + measuredAt: Date; + }> = []; + for (let i = 0; i < DAYS; i++) { + const burden = i % 4; // 0..3, real 0↔>0 variance + dayLogs.push({ + date: dayKey(i), + functionalImpact: burden, + symptomLinks: [], + }); + // Next-day sleep falls as today's burden rises (8h baseline − burden). + measurements.push({ + type: "SLEEP_DURATION", + value: 8 - burden, + measuredAt: new Date(base + (i + 1) * 86_400_000), + }); + } + + measurementFindMany.mockResolvedValue(measurements); + // One episode spanning the whole window so the builder zero-fills/overlays + // every logged day. + illnessEpisodeFindMany.mockResolvedValue([ + { + id: "ep1", + onsetAt: new Date(base), + resolvedAt: new Date(base + DAYS * 86_400_000), + }, + ]); + illnessDayLogFindMany.mockResolvedValue(dayLogs); + + const result = await readCoachCorrelations("u1"); + + expect(result.present).toBe(true); + const symptomDriver = result.drivers?.find( + (d) => + d.behaviour === "symptom severity" || d.outcome === "symptom severity", + ); + expect(symptomDriver).toBeDefined(); + expect(symptomDriver?.outcome).toBe("sleep duration"); + expect(symptomDriver?.n).toBeGreaterThanOrEqual(20); + }); }); diff --git a/src/lib/ai/coach/tools/correlations-read.ts b/src/lib/ai/coach/tools/correlations-read.ts index 131fb5890..997360678 100644 --- a/src/lib/ai/coach/tools/correlations-read.ts +++ b/src/lib/ai/coach/tools/correlations-read.ts @@ -27,11 +27,18 @@ import { prisma } from "@/lib/db"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { discoverCorrelations, + discoveryMeasurementTypes, DISCOVERY_BEHAVIOURS, DISCOVERY_OUTCOMES, + MEDICATION_COMPLIANCE_CHANNEL_KEY, + SYMPTOM_SEVERITY_CHANNEL_KEY, type DailySeriesPoint, type NamedSeries, } from "@/lib/insights/correlation-discovery"; +import { + fetchComplianceSeries, + fetchSymptomSeries, +} from "@/lib/insights/correlation-channel-series"; import { computeCoincidentDeviation, loadBaselineProfile, @@ -106,9 +113,15 @@ function toDailyMeans( .sort((a, b) => (a.day < b.day ? -1 : 1)); } +/** Natural labels for the non-measurement channel keys (read cleanly in prose). */ +const CHANNEL_LABELS: Record = { + [MEDICATION_COMPLIANCE_CHANNEL_KEY]: "medication adherence", + [SYMPTOM_SEVERITY_CHANNEL_KEY]: "symptom severity", +}; + /** Lower-case, space-separated label from a discovery channel key. */ function humanise(key: string): string { - return key.replace(/_/g, " ").toLowerCase(); + return CHANNEL_LABELS[key] ?? key.replace(/_/g, " ").toLowerCase(); } /** @@ -129,13 +142,15 @@ export async function readCoachCorrelations( const tz = userRow?.timezone ?? "Europe/Berlin"; const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY); - // MOOD is mood-entry backed, not a measurement type — strip it from the - // measurement query and source it separately, exactly like the route. - const behaviourTypes = DISCOVERY_BEHAVIOURS.filter( - (k) => k !== "MOOD", + // The non-MeasurementType channels (MOOD, MEDICATION_COMPLIANCE, + // SYMPTOM_SEVERITY) are backed by other models — `discoveryMeasurementTypes` + // drops them so the Postgres `type IN (...)` enum cast only ever sees real + // enum values; each is sourced separately below, exactly like the route. + const behaviourTypes = discoveryMeasurementTypes( + DISCOVERY_BEHAVIOURS, ) as MeasurementType[]; - const outcomeTypes = DISCOVERY_OUTCOMES.filter( - (k) => k !== "MOOD", + const outcomeTypes = discoveryMeasurementTypes( + DISCOVERY_OUTCOMES, ) as MeasurementType[]; const [measurements, moodEntries, coincidentDerived] = await Promise.all([ @@ -174,16 +189,34 @@ export async function readCoachCorrelations( tz, ); + // The two non-measurement, non-mood channels come from their own sources + // (the dose-history ledger + the illness day-log), folded in below exactly + // like the route. Each degrades to an empty series when the user has no + // data, so the discovery loop drops the channel (it cannot clear the n ≥ 20 + // floor) — it can never surface a fabricated driver. + const complianceSeries = await fetchComplianceSeries(userId, tz, since); + const symptomSeries = await fetchSymptomSeries(userId, tz, since); + const series: NamedSeries[] = []; for (const key of DISCOVERY_BEHAVIOURS) { - const points = - key === "MOOD" ? moodDaily : toDailyMeans(byType.get(key) ?? [], tz); - series.push({ key, role: "behaviour", points }); + if (key === MEDICATION_COMPLIANCE_CHANNEL_KEY) { + series.push(complianceSeries); + } else if (key === SYMPTOM_SEVERITY_CHANNEL_KEY) { + series.push({ ...symptomSeries, role: "behaviour" }); + } else { + const points = + key === "MOOD" ? moodDaily : toDailyMeans(byType.get(key) ?? [], tz); + series.push({ key, role: "behaviour", points }); + } } for (const key of DISCOVERY_OUTCOMES) { - const points = - key === "MOOD" ? moodDaily : toDailyMeans(byType.get(key) ?? [], tz); - series.push({ key, role: "outcome", points }); + if (key === SYMPTOM_SEVERITY_CHANNEL_KEY) { + series.push({ ...symptomSeries, role: "outcome" }); + } else { + const points = + key === "MOOD" ? moodDaily : toDailyMeans(byType.get(key) ?? [], tz); + series.push({ key, role: "outcome", points }); + } } const discovery = discoverCorrelations(series); diff --git a/src/lib/insights/correlation-channel-series.ts b/src/lib/insights/correlation-channel-series.ts new file mode 100644 index 000000000..7d2c65783 --- /dev/null +++ b/src/lib/insights/correlation-channel-series.ts @@ -0,0 +1,179 @@ +/** + * v1.21.0 (INTEGFIX) — shared data-fetching helpers for the two non-measurement + * FDR correlation channels (medication compliance + symptom severity). + * + * The pure series shapers live in `correlation-series-builders.ts`; the FDR + * engine itself is pure over `NamedSeries[]`. These two helpers own the DB reads + * that feed those shapers — the dose-history ledger (compliance) and the illness + * day-log (symptom severity) — so EVERY consumer of the discovery matrix (the + * `/api/insights/correlations` route AND the Coach `get_correlations` tool) + * builds the channels identically rather than re-implementing the queries, the + * tz keying, and the episode-span clamping per call site. + * + * Each helper degrades to an EMPTY series when the user has no data, so the + * channel drops out of discovery (it cannot clear the n ≥ 20 floor) rather than + * fabricating a constant. + */ +import { prisma } from "@/lib/db"; +import { + buildComplianceMedicationContext, + buildMedicationComplianceBundle, + lastNonSkippedTakenAt, + SCHEDULE_COMPLIANCE_SELECT, +} from "@/lib/analytics/compliance"; +import { + MEDICATION_COMPLIANCE_CHANNEL_KEY, + SYMPTOM_SEVERITY_CHANNEL_KEY, + type NamedSeries, +} from "@/lib/insights/correlation-discovery"; +import { + buildComplianceDailySeries, + buildSymptomSeverityDailySeries, + type SymptomDayLogRow, + type SymptomEpisodeSpan, +} from "@/lib/insights/correlation-series-builders"; +import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; + +/** + * v1.21.0 (FDREXTEND) — build the user's MEDICATION_COMPLIANCE daily series. + * + * Pools every active, non-PRN medication's unified dose-history ledger over the + * window, then collapses to one per-day adherence rate (user-tz day keys). A + * user with no active medications (or no resolved slots) yields an empty + * series, so the channel degrades to absent. + */ +export async function fetchComplianceSeries( + userId: string, + tz: string, + since: Date, +): Promise { + const medications = await prisma.medication.findMany({ + // PRN (as-needed) medications have no expected doses → no defensible rate. + where: { userId, active: true, asNeeded: false }, + include: { + schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, + scheduleRevisions: { orderBy: { validFrom: "asc" } }, + }, + orderBy: { name: "asc" }, + }); + if (medications.length === 0) { + return { + key: MEDICATION_COMPLIANCE_CHANNEL_KEY, + role: "behaviour", + points: [], + }; + } + + const events = await prisma.medicationIntakeEvent.findMany({ + where: { + userId, + deletedAt: null, + medicationId: { in: medications.map((med) => med.id) }, + scheduledFor: { gte: since }, + }, + orderBy: { scheduledFor: "asc" }, + select: { + medicationId: true, + scheduledFor: true, + takenAt: true, + skipped: true, + }, + }); + + const now = new Date(); + const ledgerRows: DoseHistoryRow[] = []; + for (const medication of medications) { + const medEvents = events.filter((e) => e.medicationId === medication.id); + const ctx = buildComplianceMedicationContext( + medication, + lastNonSkippedTakenAt(medEvents), + tz, + ); + const bundle = buildMedicationComplianceBundle( + medEvents, + medication.schedules, + ctx, + now, + ); + ledgerRows.push(...bundle.ledgerRows); + } + + return buildComplianceDailySeries(ledgerRows, tz); +} + +/** + * v1.21.0 (FDREXTEND) — build the user's SYMPTOM_SEVERITY daily series in the + * `outcome` role (callers that need the behaviour role re-tag the returned + * series — the points are role-invariant). Reads every in-window illness episode + * + its day-logs; the builder zero-fills healthy days ONLY across real episode + * spans, so a user with no episodes yields an empty series that degrades to + * absent. + */ +export async function fetchSymptomSeries( + userId: string, + tz: string, + since: Date, +): Promise { + const now = new Date(); + const episodes = await prisma.illnessEpisode.findMany({ + // An episode overlaps the window when it onset before `now` and either is + // ongoing or resolved at/after the window start. + where: { + userId, + deletedAt: null, + onsetAt: { lte: now }, + OR: [{ resolvedAt: null }, { resolvedAt: { gte: since } }], + }, + select: { id: true, onsetAt: true, resolvedAt: true }, + }); + if (episodes.length === 0) { + return { key: SYMPTOM_SEVERITY_CHANNEL_KEY, role: "outcome", points: [] }; + } + + const dayLogRows = await prisma.illnessDayLog.findMany({ + where: { + userId, + deletedAt: null, + episodeId: { in: episodes.map((e) => e.id) }, + }, + select: { + date: true, + functionalImpact: true, + symptomLinks: { select: { severity: true } }, + }, + }); + + // Collapse each day-log to one burden value (functionalImpact, else the day's + // max linked symptom severity) — the same rule the recovery-gap track uses. + const dayLogs: SymptomDayLogRow[] = []; + for (const row of dayLogRows) { + if (row.functionalImpact != null) { + dayLogs.push({ day: row.date, impact: row.functionalImpact }); + continue; + } + 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) + dayLogs.push({ day: row.date, impact: maxSeverity }); + } + + const spans: SymptomEpisodeSpan[] = episodes.map((e) => ({ + onsetAt: e.onsetAt, + resolvedAt: e.resolvedAt, + })); + + return buildSymptomSeverityDailySeries({ + dayLogs, + episodes: spans, + tz, + windowStart: since, + windowEnd: now, + role: "outcome", + }); +}