feat(environment): register env signals and wire weather into correlation discovery

Register the seven environmental-exposure signals through the signal registry
as a new "environment" kind (correlation-eligible; off Coach/MCP/FHIR in v1),
and fold their daily series into the correlation engine as lagged behaviour
channels. Weather/daylight now pair against mood, sleep, and vitals through
the existing FDR / effect-size / shrinkage gates, so a thin series degrades to
absent rather than a spurious link. All env channels share one ENVIRONMENT
family so two same-day fields never lag-correlate against each other.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 16:58:30 +02:00
1 parent 76dc03ae7f
commit cdee3e3efc
10 files changed
+265 -12

No files matched your search

@@ -20,6 +20,10 @@ vi.mock("@/lib/db", () => ({
illnessDayLog: { findMany: vi.fn().mockResolvedValue([]) },
// v1.22 — labs ↔ outcome pass fetches lab draws; default empty.
labResult: { findMany: vi.fn().mockResolvedValue([]) },
// v1.25 (W-ENV) — the route folds the environmental-exposure behaviour
// channels in (read from EnvironmentContext); default empty so no env
// channel produces a point.
environmentContext: { findMany: vi.fn().mockResolvedValue([]) },
},
}));
@@ -113,6 +117,9 @@ beforeEach(() => {
);
// v1.22 — labs ↔ outcome pass reads lab draws; default empty.
(prisma.labResult.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(
prisma.environmentContext.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
+24 -5
View File
@@ -39,6 +39,7 @@ import {
} from "@/lib/insights/correlation-discovery";
import {
fetchComplianceSeries,
fetchEnvironmentSeries,
fetchLabDraws,
fetchSymptomSeries,
} from "@/lib/insights/correlation-channel-series";
@@ -152,11 +153,16 @@ export const GET = apiHandler(async () => {
// 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).
// 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 [complianceSeries, symptomSeries, labDraws, environmentSeries] =
await Promise.all([
fetchComplianceSeries(user.id, tz, since),
fetchSymptomSeries(user.id, tz, since),
fetchLabDraws(user.id, tz, since),
// v1.25 (W-ENV) — environmental-exposure behaviour channels (weather /
// daylight). Empty when the module is off / no home set, so the channels
// degrade to absent. The module gate is implicit: no rows ⇒ no channels.
fetchEnvironmentSeries(user.id, since),
]);
const points = (key: string): DailySeriesPoint[] =>
key === "MOOD"
@@ -180,6 +186,12 @@ export const GET = apiHandler(async () => {
series.push({ key, role: "outcome", points: points(key) });
}
}
// v1.25 (W-ENV) — fold the environmental-exposure behaviour channels in. They
// pair (lag D → D+1) against every outcome above; the n ≥ 20 / FDR / effect-
// size gates apply unchanged, so a thin weather series degrades to absent.
for (const envSeries of environmentSeries) {
series.push(envSeries);
}
const result = discoverCorrelations(series);
@@ -213,6 +225,13 @@ export const GET = apiHandler(async () => {
emerging_window_days: emerging.windowDays,
lab_draws: labDraws.length,
lab_correlations: labCorrelations.discovered.length,
// v1.25 (W-ENV) — env channel reach (sum of stored daily points across
// the exposure channels) so a dashboard can see whether weather was
// available for the scan.
environment_days: environmentSeries.reduce(
(sum, s) => sum + s.points.length,
0,
),
},
});