diff --git a/prisma/migrations/0392_correlation_seasonal_engine_since/migration.sql b/prisma/migrations/0392_correlation_seasonal_engine_since/migration.sql new file mode 100644 index 000000000..4a9b66e3c --- /dev/null +++ b/prisma/migrations/0392_correlation_seasonal_engine_since/migration.sql @@ -0,0 +1,4 @@ +-- When the instance first stored a discovery finding under the seasonally +-- adjusted correlation engine. Stamped once by the engine itself; null until +-- its first run, which counts every existing pattern as an earlier finding. +ALTER TABLE "app_settings" ADD COLUMN "correlation_seasonal_engine_since" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 911083311..49e96f28f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5171,6 +5171,14 @@ model AppSettings { /// step names who confirmed when the account still exists. encryptionKeyBackupConfirmedByUserId String? @map("encryption_key_backup_confirmed_by_user_id") + /// When this instance first stored a discovery finding under the seasonally + /// adjusted correlation engine (v1.42). Stamped once, by the first + /// `syncAcceptedPatterns` run of a discovery family, before it writes; null + /// until then. Consumer: `hadFindingsBeforeSeasonalAdjustment`, which counts + /// a discovery pattern created before it (or any, while it is null) as a + /// finding of the earlier engine and so earns the one-time note. + correlationSeasonalEngineSince DateTime? @map("correlation_seasonal_engine_since") + @@map("app_settings") } diff --git a/src/lib/export/profile-backup.ts b/src/lib/export/profile-backup.ts index 614a4d4a3..282d01a70 100644 --- a/src/lib/export/profile-backup.ts +++ b/src/lib/export/profile-backup.ts @@ -506,10 +506,15 @@ export async function buildProfileBackupSection( ...(disasterRecovery ? { id: pattern.id, - createdAt: pattern.createdAt.toISOString(), updatedAt: pattern.updatedAt.toISOString(), } : {}), + // Carried in every export, not only DR: when a finding was first made + // is part of what it means. The correlation surface reads it to tell a + // finding of the earlier engine from one of the seasonally adjusted + // engine, and a restore that reset it to the restore day would drop + // that note for good. + createdAt: pattern.createdAt.toISOString(), canonicalKey: pattern.canonicalKey, family: pattern.family, factorKey: pattern.factorKey, @@ -945,9 +950,11 @@ export async function restoreProfileData( dismissedEvidenceHash: pattern.dismissedEvidenceHash, dismissedEffectSize: pattern.dismissedEffectSize, dismissedSampleSize: pattern.dismissedSampleSize, - ...(pattern.createdAt - ? { createdAt: new Date(pattern.createdAt) } - : {}), + // A file from before portable exports carried `createdAt` falls back + // to the last computation, the latest instant the finding certainly + // existed: still earlier than this restore, which is what keeps a + // finding of the earlier engine counted as one. + createdAt: new Date(pattern.createdAt ?? pattern.lastComputedAt), ...(pattern.updatedAt ? { updatedAt: new Date(pattern.updatedAt) } : {}), diff --git a/src/lib/insights/correlation-patterns.ts b/src/lib/insights/correlation-patterns.ts index 66625b36f..7d72fe4bb 100644 --- a/src/lib/insights/correlation-patterns.ts +++ b/src/lib/insights/correlation-patterns.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; export const PATTERN_FAMILIES = { discoveryRetrospective: "DISCOVERY_RETROSPECTIVE", @@ -140,6 +141,12 @@ export async function syncAcceptedPatterns(args: { })); const keys = prepared.map((item) => item.canonicalKey); + // The earlier-engine cutoff has to exist before the new engine writes its + // first discovery pattern, or that pattern would count as an earlier one. + if (DISCOVERY_FAMILIES.includes(args.family)) { + await stampSeasonalEngineStart(); + } + return prisma.$transaction(async (tx) => { const existing = await tx.correlationPattern.findMany({ where: { userId: args.userId, canonicalKey: { in: keys } }, @@ -252,33 +259,78 @@ export function decisionForEvidence( } /** - * The day the discovery engine started correlating seasonally adjusted - * residuals with an effective-sample-size p-value (v1.42). Before it, two - * series that merely shared the season or a long trend surfaced as a - * finding, so an account with history usually sees fewer pairs afterwards. + * The discovery engine correlates seasonally adjusted residuals with an + * effective-sample-size p-value since v1.42. Before it, two series that + * merely shared the season or a long trend surfaced as a finding, so an + * account with history usually sees fewer pairs afterwards. + * + * Where that change took effect is an instance fact, not a calendar date: an + * instance keeps writing earlier-engine findings until it runs the new build. + * The first discovery sync of the new engine stamps + * `AppSettings.correlationSeasonalEngineSince`, before it writes anything, and + * the cutoff below reads that stamp. Stamped once per instance; the process + * remembers it so later syncs skip the write. */ -export const SEASONAL_ADJUSTMENT_SINCE = new Date("2026-10-08T00:00:00.000Z"); +const DISCOVERY_FAMILIES: readonly PatternFamily[] = [ + PATTERN_FAMILIES.discoveryRetrospective, + PATTERN_FAMILIES.discoveryRecent, +]; + +let seasonalEngineStamped = false; + +/** + * Record that the seasonally adjusted engine is running here, unless already + * recorded. Best effort: a failed stamp leaves the marker unset, which counts + * every pattern as an earlier finding (the note shows once more than it + * should), never the reverse. + */ +export async function stampSeasonalEngineStart( + now: Date = new Date(), +): Promise { + if (seasonalEngineStamped) return; + try { + await prisma.appSettings.upsert({ + where: { id: "singleton" }, + create: { id: "singleton", correlationSeasonalEngineSince: now }, + update: {}, + }); + await prisma.appSettings.updateMany({ + where: { id: "singleton", correlationSeasonalEngineSince: null }, + data: { correlationSeasonalEngineSince: now }, + }); + seasonalEngineStamped = true; + } catch { + getEvent()?.addWarning("correlation seasonal engine stamp failed"); + } +} + +/** Test seam: forget that this process already stamped the marker. */ +export function resetSeasonalEngineStampForTests(): void { + seasonalEngineStamped = false; +} /** * Whether the record held a discovery finding under the earlier engine: a - * stored discovery pattern first written before {@link SEASONAL_ADJUSTMENT_SINCE}. - * A dismissed pattern counts, it was a finding. An account created after the - * change can never qualify, so the one-time explanation on the correlation - * surface reaches only the people whose list actually changed. + * stored discovery pattern created before the instance's seasonal-engine + * stamp, or any stored discovery pattern while there is no stamp yet. A + * dismissed pattern counts, it was a finding. An account whose first finding + * came from the new engine never qualifies, so the one-time explanation on + * the correlation surface reaches only the people whose list actually + * changed. */ export async function hadFindingsBeforeSeasonalAdjustment( userId: string, ): Promise { + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { correlationSeasonalEngineSince: true }, + }); + const since = settings?.correlationSeasonalEngineSince ?? null; const row = await prisma.correlationPattern.findFirst({ where: { userId, - family: { - in: [ - PATTERN_FAMILIES.discoveryRetrospective, - PATTERN_FAMILIES.discoveryRecent, - ], - }, - createdAt: { lt: SEASONAL_ADJUSTMENT_SINCE }, + family: { in: [...DISCOVERY_FAMILIES] }, + ...(since ? { createdAt: { lt: since } } : {}), }, select: { id: true }, }); diff --git a/tests/integration/correlation-findings-before-seasonal-adjustment.test.ts b/tests/integration/correlation-findings-before-seasonal-adjustment.test.ts index 7534347f2..91af5b9ee 100644 --- a/tests/integration/correlation-findings-before-seasonal-adjustment.test.ts +++ b/tests/integration/correlation-findings-before-seasonal-adjustment.test.ts @@ -3,25 +3,33 @@ * * The flag decides who sees the one-time note on the correlation surface, so * it must hold only for a record that kept a discovery pattern from before the - * engine change: not for a pattern written since, not for another account's - * old pattern, and not for a non-discovery family. + * engine change. The change is located by the instance's own marker + * (`AppSettings.correlationSeasonalEngineSince`), which the first discovery + * sync of the new engine stamps before it writes, not by a calendar date. */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; import { + canonicalPatternKey, hadFindingsBeforeSeasonalAdjustment, PATTERN_FAMILIES, - SEASONAL_ADJUSTMENT_SINCE, + resetSeasonalEngineStampForTests, + syncAcceptedPatterns, } from "@/lib/insights/correlation-patterns"; +import { + buildProfileBackupSection, + restoreProfileData, +} from "@/lib/export/profile-backup"; vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); const DAY_MS = 24 * 60 * 60 * 1000; -const BEFORE = new Date(SEASONAL_ADJUSTMENT_SINCE.getTime() - 30 * DAY_MS); -const AFTER = new Date(SEASONAL_ADJUSTMENT_SINCE.getTime() + DAY_MS); +const MARKER = new Date("2026-10-08T00:00:00.000Z"); +const BEFORE = new Date(MARKER.getTime() - 30 * DAY_MS); +const AFTER = new Date(MARKER.getTime() + DAY_MS); let seq = 0; async function seedUser() { @@ -40,40 +48,60 @@ async function seedPattern(userId: string, family: string, createdAt: Date) { await getPrismaClient().correlationPattern.create({ data: { userId, - canonicalKey: `p1:test-${seq}`, + canonicalKey: canonicalPatternKey(`FACTOR_${seq}`, "MOOD", 1), family, - factorKey: "ACTIVITY_STEPS", + factorKey: `FACTOR_${seq}`, outcomeKey: "MOOD", lagDays: 1, sampleSize: 60, effectSize: 0.4, pValue: 0.001, qValue: 0.01, - evidenceHash: `hash-${seq}`, + evidenceHash: "a".repeat(64), lastComputedAt: createdAt, createdAt, }, }); } +async function setMarker(at: Date | null) { + await getPrismaClient().appSettings.upsert({ + where: { id: "singleton" }, + create: { id: "singleton", correlationSeasonalEngineSince: at }, + update: { correlationSeasonalEngineSince: at }, + }); +} + beforeEach(async () => { await truncateAllTables(getPrismaClient()); + resetSeasonalEngineStampForTests(); }); describe("hadFindingsBeforeSeasonalAdjustment", () => { - it("holds for a record with a discovery pattern stored before the change", async () => { + it("counts every existing discovery pattern while the instance has no marker", async () => { + const user = await seedUser(); + // Written by the earlier engine right up to the deploy: no fixed date + // may exclude it. + await seedPattern(user.id, PATTERN_FAMILIES.discoveryRetrospective, AFTER); + expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(true); + }); + + it("holds for a record with a discovery pattern stored before the marker", async () => { + await setMarker(MARKER); const user = await seedUser(); await seedPattern(user.id, PATTERN_FAMILIES.discoveryRetrospective, BEFORE); expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(true); }); - it("holds for an early-detection pattern stored before the change", async () => { + it("holds for an early-detection pattern stored before the marker", async () => { + await setMarker(MARKER); const user = await seedUser(); await seedPattern(user.id, PATTERN_FAMILIES.discoveryRecent, BEFORE); expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(true); }); - it("does not hold for a pattern first stored after the change", async () => { + it("does not hold for a pattern first stored after the marker", async () => { + await setMarker(MARKER); const user = await seedUser(); await seedPattern(user.id, PATTERN_FAMILIES.discoveryRetrospective, AFTER); expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(false); @@ -96,3 +124,123 @@ describe("hadFindingsBeforeSeasonalAdjustment", () => { expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(false); }); }); + +describe("the first discovery sync of the new engine", () => { + const evidence = { + factorKey: "ACTIVITY_STEPS", + outcomeKey: "SLEEP_DURATION", + lagDays: 0, + sampleSize: 90, + effectSize: 0.35, + pValue: 0.002, + qValue: 0.02, + }; + + it("stamps the marker before it writes, once", async () => { + const earlier = await seedUser(); + await seedPattern( + earlier.id, + PATTERN_FAMILIES.discoveryRetrospective, + BEFORE, + ); + const fresh = await seedUser(); + + await syncAcceptedPatterns({ + userId: fresh.id, + family: PATTERN_FAMILIES.discoveryRetrospective, + accepted: [evidence], + }); + + const settings = await getPrismaClient().appSettings.findUnique({ + where: { id: "singleton" }, + }); + const stamp = settings?.correlationSeasonalEngineSince; + expect(stamp).toBeInstanceOf(Date); + // The new engine's own finding is not an earlier one; the old one is. + expect(await hadFindingsBeforeSeasonalAdjustment(fresh.id)).toBe(false); + expect(await hadFindingsBeforeSeasonalAdjustment(earlier.id)).toBe(true); + + // A later run (another process) leaves the first stamp standing. + resetSeasonalEngineStampForTests(); + await syncAcceptedPatterns({ + userId: fresh.id, + family: PATTERN_FAMILIES.discoveryRecent, + accepted: [evidence], + }); + const again = await getPrismaClient().appSettings.findUnique({ + where: { id: "singleton" }, + }); + expect(again?.correlationSeasonalEngineSince?.toISOString()).toBe( + stamp?.toISOString(), + ); + }); + + it("is not triggered by a non-discovery family", async () => { + const user = await seedUser(); + await syncAcceptedPatterns({ + userId: user.id, + family: PATTERN_FAMILIES.fixed, + accepted: [evidence], + }); + const settings = await getPrismaClient().appSettings.findUnique({ + where: { id: "singleton" }, + }); + expect(settings?.correlationSeasonalEngineSince ?? null).toBeNull(); + }); +}); + +describe("a restore", () => { + it("keeps when a finding was first made, so the note survives it", async () => { + await setMarker(MARKER); + const user = await seedUser(); + await seedPattern(user.id, PATTERN_FAMILIES.discoveryRetrospective, BEFORE); + const prisma = getPrismaClient(); + + // The portable export is the one that used to drop `createdAt`. + const section = await buildProfileBackupSection(prisma, user.id, { + purpose: "portable-export", + }); + expect(section.correlationPatterns[0]?.createdAt).toBe( + BEFORE.toISOString(), + ); + + await prisma.$transaction((tx) => + restoreProfileData(tx, user.id, { + healthProfile: section.healthProfile, + healthProfileFacts: section.healthProfileFacts, + customMetrics: section.customMetrics, + correlationPatterns: section.correlationPatterns, + }), + ); + + const restored = await prisma.correlationPattern.findFirstOrThrow({ + where: { userId: user.id }, + }); + expect(restored.createdAt.toISOString()).toBe(BEFORE.toISOString()); + expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(true); + }); + + it("falls back to the last computation for a file without createdAt", async () => { + await setMarker(MARKER); + const user = await seedUser(); + await seedPattern(user.id, PATTERN_FAMILIES.discoveryRetrospective, BEFORE); + const prisma = getPrismaClient(); + const section = await buildProfileBackupSection(prisma, user.id); + const legacy = section.correlationPatterns.map((pattern) => { + const copy = { ...pattern }; + delete copy.createdAt; + return copy; + }); + + await prisma.$transaction((tx) => + restoreProfileData(tx, user.id, { + healthProfile: section.healthProfile, + healthProfileFacts: section.healthProfileFacts, + customMetrics: section.customMetrics, + correlationPatterns: legacy, + }), + ); + + expect(await hadFindingsBeforeSeasonalAdjustment(user.id)).toBe(true); + }); +});