fix(rectification): fail-close snapshot fingerprints without rewinding discrimination
Independent Staging Quality Gate / validate (push) Successful in 11m24s
Independent Staging Quality Gate / publish (push) Successful in 15m48s

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 <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-28 18:42:18 +08:00
parent 0eb740a6b8
commit 30d8c78d19
7 changed files with 216 additions and 77 deletions
+16
View File
@@ -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
@@ -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);
@@ -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<Record<SnapshotStaleReason, string>> = {
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,
@@ -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<typeof previousInferenceFromReceipt>,
): 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) => ({
@@ -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");
}
@@ -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");
+101 -61
View File
@@ -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<unknown>;
}).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: {