merge(modules): exclude disabled-module domains from the coach snapshot

This commit is contained in:
Marc-André Bombeck committed 2026-06-15 21:16:14 +02:00
commit c9c53865df
4 files changed
+295 -1

No files matched your search

@@ -22,6 +22,22 @@ vi.mock("@/lib/insights/features", () => ({
extractFeatures: vi.fn(),
}));
// v1.18.0 — the snapshot now resolves the per-user module map; stub it so the
// default fixture is "all modules on" (legacy behaviour) without the real gate.
import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: vi.fn(),
}));
import { resolveModuleMap } from "@/lib/modules/gate";
const resolveModuleMapMock = resolveModuleMap as unknown as ReturnType<
typeof vi.fn
>;
const allModulesEnabled = (): Record<ModuleKey, boolean> =>
Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as Record<
ModuleKey,
boolean
>;
// Capture annotate calls so the budgeting assertions can prove the new
// observability events fire.
const annotateCalls: Array<{
@@ -148,6 +164,7 @@ describe("buildCoachSnapshot — budgeting + progressive degradation", () => {
vi.clearAllMocks();
annotateCalls.length = 0;
__resetCoachSnapshotCacheForTests();
resolveModuleMapMock.mockResolvedValue(allModulesEnabled());
const measurementRows = denseMeasurementRows();
prismaMock.measurement.findMany.mockImplementation(
(args: { where?: { type?: { in?: string[] } | string } }) => {
@@ -33,6 +33,22 @@ const prismaMock = prisma as unknown as {
};
const featuresMock = extractFeatures as unknown as ReturnType<typeof vi.fn>;
// v1.18.0 — the snapshot now resolves the per-user module map; stub it so the
// default fixture is "all modules on" (legacy behaviour) without the real gate.
import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: vi.fn(),
}));
import { resolveModuleMap } from "@/lib/modules/gate";
const resolveModuleMapMock = resolveModuleMap as unknown as ReturnType<
typeof vi.fn
>;
const allModulesEnabled = (): Record<ModuleKey, boolean> =>
Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as Record<
ModuleKey,
boolean
>;
/**
* v1.4.23 W4 F6 — pins the Apple Health timeline blocks the Coach
* snapshot must ship when the user has HealthKit data and explicitly
@@ -60,6 +76,7 @@ describe("buildCoachSnapshot — Apple Health additive metrics (v1.4.23)", () =>
// v1.4.33 — `buildCoachSnapshot` now memoises results in-process
// for 60 s; reset between tests so each fixture is read fresh.
__resetCoachSnapshotCacheForTests();
resolveModuleMapMock.mockResolvedValue(allModulesEnabled());
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
prismaMock.medication.findMany.mockResolvedValue([]);
@@ -344,6 +361,7 @@ describe("buildCoachSnapshot — WHOOP-native day strain (v1.17.0)", () => {
beforeEach(() => {
vi.clearAllMocks();
__resetCoachSnapshotCacheForTests();
resolveModuleMapMock.mockResolvedValue(allModulesEnabled());
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
prismaMock.medication.findMany.mockResolvedValue([]);
+187
View File
@@ -22,6 +22,27 @@ vi.mock("@/lib/insights/features", () => ({
extractFeatures: vi.fn(),
}));
// v1.18.0 — the snapshot resolves the per-user module map at build start
// so a disabled data-domain module's data never enters the coach context.
// Mock the gate's `resolveModuleMap` so the default fixture is "all
// modules on" (the legacy behaviour) and individual tests can flip a
// single module off without standing up the real gate's DB reads.
import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
const allModulesEnabled = (): Record<ModuleKey, boolean> =>
Object.fromEntries(MODULE_KEYS.map((k) => [k, true])) as Record<
ModuleKey,
boolean
>;
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: vi.fn(),
}));
import { resolveModuleMap } from "@/lib/modules/gate";
const resolveModuleMapMock = resolveModuleMap as unknown as ReturnType<
typeof vi.fn
>;
// The rolling-profile memory block reads its own persisted sources
// (period narrative + band transitions); stub it out here so the
// query-count + cache assertions below stay scoped to the core snapshot
@@ -30,6 +51,18 @@ vi.mock("../memory-snapshot", () => ({
buildCoachMemoryBlock: vi.fn().mockResolvedValue(null),
}));
// v1.18.0 — the recovery composites (derived block + trajectory) are read
// through these builders. Stub them so the recovery-disable test can
// assert they are never invoked (the disabled module pays no read cost);
// their own assembly is covered in derived-snapshot.test.ts /
// trajectory-snapshot.test.ts.
vi.mock("../derived-snapshot", () => ({
buildDerivedSnapshotBlock: vi.fn().mockResolvedValue(null),
}));
vi.mock("../trajectory-snapshot", () => ({
buildTrajectorySnapshotBlock: vi.fn().mockResolvedValue(null),
}));
import { prisma } from "@/lib/db";
import { extractFeatures } from "@/lib/insights/features";
import {
@@ -79,6 +112,8 @@ describe("buildCoachSnapshot", () => {
// v1.4.23 H4 — snapshot now reads `User.coachPrefsJson` to apply
// per-user excludeMetrics. Default null = use legacy defaults.
prismaMock.user.findUnique.mockResolvedValue({ coachPrefsJson: null });
// v1.18.0 — default fixture: every toggleable module enabled.
resolveModuleMapMock.mockResolvedValue(allModulesEnabled());
featuresMock.mockResolvedValue({
bloodPressure: undefined,
weight: undefined,
@@ -522,6 +557,158 @@ describe("buildCoachSnapshot", () => {
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.sleepRhythm).toBeUndefined();
});
// ── v1.18.0 module enable/disable — disabled domains never reach the
// coach context ───────────────────────────────────────────────────────
//
// When a toggleable data-domain module is disabled for the account, the
// snapshot must exclude that domain entirely — the source drops from
// `scope.sources`, no block is emitted, and the provenance metric is
// absent. The gate's resolved map is authoritative (delegations already
// resolved), so the snapshot folds it into the same SYSTEM-side
// exclusion the user's `excludeMetrics` flow already drives.
it("drops the mood domain from the snapshot when the mood module is disabled", async () => {
resolveModuleMapMock.mockResolvedValue({
...allModulesEnabled(),
mood: false,
});
featuresMock.mockResolvedValue({
mood: { avg30: 4.2, coverage: { count: 12 } },
});
prismaMock.moodEntry.findMany.mockResolvedValue([
{
moodLoggedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000),
score: 4,
},
]);
const out = await buildCoachSnapshot("user-1", { sources: ["mood"] });
const parsed = JSON.parse(out.snapshotJson);
// No mood block, no mood source in scope, no mood metric in provenance.
expect(parsed.mood).toBeUndefined();
expect(parsed.scope.sources).not.toContain("mood");
expect(out.provenance.metrics).not.toContain("mood");
// The disabled domain's table is never even read.
expect(prismaMock.moodEntry.findMany).not.toHaveBeenCalled();
});
it("drops the glucose domain when the glucose module is disabled", async () => {
resolveModuleMapMock.mockResolvedValue({
...allModulesEnabled(),
glucose: false,
});
featuresMock.mockResolvedValue({});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(2, 105, "BLOOD_GLUCOSE"),
daysAgo(4, 98, "BLOOD_GLUCOSE"),
]);
const out = await buildCoachSnapshot("user-1", { sources: ["glucose"] });
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.glucose).toBeUndefined();
expect(parsed.scope.sources).not.toContain("glucose");
expect(out.provenance.metrics).not.toContain("glucose");
});
it("drops the sleep + sleepRhythm blocks when the sleep module is disabled", async () => {
resolveModuleMapMock.mockResolvedValue({
...allModulesEnabled(),
sleep: false,
});
// 14 nights that would otherwise build both the sleep block and a
// ready sleepRhythm DTO.
const rows = Array.from({ length: 14 }, (_, i) => {
const dd = String(1 + i).padStart(2, "0");
return {
type: "SLEEP_DURATION",
value: 360 + (i % 3) * 20,
measuredAt: new Date(`2026-06-${dd}T06:00:00Z`),
sleepStage: "ASLEEP" as const,
source: null,
deviceType: null,
};
});
prismaMock.measurement.findMany.mockResolvedValue(rows);
featuresMock.mockResolvedValue({
context: { heightCm: null, ageYears: 40, gender: null },
});
prismaMock.user.findUnique.mockResolvedValue({
coachPrefsJson: null,
timezone: "UTC",
});
const out = await buildCoachSnapshot("user-1", { sources: ["sleep"] });
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.sleep).toBeUndefined();
expect(parsed.sleepRhythm).toBeUndefined();
expect(parsed.scope.sources).not.toContain("sleep");
expect(out.provenance.metrics).not.toContain("sleep");
});
it("drops the recovery composites when the recovery module is disabled, even with sleep on", async () => {
// Recovery off but sleep on. `derivedActive` reads the sleep signal,
// so without an explicit recovery gate the derived / dayStrain /
// trajectory blocks would still build off sleep alone.
resolveModuleMapMock.mockResolvedValue({
...allModulesEnabled(),
recovery: false,
});
const { buildDerivedSnapshotBlock } = await import("../derived-snapshot");
const { buildTrajectorySnapshotBlock } = await import(
"../trajectory-snapshot"
);
prismaMock.measurement.findMany.mockResolvedValue([]);
featuresMock.mockResolvedValue({
context: { heightCm: 180, ageYears: 40, gender: "MALE" },
});
const out = await buildCoachSnapshot("user-1", {
sources: ["sleep", "hrv", "resting_hr", "vo2_max"],
});
const parsed = JSON.parse(out.snapshotJson);
// No recovery composites reach the prompt.
expect(parsed.derived).toBeUndefined();
expect(parsed.dayStrain).toBeUndefined();
expect(parsed.trajectory).toBeUndefined();
// The recovery source tokens are stripped from scope.
for (const src of ["hrv", "resting_hr", "vo2_max"]) {
expect(parsed.scope.sources).not.toContain(src);
}
// The derived / trajectory readers are never even called — the
// disabled module pays no read cost.
expect(buildDerivedSnapshotBlock).not.toHaveBeenCalled();
expect(buildTrajectorySnapshotBlock).not.toHaveBeenCalled();
});
it("keeps an enabled domain present while a sibling module is disabled", async () => {
// glucose off, weight on — weight must still flow through.
resolveModuleMapMock.mockResolvedValue({
...allModulesEnabled(),
glucose: false,
});
featuresMock.mockResolvedValue({
weight: { latest: 80, coverage: { count: 4 } },
});
prismaMock.measurement.findMany.mockResolvedValue([
daysAgo(2, 80.0, "WEIGHT"),
]);
const out = await buildCoachSnapshot("user-1", {
sources: ["weight", "glucose"],
});
const parsed = JSON.parse(out.snapshotJson);
expect(parsed.weight).toBeDefined();
expect(parsed.scope.sources).toContain("weight");
expect(out.provenance.metrics).toContain("weight");
expect(parsed.scope.sources).not.toContain("glucose");
});
});
// ────────────────────────────────────────────────────────────────────
+73 -1
View File
@@ -48,6 +48,7 @@ import { buildCoachMemoryBlock } from "./memory-snapshot";
import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot";
import { buildCycleSnapshotBlock } from "./cycle-snapshot";
import { isCycleEnabled } from "@/lib/cycle/gate";
import { resolveModuleMap, type ModuleKey } from "@/lib/modules/gate";
import {
buildComplianceLedgerRows,
buildComplianceMedicationContext,
@@ -150,6 +151,45 @@ const CORE_CLUSTERS: ReadonlySet<CoachDataCluster> = new Set<CoachDataCluster>([
"glucose",
]);
/**
* v1.18.0 — module enable/disable → coach-snapshot domain map.
*
* When a toggleable data-domain module is disabled for the account, the
* domains it owns must never enter the coach context. We reuse the
* existing `excludeMetrics` filtering path (the `excluded` set narrows
* `sources` before any row is read) by folding the disabled modules into
* a SYSTEM-side exclusion that unions with the user's `excludeMetrics`.
*
* Each toggleable data domain maps to the `CoachScopeSource` token(s)
* its snapshot block(s) gate on:
* - `mood` → the mood block (`mood` source).
* - `sleep` → the per-night sleep block + the sleep-rhythm block
* (both gate on the `sleep` source).
* - `glucose` → the glucose per-context + clinical block.
* - `workouts` → the workouts block.
* - `recovery` → the recovery / strain composites. These are the
* derived block (READINESS / RECOVERY_SCORE / STRAIN_SCORE
* / …), the WHOOP-native dayStrain block, and the
* trajectory block — all gated on `derivedActive`, which
* reads HRV / resting-HR / VO₂max. Dropping those source
* tokens drops the raw additive timelines too; the
* composites are additionally gated below so they never
* build off the sleep signal alone.
*
* `cycle` is intentionally absent: its block already resolves through the
* single-source-of-truth cycle gate (`isCycleEnabled`) below, exactly as
* the W1 foundation prescribes. `coach` is the surface being narrated, not
* a data domain. `labs` / `achievements` / `insights` / `doctorReport`
* own no coach-snapshot data domain.
*/
const MODULE_EXCLUDED_SOURCES: Partial<Record<ModuleKey, CoachScopeSource[]>> = {
mood: ["mood"],
sleep: ["sleep"],
glucose: ["glucose"],
workouts: ["workouts"],
recovery: ["hrv", "resting_hr", "vo2_max"],
};
const WEEKDAY_KEYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const;
function windowToDays(window: CoachScopeWindow): number {
@@ -501,6 +541,13 @@ async function buildCoachSnapshotImpl(
// the request omits an explicit `scope.sources`, the resolved scope
// expands the user's saved `dataClusters` (legacy default when the
// key is absent). So the prefs read must precede `resolveScope`.
// v1.18.0 — resolve the per-user module map once at build start so a
// disabled data-domain module's data never enters the coach context.
// The map read is memoised per-request by the gate, and runs alongside
// the prefs read (both only need `userId`), so the cold path pays a
// single extra round-trip at most. Disabled modules fold into the same
// SYSTEM-side exclusion the user's `excludeMetrics` flow already drives.
const moduleMapPromise = resolveModuleMap(userId);
const prefsRow = await prisma.user.findUnique({
where: { id: userId },
select: {
@@ -533,7 +580,26 @@ async function buildCoachSnapshotImpl(
);
const userTz = prefsRow?.timezone ?? DEFAULT_TIMEZONE;
const glucoseUnit = resolveGlucoseUnit(prefsRow?.glucoseUnit ?? null);
// v1.18.0 — fold disabled data-domain modules into the system exclusion.
// `moduleMap[key] === false` means the user turned that module off; the
// gate has already resolved every delegation (cycle/coach) so this map
// is authoritative. We union the disabled modules' owned sources into
// `excluded` so the existing source-narrowing path below removes them
// before any row is read — the model never sees a disabled domain.
const moduleMap = await moduleMapPromise;
const recoveryDisabled = moduleMap.recovery === false;
const excluded = new Set<CoachExcludeMetric>(prefs.excludeMetrics);
for (const [key, srcs] of Object.entries(MODULE_EXCLUDED_SOURCES)) {
if (moduleMap[key as ModuleKey] === false) {
for (const src of srcs ?? []) {
// Every entry in MODULE_EXCLUDED_SOURCES is a CoachScopeSource that
// also exists in the CoachExcludeMetric enum overlap the
// source-narrowing loop checks against; the cast mirrors the one the
// loop already uses below.
excluded.add(src as unknown as CoachExcludeMetric);
}
}
}
// v1.4.36 W3 T2 — `medications` and `anthropometrics` are
// exclude-only toggles (not in `CoachScopeSource`); they gate the
// GLP-1 weeklyContext / compliance branch and the anthropometrics
@@ -789,7 +855,13 @@ async function buildCoachSnapshotImpl(
: null,
heightCm: derivedCtx?.heightCm ?? null,
};
const derivedActive = derivedSources.some((s) => sources.has(s));
// v1.18.0 — the derived block + WHOOP-native dayStrain + trajectory are
// the `recovery` module's domain (READINESS / RECOVERY_SCORE / STRAIN /
// …). They are gated on `derivedActive`, which still reads the `sleep`
// signal — so when `recovery` is disabled but `sleep` stays on, gate them
// off explicitly here, not just by dropping the recovery source tokens.
const derivedActive =
!recoveryDisabled && derivedSources.some((s) => sources.has(s));
const moodRowsPromise =
wantsMood && features.mood