fix(rectification): score varga-style groups at equal weight
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Record<ScoreDirection, number>> = {
|
||||
@@ -83,6 +84,7 @@ export type ConflictProbe = Readonly<{
|
||||
expected_outcomes: readonly ProbeOutcome[];
|
||||
information_gain: number;
|
||||
source: string;
|
||||
choice_kind?: ProbeChoiceKind;
|
||||
}>;
|
||||
|
||||
export type ProbeAnswer = Readonly<{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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<typeof buildCandidateContrastPacket>): 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);
|
||||
});
|
||||
Reference in New Issue
Block a user