mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(modules): exclude disabled-module domains from the coach snapshot
Resolve the per-user module map once at snapshot build start and fold any disabled data-domain module into the same system-side source exclusion the excludeMetrics flow already drives, so a disabled domain (mood, sleep, glucose, workouts) never reaches the coach context. The recovery composites are gated explicitly as well, since their derivedActive flag also reads the sleep signal and would otherwise survive when only recovery is off. Cycle keeps resolving through its single-source-of-truth gate. Tests stub resolveModuleMap to the all-on default so existing fixtures are unaffected.
This commit is contained in:
1 parent
2629976096
commit
e365350dd7
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([]);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user