From 87f32d596f7e466d3067445fb1865b37b0029c56 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 29 Aug 2026 01:45:39 +0800 Subject: [PATCH] test(rectification): lock holdout cards, style-kind scoring, and strong B conflicts Ask-holdout now requires a renderable choice frame. Scoring uses the same effective contrast kind as rendering, because the engine-varga.d9/d10 fork was live. varga_style weak_yes counts as a strong conflict; existence weak_yes still does not. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 16 ++-- .../core/candidate-contrast-packet.ts | 9 ++- .../rectification-holdout-renderable.test.ts | 1 + .../rectification-varga-style-weight.test.ts | 74 +++++++++++++++++++ progress.md | 6 ++ 5 files changed, 94 insertions(+), 12 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 8d4973e3..77d15f93 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6449,14 +6449,14 @@ - 状态:resolved - 首次发现:2026-08-28 -- 最近更新:2026-08-28 +- 最近更新:2026-08-29 - 影响面:`ConflictProbe.choice_kind`、`conflictProbesFromContrast`、`applyProbeOutcome` / `directionFor` - 用户现象:D9/D10 风格点选里,选项是并列的盘面风格,不是“有/弱有”。选后一组只得一半分,更早分钟被系统性抬高。 - 触发条件:剩余候选按 varga 风格分组出题;组 0 映射 `yes`(±2),组 1 映射 `weak_yes`(±1)。 -- 根因:`directionFor` 只看 `answer_class`。风格题复用 `weak_yes` 当第二组标签,却走了存在题的半权。 -- 修复:探针带显式 `choice_kind`。`varga_style` 各组满权 ±2,`unsure` 仍为 0。存在题 / `event_quality` 的 `weak_yes` 仍半权且一次作答不淘汰。缺字段的旧收据保持旧分。不改 `SCORE_DELTA` 数值。 -- 验证:`rectification-varga-style-weight` 锁定两组/三组风格等权、旧收据无 `choice_kind` 仍半权、replay 时 `candidate_set_id` 不变且 `revision` 单调。`rectification-distinguish-contract` 锁定存在题 `weak_yes` 半权。`rectification-coverage-collect` 锁定分数已拉开且家人/职业未覆盖时仍停在采集,且采集下一问是未覆盖的 blocking method;补齐或拒绝家人+职业后离开采集;`canConfirmExactMinute === false`。 -- 防复发:不得从 `semantic_key` 前缀猜测风格题权重。不得把存在题 `weak_yes` 改成淘汰或反向。旧收据缺 `choice_kind` 必须仍能加载。不得打开 unique-minute 门。 +- 根因:`directionFor` 只看 `answer_class`。风格题复用 `weak_yes` 当第二组标签,却走了存在题的半权。引擎若发来无 `style_options` 的 `varga.d9`/`varga.d10` 题,打分仍用原始 `choiceKind`,渲染已降为 `existence`。 +- 修复:探针带显式 `choice_kind`。`varga_style` 各组满权 ±2,`unsure` 仍为 0。存在题 / `event_quality` 的 `weak_yes` 仍半权且一次作答不淘汰。缺字段的旧收据保持旧分。不改 `SCORE_DELTA` 数值。`conflictProbesFromContrast` 改用 `effectiveContrastChoiceKind`,与渲染降级一致。 +- 验证:`rectification-varga-style-weight` 锁定两组/三组风格等权、旧收据无 `choice_kind` 仍半权、replay 时 `candidate_set_id` 不变且 `revision` 单调;`varga_style` 的 `weak_yes` 计入 `strong_conflict_count` 且连答 3 次可淘汰对面分组;引擎 `varga.d9`/`varga.d10` 无 `style_options` 时打分 `choice_kind` 与渲染 effective kind 一致。`rectification-distinguish-contract` 锁定存在题 `weak_yes` 半权。`rectification-coverage-collect` 锁定分数已拉开且家人/职业未覆盖时仍停在采集,且采集下一问是未覆盖的 blocking method;补齐或拒绝家人+职业后离开采集;`canConfirmExactMinute === false`。 +- 防复发:不得从 `semantic_key` 前缀猜测风格题权重。不得把存在题 `weak_yes` 改成淘汰或反向。`varga_style` 的 B 选项 `weak_yes` 必须与 A 一样计入强冲突。打分用的 `choice_kind` 必须等于渲染用的 effective kind。旧收据缺 `choice_kind` 必须仍能加载。不得打开 unique-minute 门。 - 相关记录:BUG-419、BUG-410 - 复发自:无 - 修复版本:待发布 @@ -6465,14 +6465,14 @@ - 状态:resolved - 首次发现:2026-08-28 -- 最近更新:2026-08-28 +- 最近更新:2026-08-29 - 影响面:`holdoutStatusFromInference` / `holdoutStatusFromState`、`decideFromDossier`、holdout 点选卡 - 用户现象:保留的 holdout 后来变成未知精度、没有年份后,决策仍要 `ask_holdout_validation`,出题计划却给不出可渲染卡片。 - 触发条件:`stickyHoldoutEvents` 把已保留 holdout 钉住;该事件 `year === null` 或 `precision: "unknown"`;或 `validate_holdout` 时既没有 `oosBlindPrompts` 也没有带年份的 holdout。 - 根因:holdout 状态只看 `usage === "holdout"`。能否出题看的是带年份的事件或 oos 提示。两套口径不一致。 - 修复:抽出共用 helper:能问(oos 提示或 dated holdout)才是 `not_started`;否则 `unavailable`。`unavailable` 且用户未停走既有 `adopt_representative`,不当成 `passed`。带年份 holdout 追问补上存在题选项和 `probe_year`,好渲染 choice frame。不改 `applyHoldoutAnswer`,不改 stickiness。 -- 验证:`rectification-holdout-renderable` 锁定未知精度 sticky holdout 不进入 `ask_holdout_validation` 且可 `ready_to_adopt`;带日期 holdout 有可渲染 `choice_frame`;通过为 `validated_range`;失败路径不变;`nextAction === ask_holdout_validation` 时 followup 非空。`canConfirmExactMinute === false`。 -- 防复发:不得只凭 `usage === "holdout"` 打开核对。不得把 `unavailable` 当成已通过。不得打开 unique-minute 门。 +- 验证:`rectification-holdout-renderable` 锁定未知精度 sticky holdout 不进入 `ask_holdout_validation` 且可 `ready_to_adopt`;带日期 holdout 有可渲染 `choice_frame`;通过为 `validated_range`;失败路径不变;`nextAction === ask_holdout_validation` 时 followup 与 `choice_frame` 均非空。`canConfirmExactMinute === false`。 +- 防复发:不得只凭 `usage === "holdout"` 打开核对。不得把 `unavailable` 当成已通过。`ask_holdout_validation` 必须能渲染出卡(`next_followup` 与 `choice_frame` 均非空)。不得打开 unique-minute 门。 - 相关记录:BUG-424、BUG-410 - 复发自:无 - 修复版本:待发布 diff --git a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts index 6c67cef3..854db58d 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts @@ -591,6 +591,7 @@ export function conflictProbesFromContrast( ...row.conflictsCandidateIds, ]))]; if (candidateIds.length < 2) return []; + const choiceKind = effectiveContrastChoiceKind(probe); return [{ id: probe.probeId, semantic_key: probe.semanticKey, @@ -602,10 +603,10 @@ export function conflictProbesFromContrast( expected_outcomes: outcomes, information_gain: probe.informationGain, source: "varga_contrast", - ...(probe.choiceKind === "varga_style" - || probe.choiceKind === "event_quality" - || probe.choiceKind === "existence" - ? { choice_kind: probe.choiceKind } + ...(choiceKind === "varga_style" + || choiceKind === "event_quality" + || choiceKind === "existence" + ? { choice_kind: choiceKind } : {}), }]; }); diff --git a/frontend/tests/rectification-holdout-renderable.test.ts b/frontend/tests/rectification-holdout-renderable.test.ts index 299b2455..be82999d 100644 --- a/frontend/tests/rectification-holdout-renderable.test.ts +++ b/frontend/tests/rectification-holdout-renderable.test.ts @@ -207,6 +207,7 @@ test("ask_holdout_validation is only returned when the same dossier can build a if (decision.nextAction !== "ask_holdout_validation") continue; const plan = holdoutFollowup(dossier); assert.ok(plan.next_followup, "ask_holdout_validation requires a holdout followup"); + assert.ok(plan.next_followup.choice_frame, "ask_holdout_validation requires a renderable card"); assert.equal(decision.canConfirmExactMinute, false); } }); diff --git a/frontend/tests/rectification-varga-style-weight.test.ts b/frontend/tests/rectification-varga-style-weight.test.ts index daf8db65..3b222260 100644 --- a/frontend/tests/rectification-varga-style-weight.test.ts +++ b/frontend/tests/rectification-varga-style-weight.test.ts @@ -6,6 +6,7 @@ import { buildInferenceState } from "../src/lib/rectification-agentic/core/build import { buildCandidateContrastPacket, conflictProbesFromContrast, + inspectDiscriminatorProbes, } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts"; import { asInferenceState } from "../src/lib/rectification-agentic/core/compose-receipt.ts"; import { decisionStateFingerprint } from "../src/lib/rectification-agentic/core/decision-fingerprint.ts"; @@ -118,6 +119,79 @@ test("old ConflictProbe receipts without choice_kind keep half-weight weak_yes", assert.equal(loaded.revision, state.revision); }); +test("varga_style B-option weak_yes counts as strong conflict; existence weak_yes does not", () => { + const style = styleProbeFrom(twoGroupStylePacket()); + assert.equal(style.choice_kind, "varga_style"); + const styleConflicted = style.expected_outcomes.find((row) => row.answer_class === "weak_yes")?.conflicts[0]; + assert.ok(styleConflicted); + let styleScores = Object.fromEntries(style.candidate_ids.map((id) => [id, 10])); + let styleCounts: Record = {}; + let eliminated = new Set(); + for (let round = 0; round < 3; round += 1) { + const applied = applyProbeOutcome(styleScores, style, "weak_yes", { + strongConflictCounts: styleCounts, + eliminatedIds: eliminated, + }); + styleScores = { ...applied.scores }; + styleCounts = { ...applied.strong_conflict_counts }; + eliminated = new Set(applied.eliminated_ids); + } + assert.equal(styleCounts[styleConflicted], 3); + assert.equal(eliminated.has(styleConflicted), true); + + const existencePacket = buildCandidateContrastPacket({ + candidateSetVersion: "05:00-05:04", + candidateTimes: ["05:00", "05:04"], + transitions: [{ layer: "d9", at: "05:04" }], + }); + const existence = conflictProbesFromContrast(existencePacket).find((item) => item.choice_kind === "existence"); + assert.ok(existence); + const existenceConflicted = existence.expected_outcomes.find((row) => row.answer_class === "weak_yes")?.conflicts[0]; + assert.ok(existenceConflicted); + let existenceScores = Object.fromEntries(existence.candidate_ids.map((id) => [id, 10])); + let existenceCounts: Record = {}; + for (let round = 0; round < 3; round += 1) { + const applied = applyProbeOutcome(existenceScores, existence, "weak_yes", { + strongConflictCounts: existenceCounts, + }); + existenceScores = { ...applied.scores }; + existenceCounts = { ...applied.strong_conflict_counts }; + assert.equal(applied.eliminated_ids.length, 0); + } + assert.equal(existenceCounts[existenceConflicted], 0); +}); + +test("engine varga.d9/d10 without style_options scores with the render effective kind", () => { + for (const semanticKey of ["varga.d9", "varga.d10"] as const) { + const packet = { + candidateSetVersion: "05:00-05:04", + vargaDifferences: [], + probes: [{ + probeId: `contrast:${semanticKey}.engine-no-signs`, + candidateSetVersion: "05:00-05:04", + question: "那几年相处更接近哪一种?", + expectedOutcomes: [ + { outcomeId: "supports_05:00", supportsCandidateIds: ["05:00"], conflictsCandidateIds: ["05:04"] }, + { outcomeId: "supports_05:04", supportsCandidateIds: ["05:04"], conflictsCandidateIds: ["05:00"] }, + ], + candidateSplitHash: `05:00-05:04:${semanticKey}.engine-no-signs`, + informationGain: 1.2, + sourceFeatures: [{ technique: semanticKey.slice(6).toUpperCase(), calculationResultId: null }], + domain: "relationship", + year: null, + semanticKey, + choiceKind: "varga_style" as const, + }], + }; + const scored = conflictProbesFromContrast(packet); + const rendered = inspectDiscriminatorProbes(packet).selected; + assert.equal(scored.length, 1, semanticKey); + assert.ok(rendered, semanticKey); + assert.equal(scored[0]?.choice_kind, rendered.choiceKind, semanticKey); + assert.equal(rendered.choiceKind, "existence", semanticKey); + } +}); + test("replaying a varga_style probe keeps candidate_set_id and a monotonic revision", () => { const probe = styleProbeFrom(twoGroupStylePacket()); const before = buildInferenceState({ diff --git a/progress.md b/progress.md index 7a8f9fd2..d21c9400 100644 --- a/progress.md +++ b/progress.md @@ -1054,3 +1054,9 @@ - 校验器:带曝光记录不得过盲测/密封用途;只有 `fresh_biography_audit` 满足人审安全条款。intake 在缺少该字段时不能把案例写成已审。 - 队列:盲测可用 0 例,已曝光/待人工复审 4 例。`production_tuning_allowed` 与 `verified_minute_claim_allowed` 仍为 false。 - 防复发:结果已被看过的案例不得再计入盲测;人审安全条款不得由自动化脚本自行置真。 + +## 2026-08-29 - D4: 补三条缺失断言;打分 kind 与渲染 effective kind 分叉已修 + +- holdout 遍历断言补上 `choice_frame` 非空。 +- 引擎 `varga.d9` / `varga.d10` 无 `style_options` 时,断言打分 `choice_kind` 等于渲染 effective kind。断言先红(打分 `varga_style`、渲染 `existence`),已让 `conflictProbesFromContrast` 改用 `effectiveContrastChoiceKind`。 +- `varga_style` 的 B 选项 `weak_yes` 计入 `strong_conflict_count`,连答 3 次可淘汰对面分组;存在题 `weak_yes` 仍不计入强冲突(BUG-419)。