Files
Jyotisha/frontend/tests/rectification-range-offer-deadend.test.ts
T
Jesse_Chen 85b14b13ae
Independent Staging Quality Gate / validate (push) Successful in 11m3s
Independent Staging Quality Gate / publish (push) Successful in 8m48s
fix(rectification): close non-converging range offer without an exit
When coverage still blocks adopt and no renderable discriminator remains, narrate the numeric range and persist a spoken collect instead of promising a time card that cannot be clicked. Recompute probe information gain on active candidates and drop zero-split probes explicitly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 18:35:16 +08:00

616 lines
23 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 { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
import { asInferenceState } from "../src/lib/rectification-agentic/core/compose-receipt.ts";
import {
inspectDiscriminatorProbes,
buildCandidateContrastPacket,
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import {
decideRectification,
isNonConvergingRangeOffer,
nonConvergingRangeNarration,
REPRESENTATIVE_MINUTE_DISCLAIMER,
} from "../src/lib/rectification-agentic/core/rectification-decision.ts";
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import type { ConflictProbe, InferenceState } from "../src/lib/rectification-agentic/core/types.ts";
import {
decideAfterInferenceChange,
decideFromDossier,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import {
exhaustionSpokenCollectFollowup,
projectRectificationChoiceCard,
spokenFollowupForUser,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { informationGainAmongActive } from "../src/lib/rectification-agentic/v9/probe-question-contract.ts";
import { projectCurrentQuestion } from "../src/lib/rectification-agentic/v9/turn-decision.ts";
import { RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import {
CASE_ID,
FOCUS_ID,
TURN_ID,
USER_ID,
candidateSnapshotFixture,
computeFixture,
dossierFixture,
fakeAccounting,
receiptHandlers,
} from "./rectification-v9-test-support.ts";
const EXISTENCE_OPTIONS = [
{ label: "明确发生且时间吻合", answer_class: "yes" as const },
{ label: "发生过但程度较弱", answer_class: "weak_yes" as const },
{ label: "明确没有发生", answer_class: "no" as const },
{ label: "这段记不清楚", answer_class: "unsure" as const },
];
const TIMES = [
"04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14", "05:15",
] as const;
const ELIMINATED = new Set(["05:00", "05:07", "05:12", "05:14", "05:15"]);
const ACTIVE = ["04:47", "04:51", "04:53", "04:59"] as const;
const SCORES: Record<string, number> = {
"04:47": 16,
"04:51": 20,
"04:53": 16,
"04:59": 10,
"05:00": 4,
"05:07": 3,
"05:12": 2,
"05:14": 1,
"05:15": 1,
};
const PROBABILITY: Record<string, number> = {
"04:47": 0.25,
"04:51": 0.4,
"04:53": 0.25,
"04:59": 0.1,
};
const EVIDENCE = [
{
id: "e-career-entry",
status: "confirmed" as const,
domain: "career",
datePrecision: "month" as const,
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
summary: "2020-04-01 career_entry",
},
{
id: "e-career-exit",
status: "confirmed" as const,
domain: "career",
datePrecision: "month" as const,
occurredFrom: "2020-10-01",
occurredTo: null,
eventKind: "career_exit",
summary: "2020-10-01 career_exit",
},
{
id: "e-rel-start",
status: "confirmed" as const,
domain: "relationship",
datePrecision: "month" as const,
occurredFrom: "2024-05-01",
occurredTo: null,
eventKind: "relationship_start",
summary: "2024-05-01 relationship_start",
},
{
id: "e-rel-end",
status: "confirmed" as const,
domain: "relationship",
datePrecision: "day" as const,
occurredFrom: "2024-08-08",
occurredTo: null,
eventKind: "relationship_end",
summary: "2024-08-08 relationship_end",
},
] as const;
const DUAL_EXIT = "可以先按当前区间看盘,也可以再补一件记得时间的经历";
function existenceProbe(input: {
key: string;
year: number;
question: string;
gain: number;
source: string;
yes: readonly string[];
no: readonly string[];
}): ConflictProbe {
return {
id: `probe:${input.key}`,
semantic_key: input.key,
candidate_split_hash: input.key,
domain: "career",
year: input.year,
question: input.question,
candidate_ids: [...new Set([...input.yes, ...input.no])],
expected_outcomes: [
{ answer_class: "yes", supports: input.yes, conflicts: input.no },
{ answer_class: "weak_yes", supports: [], conflicts: [] },
{ answer_class: "no", supports: input.no, conflicts: input.yes },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: input.gain,
source: input.source,
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
}
const D9: ConflictProbe = {
id: "contrast:varga.d9.巨蟹座/狮子座",
semantic_key: "varga.d9.巨蟹座/狮子座",
candidate_split_hash: "varga.d9.巨蟹座/狮子座",
domain: "relationship",
year: 0,
question: "亲密关系里更接近下面哪一种相处方式?",
candidate_ids: ["05:00", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] },
{ answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00"] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 1.1,
source: "varga_contrast",
choice_kind: "varga_style",
};
const D10: ConflictProbe = {
id: "contrast:varga.d10.天秤座/天蝎座",
semantic_key: "varga.d10.天秤座/天蝎座",
candidate_split_hash: "varga.d10.天秤座/天蝎座",
domain: "career",
year: 0,
question: "平时做事更接近下面哪一种职责风格?",
candidate_ids: ["05:00", "05:07"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:07"] },
{ answer_class: "weak_yes", supports: ["05:07"], conflicts: ["05:00"] },
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 1.05,
source: "varga_contrast",
choice_kind: "varga_style",
};
const CAREER_2023_05 = existenceProbe({
key: "career.2023.05.dasha_boundary",
year: 2023,
question: "2023 年 5 月前后有没有入职或换工作?",
gain: 0.9,
source: "dasha_boundary",
yes: ["05:00"],
no: ["05:07"],
});
const RELOCATION_2015_05: ConflictProbe = {
...existenceProbe({
key: "relocation.2015.05.dasha_boundary",
year: 2015,
question: "2015 年 5 月前后有没有搬家或长期住到外地?",
gain: 0.7,
source: "dasha_boundary",
yes: ["05:00"],
no: ["05:07"],
}),
domain: "relocation",
};
/** Live remaining probe: splits after/before 05:00. Zero split on 04:4704:59. */
const CAREER_2024_04 = existenceProbe({
key: "career.2024.04.dasha_boundary",
year: 2024,
question: "2024 年 4 月前后有没有入职或换工作?",
gain: 1.1712,
source: "dasha_boundary",
yes: ["05:00", "05:07", "05:12", "05:14", "05:15"],
no: ["04:47", "04:51", "04:53", "04:59"],
});
/** Live remaining probe: isolates 05:15. Zero split on 04:4704:59. */
const CAREER_2023_ACTIVATION = existenceProbe({
key: "career.2023.dasha_activation",
year: 2023,
question: "2023 年前后大运有没有启动?",
gain: 0.56,
source: "dasha_activation",
yes: ["05:15"],
no: ["04:47", "04:51", "04:53", "04:59", "05:00", "05:07", "05:12", "05:14"],
});
/** P1 fixture: dated career probe that still splits the active four minutes. */
const CAREER_ACTIVE_SPLIT = existenceProbe({
key: "career.2022.dasha_boundary",
year: 2022,
question: "2022 年前后有没有入职或换工作?",
gain: 1.4,
source: "dasha_boundary",
yes: ["04:47", "04:51"],
no: ["04:53", "04:59"],
});
function uuidAt(index: number): string {
return `88888888-8888-4888-8888-8888888888${(10 + index).toString(16).padStart(2, "0")}`;
}
function liveState(remaining: readonly ConflictProbe[]): InferenceState {
const probes = [D9, D10, CAREER_2023_05, RELOCATION_2015_05, ...remaining];
const rankedActive = [...ACTIVE].sort((left, right) => (
(PROBABILITY[right] ?? 0) - (PROBABILITY[left] ?? 0)
|| (SCORES[right] ?? 0) - (SCORES[left] ?? 0)
|| left.localeCompare(right)
));
const candidates = TIMES.map((time, index) => {
const eliminated = ELIMINATED.has(time);
const activeRank = (rankedActive as readonly string[]).indexOf(time);
return {
id: time,
time,
cluster_range: [time, time] as const,
prior_score: SCORES[time] ?? 0,
posterior_score: SCORES[time] ?? 0,
probability: eliminated ? 0 : (PROBABILITY[time] ?? 0),
status: eliminated ? "eliminated" as const : "active" as const,
rank: eliminated ? ACTIVE.length + index : activeRank + 1,
strong_conflict_count: eliminated ? 3 : 0,
};
});
const answered = [D9, D10, CAREER_2023_05, RELOCATION_2015_05].map((probe) => ({
probe_id: probe.id,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
answer_class: "no" as const,
classified_from: "choice" as const,
}));
const raw = {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: candidateSetId("04:47", "05:15", TIMES),
revision: 5,
phase: "discrimination" as const,
result_status: "discriminating" as const,
range_start: "04:47",
range_end: "05:15",
candidates,
events: [
{ id: "e-career-entry", domain: "career", year: 2020, precision: "month" as const, usage: "training" as const },
{ id: "e-career-exit", domain: "career", year: 2020, precision: "month" as const, usage: "training" as const },
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" as const, usage: "training" as const },
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" as const, usage: "training" as const },
],
probes,
answered_probes: answered,
rounds: [],
last_inference_round: null,
entropy: 1.2,
representative_time: "04:51",
credible_range: ["04:47", "04:53"] as const,
holdout_passed: null,
};
const loaded = asInferenceState(raw);
assert.ok(loaded, "live inference fixture must pass asInferenceState");
return loaded;
}
function liveDossier(
remaining: readonly ConflictProbe[],
extra: {
declinedTopics?: ReadonlyArray<Record<string, unknown>>;
status?: string;
eventProbes?: boolean;
} = {},
): DecisionDossier {
const state = liveState(remaining);
const fingerprint = evidenceLedgerFingerprint(EVIDENCE as never);
return {
evidence: EVIDENCE,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: extra.declinedTopics ?? [{ target_domain: "family", status: "declined" }],
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
selectionAllowed: true,
confirmationAllowed: false,
evidenceLedgerFingerprint: fingerprint,
candidates: TIMES.map((time, index) => ({
candidateId: uuidAt(index),
time,
rank: index + 1,
relativeSupport: Math.round(SCORES[time] ?? 0),
})),
representativeTime: "04:51",
decisionReceipt: {
accept_allowed: true,
propose_allowed: true,
selection_allowed: true,
inference_state: state,
...(extra.eventProbes
? {
discriminating_event_probes: remaining.map((probe) => ({
year: probe.year,
year_label: `${probe.year} 年前后`,
domain: probe.domain,
event_family: "入职、换工作或职责加重",
source: probe.source,
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: probe.question,
role: "distinguish",
information_gain: probe.information_gain,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
candidate_ids: probe.candidate_ids,
expected_outcomes: probe.expected_outcomes,
choice_kind: probe.choice_kind,
style_options: probe.style_options,
})),
}
: {}),
},
},
case: { acceptedTime: null, status: extra.status },
};
}
function snapshotCandidates() {
return TIMES.map((time, index) => ({
candidate_id: uuidAt(index),
rank: index + 1,
time,
relative_support: Math.round(SCORES[time] ?? 0),
tied_minute_count: 1,
}));
}
function rpcDossier(decision: DecisionDossier) {
const evidence = EVIDENCE.map((item) => ({
id: item.id,
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind,
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: `${item.occurredFrom} ${item.eventKind}`,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-08-29T00:00:00.000Z",
}));
return dossierFixture({
evidence,
latestResult: candidateSnapshotFixture({
selectionAllowed: true,
confirmationAllowed: false,
representativeTime: "04:51",
evidenceLedgerFingerprint: evidenceLedgerFingerprint(EVIDENCE as never),
candidates: snapshotCandidates(),
decisionReceipt: {
accept_allowed: true,
propose_allowed: true,
selection_allowed: true,
...(decision.latestResult?.decisionReceipt ?? {}),
},
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: null,
declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics,
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-08-30T00:00:00.000Z",
},
});
}
test("skill version stays 10.0.13 for the range-offer dead-end fix", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.13");
});
test("pre-fix dual-exit constant is gone; range narration carries numbers and the disclaimer", () => {
const decision = "../src/lib/rectification-agentic/core/rectification-decision.ts";
const answer = "../src/lib/rectification-agentic/v9/answer-choice.ts";
const route = "../src/app/api/rectification/agent/route.ts";
for (const relative of [decision, answer, route]) {
const source = readFileSync(new URL(relative, import.meta.url), "utf8");
assert.doesNotMatch(source, new RegExp(DUAL_EXIT));
assert.doesNotMatch(source, /NON_CONVERGING_RANGE_NARRATION|DISCRIMINATOR_EXHAUSTED_NARRATION/);
assert.match(source, /nonConvergingRangeNarration/);
}
const text = nonConvergingRangeNarration({
credibleRange: ["04:47", "04:53"],
representativeTime: "04:51",
});
assert.match(text, /04:4704:53/);
assert.match(text, /04:51/);
assert.match(text, new RegExp(REPRESENTATIVE_MINUTE_DISCLAIMER));
assert.doesNotMatch(text, /可以先按当前区间看盘/);
});
test("zero-split among active is dropped with an explicit reason, not kept silent", () => {
const after = informationGainAmongActive(CAREER_2024_04.expected_outcomes, ACTIVE);
const activation = informationGainAmongActive(CAREER_2023_ACTIVATION.expected_outcomes, ACTIVE);
const split = informationGainAmongActive(CAREER_ACTIVE_SPLIT.expected_outcomes, ACTIVE);
assert.equal(after.splits, false);
assert.equal(activation.splits, false);
assert.equal(split.splits, true);
const packet = buildCandidateContrastPacket({
candidateSetVersion: candidateSetId("04:47", "05:15", TIMES),
engineProbes: [CAREER_2024_04, CAREER_2023_ACTIVATION].map((probe) => ({
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
domain: probe.domain,
year: probe.year,
user_meaning: probe.question,
information_gain: probe.information_gain,
expected_outcomes: probe.expected_outcomes,
choice_kind: probe.choice_kind,
style_options: probe.style_options,
})),
providedDomains: ["career", "relationship"],
candidateTimes: [...TIMES],
});
assert.equal(packet.probes.some((item) => item.semanticKey === CAREER_2024_04.semantic_key), true);
const inspected = inspectDiscriminatorProbes(packet, { topCandidateTimes: ACTIVE });
assert.equal(inspected.selected, null);
assert.equal(inspected.dropped.some((item) => (
item.semantic_key === CAREER_2024_04.semantic_key && item.reason === "no_split_among_active"
)), true);
assert.equal(inspected.dropped.some((item) => (
item.semantic_key === CAREER_2023_ACTIVATION.semantic_key && item.reason === "no_split_among_active"
)), true);
});
test("P1: dated career probes live only in inference_state still ask when they split active minutes", () => {
const dossier = liveDossier([CAREER_ACTIVE_SPLIT]);
const fromDossier = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.equal(fromDossier.nextAction, "ask_candidate_discriminator");
assert.equal(fromDossier.probe?.semanticKey, CAREER_ACTIVE_SPLIT.semantic_key);
assert.equal(isNonConvergingRangeOffer(fromDossier), false);
const after = decideAfterInferenceChange({
dossier,
state: liveState([CAREER_ACTIVE_SPLIT]),
userStopped: false,
birthDate: "1997-08-08",
});
assert.equal(after.nextAction, "ask_candidate_discriminator");
assert.equal(after.probe?.semanticKey, CAREER_ACTIVE_SPLIT.semantic_key);
});
test("live remaining probes with zero active split enter dropped_probes and the range-offer branch", () => {
const remaining = [CAREER_2024_04, CAREER_2023_ACTIVATION];
const dossier = liveDossier(remaining);
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.equal(decision.probe, null);
assert.equal(decision.nextAction, "offer_provisional_range");
assert.equal(isNonConvergingRangeOffer(decision), true);
assert.equal(decision.canAdopt, false);
assert.equal(decision.selectionAllowed, false);
assert.deepEqual(decision.credibleRange, ["04:47", "04:53"]);
assert.equal(decision.representativeTime, "04:51");
assert.equal(decision.droppedProbes.some((item) => (
item.semantic_key === CAREER_2024_04.semantic_key && item.reason === "no_split_among_active"
)), true);
assert.equal(decision.droppedProbes.some((item) => (
item.semantic_key === CAREER_2023_ACTIVATION.semantic_key && item.reason === "no_split_among_active"
)), true);
});
test("offerRangeWithoutAdopt persists a spoken collect and narrates the numeric range", async () => {
const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION]);
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.equal(isNonConvergingRangeOffer(decision), true);
const card = projectRectificationChoiceCard({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: decision.sessionOutcome,
selectionAllowed: decision.selectionAllowed,
});
assert.equal(card, null);
const collect = exhaustionSpokenCollectFollowup({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
});
assert.equal(collect?.domain, "education");
assert.equal(collect?.intent, "collect_method_evidence");
assert.equal(collect?.choice_frame, null);
const spoken = spokenFollowupForUser(collect);
assert.ok(spoken);
const accounting = fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(dossier),
get_agentic_rectification_case_compute: () => computeFixture(),
set_agentic_rectification_conversation_focus: (_fn, args) => ({
focus: {
id: FOCUS_ID,
case_id: CASE_ID,
question_id: args.p_question_id,
intent: args.p_intent,
target_evidence_id: args.p_target_evidence_id,
target_domain: args.p_target_domain,
target_kind: args.p_target_kind,
expected_answer_schema: args.p_expected_answer_schema,
status: "active",
asked_at: "2026-08-30T00:00:00.000Z",
resolved_at: null,
},
idempotent: false,
}),
});
const idle = await persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.equal(idle.persisted, true);
assert.equal(idle.choiceReady, false);
assert.match(idle.hostNarration ?? "", /04:4704:53/);
assert.match(idle.hostNarration ?? "", /04:51/);
assert.match(idle.hostNarration ?? "", new RegExp(REPRESENTATIVE_MINUTE_DISCLAIMER));
assert.match(idle.hostNarration ?? "", /升学|转学|考试/);
assert.doesNotMatch(idle.hostNarration ?? "", new RegExp(DUAL_EXIT));
const setFocus = accounting.calls.find((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.equal(setFocus?.args.p_intent, "collect_method_evidence");
assert.equal(setFocus?.args.p_target_domain, "education");
const schema = setFocus?.args.p_expected_answer_schema as Record<string, unknown> | undefined;
const question = projectCurrentQuestion({
id: FOCUS_ID,
questionId: String(setFocus?.args.p_question_id ?? ""),
intent: "collect_method_evidence",
targetDomain: "education",
expectedAnswerSchema: schema ?? null,
});
assert.equal(question?.kind, "collect_spoken");
assert.ok(question?.prompt);
assert.equal(Boolean(idle.hostNarration) && Boolean(question), true);
});
test("userStopped still completes as provisional_range_user_stopped with canAdopt", () => {
const stopped = decideRectification({
methodCoverageAll: false,
trainingGateOpen: true,
userStopped: true,
engineAcceptAllowed: true,
engineProposeAllowed: true,
candidateScores: ACTIVE.map((time) => ({ time, score: SCORES[time] ?? 0 })),
});
assert.equal(stopped.sessionOutcome, "provisional_range_user_stopped");
assert.equal(stopped.canAdopt, true);
assert.equal(stopped.canOfferRange, true);
assert.equal(isNonConvergingRangeOffer(stopped), false);
const dossier = liveDossier([CAREER_2024_04, CAREER_2023_ACTIVATION], { status: "paused" });
const fromDossier = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.equal(fromDossier.sessionOutcome, "provisional_range_user_stopped");
assert.equal(fromDossier.canAdopt, true);
});
test("idle persist still decides from the dossier once and does not invent collect_evidence", () => {
const source = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8");
const idle = source.slice(
source.indexOf("export async function persistNextInterviewIfIdle"),
source.indexOf("async function persistApplied"),
);
assert.equal(idle.split("decideFromDossier").length - 1, 1);
assert.match(idle, /sessionOutcome:\s*decision\.sessionOutcome/);
assert.doesNotMatch(idle, /sessionOutcome:\s*"collect_evidence"/);
assert.match(idle, /persistExhaustionCollect/);
});