Files
Jyotisha/frontend/tests/rectification-discriminator-followup-consistency.test.ts
T
Jesse_ChenandClaude Opus 5.5 eb773f1299 refactor(rectification): split POST /api/rectification/agent into branch handlers
Move-only (TASK-rectification-code-split-20260926 T2/T4). No behavior, API,
copy, DB, Skill, billing or scoring change.

- route.ts: 1077 -> 152 lines; POST 927 -> 114. It builds the Supabase
  clients (so the setup-failure mapping stays here) and assembles:
  agent-route-request.ts (auth / product / schema / flag / Case-Session
  binding), agent-route-typed-message.ts (declared-window reply, typed-answer
  preflight, unfocused classification), agent-route-structured-choice.ts,
  agent-route-agent-turn.ts (opening / read-only / typed agent stream and its
  exit gate), agent-route-billing.ts, agent-route-support.ts (schema, one-shot
  NDJSON reply, context types).
- The four mutable preflight lets (expectedWrite, collectIntent,
  writeClassified, classifierDiagnostic) that crossed branches are one
  turnState object; values and flow unchanged.
- Tests: whole-source contracts read tests/rectification-agent-route-surface.ts;
  the typed fast-path slice is rebuilt from the new files; billing,
  declared-window and structured-choice slices now call the handlers
  (billing in a child process because feature-pricing imports server-only),
  each with 原值/新值/原因. Route growth caps added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-26 19:40:07 +08:00

