Files
Jyotisha/frontend/tests/rectification-convergence-budget.test.ts
T
Jesse_ChenandClaude Opus 5.5 ea21743b09 fix(rectification): stop spoken collect once the training gate opens (BUG-1084..1087)
Once the discriminator training gate is open, only choice cards are asked
and the range card goes out when they are exhausted; targeted lines, their
re-ask and guided windows no longer hold the card or invite more events.
Delivery body says how many choice questions were used instead of the event
fit percent; narration names an excluded cluster instead of "range
unchanged"; a delivered turn no longer carries a collect question.

Offline replay (v4, 3 radii x 2 directions): truth in range 20/20 in every
cell; guided-window injections give the same width in truth and opposite
directions, so red line 1 was revised by product to truth-in-range only.
Skill 10.0.31 -> 10.0.32 (10.0.31 kept as deprecated for pinned cases).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-29 10:06:57 +08:00

614 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=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: 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");
});