mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(insights): read the dismissed evidence hash in the resurface gate
Dismissing a correlation pattern stores `dismissedEvidenceHash` alongside the effect size and sample size, and the gate that decides whether to raise the finding again read the two numbers and never the hash. The hash is the only part of the snapshot that covers the whole evidence tuple, and a snapshot that carries it without the two numbers — what a partial restore leaves behind — could not be released by anything: the gate had no value to compare and fell through to `false` on every future run. Move the decision into `shouldReleaseDismissal`, which reads the hash twice and for two different reasons. Evidence byte-identical to what was dismissed settles immediately, without evaluating a threshold. Evidence whose hash no longer matches goes to the existing thresholds when the numbers are there, and releases the dismissal when they are not, because a changed hash is still proof the evidence moved. The thresholds themselves are untouched. A dismissal still has to survive ordinary sampling noise, which is what they are for; a hash that changes on every recomputation would otherwise undo a deliberate dismissal on the next nightly run.
This commit is contained in:
1 parent
4dbad48d89
commit
f39f2f32ce
2 files changed
+189
-10
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, PatternDecision>();
|
||||
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: {
|
||||
|
||||
Reference in new issue
Block a user