perf(insights): gate weight and BMI status on a cheap input fingerprint

Weight and BMI move on a weekly cadence, but the per-metric generators
rebuilt the full snapshot (bounded findMany + per-series rollup reads +
correlation math) every day and relied on the post-build content-hash gate
to skip only the model call. Add an input gate that runs first: a single
grouped probe over the salient inputs (per-type count + newest reading, plus
mood for weight and the profile height for BMI) fingerprints what the
snapshot would read. On a non-forced run whose fingerprint matches the cached
row, the cached assessment is re-stamped under today's key and the whole
build is skipped. A miss falls through to the normal build and the existing
post-build gate.

Consent is checked before any re-date so a revoked-consent user never has
stale text presented as current. Forced runs never gate but still record a
fresh fingerprint for the next day.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 00:56:07 +02:00
1 parent e18bd3e23a
commit 7a5f305a3a
8 files changed
+448 -5

No files matched your search

@@ -4,7 +4,8 @@ vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
measurement: { findMany: vi.fn() },
// v1.18.11 (P6) — the input gate probes salient inputs via groupBy.
measurement: { findMany: vi.fn(), groupBy: vi.fn() },
measurementRollup: { findMany: vi.fn() },
},
}));
@@ -50,6 +51,10 @@ beforeEach(() => {
// Cold rollup tier: the BMI graded series scales the WEIGHT tier, which
// folds from the full-history `measurement.findMany` fallback on a miss.
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
// v1.18.11 (P6) — input-gate probe default: empty groups so the fingerprint
// is computed but, with no cached `inputHash`, the gate misses and fixtures
// build normally. The forced fixtures skip the gate entirely.
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
});
describe("generateBmiStatusForUser — graded payload", () => {
@@ -16,10 +16,11 @@ vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
auditLog: { findFirst: vi.fn(), create: vi.fn() },
measurement: { findMany: vi.fn() },
// v1.18.11 (P6) — the slow-mover input gate probes salient inputs.
measurement: { findMany: vi.fn(), groupBy: vi.fn() },
measurementRollup: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn(), aggregate: vi.fn() },
},
}));
@@ -76,6 +77,13 @@ beforeEach(() => {
[] as never,
);
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
// v1.18.11 (P6) — input-gate probe: empty groups + zero mood so the gate
// misses (no cached inputHash) and every fixture builds normally.
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.aggregate).mockResolvedValue({
_count: { _all: 0 },
_max: { moodLoggedAt: null },
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: null,
gender: null,
@@ -3,6 +3,9 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn() },
// v1.18.11 (P6) — the input gate / fingerprint probe salient inputs.
measurement: { groupBy: vi.fn() },
moodEntry: { aggregate: vi.fn() },
},
}));
@@ -21,6 +24,8 @@ vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({
import { prisma } from "@/lib/db";
import {
computeStatusInputFingerprint,
gateUnchangedStatusInput,
isTimeoutStub,
readFreshStatusText,
refreshUnchangedStatusInsight,
@@ -400,3 +405,177 @@ describe("refreshUnchangedStatusInsight (v1.16.8)", () => {
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("computeStatusInputFingerprint (v1.18.11 P6)", () => {
beforeEach(() => {
statusConsentBlocksGeneration.mockResolvedValue(false);
});
it("is stable across group order and flips when a count or newest moves", async () => {
const t0 = new Date("2026-05-30T08:00:00.000Z");
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
{ type: "WEIGHT", _count: { _all: 10 }, _max: { measuredAt: t0 } },
{
type: "BLOOD_PRESSURE_SYS",
_count: { _all: 3 },
_max: { measuredAt: t0 },
},
] as never);
const a = await computeStatusInputFingerprint({
userId: "u1",
types: ["WEIGHT", "BLOOD_PRESSURE_SYS"],
});
// Same data, reversed group order → identical fingerprint.
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
{
type: "BLOOD_PRESSURE_SYS",
_count: { _all: 3 },
_max: { measuredAt: t0 },
},
{ type: "WEIGHT", _count: { _all: 10 }, _max: { measuredAt: t0 } },
] as never);
const b = await computeStatusInputFingerprint({
userId: "u1",
types: ["WEIGHT", "BLOOD_PRESSURE_SYS"],
});
expect(b).toBe(a);
// One more reading → count moves → fingerprint flips.
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
{ type: "WEIGHT", _count: { _all: 11 }, _max: { measuredAt: t0 } },
{
type: "BLOOD_PRESSURE_SYS",
_count: { _all: 3 },
_max: { measuredAt: t0 },
},
] as never);
const c = await computeStatusInputFingerprint({
userId: "u1",
types: ["WEIGHT", "BLOOD_PRESSURE_SYS"],
});
expect(c).not.toBe(a);
});
it("folds mood and extra inputs into the hash when requested", async () => {
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
{
type: "WEIGHT",
_count: { _all: 5 },
_max: { measuredAt: new Date("2026-05-30T08:00:00.000Z") },
},
] as never);
vi.mocked(prisma.moodEntry.aggregate).mockResolvedValue({
_count: { _all: 2 },
_max: { moodLoggedAt: new Date("2026-05-29T20:00:00.000Z") },
} as never);
const withMood = await computeStatusInputFingerprint({
userId: "u1",
types: ["WEIGHT"],
includeMood: true,
});
const heightChanged = await computeStatusInputFingerprint({
userId: "u1",
types: ["WEIGHT"],
includeMood: true,
extra: { heightCm: 180 },
});
expect(heightChanged).not.toBe(withMood);
// moodEntry.aggregate is only queried when includeMood is set.
await computeStatusInputFingerprint({ userId: "u1", types: ["WEIGHT"] });
expect(prisma.moodEntry.aggregate).toHaveBeenCalledTimes(2);
});
});
describe("gateUnchangedStatusInput (v1.18.11 P6)", () => {
const INPUT = "b".repeat(64);
beforeEach(() => {
statusConsentBlocksGeneration.mockResolvedValue(false);
});
it("re-stamps the cached text and skips the build on a matching input hash", async () => {
const created = new Date("2026-05-31T02:00:00.000Z");
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: "2026-05-30",
text: "Stable weight.",
model: "gpt-4o-mini",
inputHash: INPUT,
snapshotHash: "c".repeat(64),
}) as never,
);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: created,
} as never);
const hit = await gateUnchangedStatusInput({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
inputHash: INPUT,
force: false,
});
expect(hit?.text).toBe("Stable weight.");
const persisted = JSON.parse(
(
vi.mocked(prisma.auditLog.create).mock.calls[0][0] as {
data: { details: string };
}
).data.details,
) as { dateKey: string; inputHash: string; snapshotHash: string };
expect(persisted.dateKey).toBe(TODAY);
// The prior fingerprints are preserved so the next day's gates match.
expect(persisted.inputHash).toBe(INPUT);
expect(persisted.snapshotHash).toBe("c".repeat(64));
});
it("misses on a differing or missing input hash (caller builds)", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: "2026-05-30",
text: "Stable weight.",
model: "gpt-4o-mini",
// no inputHash on the row
}) as never,
);
expect(
await gateUnchangedStatusInput({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
inputHash: INPUT,
force: false,
}),
).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
it("misses on a forced run without touching the cache", async () => {
expect(
await gateUnchangedStatusInput({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
inputHash: INPUT,
force: true,
}),
).toBeNull();
expect(prisma.auditLog.findFirst).not.toHaveBeenCalled();
});
it("misses when the server-managed consent gate would block (no stale re-date)", async () => {
statusConsentBlocksGeneration.mockResolvedValue(true);
expect(
await gateUnchangedStatusInput({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
inputHash: INPUT,
force: false,
}),
).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
@@ -3,9 +3,11 @@ import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { findFirst: vi.fn(), create: vi.fn() },
measurement: { findMany: vi.fn() },
// v1.18.11 (P6) — the input gate probes salient inputs via groupBy +
// moodEntry.aggregate before the heavy findMany build.
measurement: { findMany: vi.fn(), groupBy: vi.fn() },
measurementRollup: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn(), aggregate: vi.fn() },
// v1.11.1 — the rollup readers lazy-load the user's
// `sourcePriorityJson` via `loadUserSourcePriority`. `null` here →
// default rank ladders.
@@ -55,6 +57,15 @@ beforeEach(() => {
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
// v1.11.1 — null source-priority blob → default rank ladders.
vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never);
// v1.18.11 (P6) — input-gate probe. Default to empty groups + zero mood so
// the fingerprint is computed but, with no cached `inputHash`, the gate
// misses and every fixture proceeds to its normal build. Tests that
// exercise the gate set these explicitly.
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.aggregate).mockResolvedValue({
_count: { _all: 0 },
_max: { moodLoggedAt: null },
} as never);
});
describe("generateWeightStatusForUser — graded payload", () => {
+36
View File
@@ -28,6 +28,8 @@ import {
summarizeSeries,
} from "@/lib/insights/status-shared";
import {
computeStatusInputFingerprint,
gateUnchangedStatusInput,
readFreshStatusText,
refreshUnchangedStatusInsight,
resolveReadOnlyStatusMiss,
@@ -176,6 +178,38 @@ export async function prepareBmiStatusForUser(
};
}
// v1.18.11 (P6) — input gate for this slow-moving metric. The snapshot
// derives from WEIGHT rows and the profile heightCm; a cheap probe over
// both fingerprints the inputs, so a non-forced unchanged week re-stamps
// the cached assessment and skips the whole build (findMany + BMI series +
// provider). A forced run never gates but still records a current
// fingerprint for the next day's gate.
const inputHash = await computeStatusInputFingerprint({
userId,
types: ["WEIGHT"],
extra: { heightCm: user.heightCm },
});
if (!force) {
const unchangedInput = await gateUnchangedStatusInput({
userId,
cacheAction,
todayKey,
inputHash,
force,
});
if (unchangedInput) {
return {
phase: "served",
result: {
hasProvider: true,
text: unchangedInput.text,
cached: true,
updatedAt: unchangedInput.updatedAt,
},
};
}
}
// v1.4.28 FB-D2 — cap the snapshot input (weight runs at most once
// per day for typical users; 365 covers a full year while the
// downstream payload budget trims further).
@@ -404,6 +438,8 @@ export async function prepareBmiStatusForUser(
model: outcome.model,
tokensUsed: outcome.tokensUsed,
snapshotHash,
// v1.18.11 (P6) — persist the input fingerprint for tomorrow's gate.
inputHash,
});
return {
hasProvider: true,
+155
View File
@@ -1,3 +1,4 @@
import type { MeasurementType } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import {
hasUsableStatusProvider,
@@ -7,6 +8,7 @@ import {
enqueueStatusGeneration,
type InsightStatusScope,
} from "@/lib/jobs/insight-status-generate-shared";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import { annotate } from "@/lib/logging/context";
/**
@@ -57,6 +59,8 @@ interface ParsedStatusCache {
retryAt?: string;
/** v1.16.8 — fingerprint of the data snapshot the text was generated from. */
snapshotHash?: string;
/** v1.18.11 (P6) — cheap fingerprint of the salient inputs (count + newest). */
inputHash?: string;
}
/**
@@ -198,6 +202,157 @@ export async function refreshUnchangedStatusInsight(args: {
return { text: parsed.text, updatedAt: created.createdAt.toISOString() };
}
/**
* v1.18.11 (P6) — cheap input fingerprint for the slow-moving status
* metrics (weight / BMI).
*
* The post-build content-hash gate (`refreshUnchangedStatusInsight`)
* already skips the LLM on unchanged data, but it only runs AFTER the heavy
* snapshot build (the bounded `findMany` + per-series rollup reads +
* correlation math). For metrics that move on a weekly cadence that rebuild
* is paid six days out of seven for nothing.
*
* This probe answers "did any salient input change since the cached
* assessment?" with ONE grouped query — per salient type, the live row
* `count` plus the newest `measuredAt`. A new or removed reading flips one
* of those, which flips the hash; an idle day leaves it byte-identical. The
* caller hashes the result and, on a match, skips the entire build. The
* finer post-build snapshot gate stays in place for the cases this coarse
* probe can't see (e.g. an in-place edit that keeps count + newest stamp).
*/
export async function computeStatusInputFingerprint(args: {
userId: string;
types: readonly MeasurementType[];
/**
* v1.18.11 (P6) — include the mood-entry table in the fingerprint. The
* weight snapshot folds a mood-context block, so a mood change must flip
* the input hash or the gate would skip a build whose prose could have
* moved. Omit for metrics that don't read mood.
*/
includeMood?: boolean;
/**
* v1.18.11 (P6) — extra non-measurement inputs the snapshot derives from
* (e.g. BMI reads the profile `heightCm`). Folded into the hash so a
* change to one of them flips the gate. Values must be JSON-stable.
*/
extra?: Record<string, string | number | null>;
}): Promise<string> {
const [grouped, mood] = await Promise.all([
prisma.measurement.groupBy({
by: ["type"],
where: {
userId: args.userId,
type: { in: [...args.types] },
deletedAt: null,
},
_count: { _all: true },
_max: { measuredAt: true },
}),
args.includeMood
? prisma.moodEntry.aggregate({
where: { userId: args.userId },
_count: { _all: true },
_max: { moodLoggedAt: true },
})
: Promise.resolve(null),
]);
// Deterministic shape regardless of group order: sort by type, project a
// stable `{ type, count, newest }` triple. `hashInsightSnapshot` sorts keys
// and collapses Date → ISO, so the hash is order- and clock-stable.
const fingerprint = grouped
.map((row) => ({
type: row.type,
count: row._count._all,
newest: row._max.measuredAt ? row._max.measuredAt.toISOString() : null,
}))
.sort((a, b) => (a.type < b.type ? -1 : a.type > b.type ? 1 : 0));
return hashInsightSnapshot({
statusInput: fingerprint,
...(mood
? {
mood: {
count: mood._count._all,
newest: mood._max.moodLoggedAt
? mood._max.moodLoggedAt.toISOString()
: null,
},
}
: {}),
...(args.extra ? { extra: args.extra } : {}),
});
}
/**
* v1.18.11 (P6) — the INPUT gate for slow-moving status metrics.
*
* Runs BEFORE the snapshot build. When the latest cached assessment is a
* real (non-stub) text whose stored `inputHash` equals the freshly probed
* one, nothing the prompt could see has changed, so the gate re-stamps that
* text under today's `dateKey` and returns it — the caller then skips the
* whole gather AND the provider call. Returns `null` on any miss (no prior
* row, stub, empty text, missing or differing `inputHash`, or a forced
* regeneration), in which case the caller proceeds to the normal build +
* the finer post-build content-hash gate.
*
* Consent is checked first, mirroring `refreshUnchangedStatusInsight`:
* re-dating a cached text presents it as current, so a user who revoked the
* server-managed AI consent must never have stale text re-stamped by an
* unchanged-input refresh.
*/
export async function gateUnchangedStatusInput(args: {
userId: string;
cacheAction: string;
todayKey: string;
inputHash: string;
force: boolean;
}): Promise<FreshStatusCacheHit | null> {
if (args.force) return null;
if (await statusConsentBlocksGeneration(args.userId, "insights")) {
annotate({
action: { name: "insights.status.consent_required" },
meta: { cache_action: args.cacheAction, gate: "unchanged-input" },
});
return null;
}
const latest = await prisma.auditLog.findFirst({
where: { userId: args.userId, action: args.cacheAction },
orderBy: { createdAt: "desc" },
select: { details: true },
});
if (!latest?.details) return null;
let parsed: ParsedStatusCache;
try {
parsed = JSON.parse(latest.details) as ParsedStatusCache;
} catch {
return null;
}
if (isTimeoutStub(parsed)) return null;
if (typeof parsed.text !== "string" || parsed.text.trim().length === 0) {
return null;
}
if (!parsed.inputHash || parsed.inputHash !== args.inputHash) return null;
// Same inputs, valid text — re-stamp the day key WITHOUT rebuilding the
// snapshot or calling the provider. Preserve every field (incl. the prior
// `snapshotHash` + `inputHash`) so the next day's gates still match.
const created = await prisma.auditLog.create({
data: {
userId: args.userId,
action: args.cacheAction,
details: JSON.stringify({ ...parsed, dateKey: args.todayKey }),
},
select: { createdAt: true },
});
annotate({
action: { name: "insights.status.skipped_unchanged" },
meta: { cache_action: args.cacheAction, gate: "input" },
});
return { text: parsed.text, updatedAt: created.createdAt.toISOString() };
}
export interface LastGoodStatusHit {
text: string;
updatedAt: string;
+10
View File
@@ -173,6 +173,15 @@ export async function persistStatusInsight(args: {
* provider call when nothing changed.
*/
snapshotHash?: string;
/**
* v1.18.11 (P6) — cheap fingerprint of the SALIENT INPUTS (per-type
* count + newest measuredAt) for slow-moving metrics. The input gate
* compares a freshly probed fingerprint against this BEFORE the heavy
* snapshot build, so a no-change day for weight/BMI skips the whole
* gather (not just the provider call). Absent on metrics that don't
* opt into the input gate.
*/
inputHash?: string;
}): Promise<string> {
const created = await prisma.auditLog.create({
data: {
@@ -186,6 +195,7 @@ export async function persistStatusInsight(args: {
model: args.model,
tokensUsed: args.tokensUsed,
...(args.snapshotHash ? { snapshotHash: args.snapshotHash } : {}),
...(args.inputHash ? { inputHash: args.inputHash } : {}),
}),
},
select: { createdAt: true },
+39
View File
@@ -39,6 +39,8 @@ import {
summarizeSeries,
} from "@/lib/insights/status-shared";
import {
computeStatusInputFingerprint,
gateUnchangedStatusInput,
readFreshStatusText,
refreshUnchangedStatusInsight,
resolveReadOnlyStatusMiss,
@@ -212,6 +214,40 @@ export async function prepareWeightStatusForUser(
};
}
// v1.18.11 (P6) — input gate for this slow-moving metric. A cheap grouped
// probe over the salient inputs (weight + BP channels + mood) fingerprints
// what the snapshot would read. On a non-forced run with an unchanged
// fingerprint the cached assessment is re-stamped under today's key and the
// whole heavy build below (bounded findMany + per-series rollup reads +
// correlation math + the provider call) is skipped. A forced run never
// gates — but still computes the fingerprint so the persisted row carries a
// current one for the next day's gate.
const inputHash = await computeStatusInputFingerprint({
userId,
types: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
includeMood: true,
});
if (!force) {
const unchangedInput = await gateUnchangedStatusInput({
userId,
cacheAction,
todayKey,
inputHash,
force,
});
if (unchangedInput) {
return {
phase: "served",
result: {
hasProvider: true,
text: unchangedInput.text,
cached: true,
updatedAt: unchangedInput.updatedAt,
},
};
}
}
// v1.4.28 FB-D2 — cap the snapshot input. BP captures three types
// per day so 1095 = 365 d × 3 channels. Order desc + reverse so we
// keep the most recent year of rows.
@@ -591,6 +627,9 @@ export async function prepareWeightStatusForUser(
model: outcome.model,
tokensUsed: outcome.tokensUsed,
snapshotHash,
// v1.18.11 (P6) — persist the input fingerprint so tomorrow's input
// gate can skip the rebuild when nothing salient changed.
inputHash,
});
return {
hasProvider: true,