diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1138e83df..dc46cbe69 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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:` 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: diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index ea5d940a5..a4d8b711f 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -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).mockResolvedValue( [], ); + // v1.22 — labs ↔ outcome pass reads lab draws; default empty. + (prisma.labResult.findMany as ReturnType).mockResolvedValue([]); }); const callGet = GET as unknown as (req: NextRequest) => Promise; diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index e9b511687..33cf3329a 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -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 }); }); diff --git a/src/lib/ai/coach/__tests__/correlations-read.test.ts b/src/lib/ai/coach/__tests__/correlations-read.test.ts index fcda4fbed..2ab4f2cb6 100644 --- a/src/lib/ai/coach/__tests__/correlations-read.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-read.test.ts @@ -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 }); diff --git a/src/lib/ai/coach/tools/correlations-read.ts b/src/lib/ai/coach/tools/correlations-read.ts index 997360678..ebeb3e2d5 100644 --- a/src/lib/ai/coach/tools/correlations-read.ts +++ b/src/lib/ai/coach/tools/correlations-read.ts @@ -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, diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index d5ee83614..82971e74f 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -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:` 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",