Files
Jyotisha/frontend/tests/rectification-open-collect-invite-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

397 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 { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
import { INFERENCE_ALGORITHM_VERSION, type InferenceCandidate, type InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
import {
RANGE_DELIVERY_TIE_PERCENT,
} from "../src/lib/rectification-agentic/user-copy.ts";
import {
COLLECT_KIND_ORDER,
GUIDED_NARROW_HINT,
rangeNarrowHint,
} from "../src/lib/rectification-agentic/v9/collection-question-pool.ts";
import {
decideFromDossier,
scoreableSnapshotCurrentFromDossier,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { previousInferenceFromReceipt } from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import { buildRangeDelivery } from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { RectificationRangeDelivery } from "../src/components/rectification-range-delivery.tsx";
import type { RectificationCandidateResult } from "../src/lib/rectification-candidate-result.ts";
import { rectificationQuestionGapState } from "../src/lib/rectification-surface-state.ts";
const ID_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1";
const ID_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2";
const ID_C = "cccccccc-cccc-4ccc-8ccc-ccccccccccc3";
const CLOSED_LAYERS = ["d9", "d10", "d4", "d5", "d7", "d2", "d30"] as const;
const DATED_EVIDENCE = [
{
id: "evidence-education",
status: "confirmed",
domain: "education",
datePrecision: "month",
occurredFrom: "2016-09-01",
occurredTo: null,
eventKind: "education_start",
summary: "上大学",
},
{
id: "evidence-career",
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
summary: "入职",
},
{
id: "evidence-relocation",
status: "confirmed",
domain: "relocation",
datePrecision: "month",
occurredFrom: "2018-07-01",
occurredTo: null,
eventKind: "relocation",
summary: "搬家",
},
// R1(BUG-1193):补第 4 件 / 第 3 域,保持原场景。
{
id: "evidence-career-change",
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2022-03-01",
occurredTo: null,
eventKind: "career_change",
summary: "换工作",
},
] as const;
function declineAllTargeted() {
return COLLECT_KIND_ORDER.map((domain) => ({
questionId: `collect:targeted:${domain}`,
status: "declined",
target_kind: `targeted:${domain}`,
intent: "collect_method_evidence",
target_domain: domain,
}));
}
function cand(
time: string,
probability: number,
range: readonly [string, string] = ["04:48", "05:07"],
): InferenceCandidate {
return {
id: time,
time,
cluster_range: range,
prior_score: probability * 100,
posterior_score: probability * 100,
probability,
status: "active",
rank: 1,
strong_conflict_count: 0,
};
}
function inference(overrides: Partial<InferenceState> = {}): InferenceState {
return {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: "04:48-05:07:04:50,04:52,04:56",
revision: 1,
phase: "discrimination",
result_status: "credible_range",
range_start: "04:48",
range_end: "05:07",
candidates: [
cand("04:50", 0.26),
cand("04:52", 0.26),
cand("04:56", 0.20),
],
events: [
{ id: "e1", domain: "career", year: 2020, precision: "month", usage: "training" },
{ id: "e2", domain: "education", year: 2016, precision: "month", usage: "training" },
{ id: "e3", domain: "relocation", year: 2018, precision: "month", usage: "training" },
// R1(BUG-1193):补第 4 件 / 第 3 域,保持原场景。
{ id: "e4", domain: "career", year: 2022, precision: "month", usage: "holdout" },
],
probes: [],
answered_probes: [],
rounds: [],
entropy: 1,
representative_time: "04:50",
credible_range: ["04:48", "05:07"],
refresh_count: 1,
...overrides,
};
}
function deliveryResult(
delivery: ReturnType<typeof buildRangeDelivery>,
): RectificationCandidateResult {
return {
resultId: "result-open-collect",
candidates: delivery.columns.map((column, index) => ({
candidateId: column.candidate_id,
rank: index + 1,
time: column.time,
relativeSupport: column.probability_percent,
tiedMinuteCount: 1,
})),
overallConfidence: "medium",
selectionAllowed: true,
canAdopt: true,
confirmationAllowed: false,
decisionReceipt: null,
representativeTime: delivery.representative_time,
selectedTime: null,
selectionKind: null,
houseTable: null,
houseTablesByTime: {},
natalRecast: null,
techniqueAudit: [],
windowTransitions: [],
eventDashaLedger: [],
dashaAgreement: null,
lagnaContrast: null,
nakshatraBoundary: null,
precisionStage: null,
oosBlindPrompts: [],
confirmationGate: { confirmation_allowed: false } as RectificationCandidateResult["confirmationGate"],
validated: false,
completionStatus: null,
sessionOutcome: "adopt_representative",
credibleRange: delivery.range,
rangeDelivery: delivery,
verificationReportMarkdown: delivery.verification_markdown,
};
}
function renderCard(delivery: ReturnType<typeof buildRangeDelivery>): string {
return renderToStaticMarkup(createElement(RectificationRangeDelivery, {
result: deliveryResult(delivery),
acceptingCandidateId: null,
readonly: false,
onAccept: () => undefined,
}));
}
function gapFor(decision: { sessionOutcome: string; stopReason?: string | null }): string {
return rectificationQuestionGapState({
liveQuestionVisible: false,
questionMissing: true,
questionLoadFailed: false,
collectWaiting: false,
busy: false,
readonly: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: 0,
sessionOutcome: decision.sessionOutcome,
stopReason: decision.stopReason ?? null,
});
}
test("closed collect copy no longer invites free-text after the seven lines close", () => {
const copy = rangeNarrowHint(
CLOSED_LAYERS,
DATED_EVIDENCE,
declineAllTargeted(),
["04:48", "05:07"],
5,
["04:48", "05:07"],
);
// 原值: 空池文案含「不限领域」「确切哪一天」自由文本邀请
// 新值: 门槛未达写「再对照几件经历会更准」,删除自由文本邀请
// 原因: D3 引导式补经历,系统点名逐题问
// 原值(2): 含 GUIDED_NARROW_HINT「再对照几件经历会更准」
// 新值(2): 只剩「现在还剩 04:48–05:07 里 5 个候选。」
// 原因(2): 门开后不再口述采集,收窄提示不再邀请补经历(BUG-1084,2026-09-29 D1)
assert.equal(copy, "现在还剩 04:48–05:07 里 5 个候选。");
assert.doesNotMatch(copy, new RegExp(GUIDED_NARROW_HINT));
assert.doesNotMatch(copy, /不限领域|确切哪一天|有年月也行|接着算/);
assert.doesNotMatch(copy, /哪年结婚或订婚|哪年收入明显变过|家里哪年添丁/);
assert.doesNotMatch(copy, /问完了|都问完|定到分钟|精确到分钟/);
});
test("closed-pool copy keeps the candidate count and drops the free-text invite", () => {
const two = rangeNarrowHint(
CLOSED_LAYERS,
DATED_EVIDENCE,
declineAllTargeted(),
["04:48", "05:07"],
2,
["04:48", "05:07"],
);
// 原值: 「这两分钟按现有信息分不开」+ 自由文本邀请
// 新值: 「再对照几件经历会更准」,无邀请
// 原因: D3 删除自由文本邀请
// 原值(2): 「现在还剩 04:48–05:07 里 2 个候选,再对照几件经历会更准」(four 同理)
// 新值(2): 「现在还剩 04:48–05:07 里 2 个候选。」;不带范围时为空串(原值 GUIDED_NARROW_HINT)
// 原因(2): 门开后不再口述采集,收窄提示不再邀请补经历(BUG-1084,2026-09-29 D1)
assert.equal(two, "现在还剩 04:48–05:07 里 2 个候选。");
assert.doesNotMatch(two, /不限领域|确切哪一天/);
const four = rangeNarrowHint(
CLOSED_LAYERS,
DATED_EVIDENCE,
declineAllTargeted(),
["04:47", "04:53"],
4,
["04:47", "04:53"],
);
assert.equal(four, "现在还剩 04:47–04:53 里 4 个候选。");
assert.doesNotMatch(four, /不限领域|登记结婚那天/);
const unknown = rangeNarrowHint(
CLOSED_LAYERS,
DATED_EVIDENCE,
declineAllTargeted(),
);
assert.equal(unknown, "");
assert.doesNotMatch(unknown, /不限领域/);
});
test("the range card no longer shows a free-text invite", () => {
const publicCandidates = [
{ candidateId: ID_A, time: "04:50" },
{ candidateId: ID_B, time: "04:52" },
{ candidateId: ID_C, time: "04:56" },
];
const tied = buildRangeDelivery({
inference: inference(),
publicCandidates,
credibleRange: ["04:48", "05:07"],
representativeTime: "04:50",
evidence: DATED_EVIDENCE,
declinedTopics: declineAllTargeted(),
});
assert.equal(
Math.abs((tied.columns[0]?.probability_percent ?? 0) - (tied.columns[1]?.probability_percent ?? 0))
<= RANGE_DELIVERY_TIE_PERCENT,
true,
);
const tiedHtml = renderCard(tied);
// 原值: 并列时卡上出现自由文本邀请
// 新值: 交付卡不再邀请打字
// 原因: D3 删除 RANGE_DELIVERY_OPEN_COLLECT 邀请
assert.doesNotMatch(tiedHtml, /rectification-range-delivery__invite/);
assert.doesNotMatch(tiedHtml, /不限领域|确切哪一天|换专业|创业/);
assert.match(tiedHtml, /更像这个/);
assert.doesNotMatch(tiedHtml, /问完了/);
const stillOpen = buildRangeDelivery({
inference: inference({
transitions: [{ layer: "d9", at: "04:52" }],
}),
publicCandidates,
credibleRange: ["04:48", "05:07"],
representativeTime: "04:50",
evidence: DATED_EVIDENCE,
declinedTopics: [],
});
// 原值(2): narrow_hint 含「还能再收窄」(点名还能问的线)
// 新值(2): 「现在还剩 … 个候选。这几个候选按现有信息分不开。」
// 原因(2): 门开后不再口述采集,收窄提示不再邀请补经历(BUG-1084,2026-09-29 D1)
assert.doesNotMatch(stillOpen.narrow_hint ?? "", /还能再收窄/);
assert.match(stillOpen.narrow_hint ?? "", /按现有信息分不开/);
const openHtml = renderCard(stillOpen);
assert.doesNotMatch(openHtml, /rectification-range-delivery__invite/);
assert.doesNotMatch(openHtml, /不限领域/);
});
test("a new dated event after delivery stales the snapshot and leaves the delivered gap", () => {
const rows = DATED_EVIDENCE.map((item) => ({ ...item }));
const fingerprint = evidenceLedgerFingerprint(rows as never);
const times = ["04:50", "04:52", "04:56"] as const;
const state = inference({
candidate_set_id: candidateSetId("04:48", "05:07", [...times]),
refresh_count: 1,
refresh_attempts: [{
candidate_set_id: candidateSetId("04:48", "05:07", [...times]),
answer_count: 0,
result: "no_new_probes",
at: "2026-09-14T00:00:00.000Z",
}],
});
const dossier: DecisionDossier = {
evidence: rows,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: declineAllTargeted(),
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
selectionAllowed: true,
confirmationAllowed: false,
evidenceLedgerFingerprint: fingerprint,
candidates: times.map((time, index) => ({
candidateId: [ID_A, ID_B, ID_C][index],
time,
rank: index + 1,
relativeSupport: time === "04:56" ? 20 : 26,
posterior_score: time === "04:56" ? 20 : 26,
})),
representativeTime: "04:50",
decisionReceipt: {
accept_allowed: true,
acceptance_allowed: true,
propose_allowed: true,
selection_allowed: true,
confirmation_allowed: false,
inference_state: state,
},
},
case: {
acceptedTime: null,
status: "candidate_ready",
candidateRange: { start_time: "04:45", end_time: "05:15" },
},
};
const delivered = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.ok(
delivered.nextAction === "offer_provisional_range"
|| delivered.nextAction === "ready_to_adopt"
|| delivered.nextAction === "complete_with_range",
delivered.nextAction,
);
assert.equal(gapFor(delivered), "delivered");
const nextEvidence = [
...rows,
{
id: "evidence-lawsuit",
status: "confirmed",
domain: "other",
datePrecision: "month",
occurredFrom: "2019-03-01",
occurredTo: null,
eventKind: "legal_case",
summary: "打官司",
},
];
const nextDossier: DecisionDossier = { ...dossier, evidence: nextEvidence };
const inferenceAfter = previousInferenceFromReceipt(nextDossier.latestResult?.decisionReceipt ?? null);
assert.equal(scoreableSnapshotCurrentFromDossier(dossier, undefined, inferenceAfter), true);
assert.equal(scoreableSnapshotCurrentFromDossier(nextDossier, undefined, inferenceAfter), false);
const after = decideFromDossier(nextDossier, { birthDate: "1997-08-08" });
assert.equal(
after.nextAction === "complete_with_range"
|| after.nextAction === "offer_provisional_range"
|| after.nextAction === "ready_to_adopt",
false,
after.nextAction,
);
assert.notEqual(after.stopReason, "tied_first");
assert.notEqual(gapFor(after), "delivered");
});