From 30d8c78d198ae2bdda56e0ec35414edd25391a7a Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 28 Aug 2026 18:42:18 +0800 Subject: [PATCH] fix(rectification): fail-close snapshot fingerprints without rewinding discrimination Missing evidence fingerprints are stale, not current. Stale snapshots keep asking discriminators when a probe exists, and offer-candidates refuses until the ledger matches. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 16 ++ .../core/rectification-decision.ts | 17 +- .../core/snapshot-source.ts | 17 +- .../v9/decision-from-dossier.ts | 34 +++- frontend/src/mastra/rectification-v9-tools.ts | 9 +- .../rectification-decide-next-action.test.ts | 38 ++++ .../tests/rectification-eight-method.test.ts | 162 +++++++++++------- 7 files changed, 216 insertions(+), 77 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index c33b39b5..9d88a5ed 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6365,6 +6365,22 @@ - 复发自:无 - 修复版本:待发布 +## BUG-420 | 候选快照缺指纹被当成 current,stale 时把区分打回采集 + +- 状态:resolved +- 首次发现:2026-08-28 +- 最近更新:2026-08-28 +- 影响面:`decideFromDossier`、`scoreableSnapshotIsCurrent`、`decideRectification`、GET/工具 `candidate_snapshot` +- 用户现象:已进入区分的 Case 在快照过期或缺证据指纹时,下一问变回采集,点选卡消失。工具侧和 dossier 侧对同一份账本可能一个判 stale、一个判 current。 +- 触发条件:已有区分探针和训练门;`latest_result` 缺少 `evidence_ledger_fingerprint`,或账本指纹已变但决策器仍走 `snapshotCurrent === false → collect`。 +- 根因:dossier 用 `!storedFingerprint || stored === current` fail-open;工具用 `!storedSnapshot || !fingerprint || scoreableSnapshotIsCurrent` 同样 fail-open。`decideRectification` 把 `snapshotCurrent === false` 与方法覆盖缺失绑在一起打回 `event_collection`。 +- 修复:两处都走 `candidateSnapshotSource` + `storedSnapshotIsCurrent`。指纹缺失判 `fingerprint_missing`,不得当 current。没有已存快照仍不算 stale。快照 stale 且仍有区分探针时保持 `ask_candidate_discriminator`,receipt 写 `stale_reason`,要求重算;不把区分打回采集。不改 Skill。 +- 验证:`rectification-decide-next-action` 锁定缺指纹为 stale;训练已开+探针时 `snapshotCurrent: false` 仍是 `ask_candidate_discriminator`;训练未开仍采集。`rectification-decision-authority` 无指纹的事业/感情训练账本仍区分。`rectification-eight-method` 锁定缺指纹拒绝 `offer-candidates`;暂停逃生口和覆盖后的 34/33/33 平局只在指纹匹配时出牌。 +- 防复发:不得把空指纹写成 current。不得在已有区分探针时用 stale 快照把 `nextAction` 改成 `ask_fact_collection`。不得为了暂停出牌或平局出牌把空指纹 fail-open。`occupation_note` 仍不得单独让 scoreable 快照失效。 +- 相关记录:BUG-410、#40 +- 复发自:无 +- 修复版本:待发布 + ## BUG-410 | 训练已齐仍因家人/职业方法层停在采集,Agent 只确认后截断 - 状态:resolved diff --git a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts index 7a6bfd32..44173858 100644 --- a/frontend/src/lib/rectification-agentic/core/rectification-decision.ts +++ b/frontend/src/lib/rectification-agentic/core/rectification-decision.ts @@ -113,14 +113,19 @@ export function decideRectification(input: DecideRectificationInput): Rectificat const canDiscriminateDespiteCoverage = Boolean(probe) && !separation.sufficient && input.trainingGateOpen !== false; - if ( - ((!input.methodCoverageAll && !canDiscriminateDespiteCoverage) - || input.trainingGateOpen === false - || input.snapshotCurrent === false) - && !(userStopped && input.candidateScores.length > 0) - ) { + const coverageBlocks = (!input.methodCoverageAll && !canDiscriminateDespiteCoverage) + || input.trainingGateOpen === false; + if (coverageBlocks && !(userStopped && input.candidateScores.length > 0)) { return collect(separation, holdout, range, probe); } + if (input.snapshotCurrent === false) { + if (probe && !userStopped && input.trainingGateOpen !== false) { + return discriminate(separation, holdout, range, probe); + } + if (!(userStopped && input.candidateScores.length > 0)) { + return collect(separation, holdout, range, probe); + } + } if (!separation.sufficient) { if (probe && !userStopped) { return discriminate(separation, holdout, range, probe); diff --git a/frontend/src/lib/rectification-agentic/core/snapshot-source.ts b/frontend/src/lib/rectification-agentic/core/snapshot-source.ts index b818ef1a..ac184ca2 100644 --- a/frontend/src/lib/rectification-agentic/core/snapshot-source.ts +++ b/frontend/src/lib/rectification-agentic/core/snapshot-source.ts @@ -15,13 +15,15 @@ export type SnapshotStaleReason = | "birth_profile_changed" | "scoreable_evidence_changed" | "candidate_set_superseded" - | "inference_revision_changed"; + | "inference_revision_changed" + | "fingerprint_missing"; export const SNAPSHOT_STALE_COPY: Readonly> = { birth_profile_changed: "出生资料已变化,候选已失效", scoreable_evidence_changed: "可评分证据已变化,请重新比较候选", candidate_set_superseded: "已有更新的候选结果", inference_revision_changed: "候选后验已更新,请使用当前结果", + fingerprint_missing: "候选快照缺少证据指纹,请重新比较候选", }; export function classifySnapshotStaleReason( @@ -29,6 +31,9 @@ export function classifySnapshotStaleReason( current: CandidateSnapshotSource, ): SnapshotStaleReason | null { if (!snapshot) return "candidate_set_superseded"; + if (!snapshot.scoreableEvidenceFingerprint || !current.scoreableEvidenceFingerprint) { + return "fingerprint_missing"; + } if ( snapshot.birthProfileFingerprint && current.birthProfileFingerprint @@ -54,11 +59,21 @@ export function scoreableSnapshotIsCurrent( current: CandidateSnapshotSource, ): boolean { if (!snapshot) return false; + if (!snapshot.scoreableEvidenceFingerprint || !current.scoreableEvidenceFingerprint) return false; return snapshot.scoreableEvidenceFingerprint === current.scoreableEvidenceFingerprint && snapshot.candidateSetVersion === current.candidateSetVersion && snapshot.inferenceRevision === current.inferenceRevision; } +/** No stored snapshot is not stale; a stored snapshot with a missing fingerprint is. */ +export function storedSnapshotIsCurrent( + snapshot: CandidateSnapshotSource | null | undefined, + current: CandidateSnapshotSource, +): boolean { + if (!snapshot) return true; + return scoreableSnapshotIsCurrent(snapshot, current); +} + export function snapshotIsCurrent( snapshot: CandidateSnapshotSource | null | undefined, current: CandidateSnapshotSource, diff --git a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts index 0732efd4..feaa8532 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -37,6 +37,11 @@ import { } from "./evidence-model"; import { refinementFromDecisionReceipt } from "./refinement-packet"; import { windowScanFromDecisionReceipt } from "./varga-observations"; +import { evidenceLedgerFingerprint } from "./tool-service"; +import { + candidateSnapshotSource, + storedSnapshotIsCurrent, +} from "../core/snapshot-source.ts"; export type DecisionDossier = Readonly<{ evidence: readonly Readonly<{ @@ -75,6 +80,8 @@ export type DecisionDossier = Readonly<{ representativeTime?: string | null; evidenceLedgerFingerprint?: string | null; candidateRangeFingerprint?: string | null; + policyVersion?: string | null; + algorithmVersion?: string | null; } | null; case: { acceptedTime: string | null; @@ -249,6 +256,29 @@ function contrastPacketFromState(state: InferenceState): CandidateContrastPacket }); } +function scoreableSnapshotCurrentFromDossier( + dossier: DecisionDossier, + options: { currentEvidenceFingerprint?: string | null } | undefined, + inference: ReturnType, +): boolean { + const latest = dossier.latestResult; + if (!latest) return true; + const stored = candidateSnapshotSource({ + birthProfileFingerprint: latest.candidateRangeFingerprint ?? "", + scoreableEvidenceFingerprint: latest.evidenceLedgerFingerprint ?? "", + inferenceRevision: inference?.revision ?? 0, + candidateSetVersion: inference?.candidate_set_id ?? latest.resultId ?? "", + scoringPolicyVersion: String(latest.policyVersion ?? latest.algorithmVersion ?? ""), + }); + const currentFingerprint = options?.currentEvidenceFingerprint + ?? evidenceLedgerFingerprint(dossier.evidence as never); + const current = candidateSnapshotSource({ + ...stored, + scoreableEvidenceFingerprint: currentFingerprint ?? "", + }); + return storedSnapshotIsCurrent(stored, current); +} + export function decideFromDossier( dossier: DecisionDossier, options?: { currentEvidenceFingerprint?: string | null }, @@ -260,10 +290,8 @@ export function decideFromDossier( declinedTopics: dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: "collect_evidence", }); - const storedFingerprint = dossier.latestResult?.evidenceLedgerFingerprint ?? ""; - const currentFingerprint = options?.currentEvidenceFingerprint ?? storedFingerprint; - const snapshotCurrent = !storedFingerprint || storedFingerprint === currentFingerprint; const latest = dossier.latestResult; + const snapshotCurrent = scoreableSnapshotCurrentFromDossier(dossier, options, inference); const confirmationGate = buildConfirmationGate({ engineConfirmationAllowed: latest?.confirmationAllowed === true, candidates: (latest?.candidates ?? []).map((candidate) => ({ diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index eff30ca2..844d405e 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -109,7 +109,7 @@ import { offerSessionKinds } from "@/lib/rectification-agentic/core/decide-next- import { candidateSnapshotSource, classifySnapshotStaleReason, - scoreableSnapshotIsCurrent, + storedSnapshotIsCurrent, SNAPSHOT_STALE_COPY, } from "@/lib/rectification-agentic/core/snapshot-source"; import type { HoldoutValidationStatus } from "@/lib/rectification-agentic/core/decide-next-action"; @@ -253,9 +253,7 @@ function safeCaseProjection( const separation = evaluateCandidateSeparation(candidateScores); const currentSnapshot = snapshotSourceFromDossier(dossier, compute); const storedSnapshot = latest ? storedSnapshotSource(latest) : null; - const snapshotCurrent = !storedSnapshot - || !storedSnapshot.scoreableEvidenceFingerprint - || scoreableSnapshotIsCurrent(storedSnapshot, currentSnapshot); + const snapshotCurrent = storedSnapshotIsCurrent(storedSnapshot, currentSnapshot); const collectingPlan = buildMethodFollowupPlan({ evidence: dossier.evidence, activeFocus: dossier.conversationSummary.activeFocus, @@ -1886,8 +1884,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { const candidateScores = candidateScoresFromLatest(latest); const currentSnapshot = snapshotSourceFromDossier(parsed, null); const storedSnapshot = storedSnapshotSource(latest); - const scoreableCurrent = !storedSnapshot.scoreableEvidenceFingerprint - || scoreableSnapshotIsCurrent(storedSnapshot, currentSnapshot); + const scoreableCurrent = storedSnapshotIsCurrent(storedSnapshot, currentSnapshot); if (!scoreableCurrent) { throw new RectificationToolServiceError("offer_not_allowed"); } diff --git a/frontend/tests/rectification-decide-next-action.test.ts b/frontend/tests/rectification-decide-next-action.test.ts index bd7bea22..c2977cdc 100644 --- a/frontend/tests/rectification-decide-next-action.test.ts +++ b/frontend/tests/rectification-decide-next-action.test.ts @@ -16,6 +16,7 @@ import { classifySnapshotStaleReason, scoreableSnapshotIsCurrent, snapshotIsCurrent, + storedSnapshotIsCurrent, type CandidateSnapshotSource, } from "../src/lib/rectification-agentic/core/snapshot-source.ts"; import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; @@ -461,6 +462,43 @@ test("stale reasons distinguish birth profile from scoreable evidence", () => { ); }); +test("missing scoreable fingerprint is stale, not current", () => { + const current = source(); + assert.equal(scoreableSnapshotIsCurrent(source({ scoreableEvidenceFingerprint: "" }), current), false); + assert.equal(scoreableSnapshotIsCurrent(current, source({ scoreableEvidenceFingerprint: "" })), false); + assert.equal( + classifySnapshotStaleReason(source({ scoreableEvidenceFingerprint: "" }), current), + "fingerprint_missing", + ); + assert.equal(storedSnapshotIsCurrent(null, current), true); + assert.equal(storedSnapshotIsCurrent(source({ scoreableEvidenceFingerprint: "" }), current), false); + assert.equal(storedSnapshotIsCurrent(current, current), true); +}); + +test("stale snapshot keeps discrimination instead of returning to collection", () => { + const next = decideNextAction({ + methodCoverageAll: true, + trainingGateOpen: true, + snapshotCurrent: false, + candidateScores: TIED, + discriminatorProbe: CONTRAST_PROBE, + }); + assert.equal(next.type, "ask_candidate_discriminator"); + assert.equal(decideRectification({ + methodCoverageAll: true, + trainingGateOpen: true, + snapshotCurrent: false, + candidateScores: TIED, + discriminatorProbe: CONTRAST_PROBE, + }).sessionOutcome, "discriminate_candidates"); + assert.equal(decideNextAction({ + methodCoverageAll: false, + trainingGateOpen: false, + snapshotCurrent: false, + candidateScores: TIED, + }).type, "ask_fact_collection"); +}); + test("34/33/33 is a tie, not a recommended winner", () => { const separation = evaluateCandidateSeparation(TIED); assert.equal(separation.status, "not_separated"); diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index 9d84fb9f..455fed25 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -255,6 +255,72 @@ const careerEvidence = { created_at: "2026-08-12T10:00:09.000Z", }; +const methodCoverageTieEvidence = [ + { ...educationEvidence, domain: "education" }, + { + id: "44444444-4444-4444-8444-444444444442", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "relationship_start", + domain: "relationship", + occurred_from: "2018-01-01", + occurred_to: null, + date_precision: "year", + summary: "感情变化", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-08-12T10:00:07.000Z", + }, + { + id: "44444444-4444-4444-8444-444444444443", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "career_entry", + domain: "career", + occurred_from: "2019-01-01", + occurred_to: null, + date_precision: "year", + summary: "工作变化", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-08-12T10:00:08.000Z", + }, + { + id: "44444444-4444-4444-8444-444444444446", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "family_event", + domain: "family", + occurred_from: "2020-01-01", + occurred_to: null, + date_precision: "year", + summary: "家人变化", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-08-12T10:00:09.000Z", + }, + { + id: "44444444-4444-4444-8444-444444444445", + source_turn_id: TURN_ID, + subject: "self", + event_kind: "occupation_note", + domain: "occupation", + occurred_from: null, + occurred_to: null, + date_precision: "unknown", + summary: "长期一直是程序员", + status: "confirmed", + supersedes_evidence_id: null, + created_at: "2026-08-12T10:00:10.000Z", + }, +]; + +function scoreableFingerprintForRawEvidence(evidence: unknown[]): string { + const parsed = parseV9CaseDossier(dossierFixture({ evidence })); + assert.ok(parsed); + return evidenceLedgerFingerprint(parsed.evidence); +} + function stubEngine(response: unknown) { const previous = globalThis.fetch; globalThis.fetch = (async () => ({ @@ -2690,6 +2756,7 @@ test("offer-candidates refuses while method coverage remains", async () => { latestResult: candidateSnapshotFixture({ selectionAllowed: true, representativeTime: "04:48", + evidenceLedgerFingerprint: scoreableFingerprintForRawEvidence([educationEvidence]), candidates: [ { candidate_id: CANDIDATE_ID, rank: 1, time: "04:48", relative_support: 58, tied_minute_count: 1 }, { candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "04:49", relative_support: 42, tied_minute_count: 2 }, @@ -2727,7 +2794,7 @@ test("paused case with selection_allowed may offer the escape hatch", async () = latestResult: candidateSnapshotFixture({ selectionAllowed: true, representativeTime: "04:48", - evidenceLedgerFingerprint: null, + evidenceLedgerFingerprint: scoreableFingerprintForRawEvidence([educationEvidence]), candidates: [ { candidate_id: CANDIDATE_ID, rank: 1, time: "04:48", relative_support: 58, tied_minute_count: 1 }, { candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "04:49", relative_support: 42, tied_minute_count: 2 }, @@ -2781,6 +2848,37 @@ test("paused case with selection_allowed may offer the escape hatch", async () = ); }); +test("offer-candidates refuses a stored snapshot with a missing fingerprint", async () => { + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => dossierFixture({ + status: "paused", + evidence: [educationEvidence], + latestResult: candidateSnapshotFixture({ + selectionAllowed: true, + representativeTime: "04:48", + evidenceLedgerFingerprint: null, + candidates: [ + { candidate_id: CANDIDATE_ID, rank: 1, time: "04:48", relative_support: 58, tied_minute_count: 1 }, + { candidate_id: SECOND_CANDIDATE_ID, rank: 2, time: "04:49", relative_support: 42, tied_minute_count: 2 }, + ], + }), + }), + }); + const tools = createRectificationV9Tools({ + userId: USER_ID, + caseId: CASE_ID, + turnId: TURN_ID, + accounting: accounting.client as never, + }); + await assert.rejects( + () => (tools["rectification-offer-candidates"] as unknown as { + execute(input: unknown): Promise; + }).execute({ caseId: CASE_ID }), + (error: unknown) => error instanceof RectificationToolServiceError && error.code === "offer_not_allowed", + ); +}); + test("paused case resumes only when the Agent explicitly requests it", async () => { let reads = 0; const accounting = fakeAccounting({ @@ -2819,69 +2917,11 @@ test("offer-candidates allows a 34/33/33 tie after method coverage when remainin const accounting = fakeAccounting({ ...receiptHandlers, get_agentic_rectification_case_dossier: () => dossierFixture({ - evidence: [ - { ...educationEvidence, domain: "education" }, - { - id: "44444444-4444-4444-8444-444444444442", - source_turn_id: TURN_ID, - subject: "self", - event_kind: "relationship_start", - domain: "relationship", - occurred_from: "2018-01-01", - occurred_to: null, - date_precision: "year", - summary: "感情变化", - status: "confirmed", - supersedes_evidence_id: null, - created_at: "2026-08-12T10:00:07.000Z", - }, - { - id: "44444444-4444-4444-8444-444444444443", - source_turn_id: TURN_ID, - subject: "self", - event_kind: "career_entry", - domain: "career", - occurred_from: "2019-01-01", - occurred_to: null, - date_precision: "year", - summary: "工作变化", - status: "confirmed", - supersedes_evidence_id: null, - created_at: "2026-08-12T10:00:08.000Z", - }, - { - id: "44444444-4444-4444-8444-444444444446", - source_turn_id: TURN_ID, - subject: "self", - event_kind: "family_event", - domain: "family", - occurred_from: "2020-01-01", - occurred_to: null, - date_precision: "year", - summary: "家人变化", - status: "confirmed", - supersedes_evidence_id: null, - created_at: "2026-08-12T10:00:09.000Z", - }, - { - id: "44444444-4444-4444-8444-444444444445", - source_turn_id: TURN_ID, - subject: "self", - event_kind: "occupation_note", - domain: "occupation", - occurred_from: null, - occurred_to: null, - date_precision: "unknown", - summary: "长期一直是程序员", - status: "confirmed", - supersedes_evidence_id: null, - created_at: "2026-08-12T10:00:10.000Z", - }, - ], + evidence: methodCoverageTieEvidence, latestResult: candidateSnapshotFixture({ selectionAllowed: true, representativeTime: "05:00", - evidenceLedgerFingerprint: null, + evidenceLedgerFingerprint: scoreableFingerprintForRawEvidence(methodCoverageTieEvidence), decisionReceipt: { propose_allowed: true, window_scan: {