Files
Jyotisha/frontend/tests/rectification-decide-next-action.test.ts
Jesse_Chen a658ef8d2b fix(web): stop rectification interview when remaining minutes no longer split
Coverage-complete ties stayed in discrimination because whole-window D9/D24 follow-ups were treated as probes, and restated dates inserted duplicate evidence. Skip encoded remaining layers, ask leftover D4 or offer a provisional range, and dedupe dated rows by kind and date.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 19:02:35 +08:00

316 lines
12 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import {
askedKeysFromLedgerEvidence,
buildCandidateContrastPacket,
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 {
classifySnapshotStaleReason,
scoreableSnapshotIsCurrent,
snapshotIsCurrent,
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("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 skip remaining D24/D10 and keep D4", () => {
const evidence = [
{ domain: "education", eventKind: "education_interruption", summary: "高考失利复读", status: "confirmed" },
{ domain: "occupation", eventKind: "occupation_note", summary: "互联网程序员 / 前端", status: "confirmed" },
];
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:00" },
{ layer: "d4", at: "05:03" },
{ layer: "d10", at: "05:00" },
{ layer: "d10", at: "05:03" },
{ layer: "d24", at: "05:00" },
{ layer: "d24", at: "05:03" },
{ layer: "d9", at: "04:52" },
{ layer: "d9", at: "05:08" },
{ layer: "d5", at: "05:15" },
],
askedKeys: askedKeysFromLedgerEvidence(evidence),
});
const probe = selectDiscriminatorProbe(packet);
assert.ok(probe);
assert.match(probe.semanticKey, /varga\.d4/);
assert.equal(probe.domain, "relocation");
assert.match(probe.question, /搬家|离乡/);
});
test("encoded D24/D10/D4 remaining splits leave no discriminator", () => {
const evidence = [
{ domain: "education", eventKind: "education_interruption", summary: "高考失利复读", status: "confirmed" },
{ domain: "occupation", eventKind: "occupation_note", summary: "互联网程序员", status: "confirmed" },
{ domain: "relocation", eventKind: "home_change", summary: "搬家离乡", status: "confirmed" },
];
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: askedKeysFromLedgerEvidence(evidence),
});
assert.equal(selectDiscriminatorProbe(packet), null);
});
test("remaining-candidate D24 split beats window D10 signs", () => {
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.ok(probe);
assert.match(probe.semanticKey, /varga\.d24/);
assert.equal(probe.domain, "education");
assert.deepEqual(probe.expectedOutcomes[0]?.supportsCandidateIds, ["05:00"]);
assert.ok(probe.expectedOutcomes[0]?.conflictsCandidateIds.includes("05:06"));
assert.ok(probe.expectedOutcomes.every((row) => (
row.supportsCandidateIds.every((id) => id.includes(":"))
)));
});
test("user stop with selection_allowed offers a provisional range", () => {
const next = decideNextAction({
methodCoverageAll: true,
proposeAllowed: true,
selectionAllowed: true,
userStopped: true,
candidateScores: TIED,
discriminatorProbe: CONTRAST_PROBE,
});
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("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, /当前推荐/);
});