mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
2f22d66ef7
commit
0aaaaac081
6 files changed
+339
-8
No files matched your search
@@ -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>;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user