diff --git a/src/lib/insights/__tests__/status-cache.test.ts b/src/lib/insights/__tests__/status-cache.test.ts index 9257c6dcc..4bdb32588 100644 --- a/src/lib/insights/__tests__/status-cache.test.ts +++ b/src/lib/insights/__tests__/status-cache.test.ts @@ -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([ { diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index 28d2021c8..62fe5e86e 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -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([...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; }): Promise { + // 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([...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 }, diff --git a/src/lib/insights/weight-status.ts b/src/lib/insights/weight-status.ts index 7a2c43499..8e734881a 100644 --- a/src/lib/insights/weight-status.ts +++ b/src/lib/insights/weight-status.ts @@ -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({