import assert from "node:assert/strict"; 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 InferenceState, } 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 = {}) { 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: state ? { inference_state: state } : null, }, case: { acceptedTime: null }, }; } function inferenceState(answers: InferenceState["answered_probes"]): 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: [], probes: [], answered_probes: answers, rounds: [], entropy: 1, representative_time: "05:00", credible_range: ["05:00", "05:07"], }; } 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[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), ]; 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", }, ]); assert.equal(decideFromDossier(dossier(rows, halfUncertain)).stopReason, "user_uncertainty_too_high"); assert.equal(decideAfterInferenceChange({ dossier: dossier(rows), state: halfUncertain, 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", }, ]); assert.equal(decideAfterInferenceChange({ dossier: dossier(rows), state: belowHalf, userStopped: false, }).stopReason ?? null, null); const evidenceDoesNotDilute = inferenceState([ ...halfUncertain.answered_probes, ...[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([ { probe_id: "probe-1", semantic_key: "career.2020.test", candidate_split_hash: "split-1", answer_class: "no", classified_from: "declined", }, halfUncertain.answered_probes[1], ]); assert.equal(decideAfterInferenceChange({ dossier: dossier(rows), state: halfDeclined, userStopped: false, }).stopReason, "user_uncertainty_too_high"); }); 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"); });