diff --git a/src/lib/insights/__tests__/correlation-patterns.test.ts b/src/lib/insights/__tests__/correlation-patterns.test.ts index 8308a9607..0d0c902c3 100644 --- a/src/lib/insights/__tests__/correlation-patterns.test.ts +++ b/src/lib/insights/__tests__/correlation-patterns.test.ts @@ -109,6 +109,7 @@ import { canonicalPatternKey, isMaterialEvidenceChange, PATTERN_FAMILIES, + shouldReleaseDismissal, syncAcceptedPatterns, } from "@/lib/insights/correlation-patterns"; @@ -201,3 +202,137 @@ describe("correlation pattern identity and dismissal", () => { ).toBe(true); }); }); + +/** + * `dismissedEvidenceHash` was written on every dismissal and read by nothing: + * the gate compared effect size and sample size only. These tests pin it as a + * reader, in both directions. + */ +describe("dismissal release against the stored evidence hash", () => { + const DISMISSED_AT = new Date("2026-07-02T00:00:00.000Z"); + + it("holds a dismissal whose evidence has not moved at all", () => { + expect( + shouldReleaseDismissal({ + dismissedAt: DISMISSED_AT, + dismissedEvidenceHash: "a".repeat(64), + dismissedEffectSize: 0.3, + dismissedSampleSize: 40, + currentEvidenceHash: "a".repeat(64), + currentEffectSize: 0.3, + currentSampleSize: 40, + }), + ).toBe(false); + }); + + it("releases a dismissal whose evidence moved materially", () => { + expect( + shouldReleaseDismissal({ + dismissedAt: DISMISSED_AT, + dismissedEvidenceHash: "a".repeat(64), + dismissedEffectSize: 0.3, + dismissedSampleSize: 40, + currentEvidenceHash: "b".repeat(64), + currentEffectSize: -0.2, + currentSampleSize: 40, + }), + ).toBe(true); + }); + + it("holds a dismissal whose evidence moved only within the noise band", () => { + expect( + shouldReleaseDismissal({ + dismissedAt: DISMISSED_AT, + dismissedEvidenceHash: "a".repeat(64), + dismissedEffectSize: 0.3, + dismissedSampleSize: 40, + currentEvidenceHash: "b".repeat(64), + currentEffectSize: 0.35, + currentSampleSize: 45, + }), + ).toBe(false); + }); + + it("releases a snapshot that has only a hash once that hash no longer matches", () => { + expect( + shouldReleaseDismissal({ + dismissedAt: DISMISSED_AT, + dismissedEvidenceHash: "a".repeat(64), + dismissedEffectSize: null, + dismissedSampleSize: null, + currentEvidenceHash: "b".repeat(64), + currentEffectSize: 0.3, + currentSampleSize: 40, + }), + ).toBe(true); + }); + + it("holds a snapshot that has only a hash while that hash still matches", () => { + expect( + shouldReleaseDismissal({ + dismissedAt: DISMISSED_AT, + dismissedEvidenceHash: "a".repeat(64), + dismissedEffectSize: null, + dismissedSampleSize: null, + currentEvidenceHash: "a".repeat(64), + currentEffectSize: 0.3, + currentSampleSize: 40, + }), + ).toBe(false); + }); + + it("holds a pattern that was never dismissed", () => { + expect( + shouldReleaseDismissal({ + dismissedAt: null, + dismissedEvidenceHash: null, + dismissedEffectSize: null, + dismissedSampleSize: null, + currentEvidenceHash: "b".repeat(64), + currentEffectSize: 0.9, + currentSampleSize: 400, + }), + ).toBe(false); + }); +}); + +describe("syncAcceptedPatterns honours the stored hash", () => { + beforeEach(() => { + rows.clear(); + vi.clearAllMocks(); + }); + + it("resurfaces a hash-only dismissal on the next run and leaves an unchanged one dismissed", async () => { + const first = await syncAcceptedPatterns({ + userId: "owner-2", + family: PATTERN_FAMILIES.moodTagCrosstab, + accepted: [baseEvidence], + computedAt: new Date("2026-07-01T00:00:00.000Z"), + }); + const canonicalKey = [...first.values()][0].canonicalKey; + const row = [...rows.values()][0]; + + // A dismissal snapshot carrying the hash and nothing else — the shape a + // partial restore leaves behind. It used to be frozen dismissed forever. + row.dismissedAt = new Date("2026-07-02T00:00:00.000Z"); + row.dismissedEvidenceHash = row.evidenceHash; + row.dismissedEffectSize = null; + row.dismissedSampleSize = null; + + const unchanged = await syncAcceptedPatterns({ + userId: "owner-2", + family: PATTERN_FAMILIES.moodTagCrosstab, + accepted: [baseEvidence], + }); + expect(unchanged.get(canonicalKey)?.dismissed).toBe(true); + + const moved = await syncAcceptedPatterns({ + userId: "owner-2", + family: PATTERN_FAMILIES.moodTagCrosstab, + accepted: [{ ...baseEvidence, pValue: 0.002 }], + }); + expect(moved.get(canonicalKey)?.dismissed).toBe(false); + expect(row.dismissedAt).toBeNull(); + expect(row.dismissedEvidenceHash).toBeNull(); + }); +}); diff --git a/src/lib/insights/correlation-patterns.ts b/src/lib/insights/correlation-patterns.ts index 98d22bf65..07c79f698 100644 --- a/src/lib/insights/correlation-patterns.ts +++ b/src/lib/insights/correlation-patterns.ts @@ -73,6 +73,51 @@ export function isMaterialEvidenceChange(args: { return signChanged || effectMoved || sampleDeepened; } +/** + * Whether this run releases a dismissal. + * + * Two things have to hold. The evidence must have MOVED since the dismissal, + * which is what `dismissedEvidenceHash` records — the only part of the + * snapshot that covers the whole evidence tuple rather than two of its + * numbers — and the move must be material by the thresholds above, so an + * ordinary sampling wobble does not undo a dismissal the user made on + * purpose. + * + * The hash also answers the case the two scalars cannot. A snapshot that + * carries a hash but no effect size and no sample size — a restore from a + * partial backup — used to be frozen dismissed forever, because the gate had + * no number to compare and fell through to `false` on every future run. A + * hash that no longer matches is still proof the evidence moved, so it + * releases the dismissal rather than holding it against a snapshot nobody + * can evaluate. + */ +export function shouldReleaseDismissal(args: { + dismissedAt: Date | null; + dismissedEvidenceHash: string | null; + dismissedEffectSize: number | null; + dismissedSampleSize: number | null; + currentEvidenceHash: string; + currentEffectSize: number; + currentSampleSize: number; +}): boolean { + if (args.dismissedAt == null) return false; + + // Byte-identical to what was dismissed: nothing has changed, so there is + // nothing to reconsider and no threshold worth evaluating. + if (args.dismissedEvidenceHash === args.currentEvidenceHash) return false; + + if (args.dismissedEffectSize != null && args.dismissedSampleSize != null) { + return isMaterialEvidenceChange({ + currentEffectSize: args.currentEffectSize, + currentSampleSize: args.currentSampleSize, + dismissedEffectSize: args.dismissedEffectSize, + dismissedSampleSize: args.dismissedSampleSize, + }); + } + + return args.dismissedEvidenceHash != null; +} + /** * Persist one accepted family run, retire findings no longer accepted, and * return the server's presentation decision for every accepted finding. @@ -123,16 +168,15 @@ export async function syncAcceptedPatterns(args: { const decisions = new Map(); for (const item of prepared) { const prior = existingByKey.get(item.canonicalKey); - const shouldResurface = - prior?.dismissedAt != null && - prior.dismissedEffectSize != null && - prior.dismissedSampleSize != null && - isMaterialEvidenceChange({ - currentEffectSize: item.evidence.effectSize, - currentSampleSize: item.evidence.sampleSize, - dismissedEffectSize: prior.dismissedEffectSize, - dismissedSampleSize: prior.dismissedSampleSize, - }); + const shouldResurface = shouldReleaseDismissal({ + dismissedAt: prior?.dismissedAt ?? null, + dismissedEvidenceHash: prior?.dismissedEvidenceHash ?? null, + dismissedEffectSize: prior?.dismissedEffectSize ?? null, + dismissedSampleSize: prior?.dismissedSampleSize ?? null, + currentEvidenceHash: item.evidenceHash, + currentEffectSize: item.evidence.effectSize, + currentSampleSize: item.evidence.sampleSize, + }); const row = await tx.correlationPattern.upsert({ where: {