Files
Jyotisha/frontend/tests/rectification-decide-next-action.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

667 lines
27 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 { readFileSync } from "node:fs";
import test from "node:test";
import { OPEN_ENGINE_CAPABILITY_CEILING } from "./rectification-v9-test-support.ts";
import {
askedEventProbeKeysFromLedgerEvidence,
buildCandidateContrastPacket,
mentionedVargaKeysFromLedgerEvidence,
selectDiscriminatorProbe,
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import { evaluateCandidateSeparation } from "../src/lib/rectification-agentic/core/candidate-separation.ts";
import { decideNextAction } from "../src/lib/rectification-agentic/core/decide-next-action.ts";
import { decideRectification, publicCanAdopt, publicDecisionFields } from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts";
import {
classifySnapshotStaleReason,
scoreableSnapshotIsCurrent,
snapshotIsCurrent,
storedSnapshotIsCurrent,
type CandidateSnapshotSource,
} from "../src/lib/rectification-agentic/core/snapshot-source.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { buildSkillVerificationReport } from "../src/lib/rectification-agentic/v9/skill-verification-report.ts";
const TIED = [
{ id: "c0", time: "05:00", score: 34 },
{ id: "c1", time: "05:01", score: 33 },
{ id: "c2", time: "05:02", score: 33 },
];
const SEPARATED = [
{ id: "c0", time: "05:00", score: 62 },
{ id: "c1", time: "05:01", score: 22 },
{ id: "c2", time: "05:02", score: 16 },
];
const CONTRAST_PROBE = selectDiscriminatorProbe(buildCandidateContrastPacket({
candidateSetVersion: "05:00-05:02:05:00,05:01,05:02",
calculationResultId: "11111111-1111-4111-8111-111111111111",
engineProbes: [{
semantic_key: "career.2018.dasha_activation",
candidate_split_hash: "career:2018:05:00|05:01",
domain: "career",
year: 2018,
user_meaning: "2018 年前后是否职责明显加重?",
information_gain: 0.21,
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:01", "05:02"] },
{ answer_class: "no", supports: ["05:01"], conflicts: ["05:00"] },
],
}],
vargaDifferences: [{ layer: "d10", signs: ["巨蟹", "狮子", "处女"] }],
}));
function source(overrides: Partial<CandidateSnapshotSource> = {}): CandidateSnapshotSource {
return {
birthProfileFingerprint: "birth-a",
scoreableEvidenceFingerprint: "score-a",
inferenceRevision: 3,
candidateSetVersion: "set-a",
scoringPolicyVersion: "policy-v2",
...overrides,
};
}
test("does not adopt when method coverage is complete but candidates remain tied", () => {
const next = decideNextAction({
methodCoverageAll: true,
proposeAllowed: true,
candidateScores: TIED,
discriminatorProbe: CONTRAST_PROBE,
});
assert.equal(next.type, "ask_candidate_discriminator");
assert.equal(next.separation.status, "not_separated");
assert.ok((next.probe?.expectedOutcomes.length ?? 0) >= 2);
});
test("tied candidates prefer a discriminator probe with at least two predicted outcomes", () => {
const next = decideNextAction({
methodCoverageAll: true,
proposeAllowed: true,
candidateScores: TIED,
discriminatorProbe: CONTRAST_PROBE,
});
assert.equal(next.type, "ask_candidate_discriminator");
assert.ok((next.probe?.expectedOutcomes.length ?? 0) >= 2);
});
test("holdout that has not passed cannot enter ready_to_adopt", () => {
const next = decideNextAction({
methodCoverageAll: true,
proposeAllowed: true,
candidateScores: SEPARATED,
holdoutValidation: "not_started",
});
assert.equal(next.type, "ask_holdout_validation");
assert.notEqual(next.type, "ready_to_adopt");
});
test("separated candidates with holdout passed are ready to adopt", () => {
const next = decideNextAction({
methodCoverageAll: true,
proposeAllowed: true,
candidateScores: SEPARATED,
holdoutValidation: "passed",
});
assert.equal(next.type, "ready_to_adopt");
});
test("missing method coverage stays in fact collection even if scores look separated", () => {
const next = decideNextAction({
methodCoverageAll: false,
proposeAllowed: true,
candidateScores: SEPARATED,
discriminatorProbe: CONTRAST_PROBE,
});
assert.equal(next.type, "ask_fact_collection");
});
test("unseparated scores still discriminate when training is open and a probe exists", () => {
const next = decideNextAction({
methodCoverageAll: false,
trainingGateOpen: true,
proposeAllowed: false,
candidateScores: TIED,
discriminatorProbe: CONTRAST_PROBE,
});
assert.equal(next.type, "ask_candidate_discriminator");
assert.equal(decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: false,
trainingGateOpen: true,
candidateScores: TIED,
discriminatorProbe: CONTRAST_PROBE,
}).sessionOutcome, "discriminate_candidates");
assert.equal(decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: false,
trainingGateOpen: true,
candidateScores: [],
discriminatorProbe: CONTRAST_PROBE,
}).sessionOutcome, "discriminate_candidates");
});
test("dated evidence suppresses an ordinary probe for the same domain and year", () => {
const askedKeys = askedEventProbeKeysFromLedgerEvidence([{
status: "confirmed",
domain: "career",
occurredFrom: "2018-01-01",
}]);
const packet = buildCandidateContrastPacket({
candidateSetVersion: "set-a",
engineProbes: [{
semantic_key: "career.2018.dasha_activation",
candidate_split_hash: "career:2018:split",
domain: "career",
year: 2018,
user_meaning: "2018 年前后是否有职业变化?",
information_gain: 0.4,
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] },
{ answer_class: "no", supports: ["05:07"], conflicts: ["05:00"] },
],
}],
askedKeys,
});
assert.equal(selectDiscriminatorProbe(packet), null);
});
test("ordinary education evidence does not suppress a structured D24 probe", () => {
const askedKeys = askedEventProbeKeysFromLedgerEvidence([{
status: "confirmed",
domain: "education",
occurredFrom: "2016-09-01",
}]);
const packet = buildCandidateContrastPacket({
candidateSetVersion: "set-a",
engineProbes: [{
semantic_key: "varga.d24",
candidate_split_hash: "d24:split",
domain: "education",
year: 2016,
choice_kind: "varga_style",
user_meaning: "这段学习经历更接近哪种过程?",
information_gain: 0.8,
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] },
{ answer_class: "no", supports: ["05:07"], conflicts: ["05:00"] },
],
}],
askedKeys,
});
assert.equal(selectDiscriminatorProbe(packet)?.semanticKey, "varga.d24");
});
test("whole-window D9/D10 signs do not synthesize a discriminator without remaining-minute splits", () => {
const packet = buildCandidateContrastPacket({
candidateSetVersion: "set-a",
calculationResultId: "22222222-2222-4222-8222-222222222222",
vargaDifferences: [
{ layer: "d9", signs: ["天秤", "天蝎", "射手"] },
{ layer: "d10", signs: ["巨蟹", "狮子", "处女"] },
],
});
assert.equal(selectDiscriminatorProbe(packet), null);
});
test("exam quality and occupation notes keep remaining D10 and drop yearless D4/D24 from selection", () => {
const evidence = [
{ domain: "education", eventKind: "education_interruption", summary: "高考失利复读", status: "confirmed" },
{ domain: "occupation", eventKind: "occupation_note", summary: "互联网程序员 / 前端", status: "confirmed" },
];
const mentionedKeys = mentionedVargaKeysFromLedgerEvidence(evidence);
const packet = buildCandidateContrastPacket({
candidateSetVersion: "05:00-05:04",
calculationResultId: "22222222-2222-4222-8222-222222222222",
candidateTimes: ["05:00", "05:03", "05:04"],
transitions: [
{ layer: "d4", at: "05:03", from_sign: "金牛座", to_sign: "双子座" },
{ layer: "d4", at: "05:04", from_sign: "双子座", to_sign: "巨蟹座" },
{ layer: "d10", at: "05:03", from_sign: "巨蟹座", to_sign: "狮子座" },
{ layer: "d10", at: "05:04", from_sign: "狮子座", to_sign: "处女座" },
{ layer: "d24", at: "05:03", from_sign: "白羊座", to_sign: "金牛座" },
{ layer: "d24", at: "05:04", from_sign: "金牛座", to_sign: "双子座" },
],
mentionedKeys,
});
assert.ok(packet.probes.some((item) => item.semanticKey.startsWith("varga.d10.")));
assert.ok(packet.probes.some((item) => item.semanticKey.startsWith("varga.d24.")));
const probe = selectDiscriminatorProbe(packet, { mentionedKeys });
assert.ok(probe);
assert.match(probe.semanticKey, /varga\.d10/);
assert.equal(probe.domain, "career");
assert.equal(probe.choiceKind, "varga_style");
});
test("encoded D24/D10/D4 remaining splits leave no discriminator", () => {
const packet = buildCandidateContrastPacket({
candidateSetVersion: "05:00-05:04",
candidateTimes: ["05:00", "05:03", "05:04"],
transitions: [
{ layer: "d4", at: "05:00" },
{ layer: "d4", at: "05:03" },
{ layer: "d10", at: "05:00" },
{ layer: "d24", at: "05:00" },
],
askedKeys: ["varga.d4", "varga.d10", "varga.d24", "varga.d5"],
});
assert.equal(selectDiscriminatorProbe(packet), null);
});
test("remaining-candidate D24 split is not a selectable discriminator", () => {
const packet = buildCandidateContrastPacket({
candidateSetVersion: "05:00-05:07",
calculationResultId: "22222222-2222-4222-8222-222222222222",
vargaDifferences: [
{ layer: "d10", signs: ["巨蟹座", "狮子座", "处女座"] },
],
candidateTimes: ["05:00", "05:06", "05:07"],
transitions: [
{ layer: "d10", at: "05:00" },
{ layer: "d10", at: "05:15" },
{ layer: "d24", at: "05:00" },
{ layer: "d24", at: "05:06" },
],
askedKeys: ["education.2016", "varga.d10"],
});
const probe = selectDiscriminatorProbe(packet);
assert.equal(probe, null);
});
test("user stop completes with a credible range instead of trapping on exact-minute", () => {
const next = decideNextAction({
methodCoverageAll: true,
proposeAllowed: true,
selectionAllowed: true,
userStopped: true,
candidateScores: TIED,
discriminatorProbe: CONTRAST_PROBE,
});
assert.equal(next.type, "complete_with_range");
});
test("user stop skips remaining collection and holdout when candidates already exist", () => {
const incomplete = decideNextAction({
methodCoverageAll: false,
proposeAllowed: true,
selectionAllowed: true,
userStopped: true,
candidateScores: TIED,
});
assert.equal(incomplete.type, "complete_with_range");
const separated = decideNextAction({
methodCoverageAll: false,
proposeAllowed: true,
selectionAllowed: true,
userStopped: true,
candidateScores: [
{ time: "04:48", score: 58 },
{ time: "04:49", score: 42 },
],
holdoutValidation: "not_started",
});
assert.equal(separated.type, "complete_with_range");
const stopped = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: false,
userStopped: true,
candidateScores: [
{ time: "04:48", score: 58 },
{ time: "04:49", score: 42 },
],
holdoutValidation: "not_started",
});
// 原断言 sessionOutcome=provisional_range_user_stopped
// → 新断言 sessionOutcome=adopt_representative。
// coverage 不再挡代表性采用;completionStatus 仍标记用户停止。确认门仍关。
assert.equal(stopped.sessionOutcome, "adopt_representative");
assert.equal(stopped.canAdopt, true);
assert.equal(stopped.canConfirmExactMinute, false);
assert.equal(stopped.completionStatus, "provisional_range_user_stopped");
assert.equal(stopped.validated, false);
});
// 原值: 题名「waits for three training events」/ 新值: 「waits for four dated events across three domains」/ 原因: R1 生时校正交付门槛 4 件 3 域(BUG-1193)
test("discrimination waits for four dated events across three domains after reserving holdout", () => {
const two = trainingScoreableGate([
{ id: "e1", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
{ id: "e2", status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2020-04-01", occurredTo: null },
]);
const three = trainingScoreableGate([
{ id: "e1", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
{ id: "e2", status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2020-04-01", occurredTo: null },
{ id: "e3", status: "confirmed", domain: "relationship", datePrecision: "year", occurredFrom: "2024-08-01", occurredTo: null },
]);
const four = trainingScoreableGate([
{ id: "e1", status: "confirmed", domain: "education", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
{ id: "e2", status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2020-04-01", occurredTo: null },
{ id: "e3", status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2020-10-01", occurredTo: null },
{ id: "e4", status: "confirmed", domain: "relationship", datePrecision: "year", occurredFrom: "2024-08-01", occurredTo: null },
]);
assert.equal(two.open, false);
assert.equal(two.holdoutCount, 0);
assert.equal(two.trainingCount, 2);
// 原值: 3 件即预留 holdout,trainingCount=2,门关,等到第 4 件才区分
// 新值: 恰好 3 件全部进训练,门开,holdout 仅 ≥4 才预留
// 原因: BUG-647 训练门与 holdout 解耦
// 原值: three.open=true / 新值: false / 原因: R1 生时校正交付门槛 4 件 3 域(BUG-1193);3 件仍全部进训练、不留 holdout
assert.equal(three.open, false);
assert.equal(three.holdoutCount, 0);
assert.equal(three.trainingCount, 3);
// 4 件 3 域:留出 1 件 holdout 后训练 3 件,门槛按训练 + holdout 计,门开
assert.equal(four.open, true);
assert.equal(four.trainingCount, 3);
assert.equal(four.holdoutCount, 1);
// R1(BUG-1193):4 件但只有 2 域,门仍关
const fourTwoDomains = trainingScoreableGate([
{ id: "e1", status: "confirmed", domain: "career", datePrecision: "month", occurredFrom: "2016-09-01", occurredTo: null },
{ id: "e2", status: "confirmed", domain: "career", datePrecision: "year", occurredFrom: "2020-04-01", occurredTo: null },
{ id: "e3", status: "confirmed", domain: "relationship", datePrecision: "year", occurredFrom: "2022-10-01", occurredTo: null },
{ id: "e4", status: "confirmed", domain: "relationship", datePrecision: "year", occurredFrom: "2024-08-01", occurredTo: null },
]);
assert.equal(fourTwoDomains.open, false);
assert.equal(decideNextAction({
methodCoverageAll: true,
trainingGateOpen: two.open,
proposeAllowed: false,
candidateScores: TIED,
discriminatorProbe: CONTRAST_PROBE,
}).type, "ask_fact_collection");
// 原值: ask_candidate_discriminator / 新值: ask_fact_collection / 原因: R1 生时校正交付门槛 4 件 3 域(BUG-1193),3 件门未开
assert.equal(decideNextAction({
methodCoverageAll: true,
trainingGateOpen: three.open,
proposeAllowed: false,
candidateScores: TIED,
discriminatorProbe: CONTRAST_PROBE,
}).type, "ask_fact_collection");
assert.equal(decideNextAction({
methodCoverageAll: true,
trainingGateOpen: four.open,
proposeAllowed: false,
candidateScores: TIED,
discriminatorProbe: CONTRAST_PROBE,
}).type, "ask_candidate_discriminator");
});
test("holdout passed is a validated range, not a user-stop close", () => {
const validated = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: true,
candidateScores: [
{ time: "04:48", score: 58 },
{ time: "04:49", score: 42 },
],
holdoutValidation: "passed",
});
assert.equal(validated.sessionOutcome, "validated_range");
assert.equal(validated.completionStatus, "validated_range");
assert.equal(validated.validated, true);
assert.equal(validated.canConfirmExactMinute, false);
});
test("tied candidates with no remaining discriminator offer a provisional range", () => {
const next = decideNextAction({
methodCoverageAll: true,
proposeAllowed: true,
candidateScores: TIED,
});
assert.equal(next.type, "offer_provisional_range");
});
test("non-scoreable occupation note does not change the scoreable evidence fingerprint", () => {
const dated = [{
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
status: "confirmed" as const,
eventKind: "business_start",
domain: "career",
occurredFrom: "2026-07-19",
occurredTo: null,
datePrecision: "day" as const,
summary: "注册公司",
}];
const withNote = [...dated, {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2",
status: "confirmed" as const,
eventKind: "occupation_note",
domain: "occupation",
occurredFrom: null,
occurredTo: null,
datePrecision: "unknown" as const,
summary: "长期一直是程序员",
}];
const before = evidenceLedgerFingerprint(dated as never);
const after = evidenceLedgerFingerprint(withNote as never);
assert.equal(after, before);
const snapshot = source({ scoreableEvidenceFingerprint: before });
const current = source({ scoreableEvidenceFingerprint: after, inferenceRevision: snapshot.inferenceRevision });
assert.equal(snapshotIsCurrent(snapshot, current), true);
assert.equal(scoreableSnapshotIsCurrent(snapshot, current), true);
});
test("scoreable occupation evidence requires a new inference revision", () => {
const before = source();
const afterScoreable = source({
scoreableEvidenceFingerprint: "score-b",
inferenceRevision: before.inferenceRevision + 1,
});
assert.equal(classifySnapshotStaleReason(before, afterScoreable), "scoreable_evidence_changed");
assert.equal(afterScoreable.inferenceRevision, before.inferenceRevision + 1);
});
test("candidate cards must be created from the current inference and scoreable revisions", () => {
const current = source({ inferenceRevision: 4 });
const card = source({ inferenceRevision: 3 });
assert.equal(classifySnapshotStaleReason(card, current), "inference_revision_changed");
const matching = source({ inferenceRevision: 4 });
assert.equal(snapshotIsCurrent(matching, current), true);
});
test("stale reasons distinguish birth profile from scoreable evidence", () => {
const current = source();
assert.equal(
classifySnapshotStaleReason(source({ birthProfileFingerprint: "birth-b" }), current),
"birth_profile_changed",
);
assert.equal(
classifySnapshotStaleReason(source({ scoreableEvidenceFingerprint: "score-b" }), current),
"scoreable_evidence_changed",
);
assert.equal(
classifySnapshotStaleReason(source({ candidateSetVersion: "set-b" }), current),
"candidate_set_superseded",
);
});
test("missing scoreable fingerprint is stale, not current", () => {
const current = source();
assert.equal(scoreableSnapshotIsCurrent(source({ scoreableEvidenceFingerprint: "" }), current), false);
assert.equal(scoreableSnapshotIsCurrent(current, source({ scoreableEvidenceFingerprint: "" })), false);
assert.equal(
classifySnapshotStaleReason(source({ scoreableEvidenceFingerprint: "" }), current),
"fingerprint_missing",
);
assert.equal(storedSnapshotIsCurrent(null, current), true);
assert.equal(storedSnapshotIsCurrent(source({ scoreableEvidenceFingerprint: "" }), current), false);
assert.equal(storedSnapshotIsCurrent(current, current), true);
});
test("stale snapshot keeps discrimination instead of returning to collection", () => {
const next = decideNextAction({
methodCoverageAll: true,
trainingGateOpen: true,
snapshotCurrent: false,
candidateScores: TIED,
discriminatorProbe: CONTRAST_PROBE,
});
assert.equal(next.type, "ask_candidate_discriminator");
assert.equal(decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: true,
trainingGateOpen: true,
snapshotCurrent: false,
candidateScores: TIED,
discriminatorProbe: CONTRAST_PROBE,
}).sessionOutcome, "discriminate_candidates");
assert.equal(decideNextAction({
methodCoverageAll: false,
trainingGateOpen: false,
snapshotCurrent: false,
candidateScores: TIED,
}).type, "ask_fact_collection");
});
test("a single candidate is sole_candidate, not a parallel range", () => {
const separation = evaluateCandidateSeparation([{ time: "05:00", score: 80 }]);
assert.equal(separation.status, "sole_candidate");
assert.equal(separation.sufficient, true);
assert.equal(separation.lead, 0);
assert.deepEqual(separation.credibleRange, ["05:00"]);
const decision = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: true,
trainingGateOpen: true,
candidateScores: [{ time: "05:00", score: 80 }],
holdoutValidation: "unavailable",
});
// 原断言 sessionOutcome=provisional_range 且 canAdopt=false
// → 新断言 sessionOutcome=adopt_representative 且 canAdopt=true。
// holdout unavailable 只挡确认门;单候选仍是代表性采用,不是唯一分钟确认。
assert.equal(decision.sessionOutcome, "adopt_representative");
assert.equal(decision.canAdopt, true);
assert.equal(decision.canConfirmExactMinute, false);
assert.doesNotMatch(
buildSkillVerificationReport({
representativeTime: "05:00",
widthMinutes: 0,
candidates: [{ time: "05:00", rank: 1, relativeSupport: 80 }],
separation,
}),
/基本并列/,
);
});
test("34/33/33 is a tie, not a recommended winner", () => {
const separation = evaluateCandidateSeparation(TIED);
assert.equal(separation.status, "not_separated");
assert.equal(separation.sufficient, false);
assert.deepEqual(separation.credibleRange, ["05:00", "05:01", "05:02"]);
assert.equal(separation.representativeTime, "05:00");
});
test("final report does not claim executed techniques without calculationResultId", () => {
const report = buildSkillVerificationReport({
representativeTime: "05:00",
widthMinutes: 3,
candidates: [
{ time: "05:00", rank: 1, relativeSupport: 34 },
{ time: "05:01", rank: 2, relativeSupport: 33 },
{ time: "05:02", rank: 3, relativeSupport: 33 },
],
separation: evaluateCandidateSeparation(TIED),
techniqueAuditTable: [
{ technique: "D9", status: "executed", note: "no id" },
{ technique: "D10", status: "executed", note: "has id", calculation_result_id: "33333333-3333-4333-8333-333333333333" },
],
eventFitRate: {
matched: 8,
total: 9,
percent: 89,
label: "8/9",
user_meaning: "这段时间窗对已收事件有一定解释力",
unique_minute_claim: false,
},
});
assert.match(report, /基本并列/);
assert.match(report, /事件拟合程度,不是候选区分程度/);
assert.match(report, /\| D9 \| input_covered \|/);
assert.match(report, /\| D10 \| executed \|/);
assert.doesNotMatch(report, /当前推荐/);
});
test("core barrel re-exports decideNextAction without duplicating session helpers", () => {
const index = readFileSync(new URL("../src/lib/rectification-agentic/core/index.ts", import.meta.url), "utf8");
assert.match(
index,
/export \{\s*decideNextAction,\s*type DecideNextActionInput,\s*type RectificationNextAction,\s*\} from "\.\/decide-next-action\.ts"/,
);
assert.doesNotMatch(index, /export \* from "\.\/decide-next-action\.ts"/);
assert.match(index, /export \* from "\.\/rectification-decision\.ts"/);
});
test("MethodFollowup unions include holdout validation kinds used by next_followup", () => {
const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/method-followup.ts", import.meta.url), "utf8");
const methodId = source.match(/export type MethodFollowup = Readonly<\{[\s\S]*?method_id: ([^;]+);/)?.[1] ?? "";
const askTheme = source.match(/export type MethodFollowup = Readonly<\{[\s\S]*?ask_theme: ([^;]+);/)?.[1] ?? "";
assert.match(methodId, /"holdout_validation"/);
assert.match(askTheme, /"holdout"/);
});
test("open capability with empty dated probe pool delivers instead of collecting", () => {
const collecting = decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: false,
datedMethodCollectOpen: true,
datedEventCount: 4,
datedDomainCount: 3,
trainingGateOpen: true,
snapshotCurrent: true,
candidateScores: TIED,
confirmationAllowed: false,
});
// 原值: sessionOutcome=collect_evidence,公开 can_adopt=false
// 新值: 训练门开、无带年月探针 → adopt_representative / 可出区间卡
// 原因: BUG-651 带年月池空即交付,剩余采集不得挡住结果卡
assert.equal(collecting.sessionOutcome, "adopt_representative");
assert.equal(collecting.canOfferRange, true);
assert.equal(collecting.canAdopt, true);
assert.equal(publicDecisionFields(collecting).can_adopt, true);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "adopt_representative" }), true);
assert.equal(publicCanAdopt({ canAdopt: true, sessionOutcome: "provisional_range_user_stopped" }), true);
});
const SEPARATED_COLLECT_OPEN = {
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: true,
datedMethodCollectOpen: true,
datedEventCount: 5,
datedDomainCount: 3,
trainingGateOpen: true,
snapshotCurrent: true,
candidateScores: SEPARATED,
holdoutValidation: "unavailable" as const,
confirmationAllowed: false,
};
test("separated candidates stay in collect while dated method collect is still open", () => {
const decision = decideRectification(SEPARATED_COLLECT_OPEN);
assert.equal(decision.sessionOutcome, "collect_evidence");
assert.equal(decision.nextAction, "ask_fact_collection");
assert.equal(publicDecisionFields(decision).can_adopt, false);
});
test("user stop still delivers while dated method collect is open", () => {
const stopped = decideRectification({
...SEPARATED_COLLECT_OPEN,
userStopped: true,
});
assert.equal(stopped.sessionOutcome, "adopt_representative");
assert.equal(stopped.nextAction, "complete_with_range");
assert.equal(stopped.completionStatus, "provisional_range_user_stopped");
assert.equal(publicDecisionFields(stopped).can_adopt, true);
});
test("accepted representative adopt ignores open dated collect", () => {
const accepted = decideRectification({
...SEPARATED_COLLECT_OPEN,
accepted: true,
});
assert.equal(accepted.sessionOutcome, "adopt_representative");
assert.equal(accepted.nextAction, "ready_to_adopt");
});