fix(rectification): stop a stale candidate-set prefix from hiding the choice card
Independent Staging Quality Gate / validate (push) Successful in 11m31s
Independent Staging Quality Gate / publish (push) Successful in 14m41s

Scoring scoped the contrast packet to the previous round's candidate set while
minting probes for the new one, so every later read re-prefixed the stored hash
and the persisted focus schema could never match. Mint one prefix per set and
compare splits by probe identity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVapmh2oGNyr6ECHKjPJY8
This commit is contained in:
Jesse_Chen
2026-08-29 08:51:19 +00:00
parent 0044d4f358
commit 3cecb25f42
6 changed files with 276 additions and 8 deletions
+16
View File
@@ -6686,3 +6686,19 @@
- 复发自:无
- 修复版本:待发布
## BUG-437 | 区分卡因过期候选集前缀被二次加前缀,界面无卡且 Agent 只承接不提问
- 状态:resolved
- 首次发现:2026-08-29
- 最近更新:2026-08-29
- 影响面:`probeFromEngine``candidate_split_hash` 归一化、`projectRectificationChoiceCard` 身份校验、`scoreAndPersistCurrentEvidence` 组包的 `candidateSetVersion`、GET `choice_card`
- 用户现象:`current_question` 是合法选择题(`kind=choice``focus_id``probe_id` 齐全、题干已生成),`choice_card` 却是 null。Agent 按提示第 4 条只承接不复述,正文只剩「请你凭印象选一个」,界面没有卡片,用户无法继续。`dropped_probes` 为空,看不出被抑制的原因。
- 触发条件:某一轮打分改变了候选分钟列表(例如 05:05 换成 05:00),此前铸出的 varga 对比探针仍带旧候选集前缀,随后任意一次读路径重建对比包。
- 根因:`scoreAndPersistCurrentEvidence` 组对比包时 `candidateSetVersion` 取上一轮 `inference_state.candidate_set_id``candidateTimes` 取本轮 `score.candidates`,于是新写入的探针 hash 带着旧集合前缀,而同一份 state 的 `candidate_set_id` 按新候选生成。之后 `probeFromEngine``hash.includes(candidateSetVersion)` 判断是否已有前缀,旧前缀不含新集合 id,于是再加一层前缀得到双前缀 hash。落 focus 时 `stampChoiceSchemaWithProbe` 写的是 state 里探针的单前缀原值,`projectRectificationChoiceCard``candidate_split_hash` 全等校验因此永远不成立,直接 return null,且不记入任何丢弃账本。
- 修复:新增 `splitHashForCandidateSet`hash 为 `<候选集 id>:<语义键>` 形态时按当前候选集重铸,已带当前前缀时保持不变,不透明引擎 hash 只加一次前缀,杜绝前缀叠加。新增 `isSameCandidateSplit`,两侧同为 `<任意候选集 id>:<同一语义键>` 时视为同一 split(语义键本身已编码候选分组),`projectRectificationChoiceCard` 改用它做身份校验,语义键不同或不透明 hash 不同仍然 fail closed。组包 `candidateSetVersion` 改为按本轮 `candidateRange``score.candidates` 计算的 `candidateSetId`,与写入 state 的候选集同源。Skill 版本保持 `10.0.13`,未放宽 confirmation gate。
- 验证:`frontend/tests/rectification-choice-card.test.ts` 锁定「探针 hash 带旧候选集前缀时 GET 仍返回卡片」(去掉修复即失败)与「持久化 split 属于另一探针时仍隐藏」;`rectification-candidate-contrast-packet.test.ts` 锁定前缀重铸幂等、不透明 hash 只加一次、split 身份忽略前缀但不忽略探针、组包按本轮候选集作用域。`npx tsc --noEmit``npx tsx --test tests/rectification-*.test.ts tests/birth-time-rectification-contract.test.ts tests/agentic-rectification-*.test.ts` 658 项 0 失败;全量 `npx tsx --test tests/*.test.ts` 失败集合与改动前一致(23 项数据库/部署环境类)。
- 防复发:探针 `candidate_split_hash` 只能有一层候选集前缀,任何读路径不得对已带前缀的 hash 再加前缀。打分写入的对比包必须与本轮候选集同源,不得沿用上一轮 `candidate_set_id`。卡片身份校验不得因候选集前缀差异静默吞掉合法卡片。
- 相关记录:BUG-410、BUG-411、BUG-432
- 复发自:无
- 修复版本:待发布
@@ -667,6 +667,46 @@ function inferredContrastChoiceKind(
return "existence";
}
/**
* Split hashes are `<candidate set id>:<semantic key>` for varga contrasts and
* `<candidate set id>:<opaque engine hash>` for dated event probes. A stored
* probe can carry the set id of an earlier scoring round; re-prefixing such a
* hash would stack prefixes and make the persisted focus schema unmatchable, so
* a stale set prefix is re-minted against the current set instead.
*/
export function splitHashForCandidateSet(
rawHash: string | null | undefined,
semanticKey: string,
candidateSetVersion: string,
): string {
const scoped = `${candidateSetVersion}:${semanticKey}`;
const hash = rawHash?.trim() ?? "";
if (!hash) return scoped;
if (hash === scoped) return hash;
if (hash.endsWith(`:${semanticKey}`)) return scoped;
if (hash.startsWith(`${candidateSetVersion}:`)) return hash;
return `${candidateSetVersion}:${hash}`;
}
/**
* Two hashes describe the same split when they are equal, or when both are
* `<some candidate set id>:<the same semantic key>`. The semantic key of a
* varga contrast already encodes its candidate groups, so only the set prefix
* can differ, and a stale prefix must not hide an otherwise valid card.
*/
export function isSameCandidateSplit(
left: string | null | undefined,
right: string | null | undefined,
semanticKey: string | null | undefined,
): boolean {
const a = left?.trim() ?? "";
const b = right?.trim() ?? "";
if (a === b) return true;
const key = semanticKey?.trim() ?? "";
if (!key || !a || !b) return false;
return a.endsWith(`:${key}`) && b.endsWith(`:${key}`);
}
function probeFromEngine(
probe: EngineContrastProbe,
candidateSetVersion: string,
@@ -684,11 +724,11 @@ function probeFromEngine(
if (outcomes.length < 2) return null;
if ((probe.information_gain ?? 0) <= 0) return null;
const semanticKey = probe.semantic_key ?? `${probe.domain ?? "career"}.${probe.year ?? "contrast"}`;
const split = probe.candidate_split_hash
? (probe.candidate_split_hash.includes(candidateSetVersion)
? probe.candidate_split_hash
: `${candidateSetVersion}:${probe.candidate_split_hash}`)
: `${candidateSetVersion}:${semanticKey}`;
const split = splitHashForCandidateSet(
probe.candidate_split_hash,
semanticKey,
candidateSetVersion,
);
const question = probe.question ?? probe.user_meaning ?? "";
if (!question.trim()) return null;
const candidateIds = [...new Set(outcomes.flatMap((row) => [
@@ -63,6 +63,7 @@ import { decideRectification, type HoldoutValidationStatus } from "../core/recti
import {
datedDomainsFromEvidence,
isStructuredDiscriminator,
isSameCandidateSplit,
mentionedVargaKeysFromLedgerEvidence,
vargaLayerCovered,
vargaLayerFromSemanticKey,
@@ -1818,7 +1819,14 @@ export function projectRectificationChoiceCard(
: null;
if (input.activeFocus?.intent !== followup.intent) return null;
if (followup.semantic_key && schemaRow?.semantic_key !== followup.semantic_key) return null;
if (followup.candidate_split_hash && schemaRow?.candidate_split_hash !== followup.candidate_split_hash) return null;
if (
followup.candidate_split_hash
&& !isSameCandidateSplit(
typeof schemaRow?.candidate_split_hash === "string" ? schemaRow.candidate_split_hash : null,
followup.candidate_split_hash,
followup.semantic_key,
)
) return null;
if (
!followup.semantic_key
&& !followup.candidate_split_hash
+10 -2
View File
@@ -105,6 +105,7 @@ import {
inspectDiscriminatorProbes,
mentionedVargaKeysFromLedgerEvidence,
} from "@/lib/rectification-agentic/core/candidate-contrast-packet";
import { candidateSetId } from "@/lib/rectification-agentic/core/build-state";
import { offerSessionKinds } from "@/lib/rectification-agentic/core/decide-next-action";
import {
candidateSnapshotSource,
@@ -882,9 +883,16 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) {
});
const refinement = refinementFromDecisionReceipt(receipt);
const windowScan = windowScanFromDecisionReceipt(receipt);
// The packet must be scoped to the candidate set this round produced. Using
// the previous round's set id mints split hashes that no later read can
// match, which silently suppresses the persisted choice card.
const scoredCandidateSetId = candidateSetId(
parsed.case.candidateRange.start_time,
parsed.case.candidateRange.end_time,
score.candidates.map((item) => item.time),
);
const contrastPacket = buildCandidateContrastPacket({
candidateSetVersion: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null)?.candidate_set_id
?? score.engineResultId,
candidateSetVersion: scoredCandidateSetId,
calculationResultId: score.engineResultId,
engineProbes: refinement.discriminating_event_probes,
vargaDifferences: [
@@ -1,11 +1,15 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import {
buildCandidateContrastPacket,
isSameCandidateSplit,
mentionedVargaKeysFromLedgerEvidence,
remainingVargaSplits,
selectDiscriminatorProbe,
splitHashForCandidateSet,
volunteeredDomainsFromEvidence,
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
@@ -167,3 +171,62 @@ test("occupation mention does not skip remaining D10; answered varga.d10 still d
});
assert.equal(answeredPacket.probes.some((item) => item.semanticKey.startsWith("varga.d10.")), false);
});
test("a stale candidate-set prefix is re-minted instead of stacked", () => {
const current = "05:00-05:07:05:00,05:06,05:07";
const stale = "05:00-05:07:05:00,05:05,05:07";
const key = "varga.d9.05:00|05:06/05:07";
assert.equal(splitHashForCandidateSet(`${stale}:${key}`, key, current), `${current}:${key}`);
assert.equal(splitHashForCandidateSet(`${current}:${key}`, key, current), `${current}:${key}`);
assert.equal(splitHashForCandidateSet(null, key, current), `${current}:${key}`);
// opaque engine hashes stay scoped exactly once
assert.equal(splitHashForCandidateSet("f4cc4975", "career.2023", current), `${current}:f4cc4975`);
assert.equal(
splitHashForCandidateSet(`${current}:f4cc4975`, "career.2023", current),
`${current}:f4cc4975`,
);
});
test("engine probes keep one candidate-set prefix after the set changes", () => {
const current = "05:00-05:07:05:00,05:06,05:07";
const stale = "05:00-05:07:05:00,05:05,05:07";
const key = "varga.d9.05:00|05:06/05:07";
const packet = buildCandidateContrastPacket({
candidateSetVersion: current,
candidateTimes: ["05:00", "05:06", "05:07"],
engineProbes: [{
semantic_key: key,
candidate_split_hash: `${stale}:${key}`,
domain: "relationship",
user_meaning: "亲密关系里更接近下面哪一种相处方式?",
information_gain: 1.53,
choice_kind: "varga_style",
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:06", "05:07"] },
{ answer_class: "no", supports: ["05:07"], conflicts: ["05:00", "05:06"] },
],
}],
});
const probe = packet.probes.find((item) => item.semanticKey === key);
assert.ok(probe);
assert.equal(probe.candidateSplitHash, `${current}:${key}`);
assert.doesNotMatch(probe.candidateSplitHash, /05:05/);
});
test("split identity ignores the candidate-set prefix but not the probe", () => {
const key = "varga.d9.05:00|05:06/05:07";
assert.equal(isSameCandidateSplit(`a:${key}`, `b:${key}`, key), true);
assert.equal(isSameCandidateSplit(`a:${key}`, `a:varga.d10.05:00|05:06/05:07`, key), false);
assert.equal(isSameCandidateSplit("f4cc4975", "a:f4cc4975", "career.2023"), false);
assert.equal(isSameCandidateSplit("f4cc4975", "f4cc4975", "career.2023"), true);
});
test("scoring scopes the contrast packet to the candidate set it just produced", () => {
const source = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
const packetCall = source.slice(
source.indexOf("const scoredCandidateSetId = candidateSetId("),
source.indexOf("const inference = buildCaseInferenceState("),
);
assert.ok(packetCall.includes("candidateSetVersion: scoredCandidateSetId"));
assert.doesNotMatch(packetCall, /candidateSetVersion:\s*previousInferenceFromReceipt/);
});
@@ -1376,3 +1376,136 @@ test("dasha existence cards lock the event and use supplied dynamic labels", ()
assert.equal(copy.option_a, DYNAMIC_STYLE_OPTIONS[0].label);
assert.equal(copy.option_c, DYNAMIC_STYLE_OPTIONS[2].label);
});
const D9_STALE_KEY = "varga.d9.05:00|05:06/05:07";
const CURRENT_SET_ID = "05:00-05:07:05:00,05:06,05:07";
// The probe was minted while the candidate set still held 05:05 instead of 05:06.
const STALE_SET_ID = "05:00-05:07:05:00,05:05,05:07";
const D9_STYLE_OPTIONS = [
{ sign: "天秤座", label: "和谐、美感、合作", answer_class: "yes" },
{ sign: "天蝎座", label: "深刻、占有欲、转化", answer_class: "weak_yes" },
{ sign: "射手座", label: "自由、哲学、冒险", answer_class: "no" },
{ label: "这段记不清楚", answer_class: "unsure" },
] as const;
function staleSplitDossier(schemaHash: string) {
return {
evidence: [
{ id: "e-career-a", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2020-04-01", occurredTo: "2020-04-01", eventKind: "career_entry" },
{ id: "e-career-b", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2020-10-01", occurredTo: "2020-10-01", eventKind: "career_exit" },
{ id: "e-rel-end", status: "confirmed", domain: "relationship", datePrecision: "day", occurredFrom: "2024-08-08", occurredTo: "2024-08-08", eventKind: "relationship_end" },
{ id: "e-rel-start", status: "confirmed", domain: "relationship", datePrecision: "month", occurredFrom: "2024-05-01", occurredTo: "2024-05-31", eventKind: "relationship_start" },
],
conversationSummary: {
activeFocus: {
id: FOCUS_ID,
questionId: `probe:${D9_STALE_KEY}`,
intent: "distinguish_candidates",
targetDomain: "relationship",
targetKind: null,
expectedAnswerSchema: {
choice: {
prompt: "2024 年前后,相处方式更接近其中一种?",
option_a: D9_STYLE_OPTIONS[0].label,
option_b: D9_STYLE_OPTIONS[1].label,
option_c: D9_STYLE_OPTIONS[2].label,
option_d: D9_STYLE_OPTIONS[3].label,
options: [
{ key: "A", label: D9_STYLE_OPTIONS[0].label, answer_class: "yes" },
{ key: "B", label: D9_STYLE_OPTIONS[1].label, answer_class: "weak_yes" },
{ key: "C", label: D9_STYLE_OPTIONS[2].label, answer_class: "no" },
{ key: "D", label: D9_STYLE_OPTIONS[3].label, answer_class: "unsure" },
],
},
probe_id: `contrast:${D9_STALE_KEY}`,
semantic_key: D9_STALE_KEY,
candidate_split_hash: schemaHash,
choice_kind: "varga_style",
},
},
declinedSkippedTopics: [],
},
latestResult: {
resultId: "66666666-6666-4666-8666-666666666666",
selectionAllowed: false,
candidates: [
{ time: "05:00", relativeSupport: 34 },
{ time: "05:06", relativeSupport: 33 },
{ time: "05:07", relativeSupport: 33 },
],
decisionReceipt: {
window_scan: {
scanned: true,
d9_candidates_differ: true,
d9_sign_names: ["天秤座", "天蝎座", "射手座"],
transitions: [
{ layer: "d9", at: "05:06", from_sign: "天秤座", to_sign: "天蝎座" },
{ layer: "d9", at: "05:07", from_sign: "天蝎座", to_sign: "射手座" },
],
},
inference_state: {
algorithm_version: "rectification-inference-v1",
candidate_set_id: CURRENT_SET_ID,
revision: 1,
phase: "discrimination",
result_status: "discriminating",
range_start: "05:00",
range_end: "05:07",
candidates: [
{ id: "05:00", time: "05:00", cluster_range: ["05:00", "05:00"], prior_score: 34, posterior_score: 34, probability: 0.34, status: "active", rank: 1, strong_conflict_count: 0 },
{ id: "05:06", time: "05:06", cluster_range: ["05:06", "05:06"], prior_score: 33, posterior_score: 33, probability: 0.33, status: "active", rank: 2, strong_conflict_count: 0 },
{ id: "05:07", time: "05:07", cluster_range: ["05:07", "05:07"], prior_score: 33, posterior_score: 33, probability: 0.33, status: "active", rank: 3, strong_conflict_count: 0 },
],
events: [
{ id: "e-career-a", domain: "career", year: 2020, precision: "month", usage: "training" },
{ id: "e-career-b", domain: "career", year: 2020, precision: "month", usage: "training" },
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day", usage: "training" },
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month", usage: "holdout" },
],
answered_probes: [],
probes: [{
id: `contrast:${D9_STALE_KEY}`,
semantic_key: D9_STALE_KEY,
// minted one scoring round earlier, so the prefix names the old set
candidate_split_hash: `${STALE_SET_ID}:${D9_STALE_KEY}`,
domain: "relationship",
year: 0,
question: "亲密关系里更接近下面哪一种相处方式?",
candidate_ids: ["05:00", "05:06", "05:07"],
information_gain: 1.53,
source: "varga_contrast",
choice_kind: "varga_style",
style_options: D9_STYLE_OPTIONS,
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:06", "05:07"] },
{ answer_class: "weak_yes", supports: ["05:06"], conflicts: ["05:00", "05:07"] },
{ answer_class: "no", supports: ["05:07"], conflicts: ["05:00", "05:06"] },
],
}],
rounds: [],
entropy: 1.5,
representative_time: "05:00",
credible_range: ["05:00", "05:07"],
},
},
},
case: { acceptedTime: null },
turns: [],
};
}
test("GET still renders the card when the probe hash carries an older candidate set prefix", () => {
const card = choiceCardFromCaseDossier(
staleSplitDossier(`${STALE_SET_ID}:${D9_STALE_KEY}`) as never,
);
assert.ok(card, "a stale candidate-set prefix must not silence the persisted card");
assert.equal(card.probe_id, `contrast:${D9_STALE_KEY}`);
assert.equal(card.question_id, `probe:${D9_STALE_KEY}`);
});
test("GET keeps hiding the card when the persisted split belongs to another probe", () => {
const card = choiceCardFromCaseDossier(
staleSplitDossier(`${CURRENT_SET_ID}:varga.d10.05:00|05:06/05:07`) as never,
);
assert.equal(card, null);
});