598 lines
22 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 { buildInferenceState } from "../src/lib/rectification-agentic/core/build-state.ts";
import {
inspectDiscriminatorProbes,
withCompletedContrastOptions,
type CandidateDiscriminatorProbe,
} from "../src/lib/rectification-agentic/core/candidate-contrast-packet.ts";
import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts";
import {
contrastPacketFromDossier,
decideFromDossier,
followupAsksRenderableDiscriminator,
rectificationFollowupCatalog,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { buildMethodFollowupPlan } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { rectificationAgentRouteSurface, rectificationTypedMessageFastPath } from "./rectification-agent-route-surface.ts";
const CAREER_2020_04: ConflictProbe = {
id: "probe:career.2020.04.dasha_boundary",
semantic_key: "career.2020.04.dasha_boundary",
candidate_split_hash: "career.2020.04",
domain: "career",
year: 2020,
question: "2020 年 4 月前后有没有入职或换工作?",
candidate_ids: ["05:00", "05:10"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.9,
source: "dasha_boundary",
choice_kind: "existence",
};
const REL_2024_05: ConflictProbe = {
id: "probe:relationship.2024.05.dasha_boundary",
semantic_key: "relationship.2024.05.dasha_boundary",
candidate_split_hash: "relationship.2024.05",
domain: "relationship",
year: 2024,
question: "2024 年 5 月前后有没有开始一段认真关系?",
candidate_ids: ["05:00", "05:10"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.85,
source: "dasha_boundary",
choice_kind: "existence",
};
const D24: ConflictProbe = {
id: "contrast:varga.d24.05:00/05:10",
semantic_key: "varga.d24.05:00/05:10",
candidate_split_hash: "varga.d24.05:00/05:10",
domain: "education",
year: 0,
question: "有没有学业或考试发挥明显失常、压力特别大的时候?",
candidate_ids: ["05:00", "05:10"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 2.5,
source: "varga_contrast",
choice_kind: "event_quality",
};
const D12: ConflictProbe = {
id: "contrast:varga.d12.05:00/05:10",
semantic_key: "varga.d12.05:00/05:10",
candidate_split_hash: "varga.d12.05:00/05:10",
domain: "family",
year: 0,
question: "家里有没有结婚、添丁或住院这类事?",
candidate_ids: ["05:00", "05:10"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.8,
source: "varga_contrast",
choice_kind: "existence",
};
const D9_STYLE_NO_OPTIONS: ConflictProbe = {
id: "contrast:varga.d9.巨蟹座/狮子座",
semantic_key: "varga.d9.巨蟹座/狮子座",
candidate_split_hash: "varga.d9.巨蟹座/狮子座",
domain: "relationship",
year: 0,
question: "亲密关系里更接近下面哪一种相处方式?",
candidate_ids: ["05:00", "05:10"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "weak_yes", supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 1.4,
source: "varga_contrast",
choice_kind: "varga_style",
};
const D10_STYLE_NO_OPTIONS: ConflictProbe = {
id: "contrast:varga.d10.天秤座/天蝎座",
semantic_key: "varga.d10.天秤座/天蝎座",
candidate_split_hash: "varga.d10.天秤座/天蝎座",
domain: "career",
year: 0,
question: "平时做事更接近下面哪一种职责风格?",
candidate_ids: ["05:00", "05:10"],
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "weak_yes", supports: ["05:10"], conflicts: ["05:00"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 1.2,
source: "varga_contrast",
choice_kind: "varga_style",
};
const FAMILY_2021_COLLECT = {
year: 2021,
year_label: "2021 年前后",
domain: "family" as const,
event_family: "家人结婚、添丁或住院",
source: "age_band" as const,
tracks: ["vimshottari", "narayana"] as const,
tracks_agree: false,
unique_minute_claim: false as const,
user_meaning: "时间范围锁定 2021 年前后;领域锁定 family。",
role: "collect" as const,
phase: "evidence_collection" as const,
information_gain: 0,
semantic_key: "family.2021",
candidate_split_hash: "family:2021",
candidate_ids: [] as const,
expected_outcomes: [] as const,
choice_kind: "existence" as const,
};
const EVIDENCE = [
{
id: "e-career-entry",
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-04-01",
occurredTo: null,
eventKind: "career_entry",
},
{
id: "e-career-exit",
status: "confirmed",
domain: "career",
datePrecision: "month",
occurredFrom: "2020-10-01",
occurredTo: null,
eventKind: "career_exit",
},
{
id: "e-rel-start",
status: "confirmed",
domain: "relationship",
datePrecision: "month",
occurredFrom: "2024-05-01",
occurredTo: null,
eventKind: "relationship_start",
},
{
id: "e-rel-end",
status: "confirmed",
domain: "relationship",
datePrecision: "day",
occurredFrom: "2024-08-08",
occurredTo: null,
eventKind: "relationship_end",
},
] as const;
function contrastFromConflict(probe: ConflictProbe): CandidateDiscriminatorProbe {
return {
probeId: probe.id,
candidateSetVersion: "05:00-05:10",
question: probe.question,
expectedOutcomes: probe.expected_outcomes.map((row) => ({
outcomeId: row.answer_class,
supportsCandidateIds: row.supports,
conflictsCandidateIds: row.conflicts,
})),
candidateSplitHash: probe.candidate_split_hash,
informationGain: probe.information_gain,
sourceFeatures: [{ technique: probe.domain, calculationResultId: null }],
domain: probe.domain,
year: probe.year > 0 ? probe.year : null,
semanticKey: probe.semantic_key,
choiceKind: probe.choice_kind,
};
}
function deadlockDossier(): { dossier: DecisionDossier; state: ReturnType<typeof buildInferenceState> } {
const state = buildInferenceState({
range_start: "05:00",
range_end: "05:10",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 18 },
{ id: "05:10", time: "05:10", relative_support: 16 },
],
events: [
{ id: "e-career-entry", domain: "career", year: 2020, precision: "month" },
{ id: "e-career-exit", domain: "career", year: 2020, precision: "month" },
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" },
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" },
],
probes: [CAREER_2020_04, REL_2024_05, D24, D12, D9_STYLE_NO_OPTIONS, D10_STYLE_NO_OPTIONS],
answered_probes: [
{
probe_id: CAREER_2020_04.id,
semantic_key: CAREER_2020_04.semantic_key,
candidate_split_hash: CAREER_2020_04.candidate_split_hash,
answer_class: "yes",
classified_from: "choice",
},
{
probe_id: REL_2024_05.id,
semantic_key: REL_2024_05.semantic_key,
candidate_split_hash: REL_2024_05.candidate_split_hash,
answer_class: "yes",
classified_from: "choice",
},
],
});
const dossier: DecisionDossier = {
evidence: EVIDENCE,
conversationSummary: { activeFocus: null, declinedSkippedTopics: [] },
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
candidates: state.candidates.map((item) => ({
candidateId: item.id,
time: item.time,
rank: item.rank,
relativeSupport: Math.round(item.posterior_score),
})),
representativeTime: state.representative_time,
evidenceLedgerFingerprint: evidenceLedgerFingerprint(EVIDENCE as never),
decisionReceipt: {
inference_state: state,
evidence_collection_probes: [FAMILY_2021_COLLECT],
},
},
case: { acceptedTime: null },
};
return { dossier, state };
}
test("deadlock case: dated pool empty delivers instead of a D9/D10 style card", () => {
const { dossier } = deadlockDossier();
const inspected = inspectDiscriminatorProbes(contrastPacketFromDossier(dossier));
assert.equal(inspected.selected?.semanticKey, D9_STYLE_NO_OPTIONS.semantic_key);
const d9 = withCompletedContrastOptions(contrastFromConflict(D9_STYLE_NO_OPTIONS));
const d10 = withCompletedContrastOptions(contrastFromConflict(D10_STYLE_NO_OPTIONS));
assert.equal(d9.ok, true);
assert.equal(d10.ok, true);
if (d9.ok) assert.equal(d9.probe.choiceKind, "varga_style");
if (d10.ok) assert.equal(d10.probe.choiceKind, "varga_style");
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: "discriminate_candidates",
...catalog,
candidatesSeparated: false,
});
assert.equal(followupAsksRenderableDiscriminator(plan.next_followup), false);
assert.notEqual(plan.next_followup?.choice_kind, "varga_style");
assert.equal(
plan.dropped_probes.some((item) => item.semantic_key.startsWith("varga.d9.") || item.semantic_key.startsWith("varga.d10.")),
true,
JSON.stringify(plan.dropped_probes),
);
const decision = decideFromDossier(dossier);
// 原值: ask_candidate_discriminator,出 D9/D10 性格卡
// 新值: 带年月池空即交付,性格题 yearless_deferred
// 原因: BUG-651 性格题不得挡在结果前面
assert.ok(
decision.nextAction === "offer_provisional_range"
|| decision.nextAction === "ready_to_adopt"
|| decision.nextAction === "complete_with_range"
|| decision.nextAction === "ask_fact_collection",
decision.nextAction,
);
assert.notEqual(decision.sessionOutcome, "discriminate_candidates");
assert.equal(decision.canConfirmExactMinute, false);
});
test("varga_style probes missing style_options complete the same way in inspect and followup ranking", () => {
const probe = contrastFromConflict(D9_STYLE_NO_OPTIONS);
const inspected = inspectDiscriminatorProbes({
candidateSetVersion: "05:00-05:10",
vargaDifferences: [],
probes: [probe],
});
assert.ok(inspected.selected);
assert.equal(inspected.selected?.choiceKind, "varga_style");
assert.equal(inspected.dropped.length, 0);
const plan = buildMethodFollowupPlan({
evidence: EVIDENCE,
sessionOutcome: "discriminate_candidates",
candidatesSeparated: false,
contrastPacket: {
candidateSetVersion: "05:00-05:10",
vargaDifferences: [],
probes: [probe],
},
});
assert.notEqual(plan.next_followup?.semantic_key, probe.semanticKey);
assert.notEqual(plan.next_followup?.choice_kind, "varga_style");
// 原值: 补全 style_options 后仍把 D9 当下一道区分题
// 新值: D9 记 yearless_deferred,不再占用 discriminator
// 原因: BUG-651
assert.equal(
plan.dropped_probes.some((item) => (
item.semantic_key === probe.semanticKey && item.reason === "yearless_deferred"
)),
true,
JSON.stringify(plan.dropped_probes),
);
});
test("method coverage incomplete still asks a renderable D9 style card", () => {
const { dossier } = deadlockDossier();
const decision = decideFromDossier(dossier);
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
declinedTopics: [],
sessionOutcome: "discriminate_candidates",
...catalog,
candidatesSeparated: false,
});
assert.equal(plan.methods.find((item) => item.method_id === "relatives")?.status, "uncovered");
assert.equal(plan.methods.find((item) => item.method_id === "occupation")?.status, "uncovered");
// 原值: 方法覆盖未齐仍问 D9 性格卡
// 新值: 训练门开、带年月池空即交付
// 原因: BUG-651 性格题不得挡在结果前面
assert.ok(
decision.nextAction === "offer_provisional_range"
|| decision.nextAction === "ready_to_adopt"
|| decision.nextAction === "complete_with_range"
|| decision.nextAction === "ask_fact_collection",
decision.nextAction,
);
assert.notEqual(plan.next_followup?.choice_kind, "varga_style");
assert.equal(decision.probe, null);
});
test("clock-key varga_style without signs fail-closes to dated family collect instead of a dead-end discriminator", () => {
const d9Clock: ConflictProbe = {
...D9_STYLE_NO_OPTIONS,
id: "contrast:varga.d9.05:00/05:10",
semantic_key: "varga.d9.05:00/05:10",
candidate_split_hash: "varga.d9.05:00/05:10",
};
const d10Clock: ConflictProbe = {
...D10_STYLE_NO_OPTIONS,
id: "contrast:varga.d10.05:00/05:10",
semantic_key: "varga.d10.05:00/05:10",
candidate_split_hash: "varga.d10.05:00/05:10",
};
const { dossier } = deadlockDossier();
const state = buildInferenceState({
range_start: "05:00",
range_end: "05:10",
candidates: [
{ id: "05:00", time: "05:00", relative_support: 18 },
{ id: "05:10", time: "05:10", relative_support: 16 },
],
events: [
{ id: "e-career-entry", domain: "career", year: 2020, precision: "month" },
{ id: "e-career-exit", domain: "career", year: 2020, precision: "month" },
{ id: "e-rel-start", domain: "relationship", year: 2024, precision: "month" },
{ id: "e-rel-end", domain: "relationship", year: 2024, precision: "day" },
],
probes: [CAREER_2020_04, REL_2024_05, D24, D12, d9Clock, d10Clock],
answered_probes: [
{
probe_id: CAREER_2020_04.id,
semantic_key: CAREER_2020_04.semantic_key,
candidate_split_hash: CAREER_2020_04.candidate_split_hash,
answer_class: "yes",
classified_from: "choice",
},
{
probe_id: REL_2024_05.id,
semantic_key: REL_2024_05.semantic_key,
candidate_split_hash: REL_2024_05.candidate_split_hash,
answer_class: "yes",
classified_from: "choice",
},
],
});
const clockDossier: DecisionDossier = {
...dossier,
latestResult: {
...dossier.latestResult!,
decisionReceipt: {
inference_state: state,
evidence_collection_probes: [FAMILY_2021_COLLECT],
},
},
};
const inspected = inspectDiscriminatorProbes(contrastPacketFromDossier(clockDossier));
assert.equal(inspected.selected, null);
assert.equal(inspected.dropped.some((item) => (
item.semantic_key === d9Clock.semantic_key && item.reason === "yearless_ungrounded_contrast"
)), true);
const d9 = withCompletedContrastOptions(contrastFromConflict(d9Clock));
assert.equal(d9.ok, true);
if (d9.ok) assert.equal(d9.probe.choiceKind, "existence");
const catalog = rectificationFollowupCatalog(clockDossier.latestResult, clockDossier.evidence);
const plan = buildMethodFollowupPlan({
evidence: clockDossier.evidence,
declinedTopics: [],
sessionOutcome: "discriminate_candidates",
...catalog,
candidatesSeparated: false,
});
assert.equal(followupAsksRenderableDiscriminator(plan.next_followup), false);
// 原值: 落到家人采集 + probe_year 2021
// 新值: 训练门开后不落领域轮转;年龄段年份不进题干
// 原因: 撤回 BUG-642(BUG-648)
assert.notEqual(plan.next_followup?.domain, "family");
assert.notEqual(plan.next_followup?.probe_year, 2021);
assert.notEqual(plan.next_followup?.semantic_key, d9Clock.semantic_key);
assert.notEqual(plan.next_followup?.semantic_key, d10Clock.semantic_key);
const decision = decideFromDossier(clockDossier);
assert.notEqual(decision.nextAction, "ask_candidate_discriminator");
assert.equal(decision.probe, null);
assert.equal(decision.canConfirmExactMinute, false);
});
test("unrenderable contrast probes are recorded in dropped_probes instead of being dropped silently", () => {
const unrenderable = contrastFromConflict({
...D9_STYLE_NO_OPTIONS,
information_gain: 0,
semantic_key: "varga.d9.dropped",
candidate_split_hash: "varga.d9.dropped",
id: "contrast:varga.d9.dropped",
});
const inspected = inspectDiscriminatorProbes({
candidateSetVersion: "05:00-05:10",
vargaDifferences: [],
probes: [unrenderable],
});
assert.equal(inspected.selected, null);
assert.equal(inspected.dropped.some((item) => item.semantic_key === unrenderable.semanticKey), true);
const plan = buildMethodFollowupPlan({
evidence: EVIDENCE,
sessionOutcome: "discriminate_candidates",
candidatesSeparated: false,
contrastPacket: {
candidateSetVersion: "05:00-05:10",
vargaDifferences: [],
probes: [unrenderable],
},
});
assert.equal(
plan.dropped_probes.some((item) => item.semantic_key === unrenderable.semanticKey),
true,
JSON.stringify(plan.dropped_probes),
);
});
test("agent message path no longer returns the discriminator dead-end copy", () => {
// 原值: 读 app/api/rectification/agent/route.ts 单文件。新值: rectificationAgentRouteSurface(route.ts + 拆出的 agent-route-*.ts,见 rectification-agent-route-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改写。
const route = rectificationAgentRouteSurface;
// 原值: fastPath = route.ts 里 `if (action === "message")` 到 `const requestTime` 的切片。
// 新值: rectificationTypedMessageFastPath():同一批代码的新位置(agent-route-typed-message.ts 全文 +
// route.ts 从 `if (action === "message")` 起 + agent-route-agent-turn.ts 到 `const requestTime` 为止)。
// 原因: TASK-rectification-code-split-20260926 快路径拆进多个文件;按代码去处重组,断言不变。
const fastPath = rectificationTypedMessageFastPath();
assert.doesNotMatch(fastPath, /目前没有可继续区分/);
assert.match(route, /nonConvergingRangeNarration/);
assert.match(fastPath, /persistNextInterviewIfIdle/);
assert.match(fastPath, /!plan\.next_followup/);
assert.ok(fastPath.includes("ask_candidate_discriminator"));
assert.match(fastPath, /isRenderableChoiceOpenQuestion\(open\)/);
assert.ok(route.indexOf("classifyRectificationTurnIntent") < route.indexOf("runV9AgentTurn({"));
});
test("decision.probe matches the followup the plan will actually ask", () => {
const { dossier } = deadlockDossier();
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
sessionOutcome: "discriminate_candidates",
...catalog,
candidatesSeparated: false,
});
const decision = decideFromDossier(dossier);
// 原值: 下一问是 D9/D10,decision.probe 对得上
// 新值: 性格题 deferred,decision.probe 与 next 都不是性格卡
// 原因: BUG-651
assert.notEqual(decision.nextAction, "ask_candidate_discriminator");
assert.equal(decision.probe, null);
assert.notEqual(plan.next_followup?.choice_kind, "varga_style");
assert.notEqual(plan.next_followup?.semantic_key, D24.semantic_key);
});
test("birthDate gating matches the real followup plan on an under-age event probe", () => {
const { dossier: base } = deadlockDossier();
const childhood = {
year: 2015,
year_label: "2015 年前后",
domain: "career" 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: "2015 年前后有没有入职或职责变化?",
role: "distinguish" as const,
phase: "candidate_discriminator" as const,
information_gain: 3.2,
semantic_key: "career.2015.dasha_activation",
candidate_split_hash: "career.2015",
candidate_ids: ["05:00", "05:10"] as const,
expected_outcomes: [
{ answer_class: "yes", supports: ["05:00"], conflicts: ["05:10"] },
{ answer_class: "no", supports: ["05:10"], conflicts: ["05:00"] },
],
style_options: [
{ label: "明确发生且时间吻合", answer_class: "yes" },
{ label: "发生过但程度较弱", answer_class: "weak_yes" },
{ label: "明确没有发生", answer_class: "no" },
{ label: "这段记不清楚", answer_class: "unsure" },
],
choice_kind: "existence" as const,
};
const dossier: DecisionDossier = {
...base,
latestResult: base.latestResult
? {
...base.latestResult,
decisionReceipt: {
...(base.latestResult.decisionReceipt ?? {}),
discriminating_event_probes: [childhood],
},
}
: null,
};
const birthDate = "2010-01-15";
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const planOpen = buildMethodFollowupPlan({
evidence: dossier.evidence,
declinedTopics: [],
sessionOutcome: "discriminate_candidates",
...catalog,
candidatesSeparated: false,
});
const planGated = buildMethodFollowupPlan({
evidence: dossier.evidence,
declinedTopics: [],
sessionOutcome: "discriminate_candidates",
...catalog,
birthDate,
candidatesSeparated: false,
});
assert.equal(planOpen.next_followup?.semantic_key, childhood.semantic_key);
assert.notEqual(planGated.next_followup?.semantic_key, childhood.semantic_key);
const decision = decideFromDossier(dossier, { birthDate });
assert.equal(
decision.nextAction === "ask_candidate_discriminator",
followupAsksRenderableDiscriminator(planGated.next_followup),
);
if (followupAsksRenderableDiscriminator(planGated.next_followup)) {
assert.equal(decision.probe?.semanticKey, planGated.next_followup?.semantic_key);
} else {
assert.equal(decision.nextAction, "ask_fact_collection");
}
});