Files
Jyotisha/frontend/tests/rectification-delivery-vs-collect-20260914.test.ts
T
Jesse_ChenandClaude Opus 5.5 ea21743b09 fix(rectification): stop spoken collect once the training gate opens (BUG-1084..1087)
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
2026-09-29 10:06:57 +08:00

428 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "没有拿到下一个问题。");
});