diff --git a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts index 97ba155be..ecb2b5e37 100644 --- a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts @@ -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 => + 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 } }) => { diff --git a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts index 2b692a2bc..5f61bd35d 100644 --- a/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-new-metrics.test.ts @@ -33,6 +33,22 @@ const prismaMock = prisma as unknown as { }; const featuresMock = extractFeatures as unknown as ReturnType; +// 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 => + 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([]); diff --git a/src/lib/ai/coach/__tests__/snapshot.test.ts b/src/lib/ai/coach/__tests__/snapshot.test.ts index 57856c54e..32f1a208a 100644 --- a/src/lib/ai/coach/__tests__/snapshot.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot.test.ts @@ -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 => + 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"); + }); }); // ──────────────────────────────────────────────────────────────────── diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index cb0d7ac39..dad38f8c3 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -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 = new Set([ "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> = { + 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(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