mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user