From e41eaace8d2373137efa070c4449bc4e480193db Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Sat, 29 Aug 2026 00:15:21 +0800 Subject: [PATCH] fix(rectification): score varga-style groups at equal weight Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 16 ++ .../core/apply-probe-outcome.ts | 13 +- .../core/candidate-contrast-packet.ts | 5 + .../core/compose-receipt.ts | 5 +- .../core/probes-from-engine.ts | 5 + .../lib/rectification-agentic/core/types.ts | 2 + .../v9/decision-from-dossier.ts | 10 + .../rectification-varga-style-weight.test.ts | 171 ++++++++++++++++++ 8 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 frontend/tests/rectification-varga-style-weight.test.ts diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 1191a78b..5e160eba 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6445,6 +6445,22 @@ - 复发自:无 - 修复版本:待发布 +## BUG-425 | varga 风格题按 answer_class 半权,时间靠前的组被系统性抬高 + +- 状态:resolved +- 首次发现:2026-08-28 +- 最近更新:2026-08-28 +- 影响面:`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 门。 +- 相关记录:BUG-419、BUG-410 +- 复发自:无 +- 修复版本:待发布 + ## BUG-410 | 训练已齐仍因家人/职业方法层停在采集,Agent 只确认后截断 - 状态:resolved diff --git a/frontend/src/lib/rectification-agentic/core/apply-probe-outcome.ts b/frontend/src/lib/rectification-agentic/core/apply-probe-outcome.ts index 24792cae..50d94756 100644 --- a/frontend/src/lib/rectification-agentic/core/apply-probe-outcome.ts +++ b/frontend/src/lib/rectification-agentic/core/apply-probe-outcome.ts @@ -19,9 +19,14 @@ export function outcomeForAnswer(probe: ConflictProbe, answer: AnswerClass): Pro return probe.expected_outcomes.find((item) => item.answer_class === answer) ?? null; } -export function directionFor(candidateId: string, outcome: ProbeOutcome): ScoreDirection { - if (outcome.supports.includes(candidateId)) return outcome.answer_class === "weak_yes" ? "weak_support" : "support"; - if (outcome.conflicts.includes(candidateId)) return outcome.answer_class === "weak_yes" ? "weak_conflict" : "conflict"; +export function directionFor( + candidateId: string, + outcome: ProbeOutcome, + choiceKind?: ConflictProbe["choice_kind"], +): ScoreDirection { + const halfWeight = outcome.answer_class === "weak_yes" && choiceKind !== "varga_style"; + if (outcome.supports.includes(candidateId)) return halfWeight ? "weak_support" : "support"; + if (outcome.conflicts.includes(candidateId)) return halfWeight ? "weak_conflict" : "conflict"; return "neutral"; } @@ -60,7 +65,7 @@ export function applyProbeOutcome( }; } for (const [id, score] of Object.entries(scores)) { - const direction = directionFor(id, outcome); + const direction = directionFor(id, outcome, probe.choice_kind); if (direction === "conflict") conflictCounts[id] = (conflictCounts[id] ?? 0) + 1; if (eliminated.has(id)) { next[id] = score; 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 f0ba3e8a..6c67cef3 100644 --- a/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts +++ b/frontend/src/lib/rectification-agentic/core/candidate-contrast-packet.ts @@ -602,6 +602,11 @@ 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 } + : {}), }]; }); } diff --git a/frontend/src/lib/rectification-agentic/core/compose-receipt.ts b/frontend/src/lib/rectification-agentic/core/compose-receipt.ts index 471cfd0b..8dfe9ac5 100644 --- a/frontend/src/lib/rectification-agentic/core/compose-receipt.ts +++ b/frontend/src/lib/rectification-agentic/core/compose-receipt.ts @@ -32,6 +32,7 @@ const CANDIDATE_STATUSES = new Set(["active", "eliminated", "winner", "equivalen const EVENT_PRECISIONS = new Set(["day", "month", "year", "unknown"]); const EVENT_USAGES = new Set(["training", "holdout", "unused"]); const ANSWER_CLASSES = new Set(["yes", "weak_yes", "no", "unsure"]); +const PROBE_CHOICE_KINDS = new Set(["existence", "varga_style", "event_quality"]); const ANSWER_SOURCES = new Set(["choice", "evidence", "declined"]); const ROUND_KINDS = new Set(["informative", "low_information"]); @@ -88,7 +89,9 @@ function isConflictProbe(value: unknown): boolean { && isStringArray(value.candidate_ids) && Array.isArray(value.expected_outcomes) && value.expected_outcomes.every(isProbeOutcome) && isFiniteNumber(value.information_gain) - && typeof value.source === "string" && value.source.length > 0; + && typeof value.source === "string" && value.source.length > 0 + && (value.choice_kind === undefined + || (typeof value.choice_kind === "string" && PROBE_CHOICE_KINDS.has(value.choice_kind))); } function isProbeAnswer(value: unknown): boolean { diff --git a/frontend/src/lib/rectification-agentic/core/probes-from-engine.ts b/frontend/src/lib/rectification-agentic/core/probes-from-engine.ts index 983b8edf..5b654501 100644 --- a/frontend/src/lib/rectification-agentic/core/probes-from-engine.ts +++ b/frontend/src/lib/rectification-agentic/core/probes-from-engine.ts @@ -31,6 +31,11 @@ export function probeFromEngine(probe: EngineProbeFields): ConflictProbe | null expected_outcomes: outcomes, information_gain: probe.information_gain ?? 0, source: probe.source, + ...(probe.choice_kind === "varga_style" + || probe.choice_kind === "event_quality" + || probe.choice_kind === "existence" + ? { choice_kind: probe.choice_kind } + : {}), }; } diff --git a/frontend/src/lib/rectification-agentic/core/types.ts b/frontend/src/lib/rectification-agentic/core/types.ts index b7dc3c88..894ba731 100644 --- a/frontend/src/lib/rectification-agentic/core/types.ts +++ b/frontend/src/lib/rectification-agentic/core/types.ts @@ -36,6 +36,7 @@ export type ResultStatus = export type CandidateStatus = "active" | "eliminated" | "winner" | "equivalent"; export type EventUsage = "training" | "holdout" | "unused"; export type AnswerClass = "yes" | "weak_yes" | "no" | "unsure"; +export type ProbeChoiceKind = "existence" | "varga_style" | "event_quality"; export type ScoreDirection = "support" | "weak_support" | "neutral" | "weak_conflict" | "conflict"; export const SCORE_DELTA: Readonly> = { @@ -83,6 +84,7 @@ export type ConflictProbe = Readonly<{ expected_outcomes: readonly ProbeOutcome[]; information_gain: number; source: string; + choice_kind?: ProbeChoiceKind; }>; export type ProbeAnswer = Readonly<{ 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 3a6743fe..6a14df4d 100644 --- a/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts +++ b/frontend/src/lib/rectification-agentic/v9/decision-from-dossier.ts @@ -151,6 +151,11 @@ export function contrastPacketFromLatestResult( information_gain: probe.information_gain, expected_outcomes: probe.expected_outcomes, candidate_ids: probe.candidate_ids, + ...(probe.choice_kind === "varga_style" + || probe.choice_kind === "event_quality" + || probe.choice_kind === "existence" + ? { choice_kind: probe.choice_kind } + : {}), }]; }); const merged = mergeEngineProbes( @@ -248,6 +253,11 @@ function contrastPacketFromState(state: InferenceState): CandidateContrastPacket information_gain: item.information_gain, expected_outcomes: item.expected_outcomes, candidate_ids: item.candidate_ids, + ...(item.choice_kind === "varga_style" + || item.choice_kind === "event_quality" + || item.choice_kind === "existence" + ? { choice_kind: item.choice_kind } + : {}), })), candidateTimes: state.candidates .filter((item) => item.status !== "eliminated") diff --git a/frontend/tests/rectification-varga-style-weight.test.ts b/frontend/tests/rectification-varga-style-weight.test.ts new file mode 100644 index 00000000..daf8db65 --- /dev/null +++ b/frontend/tests/rectification-varga-style-weight.test.ts @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { applyProbeOutcome } from "../src/lib/rectification-agentic/core/apply-probe-outcome.ts"; +import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts"; +import { + buildCandidateContrastPacket, + conflictProbesFromContrast, +} 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"; +import type { AnswerClass, ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts"; + +function twoGroupStylePacket() { + return buildCandidateContrastPacket({ + candidateSetVersion: "05:00-05:04", + candidateTimes: ["05:00", "05:04"], + transitions: [ + { layer: "d9", at: "05:04", from_sign: "巨蟹座", to_sign: "狮子座" }, + ], + }); +} + +function threeGroupStylePacket() { + return buildCandidateContrastPacket({ + candidateSetVersion: "05:00-05:04", + candidateTimes: ["05:00", "05:03", "05:04"], + transitions: [ + { layer: "d10", at: "05:03", from_sign: "巨蟹座", to_sign: "狮子座" }, + { layer: "d10", at: "05:04", from_sign: "狮子座", to_sign: "处女座" }, + ], + }); +} + +function styleProbeFrom(packet: ReturnType): ConflictProbe { + const probe = conflictProbesFromContrast(packet).find((item) => item.source === "varga_contrast"); + assert.ok(probe); + return probe; +} + +function absSupportDelta(probe: ConflictProbe, answer: AnswerClass, time: string): number { + const scores = Object.fromEntries(probe.candidate_ids.map((id) => [id, 10])); + const applied = applyProbeOutcome(scores, probe, answer); + return Math.abs(applied.deltas[time] ?? 0); +} + +test("two-group varga_style A and B move their groups by the same absolute delta", () => { + const probe = styleProbeFrom(twoGroupStylePacket()); + assert.equal(probe.choice_kind, "varga_style"); + const yesGroup = probe.expected_outcomes.find((row) => row.answer_class === "yes")?.supports[0]; + const weakGroup = probe.expected_outcomes.find((row) => row.answer_class === "weak_yes")?.supports[0]; + assert.ok(yesGroup); + assert.ok(weakGroup); + assert.notEqual(yesGroup, weakGroup); + const yesDelta = absSupportDelta(probe, "yes", yesGroup); + const weakDelta = absSupportDelta(probe, "weak_yes", weakGroup); + assert.equal(yesDelta, 2); + assert.equal(weakDelta, 2); + assert.equal(yesDelta, weakDelta); + const unsure = applyProbeOutcome( + Object.fromEntries(probe.candidate_ids.map((id) => [id, 10])), + probe, + "unsure", + ); + assert.ok(Object.values(unsure.deltas).every((value) => value === 0)); +}); + +test("three-group varga_style A/B/C each carry full peer weight", () => { + const probe = styleProbeFrom(threeGroupStylePacket()); + assert.equal(probe.choice_kind, "varga_style"); + const scored = (["yes", "weak_yes", "no"] as const).map((answer) => { + const support = probe.expected_outcomes.find((row) => row.answer_class === answer)?.supports[0]; + assert.ok(support, answer); + return absSupportDelta(probe, answer, support); + }); + assert.deepEqual(scored, [2, 2, 2]); +}); + +test("old ConflictProbe receipts without choice_kind keep half-weight weak_yes", () => { + const produced = styleProbeFrom(twoGroupStylePacket()); + const legacy: ConflictProbe = { + id: produced.id, + semantic_key: produced.semantic_key, + candidate_split_hash: produced.candidate_split_hash, + domain: produced.domain, + year: produced.year, + question: produced.question, + candidate_ids: produced.candidate_ids, + expected_outcomes: produced.expected_outcomes, + information_gain: produced.information_gain, + source: produced.source, + }; + assert.equal(legacy.choice_kind, undefined); + + const state = buildInferenceState({ + range_start: "05:00", + range_end: "05:04", + candidates: [ + { id: "05:00", time: "05:00", relative_support: 34 }, + { id: "05:04", time: "05:04", relative_support: 33 }, + ], + events: [{ id: "e-rel", domain: "relationship", year: 2024, precision: "year" }], + probes: [legacy], + }); + const loaded = asInferenceState(JSON.parse(JSON.stringify(state))); + assert.ok(loaded); + const loadedProbe = loaded.probes.find((item) => item.id === produced.id); + assert.ok(loadedProbe); + assert.equal(loadedProbe.choice_kind, undefined); + + const weakGroup = loadedProbe.expected_outcomes.find((row) => row.answer_class === "weak_yes")?.supports[0]; + const yesGroup = loadedProbe.expected_outcomes.find((row) => row.answer_class === "yes")?.supports[0]; + assert.ok(weakGroup); + assert.ok(yesGroup); + assert.equal(absSupportDelta(loadedProbe, "weak_yes", weakGroup), 1); + assert.equal(absSupportDelta(loadedProbe, "yes", yesGroup), 2); + assert.equal(loaded.candidate_set_id, state.candidate_set_id); + assert.equal(loaded.revision, state.revision); +}); + +test("replaying a varga_style probe keeps candidate_set_id and a monotonic revision", () => { + const probe = styleProbeFrom(twoGroupStylePacket()); + const before = buildInferenceState({ + range_start: "05:00", + range_end: "05:04", + candidates: [ + { id: "05:00", time: "05:00", relative_support: 34 }, + { id: "05:04", time: "05:04", relative_support: 33 }, + ], + events: [{ id: "e-rel", domain: "relationship", year: 2024, precision: "year" }], + probes: [probe], + }); + const after = buildInferenceState({ + range_start: before.range_start, + range_end: before.range_end, + candidates: before.candidates.map((item) => ({ + id: item.id, + time: item.time, + relative_support: item.prior_score, + })), + events: before.events, + probes: before.probes, + previous: before, + answered_probes: [{ + probe_id: probe.id, + semantic_key: probe.semantic_key, + candidate_split_hash: probe.candidate_split_hash, + answer_class: "weak_yes", + classified_from: "choice", + }], + }); + assert.equal(after.candidate_set_id, before.candidate_set_id); + assert.ok(after.revision >= before.revision); + const beforeFp = decisionStateFingerprint({ + caseId: "case-style-weight", + evidenceLedgerFingerprint: "fp-a", + candidateSetId: before.candidate_set_id, + inferenceRevision: before.revision, + answeredProbeIds: before.answered_probes.map((item) => item.probe_id), + scoringPolicyVersion: "policy-v2", + }); + const afterFp = decisionStateFingerprint({ + caseId: "case-style-weight", + evidenceLedgerFingerprint: "fp-a", + candidateSetId: after.candidate_set_id, + inferenceRevision: after.revision, + answeredProbeIds: after.answered_probes.map((item) => item.probe_id), + scoringPolicyVersion: "policy-v2", + }); + assert.notEqual(afterFp, beforeFp); +});