merge(FDREXTEND): compliance + symptom as FDR correlation channels

This commit is contained in:
Marc-André Bombeck committed 2026-06-26 13:42:51 +02:00
commit 1fa02ce4cf
8 files changed
+945 -36

No files matched your search

+196 -18
View File
@@ -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<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();
@@ -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,
},
});
@@ -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
});
});
@@ -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/);
}
});
});
+95 -6
View File
@@ -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<string> = 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
@@ -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<string, { taken: number; missed: number }>();
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<string, number>();
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 };
}
+11 -6
View File
@@ -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([
+10 -3
View File
@@ -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<string>([
...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[];
+13 -3
View File
@@ -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<string>([...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.