mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
209 lines
6.3 KiB
TypeScript
209 lines
6.3 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
import { prisma } from "@/lib/db";
|
|
|
|
export const PATTERN_FAMILIES = {
|
|
discoveryRetrospective: "DISCOVERY_RETROSPECTIVE",
|
|
discoveryRecent: "DISCOVERY_RECENT",
|
|
fixed: "FIXED",
|
|
moodTagCrosstab: "MOOD_TAG_CROSSTAB",
|
|
moodFactorCrosstab: "MOOD_FACTOR_CROSSTAB",
|
|
} as const;
|
|
|
|
export type PatternFamily =
|
|
(typeof PATTERN_FAMILIES)[keyof typeof PATTERN_FAMILIES];
|
|
|
|
export interface AcceptedPatternEvidence {
|
|
factorKey: string;
|
|
outcomeKey: string;
|
|
lagDays: number;
|
|
sampleSize: number;
|
|
effectSize: number;
|
|
pValue: number;
|
|
qValue?: number | null;
|
|
}
|
|
|
|
export interface PatternDecision {
|
|
patternId: string;
|
|
canonicalKey: string;
|
|
dismissed: boolean;
|
|
}
|
|
|
|
/** Stable identity derived only from the user-independent pair definition. */
|
|
export function canonicalPatternKey(
|
|
factorKey: string,
|
|
outcomeKey: string,
|
|
lagDays: number,
|
|
): string {
|
|
const source = JSON.stringify([factorKey, outcomeKey, lagDays]);
|
|
return `p1:${createHash("sha256").update(source).digest("hex")}`;
|
|
}
|
|
|
|
function evidenceHash(evidence: AcceptedPatternEvidence): string {
|
|
const source = JSON.stringify({
|
|
factorKey: evidence.factorKey,
|
|
outcomeKey: evidence.outcomeKey,
|
|
lagDays: evidence.lagDays,
|
|
sampleSize: evidence.sampleSize,
|
|
effectSize: evidence.effectSize,
|
|
pValue: evidence.pValue,
|
|
qValue: evidence.qValue ?? null,
|
|
});
|
|
return createHash("sha256").update(source).digest("hex");
|
|
}
|
|
|
|
/**
|
|
* A dismissal survives normal sampling noise. It is released only when the
|
|
* effect reverses direction, moves by at least 0.10, or the accepted sample
|
|
* grows by at least 25% (with a ten-observation floor).
|
|
*/
|
|
export function isMaterialEvidenceChange(args: {
|
|
currentEffectSize: number;
|
|
currentSampleSize: number;
|
|
dismissedEffectSize: number;
|
|
dismissedSampleSize: number;
|
|
}): boolean {
|
|
const signChanged =
|
|
Math.sign(args.currentEffectSize) !== Math.sign(args.dismissedEffectSize);
|
|
const effectMoved =
|
|
Math.abs(args.currentEffectSize - args.dismissedEffectSize) >= 0.1;
|
|
const sampleGrowth = Math.max(10, Math.ceil(args.dismissedSampleSize * 0.25));
|
|
const sampleDeepened =
|
|
args.currentSampleSize >= args.dismissedSampleSize + sampleGrowth;
|
|
return signChanged || effectMoved || sampleDeepened;
|
|
}
|
|
|
|
/**
|
|
* Persist one accepted family run, retire findings no longer accepted, and
|
|
* return the server's presentation decision for every accepted finding.
|
|
*/
|
|
export async function syncAcceptedPatterns(args: {
|
|
userId: string;
|
|
family: PatternFamily;
|
|
accepted: AcceptedPatternEvidence[];
|
|
computedAt?: Date;
|
|
}): Promise<Map<string, PatternDecision>> {
|
|
const computedAt = args.computedAt ?? new Date();
|
|
const prepared = args.accepted.map((evidence) => ({
|
|
evidence,
|
|
canonicalKey: canonicalPatternKey(
|
|
evidence.factorKey,
|
|
evidence.outcomeKey,
|
|
evidence.lagDays,
|
|
),
|
|
evidenceHash: evidenceHash(evidence),
|
|
}));
|
|
const keys = prepared.map((item) => item.canonicalKey);
|
|
|
|
return prisma.$transaction(async (tx) => {
|
|
const existing = await tx.correlationPattern.findMany({
|
|
where: { userId: args.userId, canonicalKey: { in: keys } },
|
|
});
|
|
const existingByKey = new Map(
|
|
existing.map((row) => [row.canonicalKey, row]),
|
|
);
|
|
|
|
if (keys.length === 0) {
|
|
await tx.correlationPattern.updateMany({
|
|
where: { userId: args.userId, family: args.family, isCurrent: true },
|
|
data: { isCurrent: false },
|
|
});
|
|
} else {
|
|
await tx.correlationPattern.updateMany({
|
|
where: {
|
|
userId: args.userId,
|
|
family: args.family,
|
|
isCurrent: true,
|
|
canonicalKey: { notIn: keys },
|
|
},
|
|
data: { isCurrent: false },
|
|
});
|
|
}
|
|
|
|
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 row = await tx.correlationPattern.upsert({
|
|
where: {
|
|
userId_canonicalKey: {
|
|
userId: args.userId,
|
|
canonicalKey: item.canonicalKey,
|
|
},
|
|
},
|
|
create: {
|
|
userId: args.userId,
|
|
canonicalKey: item.canonicalKey,
|
|
family: args.family,
|
|
factorKey: item.evidence.factorKey,
|
|
outcomeKey: item.evidence.outcomeKey,
|
|
lagDays: item.evidence.lagDays,
|
|
sampleSize: item.evidence.sampleSize,
|
|
effectSize: item.evidence.effectSize,
|
|
pValue: item.evidence.pValue,
|
|
qValue: item.evidence.qValue ?? null,
|
|
evidenceHash: item.evidenceHash,
|
|
isCurrent: true,
|
|
lastComputedAt: computedAt,
|
|
},
|
|
update: {
|
|
family: args.family,
|
|
factorKey: item.evidence.factorKey,
|
|
outcomeKey: item.evidence.outcomeKey,
|
|
lagDays: item.evidence.lagDays,
|
|
sampleSize: item.evidence.sampleSize,
|
|
effectSize: item.evidence.effectSize,
|
|
pValue: item.evidence.pValue,
|
|
qValue: item.evidence.qValue ?? null,
|
|
evidenceHash: item.evidenceHash,
|
|
isCurrent: true,
|
|
lastComputedAt: computedAt,
|
|
...(shouldResurface
|
|
? {
|
|
dismissedAt: null,
|
|
dismissedEvidenceHash: null,
|
|
dismissedEffectSize: null,
|
|
dismissedSampleSize: null,
|
|
}
|
|
: {}),
|
|
},
|
|
});
|
|
|
|
decisions.set(item.canonicalKey, {
|
|
patternId: row.id,
|
|
canonicalKey: row.canonicalKey,
|
|
dismissed: row.dismissedAt != null,
|
|
});
|
|
}
|
|
return decisions;
|
|
});
|
|
}
|
|
|
|
export function decisionForEvidence(
|
|
decisions: Map<string, PatternDecision>,
|
|
evidence: Pick<
|
|
AcceptedPatternEvidence,
|
|
"factorKey" | "outcomeKey" | "lagDays"
|
|
>,
|
|
): PatternDecision | null {
|
|
return (
|
|
decisions.get(
|
|
canonicalPatternKey(
|
|
evidence.factorKey,
|
|
evidence.outcomeKey,
|
|
evidence.lagDays,
|
|
),
|
|
) ?? null
|
|
);
|
|
}
|