Files
Jyotisha/frontend/tests/rectification-delivery-vs-collect-20260914.test.ts
T
jesse-ux 9da42c99d2
Independent Staging Quality Gate / validate (push) Successful in 13m48s
Independent Staging Quality Gate / publish (push) Successful in 2m6s
fix(rectification): keep tied-first from delivering while probes remain (BUG-683/684)
tied_first is only terminal when there is no dated probe and targeted collect is exhausted. An unnarrowed opening window cannot announce adopt. Delivered dead choice cards take the delivered gap instead of the repair copy.
2026-09-14 17:06:51 +08:00

424 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);
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, "没有拿到下一个问题。");
});