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
@@ -248,7 +248,7 @@ export function ConversationalRectificationSurface({
<p>
<strong>{controller.correctionTarget.dateLabel} · {controller.correctionTarget.summary}</strong>
</p>
<span>使</span>
<span>使</span>
<button
className="button-secondary"
disabled={controller.pending}
@@ -331,19 +331,35 @@ function domainsForClarification(
: ["career", "relationship"];
}
type CorrectionResetReason =
| "needs_clarification"
| "non_scoreable"
| "direction_change"
| "validation_fallback";
function nonScoringTurn(input: {
readonly current: LoadedConversationalRectificationCase;
readonly newEvidence: ReadonlyArray<LifeEventEvidenceInput>;
readonly domain?: RectificationEvidenceDomain;
readonly directionChange: boolean;
readonly scoringFallback?: boolean;
readonly correctionClarificationPacket?: RectificationTechnicalPacket;
readonly correctionReset?: Readonly<{
packet: RectificationTechnicalPacket;
reason: CorrectionResetReason;
}>;
}): { readonly turn: ConversationalRectificationTurn; readonly receipt: ValidationReceipt } {
const allEvidence = [...input.current.eventEvidence, ...input.newEvidence];
const hasFuture = input.newEvidence.some((item) => item.extractionStatus !== "needs_clarification"
&& item.scoreable === false && item.dateValue !== null);
const narrative = input.correctionClarificationPacket
? "这条更正已保存,原记录已经停止参与候选评分。更正后的事件时间还不够清楚,请补充大约年份、月份和发生了什么;在补清之前不会沿用旧证据推进确认。"
const correctionNarrative = input.correctionReset?.reason === "validation_fallback"
? "这条更正已保存,原记录已经停止参与候选评分。候选已从声明范围重新计算,但新的专业解释未通过事实一致性校验;本轮不会保留旧候选的确认资格,请稍后重试或继续补充真实事件。"
: input.correctionReset?.reason === "direction_change"
? "这条更正已保存,原记录已经停止参与候选评分。我们会从声明范围重新开始核对,你可以换一个真实事件方向并尽量写明年月;本轮不会沿用旧候选推进确认。"
: input.correctionReset?.reason === "non_scoreable"
? "这条更正已保存,原记录已经停止参与候选评分。更正后的内容目前不能作为已经发生的评分证据,候选已从声明范围重新计算;请再补充一件已发生并带有年月的事件。"
: "这条更正已保存,原记录已经停止参与候选评分。更正后的事件时间还不够清楚,候选已从声明范围重新计算;请补充大约年份、月份和发生了什么。";
const narrative = input.correctionReset
? correctionNarrative
: input.scoringFallback
? "本轮原文已安全保存,但新的专业解释未通过事实一致性校验,因此候选没有推进。请稍后重试,或继续补充一件已经发生并带有年月的事件。"
: input.directionChange
@@ -351,7 +367,7 @@ function nonScoringTurn(input: {
: hasFuture
? "已保存这段描述。未来事件只能作为背景,不能用于校正评分;请再说一件已经发生的事件,并尽量写明年月。"
: "我已保存你的原话,但还缺少可用于区分候选的明确时间。请用自己的话补充这件已经发生的事大约是哪一年、哪一月;不需要选择固定答案。";
const status = input.correctionClarificationPacket
const status = input.correctionReset
? "active" as const
: input.current.status === "confirming" ? "confirming" as const : "active" as const;
const actions = actionsFor(status);
@@ -370,12 +386,12 @@ function nonScoringTurn(input: {
evidenceRequest,
evidenceRecap: evidenceRecap(allEvidence),
actions,
...(input.correctionClarificationPacket ? {
...(input.correctionReset ? {
candidate: {
...projectRectificationTechnicalPacket(input.correctionClarificationPacket).candidate,
...projectRectificationTechnicalPacket(input.correctionReset.packet).candidate,
status: "pending_validation" as const,
},
technicalReceipt: exactTechnicalReceipt(input.correctionClarificationPacket),
technicalReceipt: exactTechnicalReceipt(input.correctionReset.packet),
} : {}),
});
if (!parsed.success) throw new ConversationalRectificationError("service_unavailable");
@@ -457,6 +473,9 @@ export function createConversationalRectificationService(
throw new ConversationalRectificationError("invalid_command");
}
if (extracted.length > 20) throw new ConversationalRectificationError("invalid_command");
if (command.correctsEvidenceId && extracted.length !== 1) {
throw new ConversationalRectificationError("invalid_command");
}
return extracted;
}
@@ -644,34 +663,110 @@ export function createConversationalRectificationService(
const explicitDirectionChange = explicitDirectionChangePattern.test(command.answer);
const directionChange = explicitDirectionChange
|| (scoreableEvidence.length === 0 && genericUncertaintyPattern.test(command.answer));
const unclearCorrection = Boolean(command.correctsEvidenceId)
&& evidence.some((item) => item.extractionStatus === "needs_clarification");
if (unclearCorrection) {
const allScoreable = effectiveLifeEventEvidence([...current.eventEvidence, ...evidence])
.filter((item) => item.scoreable === true
&& item.extractionStatus !== "needs_clarification"
&& !evidencePredatesBirthDate(item, current.declaredBirthInput.birthDate));
if (command.correctsEvidenceId) {
try {
const allScoreable = effectiveLifeEventEvidence([...current.eventEvidence, ...evidence])
.filter((item) => item.scoreable === true
&& item.extractionStatus !== "needs_clarification"
&& !evidencePredatesBirthDate(item, current.declaredBirthInput.birthDate));
const computed = await ports.buildTechnicalPacket({
userId,
caseId: command.caseId,
asOfDate: ports.asOfDate(),
declaredBirthInput: current.declaredBirthInput,
privateCandidate: current.privateCandidate,
// A correction invalidates any range narrowed by the retired fact.
// Rebuild from the user's declared/baseline range so eliminated
// minutes can re-enter the deterministic scan.
privateCandidate: null,
evidence: allScoreable,
});
const next = nonScoringTurn({
current,
newEvidence: evidence,
domain: command.domain,
directionChange: false,
correctionClarificationPacket: computed.packet,
const replacement = evidence[0];
if (!replacement) throw new ConversationalRectificationError("invalid_command");
const resetReason: CorrectionResetReason | null = directionChange
? "direction_change"
: replacement.extractionStatus === "needs_clarification"
? "needs_clarification"
: replacement.scoreable !== true ? "non_scoreable" : null;
if (resetReason) {
const next = nonScoringTurn({
current,
newEvidence: evidence,
domain: command.domain,
directionChange: false,
correctionReset: { packet: computed.packet, reason: resetReason },
});
const privateCandidate = privateCandidateFromPacket({
packet: computed.packet,
resultId: null,
iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1,
forceCollecting: true,
});
const saved = await ports.store.saveTurn({
userId,
caseId: command.caseId,
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
turn: next.turn,
evidence,
validationReceipt: next.receipt,
privateCandidate,
});
return publicTurn(saved);
}
const phase = computed.packet.candidate.status === "ready_for_confirmation"
? "final" as const
: "intermediate" as const;
const narrative = await generateRectificationNarrative({
phase,
packet: computed.packet,
generator: ports.narrativeGenerator,
});
if (!narrative.allowEvidenceScoringAdvance) {
const next = nonScoringTurn({
current,
newEvidence: evidence,
domain: command.domain,
directionChange: false,
correctionReset: {
packet: computed.packet,
reason: "validation_fallback",
},
});
const privateCandidate = privateCandidateFromPacket({
packet: computed.packet,
resultId: null,
iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1,
forceCollecting: true,
});
const saved = await ports.store.saveTurn({
userId,
caseId: command.caseId,
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
turn: next.turn,
evidence,
validationReceipt: narrative.validationReceipt,
privateCandidate,
});
return publicTurn(saved);
}
const privateCandidate = privateCandidateFromPacket({
packet: computed.packet,
resultId: null,
resultId: computed.resultId,
iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1,
forceCollecting: true,
});
const turn = turnFromNarrative({
caseId: command.caseId,
turnVersion: command.turnVersion + 1,
pendingConsultationQuestion: current.pendingConsultationQuestion,
packet: computed.packet,
narrative,
evidence: [...current.eventEvidence, ...evidence],
});
const saved = await ports.store.saveTurn({
userId,
@@ -679,9 +774,9 @@ export function createConversationalRectificationService(
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
turn: next.turn,
turn,
evidence,
validationReceipt: next.receipt,
validationReceipt: narrative.validationReceipt,
privateCandidate,
});
return publicTurn(saved);
@@ -715,10 +810,6 @@ export function createConversationalRectificationService(
}
try {
const allScoreable = effectiveLifeEventEvidence([...current.eventEvidence, ...evidence])
.filter((item) => item.scoreable === true
&& item.extractionStatus !== "needs_clarification"
&& !evidencePredatesBirthDate(item, current.declaredBirthInput.birthDate));
const computed = await ports.buildTechnicalPacket({
userId,
caseId: command.caseId,
@@ -640,7 +640,22 @@ begin
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
-- Correction targets are account-case evidence tips. Under the locked case version,
-- reject missing, cross-case, or already-retired targets before writing a turn.
-- reject one-to-many replacement attempts, missing/cross-case targets, or
-- already-retired targets before writing a turn.
if exists (
select 1
from (
select correction.value::uuid as target_id
from pg_catalog.jsonb_array_elements(p_evidence) item(value)
cross join lateral pg_catalog.jsonb_array_elements_text(
coalesce(item.value -> 'correctsEvidenceIds', '[]'::jsonb)
) correction(value)
group by correction.value::uuid
having pg_catalog.count(*) > 1
) duplicate_correction_target
) then
raise exception 'conversational_action_conflict' using errcode = 'P0001';
end if;
if exists (
select 1
from pg_catalog.jsonb_array_elements(p_evidence) item(value)
@@ -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 });
@@ -511,6 +511,10 @@ def test_evidence_correction_lineage_is_bounded_projected_and_validated_atomical
"insert into public.birth_time_rectification_event_evidence"
)
assert "where evidence.case_id = p_case_id" in save
assert "group by correction.value::uuid having pg_catalog.count(*) > 1" in save
assert save.index("group by correction.value::uuid having pg_catalog.count(*) > 1") < save.index(
"insert into public.birth_time_rectification_turns"
)
def test_case_mutations_enforce_cumulative_load_limits_under_the_case_lock() -> None:
@@ -1156,6 +1156,163 @@ def test_correction_lineage_is_append_only_owner_scoped_and_rejects_retired_targ
}]
def test_duplicate_correction_target_fails_atomically_without_advancing_any_durable_state(
pg14_database: PgDatabase,
) -> None:
user_id = "00000000-0000-4000-8000-000000001401"
case_id = "00000000-0000-4000-8000-000000001402"
first_action = "00000000-0000-4000-8000-000000001403"
rejected_action = "00000000-0000-4000-8000-000000001404"
first_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaa1411"
replacement_a = "00000000-0000-4000-8000-000000001412"
replacement_b = "00000000-0000-4000-8000-000000001413"
_create_user(pg14_database, user_id)
_reserve(pg14_database, user_id, case_id)
_create_case(pg14_database, user_id, case_id, _valid_declared_birth_input())
_complete(pg14_database, user_id, case_id)
first = {
"id": first_id,
"rawText": "2019 年 7 月开始第一份工作",
"domain": "career",
"eventSummary": "开始第一份工作",
"dateValue": "2019-07",
"datePrecision": "month",
"extractionStatus": "clear",
"scoreable": True,
}
pg14_database.sql(_save_statement(user_id, case_id, 0, first_action, [first]))
def durable_counts() -> dict[str, int]:
return json.loads(pg14_database.sql(
f"""
select pg_catalog.jsonb_build_object(
'version', rectification.turn_version,
'turns', (select pg_catalog.count(*) from public.birth_time_rectification_turns turn where turn.case_id = rectification.id),
'evidence', (select pg_catalog.count(*) from public.birth_time_rectification_event_evidence evidence where evidence.case_id = rectification.id),
'receipts', (select pg_catalog.count(*) from public.birth_time_rectification_action_receipts receipt where receipt.case_id = rectification.id)
)::text
from public.birth_time_rectification_cases rectification
where rectification.id = '{case_id}'::uuid;
"""
))
before = durable_counts()
duplicate_replacements = [
{
"id": replacement_a,
"rawText": "更正:2020 年 11 月离职",
"domain": "career",
"eventSummary": "离职",
"dateValue": "2020-11",
"datePrecision": "month",
"extractionStatus": "corrected",
"scoreable": True,
"correctsEvidenceIds": [first_id.upper()],
},
{
"id": replacement_b,
"rawText": "更正:2021 年 2 月入职",
"domain": "career",
"eventSummary": "入职",
"dateValue": "2021-02",
"datePrecision": "month",
"extractionStatus": "corrected",
"scoreable": True,
"correctsEvidenceIds": [first_id],
},
]
assert pg14_database.rejects(_save_statement(
user_id,
case_id,
1,
rejected_action,
duplicate_replacements,
))
assert durable_counts() == before
def test_concurrent_corrections_of_one_tip_leave_exactly_one_winner(
pg14_database: PgDatabase,
) -> None:
user_id = "00000000-0000-4000-8000-000000001421"
case_id = "00000000-0000-4000-8000-000000001422"
first_action = "00000000-0000-4000-8000-000000001423"
action_a = "00000000-0000-4000-8000-000000001424"
action_b = "00000000-0000-4000-8000-000000001425"
first_id = "00000000-0000-4000-8000-000000001431"
replacement_a = "00000000-0000-4000-8000-000000001432"
replacement_b = "00000000-0000-4000-8000-000000001433"
_create_user(pg14_database, user_id)
_reserve(pg14_database, user_id, case_id)
_create_case(pg14_database, user_id, case_id, _valid_declared_birth_input())
_complete(pg14_database, user_id, case_id)
first = {
"id": first_id,
"rawText": "2019 年 7 月开始第一份工作",
"domain": "career",
"eventSummary": "开始第一份工作",
"dateValue": "2019-07",
"datePrecision": "month",
"extractionStatus": "clear",
"scoreable": True,
}
pg14_database.sql(_save_statement(user_id, case_id, 0, first_action, [first]))
statements = []
for action_id, evidence_id, date_value, summary in (
(action_a, replacement_a, "2020-11", "离职"),
(action_b, replacement_b, "2021-02", "入职"),
):
replacement = {
"id": evidence_id,
"rawText": f"更正:{date_value} {summary}",
"domain": "career",
"eventSummary": summary,
"dateValue": date_value,
"datePrecision": "month",
"extractionStatus": "corrected",
"scoreable": True,
"correctsEvidenceIds": [first_id],
}
statements.append(_save_statement(
user_id,
case_id,
1,
action_id,
[replacement],
))
processes = [subprocess.Popen(
pg14_database.command("-A", "-t", "-q", "-c", statement),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
) for statement in statements]
results = [process.communicate(timeout=20) for process in processes]
return_codes = [process.returncode for process in processes]
assert return_codes.count(0) == 1, results
assert sum(code != 0 for code in return_codes) == 1, results
durable = json.loads(pg14_database.sql(
f"""
select pg_catalog.jsonb_build_object(
'version', rectification.turn_version,
'turns', (select pg_catalog.count(*) from public.birth_time_rectification_turns turn where turn.case_id = rectification.id),
'evidence', (select pg_catalog.count(*) from public.birth_time_rectification_event_evidence evidence where evidence.case_id = rectification.id),
'winningReceipts', (select pg_catalog.count(*) from public.birth_time_rectification_action_receipts receipt where receipt.case_id = rectification.id and receipt.action_id in ('{action_a}'::uuid, '{action_b}'::uuid))
)::text
from public.birth_time_rectification_cases rectification
where rectification.id = '{case_id}'::uuid;
"""
))
assert durable == {
"version": 2,
"turns": 3,
"evidence": 2,
"winningReceipts": 1,
}
def test_save_rejects_cumulative_evidence_count_before_inserting(
pg14_database: PgDatabase,
) -> None: