Files
Jyotisha/frontend/tests/rectification-convergence-budget.test.ts
T
Jesse_ChenandClaude Opus 5.5 758fee954b feat(rectification): range delivery needs 4 dated events across 3 domains (R1, BUG-1193)
Product decision 2026-10-02 (TASK-upstream-sync5 R1): a time range is offered
only with at least 4 dated, primary-scoreable events covering 3 domains,
counted on all of them (training + reserved holdout). Was 3 training events /
2 domains in three TS copies and the Python acceptance gate while the policy
file already said 4/3.

- One definition: references/rectification_policy.v1.json
  (minConfirmationEvents / minConfirmationDomains). TS core/types MIN_DATED_*,
  rectification-decision MIN_STANDALONE_*, evidence-model MIN_ACCEPTANCE_*,
  the convergence evaluator and the post-inference trainingGateOpen all read
  it; Python decision_policy MIN_ACCEPTANCE_* alias MIN_CONFIRMATION_*.
- Python receipt counts all scoreable events / domains for event_quality and
  domain_diversity; decision policy identity v3 -> v4 (candidate UUIDs carry
  it). Candidate scores unchanged (77 v5 cases A/B identical), so the
  algorithm stays rectification-v5-matrix-scoring-10.
- Memoization golden v3 written by write_golden; v2 frozen by sha256 with a
  test that its scores equal v3 and only the receipt policy moved.
- Collect gap copy names the exact gap ("再来两件……其中至少一件不是……")
  instead of always "再来一件"; VOICE.md updated. Legacy life-events form copy
  4/3 as well.
- 30 frontend test files, 4 Python tests: fixtures extended to the same
  scenario at 4/3, or assertions changed with 原值/新值/原因 notes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-03 00:08:00 +08:00

628 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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" },
// R1(BUG-1193):补第 4 件(holdout),与 dossier 的 4 条证据一致,保持原场景。
{ id: "e4", domain: "education", year: 2014, precision: "year", usage: "holdout" },
],
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 [
// 原值: {2 件/2 域}→insufficient_dated_events,{3 件/1 域}→insufficient_domains
// 新值: {3 件/3 域}→insufficient_dated_events,{4 件/2 域}→insufficient_domains
// 原因: R1 生时校正交付门槛 4 件 3 域(BUG-1193),两个分支各取门槛下方一格
{ reason: "insufficient_dated_events" as const, input: { datedEventCount: 3, datedDomainCount: 3 } },
{ reason: "insufficient_domains" as const, input: { datedEventCount: 4, datedDomainCount: 2 } },
]) {
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: 4, // R1(BUG-1193):门槛上 3/2 → 4/3,保持原场景。
datedDomainCount: 3,
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: 4, datedDomainCount: 3, userUncertaintyHigh: true }, // R1(BUG-1193):3/2 → 4/3
},
];
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: 4, // R1(BUG-1193):门槛上 3/2 → 4/3,保持原场景。
datedDomainCount: 3,
});
assert.equal(narrowLead.separation.tiedForFirst, false);
assert.equal(narrowLead.stopReason, undefined);
assert.equal(narrowLead.nextAction, "ask_candidate_discriminator");
const exactTie = decideRectification({
...BASE_INPUT,
datedEventCount: 4, // R1(BUG-1193):门槛上 3/2 → 4/3,保持原场景。
datedDomainCount: 3,
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 shares the 4/3 policy with exact-minute confirmation", () => {
// 原值: MIN_STANDALONE_DATED_EVENTS=3、MIN_STANDALONE_DATED_DOMAINS=2(区间门槛低于精确分钟确认门槛)
// 新值: 4、3,且与 RECTIFICATION_POLICY 同源
// 原因: R1 生时校正交付门槛 4 件 3 域(BUG-1193),只保留 rectification_policy.v1.json 一处定义
assert.equal(MIN_STANDALONE_DATED_EVENTS, 4);
assert.equal(MIN_STANDALONE_DATED_DOMAINS, 3);
assert.equal(MIN_STANDALONE_DATED_EVENTS, RECTIFICATION_POLICY.minConfirmationEvents);
assert.equal(MIN_STANDALONE_DATED_DOMAINS, RECTIFICATION_POLICY.minConfirmationDomains);
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");
// 原值: 3 条 career(3 件 1 域)→ insufficient_domains
// 新值: 4 条只覆盖 career + relationship(4 件 2 域)→ insufficient_domains
// 原因: R1 生时校正交付门槛 4 件 3 域(BUG-1193),3 件会先落到 insufficient_dated_events
const oneDomain = decideFromDossier(dossier([
evidence("career", 1),
evidence("career", 2),
evidence("career", 3),
evidence("relationship", 4),
]));
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,
});
// 原值(2): stopReason=null;新值(2): probe_pool_exhausted;原因(2): 门开后定向线与引导题不再挡卡(BUG-1084,2026-09-29 D1),回到 BUG-751 之前的值
assert.equal(halfFromDossier.stopReason, "probe_pool_exhausted");
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: 4, // R1(BUG-1193):门槛上 3/2 → 4/3,保持原场景。
datedDomainCount: 3,
});
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: 4, // R1(BUG-1193):门槛上 3/2 → 4/3,保持原场景。
datedDomainCount: 3,
candidateScores: [
{ time: "05:00", score: 34 },
{ time: "05:06", score: 34 },
],
});
assert.equal(rectificationLabel({ decision: tied }), "blocked");
});