diff --git a/src/lib/ai/coach/__tests__/correlations-module-gate.test.ts b/src/lib/ai/coach/__tests__/correlations-module-gate.test.ts index 50aef2e7c..98866173a 100644 --- a/src/lib/ai/coach/__tests__/correlations-module-gate.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-module-gate.test.ts @@ -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() })); diff --git a/src/lib/ai/coach/__tests__/correlations-read.test.ts b/src/lib/ai/coach/__tests__/correlations-read.test.ts index b5c324aaa..f020b98c1 100644 --- a/src/lib/ai/coach/__tests__/correlations-read.test.ts +++ b/src/lib/ai/coach/__tests__/correlations-read.test.ts @@ -5,6 +5,7 @@ */ import { describe, expect, it, vi, beforeEach } from "vitest"; +const moduleMap = vi.hoisted(() => ({ value: {} as Record })); 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[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(); + }); }); diff --git a/src/lib/ai/coach/tools/correlations-read.ts b/src/lib/ai/coach/tools/correlations-read.ts index 2ad9c1716..9f7d8a8d3 100644 --- a/src/lib/ai/coach/tools/correlations-read.ts +++ b/src/lib/ai/coach/tools/correlations-read.ts @@ -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; diff --git a/src/lib/insights/__tests__/discovery-matrix-modules.test.ts b/src/lib/insights/__tests__/discovery-matrix-modules.test.ts index 882e89f0a..e13d77393 100644 --- a/src/lib/insights/__tests__/discovery-matrix-modules.test.ts +++ b/src/lib/insights/__tests__/discovery-matrix-modules.test.ts @@ -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 [ diff --git a/src/lib/insights/__tests__/metric-correlation-context.test.ts b/src/lib/insights/__tests__/metric-correlation-context.test.ts index 5f4f411a5..f77052076 100644 --- a/src/lib/insights/__tests__/metric-correlation-context.test.ts +++ b/src/lib/insights/__tests__/metric-correlation-context.test.ts @@ -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 })); +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"), diff --git a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts index c861ffce9..1c2208411 100644 --- a/src/lib/insights/derived/__tests__/coach-read-locale.test.ts +++ b/src/lib/insights/derived/__tests__/coach-read-locale.test.ts @@ -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", () => ({ diff --git a/src/lib/insights/discovery-matrix.ts b/src/lib/insights/discovery-matrix.ts index 49cde8d4a..7e86c64ea 100644 --- a/src/lib/insights/discovery-matrix.ts +++ b/src/lib/insights/discovery-matrix.ts @@ -126,10 +126,16 @@ export interface DiscoveryMatrixOptions { * The record's resolved module map. A channel whose module is off * (`correlation:` 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[], diff --git a/src/lib/insights/metric-correlation-context.ts b/src/lib/insights/metric-correlation-context.ts index f626b5ef3..811e8d452 100644 --- a/src/lib/insights/metric-correlation-context.ts +++ b/src/lib/insights/metric-correlation-context.ts @@ -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 }); diff --git a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts index 2b24fcada..b742c12ac 100644 --- a/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts +++ b/src/lib/insights/narrative/__tests__/period-narrative-db.test.ts @@ -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 })); +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. diff --git a/src/lib/insights/narrative/period-narrative.ts b/src/lib/insights/narrative/period-narrative.ts index 34a600fd1..abe9d5c57 100644 --- a/src/lib/insights/narrative/period-narrative.ts +++ b/src/lib/insights/narrative/period-narrative.ts @@ -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, diff --git a/src/lib/modules/__tests__/surface-module-registry.test.ts b/src/lib/modules/__tests__/surface-module-registry.test.ts index 61ff4d15f..d271b6f09 100644 --- a/src/lib/modules/__tests__/surface-module-registry.test.ts +++ b/src/lib/modules/__tests__/surface-module-registry.test.ts @@ -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",