Files
Jyotisha/frontend/tests/rectification-dead-d9-choice-20260916.test.ts
T
Jesse_ChenandClaude Fable 5.1 5113d457b7 fix(rectification): 自建分盘探针按 receipt 重建;风格题不再计分;死卡不配采集占位
BUG-915:BUG-912 首版只在盖戳那一刻把自建探针注入内存 state,答题 / GET /
idle 三处读持久化 receipt 都找不到它 → 点选与打字回答报 stale_probe、GET 无卡、
刚落库的焦点每次 idle persist 被 superseded。新增
withOwnedDistinguishProbes(state, receipt) 按 window_scan.transitions + state
候选确定性重建(照 withNakshatraBoundaryProbe 的既有模式),接入盖戳、点选答题、
打字答题、GET 投影、idle 过期五处,并加源码契约测试钉住。重建探针的 source 为
owned_varga_style,像 nakshatra_boundary 一样从 contrast packet 与 event 探针池
排除,避免答题写回 state 后它们变成可问的题。

产品决策(任务书 §1d,选项 b):分盘风格题不再作为 distinguish_candidates 计分题。
分层判别题只在引擎给出该领域带年份事件探针时以「某年前后有没有…」出题并按该探针
计分;没有事件探针时不出题,计划直接走下一条线。删除 attachVargaDistinguishIdentity
与 withFollowupOwnedProbe,保留 buildVargaDistinguishFields / conflictProbeFromFollowup
供重建与将来选项 (a)。

BUG-917:最新助手消息带未答点选而 GET 无卡时,缺口态改走 unavailable 修复出口,
不再与「再说一件带年月的事」同屏;superseded 且未作答的焦点不再渲染成灰色选项。

BUG-916:年月阶段 host 前置挪到会话校验之后,phase 复用 answer.host_fallback。

测试改走生产路径(inferenceForPersistedAnswer / choiceCardFromCaseDossier /
persistedDistinguishFocusStale),删掉手工把自建探针塞进 state 的 fixture。
tsc 0 错;lint 0 error / 116 warning;npm test 3421 条 / 31 红,失败清单与基线
c32f81e7 逐条一致;next build --webpack 后 / 仍 ○ Static,产物 JS gzip
1,496,339 → 1,496,408(+0.005%);pytest rectification 定向 63 绿,快速门 Python
段 798 绿。Skill 未 bump。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-17 00:48:50 +00:00

