fix: reset rectification corrections safely

This commit is contained in:
Jesse_Chen
2026-07-21 05:57:30 +08:00
parent 887b40df43
commit 5c8d965f16
8 changed files with 584 additions and 34 deletions
@@ -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 });