Files
Jyotisha/frontend/tests/rectification-convergence-budget.test.ts
T
Jesse_ChenandClaude Fable 5.1 dc732825d8 fix(rectification): 有题就接着问,题问完才出卡;引导窗口不再硬贴领域
出卡时机只看题源有没有空:撤回「门槛达标就短路采集线」的写法,同时
按 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
2026-09-16 12:16:55 +00:00

613 lines
21 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" },
],
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=nullnextAction=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");
});