merge(INTEGFIX): wire compliance/symptom FDR channels into the Coach correlations reader

This commit is contained in:
Marc-André Bombeck committed 2026-06-26 13:50:48 +02:00
commit 1cee07b9f0
5 files changed
+363 -170

No files matched your search

@@ -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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(
[],
);
(
prisma.medicationIntakeEvent.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
(
prisma.illnessEpisode.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
(prisma.illnessDayLog.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
+5 -156
View File
@@ -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<NamedSeries> {
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<NamedSeries> {
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"
@@ -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<typeof real.discoverCorrelations>[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);
});
});
+46 -13
View File
@@ -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<string, string> = {
[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);
@@ -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<NamedSeries> {
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<NamedSeries> {
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",
});
}