fix(rectification): require a renderable card before holdout validation

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-29 00:30:04 +08:00
parent e41eaace8d
commit fe0afdaddb
4 changed files with 323 additions and 65 deletions
+16
View File
@@ -6461,6 +6461,22 @@
- 复发自:无
- 修复版本:待发布
## BUG-426 | 无年份 sticky holdout 仍进入核对,点选卡却渲染不出来
- 状态:resolved
- 首次发现:2026-08-28
- 最近更新:2026-08-28
- 影响面:`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 门。
- 相关记录:BUG-424、BUG-410
- 复发自:无
- 修复版本:待发布
## BUG-410 | 训练已齐仍因家人/职业方法层停在采集,Agent 只确认后截断
- 状态:resolved
@@ -17,6 +17,7 @@ import {
import {
decideRectification,
publicDecisionFields,
type HoldoutValidationStatus,
type RectificationDecision,
} from "../core/rectification-decision.ts";
import type { InferenceState } from "../core/types.ts";
@@ -110,25 +111,51 @@ export function discriminatorCandidateTimes(
return candidateScoresFromDossier(latest).map((item) => item.time);
}
function holdoutStatusFromInference(inference: ReturnType<typeof previousInferenceFromReceipt>) {
if (!inference) return "unavailable" as const;
const hasHoldout = inference.events.some((item) => item.usage === "holdout");
if (!hasHoldout) return "unavailable" as const;
if (inference.holdout_passed === true) return "passed" as const;
if (inference.holdout_passed === false || inference.result_status === "validation_failed") {
return "failed" as const;
}
return "not_started" as const;
function canAskHoldout(input: {
events?: readonly Readonly<{ usage: string; year: number | null }>[];
oosBlindPrompts?: readonly unknown[] | null;
}): boolean {
if ((input.oosBlindPrompts?.length ?? 0) > 0) return true;
return Boolean(input.events?.some((item) => item.usage === "holdout" && item.year !== null));
}
function holdoutStatusFromState(state: InferenceState) {
const hasHoldout = state.events.some((item) => item.usage === "holdout");
if (state.holdout_passed === true) return "passed" as const;
if (state.holdout_passed === false || state.result_status === "validation_failed") {
return "failed" as const;
function holdoutValidationStatus(input: {
events?: readonly Readonly<{ usage: string; year: number | null }>[];
holdoutPassed?: boolean | null;
resultStatus?: string | null;
oosBlindPrompts?: readonly unknown[] | null;
}): HoldoutValidationStatus {
if (input.holdoutPassed === true) return "passed";
if (input.holdoutPassed === false || input.resultStatus === "validation_failed") {
return "failed";
}
if (hasHoldout) return "not_started" as const;
return "unavailable" as const;
if (canAskHoldout(input)) return "not_started";
return "unavailable";
}
function holdoutStatusFromInference(
inference: ReturnType<typeof previousInferenceFromReceipt>,
oosBlindPrompts?: readonly unknown[] | null,
) {
if (!inference) return "unavailable" as const;
return holdoutValidationStatus({
events: inference.events,
holdoutPassed: inference.holdout_passed,
resultStatus: inference.result_status,
oosBlindPrompts,
});
}
function holdoutStatusFromState(
state: InferenceState,
oosBlindPrompts?: readonly unknown[] | null,
) {
return holdoutValidationStatus({
events: state.events,
holdoutPassed: state.holdout_passed,
resultStatus: state.result_status,
oosBlindPrompts,
});
}
export function contrastPacketFromLatestResult(
@@ -294,6 +321,9 @@ export function decideFromDossier(
options?: { currentEvidenceFingerprint?: string | null },
): RectificationDecision {
const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const oosBlindPrompts = refinementFromDecisionReceipt(
dossier.latestResult?.decisionReceipt ?? null,
).oos_blind_prompts;
const trainingGate = trainingScoreableGate(dossier.evidence);
const collecting = buildMethodFollowupPlan({
evidence: dossier.evidence,
@@ -326,7 +356,7 @@ export function decideFromDossier(
snapshotCurrent,
candidateScores: candidateScoresFromDossier(dossier.latestResult),
discriminatorProbe: inspected.selected,
holdoutValidation: holdoutStatusFromInference(inference),
holdoutValidation: holdoutStatusFromInference(inference, oosBlindPrompts),
accepted: Boolean(dossier.case.acceptedTime),
inferenceCredibleRange: inference?.credible_range ?? null,
}),
@@ -366,7 +396,10 @@ export function decideAfterInferenceChange(input: {
.filter((item) => item.status !== "eliminated")
.map((item) => ({ time: item.time, score: item.posterior_score })),
discriminatorProbe: inspected.selected,
holdoutValidation: holdoutStatusFromState(input.state),
holdoutValidation: holdoutStatusFromState(
input.state,
refinementFromDecisionReceipt(input.dossier.latestResult?.decisionReceipt ?? null).oos_blind_prompts,
),
inferenceCredibleRange: input.state.credible_range,
userStopped: input.userStopped,
accepted: Boolean(input.dossier.case.acceptedTime),
@@ -74,6 +74,7 @@ import {
completeStyleOptions,
isRenderableProbe,
rankDiscriminatorScore,
EXISTENCE_STYLE_OPTIONS,
type ProbeStyleOption,
} from "./probe-question-contract.ts";
import type { SessionOutcomeKind } from "./confirmation-gate.ts";
@@ -1032,6 +1033,39 @@ export function buildNextUserAction(input: {
return { id: explain.id, user_meaning: explain.user_meaning, on_user_stop: explain };
}
function holdoutAskFields(
prompt: OosBlindPrompt | null | undefined,
reserved: Readonly<{ domain: string; year: number | null }> | null,
): Omit<MethodFollowup, "must_not_label" | "choice_frame"> | null {
if (prompt) {
return {
method_id: "oos_blind",
intent: "out_of_sample_check",
ask_theme: "holdout",
domain: prompt.domain,
kind_hint: null,
user_prompt_hint: prompt.user_meaning,
source: "oos_blind",
choice_kind: "existence",
style_options: EXISTENCE_STYLE_OPTIONS,
};
}
if (reserved?.year == null) return null;
return {
method_id: "holdout_validation",
intent: "out_of_sample_check",
ask_theme: "holdout",
domain: reserved.domain,
kind_hint: null,
user_prompt_hint: `${reserved.year} 年前后这件事还要单独核对一次,不计入候选分数。`,
source: "oos_blind",
probe_year: reserved.year,
year_label: `${reserved.year} 年前后`,
choice_kind: "existence",
style_options: EXISTENCE_STYLE_OPTIONS,
};
}
export function buildMethodFollowupPlan(input: {
evidence: readonly MethodFollowupEvidence[];
activeFocus?: MethodFollowupFocus | null;
@@ -1230,32 +1264,13 @@ export function buildMethodFollowupPlan(input: {
}
if (sessionOutcome === "validate_holdout") {
const prompt = input.oosBlindPrompts?.[0] ?? null;
const reserved = (input.holdoutEvents ?? []).find((item) => item.year !== null) ?? null;
const holdoutNext = prompt
? makeFollowup({
method_id: "oos_blind",
intent: "out_of_sample_check",
ask_theme: "holdout",
domain: prompt.domain,
kind_hint: null,
user_prompt_hint: prompt.user_meaning,
source: "oos_blind",
}, false, true)
: reserved
? makeFollowup({
method_id: "holdout_validation",
intent: "out_of_sample_check",
ask_theme: "holdout",
domain: reserved.domain,
kind_hint: null,
user_prompt_hint: `${reserved.year} 年前后这件事还要单独核对一次,不计入候选分数。`,
source: "oos_blind",
}, false, true)
: null;
const fields = holdoutAskFields(
input.oosBlindPrompts?.[0],
(input.holdoutEvents ?? []).find((item) => item.year !== null) ?? null,
);
return {
methods,
next_followup: holdoutNext,
next_followup: fields ? makeFollowup(fields, false, true) : null,
deferred_followup: null,
session_outcome: sessionOutcome ?? "collect_evidence",
stop_domain_rotation: true,
@@ -1663,29 +1678,11 @@ export function buildMethodFollowupPlan(input: {
source: "nakshatra_boundary",
});
} else if (input.holdoutValidation === "not_started") {
const prompt = input.oosBlindPrompts?.[0];
const reserved = (input.holdoutEvents ?? []).find((item) => item.year !== null);
if (prompt) {
next = makeFollowup({
method_id: "oos_blind",
intent: "out_of_sample_check",
ask_theme: "holdout",
domain: prompt.domain,
kind_hint: null,
user_prompt_hint: prompt.user_meaning,
source: "oos_blind",
}, false, true);
} else if (reserved) {
next = makeFollowup({
method_id: "holdout_validation",
intent: "out_of_sample_check",
ask_theme: "holdout",
domain: reserved.domain,
kind_hint: null,
user_prompt_hint: `${reserved.year} 年前后这件事还要单独核对一次,不计入候选分数。`,
source: "oos_blind",
}, false, true);
}
const fields = holdoutAskFields(
input.oosBlindPrompts?.[0],
(input.holdoutEvents ?? []).find((item) => item.year !== null) ?? null,
);
if (fields) next = makeFollowup(fields, false, true);
} else if (horaryStatus === "uncovered") {
next = makeFollowup({
method_id: "horary",
@@ -0,0 +1,212 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyHoldoutAnswer } from "../src/lib/rectification-agentic/core/build-state.ts";
import { decideFromDossier, rectificationFollowupCatalog } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { buildCaseInferenceState } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import type { InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
const COVERED_EVIDENCE = [
{
id: "e-edu",
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: null,
eventKind: "education_milestone",
},
{
id: "e-career-a",
status: "confirmed",
domain: "career",
datePrecision: "year",
occurredFrom: "2018-01-01",
occurredTo: null,
eventKind: "career_entry",
},
{
id: "e-career-b",
status: "confirmed",
domain: "career",
datePrecision: "year",
occurredFrom: "2020-01-01",
occurredTo: null,
eventKind: "career_change",
},
{
id: "e-rel",
status: "confirmed",
domain: "relationship",
datePrecision: "year",
occurredFrom: "2024-01-01",
occurredTo: null,
eventKind: "relationship_start",
},
{
id: "e-fam",
status: "confirmed",
domain: "family",
datePrecision: "year",
occurredFrom: "2023-01-01",
occurredTo: null,
eventKind: "family_event",
},
{
id: "e-occ",
status: "confirmed",
domain: "occupation",
datePrecision: "unknown",
occurredFrom: null,
occurredTo: null,
eventKind: "occupation_note",
},
] as const;
function producedDossier(
evidence: readonly Readonly<{
id: string;
status: string;
domain: string;
datePrecision: string;
occurredFrom: string | null;
occurredTo: string | null;
eventKind?: string | null;
}>[],
options: { previous?: InferenceState | null } = {},
) {
const state = buildCaseInferenceState({
range: { start_time: "04:55", end_time: "05:02" },
candidates: [
{ candidateId: "05:02", time: "05:02", relativeSupport: 58 },
{ candidateId: "04:55", time: "04:55", relativeSupport: 42 },
],
evidence,
probes: [],
previous: options.previous,
});
const latest = {
resultId: "55555555-5555-4555-8555-555555555555",
candidates: state.candidates.map((item) => ({
candidateId: item.id,
time: item.time,
rank: item.rank,
relativeSupport: Math.round(item.posterior_score),
})),
representativeTime: state.representative_time,
evidenceLedgerFingerprint: evidenceLedgerFingerprint(evidence as never),
decisionReceipt: { inference_state: state },
};
return {
state,
dossier: {
evidence,
conversationSummary: { activeFocus: null, declinedSkippedTopics: [] },
latestResult: latest,
case: { acceptedTime: null },
},
};
}
function holdoutFollowup(dossier: ReturnType<typeof producedDossier>["dossier"]) {
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
return buildMethodFollowupPlan({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: "validate_holdout",
...catalog,
candidatesSeparated: true,
});
}
test("sticky holdout with unknown precision does not ask validation and can adopt", () => {
const { state, dossier: dated } = producedDossier(COVERED_EVIDENCE);
const holdout = state.events.find((item) => item.usage === "holdout");
assert.ok(holdout);
assert.notEqual(holdout.year, null);
assert.equal(decideFromDossier(dated).nextAction, "ask_holdout_validation");
const undated = COVERED_EVIDENCE.map((item) => (
item.id === holdout.id
? { ...item, datePrecision: "unknown", occurredFrom: null, occurredTo: null }
: item
));
const { state: sticky, dossier } = producedDossier(undated, { previous: state });
const stickyHoldout = sticky.events.find((item) => item.id === holdout.id);
assert.equal(stickyHoldout?.usage, "holdout");
assert.equal(stickyHoldout?.year, null);
assert.equal(stickyHoldout?.precision, "unknown");
const decision = decideFromDossier(dossier);
assert.notEqual(decision.nextAction, "ask_holdout_validation");
assert.equal(decision.nextAction, "ready_to_adopt");
assert.equal(decision.sessionOutcome, "adopt_representative");
assert.notEqual(decision.sessionOutcome, "validated_range");
assert.equal(decision.validated, false);
assert.equal(decision.canAdopt, true);
assert.equal(decision.canConfirmExactMinute, false);
});
test("dated holdout asks validation with a renderable followup card", () => {
const { dossier } = producedDossier(COVERED_EVIDENCE);
const decision = decideFromDossier(dossier);
assert.equal(decision.nextAction, "ask_holdout_validation");
assert.equal(decision.canConfirmExactMinute, false);
const plan = holdoutFollowup(dossier);
assert.ok(plan.next_followup);
assert.ok(plan.next_followup.choice_frame, "holdout card must be renderable");
assert.equal(plan.next_followup.choice_frame.scoring, false);
assert.ok(plan.next_followup.choice_frame.prompt);
});
test("passed holdout is a validated range, not a unique minute", () => {
const { state, dossier } = producedDossier(COVERED_EVIDENCE);
const passed = applyHoldoutAnswer(state, "yes");
const decided = decideFromDossier({
...dossier,
latestResult: {
...dossier.latestResult,
decisionReceipt: { inference_state: passed },
},
});
assert.equal(decided.sessionOutcome, "validated_range");
assert.equal(decided.canAdopt, true);
assert.equal(decided.validated, true);
assert.equal(decided.canConfirmExactMinute, false);
assert.notEqual(decided.nextAction, "ask_holdout_validation");
});
test("failed holdout keeps existing non-validated close or discriminate path", () => {
const { state, dossier } = producedDossier(COVERED_EVIDENCE);
const failed = applyHoldoutAnswer(state, "no");
assert.equal(failed.holdout_passed, false);
const decided = decideFromDossier({
...dossier,
latestResult: {
...dossier.latestResult,
decisionReceipt: { inference_state: failed },
},
});
assert.equal(decided.holdoutValidation, "failed");
assert.notEqual(decided.nextAction, "ask_holdout_validation");
assert.notEqual(decided.sessionOutcome, "validated_range");
assert.equal(decided.validated, false);
assert.equal(decided.canConfirmExactMinute, false);
});
test("ask_holdout_validation is only returned when the same dossier can build a holdout followup", () => {
const fixtures = [
producedDossier(COVERED_EVIDENCE).dossier,
producedDossier(COVERED_EVIDENCE.filter((item) => item.domain !== "occupation")).dossier,
];
for (const dossier of fixtures) {
const decision = decideFromDossier(dossier);
if (decision.nextAction !== "ask_holdout_validation") continue;
const plan = holdoutFollowup(dossier);
assert.ok(plan.next_followup, "ask_holdout_validation requires a holdout followup");
assert.equal(decision.canConfirmExactMinute, false);
}
});