Files
Jyotisha/frontend/tests/rectification-dead-d9-choice-20260916.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

501 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";
import { rectificationChatSurface } from "./rectification-chat-surface.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",
},
// R1(BUG-1193):补第 4 件,保持原场景(训练门已开)。
{
status: "confirmed",
domain: "career",
datePrecision: "year",
occurredFrom: "2021-01-01",
occurredTo: "2021-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",
);
// 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。
const chat = rectificationChatSurface;
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);
});