Files
Jyotisha/frontend/tests/rectification-tiebreak-before-card-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

420 lines
15 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 {
decideRectification,
publicCanAdopt,
sessionOutcomeAllowsDelivery,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { rangeNarrowHint } from "../src/lib/rectification-agentic/v9/collection-question-pool.ts";
import { rangeDeliveryForSnapshot } from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
import {
tieBreakGateInput,
tieBreakPersonalityAvailable,
tieBreakPersonalityFollowup,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import {
RECTIFICATION_QUESTION_RETRY_LIMIT,
rectificationQuestionGapState,
} from "../src/lib/rectification-surface-state.ts";
import { applySnapshotTurnsToMessages } from "../src/lib/rectification-snapshot-messages.ts";
import type { TurnQuestion } from "../src/lib/rectification-agentic/v9/turn-question.ts";
import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts";
import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts";
import { alreadyDelivered, markDeliveryTurn, resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
function contrastProbe(layer: "d9" | "d10") {
const signs = layer === "d9" ? ["巨蟹座", "狮子座"] as const : ["狮子座", "处女座"];
return {
probeId: `contrast:varga.${layer}.${signs[0]}/${signs[1]}`,
candidateSetVersion: "04:48-05:07",
question: layer === "d9"
? "亲密关系里更接近下面哪一种相处方式?"
: "平时做事,你更接近下面哪一种?",
expectedOutcomes: [
{ outcomeId: "yes", supportsCandidateIds: ["04:53"], conflictsCandidateIds: ["05:06"] },
{ outcomeId: "weak_yes", supportsCandidateIds: ["05:06"], conflictsCandidateIds: ["04:53"] },
],
candidateSplitHash: `varga.${layer}.${signs[0]}/${signs[1]}`,
informationGain: layer === "d9" ? 1.4 : 0.9,
sourceFeatures: [{ technique: layer.toUpperCase(), calculationResultId: null }],
domain: layer === "d9" ? "relationship" : "career",
year: null,
semanticKey: `varga.${layer}.${signs[0]}/${signs[1]}`,
choiceKind: "varga_style" as const,
styleOptions: [
{ label: "相处里更在意照顾对方的感受", answerClass: "yes" as const, sign: signs[0] },
{ label: "习惯带头,也不排斥站到台前", answerClass: "weak_yes" as const, sign: signs[1] },
],
};
}
const D9 = contrastProbe("d9");
const D10 = contrastProbe("d10");
function planInput(askedProbeKeys: readonly string[] = []) {
return {
evidence: [
{
status: "confirmed",
domain: "education",
datePrecision: "month",
occurredFrom: "2016-09-01",
occurredTo: "2016-09-30",
eventKind: "education_start",
summary: "上大学",
},
{
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
summary: "入职实习",
},
{
status: "confirmed",
domain: "relationship",
datePrecision: "month",
occurredFrom: "2024-05-01",
occurredTo: null,
eventKind: "relationship_start",
summary: "确定关系",
},
],
declinedTopics: [],
sessionOutcome: "discriminate_candidates" as const,
candidatesSeparated: false,
remainingLayers: ["d9", "d10"],
remainingSplitTimes: ["04:48", "05:07"] as const,
remainingCandidateCount: 4,
topCandidateTimes: ["04:48", "04:53", "05:06", "05:07"],
askedProbeKeys,
contrastPacket: {
candidateSetVersion: "04:48-05:07",
vargaDifferences: [],
probes: [D9, D10],
},
};
}
function tiedDecision(overrides: Partial<Parameters<typeof decideRectification>[0]> = {}) {
return decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: false,
trainingGateOpen: true,
candidateScores: [
{ time: "04:53", score: 16 },
{ time: "05:00", score: 16 },
{ time: "05:06", score: 15 },
{ time: "04:51", score: 14 },
{ time: "04:59", score: 13 },
],
discriminatorProbe: null,
holdoutValidation: "unavailable",
datedEventCount: 3,
datedDomainCount: 3,
targetedCollectExhausted: true,
refreshExhausted: true,
...overrides,
});
}
/** Normal convergence: unique first place, methods covered, holdout passed. */
function convergingDecision(overrides: Partial<Parameters<typeof decideRectification>[0]> = {}) {
return decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: true,
trainingGateOpen: true,
candidateScores: [
{ time: "04:53", score: 24 },
{ time: "05:00", score: 14 },
{ time: "05:06", score: 13 },
],
discriminatorProbe: null,
holdoutValidation: "passed",
datedEventCount: 3,
datedDomainCount: 3,
targetedCollectExhausted: true,
refreshExhausted: true,
tieBreakFollowupReady: true,
pendingTieBreak: true,
...overrides,
});
}
test("a tie-break snapshot turn is merged into messages with its question", () => {
type Message = {
role: "assistant";
text: string;
renderKey: string;
turnId: string;
question?: TurnQuestion;
};
const current: Message[] = [{
role: "assistant",
text: "目前范围 04:48–05:07",
renderKey: "existing",
turnId: "turn-1",
}];
const turns = [
{ id: "turn-1", role: "assistant", text: "目前范围 04:48–05:07" },
{
id: "turn-2",
role: "assistant",
text: RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck,
question: {
focus_id: "focus-d9",
question_id: "varga.d9.巨蟹座/狮子座",
kind: "choice",
prompt: D9.question,
options: [
{ key: "A", label: "相处里更在意照顾对方的感受" },
{ key: "B", label: "习惯带头,也不排斥站到台前" },
{ key: "C", label: "一时说不好" },
{ key: "D", label: "先这样" },
],
status: "active",
answer_option: null,
probe_id: D9.probeId,
},
},
];
const next = applySnapshotTurnsToMessages(current, turns, (incoming): Message[] => (
incoming.flatMap((item) => {
if (!item || typeof item !== "object") return [];
const turn = item as { id?: string; role?: string; text?: string; question?: unknown };
if (turn.role !== "assistant" || typeof turn.id !== "string") return [];
return [{
role: "assistant" as const,
text: turn.text ?? "",
renderKey: turn.id,
turnId: turn.id,
}];
})
));
assert.equal(next.length, 2);
assert.equal(next[1]?.turnId, "turn-2");
assert.equal(next[1]?.text, RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck);
assert.equal(next[1]?.question?.prompt, D9.question);
assert.equal(next[1]?.question?.kind, "choice");
});
test("a delivery card plus an unanswered focus is not an idle gap", () => {
assert.equal(
rectificationQuestionGapState({
liveQuestionVisible: false,
questionMissing: false,
questionLoadFailed: false,
questionPersisted: true,
offerAwaitingReader: true,
busy: false,
readonly: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: RECTIFICATION_QUESTION_RETRY_LIMIT,
sessionOutcome: "completed_with_range",
}),
"persisted_question",
);
});
test("a converging case with unused personality questions holds delivery", () => {
const decision = convergingDecision();
assert.equal(decision.heldForTieBreak, true);
assert.notEqual(decision.nextAction, "complete_with_range");
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), false);
assert.equal(publicCanAdopt(decision), false);
const followup = tieBreakPersonalityFollowup(planInput());
assert.equal(followup?.choice_kind, "varga_style");
assert.match(followup?.semantic_key ?? "", /varga\.d9/);
});
test("the same tie delivers after both personality questions are used", () => {
const bothAsked = planInput([D9.semanticKey, D10.semanticKey]);
assert.equal(tieBreakPersonalityAvailable(bothAsked), false);
const decision = tiedDecision({ pendingTieBreak: false });
assert.equal(decision.heldForTieBreak ?? false, false);
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
assert.equal(rangeDeliveryForSnapshot({
tieBreakAvailable: tieBreakPersonalityAvailable(bothAsked),
tieBreakUsed: true,
stillTied: decision.separation.lead <= 1,
}).tie_break_available, false);
assert.equal(
rangeDeliveryForSnapshot({
tieBreakAvailable: false,
tieBreakUsed: true,
stillTied: true,
}).tie_break_note,
RECTIFICATION_USER_COPY.rangeDeliveryTieBreakUsed,
);
});
test("a two-point lead still asks personality questions before delivery", () => {
const decision = convergingDecision();
assert.equal(decision.heldForTieBreak, true);
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), false);
assert.equal(tieBreakPersonalityFollowup(planInput())?.choice_kind, "varga_style");
});
test("pending style flag without a renderable followup delivers", () => {
const decision = convergingDecision({
pendingTieBreak: true,
tieBreakFollowupReady: false,
});
assert.equal(decision.heldForTieBreak ?? false, false);
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
});
test("an exhausted first-place tie delivers even if style questions remain", () => {
const decision = tiedDecision({
pendingTieBreak: true,
tieBreakFollowupReady: true,
});
assert.equal(decision.heldForTieBreak ?? false, false);
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
});
test("already held once without a followup delivers", () => {
const decision = convergingDecision({
pendingTieBreak: true,
tieBreakFollowupReady: true,
tieBreakAlreadyHeld: true,
});
assert.equal(decision.heldForTieBreak ?? false, false);
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
});
test("a layer with only one rising sign does not occupy the style slot", () => {
const onlyD10 = {
...planInput(),
remainingLayers: ["d10"],
contrastPacket: {
candidateSetVersion: "04:48-05:07",
vargaDifferences: [],
probes: [D10],
},
};
const followup = tieBreakPersonalityFollowup(onlyD10);
assert.equal(followup?.choice_kind, "varga_style");
assert.match(followup?.semantic_key ?? "", /varga\.d10/);
assert.doesNotMatch(followup?.semantic_key ?? "", /varga\.d9/);
});
test("holding for a tie-break does not mark a delivery turn", () => {
resetDeliveryTurnGuardForTests();
const decision = convergingDecision();
assert.equal(decision.heldForTieBreak, true);
markDeliveryTurn({ caseId: "case-1", resultId: "result-1" });
assert.equal(alreadyDelivered({
caseId: "case-1",
resultId: "result-1",
hasUserMessage: false,
}), true);
assert.equal(alreadyDelivered({
caseId: "case-1",
resultId: "result-2",
hasUserMessage: false,
}), false);
resetDeliveryTurnGuardForTests();
});
test("range copy omits declined lines and closes when none remain", () => {
const layers = ["d9", "d2", "d7", "d4"];
const evidence = [
{
status: "confirmed",
domain: "education",
datePrecision: "month",
occurredFrom: "2016-09-01",
occurredTo: null,
eventKind: "education_start",
},
{
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
},
{
status: "confirmed",
domain: "relocation",
datePrecision: "month",
occurredFrom: "2018-07-01",
occurredTo: null,
eventKind: "relocation",
},
];
const declined = [
{ questionId: "collect:targeted:relationship", status: "declined", target_kind: "targeted:relationship" },
{ questionId: "collect:targeted:finance", status: "declined", target_kind: "targeted:finance" },
{ questionId: "collect:targeted:family", status: "declined", target_kind: "targeted:family" },
];
const openCopy = rangeNarrowHint(layers, evidence, [], ["04:48", "05:07"], 5, ["04:48", "05:07"]);
// 原值(2): openCopy 点名结婚 / 收入 / 添丁三条线;copy 仍列健康线;closedCopy 含「再对照几件经历会更准」
// 新值(2): 门开后三处都只剩「现在还剩 04:48–05:07 里 5 个候选。」
// 原因(2): 门开后不再口述采集,收窄提示不再邀请补经历(BUG-1084,2026-09-29 D1)
assert.equal(openCopy, "现在还剩 04:48–05:07 里 5 个候选。");
const copy = rangeNarrowHint(layers, evidence, declined, ["04:48", "05:07"], 5, ["04:48", "05:07"]);
// 原值: 空池邀请自由打字
// 新值: 已拒答的线不再列出;D5 未覆盖且未拒绝的健康线仍在池里
// 原因: D3 删除自由文本邀请;BUG-687 不得把已拒答的线再问一遍;
// remainingTargetedDomains 不再按分盘层剔除
assert.doesNotMatch(copy, /哪年结婚或订婚|哪年收入明显变过|家里哪年添丁/);
assert.doesNotMatch(copy, /问完了|不限领域|确切哪一天/);
assert.doesNotMatch(copy, /哪年住院或手术/);
const closed = [
...declined,
{
questionId: "collect:targeted:health_pressure",
status: "declined",
target_kind: "targeted:health_pressure",
},
];
const closedCopy = rangeNarrowHint(layers, evidence, closed, ["04:48", "05:07"], 5, ["04:48", "05:07"]);
assert.equal(closedCopy, "现在还剩 04:48–05:07 里 5 个候选。");
});
test("GET availability and the POST gate share one input builder", () => {
const openPlan = planInput();
const open = tieBreakGateInput({
evidence: openPlan.evidence,
declinedTopics: [],
catalog: {
remainingLayers: openPlan.remainingLayers,
remainingSplitTimes: openPlan.remainingSplitTimes,
remainingCandidateCount: openPlan.remainingCandidateCount,
contrastPacket: openPlan.contrastPacket,
askedProbeKeys: openPlan.askedProbeKeys,
topCandidateTimes: openPlan.topCandidateTimes,
},
accepted: false,
stage: "minute",
candidateRange: { start_time: "04:45", end_time: "05:15" },
});
const closed = tieBreakGateInput({
evidence: openPlan.evidence,
declinedTopics: [],
catalog: {
remainingLayers: openPlan.remainingLayers,
remainingSplitTimes: openPlan.remainingSplitTimes,
remainingCandidateCount: openPlan.remainingCandidateCount,
contrastPacket: openPlan.contrastPacket,
askedProbeKeys: [D9.semanticKey, D10.semanticKey],
topCandidateTimes: openPlan.topCandidateTimes,
},
accepted: false,
stage: "minute",
candidateRange: { start_time: "04:45", end_time: "05:15" },
});
assert.equal(tieBreakPersonalityAvailable(open), true);
assert.equal(Boolean(tieBreakPersonalityFollowup(open)?.choice_kind === "varga_style"), true);
assert.equal(tieBreakPersonalityAvailable(closed), false);
assert.equal(tieBreakPersonalityFollowup(closed), null);
});