494 lines
18 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 { readFileSync } from "node:fs";
import { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import {
applyChoiceWithoutEvidence,
previousInferenceFromReceipt,
stampChoiceSchemaWithProbe,
} from "../src/lib/rectification-agentic/v9/inference-adapter.ts";
import {
buildMethodFollowupPlan,
yearlessPersonalityCanAsk,
type MethodFollowup,
} from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { choiceCardFromCaseDossier } from "../src/lib/rectification-agentic/v9/interview-state.ts";
import { rectificationFollowupCatalog } from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import {
inferenceForPersistedAnswer,
persistedDistinguishFocusStale,
} from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { expectedAnswerSchemaFor } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import { buildChoiceFrame } from "../src/lib/rectification-agentic/v9/choice-card.ts";
import { buildRangeDelivery } from "../src/lib/rectification-agentic/v9/divergence-panel.ts";
import { parseWindowScan } from "../src/lib/rectification-agentic/v9/varga-observations.ts";
import {
buildVargaDistinguishFields,
withOwnedDistinguishProbes,
} from "../src/lib/rectification-agentic/v9/varga-distinguish-probe.ts";
import { rectificationQuestionGapState } from "../src/lib/rectification-surface-state.ts";
import { FOCUS_ID } from "./rectification-v9-test-support.ts";
const OWNED_KEY = "d9_relationship:relationship_style";
const QUESTION_ID = "d9_relationship:relationship_style:score";
const DATED = [
{
status: "confirmed",
domain: "health_pressure",
datePrecision: "month",
occurredFrom: "2019-03-01",
occurredTo: "2019-03-31",
},
{
status: "confirmed",
domain: "career",
datePrecision: "year",
occurredFrom: "2018-01-01",
occurredTo: "2018-12-31",
},
{
status: "confirmed",
domain: "education",
datePrecision: "year",
occurredFrom: "2016-01-01",
occurredTo: "2016-12-31",
},
{
status: "confirmed",
domain: "occupation",
datePrecision: "unknown",
occurredFrom: null,
occurredTo: null,
},
];
const D24 = {
id: "contrast:varga.d24.白羊座|金牛座|双子座|巨蟹座|狮子座|处女座",
semantic_key: "varga.d24.白羊座|金牛座|双子座|巨蟹座|狮子座|处女座",
candidate_split_hash: "set:varga.d24.白羊座|金牛座|双子座|巨蟹座|狮子座|处女座",
domain: "education",
year: 0,
question: "学业对照",
candidate_ids: ["04:48", "04:59"],
expected_outcomes: [
{ answer_class: "yes" as const, supports: ["04:48"], conflicts: ["04:59"] },
{ answer_class: "weak_yes" as const, supports: ["04:59"], conflicts: ["04:48"] },
{ answer_class: "no" as const, supports: [], conflicts: [] },
{ answer_class: "unsure" as const, supports: [], conflicts: [] },
],
information_gain: 0.9,
source: "engine",
choice_kind: "event_quality" as const,
};
/** Engine window scan as it arrives on the decision receipt. */
const WINDOW_SCAN_RECEIPT = {
scanned: true,
d9_lagna_count: 2,
d10_lagna_count: 1,
d9_candidates_differ: true,
d10_candidates_differ: false,
d9_sign_names: ["天蝎座", "天秤座"],
d10_sign_names: ["巨蟹座"],
transitions: [
{ layer: "d9", at: "04:53", from_sign: "天蝎座", to_sign: "天秤座" },
{ layer: "d10", at: "04:40", from_sign: "巨蟹座", to_sign: "巨蟹座" },
],
};
const WINDOW = parseWindowScan(WINDOW_SCAN_RECEIPT)!;
const CANDIDATES = [
{ id: "04:48", time: "04:48" },
{ id: "04:53", time: "04:53" },
{ id: "04:59", time: "04:59" },
];
/** The persisted state: engine probes only, exactly as the receipt stores it. */
function persistedState() {
return buildInferenceState({
range_start: "04:48",
range_end: "05:07",
candidates: [
{ id: "04:48", time: "04:48", relative_support: 44 },
{ id: "04:53", time: "04:53", relative_support: 35 },
{ id: "04:59", time: "04:59", relative_support: 21 },
],
events: Array.from({ length: 5 }, (_, index) => ({
id: `e${index}`,
domain: "health",
year: 2019,
precision: "month" as const,
})),
probes: [D24],
});
}
function receipt() {
return { inference_state: persistedState(), window_scan: WINDOW_SCAN_RECEIPT };
}
/**
* A D9 style focus as it was persisted before the 2026-09-17 (b) decision:
* its identity is the layer's own key, and its probe lives nowhere but the
* receipt's window scan.
*/
function legacyD9Followup(): MethodFollowup {
const fields = buildVargaDistinguishFields({
methodId: "d9_relationship",
askTheme: "relationship_style",
transitions: WINDOW.transitions,
candidates: CANDIDATES,
});
assert.ok(fields);
const followup = {
method_id: "d9_relationship",
intent: "distinguish_candidates",
ask_theme: "relationship_style",
domain: "relationship",
kind_hint: "relationship_change",
user_prompt_hint: "当前候选在关系主题上仍分不开。",
source: "varga_observation",
must_not_label: false,
choice_frame: null,
...fields,
} as MethodFollowup;
const frame = buildChoiceFrame(followup, {
evidence: DATED,
probes: [{
year: 0,
year_label: "当前这几个候选",
domain: "relationship",
event_family: "开始认真关系、分手或结婚",
source: "dasha_activation",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: followup.user_prompt_hint,
role: "distinguish",
phase: "candidate_discriminator",
information_gain: followup.information_gain,
semantic_key: followup.semantic_key,
candidate_split_hash: followup.candidate_split_hash,
candidate_ids: followup.candidate_ids,
expected_outcomes: followup.expected_outcomes,
choice_kind: "varga_style" as const,
style_options: followup.style_options,
}],
});
assert.ok(frame);
return { ...followup, choice_frame: frame };
}
function persistedSchema(): Record<string, unknown> {
const followup = legacyD9Followup();
const schema = expectedAnswerSchemaFor(
followup.choice_frame!,
QUESTION_ID,
receipt(),
followup,
);
assert.ok(schema);
return schema;
}
test("stamping rebuilds the owned probe from the receipt instead of borrowing D24 (BUG-912/915)", () => {
const state = persistedState();
const borrowed = stampChoiceSchemaWithProbe({
choice: {
options: [
{ key: "A", answer_class: "yes" },
{ key: "B", answer_class: "weak_yes" },
{ key: "C", answer_class: "no" },
{ key: "D", answer_class: "unsure" },
],
},
}, state, QUESTION_ID);
assert.notEqual(borrowed.probe_id, D24.id);
// The persisted receipt itself never carries the owned probe.
const stored = previousInferenceFromReceipt(receipt());
assert.ok(stored);
assert.equal(stored.probes.some((probe) => probe.semantic_key === OWNED_KEY), false);
const rebuilt = withOwnedDistinguishProbes(stored, receipt());
assert.ok(rebuilt?.probes.some((probe) => probe.semantic_key === OWNED_KEY));
const schema = persistedSchema();
assert.equal(schema.semantic_key, OWNED_KEY);
assert.equal(schema.probe_id, `probe:${OWNED_KEY}`);
assert.notEqual(String(schema.probe_id ?? ""), D24.id);
});
test("the production answer path applies a persisted owned-probe choice (BUG-915)", () => {
const schema = persistedSchema();
const answerInput = {
choiceKey: "C",
schema,
questionId: QUESTION_ID,
domain: "relationship",
classifiedFrom: "choice" as const,
};
// Without the rebuild the same schema is unanswerable: that was the P0.
const withoutRebuild = applyChoiceWithoutEvidence(previousInferenceFromReceipt(receipt())!, answerInput);
assert.equal(withoutRebuild.applied, false);
assert.equal(withoutRebuild.reason, "stale_probe");
const applied = applyChoiceWithoutEvidence(inferenceForPersistedAnswer(receipt())!, answerInput);
assert.equal(applied.reason, "applied");
assert.equal(applied.applied, true);
assert.equal(applied.state.answered_probes.at(-1)?.semantic_key, OWNED_KEY);
assert.ok(!applied.state.answered_probes.some((row) => row.probe_id === D24.id));
});
test("GET projects the persisted D9 card from the receipt alone (BUG-915)", () => {
const schema = persistedSchema();
const card = choiceCardFromCaseDossier({
evidence: DATED,
conversationSummary: {
activeFocus: {
id: FOCUS_ID,
questionId: QUESTION_ID,
intent: "distinguish_candidates",
targetDomain: "relationship",
targetKind: null,
expectedAnswerSchema: schema,
},
declinedSkippedTopics: [],
},
latestResult: {
resultId: "result-1",
decisionReceipt: receipt(),
selectionAllowed: false,
confirmationAllowed: false,
candidates: [
{ time: "04:48", rank: 1, relativeSupport: 44 },
{ time: "04:53", rank: 2, relativeSupport: 35 },
{ time: "04:59", rank: 3, relativeSupport: 21 },
],
},
case: { acceptedTime: null, status: "active", stage: "minute" },
});
assert.ok(card, "GET must render the persisted focus");
assert.equal(card.question_id, QUESTION_ID);
assert.equal(card.options.length, 4);
});
test("idle persist does not supersede a focus whose probe is rebuilt (BUG-915)", () => {
const schema = persistedSchema();
const focus = { intent: "distinguish_candidates", expectedAnswerSchema: schema };
// The raw receipt state has no owned probe: that is what superseded the
// fresh focus on every idle persist.
assert.equal(persistedDistinguishFocusStale(previousInferenceFromReceipt(receipt()), focus), true);
assert.equal(persistedDistinguishFocusStale(inferenceForPersistedAnswer(receipt()), focus), false);
// A changed candidate set still expires the focus (BUG-559 stays).
const movedReceipt = {
window_scan: WINDOW_SCAN_RECEIPT,
inference_state: buildInferenceState({
range_start: "04:48",
range_end: "05:07",
candidates: [
{ id: "04:48", time: "04:48", relative_support: 60 },
{ id: "04:59", time: "04:59", relative_support: 40 },
],
events: persistedState().events,
probes: [D24],
}),
};
assert.equal(persistedDistinguishFocusStale(inferenceForPersistedAnswer(movedReceipt), focus), true);
});
test("every state read that can touch an owned probe rebuilds it (BUG-915 source contract)", () => {
const sites = [
"src/lib/rectification-agentic/v9/server-focus.ts",
"src/lib/rectification-agentic/v9/interview-state.ts",
];
for (const site of sites) {
const source = readFileSync(new URL(`../${site}`, import.meta.url), "utf8");
assert.match(source, /withOwnedDistinguishProbes\(/, site);
}
const answerChoice = readFileSync(
new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url),
"utf8",
);
// The tap answer and the idle supersede check both read the rebuilt state.
assert.match(answerChoice, /const previous = inferenceForPersistedAnswer\(receipt\);/);
assert.match(answerChoice, /const inferenceForStale = withOwnedDistinguishProbes\(/);
const tools = readFileSync(new URL("../src/mastra/rectification-v9-tools.ts", import.meta.url), "utf8");
// The typed answer goes through the same helper as the tap.
assert.match(tools, /const previous = inferenceForPersistedAnswer\(/);
});
const REL_2020_PROBE = {
year: 2020,
year_label: "2020 年前后",
domain: "relationship" as const,
event_family: "开始认真关系、分手或结婚",
source: "dasha_activation" as const,
tracks: ["vimshottari", "narayana"] as const,
tracks_agree: true,
unique_minute_claim: false as const,
user_meaning: "2020 年感情线",
role: "distinguish" as const,
phase: "candidate_discriminator" as const,
information_gain: 0.5,
semantic_key: "relationship.2020",
candidate_split_hash: "set:relationship.2020",
candidate_ids: ["04:48", "04:53", "04:59"],
expected_outcomes: [
{ answer_class: "yes" as const, supports: ["04:48"], conflicts: ["04:53", "04:59"] },
{ answer_class: "weak_yes" as const, supports: ["04:53"], conflicts: ["04:48", "04:59"] },
{ answer_class: "no" as const, supports: ["04:59"], conflicts: ["04:48", "04:53"] },
{ answer_class: "unsure" as const, supports: [], conflicts: [] },
],
choice_kind: "existence" as const,
};
/** Every dated collect line already closed, as in the 2026-09-17 real run. */
const CLOSED_COLLECT = [
"family",
"education",
"finance",
"relocation",
"health_pressure",
"career",
"relationship",
].map((domain) => ({
question_id: `collect:targeted:${domain}`,
target_kind: `targeted:${domain}`,
domain,
status: "declined",
intent: "collect_method_evidence",
}));
function planWithD9Split(input: { askedProbeKeys: readonly string[] }) {
return buildMethodFollowupPlan({
evidence: DATED,
observations: [
{ layer: "d9", candidates_differ: true, ask_theme: "relationship_style" },
{ layer: "d10", candidates_differ: false, ask_theme: null },
],
windowScan: WINDOW,
inferenceState: persistedState(),
sessionOutcome: "discriminate_candidates",
precisionStage: "d9_refine",
eventProbes: [REL_2020_PROBE],
askedProbeKeys: input.askedProbeKeys,
closedCollectFocuses: CLOSED_COLLECT,
topCandidateTimes: ["04:48", "04:53", "04:59"],
candidateRanges: CANDIDATES.map((row) => ({ id: row.id, time: row.time })),
});
}
test("a varga style question is never a scoring discriminator (2026-09-17 decision b)", () => {
// The engine's relationship probe is already asked, so nothing dates this
// line: before the decision this is where 「你更接近哪一种相处方式」 came from.
const plan = planWithD9Split({ askedProbeKeys: ["relationship.2020"] });
const next = plan.next_followup;
assert.notEqual(next?.semantic_key, OWNED_KEY);
assert.notEqual(next?.choice_kind, "varga_style");
assert.doesNotMatch(next?.choice_frame?.prompt ?? "", /更接近哪一种/);
assert.equal(
next?.intent === "distinguish_candidates"
&& (next.source === "varga_observation" || next.source === "precision_stage"),
false,
JSON.stringify({ intent: next?.intent, source: next?.source, key: next?.semantic_key }),
);
assert.ok(
plan.dropped_probes.some((row) => (
row.semantic_key === "d9_relationship" && row.reason === "frameless_distinguish"
)),
JSON.stringify(plan.dropped_probes),
);
});
test("the D9 line is still asked when the engine dates it (2026-09-17 decision b)", () => {
const plan = planWithD9Split({ askedProbeKeys: [] });
const next = plan.next_followup;
assert.equal(next?.method_id, "d9_relationship");
assert.equal(next?.intent, "distinguish_candidates");
assert.equal(next?.semantic_key, REL_2020_PROBE.semantic_key);
assert.equal(next?.choice_kind, "existence");
assert.match(next?.choice_frame?.prompt ?? "", /2020/);
});
test("a rebuilt owned probe never becomes an askable discriminator (decision b)", () => {
// Answering an old style focus writes the rebuilt probes into the receipt.
const answered = withOwnedDistinguishProbes(previousInferenceFromReceipt(receipt()), receipt())!;
const owned = answered.probes.find((probe) => probe.semantic_key === OWNED_KEY);
assert.ok(owned);
const catalog = rectificationFollowupCatalog(
{ resultId: "result-1", decisionReceipt: { ...receipt(), inference_state: answered } },
DATED,
);
assert.equal(catalog.eventProbes.some((probe) => probe.semantic_key === OWNED_KEY), false);
assert.equal(
(catalog.contrastPacket?.probes ?? []).some((probe) => probe.semanticKey === OWNED_KEY),
false,
);
});
test("a dead tap card never shares the screen with the collect-wait placeholder (BUG-917)", () => {
const base = {
liveQuestionVisible: false,
questionMissing: true,
questionLoadFailed: false,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: 0,
collectWaiting: true,
};
assert.equal(rectificationQuestionGapState(base), "collect_waiting");
assert.equal(
rectificationQuestionGapState({ ...base, unansweredDeadChoice: true }),
"unavailable",
);
const chat = readFileSync(
new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url),
"utf8",
);
assert.match(chat, /unansweredDeadChoice: unansweredDeadChoiceOnMessages,/);
assert.match(chat, /latestSettledAssistant\.question\.status === "superseded"/);
const entry = readFileSync(
new URL("../src/components/rectification-message-entry.tsx", import.meta.url),
"utf8",
);
assert.match(entry, /question\.status === "superseded" && !question\.answer_option/);
});
test("five dated events do not hold for yearless personality (BUG-913 closed_by_design)", () => {
assert.equal(yearlessPersonalityCanAsk({ datedCount: 5, candidatesSeparated: false }), false);
assert.equal(yearlessPersonalityCanAsk({ datedCount: 0, candidatesSeparated: false, topCandidateTimes: ["04:48", "04:59"] }), true);
});
test("nakshatra opposite poles are not both lifted as shared traits (BUG-914)", () => {
const earlier = "希望两边都能说得过去";
const later = "必要时会直接选边";
const delivery = buildRangeDelivery({
inference: persistedState(),
publicCandidates: [
{ candidateId: "a", time: "04:48" },
{ candidateId: "b", time: "04:53" },
{ candidateId: "c", time: "04:59" },
],
credibleRange: ["04:48", "04:59"],
representativeTime: "04:59",
nakshatraBoundary: {
near_boundary: true,
user_meaning: `A${earlier}。B${later}。`,
options: [
{ key: "A", time_bias: "earlier", traits: [earlier] },
{ key: "B", time_bias: "later", traits: [later] },
],
},
});
const shared = delivery.shared_traits.join(" ");
assert.equal(shared.includes(earlier) && shared.includes(later), false);
});