Files
Jyotisha/frontend/tests/rectification-delivery-vs-collect-20260914.test.ts
T
Jesse_ChenandClaude Fable 5.1 dc732825d8 fix(rectification): 有题就接着问,题问完才出卡;引导窗口不再硬贴领域
出卡时机只看题源有没有空:撤回「门槛达标就短路采集线」的写法,同时
按 D2 保住「题源全空就按现行规则出卡」——门槛只在还有题可问时挡住
出卡,precision_gate_met 改成只上报(新挂在决策与公开投影上),不再
单独决定时机。引导窗口题在无领域轨道上改问开放题,一个时间窗只问一
次;录入卡提交的是「YYYY 年 M 月,<领域>方面有一件事」,不再是题干
的三选一列表。记忆化 golden 只补一个新键并冻结墙钟。离线回放改成注
入真值方向的边界事件,另跑一组反方向对照。Skill 10.0.28。

BUG-747~752

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-16 12:16:55 +00:00

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)
assert.equal(getDecision.stopReason, "tied_first");
assert.equal(getDelivers, false);
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, "没有拿到下一个问题。");
});