|
|
|
@@ -0,0 +1,420 @@
|
|
|
|
|
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", () => {
|
|
|
|
|
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: false,
|
|
|
|
|
});
|
|
|
|
|
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);
|
|
|
|
|
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,
|
|
|
|
|
regenerating: 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,
|
|
|
|
|
regenerating: 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,
|
|
|
|
|
regenerating: false,
|
|
|
|
|
snapshotLoaded: true,
|
|
|
|
|
resumableCase: true,
|
|
|
|
|
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
|
|
|
|
|
}),
|
|
|
|
|
"unavailable",
|
|
|
|
|
);
|
|
|
|
|
assert.equal(RECTIFICATION_DELIVERED_COPY.includes("没有拿到下一个问题"), false);
|
|
|
|
|
assert.equal(RECTIFICATION_QUESTION_UNAVAILABLE_COPY, "没有拿到下一个问题。");
|
|
|
|
|
});
|