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 { 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); });