feat(insights): surface early-detection and lab correlations to the correlations route and coach

Thread the rolling early-detection pass and the labs -> outcome pass through the
correlations route and the coach correlations reader, both of which already
build the same daily series. The route response gains an optional `emerging`
block (recent-window pairs not yet established, flagged provisional) and a
`labCorrelations` block; the coach reader gains matching `emerging` and
`labDrivers` fields. Regenerate the OpenAPI contract for the new response shape.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 13:34:43 +02:00
1 parent 2f22d66ef7
commit 0aaaaac081
6 files changed
+339 -8

No files matched your search

+119
View File
@@ -18624,6 +18624,14 @@ components:
minimum: -9007199254740991
maximum: 9007199254740991
description: Minimum paired-day count enforced per pair.
emerging:
description: v1.22 — rolling early-detection pass over the trailing window; emerging pairs not yet established
retrospectively (no double-count).
$ref: "#/components/schemas/EmergingCorrelationResult"
labCorrelations:
description: v1.22 — labs ↔ outcome associations (each draw vs the contemporaneous outcome window-mean), FDR-controlled;
absent-degrading on sparse draws.
$ref: "#/components/schemas/LabCorrelationResult"
required:
- discovered
- pairsTested
@@ -18663,6 +18671,16 @@ components:
minimum: -9007199254740991
maximum: 9007199254740991
description: Lag in days applied (1).
window:
description: v1.22 — which window surfaced the pair. Absent on the 180-day scan (retrospective default); `recent` for an
emerging early-detection pair.
type: string
enum:
- retrospective
- recent
provisional:
description: "v1.22 — true for an emerging recent-window pair: fewer days, hedged as provisional rather than established."
type: boolean
required:
- behaviour
- outcome
@@ -18673,6 +18691,107 @@ components:
- interpretation
- lagDays
additionalProperties: false
EmergingCorrelationResult:
type: object
properties:
emerging:
type: array
items:
$ref: "#/components/schemas/DiscoveredCorrelation"
description: Recent-window pairs NOT already established retrospectively — the emerging signals (provisional, hedged).
windowDays:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Trailing window (days) the early pass scanned.
minPairs:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Paired-day floor enforced for the early pass.
fdrQ:
type: number
description: FDR target the early pass used (tighter than the main scan).
pairsTested:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Pairs tested in the early window.
required:
- emerging
- windowDays
- minPairs
- fdrQ
- pairsTested
additionalProperties: false
LabCorrelationResult:
type: object
properties:
discovered:
type: array
items:
$ref: "#/components/schemas/DiscoveredLabCorrelation"
description: Lab ↔ outcome associations surviving the per-pair floor + BH-FDR.
pairsTested:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Lab × outcome pairs assessed.
fdrQ:
type: number
description: The FDR target the pass used.
minDraws:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Minimum paired-draw count enforced per pair.
required:
- discovered
- pairsTested
- fdrQ
- minDraws
additionalProperties: false
DiscoveredLabCorrelation:
type: object
properties:
lab:
type: string
description: "`LAB:<analyte>` channel key (display strips the prefix)."
outcome:
type: string
description: Outcome channel the marker tracks with (WEIGHT, BLOOD_GLUCOSE, BLOOD_PRESSURE_SYS).
n:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Draws paired with a usable contemporaneous outcome window.
r:
type: number
description: Pearson r over (draw value, contemporaneous outcome window-mean).
pValue:
type: number
description: Two-sided exact Student-t p-value (< 0.05).
qValue:
type: number
description: Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold).
windowDays:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Trailing days each draw's outcome window spanned.
interpretation:
type: string
description: Conservative, descriptive interpretation — never causal.
required:
- lab
- outcome
- n
- r
- pValue
- qValue
- windowDays
- interpretation
additionalProperties: false
MoodTagTreeEnvelope:
type: object
properties:
@@ -18,6 +18,8 @@ vi.mock("@/lib/db", () => ({
medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) },
illnessEpisode: { findMany: vi.fn().mockResolvedValue([]) },
illnessDayLog: { findMany: vi.fn().mockResolvedValue([]) },
// v1.22 — labs ↔ outcome pass fetches lab draws; default empty.
labResult: { findMany: vi.fn().mockResolvedValue([]) },
},
}));
@@ -109,6 +111,8 @@ beforeEach(() => {
(prisma.illnessDayLog.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
// v1.22 — labs ↔ outcome pass reads lab draws; default empty.
(prisma.labResult.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
+31 -3
View File
@@ -26,9 +26,12 @@ import { wallClockInTz } from "@/lib/tz/wall-clock";
import type { MeasurementType } from "@/generated/prisma/client";
import {
discoverCorrelations,
discoverEmergingCorrelations,
discoverLabOutcomeCorrelations,
discoveryMeasurementTypes,
DISCOVERY_BEHAVIOURS,
DISCOVERY_OUTCOMES,
EARLY_WINDOW_DAYS,
MEDICATION_COMPLIANCE_CHANNEL_KEY,
SYMPTOM_SEVERITY_CHANNEL_KEY,
type DailySeriesPoint,
@@ -36,6 +39,7 @@ import {
} from "@/lib/insights/correlation-discovery";
import {
fetchComplianceSeries,
fetchLabDraws,
fetchSymptomSeries,
} from "@/lib/insights/correlation-channel-series";
@@ -147,8 +151,12 @@ 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 fetchComplianceSeries(user.id, tz, since);
const symptomSeries = await fetchSymptomSeries(user.id, tz, since);
// v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside.
const [complianceSeries, symptomSeries, labDraws] = await Promise.all([
fetchComplianceSeries(user.id, tz, since),
fetchSymptomSeries(user.id, tz, since),
fetchLabDraws(user.id, tz, since),
]);
const points = (key: string): DailySeriesPoint[] =>
key === "MOOD"
@@ -175,6 +183,21 @@ export const GET = apiHandler(async () => {
const result = discoverCorrelations(series);
// v1.22 — rolling early-detection pass over the trailing window, re-using
// the already-built series (no extra DB read). Emerging pairs exclude anything
// the retrospective scan already established (no double-count).
const recentFromDayKey = tzDayKey(
new Date(now.getTime() - EARLY_WINDOW_DAYS * MS_PER_DAY),
tz,
);
const emerging = discoverEmergingCorrelations(series, result, {
recentFromDayKey,
});
// v1.22 — labs ↔ outcome pass (point-vs-window over sparse draws). Degrades
// to absent when the user has too few draws to clear the per-pair floor.
const labCorrelations = discoverLabOutcomeCorrelations(labDraws, series);
annotate({
action: { name: "insights.correlations.discover" },
meta: {
@@ -185,8 +208,13 @@ export const GET = apiHandler(async () => {
// two sparse new channels reached the n ≥ 20 floor or degraded to absent.
compliance_days: complianceSeries.points.length,
symptom_days: symptomSeries.points.length,
// v1.22 — early-detection + labs reach.
emerging: emerging.emerging.length,
emerging_window_days: emerging.windowDays,
lab_draws: labDraws.length,
lab_correlations: labCorrelations.discovered.length,
},
});
return apiSuccess(result);
return apiSuccess({ ...result, emerging, labCorrelations });
});
@@ -12,6 +12,7 @@ const medicationFindMany = vi.fn();
const intakeEventFindMany = vi.fn();
const illnessEpisodeFindMany = vi.fn();
const illnessDayLogFindMany = vi.fn();
const labResultFindMany = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: (a: unknown) => measurementFindMany(a) },
@@ -21,6 +22,7 @@ vi.mock("@/lib/db", () => ({
medicationIntakeEvent: { findMany: (a: unknown) => intakeEventFindMany(a) },
illnessEpisode: { findMany: (a: unknown) => illnessEpisodeFindMany(a) },
illnessDayLog: { findMany: (a: unknown) => illnessDayLogFindMany(a) },
labResult: { findMany: (a: unknown) => labResultFindMany(a) },
},
}));
@@ -59,6 +61,7 @@ describe("readCoachCorrelations", () => {
intakeEventFindMany.mockReset().mockResolvedValue([]);
illnessEpisodeFindMany.mockReset().mockResolvedValue([]);
illnessDayLogFindMany.mockReset().mockResolvedValue([]);
labResultFindMany.mockReset().mockResolvedValue([]);
loadBaselineProfile
.mockReset()
.mockResolvedValue({ ageYears: 40, sex: "MALE", heightCm: 180 });
+84 -5
View File
@@ -27,9 +27,12 @@ import { prisma } from "@/lib/db";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import {
discoverCorrelations,
discoverEmergingCorrelations,
discoverLabOutcomeCorrelations,
discoveryMeasurementTypes,
DISCOVERY_BEHAVIOURS,
DISCOVERY_OUTCOMES,
EARLY_WINDOW_DAYS,
MEDICATION_COMPLIANCE_CHANNEL_KEY,
SYMPTOM_SEVERITY_CHANNEL_KEY,
type DailySeriesPoint,
@@ -37,6 +40,7 @@ import {
} from "@/lib/insights/correlation-discovery";
import {
fetchComplianceSeries,
fetchLabDraws,
fetchSymptomSeries,
} from "@/lib/insights/correlation-channel-series";
import {
@@ -77,9 +81,38 @@ export interface CoachCoincidentFlag {
illnessExplained: boolean;
}
/** One emerging (recent-window) driver — provisional, hedged. */
export interface CoachEmergingDriver extends CoachCorrelationDriver {
/** Always true here — a recent-window signal on fewer days. */
provisional: true;
}
/** One labs ↔ outcome association — descriptive, never causal. */
export interface CoachLabCorrelation {
/** Display analyte name (LAB: prefix stripped). */
lab: string;
/** The outcome it tracks with. */
outcome: string;
direction: "higher" | "lower";
/** Paired draws. */
n: number;
r: number;
note: string;
}
export interface CoachCorrelationsResult {
present: boolean;
drivers?: CoachCorrelationDriver[];
/**
* v1.22 — emerging recent-window drivers NOT yet established by the 180-day
* scan: early-detection signals the Coach narrates as provisional.
*/
emerging?: CoachEmergingDriver[];
/**
* v1.22 — labs ↔ outcome associations (each draw vs the contemporaneous
* outcome window-mean), FDR-controlled. Descriptive, never causal.
*/
labDrivers?: CoachLabCorrelation[];
coincident?: CoachCoincidentFlag;
/** How many behaviour×outcome pairs were tested (honest footer). */
pairsTested?: number;
@@ -194,8 +227,12 @@ export async function readCoachCorrelations(
// 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 [complianceSeries, symptomSeries, labDraws] = await Promise.all([
fetchComplianceSeries(userId, tz, since),
fetchSymptomSeries(userId, tz, since),
// v1.22 — lab draws for the labs ↔ outcome pass (degrades to absent).
fetchLabDraws(userId, tz, since),
]);
const series: NamedSeries[] = [];
for (const key of DISCOVERY_BEHAVIOURS) {
@@ -230,17 +267,59 @@ export async function readCoachCorrelations(
note: d.interpretation,
}));
// v1.22 — rolling early-detection pass over the trailing window (re-uses
// the already-built series). Emerging pairs exclude anything the 180-day
// scan already established, so the Coach never narrates the same pattern as
// both "established" and "emerging".
const recentFromDayKey = tzDayKey(
new Date(Date.now() - EARLY_WINDOW_DAYS * MS_PER_DAY),
tz,
);
const emergingResult = discoverEmergingCorrelations(series, discovery, {
recentFromDayKey,
});
const emerging: CoachEmergingDriver[] = emergingResult.emerging.map(
(d) => ({
behaviour: humanise(d.behaviour),
outcome: humanise(d.outcome),
direction: d.r >= 0 ? "higher" : "lower",
lagDays: d.lagDays,
n: d.n,
r: Math.round(d.r * 100) / 100,
note: d.interpretation,
provisional: true,
}),
);
// v1.22 — labs ↔ outcome pass (point-vs-window over sparse draws).
const labResult = discoverLabOutcomeCorrelations(labDraws, series);
const labDrivers: CoachLabCorrelation[] = labResult.discovered.map((d) => ({
lab: d.lab.startsWith("LAB:") ? d.lab.slice("LAB:".length) : d.lab,
outcome: humanise(d.outcome),
direction: d.r >= 0 ? "higher" : "lower",
n: d.n,
r: Math.round(d.r * 100) / 100,
note: d.interpretation,
}));
const coincident = buildCoincidentFlag(coincidentDerived);
// Nothing to say: no surviving driver AND the coincident flag is either
// insufficient or quiet (not fired). Report a clean miss.
if (drivers.length === 0 && (!coincident || !coincident.fired)) {
// Nothing to say: no surviving driver of any kind AND the coincident flag is
// either insufficient or quiet (not fired). Report a clean miss.
if (
drivers.length === 0 &&
emerging.length === 0 &&
labDrivers.length === 0 &&
(!coincident || !coincident.fired)
) {
return { present: false, reason: "no_significant_pattern" };
}
return {
present: true,
...(drivers.length > 0 ? { drivers } : {}),
...(emerging.length > 0 ? { emerging } : {}),
...(labDrivers.length > 0 ? { labDrivers } : {}),
...(coincident ? { coincident } : {}),
pairsTested: discovery.pairsTested,
windowDays: WINDOW_DAYS,
@@ -296,9 +296,97 @@ export const discoveredCorrelation = z
.string()
.describe("Conservative, descriptive interpretation — never causal."),
lagDays: z.number().int().describe("Lag in days applied (1)."),
window: z
.enum(["retrospective", "recent"])
.optional()
.describe(
"v1.22 — which window surfaced the pair. Absent on the 180-day scan (retrospective default); `recent` for an emerging early-detection pair.",
),
provisional: z
.boolean()
.optional()
.describe(
"v1.22 — true for an emerging recent-window pair: fewer days, hedged as provisional rather than established.",
),
})
.meta({ id: "DiscoveredCorrelation" });
// v1.22 — one labs ↔ outcome association (point-vs-window over sparse draws).
export const discoveredLabCorrelation = z
.object({
lab: z
.string()
.describe("`LAB:<analyte>` channel key (display strips the prefix)."),
outcome: z
.string()
.describe(
"Outcome channel the marker tracks with (WEIGHT, BLOOD_GLUCOSE, BLOOD_PRESSURE_SYS).",
),
n: z
.number()
.int()
.describe("Draws paired with a usable contemporaneous outcome window."),
r: z
.number()
.describe(
"Pearson r over (draw value, contemporaneous outcome window-mean).",
),
pValue: z.number().describe("Two-sided exact Student-t p-value (< 0.05)."),
qValue: z
.number()
.describe(
"Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold).",
),
windowDays: z
.number()
.int()
.describe("Trailing days each draw's outcome window spanned."),
interpretation: z
.string()
.describe("Conservative, descriptive interpretation — never causal."),
})
.meta({ id: "DiscoveredLabCorrelation" });
// v1.22 — rolling early-detection result (recent-window emerging pairs).
export const emergingCorrelationResult = z
.object({
emerging: z
.array(discoveredCorrelation)
.describe(
"Recent-window pairs NOT already established retrospectively — the emerging signals (provisional, hedged).",
),
windowDays: z
.number()
.int()
.describe("Trailing window (days) the early pass scanned."),
minPairs: z
.number()
.int()
.describe("Paired-day floor enforced for the early pass."),
fdrQ: z
.number()
.describe("FDR target the early pass used (tighter than the main scan)."),
pairsTested: z.number().int().describe("Pairs tested in the early window."),
})
.meta({ id: "EmergingCorrelationResult" });
// v1.22 — labs ↔ outcome pass result.
export const labCorrelationResult = z
.object({
discovered: z
.array(discoveredLabCorrelation)
.describe(
"Lab ↔ outcome associations surviving the per-pair floor + BH-FDR.",
),
pairsTested: z.number().int().describe("Lab × outcome pairs assessed."),
fdrQ: z.number().describe("The FDR target the pass used."),
minDraws: z
.number()
.int()
.describe("Minimum paired-draw count enforced per pair."),
})
.meta({ id: "LabCorrelationResult" });
export const correlationDiscoveryResponse = z
.object({
discovered: z
@@ -313,6 +401,16 @@ export const correlationDiscoveryResponse = z
.number()
.int()
.describe("Minimum paired-day count enforced per pair."),
emerging: emergingCorrelationResult
.optional()
.describe(
"v1.22 — rolling early-detection pass over the trailing window; emerging pairs not yet established retrospectively (no double-count).",
),
labCorrelations: labCorrelationResult
.optional()
.describe(
"v1.22 — labs ↔ outcome associations (each draw vs the contemporaneous outcome window-mean), FDR-controlled; absent-degrading on sparse draws.",
),
})
.meta({
id: "CorrelationDiscoveryResponse",