Files
Jyotisha/frontend/tests/birth-time-scoring-memory-store.ts
T
2026-07-18 18:40:23 +08:00

204 lines
7.4 KiB
TypeScript

import {
BirthTimeScoringJobError,
scoringJobDurationMs,
scoringProcessingLeaseMs,
} from "../src/lib/birth-time-scoring-job.ts";
import type { CandidateResult } from "../src/lib/birth-time-evidence.ts";
import type {
BirthTimeJourneyStore,
StoredRectificationCase,
} from "../src/lib/birth-time-journey-service.ts";
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
type MemoryJob = {
readonly id: string;
readonly caseId: string;
readonly userId: string;
readonly evidenceFingerprint: string;
readonly algorithmVersion: string;
readonly expiresAt: string;
readonly updatedAt: string;
readonly status: "pending" | "processing" | "completed" | "failed";
readonly result: CandidateResult | null;
};
type MemoryContext = {
readonly read: () => StoredRectificationCase | null;
readonly write: (value: StoredRectificationCase) => void;
readonly committed: () => void;
};
type ScoringMethods = Pick<BirthTimeJourneyStore,
| "createScoringJob"
| "claimScoringJob"
| "completeScoringJob"
| "failScoringJob"
>;
class MissingMemoryCaseError extends Error {
readonly name = "MissingMemoryCaseError";
}
function currentCase(context: MemoryContext): StoredRectificationCase {
const current = context.read();
if (!current) throw new MissingMemoryCaseError();
return current;
}
export function createMemoryScoringJobs(context: MemoryContext) {
const jobs = new Map<string, MemoryJob>();
let createdCase: StoredRectificationCase | null = null;
const methods: ScoringMethods = {
async createScoringJob(value, expectedVersion, actionId, spec) {
const current = currentCase(context);
if (current.processedActionIds?.includes(actionId.toLowerCase())) return current;
if (current.turnVersion !== expectedVersion) {
throw new StaleJourneyTurnError(value.id, expectedVersion, current.turnVersion ?? 0);
}
if ([...jobs.values()].some((job) => job.caseId === value.id
&& job.evidenceFingerprint === spec.evidenceFingerprint
&& job.algorithmVersion === spec.algorithmVersion)) {
throw new BirthTimeScoringJobError("unavailable");
}
const saved = {
...value,
turnVersion: expectedVersion + 1,
processedActionIds: [...(current.processedActionIds ?? []), actionId],
} satisfies StoredRectificationCase;
jobs.set(spec.jobId, {
id: spec.jobId,
caseId: value.id,
userId: value.userId,
evidenceFingerprint: spec.evidenceFingerprint,
algorithmVersion: spec.algorithmVersion,
expiresAt: spec.expiresAt,
updatedAt: new Date(
new Date(spec.expiresAt).getTime() - scoringJobDurationMs,
).toISOString(),
status: "pending",
result: null,
});
context.write(saved);
context.committed();
createdCase = saved;
return saved;
},
async claimScoringJob(identity) {
const job = jobs.get(identity.jobId);
if (!job || job.userId !== identity.userId || job.caseId !== identity.caseId) {
throw new BirthTimeScoringJobError("unavailable");
}
if (job.evidenceFingerprint !== identity.evidenceFingerprint) {
throw new BirthTimeScoringJobError("unavailable");
}
if (job.algorithmVersion !== identity.algorithmVersion) {
throw new BirthTimeScoringJobError("algorithm_mismatch");
}
const current = currentCase(context);
if (job.status === "completed") {
if (!completedStateMatches(current, job)) {
throw new BirthTimeScoringJobError("algorithm_mismatch");
}
return { kind: "completed", algorithmVersion: job.algorithmVersion };
}
const action = current.turnState?.nextAction;
if ((action?.kind !== "score_pending" && action?.kind !== "retry_scoring")
|| action.jobId !== job.id) {
throw new BirthTimeScoringJobError("invalid_turn");
}
if (job.status === "processing"
&& new Date(job.updatedAt).getTime()
> new Date(identity.now).getTime() - scoringProcessingLeaseMs) {
return { kind: "processing", algorithmVersion: job.algorithmVersion };
}
jobs.set(job.id, {
...job,
status: "processing",
updatedAt: identity.now,
expiresAt: new Date(
new Date(identity.now).getTime() + scoringJobDurationMs,
).toISOString(),
});
return { kind: "claimed", algorithmVersion: job.algorithmVersion };
},
async completeScoringJob(value, expectedVersion, jobId, fingerprint) {
const current = currentCase(context);
const job = jobs.get(jobId);
if (!job || job.status !== "processing" || job.evidenceFingerprint !== fingerprint) {
throw new BirthTimeScoringJobError("unavailable");
}
if (current.turnVersion !== expectedVersion) {
throw new StaleJourneyTurnError(value.id, expectedVersion, current.turnVersion ?? 0);
}
const saved = { ...value, turnVersion: expectedVersion + 1 } satisfies StoredRectificationCase;
jobs.set(job.id, {
...job,
status: "completed",
result: value.candidateResult ?? null,
});
context.write(saved);
return saved;
},
async failScoringJob(value, expectedVersion, jobId, fingerprint) {
const current = currentCase(context);
const job = jobs.get(jobId);
if (!job || job.status !== "processing" || job.evidenceFingerprint !== fingerprint) {
throw new BirthTimeScoringJobError("unavailable");
}
if (current.turnVersion !== expectedVersion) {
throw new StaleJourneyTurnError(value.id, expectedVersion, current.turnVersion ?? 0);
}
const saved = { ...value, turnVersion: expectedVersion + 1 } satisfies StoredRectificationCase;
jobs.set(job.id, { ...job, status: "failed" });
context.write(saved);
return saved;
},
};
return {
methods,
status: (jobId: string) => jobs.get(jobId)?.status ?? null,
count: () => jobs.size,
setAlgorithm: (jobId: string, algorithmVersion: string) => {
const job = jobs.get(jobId);
if (!job) throw new BirthTimeScoringJobError("unavailable");
jobs.set(jobId, { ...job, algorithmVersion });
},
createdCase: () => {
if (!createdCase) throw new MissingMemoryCaseError();
return createdCase;
},
};
}
function completedStateMatches(
stored: StoredRectificationCase,
job: MemoryJob,
): boolean {
const result = stored.candidateResult ?? null;
if (!job.result || !result || JSON.stringify(job.result) !== JSON.stringify(result)) return false;
const turn = stored.turnState;
if (!turn || result.algorithmVersion !== job.algorithmVersion
|| turn.turnVersion !== stored.turnVersion) return false;
const action = turn.nextAction;
switch (result.confidence) {
case "low":
return action.kind === "ask_adaptive_evidence"
|| action.kind === "review_evidence_draft"
|| action.kind === "paused"
|| (action.kind === "present_low_result" && action.resultId === result.resultId);
case "medium":
return (action.kind === "present_medium_result" || action.kind === "candidate_saved")
&& action.resultId === result.resultId;
case "high":
return (action.kind === "request_candidate_confirmation" && action.resultId === result.resultId)
|| action.kind === "ready";
default: {
const exhaustive: never = result.confidence;
return exhaustive;
}
}
}