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
428 lines
14 KiB
TypeScript
428 lines
14 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import test from "node:test";
|
||
|
||
import {
|
||
DELIVERY_OUTCOMES,
|
||
decideRectification,
|
||
deliveryNarrationAllowed,
|
||
sessionOutcomeAllowsDelivery,
|
||
type DecisionSessionOutcome,
|
||
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
|
||
import {
|
||
canShowRectificationReadonlyRange,
|
||
canShowRectificationSelectionCards,
|
||
parseRectificationCandidateResult,
|
||
} from "../src/lib/rectification-candidate-result.ts";
|
||
import {
|
||
persistNextInterviewIfIdle,
|
||
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
|
||
import { decideFromDossier } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
|
||
import {
|
||
refreshDatedDiscriminatorPoolIfNeeded,
|
||
resetRefreshDiscriminatorProbesForTests,
|
||
setRefreshDiscriminatorProbesForTests,
|
||
} from "../src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts";
|
||
import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||
import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
|
||
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
|
||
import {
|
||
RECTIFICATION_DELIVERED_COPY,
|
||
RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||
RECTIFICATION_QUESTION_UNAVAILABLE_COPY,
|
||
rectificationQuestionGapState,
|
||
} from "../src/lib/rectification-surface-state.ts";
|
||
import {
|
||
CASE_ID,
|
||
OPEN_ENGINE_CAPABILITY_CEILING,
|
||
TURN_ID,
|
||
USER_ID,
|
||
candidateSnapshotFixture,
|
||
computeFixture,
|
||
dossierFixture,
|
||
fakeAccounting,
|
||
receiptHandlers,
|
||
} from "./rectification-v9-test-support.ts";
|
||
|
||
const TIED_CANDIDATES = [
|
||
{ candidateId: "88888888-8888-4888-8888-888888888881", time: "04:53", rank: 1, relativeSupport: 16 },
|
||
{ candidateId: "88888888-8888-4888-8888-888888888882", time: "05:00", rank: 2, relativeSupport: 16 },
|
||
{ candidateId: "88888888-8888-4888-8888-888888888883", time: "05:06", rank: 3, relativeSupport: 15 },
|
||
{ candidateId: "88888888-8888-4888-8888-888888888884", time: "04:51", rank: 4, relativeSupport: 14 },
|
||
{ candidateId: "88888888-8888-4888-8888-888888888885", time: "04:59", rank: 5, relativeSupport: 13 },
|
||
] as const;
|
||
|
||
const SNAPSHOT_BASE = {
|
||
resultId: "11111111-1111-4111-8111-111111111111",
|
||
candidates: [
|
||
{ candidateId: "88888888-8888-4888-8888-888888888881", rank: 1, time: "04:53", relativeSupport: 16, tiedMinuteCount: 2 },
|
||
{ candidateId: "88888888-8888-4888-8888-888888888882", rank: 2, time: "05:00", relativeSupport: 16, tiedMinuteCount: 2 },
|
||
],
|
||
overallConfidence: "medium" as const,
|
||
selectionAllowed: true,
|
||
canAdopt: false,
|
||
confirmationAllowed: false,
|
||
representativeTime: "04:53",
|
||
selectedTime: null,
|
||
selectionKind: null,
|
||
credibleRange: ["04:48", "05:07"] as const,
|
||
decisionReceipt: {
|
||
display_allowed: true,
|
||
selection_allowed: true,
|
||
acceptance_allowed: true,
|
||
propose_allowed: true,
|
||
confirmation_allowed: false,
|
||
accept_allowed: true,
|
||
confirm_allowed: false,
|
||
},
|
||
};
|
||
|
||
function datedEvidenceRpc() {
|
||
return [
|
||
{
|
||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
|
||
source_turn_id: TURN_ID,
|
||
subject: "self",
|
||
event_kind: "career_entry",
|
||
domain: "career",
|
||
occurred_from: "2016-09-01",
|
||
occurred_to: null,
|
||
date_precision: "month",
|
||
summary: "入职",
|
||
status: "confirmed",
|
||
supersedes_evidence_id: null,
|
||
created_at: "2026-08-12T10:00:06.000Z",
|
||
},
|
||
{
|
||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2",
|
||
source_turn_id: TURN_ID,
|
||
subject: "self",
|
||
event_kind: "education_completion",
|
||
domain: "education",
|
||
occurred_from: "2012-06-01",
|
||
occurred_to: null,
|
||
date_precision: "month",
|
||
summary: "毕业",
|
||
status: "confirmed",
|
||
supersedes_evidence_id: null,
|
||
created_at: "2026-08-12T10:00:07.000Z",
|
||
},
|
||
{
|
||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3",
|
||
source_turn_id: TURN_ID,
|
||
subject: "self",
|
||
event_kind: "family_event",
|
||
domain: "family",
|
||
occurred_from: "2018-03-01",
|
||
occurred_to: null,
|
||
date_precision: "month",
|
||
summary: "家里添丁",
|
||
status: "confirmed",
|
||
supersedes_evidence_id: null,
|
||
created_at: "2026-08-12T10:00:08.000Z",
|
||
},
|
||
{
|
||
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4",
|
||
source_turn_id: TURN_ID,
|
||
subject: "self",
|
||
event_kind: "relationship_start",
|
||
domain: "relationship",
|
||
occurred_from: "2020-05-01",
|
||
occurred_to: null,
|
||
date_precision: "month",
|
||
summary: "恋爱",
|
||
status: "confirmed",
|
||
supersedes_evidence_id: null,
|
||
created_at: "2026-08-12T10:00:09.000Z",
|
||
},
|
||
];
|
||
}
|
||
|
||
function tiedInference(extra: Record<string, unknown> = {}) {
|
||
const times = TIED_CANDIDATES.map((item) => item.time);
|
||
return {
|
||
algorithm_version: INFERENCE_ALGORITHM_VERSION,
|
||
candidate_set_id: candidateSetId("04:48", "05:07", times),
|
||
revision: 6,
|
||
phase: "discrimination",
|
||
result_status: "discriminating",
|
||
range_start: "04:48",
|
||
range_end: "05:07",
|
||
candidates: TIED_CANDIDATES.map((item) => ({
|
||
id: item.candidateId,
|
||
time: item.time,
|
||
cluster_range: ["04:48", "05:07"] as const,
|
||
prior_score: item.relativeSupport,
|
||
posterior_score: item.relativeSupport,
|
||
probability: item.relativeSupport === 16 ? 0.2592219508007118 : item.relativeSupport / 74,
|
||
status: "active" as const,
|
||
rank: item.rank,
|
||
strong_conflict_count: 0,
|
||
})),
|
||
events: datedEvidenceRpc().map((item, index) => ({
|
||
id: item.id,
|
||
domain: item.domain,
|
||
year: 2012 + index,
|
||
precision: "month" as const,
|
||
usage: "training" as const,
|
||
})),
|
||
probes: [],
|
||
answered_probes: [
|
||
{
|
||
probe_id: "probe-1",
|
||
semantic_key: "career.2016.test",
|
||
candidate_split_hash: "split-1",
|
||
answer_class: "yes" as const,
|
||
classified_from: "choice" as const,
|
||
},
|
||
],
|
||
rounds: [],
|
||
entropy: 1,
|
||
representative_time: "04:53",
|
||
credible_range: ["04:48", "05:07"] as const,
|
||
refresh_attempts: [],
|
||
...extra,
|
||
};
|
||
}
|
||
|
||
function tiedRpcDossier() {
|
||
const evidence = datedEvidenceRpc();
|
||
return dossierFixture({
|
||
evidenceCount: 4,
|
||
evidence,
|
||
latestResult: candidateSnapshotFixture({
|
||
representativeTime: "04:53",
|
||
selectionAllowed: true,
|
||
candidates: TIED_CANDIDATES.map((item) => ({
|
||
candidate_id: item.candidateId,
|
||
rank: item.rank,
|
||
time: item.time,
|
||
relative_support: item.relativeSupport,
|
||
tied_minute_count: 2,
|
||
})),
|
||
decisionReceipt: {
|
||
acceptance_allowed: true,
|
||
selection_allowed: true,
|
||
propose_allowed: true,
|
||
confirmation_allowed: false,
|
||
inference_state: tiedInference(),
|
||
},
|
||
}),
|
||
});
|
||
}
|
||
|
||
test("tied first place delivers a range even when narrowing flags are still open", () => {
|
||
// 原值: targetedCollectExhausted: false 仍 complete_with_range
|
||
// 新值: targetedCollectExhausted: true 才交付;false 时继续定向补事
|
||
// 原因: BUG-683 并列早退缺 probe / 定向补事守卫,新案子答两题就被判可采用
|
||
const decision = decideRectification({
|
||
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
|
||
methodCoverageAll: false,
|
||
trainingGateOpen: true,
|
||
candidateScores: TIED_CANDIDATES.map((item) => ({ time: item.time, score: item.relativeSupport })),
|
||
discriminatorProbe: null,
|
||
holdoutValidation: "unavailable",
|
||
datedEventCount: 3,
|
||
datedDomainCount: 3,
|
||
refreshExhausted: false,
|
||
targetedCollectExhausted: true,
|
||
});
|
||
assert.equal(decision.stopReason, "tied_first");
|
||
assert.equal(decision.canOfferRange, true);
|
||
assert.equal(decision.nextAction, "complete_with_range");
|
||
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
|
||
});
|
||
|
||
test("refresh persist failure is not recorded as an attempt", async () => {
|
||
resetRefreshDiscriminatorProbesForTests();
|
||
setRefreshDiscriminatorProbesForTests(async ({ state }) => ({
|
||
state: { ...state, candidates: [] },
|
||
eventProbes: [],
|
||
candidateSetId: state.candidate_set_id,
|
||
refreshCount: 1,
|
||
}));
|
||
const rpc = tiedRpcDossier();
|
||
const dossier = parseV9CaseDossier(rpc);
|
||
assert.ok(dossier);
|
||
const warnings: string[] = [];
|
||
const original = console.warn;
|
||
console.warn = (value: unknown) => {
|
||
warnings.push(String(value));
|
||
};
|
||
try {
|
||
const refreshed = await refreshDatedDiscriminatorPoolIfNeeded({
|
||
accounting: fakeAccounting({
|
||
...receiptHandlers,
|
||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||
append_agentic_rectification_inference_transition: () => {
|
||
throw new Error("candidate_state_inconsistent");
|
||
},
|
||
}).client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
dossier: dossier!,
|
||
hasDatedProbe: false,
|
||
});
|
||
assert.equal(refreshed.attemptRecorded, false);
|
||
assert.equal(refreshed.refreshed, false);
|
||
assert.ok(warnings.some((line) => line.includes("rectification_refresh_attempt_persist_failed")));
|
||
const before = decideFromDossier(dossier!, { birthDate: "1997-08-08" });
|
||
const after = decideFromDossier(refreshed.dossier, { birthDate: "1997-08-08" });
|
||
assert.equal(after.sessionOutcome, before.sessionOutcome);
|
||
assert.equal(after.canOfferRange, before.canOfferRange);
|
||
assert.equal(after.stopReason, before.stopReason);
|
||
} finally {
|
||
console.warn = original;
|
||
resetRefreshDiscriminatorProbesForTests();
|
||
}
|
||
});
|
||
|
||
test("POST idle persist and GET decideFromDossier agree on a first-place tie", async () => {
|
||
const rpc = tiedRpcDossier();
|
||
const dossier = parseV9CaseDossier(rpc);
|
||
assert.ok(dossier);
|
||
const getDecision = decideFromDossier(dossier!, { birthDate: "1997-08-08", snapshotCurrent: true });
|
||
const accounting = fakeAccounting({
|
||
...receiptHandlers,
|
||
get_agentic_rectification_case_dossier: () => rpc,
|
||
get_agentic_rectification_case_compute: () => computeFixture(),
|
||
append_agentic_rectification_inference_transition: () => {
|
||
throw new Error("candidate_state_inconsistent");
|
||
},
|
||
set_agentic_rectification_conversation_focus: (_fn, args) => {
|
||
throw new Error(`must not persist ${String(args.p_question_id)}`);
|
||
},
|
||
append_agentic_rectification_turn: () => ({
|
||
turn_id: "33333333-3333-4333-8333-333333333333",
|
||
idempotent: false,
|
||
}),
|
||
finalize_agentic_rectification_turn: () => ({
|
||
turn_id: "33333333-3333-4333-8333-333333333333",
|
||
status: "completed",
|
||
idempotent: false,
|
||
}),
|
||
get_agentic_rectification_turn_receipt: () => null,
|
||
});
|
||
const idle = await persistNextInterviewIfIdle({
|
||
accounting: accounting.client,
|
||
userId: USER_ID,
|
||
caseId: CASE_ID,
|
||
});
|
||
const getDelivers = sessionOutcomeAllowsDelivery(getDecision.sessionOutcome);
|
||
const postDelivers = idle.terminalNote === true && Boolean(idle.hostNarration);
|
||
// 原值: getDelivers=true(头名并列即交付)
|
||
// 新值: getDelivers=false
|
||
// 原因: D1——并列时 precision_gate_met 恒为假;D6——门槛未达不交付。
|
||
// 题名要的「POST 与 GET 口径一致」仍由下面一条保证(BUG-751)
|
||
// 原值(2): getDelivers=false
|
||
// 新值(2): getDelivers=true(回到 BUG-751 之前的「头名并列即交付」)
|
||
// 原因(2): 门开后定向线不再挡卡,门槛只上报(BUG-1084,2026-09-29 D1)
|
||
assert.equal(getDecision.stopReason, "tied_first");
|
||
assert.equal(getDelivers, true);
|
||
assert.equal(postDelivers, getDelivers);
|
||
});
|
||
|
||
test("completed_with_range with selection allowed shows the delivery card, not the collect range line", () => {
|
||
const result = parseRectificationCandidateResult({
|
||
...SNAPSHOT_BASE,
|
||
session_outcome: "completed_with_range",
|
||
selectionAllowed: true,
|
||
canAdopt: false,
|
||
});
|
||
assert.equal(canShowRectificationSelectionCards(result), true);
|
||
assert.equal(canShowRectificationReadonlyRange(result), false);
|
||
});
|
||
|
||
test("delivery narration and delivery cards stay true together for every outcome", () => {
|
||
const outcomes: DecisionSessionOutcome[] = [
|
||
"collect_evidence",
|
||
"compare_blocks",
|
||
"widen_window",
|
||
"discriminate_candidates",
|
||
"validate_holdout",
|
||
"provisional_range",
|
||
"provisional_range_user_stopped",
|
||
"completed_with_range",
|
||
"validated_range",
|
||
"exact_minute_confirmed",
|
||
"adopt_representative",
|
||
"awaiting_confirmation",
|
||
];
|
||
for (const outcome of outcomes) {
|
||
const adopt = outcome === "adopt_representative"
|
||
|| outcome === "provisional_range_user_stopped"
|
||
|| outcome === "awaiting_confirmation"
|
||
|| outcome === "validated_range";
|
||
const nextAction = outcome === "provisional_range"
|
||
? "offer_provisional_range"
|
||
: outcome === "completed_with_range"
|
||
? "complete_with_range"
|
||
: "ask_fact_collection";
|
||
const narrate = deliveryNarrationAllowed({
|
||
canAdopt: adopt,
|
||
sessionOutcome: outcome,
|
||
nextAction,
|
||
selectionAllowed: true,
|
||
});
|
||
const result = parseRectificationCandidateResult({
|
||
...SNAPSHOT_BASE,
|
||
session_outcome: outcome,
|
||
canAdopt: adopt,
|
||
selectionAllowed: true,
|
||
});
|
||
const cards = canShowRectificationSelectionCards(result);
|
||
if (narrate) {
|
||
assert.equal(cards, true, outcome);
|
||
}
|
||
if (DELIVERY_OUTCOMES.has(outcome) && outcome !== "completed_with_range" && outcome !== "provisional_range") {
|
||
assert.equal(narrate, true, outcome);
|
||
}
|
||
}
|
||
});
|
||
|
||
test("a delivered tie is not the unavailable repair gap", () => {
|
||
assert.equal(
|
||
rectificationQuestionGapState({
|
||
liveQuestionVisible: false,
|
||
questionMissing: true,
|
||
questionLoadFailed: false,
|
||
busy: false,
|
||
readonly: false,
|
||
snapshotLoaded: true,
|
||
resumableCase: true,
|
||
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||
sessionOutcome: "collect_evidence",
|
||
stopReason: "tied_first",
|
||
}),
|
||
"delivered",
|
||
);
|
||
assert.equal(
|
||
rectificationQuestionGapState({
|
||
liveQuestionVisible: false,
|
||
questionMissing: true,
|
||
questionLoadFailed: false,
|
||
busy: false,
|
||
readonly: false,
|
||
snapshotLoaded: true,
|
||
resumableCase: true,
|
||
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||
sessionOutcome: "completed_with_range",
|
||
}),
|
||
"delivered",
|
||
);
|
||
assert.equal(
|
||
rectificationQuestionGapState({
|
||
liveQuestionVisible: false,
|
||
questionMissing: true,
|
||
questionLoadFailed: true,
|
||
busy: false,
|
||
readonly: false,
|
||
snapshotLoaded: true,
|
||
resumableCase: true,
|
||
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
|
||
}),
|
||
"unavailable",
|
||
);
|
||
assert.equal(RECTIFICATION_DELIVERED_COPY.includes("没有拿到下一个问题"), false);
|
||
assert.equal(RECTIFICATION_QUESTION_UNAVAILABLE_COPY, "没有拿到下一个问题。");
|
||
});
|