mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(insights): leave switched-off modules out of the correlation statistics
The correlation scan still paired mood, sleep, glucose, adherence, symptoms and weather with everything else after their modules were switched off. The discovery matrix now drops every channel whose module is off, read through the surface map, and the correlations route passes the record's module map, skips the lab draws with labs off, and keys its cache on the switched-off set.
This commit is contained in:
1 parent
9f98636a43
commit
8ae0f75ecd
4 files changed
+223
-7
No files matched your search
@@ -83,7 +83,7 @@ vi.mock("next/headers", () => ({
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
@@ -100,6 +100,10 @@ beforeEach(() => {
|
||||
// prior test would serve a hit and skip the builder every assertion spies on.
|
||||
__resetAllCachesForTests();
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
// Every module on unless a test says otherwise.
|
||||
vi.mocked(resolveModuleMap).mockResolvedValue(
|
||||
{} as Awaited<ReturnType<typeof resolveModuleMap>>,
|
||||
);
|
||||
// v1.15.20 — default to an allowing analytics-read budget.
|
||||
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
@@ -249,6 +253,29 @@ describe("GET /api/insights/correlations", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("skips the lab draws with the labs module off", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(resolveModuleMap).mockResolvedValue({
|
||||
labs: false,
|
||||
} as Awaited<ReturnType<typeof resolveModuleMap>>);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.labResult.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recomputes rather than serve a cached body after a module was switched off", async () => {
|
||||
// The switched-off set is part of the cache key. Watched red with the
|
||||
// suffix removed from the key: the second call is a cache hit and the
|
||||
// measurement window is read once.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
await callGet(makeReq());
|
||||
vi.mocked(resolveModuleMap).mockResolvedValue({
|
||||
mood: false,
|
||||
} as Awaited<ReturnType<typeof resolveModuleMap>>);
|
||||
await callGet(makeReq());
|
||||
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// v1.18.0 (B2) — the route now also requires the `insights` module.
|
||||
it("returns 403 + module.disabled when the insights module is off", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
@@ -23,7 +23,12 @@ import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import {
|
||||
MODULE_KEYS,
|
||||
requireModuleEnabled,
|
||||
resolveModuleMap,
|
||||
} from "@/lib/modules/gate";
|
||||
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
@@ -89,12 +94,18 @@ export const GET = apiHandler(async () => {
|
||||
// `invalidateUserCorrelationPatterns`, which sweeps it too. The
|
||||
// `syncAcceptedPatterns` calls are DB writes and live INSIDE the builder,
|
||||
// so a cache hit performs no write on the read path.
|
||||
// Statistics serve only with their owning modules: a channel whose module
|
||||
// is off never enters the scan. The switched-off set is part of the cache
|
||||
// key, so toggling a module never serves a body computed under the other
|
||||
// setting.
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
const modulesOff = MODULE_KEYS.filter((key) => modules[key] === false);
|
||||
const body = await cachedSwr(
|
||||
caches.analytics as ServerCache<
|
||||
Awaited<ReturnType<typeof buildCorrelationsResponse>>
|
||||
>,
|
||||
`${user.id}|correlations|${locale}`,
|
||||
() => buildCorrelationsResponse(user.id, locale),
|
||||
`${user.id}|correlations|${locale}|off:${modulesOff.join(",")}`,
|
||||
() => buildCorrelationsResponse(user.id, locale, modules),
|
||||
annotate,
|
||||
);
|
||||
|
||||
@@ -104,6 +115,7 @@ export const GET = apiHandler(async () => {
|
||||
async function buildCorrelationsResponse(
|
||||
userId: string,
|
||||
locale: Awaited<ReturnType<typeof resolveServerLocale>>,
|
||||
modules: SurfaceModuleMap,
|
||||
) {
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -125,8 +137,15 @@ async function buildCorrelationsResponse(
|
||||
// v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside; they feed
|
||||
// a different pass over a different grain, so they are not matrix channels.
|
||||
const [matrix, labDraws] = await Promise.all([
|
||||
assembleDiscoveryMatrix(userId, { tz, since, fetchMode: "tiered" }),
|
||||
fetchLabDraws(userId, tz, since),
|
||||
assembleDiscoveryMatrix(userId, {
|
||||
tz,
|
||||
since,
|
||||
fetchMode: "tiered",
|
||||
modules,
|
||||
}),
|
||||
isSurfaceVisible("correlation:LAB_DRAWS", modules)
|
||||
? fetchLabDraws(userId, tz, since)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const { series, diagnostics } = matrix;
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* The discovery matrix leaves out every channel whose module the record
|
||||
* switched off, through the surface map, so no correlation statistic is
|
||||
* computed over a switched-off module.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const pts = [{ day: "2026-09-01", value: 1 }];
|
||||
|
||||
vi.mock("@/lib/insights/correlation-channel-series", () => ({
|
||||
buildMeasurementDailySeries: vi.fn(),
|
||||
fetchMeasurementWindowSeries: vi.fn(),
|
||||
fetchMeasurementDailySeriesTiered: vi.fn(
|
||||
async (_u: string, _tz: string, _s: Date, types: string[]) => ({
|
||||
byType: new Map(types.map((t) => [t, pts])),
|
||||
measurementsCapped: false,
|
||||
rollupTypes: [],
|
||||
}),
|
||||
),
|
||||
fetchMoodWindowSeries: vi.fn(async () => ({
|
||||
moodDaily: pts,
|
||||
moodCapped: false,
|
||||
})),
|
||||
fetchMoodFactorWindowSeries: vi.fn(async () => ({
|
||||
moodDaily: pts,
|
||||
moodCapped: false,
|
||||
factorSeries: new Map([["FACTOR:work", pts]]),
|
||||
})),
|
||||
fetchComplianceSeries: vi.fn(async () => ({
|
||||
key: "MEDICATION_COMPLIANCE",
|
||||
role: "behaviour",
|
||||
points: pts,
|
||||
})),
|
||||
fetchSymptomSeries: vi.fn(async () => ({
|
||||
key: "SYMPTOM_SEVERITY",
|
||||
role: "outcome",
|
||||
points: pts,
|
||||
})),
|
||||
fetchEnvironmentSeries: vi.fn(async () => [
|
||||
{ key: "ENV_TEMP_MEAN", role: "behaviour", points: pts },
|
||||
]),
|
||||
fetchCustomMetricBehaviourSeries: vi.fn(async () => [
|
||||
{ key: "CUSTOM_METRIC:abc", role: "behaviour", points: pts },
|
||||
]),
|
||||
}));
|
||||
vi.mock("@/lib/rollups/measurement-read", () => ({
|
||||
loadUserSourcePriority: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
assembleDiscoveryMatrix,
|
||||
maskSeriesByModules,
|
||||
} from "@/lib/insights/discovery-matrix";
|
||||
|
||||
const OPTS = {
|
||||
tz: "UTC",
|
||||
since: new Date("2026-03-01T00:00:00Z"),
|
||||
fetchMode: "tiered" as const,
|
||||
includeMoodFactors: true,
|
||||
};
|
||||
|
||||
const keysOf = (series: { key: string }[]) => [
|
||||
...new Set(series.map((s) => s.key)),
|
||||
];
|
||||
|
||||
describe("assembleDiscoveryMatrix — switched-off modules", () => {
|
||||
it("keeps every channel without a module map", async () => {
|
||||
const { series } = await assembleDiscoveryMatrix("u1", OPTS);
|
||||
const keys = keysOf(series);
|
||||
for (const key of [
|
||||
"MOOD",
|
||||
"FACTOR:work",
|
||||
"SLEEP_DURATION",
|
||||
"BLOOD_GLUCOSE",
|
||||
"MEDICATION_COMPLIANCE",
|
||||
"SYMPTOM_SEVERITY",
|
||||
"ENV_TEMP_MEAN",
|
||||
"CUSTOM_METRIC:abc",
|
||||
"WEIGHT",
|
||||
]) {
|
||||
expect(keys).toContain(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops each module's channels, and only those, when it is off", async () => {
|
||||
const { series, byMetric } = await assembleDiscoveryMatrix("u1", {
|
||||
...OPTS,
|
||||
modules: {
|
||||
mood: false,
|
||||
sleep: false,
|
||||
glucose: false,
|
||||
medications: false,
|
||||
illness: false,
|
||||
environment: false,
|
||||
},
|
||||
});
|
||||
const keys = keysOf(series);
|
||||
for (const gone of [
|
||||
"MOOD",
|
||||
"FACTOR:work",
|
||||
"SLEEP_DURATION",
|
||||
"BLOOD_GLUCOSE",
|
||||
"MEDICATION_COMPLIANCE",
|
||||
"SYMPTOM_SEVERITY",
|
||||
"ENV_TEMP_MEAN",
|
||||
]) {
|
||||
expect(keys, gone).not.toContain(gone);
|
||||
expect(byMetric.has(gone), gone).toBe(false);
|
||||
}
|
||||
// Core vitals and custom metrics belong to no module.
|
||||
expect(keys).toContain("WEIGHT");
|
||||
expect(keys).toContain("BLOOD_PRESSURE_SYS");
|
||||
expect(keys).toContain("CUSTOM_METRIC:abc");
|
||||
});
|
||||
|
||||
it("drops nothing for AI analysis off", async () => {
|
||||
const all = await assembleDiscoveryMatrix("u1", OPTS);
|
||||
const off = await assembleDiscoveryMatrix("u1", {
|
||||
...OPTS,
|
||||
modules: { insights: false },
|
||||
});
|
||||
expect(off.series.map((s) => s.key)).toEqual(all.series.map((s) => s.key));
|
||||
});
|
||||
});
|
||||
|
||||
describe("maskSeriesByModules", () => {
|
||||
it("keeps the fold order of what remains", () => {
|
||||
const series = ["WEIGHT", "MOOD", "PULSE", "FACTOR:x"].map((key) => ({
|
||||
key,
|
||||
role: "behaviour" as const,
|
||||
points: pts,
|
||||
}));
|
||||
expect(
|
||||
maskSeriesByModules(series, { mood: false }).map((s) => s.key),
|
||||
).toEqual(["WEIGHT", "PULSE"]);
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,11 @@ import {
|
||||
type MoodFactorWindowFetch,
|
||||
} from "@/lib/insights/correlation-channel-series";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import {
|
||||
correlationChannelSurfaceId,
|
||||
isSurfaceVisible,
|
||||
type SurfaceModuleMap,
|
||||
} from "@/lib/modules/surface";
|
||||
|
||||
/**
|
||||
* How the measurement channels are read.
|
||||
@@ -117,6 +122,14 @@ export interface DiscoveryMatrixOptions {
|
||||
* measurement read instead of a second one.
|
||||
*/
|
||||
extraMeasurementTypes?: readonly MeasurementType[];
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
modules?: SurfaceModuleMap;
|
||||
}
|
||||
|
||||
/** Per-channel reach, for the callers' wide-event annotations. */
|
||||
@@ -150,6 +163,20 @@ export interface DiscoveryMatrix {
|
||||
diagnostics: DiscoveryMatrixDiagnostics;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}).
|
||||
*/
|
||||
export function maskSeriesByModules(
|
||||
series: readonly NamedSeries[],
|
||||
modules: SurfaceModuleMap | null | undefined,
|
||||
): NamedSeries[] {
|
||||
return series.filter((s) =>
|
||||
isSurfaceVisible(correlationChannelSurfaceId(s.key), modules),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mood read, normalised to the factor-carrying shape. With factors off the
|
||||
* factor map is empty, so the fold below has one code path rather than two.
|
||||
@@ -302,8 +329,14 @@ export async function assembleDiscoveryMatrix(
|
||||
byMetric.set(key, factorPoints);
|
||||
}
|
||||
|
||||
for (const key of [...byMetric.keys()]) {
|
||||
if (!isSurfaceVisible(correlationChannelSurfaceId(key), opts.modules)) {
|
||||
byMetric.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
series,
|
||||
series: maskSeriesByModules(series, opts.modules),
|
||||
byMetric,
|
||||
diagnostics: {
|
||||
measurementsCapped: measurements.measurementsCapped,
|
||||
|
||||
Reference in new issue
Block a user