mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(correlations): date the earlier-engine note from the instance's first new run
The note for records whose findings predate the seasonally adjusted engine compared against a fixed calendar date, so findings an instance kept writing under the earlier engine until its deploy did not count. The first discovery sync of the new engine now stamps AppSettings.correlationSeasonalEngineSince before it writes; without a stamp every stored discovery pattern counts as an earlier finding. A restore from a portable export reset each pattern's createdAt to the restore day and dropped the note for good. createdAt now rides every export, and a file without it falls back to lastComputedAt.
This commit is contained in:
1 parent
587ea35d34
commit
0ea8d929d2
5 files changed
+250
-31
No files matched your search
@@ -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);
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
: {}),
|
||||
|
||||
@@ -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<void> {
|
||||
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<boolean> {
|
||||
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 },
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user