Files
Jyotisha/frontend/tests/rectification-v4-service.test.ts
T

542 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import test from "node:test";
import { composeRectificationPublicTurn } from "../src/lib/rectification-agent/orchestrator.ts";
import { createRectificationV4CaseService } from "../src/lib/rectification-v4/case-service.ts";
import type { CalculationSpec, CandidateSnapshot, LifeEventRevision, PendingEvidence } from "../src/lib/rectification-v4/contracts.ts";
import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts";
import { createRectificationV4Worker, resolvedPendingEvidence } from "../src/lib/rectification-v4/worker.ts";
function createTestCaseService(
store: Parameters<typeof createRectificationV4CaseService>[0],
options: Parameters<typeof createRectificationV4CaseService>[1] = {},
) {
return createRectificationV4CaseService(store, {
generateOpeningQuestion: async ({ candidateRange }) =>
`Agent 将在 ${candidateRange.start}${candidateRange.end} 的待核对范围内陪你梳理;这并不是已确认的出生分钟。你愿意先说一段自己记得比较清楚的人生经历吗?`,
...options,
});
}
const fixedNow = () => new Date("2026-07-28T12:00:00.000Z");
const spec: CalculationSpec = {
version: "rectification-calculation-spec-v4",
birthDate: "1997-08-08",
candidateRange: { start: "04:30", end: "05:30" },
latitude: 36.419,
longitude: 114.213,
timezoneOffsetHours: 8,
ayanamsa: "lahiri",
nodeMode: "mean",
minuteStep: 1,
};
async function withMode<T>(mode: "v4_legacy" | "v5_shadow" | "v5_agent", run: () => Promise<T>): Promise<T> {
const keys = ["RECTIFICATION_AGENT_V5_ENABLED", "RECTIFICATION_AGENT_V5_SHADOW", "RECTIFICATION_AGENT_V5_CANARY_PERCENT"] as const;
const before = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
process.env.RECTIFICATION_AGENT_V5_ENABLED = mode === "v4_legacy" ? "0" : "1";
process.env.RECTIFICATION_AGENT_V5_SHADOW = mode === "v5_shadow" ? "1" : "0";
process.env.RECTIFICATION_AGENT_V5_CANARY_PERCENT = "100";
try { return await run(); } finally {
for (const key of keys) {
if (before[key] === undefined) delete process.env[key];
else process.env[key] = before[key];
}
}
}
test("new cases use the Agent-generated opening and do not regenerate it when resuming", async () => withMode("v5_agent", async () => {
const store = createRectificationV4MemoryStore();
let calls = 0;
const service = createTestCaseService(store, {
now: fixedNow,
generateOpeningQuestion: async ({ candidateRange }) => {
calls += 1;
return `这是 Agent 为 ${candidateRange.start}${candidateRange.end} 生成的首轮引导。`;
},
});
const userId = randomUUID();
const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const resumed = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
assert.equal(first.case.currentQuestion?.prompt, "这是 Agent 为 04:3005:30 生成的首轮引导。");
assert.equal(first.case.agentMode, "deterministic_fallback");
assert.equal(resumed.case.id, first.case.id);
assert.equal(calls, 1);
}));
test("same calculation spec resumes while a changed spec abandons the old case and stales its job", async () => withMode("v4_legacy", async () => {
const store = createRectificationV4MemoryStore();
const service = createTestCaseService(store, { now: fixedNow });
const userId = randomUUID();
const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const resumed = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: { ...spec } });
assert.equal(resumed.case.id, first.case.id);
const queued = await service.answer({
userId, caseId: first.case.id, actionId: randomUUID(), expectedCaseVersion: 0, answer: "2016年9月上大学",
});
assert.ok(queued?.job);
const replacement = await service.createCase({
userId, actionId: randomUUID(), calculationSpec: { ...spec, candidateRange: { start: "04:45", end: "05:30" } },
});
assert.notEqual(replacement.case.id, first.case.id);
assert.equal(store.cases.get(first.case.id)?.status, "abandoned");
assert.equal(store.jobs.get(queued.job.id)?.status, "stale");
}));
test("new case replaces paused or orphaned processing state and can be answered immediately", async () => withMode("v5_agent", async () => {
const store = createRectificationV4MemoryStore();
const service = createTestCaseService(store, { now: fixedNow });
const userId = randomUUID();
const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
await service.transition({ userId, caseId: first.case.id, actionId: randomUUID(), expectedCaseVersion: 0, kind: "pause" });
const afterPause = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
assert.notEqual(afterPause.case.id, first.case.id);
assert.equal(store.cases.get(first.case.id)?.status, "abandoned");
assert.ok(afterPause.case.currentQuestion);
store.cases.set(afterPause.case.id, { ...afterPause.case, status: "processing", phase: "reasoning", currentQuestion: null });
const replacement = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
assert.notEqual(replacement.case.id, afterPause.case.id);
assert.equal(store.cases.get(afterPause.case.id)?.status, "abandoned");
assert.ok(replacement.case.currentQuestion);
const queued = await service.answer({
userId,
caseId: replacement.case.id,
actionId: randomUUID(),
expectedCaseVersion: replacement.case.version,
answer: "2016 年离家去外地上大学",
});
assert.ok(queued?.job);
}));
test("new scoring version replaces a resumable Case created by an older algorithm", async () => withMode("v5_agent", async () => {
const store = createRectificationV4MemoryStore();
const service = createTestCaseService(store, { now: fixedNow });
const userId = randomUUID();
const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
store.cases.set(first.case.id, { ...first.case, algorithmVersion: "rectification-v5-matrix-scoring-1" });
const second = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
assert.notEqual(second.case.id, first.case.id);
assert.equal(second.case.algorithmVersion, "rectification-v5-matrix-scoring-2");
assert.equal(store.cases.get(first.case.id)?.status, "abandoned");
}));
test("answer is durably queued and a processing case reload restores its active job", async () => withMode("v5_agent", async () => {
const store = createRectificationV4MemoryStore();
const service = createTestCaseService(store, { now: fixedNow });
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
assert.equal(created.case.protocol, "rectification-evidence-v5");
assert.equal(created.case.deploymentMode, "v5_agent");
const queued = await service.answer({
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0,
answer: "2015年7月高中毕业后复读一年,2016年6月再次毕业", modelId: "gpt-5.5",
});
assert.equal(queued?.case.status, "processing");
assert.equal(queued?.turns.at(-1)?.modelId, "gpt-5.5");
const queuedJob = queued?.job;
assert.ok(queuedJob);
const before = JSON.stringify([...store.jobs.values()]);
const restored = await service.loadCase(userId, created.case.id);
assert.equal(restored?.job?.id, queuedJob.id);
assert.equal(restored?.job?.status, "pending");
assert.equal(JSON.stringify([...store.jobs.values()]), before);
}));
test("V5 agent fallback persists the Director decision, Public Message and next question", async () => withMode("v5_agent", async () => {
const store = createRectificationV4MemoryStore();
const service = createTestCaseService(store, { now: fixedNow });
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const queued = await service.answer({
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0,
answer: "2016年9月离家去外地上大学", modelId: "gpt-5.5",
});
assert.ok(queued?.job);
const worker = createRectificationV4Worker({
store, now: fixedNow,
engine: { async score() { throw new Error("engine must not run before enough events"); } },
});
assert.equal(await worker.runOnce(), true);
const done = await service.loadCase(userId, created.case.id);
const event = done?.events.find((item) => item.summary === "离家去外地上大学");
const run = [...store.agentRuns.values()][0];
const message = store.publicMessages.get(queued.job.id);
assert.ok(event && run && message);
assert.equal(run.deploymentMode, "v5_agent");
assert.equal(run.modelId, "gpt-5.5");
assert.equal(run.validatedDecision.mode, "deterministic_fallback");
assert.equal(run.validatedDecision.decision.action, "ask_question");
assert.equal(run.validatedDecision.selectedOpportunity, null);
assert.equal(done?.case.currentQuestion?.targetEventId, null);
assert.ok((done?.case.currentQuestion?.prompt ?? "").length > 0);
assert.doesNotMatch(done?.case.currentQuestion?.prompt ?? "", /具体哪一天|几号/);
assert.equal(done?.case.currentQuestion?.prompt, composeRectificationPublicTurn(message));
assert.match(done?.case.currentQuestion?.prompt ?? "", /离家去外地上大学/);
assert.match(done?.case.currentQuestion?.prompt ?? "", /交叉核对/);
assert.ok(message.question && done?.case.currentQuestion?.prompt.includes(message.question));
assert.equal(done?.case.latestSnapshot, null);
assert.equal(done?.runtimeTrace.modelId, "gpt-5.5");
assert.equal(done?.runtimeTrace.executionMode, "deterministic_fallback");
assert.equal(done?.runtimeTrace.fallbackCode, run.fallbackReason);
}));
test("V5 shadow runs and persists V5 artifacts but keeps the legacy visible projection", async () => withMode("v5_shadow", async () => {
const store = createRectificationV4MemoryStore();
const service = createTestCaseService(store, { now: fixedNow });
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const queued = await service.answer({
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0,
answer: "2016年9月离家去外地上大学",
});
const worker = createRectificationV4Worker({
store, now: fixedNow,
engine: { async score() { throw new Error("engine must not run before enough events"); } },
});
assert.equal(await worker.runOnce(), true);
const done = await service.loadCase(userId, created.case.id);
const event = done?.events[0];
const run = [...store.agentRuns.values()][0];
assert.ok(queued?.job && event && run);
assert.equal(run.deploymentMode, "v5_shadow");
assert.equal(run.validatedDecision.decision.action, "ask_question");
assert.equal(run.validatedDecision.selectedOpportunity, null);
assert.equal(done.case.currentQuestion?.targetEventId, null);
assert.match(done.case.currentQuestion?.reason ?? "", /V4 legacy projector/);
assert.match(store.publicMessages.get(queued.job.id)?.acknowledgement ?? "", /我记下了/);
}));
test("V5 shadow keeps legacy year-precision refinement targeted to the original event", async () => withMode("v5_shadow", async () => {
const store = createRectificationV4MemoryStore();
const service = createTestCaseService(store, { now: fixedNow });
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const queued = await service.answer({
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0,
answer: "2016年离家去外地上大学",
});
const worker = createRectificationV4Worker({
store, now: fixedNow,
engine: { async score() { throw new Error("engine must not run before enough events"); } },
});
assert.equal(await worker.runOnce(), true);
const done = await service.loadCase(userId, created.case.id);
const event = done?.events[0];
assert.ok(queued?.job && event);
assert.equal(done.case.currentQuestion?.targetEventId, event.eventId);
assert.match(done.case.currentQuestion?.reason ?? "", /V4 legacy projector/);
assert.ok([...store.agentRuns.values()].length > 0);
assert.ok(store.publicMessages.has(queued.job.id));
}));
test("legacy cases are not hard-switched to V5 even when flags change later", async () => {
const store = createRectificationV4MemoryStore();
const service = createTestCaseService(store, { now: fixedNow });
const userId = randomUUID();
const created = await withMode("v4_legacy", () => service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }));
await withMode("v5_agent", async () => {
const queued = await service.answer({
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0, answer: "2016年9月上大学",
});
const worker = createRectificationV4Worker({
store, now: fixedNow,
engine: { async score() { throw new Error("engine must not run before enough events"); } },
});
assert.equal(await worker.runOnce(), true);
const run = [...store.agentRuns.values()][0];
assert.ok(queued?.job && run);
assert.equal(run.deploymentMode, "v4_legacy");
assert.equal(run.fallbackReason, "deployment_mode_legacy");
});
});
test("V5 Agent regenerate rewrites only the current semantic question and replays the same action once", async () => withMode("v5_agent", async () => {
const store = createRectificationV4MemoryStore();
let realizationCalls = 0;
const service = createTestCaseService(store, {
now: fixedNow,
regenerateDirectorQuestion: async ({ currentQuestion }) => {
realizationCalls += 1;
await new Promise((resolve) => setTimeout(resolve, 5));
return `${currentQuestion}(换一种问法)`;
},
});
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const queued = await service.answer({
userId,
caseId: created.case.id,
actionId: randomUUID(),
expectedCaseVersion: created.case.version,
answer: "2020年4月去石油化工研究院实习做研究员",
});
assert.ok(queued?.job);
const worker = createRectificationV4Worker({
store,
now: fixedNow,
engine: { async score() { throw new Error("engine must not run before enough events"); } },
});
assert.equal(await worker.runOnce(), true);
const before = await service.loadCase(userId, created.case.id);
assert.ok(before?.case.currentQuestion);
const actionId = randomUUID();
const regenerationInput = {
userId,
caseId: created.case.id,
actionId,
expectedCaseVersion: before.case.version,
};
const [regenerated, concurrentReplay] = await Promise.all([
service.regenerateQuestion(regenerationInput),
service.regenerateQuestion(regenerationInput),
]);
assert.ok(regenerated?.case.currentQuestion);
assert.equal(concurrentReplay?.case.currentQuestion?.id, regenerated.case.currentQuestion.id);
assert.equal(realizationCalls, 1);
assert.equal(regenerated.case.version, before.case.version + 1);
assert.notEqual(regenerated.case.currentQuestion.id, before.case.currentQuestion.id);
assert.equal(regenerated.case.currentQuestion.domain, before.case.currentQuestion.domain);
assert.equal(regenerated.case.currentQuestion.targetEventId, before.case.currentQuestion.targetEventId);
assert.equal(regenerated.case.evidenceSetHash, before.case.evidenceSetHash);
assert.deepEqual(regenerated.events, before.events);
assert.deepEqual(regenerated.turns, before.turns);
assert.deepEqual(regenerated.case.latestSnapshot, before.case.latestSnapshot);
assert.equal(store.jobs.size, 1);
const replayed = await service.regenerateQuestion({
userId,
caseId: created.case.id,
actionId,
expectedCaseVersion: before.case.version,
});
assert.equal(replayed?.case.version, regenerated.case.version);
assert.equal(replayed?.case.currentQuestion?.id, regenerated.case.currentQuestion.id);
assert.equal(realizationCalls, 1);
assert.equal(store.jobs.size, 1);
assert.equal(regenerated.case.latestSnapshot?.canConfirmExactMinute ?? false, false);
}));
test("V5 Agent regenerate converts historical selected opportunities into server-rendered questions", async () => withMode("v5_agent", async () => {
const store = createRectificationV4MemoryStore();
const service = createTestCaseService(store, { now: fixedNow });
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const queued = await service.answer({
userId,
caseId: created.case.id,
actionId: randomUUID(),
expectedCaseVersion: created.case.version,
answer: "2020年4月去石油化工研究院实习做研究员",
});
assert.ok(queued?.job);
const worker = createRectificationV4Worker({
store,
now: fixedNow,
engine: { async score() { throw new Error("engine must not run before enough events"); } },
});
assert.equal(await worker.runOnce(), true);
const current = await service.loadCase(userId, created.case.id);
const event = current?.events.find((item) => item.domain === "career");
const run = [...store.agentRuns.values()][0];
assert.ok(current?.case.currentQuestion && event && run && queued.job);
const opportunityId = randomUUID();
store.validatedDecisions.set(queued.job.id, {
decision: { action: "ask_question", opportunityId, narrativeFocus: ["date_precision"] },
mode: "agent",
validationIssues: [],
selectedOpportunity: {
contractVersion: "semantic-question-v2",
opportunityId,
kind: "refine_event_date",
domain: "career",
targetEventId: event.eventId,
goal: "核对这段实习经历的月份",
requestedFields: ["event_month"],
anchors: [event.summary],
contextFacts: [],
forbiddenMoves: ["expose_technique_trace"],
fallbackPrompt: "分盘已表明甲组更有把握,请确认 D10 的结论。",
reason: "历史问题机会兼容测试",
expectedInformationGain: .5,
dateSensitivity: .5,
candidateSplitRelevance: .5,
domainCoverageGain: 0,
recallEase: .8,
novelty: .5,
repetitionPenalty: 0,
privacyCost: 0,
utility: .5,
active: true,
},
});
store.cases.set(created.case.id, {
...current.case,
currentQuestion: {
...current.case.currentQuestion,
domain: "career",
targetEventId: event.eventId,
prompt: "分盘已表明甲组更有把握,请确认 D10 的结论。",
},
});
const regenerated = await service.regenerateQuestion({
userId,
caseId: created.case.id,
actionId: randomUUID(),
expectedCaseVersion: current.case.version,
});
assert.ok(regenerated?.case.currentQuestion);
assert.equal(regenerated.case.currentQuestion.domain, "career");
assert.equal(regenerated.case.currentQuestion.targetEventId, event.eventId);
assert.match(regenerated.case.currentQuestion.prompt, /2020年4月|石油化工研究院|实习|研究员/);
assert.doesNotMatch(regenerated.case.currentQuestion.prompt, /D10|分盘|测算|推演|甲组|头一组|前者占优/);
}));
test("legacy and shadow cases cannot call the V5 Agent question renderer", async () => {
for (const mode of ["v4_legacy", "v5_shadow"] as const) {
await withMode(mode, async () => {
const store = createRectificationV4MemoryStore();
const service = createTestCaseService(store, { now: fixedNow });
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
assert.equal(await service.regenerateQuestion({
userId,
caseId: created.case.id,
actionId: randomUUID(),
expectedCaseVersion: created.case.version,
}), null);
});
}
});
test("worker closes only the uniquely matched historical pending evidence", async () => withMode("v4_legacy", async () => {
const store = createRectificationV4MemoryStore();
const service = createTestCaseService(store, { now: fixedNow });
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const pending: PendingEvidence = {
id: randomUUID(),
caseId: created.case.id,
turnId: randomUUID(),
rawText: "后来搬家一次,但记不清时间了",
reasonCode: "date_unresolved",
targetEventId: null,
resolvedEventId: null,
createdAt: "2026-07-27T12:00:00.000Z",
resolvedAt: null,
};
store.pendingEvidence.set(pending.id, pending);
const queued = await service.answer({
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0,
answer: "2018年9月搬家到北京",
});
assert.ok(queued?.job);
const worker = createRectificationV4Worker({
store, now: fixedNow,
engine: { async score() { throw new Error("engine must not run before enough events"); } },
});
assert.equal(await worker.runOnce(), true);
const resolved = store.pendingEvidence.get(pending.id);
assert.ok(resolved?.resolvedEventId);
assert.equal(resolved.resolvedAt, fixedNow().toISOString());
const events = await store.loadEvents(userId, created.case.id);
assert.ok(events.some((event) => event.eventId === resolved.resolvedEventId && event.domain === "relocation"));
}));
test("worker leaves ambiguous historical pending evidence unresolved", async () => withMode("v4_legacy", async () => {
const store = createRectificationV4MemoryStore();
const service = createTestCaseService(store, { now: fixedNow });
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const pending = ["后来搬家一次,但记不清时间了", "以前也搬家,时间忘了"].map((rawText): PendingEvidence => ({
id: randomUUID(), caseId: created.case.id, turnId: randomUUID(), rawText,
reasonCode: "date_unresolved", targetEventId: null, resolvedEventId: null,
createdAt: "2026-07-27T12:00:00.000Z", resolvedAt: null,
}));
for (const item of pending) store.pendingEvidence.set(item.id, item);
await service.answer({
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0,
answer: "2018年9月搬家到北京",
});
const worker = createRectificationV4Worker({
store, now: fixedNow,
engine: { async score() { throw new Error("engine must not run before enough events"); } },
});
assert.equal(await worker.runOnce(), true);
assert.ok(pending.every((item) => store.pendingEvidence.get(item.id)?.resolvedAt === null));
}));
test("date pending evidence closes only after the event date changes", () => {
const eventId = randomUUID();
const original: LifeEventRevision = {
id: randomUUID(), eventId, revision: 1, domain: "relationship", eventKind: "relationship_start",
subject: "self", relatedPerson: "partner", summary: "关系开始", rawText: "2020年关系开始",
dateRange: { start: "2020-01-01", end: "2020-12-31", precision: "year", label: "2020年" },
scoreability: "scoreable", supersedesRevisionId: null, createdAt: "2026-07-27T12:00:00.000Z",
};
const pending: PendingEvidence = {
id: randomUUID(), caseId: randomUUID(), turnId: randomUUID(), rawText: "时间还不确定",
reasonCode: "date_unresolved", targetEventId: eventId, resolvedEventId: null,
createdAt: "2026-07-27T12:00:00.000Z", resolvedAt: null,
};
const reclassified: LifeEventRevision = {
...original, id: randomUUID(), revision: 2, eventKind: "relationship_end", summary: "关系结束",
scoreability: "pending_review", supersedesRevisionId: original.id, createdAt: "2026-07-28T12:00:00.000Z",
};
assert.deepEqual(resolvedPendingEvidence([pending], [reclassified], [original], "2026-07-30"), []);
const dated: LifeEventRevision = {
...reclassified, id: randomUUID(), revision: 3,
dateRange: { start: "2020-04-01", end: "2020-04-30", precision: "month", label: "2020年4月" },
supersedesRevisionId: reclassified.id,
};
assert.deepEqual(resolvedPendingEvidence([pending], [dated], [original, reclassified], "2026-07-30"), [{
pendingEvidenceId: pending.id,
resolvedEventId: eventId,
}]);
});
test("legacy scoreable relationship-end snapshots cannot be accepted", async () => withMode("v5_agent", async () => {
const store = createRectificationV4MemoryStore();
const service = createTestCaseService(store, { now: fixedNow });
const userId = randomUUID();
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
const revision: LifeEventRevision = {
id: randomUUID(), eventId: randomUUID(), revision: 1, domain: "relationship", eventKind: "relationship_end",
subject: "self", relatedPerson: "partner", summary: "关系结束", rawText: "2020年关系结束",
dateRange: { start: "2020-01-01", end: "2020-12-31", precision: "year", label: "2020年" },
scoreability: "scoreable", supersedesRevisionId: null, createdAt: fixedNow().toISOString(),
};
const revised = await store.reviseEvent({
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: created.case.version,
revision, jobId: randomUUID(), now: fixedNow().toISOString(),
});
const snapshot: CandidateSnapshot = {
id: randomUUID(), caseId: created.case.id, caseVersion: revised.case.version,
evidenceSetHash: revised.case.evidenceSetHash, calculationSpecHash: revised.case.calculationSpecHash,
algorithmVersion: "rectification-v5-matrix-scoring-1",
candidates: [{ time: "05:12", score: 10, supportingEventIds: [revision.eventId], conflictingEventIds: [] }],
clusters: [{ rank: 1, startTime: "05:12", endTime: "05:18", representativeTime: "05:13", widthMinutes: 7, peakScore: 10, scoreMass: 1 }],
robustness: { neighborSupportMinutes: 7, leaveOneOutRetentionRate: .8, leaveOneDomainOutRetentionRate: .8, dateSensitivityRetentionRate: .8, calculationSpecHashMatched: true },
canConfirmExactMinute: false, canAcceptRange: true, gateReasons: [], createdAt: fixedNow().toISOString(),
};
store.cases.set(created.case.id, { ...revised.case, latestSnapshot: snapshot });
assert.equal(await service.acceptRange({
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: revised.case.version,
startTime: "05:12", endTime: "05:18",
}), null);
}));