diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index dea015144..383c5df54 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -26,11 +26,27 @@ import { wallClockInTz } from "@/lib/tz/wall-clock"; import type { MeasurementType } from "@/generated/prisma/client"; 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 { + 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"; export const dynamic = "force-dynamic"; @@ -65,6 +81,148 @@ 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(); @@ -85,16 +243,19 @@ export const GET = apiHandler(async () => { select: { timezone: true }, }); const tz = profile?.timezone ?? "Europe/Berlin"; - const since = new Date(Date.now() - WINDOW_DAYS * MS_PER_DAY); + const now = new Date(); + const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY); - // MOOD is backed by mood entries, not measurements — it appears as both a - // behaviour and (v1.11.5 F3) an outcome channel, so filter it out of the - // measurement type list on either side. - const behaviourTypes = DISCOVERY_BEHAVIOURS.filter( - (k) => k !== "MOOD", + // Non-MeasurementType channels are backed by other models, not measurements — + // MOOD (MoodEntry), MEDICATION_COMPLIANCE (the dose-history ledger), and + // SYMPTOM_SEVERITY (the illness day-log). `discoveryMeasurementTypes` drops + // them so the `type IN (...)` query carries only real enum values; each is + // built from its own source below and folded into the series. + 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] = await Promise.all([ @@ -134,20 +295,33 @@ export const GET = apiHandler(async () => { tz, ); + // 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 points = (key: string): DailySeriesPoint[] => + key === "MOOD" + ? moodDaily + : toDailyMeans(measurementsByType.get(key) ?? [], tz); + const series: NamedSeries[] = []; for (const key of DISCOVERY_BEHAVIOURS) { - const points = - key === "MOOD" - ? moodDaily - : toDailyMeans(measurementsByType.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 { + series.push({ key, role: "behaviour", points: points(key) }); + } } for (const key of DISCOVERY_OUTCOMES) { - const points = - key === "MOOD" - ? moodDaily - : toDailyMeans(measurementsByType.get(key) ?? [], tz); - series.push({ key, role: "outcome", points }); + if (key === SYMPTOM_SEVERITY_CHANNEL_KEY) { + series.push({ ...symptomSeries, role: "outcome" }); + } else { + series.push({ key, role: "outcome", points: points(key) }); + } } const result = discoverCorrelations(series); @@ -158,6 +332,10 @@ export const GET = apiHandler(async () => { pairs_tested: result.pairsTested, discovered: result.discovered.length, fdr_q: result.fdrQ, + // FDREXTEND — per-channel day-counts so a dashboard can see whether the + // two sparse new channels reached the n ≥ 20 floor or degraded to absent. + compliance_days: complianceSeries.points.length, + symptom_days: symptomSeries.points.length, }, }); diff --git a/src/lib/insights/__tests__/correlation-discovery.test.ts b/src/lib/insights/__tests__/correlation-discovery.test.ts index 18845fcc5..8eb2bff08 100644 --- a/src/lib/insights/__tests__/correlation-discovery.test.ts +++ b/src/lib/insights/__tests__/correlation-discovery.test.ts @@ -366,3 +366,166 @@ describe("D2-2 — effect-size floor on discovered drivers", () => { expect(pair!.interpretation).toMatch(/never a cause/); }); }); + +// ── FDREXTEND — medication compliance + symptom severity channels ───── + +import { + DISCOVERY_BEHAVIOURS, + MEDICATION_COMPLIANCE_CHANNEL_KEY, + SYMPTOM_SEVERITY_CHANNEL_KEY, +} from "../correlation-discovery"; + +describe("compliance + symptom channels (FDREXTEND)", () => { + it("registers compliance as a behaviour and symptom in both roles", () => { + expect(DISCOVERY_BEHAVIOURS).toContain(MEDICATION_COMPLIANCE_CHANNEL_KEY); + expect(DISCOVERY_BEHAVIOURS).toContain(SYMPTOM_SEVERITY_CHANNEL_KEY); + expect(DISCOVERY_OUTCOMES).toContain(SYMPTOM_SEVERITY_CHANNEL_KEY); + }); + + it("each new channel is its own metricFamily (no shared collapse)", () => { + expect(metricFamily(MEDICATION_COMPLIANCE_CHANNEL_KEY)).toBe( + MEDICATION_COMPLIANCE_CHANNEL_KEY, + ); + expect(metricFamily(SYMPTOM_SEVERITY_CHANNEL_KEY)).toBe( + SYMPTOM_SEVERITY_CHANNEL_KEY, + ); + // Neither shares a family with a vital / sleep / mood channel, so they can + // pair cross-domain. + expect(metricFamily(MEDICATION_COMPLIANCE_CHANNEL_KEY)).not.toBe( + metricFamily(SYMPTOM_SEVERITY_CHANNEL_KEY), + ); + expect(metricFamily(MEDICATION_COMPLIANCE_CHANNEL_KEY)).not.toBe( + metricFamily("RESTING_HEART_RATE"), + ); + }); + + it("surfaces an adherence-dip → next-day symptom-flare link at a confident tier", () => { + // 70 contiguous days. Lower adherence on day D drives a higher symptom + // burden on day D+1 (the flagship cross-metric link). Adherence oscillates + // 100/70/40; symptom[d+1] = (100 - adherence[d]) scaled into 0..3. + const n = 70; + const adherence = Array.from({ length: n }, (_, i) => + i % 3 === 0 ? 100 : i % 3 === 1 ? 70 : 40, + ); + const symptom = [ + 0, + ...adherence + .slice(0, n - 1) + // (100 - a) maps 0..60 → ~0..3, plus a tiny deterministic jitter so the + // pair is not a perfect line (a real-world-shaped strong link). + .map((a, i) => ((100 - a) / 20) * (i % 2 === 0 ? 1.0 : 0.98)), + ]; + const result = discoverCorrelations([ + { + key: MEDICATION_COMPLIANCE_CHANNEL_KEY, + role: "behaviour", + points: longSeries(adherence), + }, + { + key: SYMPTOM_SEVERITY_CHANNEL_KEY, + role: "outcome", + points: longSeries(symptom), + }, + ]); + const pair = result.discovered.find( + (p) => + p.behaviour === MEDICATION_COMPLIANCE_CHANNEL_KEY && + p.outcome === SYMPTOM_SEVERITY_CHANNEL_KEY, + ); + expect(pair).toBeDefined(); + expect(pair!.n).toBeGreaterThanOrEqual(20); + // Negative: higher adherence → lower next-day symptom burden. + expect(pair!.r).toBeLessThan(0); + expect(pair!.tier).toBe("high"); + expect(pair!.interpretation).toMatch(/medication compliance/); + expect(pair!.interpretation).toMatch(/symptom severity/); + expect(pair!.interpretation).toMatch(/not a cause/); + }); + + it("does NOT surface a link when the overlap is below the n ≥ 20 floor", () => { + // A real adherence→symptom relationship but only ~10 paired days: sparse + // logging must withhold, never fabricate a confident driver. + const n = 12; + const adherence = Array.from({ length: n }, (_, i) => + i % 2 === 0 ? 100 : 40, + ); + const symptom = [ + 0, + ...adherence.slice(0, n - 1).map((a) => (100 - a) / 30), + ]; + const result = discoverCorrelations([ + { + key: MEDICATION_COMPLIANCE_CHANNEL_KEY, + role: "behaviour", + points: longSeries(adherence), + }, + { + key: SYMPTOM_SEVERITY_CHANNEL_KEY, + role: "outcome", + points: longSeries(symptom), + }, + ]); + // Below the floor the pair is never even tested. + expect(result.pairsTested).toBe(0); + expect(result.discovered).toHaveLength(0); + }); + + it("never tests the symptom → symptom self-lag", () => { + const n = 40; + const symptom = Array.from({ length: n }, (_, i) => (i % 4) / 2); + const result = discoverCorrelations([ + { + key: SYMPTOM_SEVERITY_CHANNEL_KEY, + role: "behaviour", + points: longSeries(symptom), + }, + { + key: SYMPTOM_SEVERITY_CHANNEL_KEY, + role: "outcome", + points: longSeries(symptom), + }, + ]); + expect( + result.discovered.find( + (p) => + p.behaviour === SYMPTOM_SEVERITY_CHANNEL_KEY && + p.outcome === SYMPTOM_SEVERITY_CHANNEL_KEY, + ), + ).toBeUndefined(); + expect(result.pairsTested).toBe(0); + }); + + it("compliance can pair with a vital outcome (cross-domain)", () => { + // Adherence dip today → resting HR drift up tomorrow. Confirms the channel + // is free to pair with vitals, not just symptom. + const n = 70; + const adherence = Array.from({ length: n }, (_, i) => + i % 3 === 0 ? 100 : i % 3 === 1 ? 65 : 35, + ); + const rhr = [ + 55, + ...adherence + .slice(0, n - 1) + .map((a, i) => 55 + (100 - a) / 6 + (i % 2 === 0 ? 0.1 : -0.1)), + ]; + const result = discoverCorrelations([ + { + key: MEDICATION_COMPLIANCE_CHANNEL_KEY, + role: "behaviour", + points: longSeries(adherence), + }, + { + key: "RESTING_HEART_RATE", + role: "outcome", + points: longSeries(rhr), + }, + ]); + const pair = result.discovered.find( + (p) => + p.behaviour === MEDICATION_COMPLIANCE_CHANNEL_KEY && + p.outcome === "RESTING_HEART_RATE", + ); + expect(pair).toBeDefined(); + expect(pair!.r).toBeLessThan(0); // higher adherence → lower next-day RHR + }); +}); diff --git a/src/lib/insights/__tests__/correlation-series-builders.test.ts b/src/lib/insights/__tests__/correlation-series-builders.test.ts new file mode 100644 index 000000000..5c3b9a78c --- /dev/null +++ b/src/lib/insights/__tests__/correlation-series-builders.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect } from "vitest"; + +import { + buildComplianceDailySeries, + buildSymptomSeverityDailySeries, + type SymptomDayLogRow, + type SymptomEpisodeSpan, +} from "../correlation-series-builders"; +import { + discoverCorrelations, + MEDICATION_COMPLIANCE_CHANNEL_KEY, + SYMPTOM_SEVERITY_CHANNEL_KEY, + type NamedSeries, +} from "../correlation-discovery"; +import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; + +/** Minimal slot ledger row at a UTC instant with a given status. */ +function slot(at: string, status: DoseHistoryRow["status"]): DoseHistoryRow { + return { kind: "slot", at: new Date(at), status } as DoseHistoryRow; +} + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +describe("buildComplianceDailySeries", () => { + it("computes a per-day rate pooled across slots, keyed in the user tz", () => { + const rows: DoseHistoryRow[] = [ + // 2026-03-02 (Berlin): 1 taken, 1 missed → 50%. + slot("2026-03-02T07:00:00Z", "taken_on_time"), + slot("2026-03-02T19:00:00Z", "missed"), + // 2026-03-03 (Berlin): both taken → 100%. + slot("2026-03-03T07:00:00Z", "taken_on_time"), + slot("2026-03-03T19:00:00Z", "taken_late"), + ]; + const series = buildComplianceDailySeries(rows, "Europe/Berlin"); + expect(series.key).toBe(MEDICATION_COMPLIANCE_CHANNEL_KEY); + expect(series.role).toBe("behaviour"); + expect(series.points).toEqual([ + { day: "2026-03-02", value: 50 }, + { day: "2026-03-03", value: 100 }, + ]); + }); + + it("re-keys late-night instants into the correct user-tz day (not UTC)", () => { + // 2026-03-02T23:30Z is 2026-03-03 00:30 in Berlin (UTC+1) — must bucket on + // the Berlin day, proving the day-key is tz-aware, not a UTC slice. + const rows: DoseHistoryRow[] = [ + slot("2026-03-02T23:30:00Z", "taken_on_time"), + ]; + const series = buildComplianceDailySeries(rows, "Europe/Berlin"); + expect(series.points).toEqual([{ day: "2026-03-03", value: 100 }]); + }); + + it("excludes skipped / upcoming / ad-hoc rows from the denominator", () => { + const rows: DoseHistoryRow[] = [ + slot("2026-03-02T07:00:00Z", "taken_on_time"), + slot("2026-03-02T12:00:00Z", "skipped"), + slot("2026-03-02T19:00:00Z", "upcoming"), + { + kind: "ad_hoc", + at: new Date("2026-03-02T15:00:00Z"), + status: "ad_hoc", + } as DoseHistoryRow, + ]; + const series = buildComplianceDailySeries(rows, "Europe/Berlin"); + // Only the one taken slot counts → 100%, denominator 1. + expect(series.points).toEqual([{ day: "2026-03-02", value: 100 }]); + }); + + it("degrades to an empty series when no resolved slots exist", () => { + const rows: DoseHistoryRow[] = [ + slot("2026-03-02T07:00:00Z", "skipped"), + slot("2026-03-02T19:00:00Z", "upcoming"), + ]; + const series = buildComplianceDailySeries(rows, "Europe/Berlin"); + expect(series.points).toHaveLength(0); + }); +}); + +describe("buildSymptomSeverityDailySeries", () => { + const tz = "Europe/Berlin"; + + it("zero-fills healthy days only across a real episode span", () => { + // Episode runs 2026-03-01 → 2026-03-05. Logs: impact 2 on the 2nd, 3 on the + // 3rd. The other in-span days fill to 0 (healthy). + const windowStart = new Date("2026-02-01T00:00:00Z"); + const windowEnd = new Date("2026-03-31T00:00:00Z"); + const episodes: SymptomEpisodeSpan[] = [ + { + onsetAt: new Date("2026-03-01T08:00:00Z"), + resolvedAt: new Date("2026-03-05T08:00:00Z"), + }, + ]; + const dayLogs: SymptomDayLogRow[] = [ + { day: "2026-03-02", impact: 2 }, + { day: "2026-03-03", impact: 3 }, + ]; + const series = buildSymptomSeverityDailySeries({ + dayLogs, + episodes, + tz, + windowStart, + windowEnd, + role: "outcome", + }); + expect(series.key).toBe(SYMPTOM_SEVERITY_CHANNEL_KEY); + expect(series.role).toBe("outcome"); + const byDay = new Map(series.points.map((p) => [p.day, p.value])); + // Span days 01..05 present; logged days carry their impact, others 0. + expect(byDay.get("2026-03-01")).toBe(0); + expect(byDay.get("2026-03-02")).toBe(2); + expect(byDay.get("2026-03-03")).toBe(3); + expect(byDay.get("2026-03-04")).toBe(0); + expect(byDay.get("2026-03-05")).toBe(0); + // A day far outside the span is NOT fabricated. + expect(byDay.has("2026-02-15")).toBe(false); + // Genuine variance exists (0 ↔ >0), so the series is usable by Pearson. + const values = series.points.map((p) => p.value); + expect(Math.max(...values)).toBeGreaterThan(Math.min(...values)); + }); + + it("degrades to an empty series when the user has no episodes", () => { + const series = buildSymptomSeverityDailySeries({ + dayLogs: [], + episodes: [], + tz, + windowStart: new Date("2026-02-01T00:00:00Z"), + windowEnd: new Date("2026-03-31T00:00:00Z"), + role: "outcome", + }); + expect(series.points).toHaveLength(0); + }); + + it("clamps an ongoing episode (null resolvedAt) to the window end", () => { + const windowStart = new Date("2026-03-01T00:00:00Z"); + const windowEnd = new Date("2026-03-04T00:00:00Z"); + const episodes: SymptomEpisodeSpan[] = [ + { onsetAt: new Date("2026-03-02T08:00:00Z"), resolvedAt: null }, + ]; + const series = buildSymptomSeverityDailySeries({ + dayLogs: [{ day: "2026-03-02", impact: 1 }], + episodes, + tz, + windowStart, + windowEnd, + role: "outcome", + }); + const days = series.points.map((p) => p.day); + // Span clamps to windowEnd (03-04); no day past it is fabricated. + expect(days).toContain("2026-03-02"); + expect(days).toContain("2026-03-04"); + expect(days.every((d) => d <= "2026-03-04")).toBe(true); + }); +}); + +describe("integration — built series flow through the discovery gates", () => { + const tz = "Europe/Berlin"; + + it("a sparse, thin-overlap compliance series produces NO confident driver", () => { + // Only ~8 days of compliance data: below the n ≥ 20 floor → withheld. + const rows: DoseHistoryRow[] = []; + for (let i = 0; i < 8; i++) { + const day = new Date(Date.UTC(2026, 2, 1 + i, 7, 0, 0)); + rows.push( + slot(day.toISOString(), i % 2 === 0 ? "taken_on_time" : "missed"), + ); + } + const compliance = buildComplianceDailySeries(rows, tz); + + // A symptom outcome over the same thin span. + const episodes: SymptomEpisodeSpan[] = [ + { + onsetAt: new Date("2026-03-01T08:00:00Z"), + resolvedAt: new Date("2026-03-09T08:00:00Z"), + }, + ]; + const symptom = buildSymptomSeverityDailySeries({ + dayLogs: Array.from({ length: 8 }, (_, i) => ({ + day: `2026-03-0${i + 1}`, + impact: i % 2 === 0 ? 0 : 2, + })), + episodes, + tz, + windowStart: new Date("2026-02-01T00:00:00Z"), + windowEnd: new Date("2026-03-31T00:00:00Z"), + role: "outcome", + }); + + const result = discoverCorrelations([compliance, symptom]); + expect(result.pairsTested).toBe(0); + expect(result.discovered).toHaveLength(0); + }); + + it("a sufficient adherence-dip → symptom-flare built series surfaces the link", () => { + // 50 days of daily compliance + a 50-day episode span with logged flares on + // low-adherence days → a discoverable next-day link. + const start = Date.UTC(2026, 0, 1, 7, 0, 0); + const rows: DoseHistoryRow[] = []; + const dayLogs: SymptomDayLogRow[] = []; + for (let i = 0; i < 50; i++) { + const at = new Date(start + i * MS_PER_DAY); + const low = i % 3 === 2; // every third day is a big adherence dip + rows.push(slot(at.toISOString(), low ? "missed" : "taken_on_time")); + // Symptom on day D+1 flares after a dip on day D. + const prevLow = i > 0 && (i - 1) % 3 === 2; + const dayKey = new Date(start + i * MS_PER_DAY) + .toISOString() + .slice(0, 10); + dayLogs.push({ day: dayKey, impact: prevLow ? 3 : 0 }); + } + const compliance = buildComplianceDailySeries(rows, tz); + const symptom = buildSymptomSeverityDailySeries({ + dayLogs, + episodes: [ + { + onsetAt: new Date(start), + resolvedAt: new Date(start + 50 * MS_PER_DAY), + }, + ], + tz, + windowStart: new Date(start - 5 * MS_PER_DAY), + windowEnd: new Date(start + 55 * MS_PER_DAY), + role: "outcome", + }); + + const result = discoverCorrelations([compliance, symptom]); + const pair = result.discovered.find( + (p) => + p.behaviour === MEDICATION_COMPLIANCE_CHANNEL_KEY && + p.outcome === SYMPTOM_SEVERITY_CHANNEL_KEY, + ); + expect(pair).toBeDefined(); + expect(pair!.n).toBeGreaterThanOrEqual(20); + expect(pair!.r).toBeLessThan(0); // higher adherence → lower next-day symptom + expect(pair!.interpretation).toMatch(/not a cause/); + }); + + it("the two built channels keep the never-causal framing", () => { + const series: NamedSeries[] = [ + buildComplianceDailySeries( + Array.from({ length: 40 }, (_, i) => + slot( + new Date(Date.UTC(2026, 0, 1 + i, 7)).toISOString(), + i % 4 === 0 ? "missed" : "taken_on_time", + ), + ), + tz, + ), + ]; + // Pair against a vital with a clean linear lag so a driver surfaces. + const start = Date.UTC(2026, 0, 1); + series.push({ + key: "RESTING_HEART_RATE", + role: "outcome", + points: Array.from({ length: 40 }, (_, i) => ({ + day: new Date(start + i * MS_PER_DAY).toISOString().slice(0, 10), + value: 55 + (i > 0 && (i - 1) % 4 === 0 ? 8 : 0) + (i % 2) * 0.1, + })), + }); + const result = discoverCorrelations(series); + for (const p of result.discovered) { + expect(p.interpretation).toMatch(/not a cause|never a cause/); + } + }); +}); diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index 9ebc9cf0c..54c4d3f05 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -26,9 +26,17 @@ * already-fetched daily series — the DB read lives in the route. * * Framing discipline: descriptive, never causal; every pair carries n, r, - * p, and the BH-adjusted q; medication compliance is NOT yet a behaviour - * channel (the cadence-aware per-day rate needs the compliance engine — a - * later wave), documented here so the omission is intentional, not a gap. + * p, and the BH-adjusted q. + * + * v1.21.0 (FDREXTEND) — medication compliance (a daily adherence rate from the + * compliance engine's dose-history ledger) and symptom severity (the illness + * day-log functional-impact / symptom-burden track) are now first-class daily + * channels in the matrix, so the high-value "adherence dip → symptom flare" and + * "compliance↓ → a vital drifting" links can finally be discovered. They are + * the sparsest, noisiest inputs in the system — they flow through the SAME + * n ≥ 20 / p < 0.05 / BH-FDR / effect-size-floor / shrinkage gates as every + * other channel, so a thin series degrades to absent rather than to a spurious + * link. The series builders live in `correlation-series-builders.ts`. */ import { pearson, MIN_PAIRED_N } from "@/lib/insights/correlations"; @@ -94,6 +102,14 @@ export function metricFamily(key: string): string { // tautology as MOOD→MOOD and is excluded too. if (key.startsWith(FACTOR_CHANNEL_PREFIX) || key === "MOOD") return "MOOD"; if (key.startsWith("BLOOD_PRESSURE")) return "BLOOD_PRESSURE"; + // v1.21.0 (FDREXTEND) — medication compliance and symptom severity each form + // their OWN single-channel family, so the loop's same-family guard collapses + // only the self-lag (compliance→compliance, symptom→symptom). The returned + // key equals the channel key for both, so neither shares a family with any + // vital / sleep / mood channel — they remain free to pair cross-domain + // (the whole point: adherence↓ → next-day symptom↑, or compliance↓ → a vital + // drifting). No special-case branch is needed; the `return key` fall-through + // already isolates them. This comment documents the deliberate decision. return key; } @@ -286,6 +302,62 @@ function humanise(key: string): string { /** Namespace prefix for a RATED-mood-factor discovery channel (v1.14.0). */ export const FACTOR_CHANNEL_PREFIX = "FACTOR:"; +/** + * v1.21.0 (FDREXTEND) — stable channel key for the daily medication-compliance + * adherence rate (per-day taken/scheduled, 0–100). Sourced from the compliance + * engine's unified dose-history ledger, NOT a `MeasurementType`, so the caller + * builds its series separately (the way MOOD is read from MoodEntry) and folds + * it in. A BEHAVIOUR channel: the actionable, high-value direction is + * "adherence dip today → a worse outcome tomorrow" (next-day symptom flare or a + * vital drifting), so compliance lags the outcome by a day like every other + * behaviour. It forms its own `metricFamily`, so the only pair the loop skips is + * the compliance→compliance self-lag. + */ +export const MEDICATION_COMPLIANCE_CHANNEL_KEY = "MEDICATION_COMPLIANCE"; + +/** + * v1.21.0 (FDREXTEND) — stable channel key for the daily symptom-severity / + * functional-impact burden (0–3; 0 = healthy, 3 = bedbound). Sourced from the + * illness day-log (`functionalImpact`, else max linked symptom severity), NOT a + * `MeasurementType`. It rides BOTH roles like MOOD: as an OUTCOME it surfaces + * "adherence dip today → symptom flare tomorrow"; as a BEHAVIOUR it surfaces + * "symptom burden today → a vital drifting tomorrow". The same-family guard + * skips only the symptom→symptom self-lag. The series is built only across the + * span the user actually logs illness (healthy days inside that span = 0); a + * user who never logs illness yields an EMPTY series that degrades to absent — + * never a spurious all-zero constant that could fabricate a link. + */ +export const SYMPTOM_SEVERITY_CHANNEL_KEY = "SYMPTOM_SEVERITY"; + +/** + * v1.21.0 (FDREXTEND) — discovery channels that are NOT `MeasurementType` enum + * values: each is backed by a different model (MOOD → MoodEntry, + * MEDICATION_COMPLIANCE → the dose-history ledger, SYMPTOM_SEVERITY → the + * illness day-log). EVERY caller that derives a `MeasurementType[]` list from + * `DISCOVERY_BEHAVIOURS` / `DISCOVERY_OUTCOMES` to feed a Prisma + * `measurement.findMany({ where: { type: { in } } })` MUST exclude these — a + * non-enum string in the `IN (...)` list errors the Postgres enum cast. Use + * {@link discoveryMeasurementTypes} (or this set) rather than re-spelling the + * `k !== "MOOD"` filter, so a future non-measurement channel cannot be missed at + * one call site and crash its query. + */ +export const NON_MEASUREMENT_DISCOVERY_CHANNELS: ReadonlySet = new Set([ + "MOOD", + MEDICATION_COMPLIANCE_CHANNEL_KEY, + SYMPTOM_SEVERITY_CHANNEL_KEY, +]); + +/** + * v1.21.0 (FDREXTEND) — the subset of `keys` that ARE real `MeasurementType` + * enum values (i.e. excluding every {@link NON_MEASUREMENT_DISCOVERY_CHANNELS} + * channel), safe to splice into a Prisma `type IN (...)` filter. The cast is the + * caller's existing `as MeasurementType[]` assertion — this only drops the + * non-enum keys first. + */ +export function discoveryMeasurementTypes(keys: readonly string[]): string[] { + return keys.filter((k) => !NON_MEASUREMENT_DISCOVERY_CHANNELS.has(k)); +} + /** * Run the FDR-controlled discovery over the behaviour × outcome matrix. * @@ -382,9 +454,15 @@ export function discoverCorrelations( /** * The curated discovery matrix — the channels the engine pairs. Behaviours - * (lag sources) on the left, outcomes (lag targets) on the right. Medication - * compliance is deliberately NOT yet a behaviour channel — its cadence-aware - * per-day rate needs the compliance engine; folding it in is a later wave. + * (lag sources) on the left, outcomes (lag targets) on the right. + * + * v1.21.0 (FDREXTEND) — medication compliance is NOW a behaviour channel (its + * cadence-aware per-day rate comes from the compliance engine's dose-history + * ledger; the caller builds the series and folds it in, the way MOOD is read + * from MoodEntry). Symptom severity rides both roles (see the OUTCOMES note). + * Both are sparse, noisy inputs — the engine's existing n ≥ 20 / p < 0.05 / + * BH-FDR / effect-size-floor / shrinkage gates apply UNCHANGED, so a thin + * compliance or symptom series cannot surface a confident driver. */ export const DISCOVERY_BEHAVIOURS = [ "TIME_IN_DAYLIGHT", @@ -392,6 +470,12 @@ export const DISCOVERY_BEHAVIOURS = [ "BLOOD_GLUCOSE", "BLOOD_PRESSURE_SYS", "ACTIVITY_STEPS", + // v1.21.0 (FDREXTEND) — daily adherence rate as a lag source: the high-value + // "adherence dip today → a worse outcome tomorrow" direction. + MEDICATION_COMPLIANCE_CHANNEL_KEY, + // v1.21.0 (FDREXTEND) — symptom burden as a lag source too: "more symptomatic + // today → a vital drifting tomorrow". Mirrored as an outcome below. + SYMPTOM_SEVERITY_CHANNEL_KEY, ] as const; export const DISCOVERY_OUTCOMES = [ @@ -406,6 +490,11 @@ export const DISCOVERY_OUTCOMES = [ // to the existing "mood today → next-day outcome" direction. BH-FDR // already controls the larger pair family this opens up. "MOOD", + // v1.21.0 (FDREXTEND) — symptom severity as an OUTCOME: the flagship + // "adherence dip today → symptom flare tomorrow" link (compliance behaviour → + // symptom outcome). The same-family guard skips the symptom→symptom self-lag; + // BH-FDR controls the wider family the dual-role channel opens up. + SYMPTOM_SEVERITY_CHANNEL_KEY, // Wrist temperature is a credible future OUTCOME channel (near-daily, so // n ≥ 20 is reachable; "did a hard workout / late alcohol raise next-night // temperature?"), but it is deliberately NOT a channel yet: it is diff --git a/src/lib/insights/correlation-series-builders.ts b/src/lib/insights/correlation-series-builders.ts new file mode 100644 index 000000000..b387fa643 --- /dev/null +++ b/src/lib/insights/correlation-series-builders.ts @@ -0,0 +1,193 @@ +/** + * v1.21.0 (FDREXTEND) — daily-series builders for the two cross-metric channels + * the FDR correlation matrix could not previously see: medication compliance + * and symptom severity. + * + * These are the SPARSEST, noisiest inputs in the system, so the builders are + * deliberately conservative: + * + * - **Compliance** is the per-day adherence rate (0–100) from the compliance + * engine's unified dose-history ledger. Only days the cadence ACTUALLY + * expected a dose produce a point (off-cadence / off-week days emit nothing), + * so a sparse schedule never fabricates a 0%-or-100% point on a day it did + * not dose. The ledger's `at` instant is re-keyed to the user's display tz + * here (the engine's `dailyComplianceRatesFromLedger` keys in fixed Berlin), + * so the day-key basis aligns with every other discovery series. + * + * - **Symptom severity** is the illness day-log functional-impact / symptom + * burden (0–3). The logged days are SPARSE and, on their own, often + * constant (a short flare logged at impact 2 for three days is a flat line + * with no variance — Pearson cannot use it). To make the actionable + * "adherence dip → symptom flare" link discoverable we zero-fill HEALTHY + * days, but ONLY across the span of the user's real episodes (onset → + * resolved/window-end) intersected with the window. A user who never logs an + * illness episode yields an EMPTY series, so the channel DEGRADES TO ABSENT + * rather than to a spurious all-zero constant. Within an episode span, days + * the user didn't log are treated as 0 (healthy) — the same constant-0 + * healthy baseline the recovery-gap symptom track already uses. + * + * Both series then flow UNCHANGED through `discoverCorrelations` — the n ≥ 20 + * paired-day floor, the BH-FDR control, the effect-size floor, and the + * James-Stein shrinkage all apply exactly as they do to every vital channel. + * Nothing here computes a statistic; these are pure series shapers. + * + * Pure over already-fetched rows — the DB reads live in the caller (the route), + * mirroring how the engine itself is pure over `NamedSeries[]`. + */ +import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; +import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history"; +import { + MEDICATION_COMPLIANCE_CHANNEL_KEY, + SYMPTOM_SEVERITY_CHANNEL_KEY, + type DailySeriesPoint, + type NamedSeries, +} from "@/lib/insights/correlation-discovery"; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** + * Build the daily MEDICATION_COMPLIANCE behaviour series from the user's pooled + * dose-history ledger rows (every medication's `slot` rows over the window). + * + * One point per calendar day (user tz) that had at least one scheduled, + * resolved slot (`taken_on_time` / `taken_late` / `missed`). The rate is the + * day's pooled `taken / (taken + missed)`, 0–100 — the SAME numerator / + * denominator the window tally uses. `skipped` (deliberate), `upcoming` (window + * not open), and `ad_hoc` (off-schedule top-up) rows never enter the day's + * counts, exactly as the engine excludes them, so a day whose only rows are + * skips emits no point (no fabricated 0%). + * + * Returns a behaviour `NamedSeries`. When no day clears the slot test the + * `points` array is empty — the discovery loop then drops the channel (it + * cannot clear the n ≥ 20 floor), so the channel degrades to absent. + */ +export function buildComplianceDailySeries( + ledgerRows: DoseHistoryRow[], + tz: string, +): NamedSeries { + const byDay = new Map(); + for (const row of ledgerRows) { + // Only scheduled slots have a defensible denominator (ad-hoc top-ups are + // excluded from the rate exactly as the ledger tally excludes them). + if (row.kind !== "slot") continue; + const isTaken = + row.status === "taken_on_time" || row.status === "taken_late"; + const isMissed = row.status === "missed"; + if (!isTaken && !isMissed) continue; + + const day = dayKeyForUserTz(row.at, tz); + const bucket = byDay.get(day) ?? { taken: 0, missed: 0 }; + if (isTaken) bucket.taken += 1; + else bucket.missed += 1; + byDay.set(day, bucket); + } + + const points: DailySeriesPoint[] = []; + for (const [day, { taken, missed }] of byDay) { + const denom = taken + missed; + if (denom === 0) continue; + points.push({ day, value: Math.min(100, (taken / denom) * 100) }); + } + points.sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)); + return { + key: MEDICATION_COMPLIANCE_CHANNEL_KEY, + role: "behaviour", + points, + }; +} + +/** A logged symptom-burden day (already keyed in the user's local tz). */ +export interface SymptomDayLogRow { + /** Stored `date` string — already a user-tz `YYYY-MM-DD` day key. */ + day: string; + /** The day's burden (0–3): functionalImpact, else max linked severity. */ + impact: number; +} + +/** An episode's active span (instants), used to bound the healthy-day fill. */ +export interface SymptomEpisodeSpan { + onsetAt: Date; + /** NULL = ongoing → bounded by the window end at build time. */ + resolvedAt: Date | null; +} + +/** Inclusive `YYYY-MM-DD` day key for an instant in the user's tz. */ +function dayKey(at: Date, tz: string): string { + return dayKeyForUserTz(at, tz); +} + +/** + * Build the daily SYMPTOM_SEVERITY series (rides both roles; this returns it in + * the requested `role`). Healthy days are zero-filled — but ONLY across the + * span of the user's real episodes intersected with `[windowStart, windowEnd]`, + * so a user with no episodes yields an EMPTY series (the channel degrades to + * absent, never a spurious all-zero constant). + * + * Algorithm: + * 1. Compute the set of in-window calendar days covered by ANY episode span + * (onset → resolved, an ongoing episode clamped to `windowEnd`). This is + * the only span where a "healthy = 0" claim is defensible: the user was in + * a logging-relevant period. + * 2. Initialise every such day to 0 (healthy baseline). + * 3. Overlay the logged impacts (a day logged at impact k → k; multiple logs + * on a day → the MAX, the most-symptomatic reading). + * + * The result is a dense-within-episode-spans series with genuine 0↔>0 variance + * when the user had symptomatic days, and NOTHING at all when they had no + * episodes. Pure. + */ +export function buildSymptomSeverityDailySeries(args: { + dayLogs: SymptomDayLogRow[]; + episodes: SymptomEpisodeSpan[]; + tz: string; + windowStart: Date; + windowEnd: Date; + role: "behaviour" | "outcome"; +}): NamedSeries { + const { dayLogs, episodes, tz, windowStart, windowEnd, role } = args; + + const startKey = dayKey(windowStart, tz); + const endKey = dayKey(windowEnd, tz); + + // Step 1+2 — zero-fill the healthy baseline across episode spans (clamped to + // the window). An episode with no in-window days contributes nothing. + const byDay = new Map(); + for (const ep of episodes) { + const epStart = ep.onsetAt.getTime(); + const epEnd = (ep.resolvedAt ?? windowEnd).getTime(); + // Clamp the iteration to the window so we never fabricate days outside it. + const from = Math.max(epStart, windowStart.getTime()); + const to = Math.min(epEnd, windowEnd.getTime()); + if (from > to) continue; + // Walk calendar days from `from` to `to`, keying each in the user's tz. + // Step by a day in ms; the tz-key collapses any DST hour drift to the + // right local day (we never index by the raw ms, only by the day key). + for (let t = from; t <= to + MS_PER_DAY; t += MS_PER_DAY) { + const key = dayKey(new Date(t), tz); + if (key < startKey || key > endKey) continue; + if (!byDay.has(key)) byDay.set(key, 0); + if (key === endKey) break; + } + } + + // If no episode put a single day on the board, the channel is absent. + if (byDay.size === 0) { + return { key: SYMPTOM_SEVERITY_CHANNEL_KEY, role, points: [] }; + } + + // Step 3 — overlay logged impacts (MAX per day). A logged day outside every + // episode span is still honoured (it is, by definition, a symptomatic day the + // user recorded), so it joins the series even if the span walk missed it. + for (const log of dayLogs) { + if (log.day < startKey || log.day > endKey) continue; + if (!Number.isFinite(log.impact)) continue; + const prev = byDay.get(log.day); + byDay.set(log.day, prev == null ? log.impact : Math.max(prev, log.impact)); + } + + const points: DailySeriesPoint[] = [...byDay.entries()] + .map(([day, value]) => ({ day, value })) + .sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0)); + + return { key: SYMPTOM_SEVERITY_CHANNEL_KEY, role, points }; +} diff --git a/src/lib/insights/metric-correlation-context.ts b/src/lib/insights/metric-correlation-context.ts index f9b404cc9..2d18cd396 100644 --- a/src/lib/insights/metric-correlation-context.ts +++ b/src/lib/insights/metric-correlation-context.ts @@ -25,6 +25,7 @@ import { prisma } from "@/lib/db"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { discoverCorrelations, + discoveryMeasurementTypes, DISCOVERY_BEHAVIOURS, DISCOVERY_OUTCOMES, type DailySeriesPoint, @@ -103,13 +104,17 @@ export async function getRelevantCorrelationsForMetric( const tz = profile?.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", + // Non-measurement channels (MOOD, and v1.21.0 MEDICATION_COMPLIANCE / + // SYMPTOM_SEVERITY) are backed by other models — `discoveryMeasurementTypes` + // strips them from the measurement `type IN (...)` query (a non-enum string + // would error the cast). This per-metric-card surface does not populate the + // compliance / symptom channels (they degrade to absent here); the + // canonical `/api/insights/correlations` route builds them. + 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] = await Promise.all([ diff --git a/src/lib/insights/narrative/period-narrative.ts b/src/lib/insights/narrative/period-narrative.ts index 12f14a004..96ce3adc6 100644 --- a/src/lib/insights/narrative/period-narrative.ts +++ b/src/lib/insights/narrative/period-narrative.ts @@ -34,6 +34,7 @@ import { prisma } from "@/lib/db"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { discoverCorrelations, + discoveryMeasurementTypes, DISCOVERY_BEHAVIOURS, DISCOVERY_OUTCOMES, FACTOR_CHANNEL_PREFIX, @@ -532,13 +533,19 @@ export async function buildPeriodNarrativeContext( ); // The full set of types any beat needs: delta metrics ∪ banded vitals ∪ - // discovery channels (minus MOOD, which is mood-entry backed). + // discovery channels (minus the NON-measurement channels: MOOD is mood-entry + // backed; v1.21.0 MEDICATION_COMPLIANCE / SYMPTOM_SEVERITY are + // ledger / illness-day-log backed — none are MeasurementType enum values, so + // `discoveryMeasurementTypes` drops them from the `type IN (...)` query). + // Those two channels are not populated on the period-narrative surface (they + // degrade to absent here); the canonical `/api/insights/correlations` route + // builds them. const measurementTypes = Array.from( new Set([ ...DELTA_METRICS.map((m) => m.type), ...VITALS_BASELINE_TYPES, - ...DISCOVERY_BEHAVIOURS.filter((k) => k !== "MOOD"), - ...DISCOVERY_OUTCOMES.filter((k) => k !== "MOOD"), + ...discoveryMeasurementTypes(DISCOVERY_BEHAVIOURS), + ...discoveryMeasurementTypes(DISCOVERY_OUTCOMES), ]), ) as MeasurementType[]; diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index 62fe5e86e..2e309ba98 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -10,6 +10,7 @@ import { } from "@/lib/jobs/insight-status-generate-shared"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { + discoveryMeasurementTypes, DISCOVERY_BEHAVIOURS, DISCOVERY_OUTCOMES, } from "@/lib/insights/correlation-discovery"; @@ -25,10 +26,19 @@ import { annotate } from "@/lib/logging/context"; * relation would be silently skipped for the day. `MOOD` is mood-entry backed * (folded separately via `includeMood`), so it is excluded from the * measurement-type set here. + * + * v1.21.0 (FDREXTEND) — `MEDICATION_COMPLIANCE` (dose-history ledger) and + * `SYMPTOM_SEVERITY` (illness day-log) are likewise NON-measurement channels; + * `discoveryMeasurementTypes` excludes them too, or the `type IN (...)` groupBy + * below would try to cast a non-enum string to `MeasurementType` and error. + * Their own data changes still flip a card's gate through their source models — + * they simply do not belong in the measurement fingerprint set. */ -const CORRELATION_CHANNEL_TYPES: readonly MeasurementType[] = Array.from( - new Set([...DISCOVERY_BEHAVIOURS, ...DISCOVERY_OUTCOMES]), -).filter((key) => key !== "MOOD") as MeasurementType[]; +const CORRELATION_CHANNEL_TYPES: readonly MeasurementType[] = + discoveryMeasurementTypes([ + ...DISCOVERY_BEHAVIOURS, + ...DISCOVERY_OUTCOMES, + ]) as MeasurementType[]; /** * Shared cache-read for the seven `*-status.ts` insight generators.