fix(rectification): require a renderable card before holdout validation
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user