Files
Jyotisha/frontend/tests/rectification-tiebreak-before-card-20260914.test.ts
T
Jesse_ChenandClaude Opus 5.5 758fee954b feat(rectification): range delivery needs 4 dated events across 3 domains (R1, BUG-1193)
Product decision 2026-10-02 (TASK-upstream-sync5 R1): a time range is offered
only with at least 4 dated, primary-scoreable events covering 3 domains,
counted on all of them (training + reserved holdout). Was 3 training events /
2 domains in three TS copies and the Python acceptance gate while the policy
file already said 4/3.

- One definition: references/rectification_policy.v1.json
  (minConfirmationEvents / minConfirmationDomains). TS core/types MIN_DATED_*,
  rectification-decision MIN_STANDALONE_*, evidence-model MIN_ACCEPTANCE_*,
  the convergence evaluator and the post-inference trainingGateOpen all read
  it; Python decision_policy MIN_ACCEPTANCE_* alias MIN_CONFIRMATION_*.
- Python receipt counts all scoreable events / domains for event_quality and
  domain_diversity; decision policy identity v3 -> v4 (candidate UUIDs carry
  it). Candidate scores unchanged (77 v5 cases A/B identical), so the
  algorithm stays rectification-v5-matrix-scoring-10.
- Memoization golden v3 written by write_golden; v2 frozen by sha256 with a
  test that its scores equal v3 and only the receipt policy moved.
- Collect gap copy names the exact gap ("再来两件……其中至少一件不是……")
  instead of always "再来一件"; VOICE.md updated. Legacy life-events form copy
  4/3 as well.
- 30 frontend test files, 4 Python tests: fixtures extended to the same
  scenario at 4/3, or assertions changed with 原值/新值/原因 notes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-10-03 00:08:00 +08:00

441 lines
16 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: "确定关系",
},
// R1(BUG-1193):补第 4 件 / 第 3 域,保持原场景。
{
status: "confirmed",
domain: "relocation",
datePrecision: "month",
occurredFrom: "2018-07-01",
occurredTo: null,
eventKind: "relocation",
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",
// R1(BUG-1193):补第 4 件 / 第 3 域,保持原场景。
datedEventCount: 4,
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",
// R1(BUG-1193):补第 4 件 / 第 3 域,保持原场景。
datedEventCount: 4,
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",
},
// R1(BUG-1193):补第 4 件 / 第 3 域,保持原场景。
{
status: "confirmed",
domain: "education",
datePrecision: "month",
occurredFrom: "2020-06-01",
occurredTo: null,
eventKind: "education_milestone",
},
];
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);
});