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
parent e8c98c37cf
commit 0c0df42679
11 changed files with 335 additions and 51 deletions
@@ -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:0214: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);