fix(insights): pass the record's module map to every correlation scan

The Coach correlations tool, the per-metric card and the period
narrative scanned every channel, so a switched-off module still fed
their statistics and the text a model was grounded on. The matrix's
modules option is now required, each caller resolves the record's map,
and the Coach skips the lab pass with labs off. A registry guard holds
every caller to resolving the map itself.

The mask's doc comment now states what it does to the false discovery
correction: fewer channels is a smaller family, so q-values of the
remaining pairs move and a borderline pair can cross the threshold.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 10:17:14 +02:00
1 parent ab0d1b4b71
commit 5bd19573ea
11 files changed
+238 -7

No files matched your search

@@ -16,6 +16,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn(async () => true),
// Every module on: the record's map feeds the matrix mask.
resolveModuleMap: vi.fn(async () => ({})),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
@@ -5,6 +5,7 @@
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
const moduleMap = vi.hoisted(() => ({ value: {} as Record<string, boolean> }));
const measurementFindMany = vi.fn();
const moodFindMany = vi.fn();
const userFindUnique = vi.fn();
@@ -17,6 +18,8 @@ const environmentContextFindMany = vi.fn();
const customMetricFindMany = vi.fn();
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn(async () => true),
// The record's map feeds the matrix mask; every module on by default.
resolveModuleMap: vi.fn(async () => moduleMap.value),
}));
vi.mock("@/lib/db", () => ({
prisma: {
@@ -67,6 +70,7 @@ import { readCoachCorrelations } from "@/lib/ai/coach/tools/correlations-read";
describe("readCoachCorrelations", () => {
beforeEach(() => {
moduleMap.value = {};
measurementFindMany.mockReset().mockResolvedValue([]);
moodFindMany.mockReset().mockResolvedValue([]);
userFindUnique.mockReset().mockResolvedValue({ timezone: "Europe/Berlin" });
@@ -363,4 +367,55 @@ describe("readCoachCorrelations", () => {
// The channel key must never leak into prose the Coach reads out.
expect(pressureDriver?.behaviour).not.toContain("ENV_");
});
it("never hands the Coach a weather driver with the environment module off", async () => {
moduleMap.value = { environment: false };
const real = await vi.importActual<
typeof import("@/lib/insights/correlation-discovery")
>("@/lib/insights/correlation-discovery");
discoverCorrelations.mockImplementation((series) =>
real.discoverCorrelations(
series as Parameters<typeof real.discoverCorrelations>[0],
{ locale: "en" },
),
);
// 45 consecutive days. Today's barometric pressure (behaviour, day D)
// tracks the NEXT day's sleep (outcome, day D+1) with a deterministic
// linear relationship, so the pair clears n ≥ 20 / p < 0.05 / FDR / the
// effect-size floor rather than riding on noise.
const DAYS = 45;
const base = Date.UTC(2026, 4, 1, 12, 0, 0); // midday → tz-stable day key
const dayKey = (offset: number): string =>
new Date(base + offset * 86_400_000).toISOString().slice(0, 10);
const envRows: Array<{ date: string; pressureMean: number }> = [];
const measurements: Array<{
type: string;
value: number;
measuredAt: Date;
}> = [];
for (let i = 0; i < DAYS; i++) {
const swing = i % 5; // 0..4, real variance across the window
envRows.push({ date: dayKey(i), pressureMean: 1000 + swing });
// Higher pressure today → shorter sleep tomorrow (a clean negative r).
measurements.push({
type: "SLEEP_DURATION",
value: 8 - swing * 0.5,
measuredAt: new Date(base + (i + 1) * 86_400_000),
});
}
measurementFindMany.mockResolvedValue(measurements);
// Every other env column is null, so only the pressure channel carries
// points and the assertion below cannot be satisfied by a sibling field.
environmentContextFindMany.mockResolvedValue(envRows);
const result = await readCoachCorrelations("u1", "en");
// The same data that surfaces a pressure driver above surfaces none here:
// the channel never enters the scan.
expect(
result.drivers?.find((d) => d.behaviour === "barometric pressure"),
).toBeUndefined();
});
});
+9 -3
View File
@@ -38,7 +38,8 @@ import { prisma } from "@/lib/db";
import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields";
import type { Locale } from "@/lib/i18n/config";
import { annotate } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
import { isModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
import { isSurfaceVisible } from "@/lib/modules/surface";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import {
discoverCorrelations,
@@ -217,14 +218,19 @@ export async function readCoachCorrelations(
//
// v1.22 — the lab draws feed a separate point-vs-window pass, not the
// matrix, so they are fetched alongside rather than assembled in.
// Switched-off modules never reach the Coach: their channels are left out
// of the scan and the lab pass is skipped with labs off.
const modules = await resolveModuleMap(userId);
const [matrix, coincidentDerived, labDraws] = await Promise.all([
assembleDiscoveryMatrix(userId, { tz, since, fetchMode: "raw" }),
assembleDiscoveryMatrix(userId, { tz, since, fetchMode: "raw", modules }),
// Coincident-deviation is its own derived metric — fail-soft to null so a
// baseline hiccup never sinks the whole correlations read. D2-8: pass the
// user's tz so the "today" grouping matches the user's calendar day, not
// UTC's, before the fired flag is narrated as "out of band TODAY".
computeCoincidentDeviation(userId, profile, { tz }).catch(() => null),
fetchLabDraws(userId, tz, since),
isSurfaceVisible("correlation:LAB_DRAWS", modules)
? fetchLabDraws(userId, tz, since)
: Promise.resolve([]),
]);
const { series, diagnostics } = matrix;
@@ -57,6 +57,7 @@ const OPTS = {
since: new Date("2026-03-01T00:00:00Z"),
fetchMode: "tiered" as const,
includeMoodFactors: true,
modules: {},
};
const keysOf = (series: { key: string }[]) => [
@@ -64,7 +65,7 @@ const keysOf = (series: { key: string }[]) => [
];
describe("assembleDiscoveryMatrix — switched-off modules", () => {
it("keeps every channel without a module map", async () => {
it("keeps every channel with every module on", async () => {
const { series } = await assembleDiscoveryMatrix("u1", OPTS);
const keys = keysOf(series);
for (const key of [
@@ -14,6 +14,12 @@ vi.mock("@/lib/db", () => ({
},
}));
// The record's module map; every module on unless a test switches one off.
const moduleMap = vi.hoisted(() => ({ value: {} as Record<string, boolean> }));
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: vi.fn(async () => moduleMap.value),
}));
vi.mock("@/lib/insights/correlation-patterns", () => ({
PATTERN_FAMILIES: { discoveryRetrospective: "DISCOVERY_RETROSPECTIVE" },
syncAcceptedPatterns: vi.fn().mockResolvedValue(new Map()),
@@ -25,6 +31,7 @@ import { getRelevantCorrelationsForMetric } from "../metric-correlation-context"
beforeEach(() => {
vi.resetAllMocks();
moduleMap.value = {};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
@@ -98,6 +105,35 @@ describe("getRelevantCorrelationsForMetric", () => {
}
});
it("never grounds a card on a module that is switched off", async () => {
// Blood glucose today tracks tomorrow's weight. With the glucose module on,
// the weight card cites the pair (the positive control); with it off, the
// glucose channel never enters the scan and the card cites nothing.
const rows: Array<{ type: string; value: number; measuredAt: Date }> = [];
const base = new Date("2026-01-01T12:00:00Z");
for (let d = 0; d < 60; d++) {
const glucose = 80 + (d % 7) * 5;
rows.push({
type: "BLOOD_GLUCOSE",
value: glucose,
measuredAt: new Date(base.getTime() + d * 86_400_000),
});
rows.push({
type: "WEIGHT",
value: 60 + glucose * 0.1,
measuredAt: new Date(base.getTime() + (d + 1) * 86_400_000),
});
}
vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never);
const on = await getRelevantCorrelationsForMetric("u-1", "WEIGHT", "en");
expect(on.length).toBeGreaterThan(0);
moduleMap.value = { glucose: false };
const off = await getRelevantCorrelationsForMetric("u-1", "WEIGHT", "en");
expect(off).toEqual([]);
});
it("is best-effort: a DB failure resolves to [] rather than throwing", async () => {
vi.mocked(prisma.measurement.findMany).mockRejectedValue(
new Error("db down"),
@@ -29,6 +29,8 @@ const customMetricFindMany = vi.fn();
vi.mock("@/lib/modules/gate", () => ({
isModuleEnabled: vi.fn(async () => true),
// Every module on: the record's map feeds the matrix mask.
resolveModuleMap: vi.fn(async () => ({})),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/db", () => ({
+20 -3
View File
@@ -126,10 +126,16 @@ export interface DiscoveryMatrixOptions {
* The record's resolved module map. A channel whose module is off
* (`correlation:<key>` in the surface map: mood and its rated factors,
* sleep, glucose, medication adherence, symptoms, weather) is left out of
* `series` and `byMetric`, so no statistic is computed over a module the
* person switched off. Omitted: every channel is kept.
* `series` and `byMetric`, so no statistic is computed, and no prompt is
* grounded, on a module the person switched off.
*
* Required, not optional: a caller that forgot it would scan every channel
* and nothing would notice, which is how the Coach tool, the metric card
* and the period narrative kept scanning switched-off modules after the
* route stopped. Omitting it is now a compile error. Pass
* `resolveModuleMap(userId)` for the record being read.
*/
modules?: SurfaceModuleMap;
modules: SurfaceModuleMap;
}
/** Per-channel reach, for the callers' wide-event annotations. */
@@ -167,6 +173,17 @@ export interface DiscoveryMatrix {
* Leave out every channel whose owning module is off. Pure, and applied after
* the fold so the order of what remains is the order it was folded in (the
* ranking stability note on {@link assembleDiscoveryMatrix}).
*
* This changes the statistics of the pairs that remain, not only which pairs
* exist. Benjamini-Hochberg controls the false discovery rate across every
* pair tested, so a smaller channel set is a smaller family: each surviving
* pair's q-value is computed against fewer tests and is usually lower (never
* higher for the same p-values and rank order). Switching a module off can
* therefore let a borderline pair between two unrelated channels cross the
* FDR threshold, and switching it back on can push that pair back under.
* That is the correct correction for the question actually asked (the pairs
* over what the person tracks); it is also why a persisted pattern's q-value
* is only comparable to another computed under the same module set.
*/
export function maskSeriesByModules(
series: readonly NamedSeries[],
@@ -33,6 +33,7 @@ import {
DISCOVERY_BEHAVIOURS,
DISCOVERY_OUTCOMES,
} from "@/lib/insights/correlation-discovery";
import { resolveModuleMap } from "@/lib/modules/gate";
import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix";
import {
decisionForEvidence,
@@ -98,10 +99,13 @@ export async function getRelevantCorrelationsForMetric(
// scans, so a card can never be missing a pair the page would show. The
// measurement read stays `"raw"` (the route's rollup read-swap is a
// read-cost choice, not a channel-set one; see the option's doc comment).
// A channel whose module is off never enters the scan, so the card is
// never grounded on a module the person switched off.
const { series } = await assembleDiscoveryMatrix(userId, {
tz,
since,
fetchMode: "raw",
modules: await resolveModuleMap(userId),
});
const result = discoverCorrelations(series, { locale });
@@ -30,6 +30,12 @@ const illnessEpisodeFindMany = vi.fn();
const illnessDayLogFindMany = vi.fn();
const environmentContextFindMany = vi.fn();
const customMetricFindMany = vi.fn();
// The record's module map; every module on unless a test switches one off.
const moduleMap = vi.hoisted(() => ({ value: {} as Record<string, boolean> }));
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: vi.fn(async () => moduleMap.value),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: (a: unknown) => userFindUnique(a) },
@@ -51,6 +57,7 @@ import { buildPeriodNarrativeContext } from "../period-narrative";
const DAY_MS = 24 * 60 * 60 * 1000;
beforeEach(() => {
moduleMap.value = {};
userFindUnique.mockReset().mockResolvedValue({
timezone: "UTC",
sourcePriorityJson: null,
@@ -66,6 +73,84 @@ beforeEach(() => {
});
describe("buildPeriodNarrativeContext — grain (QA F3)", () => {
it("never narrates a module that is switched off", async () => {
// The fixture of the test above, with the sleep module off: the sleep
// night is in the data, and the narrative must neither delta it nor scan
// it. Mutation-checked against a caller passing an empty module map.
moduleMap.value = { sleep: false };
// `now` deep enough into the week that a full current + prior 7-day
// span (plus the +1 day lag slack) has clean, unambiguous UTC days.
const now = new Date("2026-06-30T12:00:00.000Z");
// WEIGHT + PULSE: 14 daily readings each (current + prior week) so both
// clear the >=3-covered-day floor and the >=2-metrics-covered gate.
const dailyMetricRows: Array<{
type: string;
value: number;
measuredAt: Date;
}> = [];
for (let i = 0; i < 14; i++) {
const at = new Date(now.getTime() - i * DAY_MS - 6 * 60 * 60 * 1000);
dailyMetricRows.push({ type: "WEIGHT", value: 80, measuredAt: at });
dailyMetricRows.push({ type: "PULSE", value: 60, measuredAt: at });
}
// One night THIS week: CORE 240 + DEEP 90 + REM 80 = 410 minutes total
// asleep. `measuredAt` is the END of each segment (reconstructor derives
// the start from `measuredAt - value minutes`), matching
// `sleep-night.test.ts` / `correlation-channel-series.test.ts`.
const nightEnd = new Date(now.getTime() - 1 * DAY_MS);
const sleepRows = [
{
type: "SLEEP_DURATION",
value: 240,
measuredAt: new Date(nightEnd.getTime() - 3 * 60 * 60 * 1000),
source: "APPLE_HEALTH",
deviceType: null,
sleepStage: "CORE",
},
{
type: "SLEEP_DURATION",
value: 90,
measuredAt: new Date(nightEnd.getTime() - 1.5 * 60 * 60 * 1000),
source: "APPLE_HEALTH",
deviceType: null,
sleepStage: "DEEP",
},
{
type: "SLEEP_DURATION",
value: 80,
measuredAt: nightEnd,
source: "APPLE_HEALTH",
deviceType: null,
sleepStage: "REM",
},
];
measurementFindMany.mockResolvedValue([
...dailyMetricRows.map((r) => ({
...r,
source: "MANUAL",
deviceType: null,
sleepStage: null,
})),
...sleepRows,
]);
const result = await buildPeriodNarrativeContext("u1", {
period: "week",
now,
locale: "en",
});
expect(result.status).toBe("ready");
if (result.status !== "ready") return;
expect(
result.metricDeltas.find((d) => d.type === "SLEEP_DURATION"),
).toBeUndefined();
expect(result.metricDeltas.length).toBeGreaterThan(0);
});
it("sums a night's per-stage segments into the SLEEP_DURATION delta instead of averaging them", async () => {
// `now` deep enough into the week that a full current + prior 7-day
// span (plus the +1 day lag slack) has clean, unambiguous UTC days.
@@ -54,6 +54,7 @@ import {
import { buildBaselineBand, median } from "@/lib/insights/derived/baseline";
import { VITALS_BASELINE_TYPES } from "@/lib/insights/derived/registry";
import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix";
import { resolveModuleMap } from "@/lib/modules/gate";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -514,6 +515,9 @@ export async function buildPeriodNarrativeContext(
tz,
since,
fetchMode: "raw",
// Switched-off modules are left out of the scan AND of `byMetric`, so the
// narrative never cites a delta or a pattern from a module that is off.
modules: await resolveModuleMap(userId),
// The one surface that admits RATED mood factors — see the option's doc
// comment for why it is one and not four.
includeMoodFactors: true,
@@ -300,6 +300,25 @@ describe("the replaced maps and guards stay gone", () => {
expect(hits).toEqual([]);
});
it("every discovery-matrix caller passes the record's module map", () => {
// `modules` is a required option, so omitting it fails to compile; this
// catches the other way to get it wrong, a hand-built map, by requiring
// each calling file to resolve the record's own. A file that forwards a
// map it received from its caller would need an entry here.
const callers = sources.filter(
(s) =>
/\bassembleDiscoveryMatrix\s*\(/.test(s.code) &&
s.rel !== "lib/insights/discovery-matrix.ts",
);
expect(callers.length).toBeGreaterThanOrEqual(4);
for (const caller of callers) {
expect(caller.code, caller.rel).toMatch(/\bresolveModuleMap\s*\(/);
expect(caller.code, caller.rel).toMatch(
/assembleDiscoveryMatrix\s*\([\s\S]*?\bmodules\b/,
);
}
});
it("the dashboard maps are views, built from the surface map and nowhere else", () => {
const widgetModules = sources.find(
(s) => s.rel === "lib/dashboard/widget-modules.ts",