feat(insights): make each person-defined symptom its own correlation channel

Every active symptom definition enters pattern discovery as an outcome
channel SYMPTOM:<id>, labelled with its decrypted name (the data label
the interpretation already prefers over the key). The daily value is the
day's highest intensity in the person's zone; days without an occurrence
count as 0 only between the first and the last logged day. At most eight,
the active-definition cap. The SYMPTOM: prefix resolves to the illness
module in the surface map, so the family leaves the matrix with the
module, and the discovery guard lists the new fetcher.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 21:14:08 +02:00
1 parent d6571c4e9c
commit 9d9e1f153c
15 files changed
+799 -4

No files matched your search

@@ -91,6 +91,7 @@ describe("T1 — only the assembler fetches discovery channels", () => {
"fetchSymptomSeries",
"fetchEnvironmentSeries",
"fetchCustomMetricBehaviourSeries",
"fetchSymptomEventSeries",
"fetchMoodWindowSeries",
"fetchMoodFactorWindowSeries",
"fetchMeasurementDailySeriesTiered",
@@ -31,6 +31,7 @@ vi.mock("@/lib/db", () => ({
// channel produces a point.
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
customMetric: { findMany: vi.fn().mockResolvedValue([]) },
symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) },
},
}));
@@ -153,6 +154,7 @@ beforeEach(() => {
prisma.environmentContext.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -236,6 +236,7 @@ async function buildCorrelationsResponse(
// available for the scan.
environment_days: diagnostics.environmentDays,
custom_metric_channels: diagnostics.customMetricChannels,
symptom_event_channels: diagnostics.symptomEventChannels,
custom_metric_days: diagnostics.customMetricDays,
},
});
@@ -39,6 +39,7 @@ vi.mock("@/lib/db", () => ({
findMany: (a: unknown) => environmentContextFindMany(a),
},
customMetric: { findMany: (a: unknown) => customMetricFindMany(a) },
symptomDefinition: { findMany: async () => [] },
},
}));
@@ -42,6 +42,9 @@ vi.mock("@/lib/insights/correlation-channel-series", () => ({
fetchCustomMetricBehaviourSeries: vi.fn(async () => [
{ key: "CUSTOM_METRIC:abc", role: "behaviour", points: pts },
]),
fetchSymptomEventSeries: vi.fn(async () => [
{ key: "SYMPTOM:def1", label: "Aura", role: "outcome", points: pts },
]),
}));
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(),
@@ -77,6 +80,7 @@ describe("assembleDiscoveryMatrix — switched-off modules", () => {
"SYMPTOM_SEVERITY",
"ENV_TEMP_MEAN",
"CUSTOM_METRIC:abc",
"SYMPTOM:def1",
"WEIGHT",
]) {
expect(keys).toContain(key);
@@ -103,6 +107,7 @@ describe("assembleDiscoveryMatrix — switched-off modules", () => {
"BLOOD_GLUCOSE",
"MEDICATION_COMPLIANCE",
"SYMPTOM_SEVERITY",
"SYMPTOM:def1",
"ENV_TEMP_MEAN",
]) {
expect(keys, gone).not.toContain(gone);
@@ -11,6 +11,7 @@ vi.mock("@/lib/db", () => ({
illnessDayLog: { findMany: vi.fn() },
environmentContext: { findMany: vi.fn() },
customMetric: { findMany: vi.fn() },
symptomDefinition: { findMany: vi.fn(async () => []) },
},
}));
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { buildSymptomEventDailySeries } from "@/lib/insights/correlation-series-builders";
import { maskSeriesByModules } from "@/lib/insights/discovery-matrix";
const at = (iso: string) => new Date(iso);
describe("buildSymptomEventDailySeries", () => {
it("takes the day's highest intensity and zero-fills only between the first and last day", () => {
const series = buildSymptomEventDailySeries({
key: "SYMPTOM:def1",
label: "Aura",
tz: "Europe/Berlin",
events: [
{ at: at("2026-07-01T07:00:00.000Z"), intensity: 3 },
{ at: at("2026-07-01T15:00:00.000Z"), intensity: 7 },
{ at: at("2026-07-04T10:00:00.000Z"), intensity: 2 },
],
});
expect(series).toEqual({
key: "SYMPTOM:def1",
label: "Aura",
role: "outcome",
points: [
{ day: "2026-07-01", value: 7 },
{ day: "2026-07-02", value: 0 },
{ day: "2026-07-03", value: 0 },
{ day: "2026-07-04", value: 2 },
],
});
});
it("keys a late-evening occurrence to the person's own day", () => {
const series = buildSymptomEventDailySeries({
key: "SYMPTOM:def1",
label: "Aura",
tz: "Europe/Berlin",
// 23:30 UTC on the 1st is 01:30 on the 2nd in Berlin (summer time).
events: [{ at: at("2026-07-01T23:30:00.000Z"), intensity: 5 }],
});
expect(series.points).toEqual([{ day: "2026-07-02", value: 5 }]);
});
it("is empty with no occurrences, so the channel drops out", () => {
const series = buildSymptomEventDailySeries({
key: "SYMPTOM:def1",
label: "Aura",
tz: "UTC",
events: [],
});
expect(series.points).toEqual([]);
});
});
describe("the SYMPTOM: channel family follows the illness module", () => {
const series = [
{ key: "SYMPTOM:def1", role: "outcome" as const, points: [] },
{ key: "SLEEP_DURATION", role: "behaviour" as const, points: [] },
];
it("is masked out when the illness module is off", () => {
expect(
maskSeriesByModules(series, { illness: false }).map((s) => s.key),
).toEqual(["SLEEP_DURATION"]);
});
it("stays when the module is on", () => {
expect(
maskSeriesByModules(series, { illness: true }).map((s) => s.key),
).toEqual(["SYMPTOM:def1", "SLEEP_DURATION"]);
});
});
@@ -39,6 +39,7 @@ import { wallClockInTz } from "@/lib/tz/wall-clock";
import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields";
import {
buildComplianceDailySeries,
buildSymptomEventDailySeries,
buildSymptomSeverityDailySeries,
type SymptomDayLogRow,
type SymptomEpisodeSpan,
@@ -61,6 +62,11 @@ import type {
} from "@/generated/prisma/client";
import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking";
import { dateOnlyKey } from "@/lib/tz/date-only";
import { decryptSymptomText } from "@/lib/symptoms/server";
import {
MAX_ACTIVE_SYMPTOM_DEFINITIONS,
SYMPTOM_CHANNEL_PREFIX,
} from "@/lib/symptoms/shared";
/**
* v1.21.0 (FDREXTEND) — build the user's MEDICATION_COMPLIANCE daily series.
@@ -315,6 +321,60 @@ export async function fetchCustomMetricBehaviourSeries(
}));
}
/**
* Ceiling on `SYMPTOM:<id>` channels. The definition cap already holds the
* active set at eight; restated here so the matrix bound does not depend on a
* route check staying where it is.
*/
export const MAX_SYMPTOM_CORRELATION_CHANNELS = MAX_ACTIVE_SYMPTOM_DEFINITIONS;
/**
* v1.40 — one OUTCOME series per active person-defined symptom, keyed
* `SYMPTOM:<definitionId>` and labelled with the decrypted name (the data
* label `interpret()` prefers over the key). Daily value = that day's highest
* intensity; see `buildSymptomEventDailySeries` for the zero-fill rule. Hidden
* definitions are not channels. A symptom whose name cannot be decrypted is
* left out rather than shown under its database id.
*/
export async function fetchSymptomEventSeries(
userId: string,
tz: string,
since: Date,
): Promise<NamedSeries[]> {
const definitions = await prisma.symptomDefinition.findMany({
where: { userId, isActive: true },
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }, { id: "asc" }],
take: MAX_SYMPTOM_CORRELATION_CHANNELS,
select: {
id: true,
labelEncrypted: true,
events: {
where: { occurredAt: { gte: since } },
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take: 5000,
select: { occurredAt: true, intensity: true },
},
},
});
const series: NamedSeries[] = [];
for (const definition of definitions) {
const label = decryptSymptomText(definition.labelEncrypted, "label");
if (label === null) continue;
series.push(
buildSymptomEventDailySeries({
key: `${SYMPTOM_CHANNEL_PREFIX}${definition.id}`,
label,
events: definition.events.map((event) => ({
at: event.occurredAt,
intensity: event.intensity,
})),
tz,
}),
);
}
return series;
}
/** Day key (YYYY-MM-DD) for an instant in the user's display timezone. */
function tzDayKey(at: Date, tz: string): string {
const { year, month, day } = wallClockInTz(at, tz);
@@ -35,6 +35,7 @@
* mirroring how the engine itself is pure over `NamedSeries[]`.
*/
import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz";
import { shiftDateKey } from "@/lib/tz/format";
import type { DoseHistoryRow } from "@/lib/medications/scheduling/dose-history";
import {
MEDICATION_COMPLIANCE_CHANNEL_KEY,
@@ -191,3 +192,53 @@ export function buildSymptomSeverityDailySeries(args: {
return { key: SYMPTOM_SEVERITY_CHANNEL_KEY, role, points };
}
/** One occurrence of a person-defined symptom, as the builder reads it. */
export interface SymptomEventRow {
at: Date;
intensity: number;
}
/**
* Build one `SYMPTOM:<definitionId>` outcome series from that symptom's
* occurrences (v1.40).
*
* The daily value is the day's HIGHEST intensity (0-10) in the person's zone:
* two attacks in a day are as bad as the worse one. Days with no occurrence
* are 0, but only between the first and the last logged day: inside that span
* the person was tracking the symptom and its absence is a real zero, outside
* it nothing says whether they were tracking at all. One logged day yields a
* one-point series, which the n >= 20 floor then drops. Pure.
*/
export function buildSymptomEventDailySeries(args: {
key: string;
label: string | undefined;
events: SymptomEventRow[];
tz: string;
}): NamedSeries {
const { key, label, events, tz } = args;
const byDay = new Map<string, number>();
for (const event of events) {
if (!Number.isFinite(event.intensity)) continue;
const day = dayKey(event.at, tz);
const prev = byDay.get(day);
byDay.set(
day,
prev == null ? event.intensity : Math.max(prev, event.intensity),
);
}
const keys = [...byDay.keys()].sort();
const points: DailySeriesPoint[] = [];
if (keys.length > 0) {
const last = keys[keys.length - 1];
for (let day = keys[0]; day <= last; day = shiftDateKey(day, 1)) {
points.push({ day, value: byDay.get(day) ?? 0 });
}
}
return {
key,
...(label !== undefined ? { label } : {}),
role: "outcome",
points,
};
}
@@ -52,6 +52,7 @@ vi.mock("@/lib/db", () => ({
findMany: (a: unknown) => environmentContextFindMany(a),
},
customMetric: { findMany: (a: unknown) => customMetricFindMany(a) },
symptomDefinition: { findMany: async () => [] },
},
}));
+11
View File
@@ -48,6 +48,7 @@ import {
buildMeasurementDailySeries,
fetchComplianceSeries,
fetchCustomMetricBehaviourSeries,
fetchSymptomEventSeries,
fetchEnvironmentSeries,
fetchMeasurementDailySeriesTiered,
fetchMeasurementWindowSeries,
@@ -154,6 +155,8 @@ export interface DiscoveryMatrixDiagnostics {
customMetricDays: number;
/** `FACTOR:*` channels admitted (0 unless `includeMoodFactors`). */
moodFactorChannels: number;
/** `SYMPTOM:*` channels read (before the module mask). */
symptomEventChannels: number;
}
export interface DiscoveryMatrix {
@@ -294,6 +297,7 @@ export async function assembleDiscoveryMatrix(
symptomSeries,
environmentSeries,
customMetricSeries,
symptomEventSeries,
] = await Promise.all([
readMeasurementDaily(userId, tz, since, measurementTypes, fetchMode),
readMood(userId, tz, since, opts.includeMoodFactors === true),
@@ -301,6 +305,7 @@ export async function assembleDiscoveryMatrix(
fetchSymptomSeries(userId, tz, since),
fetchEnvironmentSeries(userId, since),
fetchCustomMetricBehaviourSeries(userId, tz, since),
fetchSymptomEventSeries(userId, tz, since),
]);
const { factorSeries } = mood;
@@ -331,6 +336,11 @@ export async function assembleDiscoveryMatrix(
// (D → D+1) against every outcome above, under the unchanged n ≥ 20 / FDR /
// effect-size gates, so a thin weather or custom series degrades to absent.
series.push(...environmentSeries, ...customMetricSeries);
// The person's own symptoms are OUTCOMES: "short sleep, then an aura the
// next day" is the question they log them to answer. Each is its own
// channel and its own family, capped at eight; the illness module owns them
// through the `SYMPTOM:` prefix rule in the surface map.
series.push(...symptomEventSeries);
// A factor is plausibly both a lag source ("rated work today → next-day
// sleep") and a lag target ("steps today → next-day rated energy"), so it
// enters as both roles. The engine skips self-pairs — every factor shares
@@ -371,6 +381,7 @@ export async function assembleDiscoveryMatrix(
0,
),
moodFactorChannels: factorSeries.size,
symptomEventChannels: symptomEventSeries.length,
},
};
}
@@ -49,6 +49,7 @@ vi.mock("@/lib/db", () => ({
findMany: (a: unknown) => environmentContextFindMany(a),
},
customMetric: { findMany: (a: unknown) => customMetricFindMany(a) },
symptomDefinition: { findMany: async () => [] },
},
}));
@@ -66,9 +66,11 @@ import {
TYPE_TO_SUB_PAGE_SLUG,
} from "@/lib/insights/sub-page-metric";
import { TREND_CHART_CONFIG } from "@/lib/insights/trend-chart-select";
import { SYMPTOM_CHANNEL_PREFIX } from "@/lib/symptoms/shared";
import {
SURFACE_KINDS,
SURFACE_MODULE,
correlationChannelSurfaceId,
surfaceModule,
surfaceModulesOfKind,
type SurfaceKind,
@@ -167,6 +169,11 @@ describe("every surface id names a surface that exists", () => {
// `correlationChannelSurfaceId` folds rated factors onto mood by this
// prefix; it has to be the prefix the engine actually writes.
expect(FACTOR_CHANNEL_PREFIX).toBe("FACTOR:");
// Person-defined symptoms fold onto the illness symptom channel by theirs.
expect(SYMPTOM_CHANNEL_PREFIX).toBe("SYMPTOM:");
expect(correlationChannelSurfaceId(`${SYMPTOM_CHANNEL_PREFIX}abc`)).toBe(
"correlation:SYMPTOM_SEVERITY",
);
});
});
+7 -4
View File
@@ -274,10 +274,13 @@ export function surfaceModulesOfKind(
/**
* The surface id of a correlation channel. Rated mood factors carry their own
* `FACTOR:<key>` channel names but are mood entries all the same, so they
* resolve to the mood channel.
* resolve to the mood channel; person-defined symptoms (`SYMPTOM:<id>`) resolve
* to the illness symptom channel the same way.
*/
export function correlationChannelSurfaceId(channelKey: string): string {
return channelKey.startsWith("FACTOR:")
? "correlation:MOOD"
: `correlation:${channelKey}`;
if (channelKey.startsWith("FACTOR:")) return "correlation:MOOD";
// A person-defined symptom (`SYMPTOM:<id>`, v1.40) rides the illness
// module, like the symptom-burden channel it sits beside.
if (channelKey.startsWith("SYMPTOM:")) return "correlation:SYMPTOM_SEVERITY";
return `correlation:${channelKey}`;
}