20df8c6022
deliveryCapability no longer ties adoption to minute separation or holdout; those stay on the exact-minute confirmation gate so users can save a range. Co-authored-by: Cursor <cursoragent@cursor.com>
459 lines
15 KiB
TypeScript
459 lines
15 KiB
TypeScript
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<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: 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<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),
|
|
];
|
|
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");
|
|
});
|