fix: reset rectification corrections safely
This commit is contained in:
@@ -143,6 +143,7 @@ test("correction mode identifies its durable target, can be cancelled, and marks
|
||||
|
||||
assert.match(markup, /正在更正/);
|
||||
assert.match(markup, /开始第一份长期工作/);
|
||||
assert.match(markup, /一次只更正一条事件/);
|
||||
assert.match(markup, /取消更正/);
|
||||
assert.match(markup, /已修订/);
|
||||
});
|
||||
|
||||
@@ -95,13 +95,21 @@ function packet(ready = false): RectificationTechnicalPacket {
|
||||
};
|
||||
}
|
||||
|
||||
function validGenerator(events: string[], varyNarrative = false) {
|
||||
function validGenerator(
|
||||
events: string[],
|
||||
varyNarrative = false,
|
||||
invalidNarrativeFromGeneration?: number,
|
||||
) {
|
||||
let generation = 0;
|
||||
return {
|
||||
modelId: "synthetic-rectification-model",
|
||||
async generate(prompt: string) {
|
||||
generation += 1;
|
||||
events.push("narrative");
|
||||
if (invalidNarrativeFromGeneration !== undefined
|
||||
&& generation >= invalidNarrativeFromGeneration) {
|
||||
return { text: "not a grounded narrative result" };
|
||||
}
|
||||
const request = JSON.parse(prompt) as {
|
||||
phase: "first" | "intermediate" | "final";
|
||||
packet: Omit<ReturnType<typeof packet>, "candidate"> & {
|
||||
@@ -153,6 +161,7 @@ function harness(options: {
|
||||
readonly releaseFailure?: boolean;
|
||||
readonly readyAfterEvidenceCount?: number;
|
||||
readonly varyNarrative?: boolean;
|
||||
readonly invalidNarrativeFromGeneration?: number;
|
||||
} = {}) {
|
||||
const events: string[] = [];
|
||||
const mutations: string[] = [];
|
||||
@@ -165,6 +174,12 @@ function harness(options: {
|
||||
commandFingerprint?: string;
|
||||
}>();
|
||||
const packetEvidenceCounts: number[] = [];
|
||||
const packetEvidenceIds: string[][] = [];
|
||||
const packetPrivateCandidates: Array<Readonly<{
|
||||
rangeStart?: string | null;
|
||||
rangeEnd?: string | null;
|
||||
resultId?: string | null;
|
||||
}> | null> = [];
|
||||
let packetBuilds = 0;
|
||||
let reserveCount = 0;
|
||||
let releaseCount = 0;
|
||||
@@ -358,13 +373,25 @@ function harness(options: {
|
||||
async buildTechnicalPacket(input) {
|
||||
packetBuilds += 1;
|
||||
packetEvidenceCounts.push(input.evidence.length);
|
||||
packetEvidenceIds.push(input.evidence.map((item) => item.id));
|
||||
packetPrivateCandidates.push(input.privateCandidate
|
||||
? {
|
||||
rangeStart: input.privateCandidate.rangeStart,
|
||||
rangeEnd: input.privateCandidate.rangeEnd,
|
||||
resultId: input.privateCandidate.resultId,
|
||||
}
|
||||
: null);
|
||||
events.push(input.evidence.length > 0 ? "score-packet" : "packet");
|
||||
if (options.packetFailure) throw options.packetFailure;
|
||||
return input.evidence.length >= (options.readyAfterEvidenceCount ?? 1)
|
||||
? { packet: packet(true), resultId }
|
||||
: { packet: packet(false), resultId: null };
|
||||
},
|
||||
narrativeGenerator: validGenerator(events, options.varyNarrative),
|
||||
narrativeGenerator: validGenerator(
|
||||
events,
|
||||
options.varyNarrative,
|
||||
options.invalidNarrativeFromGeneration,
|
||||
),
|
||||
asOfDate: () => "2026-07-21",
|
||||
};
|
||||
|
||||
@@ -372,6 +399,8 @@ function harness(options: {
|
||||
events,
|
||||
mutations,
|
||||
packetEvidenceCounts,
|
||||
packetEvidenceIds,
|
||||
packetPrivateCandidates,
|
||||
cases,
|
||||
service: createConversationalRectificationService(ports),
|
||||
counts: () => ({ packetBuilds, reserveCount, releaseCount }),
|
||||
@@ -583,6 +612,148 @@ test("an unclear correction immediately retires the wrong fact and stays retired
|
||||
assert.equal(later.evidenceRecap.some((item) => item.id === unclear?.id), true);
|
||||
});
|
||||
|
||||
test("every non-confirmable correction rescans the declared range and withdraws the old candidate", async () => {
|
||||
const scenarios = [
|
||||
{ name: "unclear", answer: "更正:具体年月记不清" },
|
||||
{ name: "future", answer: "更正:2099年3月开始新工作" },
|
||||
{ name: "direction change", answer: "更正:这些都不符合" },
|
||||
{
|
||||
name: "narrative validation fallback",
|
||||
answer: "更正:其实是2020年11月离职",
|
||||
invalidNarrativeFromGeneration: 3,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const invalidNarrativeFromGeneration = "invalidNarrativeFromGeneration" in scenario
|
||||
? scenario.invalidNarrativeFromGeneration
|
||||
: undefined;
|
||||
const value = harness({
|
||||
...(invalidNarrativeFromGeneration === undefined
|
||||
? {}
|
||||
: { invalidNarrativeFromGeneration }),
|
||||
});
|
||||
await start(value, null);
|
||||
const prior = await value.service.answer(userId, {
|
||||
type: "answer",
|
||||
caseId: startActionId,
|
||||
actionId: answerActionId,
|
||||
turnVersion: 0,
|
||||
answer: "2019年7月开始第一份工作",
|
||||
});
|
||||
assert.equal(prior.status, "confirming", scenario.name);
|
||||
const wrongId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id;
|
||||
assert.ok(wrongId, scenario.name);
|
||||
const buildsBeforeCorrection = value.counts().packetBuilds;
|
||||
|
||||
const corrected = await value.service.answer(userId, {
|
||||
type: "answer",
|
||||
caseId: startActionId,
|
||||
actionId: secondAnswerActionId,
|
||||
turnVersion: 1,
|
||||
answer: scenario.answer,
|
||||
correctsEvidenceId: wrongId,
|
||||
});
|
||||
|
||||
const stored = value.cases.get(startActionId)?.row;
|
||||
const replacement = stored?.eventEvidence.at(-1);
|
||||
assert.ok(stored && replacement, scenario.name);
|
||||
assert.equal(value.counts().packetBuilds, buildsBeforeCorrection + 1, scenario.name);
|
||||
assert.equal(value.packetPrivateCandidates.at(-1), null, scenario.name);
|
||||
assert.equal(value.packetEvidenceIds.at(-1)?.includes(wrongId), false, scenario.name);
|
||||
assert.equal(corrected.status, "active", scenario.name);
|
||||
assert.equal(corrected.candidate.status, "pending_validation", scenario.name);
|
||||
assert.equal(corrected.actions.includes("confirm"), false, scenario.name);
|
||||
assert.equal(stored.privateCandidate.resultId ?? null, null, scenario.name);
|
||||
assert.equal(stored.privateCandidate.workingState?.phase, "collecting_evidence", scenario.name);
|
||||
assert.equal(corrected.evidenceRecap.some((item) => item.id === wrongId), false, scenario.name);
|
||||
assert.equal(corrected.evidenceRecap.some((item) => item.id === replacement.id), true, scenario.name);
|
||||
assert.equal(value.counts().reserveCount, 1, scenario.name);
|
||||
|
||||
if (scenario.name === "narrative validation fallback") {
|
||||
assert.deepEqual(value.packetEvidenceIds.at(-1), [replacement.id]);
|
||||
} else {
|
||||
assert.deepEqual(value.packetEvidenceIds.at(-1), []);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("a clear one-to-one correction can form a new confirmation candidate only after a declared-range rescan", async () => {
|
||||
const value = harness();
|
||||
await start(value, null);
|
||||
await value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||||
turnVersion: 0, answer: "2019年7月开始第一份工作",
|
||||
});
|
||||
const wrongId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id;
|
||||
assert.ok(wrongId);
|
||||
|
||||
const corrected = await value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: secondAnswerActionId,
|
||||
turnVersion: 1, answer: "更正:其实是2020年11月离职",
|
||||
correctsEvidenceId: wrongId,
|
||||
});
|
||||
const stored = value.cases.get(startActionId)?.row;
|
||||
const replacementId = stored?.eventEvidence.at(-1)?.id;
|
||||
assert.ok(stored && replacementId);
|
||||
|
||||
assert.equal(value.packetPrivateCandidates.at(-1), null);
|
||||
assert.deepEqual(value.packetEvidenceIds.at(-1), [replacementId]);
|
||||
assert.equal(corrected.status, "confirming");
|
||||
assert.equal(corrected.candidate.status, "ready_for_confirmation");
|
||||
assert.equal(corrected.actions.includes("confirm"), true);
|
||||
assert.equal(stored.privateCandidate.resultId, resultId);
|
||||
});
|
||||
|
||||
test("a targeted correction must extract exactly one replacement before scoring or persistence", async () => {
|
||||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||||
await start(value, null);
|
||||
await value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||||
turnVersion: 0, answer: "2019年7月开始第一份工作",
|
||||
});
|
||||
const wrongId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id;
|
||||
assert.ok(wrongId);
|
||||
const before = {
|
||||
builds: value.counts().packetBuilds,
|
||||
mutations: [...value.mutations],
|
||||
version: value.cases.get(startActionId)?.row.turnVersion,
|
||||
evidenceCount: value.cases.get(startActionId)?.row.eventEvidence.length,
|
||||
};
|
||||
|
||||
await assert.rejects(value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: secondAnswerActionId,
|
||||
turnVersion: 1,
|
||||
answer: "更正:2020年11月离职,并在2021年2月入职",
|
||||
correctsEvidenceId: wrongId,
|
||||
}), (error: unknown) => error instanceof ConversationalRectificationError
|
||||
&& error.code === "invalid_command");
|
||||
|
||||
assert.equal(value.counts().packetBuilds, before.builds);
|
||||
assert.deepEqual(value.mutations, before.mutations);
|
||||
assert.equal(value.cases.get(startActionId)?.row.turnVersion, before.version);
|
||||
assert.equal(value.cases.get(startActionId)?.row.eventEvidence.length, before.evidenceCount);
|
||||
});
|
||||
|
||||
test("ordinary new evidence continues incrementally from the current candidate range", async () => {
|
||||
const value = harness();
|
||||
await start(value, null);
|
||||
await value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: answerActionId,
|
||||
turnVersion: 0, answer: "2019年7月开始第一份工作",
|
||||
});
|
||||
await value.service.answer(userId, {
|
||||
type: "answer", caseId: startActionId, actionId: secondAnswerActionId,
|
||||
turnVersion: 1, answer: "2020年11月搬家",
|
||||
});
|
||||
|
||||
assert.deepEqual(value.packetPrivateCandidates, [
|
||||
null,
|
||||
{ rangeStart: "04:50", rangeEnd: "05:50", resultId: null },
|
||||
{ rangeStart: "05:16", rangeEnd: "05:20", resultId },
|
||||
]);
|
||||
});
|
||||
|
||||
test("missing or already-retired correction targets fail closed without advancing or persisting", async () => {
|
||||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||||
await start(value, null);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { ConversationalRectificationError } from "../src/lib/conversational-rectification/errors.ts";
|
||||
import type { BirthTimeJourneyEngine } from "../src/lib/birth-time-journey-service.ts";
|
||||
import type { LifeEventEvidence } from "../src/lib/conversational-rectification/persistence-contracts.ts";
|
||||
import type { LifeEvent } from "../src/lib/birth-time-evidence.ts";
|
||||
import type { CandidateResult, LifeEvent } from "../src/lib/birth-time-evidence.ts";
|
||||
|
||||
const userId = "00000000-0000-4000-8000-000000000711";
|
||||
const actionId = "00000000-0000-4000-8000-000000000712";
|
||||
@@ -88,7 +88,10 @@ function syntheticEvidence(
|
||||
function packetEngine(options: {
|
||||
readonly scoreCalls?: LifeEvent[][];
|
||||
readonly scanTimes?: readonly string[];
|
||||
readonly scanCalls?: Array<{ readonly birthTime: string; readonly uncertaintyMinutes: number }>;
|
||||
readonly scoreResults?: readonly CandidateResult[];
|
||||
} = {}): BirthTimeJourneyEngine {
|
||||
let scoreResultIndex = 0;
|
||||
const minute = (value: string) => {
|
||||
const [hour = 0, part = 0] = value.slice(-5).split(":").map(Number);
|
||||
return hour * 60 + part;
|
||||
@@ -99,6 +102,10 @@ function packetEngine(options: {
|
||||
};
|
||||
return {
|
||||
async scan(input) {
|
||||
options.scanCalls?.push({
|
||||
birthTime: input.birthTime,
|
||||
uncertaintyMinutes: input.uncertaintyMinutes,
|
||||
});
|
||||
const center = minute(input.birthTime);
|
||||
const times = options.scanTimes ?? [
|
||||
clock(center - input.uncertaintyMinutes),
|
||||
@@ -136,6 +143,9 @@ function packetEngine(options: {
|
||||
assert.ok(event.date >= birthBoundary, "synthetic scorer rejected pre-birth evidence");
|
||||
}
|
||||
options.scoreCalls?.push([...input.events]);
|
||||
const configured = options.scoreResults?.[scoreResultIndex];
|
||||
scoreResultIndex += 1;
|
||||
if (configured) return configured;
|
||||
return {
|
||||
resultId: "00000000-0000-4000-8000-000000000899",
|
||||
confidence: "low",
|
||||
@@ -476,6 +486,107 @@ test("production packet waits for three supported events and then scores the acc
|
||||
assert.deepEqual(scoreCalls[0]?.map((event) => event.id), evidence.map((item) => item.id));
|
||||
});
|
||||
|
||||
test("production rescans the declared range after correction while ordinary evidence stays incremental", async () => {
|
||||
const scanCalls: Array<{ readonly birthTime: string; readonly uncertaintyMinutes: number }> = [];
|
||||
const narrowResult: CandidateResult = {
|
||||
resultId: "00000000-0000-4000-8000-000000001301",
|
||||
confidence: "low",
|
||||
canApply: false,
|
||||
winningSegment: {
|
||||
startTime: "05:16",
|
||||
endTime: "05:20",
|
||||
representativeTime: "05:18",
|
||||
widthMinutes: 5,
|
||||
},
|
||||
eventCount: 3,
|
||||
domainCount: 3,
|
||||
topScore: 4,
|
||||
secondScore: 3,
|
||||
marginPercent: 10,
|
||||
reasons: ["synthetic narrowed range"],
|
||||
evidence: [],
|
||||
algorithmVersion: "synthetic-event-score-v1",
|
||||
};
|
||||
const broadResult: CandidateResult = {
|
||||
...narrowResult,
|
||||
resultId: "00000000-0000-4000-8000-000000001302",
|
||||
winningSegment: null,
|
||||
reasons: ["synthetic evidence no longer narrows the range"],
|
||||
};
|
||||
const engine = packetEngine({
|
||||
scanCalls,
|
||||
scoreResults: [narrowResult, broadResult, broadResult],
|
||||
});
|
||||
const declaredBirthInput = {
|
||||
source: "approximate" as const,
|
||||
birthDate: "1990-01-01",
|
||||
reportedTime: "05:20",
|
||||
uncertaintyBeforeMinutes: 30 as const,
|
||||
uncertaintyAfterMinutes: 30 as const,
|
||||
birthTimeClue: null,
|
||||
birthplace: packetBirthplace,
|
||||
};
|
||||
const oldEvidence = [
|
||||
syntheticEvidence(41, "education"),
|
||||
syntheticEvidence(42, "relocation"),
|
||||
syntheticEvidence(43, "career"),
|
||||
];
|
||||
|
||||
const narrowed = await buildProductionConversationalRectificationPacket(engine, {
|
||||
userId,
|
||||
caseId,
|
||||
asOfDate: "2026-07-21",
|
||||
declaredBirthInput,
|
||||
privateCandidate: null,
|
||||
evidence: oldEvidence,
|
||||
});
|
||||
assert.deepEqual(narrowed.packet.candidate.range, { startTime: "05:16", endTime: "05:20" });
|
||||
assert.deepEqual(scanCalls.at(-1), {
|
||||
birthTime: "1990-01-01 05:18",
|
||||
uncertaintyMinutes: 2,
|
||||
});
|
||||
|
||||
const currentCandidate = {
|
||||
calculationVersion: narrowed.packet.calculationVersion,
|
||||
rangeStart: narrowed.packet.candidate.range.startTime,
|
||||
rangeEnd: narrowed.packet.candidate.range.endTime,
|
||||
representativeTime: narrowed.packet.candidate.representativeTime,
|
||||
};
|
||||
const ordinary = await buildProductionConversationalRectificationPacket(engine, {
|
||||
userId,
|
||||
caseId,
|
||||
asOfDate: "2026-07-21",
|
||||
declaredBirthInput,
|
||||
privateCandidate: currentCandidate,
|
||||
evidence: [...oldEvidence, syntheticEvidence(44, "relationship")],
|
||||
});
|
||||
assert.deepEqual(ordinary.packet.candidate.range, { startTime: "05:16", endTime: "05:20" });
|
||||
assert.deepEqual(scanCalls.at(-1), {
|
||||
birthTime: "1990-01-01 05:18",
|
||||
uncertaintyMinutes: 2,
|
||||
});
|
||||
|
||||
const corrected = await buildProductionConversationalRectificationPacket(engine, {
|
||||
userId,
|
||||
caseId,
|
||||
asOfDate: "2026-07-21",
|
||||
declaredBirthInput,
|
||||
privateCandidate: null,
|
||||
evidence: [
|
||||
syntheticEvidence(45, "education"),
|
||||
syntheticEvidence(46, "relocation"),
|
||||
syntheticEvidence(47, "career"),
|
||||
],
|
||||
});
|
||||
assert.deepEqual(corrected.packet.candidate.range, { startTime: "04:50", endTime: "05:50" });
|
||||
assert.deepEqual(scanCalls.at(-1), {
|
||||
birthTime: "1990-01-01 05:20",
|
||||
uncertaintyMinutes: 30,
|
||||
});
|
||||
assert.ok(corrected.packet.sensitivityScope.sampleTimes.includes("04:50"));
|
||||
assert.equal(ordinary.packet.sensitivityScope.sampleTimes.includes("04:50"), false);
|
||||
});
|
||||
|
||||
test("production packet deterministically sends only the latest six supported events", async () => {
|
||||
const scoreCalls: LifeEvent[][] = [];
|
||||
const engine = packetEngine({ scoreCalls });
|
||||
|
||||
Reference in New Issue
Block a user