perf(insights): fold correlation-discovery channels into the status input gate

The slow-moving status input fingerprint covered the card's own metric
rows plus mood and height, but not the FDR discovery channels that also
feed the prompt via getRelevantCorrelationsForMetric. A correlation
newly surfacing off a behaviour channel (e.g. daylight -> next-day
weight) left the card's own rows untouched, so the input gate re-stamped
the stale assessment and the discovery never reached the prose.

computeStatusInputFingerprint gains includeCorrelationChannels: it widens
the same grouped probe by the discovery behaviour + outcome channels
(deduped, mood excluded as it is mood-entry backed), so a channel change
flips the hash. Wired into weight-status, the one caller that folds a
relations block.
This commit is contained in:
Marc-André Bombeck committed 2026-06-21 10:23:04 +02:00
1 parent 7540d0b508
commit 7f9aeba918
3 files changed
+115 -1

No files matched your search

@@ -457,6 +457,73 @@ describe("computeStatusInputFingerprint (v1.18.11 P6)", () => {
expect(c).not.toBe(a);
});
it("flips when a correlation-discovery channel moves (P6-tighten)", async () => {
const t0 = new Date("2026-05-30T08:00:00.000Z");
// The card's own metric (WEIGHT) is steady across both probes; only a
// discovery BEHAVIOUR channel (steps) gains rows. Without the channel
// coverage the gate would re-stamp the stale assessment and a newly
// discoverable "steps → next-day weight" relation would never surface.
const weightOnly = [
{ type: "WEIGHT", _count: { _all: 10 }, _max: { measuredAt: t0 } },
];
vi.mocked(prisma.measurement.groupBy).mockResolvedValueOnce(
weightOnly as never,
);
const before = await computeStatusInputFingerprint({
userId: "u1",
types: ["WEIGHT"],
includeCorrelationChannels: true,
});
// A new ACTIVITY_STEPS reading (a discovery channel, NOT the card's own
// type) appears — the grouped probe now returns it.
vi.mocked(prisma.measurement.groupBy).mockResolvedValueOnce([
...weightOnly,
{
type: "ACTIVITY_STEPS",
_count: { _all: 30 },
_max: { measuredAt: t0 },
},
] as never);
const after = await computeStatusInputFingerprint({
userId: "u1",
types: ["WEIGHT"],
includeCorrelationChannels: true,
});
expect(after).not.toBe(before);
});
it("widens the grouped query by the discovery channels only when asked", async () => {
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
await computeStatusInputFingerprint({
userId: "u1",
types: ["WEIGHT"],
});
const narrow = vi.mocked(prisma.measurement.groupBy).mock.calls[0][0] as {
where: { type: { in: string[] } };
};
// No channel widening — exactly the card's own types.
expect(narrow.where.type.in).toEqual(["WEIGHT"]);
vi.mocked(prisma.measurement.groupBy).mockClear();
await computeStatusInputFingerprint({
userId: "u1",
types: ["WEIGHT"],
includeCorrelationChannels: true,
});
const wide = vi.mocked(prisma.measurement.groupBy).mock.calls[0][0] as {
where: { type: { in: string[] } };
};
// The card's own type is present exactly once (de-duped against the
// discovery channel set, which also carries WEIGHT as an outcome).
expect(wide.where.type.in.filter((t) => t === "WEIGHT")).toHaveLength(1);
// A behaviour channel the card never reads on its own is now folded in.
expect(wide.where.type.in).toContain("ACTIVITY_STEPS");
expect(wide.where.type.in).toContain("SLEEP_DURATION");
// MOOD is mood-entry backed, not a measurement type — never in the IN set.
expect(wide.where.type.in).not.toContain("MOOD");
});
it("folds mood and extra inputs into the hash when requested", async () => {
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([
{
+42 -1
View File
@@ -9,8 +9,27 @@ import {
type InsightStatusScope,
} from "@/lib/jobs/insight-status-generate-shared";
import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash";
import {
DISCOVERY_BEHAVIOURS,
DISCOVERY_OUTCOMES,
} from "@/lib/insights/correlation-discovery";
import { annotate } from "@/lib/logging/context";
/**
* v1.18.11 (P6-tighten) — the measurement types the FDR correlation-discovery
* matrix scans (`correlation-discovery.ts`). A status card whose prompt folds
* the surviving cross-metric correlations (`getRelevantCorrelationsForMetric`)
* can have a NEW correlation surface purely because one of these channels
* gained paired data — with no change to the card's own metric rows. The input
* gate must therefore fingerprint these channels too, or a freshly discovered
* relation would be silently skipped for the day. `MOOD` is mood-entry backed
* (folded separately via `includeMood`), so it is excluded from the
* measurement-type set here.
*/
const CORRELATION_CHANNEL_TYPES: readonly MeasurementType[] = Array.from(
new Set<string>([...DISCOVERY_BEHAVIOURS, ...DISCOVERY_OUTCOMES]),
).filter((key) => key !== "MOOD") as MeasurementType[];
/**
* Shared cache-read for the seven `*-status.ts` insight generators.
*
@@ -230,6 +249,20 @@ export async function computeStatusInputFingerprint(args: {
* moved. Omit for metrics that don't read mood.
*/
includeMood?: boolean;
/**
* v1.18.11 (P6-tighten) — include the FDR correlation-discovery channels
* (`CORRELATION_CHANNEL_TYPES`) in the fingerprint. A card that folds the
* surviving cross-metric correlations (via `getRelevantCorrelationsForMetric`)
* can surface a NEW relation purely because a discovery channel — steps,
* sleep, HRV, glucose, daylight, … — gained paired data, with NO change to
* the card's own metric rows. Without this the input gate would re-stamp the
* stale assessment and the freshly discovered correlation would never reach
* the prose. Cheap: it widens the SAME grouped query by the channel type set
* (no extra round-trip). `includeMood` is honoured for the mood arm of the
* discovery matrix as before. Set on any card whose prompt carries a
* relations block (i.e. whose metric is a discovery channel).
*/
includeCorrelationChannels?: 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
@@ -237,12 +270,20 @@ export async function computeStatusInputFingerprint(args: {
*/
extra?: Record<string, string | number | null>;
}): Promise<string> {
// Widen the grouped query by the correlation-discovery channels when the
// card folds a relations block, so a discovery-channel change flips the gate.
// De-duplicate the union (a card's own type can also be a discovery channel)
// so the `type IN (...)` list carries each type once.
const groupTypes = args.includeCorrelationChannels
? Array.from(new Set<string>([...args.types, ...CORRELATION_CHANNEL_TYPES]))
: [...args.types];
const [grouped, mood] = await Promise.all([
prisma.measurement.groupBy({
by: ["type"],
where: {
userId: args.userId,
type: { in: [...args.types] },
type: { in: groupTypes as MeasurementType[] },
deletedAt: null,
},
_count: { _all: true },
+6
View File
@@ -226,6 +226,12 @@ export async function prepareWeightStatusForUser(
userId,
types: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
includeMood: true,
// v1.18.11 (P6-tighten) — WEIGHT is an FDR discovery OUTCOME channel and
// the prompt folds `getRelevantCorrelationsForMetric(userId, "WEIGHT")`, so
// a relation surfacing off a behaviour channel (e.g. more daylight →
// next-day weight) must flip the gate even when no weight row moved. Fold
// the discovery channels + the prior-analysis anchor into the fingerprint.
includeCorrelationChannels: true,
});
if (!force) {
const unchangedInput = await gateUnchangedStatusInput({