fix(rectification): require three answers before uncertainty stop
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:
@@ -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);
|
||||
|
||||
@@ -6,5 +6,6 @@
|
||||
"maxExternalValidationWidthMinutes": 15,
|
||||
"maxConfirmationWidthMinutes": 5,
|
||||
"minConfirmationMarginPercent": 20,
|
||||
"maxPlateauRounds": 2
|
||||
"maxPlateauRounds": 2,
|
||||
"minUncertaintyAnswers": 3
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user