fix(rectification): require three answers before uncertainty stop
Independent Staging Quality Gate / validate (push) Successful in 9m19s
Independent Staging Quality Gate / publish (push) Successful in 1m52s

One "一时说不好" no longer ends the interview. Plateau streaks use the
same sample floor. Delivery copy states why the range stopped.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-03 12:08:37 +08:00
co-authored by Cursor
parent e8c98c37cf
commit 0c0df42679
11 changed files with 335 additions and 51 deletions
+16
View File
@@ -7745,3 +7745,19 @@
- 复发自:无
- 修复版本:待发布
## BUG-503 | 一道题选「一时说不好」就交付
- 状态:resolved
- 首次发现:2026-09-03
- 最近更新:2026-09-03
- 影响面:`uncertaintyHighFromAnswers`、`decisionBudgetFromInference`、`composeChoiceNarration`、`publicNextAction`、`user-copy`
- 用户现象:整场只收到一道选择题,选 D「一时说不好」后直接交付几十分钟区间;旁白还说「更新了候选比较」。
- 触发条件:`classified_from=choice` 的用户答题数为 1,`answer_class=unsure`。连续两道 D 也会因 `maxPlateauRounds=2` 交付。
- 根因:`uncertaintyHighFromAnswers` 没有样本下限,1 答即 `1 >= ceil(1/2)`。`applyProbeOutcome` 把 unsure 记成 `low_information`,平台期从第一道就开始计数。上游 Stop Rule 是「样本够多且大半答不上来」,实现成了任意一道答不上来。
- 修复:`references/rectification_policy.v1.json` 增加 `minUncertaintyAnswers: 3`。不足 3 答不停。达到下限后仍用「≥ 一半」判定。平台期在 `decisionBudgetFromInference` 用同一下限才统计连续 `low_information`,`maxPlateauRounds` 仍为 2。unsure 旁白改为「已记录。这题先不计分,换一件事问。」`appliedInference` 仍持久化。交付话术在进度句前说明 `user_uncertainty_too_high` / `tied_first`。`publicNextAction` 只读暴露 `stop_reason`。用户主动「先这样」仍立即交付。
- 验证:`rectification-convergence-budget` 1 答不停且继续出区分题;3 答 2 unsure 停;4 答 2 unsure 停;2 答连续 unsure 不因平台期停,5 答后连续 2 道 unsure 停。`rectification-answer-choice` unsure 旁白与 §0 形状(答 D 持久化下一问、不采用)。`agent-voice-copy-contract` 锁停止原因文案。
- 防复发:不确定度停止必须先过 `minUncertaintyAnswers`。该数字只在 `references/rectification_policy.v1.json` 定义。不得把 `maxPlateauRounds` / 8 轮 / 10 答外层熔断改掉。不得用题干字符串反推停止原因。
- 相关记录:BUG-463
- 复发自:无
- 修复版本:待发布
@@ -628,6 +628,7 @@ export function publicNextAction(decision: RectificationDecision): Readonly<{
precision_stage: DerivedPrecisionStage;
representative_time: string | null;
credible_range: readonly [string, string] | null;
stop_reason: EvidenceStopReason | null;
}> {
return {
type: decision.nextAction,
@@ -642,6 +643,7 @@ export function publicNextAction(decision: RectificationDecision): Readonly<{
precision_stage: decision.precisionStage,
representative_time: decision.representativeTime,
credible_range: decision.credibleRange,
stop_reason: decision.stopReason ?? null,
};
}
@@ -62,6 +62,8 @@ export const RECTIFICATION_USER_COPY = {
adoptCue: "可以从下面选一个先用着。",
hostNarrationFallback: "我按现有材料继续往下收。",
continueCollectFallback: "请继续说下一件你记得比较清楚、大概带年份的经历。",
uncertaintyStop: "前面几道题你多半选了\"说不好\",再问下去也分不开,先停在这里。",
tiedFirstStop: "几个候选打成平手,问题已经分不开它们。",
} as const;
export type RangeNarrationVariant = "delivery" | "intermediate";
@@ -71,6 +73,7 @@ export type RangeNarrationInput = {
representativeTime?: string | null;
openingRange?: readonly [string, string] | null;
variant?: RangeNarrationVariant;
stopReason?: string | null;
};
function clockMinutes(value: string): number | null {
@@ -104,6 +107,12 @@ export function containsBoundarySemantics(text: string): boolean {
return BOUNDARY_SEMANTIC_PHRASES.some((phrase) => text.includes(phrase));
}
export function stopReasonPrefix(reason: string | null | undefined): string | null {
if (reason === "user_uncertainty_too_high") return RECTIFICATION_USER_COPY.uncertaintyStop;
if (reason === "tied_first") return RECTIFICATION_USER_COPY.tiedFirstStop;
return null;
}
function progressClause(
openingRange: readonly [string, string] | null | undefined,
rangeText: string,
@@ -127,6 +136,7 @@ export function nonConvergingRangeNarration(
const progress = range && rangeText
? progressClause(input.openingRange, rangeText, range)
: null;
const reason = variant === "delivery" ? stopReasonPrefix(input.stopReason) : null;
if (variant === "intermediate") {
if (rangeText && representative) {
@@ -144,16 +154,20 @@ export function nonConvergingRangeNarration(
if (rangeText && representative) {
const head = progress ?? `更站得住的范围是 ${rangeText},代表分钟 ${representative}`;
const representativeClause = progress ? `。代表分钟是 ${representative}。` : `。`;
return `${head}${representativeClause}${boundaryCopy}`;
const body = `${head}${representativeClause}${boundaryCopy}`;
return reason ? `${reason}${body}` : body;
}
if (rangeText) {
const head = progress ?? `更站得住的范围是 ${rangeText}`;
return `${head}。${boundaryCopy}`;
const body = `${head}。${boundaryCopy}`;
return reason ? `${reason}${body}` : body;
}
if (representative) {
return `当前代表分钟 ${representative}。${boundaryCopy}`;
const body = `当前代表分钟 ${representative}。${boundaryCopy}`;
return reason ? `${reason}${body}` : body;
}
return `当前几个候选还分不开。${boundaryCopy}`;
const body = `当前几个候选还分不开。${boundaryCopy}`;
return reason ? `${reason}${body}` : body;
}
export function deliveryAdoptNarration(input: RangeNarrationInput): string {
@@ -198,6 +212,8 @@ export function listUserVisibleCopy(): string[] {
RECTIFICATION_USER_COPY.adoptCue,
RECTIFICATION_USER_COPY.hostNarrationFallback,
RECTIFICATION_USER_COPY.continueCollectFallback,
RECTIFICATION_USER_COPY.uncertaintyStop,
RECTIFICATION_USER_COPY.tiedFirstStop,
...Object.values(USER_COLLECT_QUESTION),
...Object.values(USER_COLLECT_QUESTION_RETRY),
];
@@ -20,7 +20,7 @@ import {
withNakshatraBoundaryProbe,
} from "./inference-adapter";
import { decideAfterInferenceChange, decideFromDossier, rectificationFollowupCatalog } from "./decision-from-dossier";
import type { InferenceState } from "../core/types.ts";
import type { AnswerClass, InferenceState } from "../core/types.ts";
import {
CHOICE_ACTION,
STOP_ACTION,
@@ -125,6 +125,7 @@ function adoptHostNarration(input: {
credibleRange: input.credibleRange,
representativeTime: input.representativeTime,
openingRange: input.openingRange,
stopReason: typeof input.receipt?.stopReason === "string" ? input.receipt.stopReason : null,
}), input.receipt);
}
@@ -251,7 +252,12 @@ export async function applyRectificationChoice(
}
if (!previous) {
const narration = composeChoiceNarration({ optionId, scoring, appliedInference: false });
const narration = composeChoiceNarration({
optionId,
scoring,
appliedInference: false,
answerClass,
});
return persistApplied(accounting, command, {
focusId: focus.id,
questionId,
@@ -298,6 +304,7 @@ export async function applyRectificationChoice(
optionId,
scoring,
appliedInference: persistable && scoring,
answerClass,
});
const evidenceFp = dossier.latestResult?.evidenceLedgerFingerprint
?? evidenceLedgerFingerprint(dossier.evidence);
@@ -513,6 +520,7 @@ export async function persistNextInterviewAfterChoice(input: {
representativeTime: input.nextAction.representative_time,
openingRange: openingRangeFromDossier(input.dossier),
variant: input.nextAction.can_adopt ? "delivery" : "intermediate",
stopReason: input.nextAction.stop_reason,
}), latest.decisionReceipt),
choiceReady: false,
};
@@ -822,7 +830,7 @@ async function persistApplied(
optionId: ChoiceOptionId;
scoring: boolean;
appliedInference: boolean;
answerClass: string | null;
answerClass: AnswerClass | null;
sourceQuote: string | null;
year: number | null;
expectedRevision: number;
@@ -902,6 +910,7 @@ ${nextInterview.hostNarration}`;
credibleRange: nextAction.credible_range,
representativeTime: nextAction.representative_time,
openingRange: openingRangeFromDossier(input.dossier),
stopReason: nextAction.stop_reason,
}), input.dossier.latestResult?.decisionReceipt)
: null;
const completedRangePrefix = input.narration.replace(RECTIFICATION_TERMINATION_COPY, "").trim();
@@ -913,6 +922,7 @@ ${nonConvergingRangeNarration({
representativeTime: nextAction.representative_time,
openingRange: openingRangeFromDossier(input.dossier),
variant: "delivery",
stopReason: nextAction.stop_reason,
}, RECTIFICATION_TERMINATION_COPY)}`, input.dossier.latestResult?.decisionReceipt)
: null;
const keptNextQuestion = nextChoiceReady || (nextInterviewPersisted && !skippedNextInterview);
@@ -962,6 +972,7 @@ ${nonConvergingRangeNarration({
optionId: input.optionId,
scoring: input.scoring,
appliedInference: input.appliedInference,
answerClass: input.answerClass,
});
return {
@@ -94,10 +94,14 @@ export function composeChoiceNarration(input: {
optionId: ChoiceOptionId;
scoring: boolean;
appliedInference: boolean;
answerClass?: AnswerClass | null;
}): string {
if (input.optionId === "stop") {
return `已记录你的选择,并结束本次校正,交付当前可信区间和代表性工作时间。${RECTIFICATION_TERMINATION_COPY}`;
}
if (input.answerClass === "unsure") {
return "已记录。这题先不计分,换一件事问。";
}
if (!input.scoring) {
return "已记录你的选择。这是盘外核对,不会改候选分数。";
}
@@ -44,6 +44,7 @@ import {
import { refinementFromDecisionReceipt, type DiscriminatingEventProbe } from "./refinement-packet";
import { windowScanFromDecisionReceipt } from "./varga-observations";
import type { DroppedProbe } from "./probe-question-contract.ts";
import { RECTIFICATION_POLICY } from "../../rectification-policy.ts";
import { evidenceLedgerFingerprint } from "./tool-service";
import {
candidateSnapshotSource,
@@ -359,12 +360,25 @@ export type DecideFromDossierOptions = Readonly<{
birthDate?: string | null;
}>;
function userInterviewAnswers(
answers: InferenceState["answered_probes"] | undefined,
) {
return answers?.filter((item) => (
item.classified_from === "choice" || item.classified_from === "declined"
)) ?? [];
}
function decisionBudgetFromInference(inference: InferenceState | null | undefined) {
const rounds = inference?.rounds ?? [];
const userAnswers = userInterviewAnswers(inference?.answered_probes);
let plateauRounds = 0;
for (let index = rounds.length - 1; index >= 0; index -= 1) {
if (rounds[index]?.kind !== "low_information") break;
plateauRounds += 1;
// Unsure/low-information streaks only count toward the plateau after the
// same sample floor as userUncertaintyHigh. maxPlateauRounds stays 2.
if (userAnswers.length >= RECTIFICATION_POLICY.minUncertaintyAnswers) {
for (let index = rounds.length - 1; index >= 0; index -= 1) {
if (rounds[index]?.kind !== "low_information") break;
plateauRounds += 1;
}
}
return {
// The existing convergence evaluator budgets informative rounds. Keep the
@@ -527,12 +541,10 @@ function evidenceStopInputs(evidence: DecisionDossier["evidence"]): {
}
function uncertaintyHighFromAnswers(
answers: readonly Readonly<{ answer_class?: string; classified_from?: string }>[] | undefined,
answers: InferenceState["answered_probes"] | undefined,
): boolean {
const userAnswers = answers?.filter((item) => (
item.classified_from === "choice" || item.classified_from === "declined"
)) ?? [];
if (userAnswers.length === 0) return false;
const userAnswers = userInterviewAnswers(answers);
if (userAnswers.length < RECTIFICATION_POLICY.minUncertaintyAnswers) return false;
const uncertain = userAnswers.filter((item) => (
item.answer_class === "unsure" || item.classified_from === "declined"
)).length;
@@ -11,6 +11,7 @@ import {
engineMeaningToDisplayCopy,
listUserVisibleCopy,
nonConvergingRangeNarration,
stopReasonPrefix,
} from "../src/lib/rectification-agentic/user-copy.ts";
import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts";
@@ -79,6 +80,24 @@ test("default range narration stays semantic even without an opening window", ()
assert.equal(containsBoundarySemantics(text), true);
});
test("delivery range narration puts the stop reason before the progress clause", () => {
assert.equal(
stopReasonPrefix("user_uncertainty_too_high"),
RECTIFICATION_USER_COPY.uncertaintyStop,
);
assert.equal(stopReasonPrefix("tied_first"), RECTIFICATION_USER_COPY.tiedFirstStop);
const text = nonConvergingRangeNarration({
credibleRange: ["14:02", "14:43"],
representativeTime: "14:43",
openingRange: ["14:00", "15:00"],
stopReason: "user_uncertainty_too_high",
});
assert.match(text, /^前面几道题你多半选了"说不好"/);
assert.match(text, /已经从最初的 60 分钟收到 14:02–14:43 这 41 分钟/);
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.uncertaintyStop));
assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.tiedFirstStop));
});
test("question stem ownership stays on set-focus spokenPrompt, not a slot or a second turn", () => {
const agent = readFileSync(new URL("../src/mastra/agentic-rectification.ts", import.meta.url), "utf8");
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
@@ -805,6 +805,48 @@ test("deferFollowup scores the choice without persisting the next interview or t
assert.equal(applied.narrationPersisted, false);
});
test("one unsure discriminator answer does not adopt and persists the next question", async () => {
const accounting = persistChoiceAccounting(twoProbeDossier(), {
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: NEXT_FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: args.p_target_evidence_id,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-08-28T07:37:50.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const applied = await applyRectificationChoice(accounting.client, {
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
actionId: ACTION_ID,
action: CHOICE_ACTION,
focusId: FOCUS_ID,
questionId: QUESTION_ID,
probeId: RELOCATION_2015_PROBE.id,
optionId: "D",
expectedRevision: twoProbeInference().revision,
});
assert.equal(applied.answerClass, "unsure");
assert.equal(applied.narration, "已记录。这题先不计分,换一件事问。");
assert.notEqual(applied.nextAction.session_outcome, "adopt_representative");
assert.equal(applied.nextAction.type, "ask_candidate_discriminator");
assert.equal(applied.nextInterviewPersisted, true);
const persist = accounting.calls.find((call) => call.fn === "apply_agentic_rectification_choice_action");
assert.ok(persist?.args.p_inference);
const setFocus = accounting.calls.find((call) => call.fn === "set_agentic_rectification_conversation_focus");
assert.ok(setFocus);
});
test("answering the last discriminator persists a year-locked family collect focus, not a yearless D24 card", async () => {
const accounting = persistChoiceAccounting(familyCollectDossier(), {
set_agentic_rectification_conversation_focus: (_fn, args) => ({
@@ -1116,11 +1158,29 @@ test("structured choice narration never persists a fake loading state", () => {
composeChoiceNarration({ optionId: "A", scoring: true, appliedInference: true }),
composeChoiceNarration({ optionId: "A", scoring: true, appliedInference: false }),
composeChoiceNarration({ optionId: "A", scoring: false, appliedInference: false }),
composeChoiceNarration({
optionId: "D",
scoring: true,
appliedInference: true,
answerClass: "unsure",
}),
]) {
assert.doesNotMatch(narration, /正在准备下一步/);
}
});
test("an unsure choice keeps applied inference and does not claim scores changed", () => {
const narration = composeChoiceNarration({
optionId: "D",
scoring: true,
appliedInference: true,
answerClass: "unsure",
});
// 旧:appliedInference 仍说「更新了候选比较」→ 新:unsure 不计分旁白 → 保留 inference 行仍持久化
assert.equal(narration, "已记录。这题先不计分,换一件事问。");
assert.doesNotMatch(narration, /更新了候选比较/);
});
test("the public agent route treats structured choice as a non-model command", () => {
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
const start = route.indexOf("if (isStructuredChoice)");
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts";
@@ -7,7 +8,9 @@ import { EFFECTIVE_ANSWER_SAFETY_CAP } from "../src/lib/birth-time-dynamic-stop-
import {
DEFAULT_MAX_DISCRIMINATION_ROUNDS,
INFERENCE_ALGORITHM_VERSION,
type ConflictProbe,
type InferenceState,
type RoundTrace,
} from "../src/lib/rectification-agentic/core/types.ts";
import {
MIN_STANDALONE_DATED_DOMAINS,
@@ -92,13 +95,22 @@ function dossier(rows: DecisionDossier["evidence"], state?: InferenceState): Dec
candidates: DOSSIER_CANDIDATES,
representativeTime: "05:00",
evidenceLedgerFingerprint: evidenceLedgerFingerprint(rows as never),
decisionReceipt: state ? { inference_state: state } : null,
decisionReceipt: {
acceptance_allowed: true,
selection_allowed: true,
propose_allowed: true,
confirmation_allowed: true,
...(state ? { inference_state: state } : {}),
},
},
case: { acceptedTime: null },
};
}
function inferenceState(answers: InferenceState["answered_probes"]): InferenceState {
function inferenceState(
answers: InferenceState["answered_probes"],
extra: Partial<InferenceState> = {},
): InferenceState {
return {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: candidateSetId("05:00", "05:07", DOSSIER_CANDIDATES.map((item) => item.time)),
@@ -118,13 +130,67 @@ function inferenceState(answers: InferenceState["answered_probes"]): InferenceSt
rank: item.rank,
strong_conflict_count: 0,
})),
events: [],
events: [
{ id: "e1", domain: "career", year: 2011, precision: "year", usage: "training" },
{ id: "e2", domain: "relationship", year: 2012, precision: "year", usage: "training" },
{ id: "e3", domain: "family", year: 2013, precision: "year", usage: "training" },
],
probes: [],
answered_probes: answers,
rounds: [],
entropy: 1,
representative_time: "05:00",
credible_range: ["05:00", "05:07"],
...extra,
};
}
function remainingProbe(id: string, year: number): ConflictProbe {
return {
id,
semantic_key: `finance.${year}.test`,
candidate_split_hash: `split-${id}`,
domain: "finance",
year,
question: `${year} 年前后收入有没有明显变化?`,
candidate_ids: ["05:00", "05:06", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:06", "05:07"] },
{ answer_class: "no", supports: ["05:06", "05:07"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.5,
source: "dasha_boundary",
};
}
function lowInformationRounds(count: number): RoundTrace[] {
return Array.from({ length: count }, (_, index) => ({
round: index + 1,
phase: "discrimination" as const,
probe_id: `probe-${index + 1}`,
scores_before: {},
scores_after: {},
entropy_before: 1,
entropy_after: 1,
eliminated_ids: [],
winner_id: null,
kind: "low_information" as const,
}));
}
function choiceAnswer(
probeId: string,
year: number,
answerClass: "yes" | "no" | "unsure",
classifiedFrom: "choice" | "declined" = "choice",
) {
return {
probe_id: probeId,
semantic_key: `career.${year}.test`,
candidate_split_hash: `split-${probeId}`,
answer_class: answerClass,
classified_from: classifiedFrom,
};
}
@@ -340,39 +406,62 @@ test("dossier and post-inference decisions share the half-uncertain stop rule",
evidence("career", 1),
evidence("relationship", 2),
evidence("family", 3),
evidence("education", 4),
];
const nextProbe = remainingProbe("probe-next", 2023);
const oneUnsure = inferenceState(
[choiceAnswer("probe-1", 2020, "unsure")],
{ probes: [remainingProbe("probe-1", 2020), nextProbe] },
);
const oneUnsureDecision = decideFromDossier(dossier(rows, oneUnsure));
// 旧:1 答 1 unsure 即停 → 新:不足 3 答不停 → 保留 达到下限后一半不确定仍停
assert.equal(oneUnsureDecision.stopReason ?? null, null);
assert.equal(oneUnsureDecision.nextAction, "ask_candidate_discriminator");
assert.equal(decideAfterInferenceChange({
dossier: dossier(rows),
state: oneUnsure,
userStopped: false,
}).nextAction, "ask_candidate_discriminator");
const halfUncertain = inferenceState([
{
probe_id: "probe-1",
semantic_key: "career.2020.test",
candidate_split_hash: "split-1",
answer_class: "unsure",
classified_from: "choice",
},
{
probe_id: "probe-2",
semantic_key: "relationship.2021.test",
candidate_split_hash: "split-2",
answer_class: "yes",
classified_from: "choice",
},
choiceAnswer("probe-1", 2020, "unsure"),
choiceAnswer("probe-2", 2021, "yes"),
]);
assert.equal(decideFromDossier(dossier(rows, halfUncertain)).stopReason, "user_uncertainty_too_high");
assert.equal(decideFromDossier(dossier(rows, halfUncertain)).stopReason ?? null, null);
assert.equal(decideAfterInferenceChange({
dossier: dossier(rows),
state: halfUncertain,
userStopped: false,
}).stopReason ?? null, null);
const threeAnswersTwoUnsure = inferenceState([
choiceAnswer("probe-1", 2020, "unsure"),
choiceAnswer("probe-2", 2021, "yes"),
choiceAnswer("probe-3", 2022, "unsure"),
]);
assert.equal(decideFromDossier(dossier(rows, threeAnswersTwoUnsure)).stopReason, "user_uncertainty_too_high");
assert.equal(decideAfterInferenceChange({
dossier: dossier(rows),
state: threeAnswersTwoUnsure,
userStopped: false,
}).stopReason, "user_uncertainty_too_high");
const fourAnswersTwoUnsure = inferenceState([
choiceAnswer("probe-1", 2020, "unsure"),
choiceAnswer("probe-2", 2021, "yes"),
choiceAnswer("probe-3", 2022, "unsure"),
choiceAnswer("probe-4", 2023, "yes"),
]);
assert.equal(decideAfterInferenceChange({
dossier: dossier(rows),
state: fourAnswersTwoUnsure,
userStopped: false,
}).stopReason, "user_uncertainty_too_high");
const belowHalf = inferenceState([
...halfUncertain.answered_probes,
{
probe_id: "probe-3",
semantic_key: "family.2022.test",
candidate_split_hash: "split-3",
answer_class: "no",
classified_from: "choice",
},
choiceAnswer("probe-1", 2020, "unsure"),
choiceAnswer("probe-2", 2021, "yes"),
choiceAnswer("probe-3", 2022, "no"),
]);
assert.equal(decideAfterInferenceChange({
dossier: dossier(rows),
@@ -381,7 +470,9 @@ test("dossier and post-inference decisions share the half-uncertain stop rule",
}).stopReason ?? null, null);
const evidenceDoesNotDilute = inferenceState([
...halfUncertain.answered_probes,
choiceAnswer("probe-1", 2020, "unsure"),
choiceAnswer("probe-2", 2021, "yes"),
choiceAnswer("probe-3", 2022, "unsure"),
...[1, 2, 3].map((index) => ({
probe_id: `evidence-${index}`,
semantic_key: `evidence.${index}`,
@@ -397,14 +488,9 @@ test("dossier and post-inference decisions share the half-uncertain stop rule",
}).stopReason, "user_uncertainty_too_high");
const halfDeclined = inferenceState([
{
probe_id: "probe-1",
semantic_key: "career.2020.test",
candidate_split_hash: "split-1",
answer_class: "no",
classified_from: "declined",
},
halfUncertain.answered_probes[1],
choiceAnswer("probe-1", 2020, "no", "declined"),
choiceAnswer("probe-2", 2021, "yes"),
choiceAnswer("probe-3", 2022, "unsure"),
]);
assert.equal(decideAfterInferenceChange({
dossier: dossier(rows),
@@ -413,6 +499,61 @@ test("dossier and post-inference decisions share the half-uncertain stop rule",
}).stopReason, "user_uncertainty_too_high");
});
test("unsure plateau rounds wait for the uncertainty sample floor", () => {
const rows = [
evidence("career", 1),
evidence("relationship", 2),
evidence("family", 3),
evidence("education", 4),
];
const twoUnsure = inferenceState(
[choiceAnswer("probe-1", 2020, "unsure"), choiceAnswer("probe-2", 2021, "unsure")],
{
probes: [remainingProbe("probe-next", 2023)],
rounds: lowInformationRounds(2),
},
);
const early = decideAfterInferenceChange({
dossier: dossier(rows),
state: twoUnsure,
userStopped: false,
});
assert.equal(early.stopReason ?? null, null);
assert.notEqual(early.nextAction, "complete_with_range");
assert.notEqual(early.sessionOutcome, "adopt_representative");
const afterFloor = inferenceState(
[
choiceAnswer("probe-1", 2020, "yes"),
choiceAnswer("probe-2", 2021, "yes"),
choiceAnswer("probe-3", 2022, "yes"),
choiceAnswer("probe-4", 2023, "unsure"),
choiceAnswer("probe-5", 2024, "unsure"),
],
{
probes: [remainingProbe("probe-next", 2025)],
rounds: lowInformationRounds(2),
},
);
const plateaued = decideAfterInferenceChange({
dossier: dossier(rows),
state: afterFloor,
userStopped: false,
});
assert.equal(plateaued.nextAction, "complete_with_range");
assert.equal(plateaued.sessionOutcome, "adopt_representative");
});
test("minUncertaintyAnswers is defined once in policy json", () => {
assert.equal(RECTIFICATION_POLICY.minUncertaintyAnswers, 3);
assert.equal(RECTIFICATION_POLICY.maxPlateauRounds, 2);
const policy = readFileSync(
new URL("../../references/rectification_policy.v1.json", import.meta.url),
"utf8",
);
assert.equal([...policy.matchAll(/minUncertaintyAnswers/g)].length, 1);
});
test("rectification label ladder follows evidence state and explicit adapter support", () => {
const blocked = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
@@ -673,6 +673,8 @@ test("public decision fields are derived from decideRectification", () => {
precision_stage: decision.precisionStage,
representative_time: decision.representativeTime,
credible_range: decision.credibleRange,
// 旧 公开字段无 stop_reason → 新 只读暴露停止原因 → 保留 采用/确认门字段语义不变
stop_reason: decision.stopReason ?? null,
});
const publicOverlay = overlayPublicDecision({ selectionAllowed: true }, decision);
assert.equal(publicOverlay.selectionAllowed, false);
+2 -1
View File
@@ -6,5 +6,6 @@
"maxExternalValidationWidthMinutes": 15,
"maxConfirmationWidthMinutes": 5,
"minConfirmationMarginPercent": 20,
"maxPlateauRounds": 2
"maxPlateauRounds": 2,
"minUncertaintyAnswers": 3
}