mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
merge(modules): exclude disabled-module domains from the coach snapshot
This commit is contained in:
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([]);
|
||||
|
||||
@@ -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