出卡时机只看题源有没有空:撤回「门槛达标就短路采集线」的写法,同时 按 D2 保住「题源全空就按现行规则出卡」——门槛只在还有题可问时挡住 出卡,precision_gate_met 改成只上报(新挂在决策与公开投影上),不再 单独决定时机。引导窗口题在无领域轨道上改问开放题,一个时间窗只问一 次;录入卡提交的是「YYYY 年 M 月,<领域>方面有一件事」,不再是题干 的三选一列表。记忆化 golden 只补一个新键并冻结墙钟。离线回放改成注 入真值方向的边界事件,另跑一组反方向对照。Skill 10.0.28。 BUG-747~752 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
613 lines
21 KiB
TypeScript
613 lines
21 KiB
TypeScript
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";
|
||
|
||
import { EFFECTIVE_ANSWER_SAFETY_CAP } from "../src/lib/birth-time-dynamic-stop-policy.ts";
|
||
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,
|
||
MIN_STANDALONE_DATED_EVENTS,
|
||
RECTIFICATION_TERMINATION_COPY,
|
||
decideRectification,
|
||
type EvidenceStopReason,
|
||
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
||
import { RECTIFICATION_POLICY } from "../src/lib/rectification-policy.ts";
|
||
import type { CandidateDiscriminatorProbe } from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
|
||
import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||
import {
|
||
decideAfterInferenceChange,
|
||
decideFromDossier,
|
||
type DecisionDossier,
|
||
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||
import { rectificationLabel } from "../src/lib/rectification-agentic/v9/rectification-label.ts";
|
||
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||
|
||
const PROBE: CandidateDiscriminatorProbe = {
|
||
probeId: "probe-1",
|
||
candidateSetVersion: "set-1",
|
||
question: "这件事更接近哪一种情况?",
|
||
expectedOutcomes: [
|
||
{
|
||
outcomeId: "yes",
|
||
supportsCandidateIds: ["05:00"],
|
||
conflictsCandidateIds: ["05:06", "05:07"],
|
||
},
|
||
{
|
||
outcomeId: "no",
|
||
supportsCandidateIds: ["05:06", "05:07"],
|
||
conflictsCandidateIds: ["05:00"],
|
||
},
|
||
],
|
||
candidateSplitHash: "05:00|05:06|05:07",
|
||
informationGain: 0.5,
|
||
sourceFeatures: [{ technique: "test", calculationResultId: null }],
|
||
domain: "career",
|
||
year: 2020,
|
||
semanticKey: "career.2020.test",
|
||
};
|
||
|
||
const BASE_INPUT = {
|
||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||
methodCoverageAll: true,
|
||
trainingGateOpen: true,
|
||
candidateScores: [
|
||
{ time: "05:00", score: 34 },
|
||
{ time: "05:06", score: 33 },
|
||
{ time: "05:07", score: 33 },
|
||
],
|
||
discriminatorProbe: PROBE,
|
||
holdoutValidation: "unavailable" as const,
|
||
};
|
||
|
||
const DOSSIER_CANDIDATES = BASE_INPUT.candidateScores.map((item, index) => ({
|
||
candidateId: `candidate-${index + 1}`,
|
||
time: item.time,
|
||
rank: index + 1,
|
||
relativeSupport: item.score,
|
||
}));
|
||
|
||
function evidence(domain: string, index: number, overrides: Partial<DecisionDossier["evidence"][number]> = {}) {
|
||
return {
|
||
id: `evidence-${index}`,
|
||
status: "confirmed",
|
||
domain,
|
||
datePrecision: "year",
|
||
occurredFrom: `${2010 + index}-01-01`,
|
||
occurredTo: null,
|
||
eventKind: `${domain}_event`,
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
function dossier(rows: DecisionDossier["evidence"], state?: InferenceState): DecisionDossier {
|
||
return {
|
||
evidence: rows,
|
||
conversationSummary: { activeFocus: null, declinedSkippedTopics: [] },
|
||
latestResult: {
|
||
candidates: DOSSIER_CANDIDATES,
|
||
representativeTime: "05:00",
|
||
evidenceLedgerFingerprint: evidenceLedgerFingerprint(rows as never),
|
||
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"],
|
||
extra: Partial<InferenceState> = {},
|
||
): InferenceState {
|
||
return {
|
||
algorithm_version: INFERENCE_ALGORITHM_VERSION,
|
||
candidate_set_id: candidateSetId("05:00", "05:07", DOSSIER_CANDIDATES.map((item) => item.time)),
|
||
revision: 1,
|
||
phase: "discrimination",
|
||
result_status: "discriminating",
|
||
range_start: "05:00",
|
||
range_end: "05:07",
|
||
candidates: DOSSIER_CANDIDATES.map((item) => ({
|
||
id: item.candidateId,
|
||
time: item.time,
|
||
cluster_range: [item.time, item.time],
|
||
prior_score: item.relativeSupport,
|
||
posterior_score: item.relativeSupport,
|
||
probability: item.relativeSupport / 100,
|
||
status: "active" as const,
|
||
rank: item.rank,
|
||
strong_conflict_count: 0,
|
||
})),
|
||
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,
|
||
};
|
||
}
|
||
|
||
function decideWithBudget(budget: {
|
||
inferenceRounds?: number;
|
||
effectiveAnswerCount?: number;
|
||
plateauRounds?: number;
|
||
}) {
|
||
return decideRectification({ ...BASE_INPUT, ...budget });
|
||
}
|
||
|
||
test("every persisted discrimination budget terminates before asking another probe", () => {
|
||
for (const budget of [
|
||
{ inferenceRounds: DEFAULT_MAX_DISCRIMINATION_ROUNDS },
|
||
{ effectiveAnswerCount: EFFECTIVE_ANSWER_SAFETY_CAP },
|
||
{ plateauRounds: RECTIFICATION_POLICY.maxPlateauRounds },
|
||
]) {
|
||
const decision = decideWithBudget(budget);
|
||
assert.equal(decision.nextAction, "complete_with_range");
|
||
// 原断言 sessionOutcome=completed_with_range → 新断言 adopt_representative。
|
||
// 为什么:预算耗尽只结束提问;覆盖完成且引擎可出牌时应交付代表性采用,不挡在 review-only 区间。
|
||
assert.equal(decision.sessionOutcome, "adopt_representative");
|
||
assert.equal(decision.canAdopt, true);
|
||
assert.equal(decision.canConfirmExactMinute, false);
|
||
assert.notEqual(decision.nextAction, "ask_candidate_discriminator");
|
||
}
|
||
});
|
||
|
||
test("repeated declined or unsure answers reach the existing plateau terminal", () => {
|
||
const decision = decideWithBudget({
|
||
inferenceRounds: 0,
|
||
effectiveAnswerCount: RECTIFICATION_POLICY.maxPlateauRounds,
|
||
plateauRounds: RECTIFICATION_POLICY.maxPlateauRounds,
|
||
});
|
||
assert.equal(decision.nextAction, "complete_with_range");
|
||
// 原断言 sessionOutcome=completed_with_range → 新断言 adopt_representative。
|
||
assert.equal(decision.sessionOutcome, "adopt_representative");
|
||
assert.equal(decision.canAdopt, true);
|
||
assert.equal(decision.canConfirmExactMinute, false);
|
||
});
|
||
|
||
test("exhausted discrimination still delivers the credible candidate range", () => {
|
||
const decision = decideWithBudget({
|
||
inferenceRounds: DEFAULT_MAX_DISCRIMINATION_ROUNDS,
|
||
effectiveAnswerCount: 0,
|
||
plateauRounds: 0,
|
||
});
|
||
assert.equal(decision.resultStatus, "completed_with_range");
|
||
// 原断言 sessionOutcome=completed_with_range / canAdopt=false → 新断言 adopt_representative / canAdopt=true。
|
||
// 为什么:exhausted 只结束区分轮,不挡代表性采用;唯一分钟确认门仍关。
|
||
assert.equal(decision.sessionOutcome, "adopt_representative");
|
||
assert.equal(decision.canOfferRange, true);
|
||
assert.equal(decision.canAdopt, true);
|
||
assert.equal(decision.canConfirmExactMinute, false);
|
||
assert.deepEqual(decision.credibleRange, ["05:00", "05:07"]);
|
||
});
|
||
|
||
test("additional score evidence never widens the credible range", () => {
|
||
const before = decideRectification({
|
||
...BASE_INPUT,
|
||
candidateScores: [
|
||
{ time: "05:00", score: 34 },
|
||
{ time: "05:06", score: 33 },
|
||
{ time: "05:07", score: 33 },
|
||
],
|
||
inferenceRounds: DEFAULT_MAX_DISCRIMINATION_ROUNDS,
|
||
});
|
||
const after = decideRectification({
|
||
...BASE_INPUT,
|
||
candidateScores: [
|
||
{ time: "05:00", score: 42 },
|
||
{ time: "05:06", score: 33 },
|
||
{ time: "05:07", score: 33 },
|
||
],
|
||
inferenceRounds: DEFAULT_MAX_DISCRIMINATION_ROUNDS,
|
||
});
|
||
|
||
const width = (range: readonly [string, string] | null) => {
|
||
assert.ok(range);
|
||
const toMinutes = (time: string) => Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5));
|
||
return toMinutes(range[1]) - toMinutes(range[0]);
|
||
};
|
||
|
||
assert.ok(width(after.credibleRange) <= width(before.credibleRange));
|
||
});
|
||
|
||
test("insufficient standalone evidence keeps collecting", () => {
|
||
for (const item of [
|
||
{ reason: "insufficient_dated_events" as const, input: { datedEventCount: 2, datedDomainCount: 2 } },
|
||
{ reason: "insufficient_domains" as const, input: { datedEventCount: 3, datedDomainCount: 1 } },
|
||
]) {
|
||
const decision = decideRectification({ ...BASE_INPUT, ...item.input });
|
||
assert.equal(decision.nextAction, "ask_fact_collection");
|
||
assert.equal(decision.sessionOutcome, "collect_evidence");
|
||
assert.equal(decision.canOfferRange, false);
|
||
assert.equal(decision.stopReason, item.reason);
|
||
}
|
||
});
|
||
|
||
test("exhausted evidence-state stops complete with a usable range and the fixed termination copy", () => {
|
||
const cases: readonly Readonly<{
|
||
reason: EvidenceStopReason;
|
||
input: Partial<Parameters<typeof decideRectification>[0]>;
|
||
}>[] = [
|
||
{
|
||
reason: "tied_first",
|
||
input: {
|
||
datedEventCount: 3,
|
||
datedDomainCount: 2,
|
||
candidateScores: [
|
||
{ time: "05:00", score: 34 },
|
||
{ time: "05:06", score: 34 },
|
||
{ time: "05:07", score: 32 },
|
||
],
|
||
discriminatorProbe: null,
|
||
},
|
||
},
|
||
{
|
||
reason: "user_uncertainty_too_high",
|
||
input: { datedEventCount: 3, datedDomainCount: 2, userUncertaintyHigh: true },
|
||
},
|
||
];
|
||
|
||
for (const item of cases) {
|
||
const decision = decideRectification({ ...BASE_INPUT, ...item.input });
|
||
assert.equal(decision.nextAction, "complete_with_range");
|
||
// 原断言 sessionOutcome=completed_with_range → 新断言 adopt_representative。
|
||
assert.equal(decision.sessionOutcome, "adopt_representative");
|
||
assert.equal(decision.resultStatus, "completed_with_range");
|
||
assert.equal(decision.canOfferRange, true);
|
||
assert.equal(decision.canAdopt, true);
|
||
assert.equal(decision.canConfirmExactMinute, false);
|
||
assert.ok(decision.credibleRange);
|
||
assert.equal(decision.stopReason, item.reason);
|
||
assert.equal(decision.terminationCopy, RECTIFICATION_TERMINATION_COPY);
|
||
}
|
||
});
|
||
|
||
test("stale snapshots cannot complete or adopt an evidence-stop range", () => {
|
||
const withProbe = decideRectification({
|
||
...BASE_INPUT,
|
||
snapshotCurrent: false,
|
||
datedEventCount: 2,
|
||
datedDomainCount: 2,
|
||
});
|
||
assert.equal(withProbe.nextAction, "ask_candidate_discriminator");
|
||
assert.equal(withProbe.canAdopt, false);
|
||
assert.equal(withProbe.selectionAllowed, false);
|
||
|
||
const withoutProbe = decideRectification({
|
||
...BASE_INPUT,
|
||
discriminatorProbe: null,
|
||
snapshotCurrent: false,
|
||
datedEventCount: 2,
|
||
datedDomainCount: 2,
|
||
});
|
||
assert.equal(withoutProbe.nextAction, "ask_fact_collection");
|
||
assert.equal(withoutProbe.canAdopt, false);
|
||
assert.equal(withoutProbe.selectionAllowed, false);
|
||
});
|
||
|
||
test("an exact first-place tie is distinct from a narrow 34/33/33 lead", () => {
|
||
const narrowLead = decideRectification({
|
||
...BASE_INPUT,
|
||
datedEventCount: 3,
|
||
datedDomainCount: 2,
|
||
});
|
||
assert.equal(narrowLead.separation.tiedForFirst, false);
|
||
assert.equal(narrowLead.stopReason, undefined);
|
||
assert.equal(narrowLead.nextAction, "ask_candidate_discriminator");
|
||
|
||
const exactTie = decideRectification({
|
||
...BASE_INPUT,
|
||
datedEventCount: 3,
|
||
datedDomainCount: 2,
|
||
candidateScores: [
|
||
{ time: "05:00", score: 34 },
|
||
{ time: "05:06", score: 34 },
|
||
{ time: "05:07", score: 32 },
|
||
],
|
||
});
|
||
assert.equal(exactTie.separation.tiedForFirst, true);
|
||
assert.equal(exactTie.stopReason, "tied_first");
|
||
});
|
||
|
||
test("standalone delivery floor remains separate from exact-minute confirmation", () => {
|
||
assert.equal(MIN_STANDALONE_DATED_EVENTS, 3);
|
||
assert.equal(MIN_STANDALONE_DATED_DOMAINS, 2);
|
||
assert.equal(RECTIFICATION_POLICY.minConfirmationEvents, 4);
|
||
assert.equal(RECTIFICATION_POLICY.minConfirmationDomains, 3);
|
||
});
|
||
|
||
test("dossier wiring counts only confirmed dated primary events", () => {
|
||
const decision = decideFromDossier(dossier([
|
||
evidence("career", 1),
|
||
evidence("relationship", 2),
|
||
evidence("family", 3, { status: "draft" }),
|
||
evidence("education", 4, { datePrecision: "unknown", occurredFrom: null }),
|
||
evidence("occupation", 5, { eventKind: "occupation_note" }),
|
||
]));
|
||
assert.equal(decision.stopReason, "insufficient_dated_events");
|
||
|
||
const oneDomain = decideFromDossier(dossier([
|
||
evidence("career", 1),
|
||
evidence("career", 2),
|
||
evidence("career", 3),
|
||
]));
|
||
assert.equal(oneDomain.stopReason, "insufficient_domains");
|
||
});
|
||
|
||
test("dossier and post-inference decisions share the half-uncertain stop rule", () => {
|
||
const rows = [
|
||
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([
|
||
choiceAnswer("probe-1", 2020, "unsure"),
|
||
choiceAnswer("probe-2", 2021, "yes"),
|
||
]);
|
||
// 原值: stopReason="probe_pool_exhausted"(BUG-646 探针池空即停)
|
||
// 新值: stopReason=null,nextAction=ask_fact_collection
|
||
// 原因: D6——探针池空只是没有点选题,采集线还没问完就不算停;两条路径
|
||
// 仍然同口径(题名要的「share the half-uncertain stop rule」)(BUG-751)
|
||
const halfFromDossier = decideFromDossier(dossier(rows, halfUncertain));
|
||
const halfAfterInference = decideAfterInferenceChange({
|
||
dossier: dossier(rows),
|
||
state: halfUncertain,
|
||
userStopped: false,
|
||
});
|
||
assert.equal(halfFromDossier.stopReason ?? null, null);
|
||
assert.equal(halfAfterInference.stopReason ?? null, halfFromDossier.stopReason ?? null);
|
||
assert.equal(halfFromDossier.nextAction, halfAfterInference.nextAction);
|
||
|
||
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(
|
||
[
|
||
choiceAnswer("probe-1", 2020, "unsure"),
|
||
choiceAnswer("probe-2", 2021, "yes"),
|
||
choiceAnswer("probe-3", 2022, "no"),
|
||
],
|
||
{ probes: [remainingProbe("probe-next", 2023)] },
|
||
);
|
||
// 原值: probes 默认空仍继续
|
||
// 新值: 探针池非空且不确定未过半时继续区分;空池会走 probe_pool_exhausted
|
||
// 原因: BUG-646 S2 停条件含探针池空
|
||
assert.equal(decideAfterInferenceChange({
|
||
dossier: dossier(rows),
|
||
state: belowHalf,
|
||
userStopped: false,
|
||
}).stopReason ?? null, null);
|
||
|
||
const evidenceDoesNotDilute = inferenceState([
|
||
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}`,
|
||
candidate_split_hash: `evidence-${index}`,
|
||
answer_class: "yes" as const,
|
||
classified_from: "evidence" as const,
|
||
})),
|
||
]);
|
||
assert.equal(decideAfterInferenceChange({
|
||
dossier: dossier(rows),
|
||
state: evidenceDoesNotDilute,
|
||
userStopped: false,
|
||
}).stopReason, "user_uncertainty_too_high");
|
||
|
||
const halfDeclined = inferenceState([
|
||
choiceAnswer("probe-1", 2020, "no", "declined"),
|
||
choiceAnswer("probe-2", 2021, "yes"),
|
||
choiceAnswer("probe-3", 2022, "unsure"),
|
||
]);
|
||
assert.equal(decideAfterInferenceChange({
|
||
dossier: dossier(rows),
|
||
state: halfDeclined,
|
||
userStopped: false,
|
||
}).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,
|
||
methodCoverageAll: false,
|
||
trainingGateOpen: false,
|
||
candidateScores: [],
|
||
});
|
||
// 原值是 blocked;空候选现在被分类为证据不足,不能继续无原因地 fail-open。
|
||
assert.equal(rectificationLabel({ decision: blocked }), "user_history_verification_required");
|
||
|
||
const stopped = decideRectification({
|
||
...BASE_INPUT,
|
||
datedEventCount: 2,
|
||
datedDomainCount: 2,
|
||
});
|
||
assert.equal(rectificationLabel({ decision: stopped }), "user_history_verification_required");
|
||
|
||
const continuing = decideRectification({
|
||
...BASE_INPUT,
|
||
datedEventCount: 3,
|
||
datedDomainCount: 2,
|
||
});
|
||
assert.equal(rectificationLabel({ decision: continuing }), "manual_pattern_consensus");
|
||
assert.equal(rectificationLabel({
|
||
decision: continuing,
|
||
supportedAdapterCount: 1,
|
||
}), "single_adapter_support");
|
||
assert.equal(rectificationLabel({
|
||
decision: continuing,
|
||
supportedAdapterCount: 2,
|
||
}), "multi_adapter_consensus");
|
||
|
||
const tied = decideRectification({
|
||
...BASE_INPUT,
|
||
datedEventCount: 3,
|
||
datedDomainCount: 2,
|
||
candidateScores: [
|
||
{ time: "05:00", score: 34 },
|
||
{ time: "05:06", score: 34 },
|
||
],
|
||
});
|
||
assert.equal(rectificationLabel({ decision: tied }), "blocked");
|
||
});
|