Files
Jyotisha/frontend/tests/rectification-replay-20260911.test.ts
T
Jesse_ChenandCursor 1fa994ea63
Independent Staging Quality Gate / validate (push) Successful in 13m26s
Independent Staging Quality Gate / publish (push) Successful in 9m33s
fix(rectification): keep dated occupation answers scoreable (BUG-649/650)
Occupation collect was wiping year-month into an unscored note, and idle gap copy never joined the evidence turn.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 11:49:52 +08:00

654 lines
24 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 { candidateSetId } from "../src/lib/rectification-agentic/core/build-state.ts";
import { asInferenceState } from "../src/lib/rectification-agentic/core/compose-receipt.ts";
import { INFERENCE_ALGORITHM_VERSION } from "../src/lib/rectification-agentic/core/types.ts";
import type { ConflictProbe } from "../src/lib/rectification-agentic/core/types.ts";
import {
decideFromDossier,
type DecisionDossier,
} from "../src/lib/rectification-agentic/v9/decision-from-dossier.ts";
import { persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts";
import { resetDeliveryTurnGuardForTests } from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
import { RECTIFICATION_SKILL_NAME, RECTIFICATION_SKILL_VERSION } from "../src/lib/rectification-agentic/v9/case-status.ts";
import { evidenceLedgerFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { runV9AgentTurn } from "../src/lib/rectification-agentic/v9/agent-run.ts";
import { composeIdleGapIntoSpoken } from "../src/lib/rectification-agentic/v9/collect-prompt.ts";
import {
COLLECT_KIND_ORDER,
anchoredFollowups,
collectionQuestionPool,
preciseGapNarration,
} from "../src/lib/rectification-agentic/v9/collection-question-pool.ts";
import { applyOccupationCollectLedgerNorm, trainingScoreableGate } from "../src/lib/rectification-agentic/v9/evidence-model.ts";
import { buildMethodFollowupPlan, followupFromPoolItem } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { parseAgentChoiceCopy } from "../src/lib/rectification-agentic/v9/choice-card.ts";
import { isRenderableChoiceOpenQuestion } from "../src/lib/rectification-agentic/v9/server-focus.ts";
import {
interviewCollectWaiting,
rectificationQuestionGapState,
} from "../src/lib/rectification-surface-state.ts";
import {
CASE_ID,
FOCUS_ID,
SESSION_ID,
TURN_ID,
USER_ID,
activeFocusFixture,
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 educationStart = {
id: "e-edu-start",
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2016-09-01",
occurredTo: "2016-09-30",
eventKind: "education_start",
summary: "2016年9月上大学",
};
const educationEnd = {
id: "e-edu-end",
status: "confirmed" as const,
domain: "education",
datePrecision: "month" as const,
occurredFrom: "2020-06-01",
occurredTo: "2020-06-30",
eventKind: "education_completion",
summary: "2020年6月毕业",
};
const health2024 = {
id: "e-health-2024",
status: "confirmed" as const,
domain: "health_pressure",
datePrecision: "month" as const,
occurredFrom: "2024-10-01",
occurredTo: "2024-10-31",
eventKind: "self_health_event",
summary: "2024年10月一次身体事故",
};
const career2024 = {
id: "e-career-2024",
status: "confirmed" as const,
domain: "career",
datePrecision: "month" as const,
occurredFrom: "2024-04-01",
occurredTo: null,
eventKind: "career_entry",
summary: "2024年4月开始做程序员",
};
const occupationNote = {
id: "e-occupation-note",
status: "confirmed" as const,
domain: "occupation",
datePrecision: "unknown" as const,
occurredFrom: null,
occurredTo: null,
eventKind: "occupation_note",
summary: "程序员",
};
const inviteDeclinedTopic = {
target_domain: "other",
status: "declined",
intent: "collect_method_evidence",
questionId: "collect:invite:more",
target_kind: "invite_more",
};
const OCCUPATION_FOCUS = {
intent: "collect_method_evidence" as const,
targetDomain: "occupation",
targetKind: "occupation_note",
questionId: "collect:occupation:collect_method_evidence",
};
function askedPoolTopics() {
return [
inviteDeclinedTopic,
{
target_domain: "career",
status: "resolved",
intent: "collect_method_evidence",
questionId: "collect:anchor:education_completion:2020",
target_kind: "anchor:education_completion:2020",
},
{
target_domain: "relocation",
status: "resolved",
intent: "collect_method_evidence",
questionId: "collect:anchor:education_start:2016",
target_kind: "anchor:education_start:2016",
},
...COLLECT_KIND_ORDER.flatMap((kind) => [
{
target_domain: kind,
status: "resolved",
intent: "collect_method_evidence",
questionId: `collect:anchor:after_event:${kind === "education" ? "2016" : "2020"}`,
target_kind: `anchor:after_event:${kind === "education" ? "2016" : "2020"}`,
},
{
target_domain: kind,
status: "resolved",
intent: "collect_method_evidence",
questionId: `collect:generic:${kind}`,
target_kind: `generic:${kind}`,
},
]),
];
}
function uuidAt(index: number) {
return `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`;
}
function existenceProbe(input: {
key: string;
domain: string;
year: number;
question: string;
}): ConflictProbe {
return {
id: `probe:${input.key}`,
semantic_key: input.key,
candidate_split_hash: input.key,
domain: input.domain,
year: input.year,
question: input.question,
candidate_ids: [...ACTIVE],
expected_outcomes: [
{ answer_class: "yes", supports: ["04:51"], conflicts: ["04:47"] },
{ answer_class: "weak_yes", supports: [], conflicts: [] },
{ answer_class: "no", supports: ["04:47"], conflicts: ["04:51"] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.4,
source: "dasha_boundary",
choice_kind: "existence",
style_options: EXISTENCE_OPTIONS,
};
}
const ASKED_PROBES = [
existenceProbe({
key: "career.2023.05.dasha_boundary",
domain: "career",
year: 2023,
question: "2023 年 5 月前后有没有入职或换工作",
}),
];
const LEFTOVER_PROBE = existenceProbe({
key: "career.2021.04.dasha_boundary",
domain: "career",
year: 2021,
question: "2021 年 4 月前后有没有入职或换工作",
});
function liveState(extraProbes: readonly ConflictProbe[] = []) {
const probes = [...ASKED_PROBES, ...extraProbes];
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 raw = {
algorithm_version: INFERENCE_ALGORITHM_VERSION,
candidate_set_id: candidateSetId("04:47", "05:15", TIMES),
revision: 6,
phase: "discrimination" as const,
result_status: "discriminating" as const,
range_start: "04:47",
range_end: "05:15",
candidates,
events: [
{ id: educationStart.id, domain: "education", year: 2016, precision: "month" as const, usage: "training" as const },
{ id: educationEnd.id, domain: "education", year: 2020, precision: "month" as const, usage: "training" as const },
{ id: health2024.id, domain: "health_pressure", year: 2024, precision: "month" as const, usage: "training" as const },
{ id: career2024.id, domain: "career", year: 2024, precision: "month" as const, usage: "holdout" as const },
],
probes,
answered_probes: ASKED_PROBES.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,
})),
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);
return loaded;
}
function eventProbeRow(probe: ConflictProbe) {
return {
year: probe.year,
year_label: probe.year > 0 ? `${probe.year} 年前后` : "",
domain: probe.domain,
event_family: probe.domain === "career" ? "入职、换工作或职责加重" : probe.domain,
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,
};
}
function fourEventDossier(): DecisionDossier {
const evidence = [educationStart, educationEnd, health2024, career2024, occupationNote];
const state = liveState([LEFTOVER_PROBE]);
const fingerprint = evidenceLedgerFingerprint(evidence as never);
return {
evidence,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: [inviteDeclinedTopic],
},
latestResult: {
resultId: "55555555-5555-4555-8555-555555555555",
selectionAllowed: false,
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: false,
acceptance_allowed: false,
propose_allowed: false,
selection_allowed: false,
confirmation_allowed: false,
acceptance_reasons: ["insufficient_events"],
inference_state: state,
discriminating_event_probes: [
...ASKED_PROBES.map(eventProbeRow),
eventProbeRow(LEFTOVER_PROBE),
],
oos_blind_prompts: [],
},
},
case: { acceptedTime: null, status: "collecting_evidence" },
};
}
function rpcDossier(decision: DecisionDossier, extra: { activeFocus?: ReturnType<typeof activeFocusFixture> } = {}) {
const evidence = decision.evidence.map((item) => ({
id: item.id ?? "e-unknown",
source_turn_id: TURN_ID,
subject: "self",
event_kind: item.eventKind ?? item.domain,
domain: item.domain,
occurred_from: item.occurredFrom,
occurred_to: item.occurredTo,
date_precision: item.datePrecision,
summary: item.summary ?? item.domain,
status: item.status,
supersedes_evidence_id: null,
created_at: "2026-09-11T00:00:00.000Z",
}));
return dossierFixture({
evidence,
latestResult: candidateSnapshotFixture({
selectionAllowed: decision.latestResult?.selectionAllowed ?? true,
confirmationAllowed: false,
representativeTime: "04:51",
evidenceLedgerFingerprint: evidenceLedgerFingerprint(decision.evidence as never),
candidates: decision.latestResult?.candidates?.map((item, index) => ({
candidate_id: item.candidateId ?? uuidAt(index),
time: item.time,
rank: item.rank ?? index + 1,
relative_support: Math.max(0, Math.min(100, item.relativeSupport ?? 0)),
tied_minute_count: 1,
})) ?? [],
decisionReceipt: { ...(decision.latestResult?.decisionReceipt ?? {}) },
}),
conversationSummary: {
confirmed_evidence_summary: [],
pending_revisions: [],
active_focus: extra.activeFocus ?? null,
declined_skipped_topics: decision.conversationSummary.declinedSkippedTopics,
candidate_divergence_summary: null,
missing_evidence_categories: [],
last_result_policy: null,
summary_version: 1,
updated_at: "2026-09-11T00:00:00.000Z",
},
});
}
function idleHandlers(decision: DecisionDossier, extra: {
activeFocus?: ReturnType<typeof activeFocusFixture>;
allowFocus?: boolean;
} = {}) {
return fakeAccounting({
...receiptHandlers,
get_agentic_rectification_case_dossier: () => rpcDossier(decision, extra),
get_agentic_rectification_case_compute: () => computeFixture(),
append_agentic_rectification_turn: () => ({ turn_id: TURN_ID, idempotent: false }),
set_agentic_rectification_conversation_focus: extra.allowFocus
? (_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-09-11T00:00:00.000Z",
resolved_at: null,
asked_turn_id: args.p_asked_turn_id ?? null,
},
idempotent: false,
})
: (_fn, args) => {
throw new Error(`must not persist collect focus ${String(args.p_question_id ?? args.p_target_domain)}`);
},
finalize_agentic_rectification_turn: () => ({ turn_id: TURN_ID, status: "completed", idempotent: false }),
get_agentic_rectification_turn_receipt: () => null,
});
}
function fakeAgentStream(chunks: Array<{ type: string; payload?: Record<string, unknown> }>) {
const streamResult = {
fullStream: (async function* () {
for (const item of chunks) yield item;
})(),
totalUsage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }),
};
return {
stream: async () => streamResult,
getSkill: async () => ({ name: RECTIFICATION_SKILL_NAME, instructions: "skill" }),
};
}
function warnLines(run: () => Promise<unknown> | unknown) {
const lines: string[] = [];
const original = console.warn;
console.warn = (...args: unknown[]) => {
lines.push(args.map((item) => String(item)).join(" "));
original.apply(console, args);
};
return Promise.resolve(run()).finally(() => {
console.warn = original;
}).then((result) => ({ result, lines }));
}
test("skill version stays 10.0.23", () => {
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.23");
});
test("two education events do not spawn birth-year reverse questions", () => {
const pool = collectionQuestionPool([educationStart, educationEnd]);
const prompts = pool.map((item) => item.prompt).join("\n");
assert.equal(pool[0]?.kind, "invite");
assert.doesNotMatch(prompts, /年前后/);
const plan = buildMethodFollowupPlan({
evidence: [educationStart, educationEnd],
birthDate: "1997-08-08",
});
assert.doesNotMatch(plan.next_followup?.user_prompt_hint ?? "", /年前后/);
assert.doesNotMatch(plan.next_followup?.spoken_prompt ?? "", /年前后/);
});
test("after declining invite, the next follow-up is the user-year career anchor", () => {
const asked = new Set<string>();
const anchors = anchoredFollowups([educationStart, educationEnd], new Set(), asked);
const job = anchors.find((item) => item.key === "collect:anchor:education_completion:2020");
assert.ok(job);
assert.equal(job?.domain, "career");
const followup = followupFromPoolItem(job!);
assert.equal(followup.domain, "career");
assert.match(followup.user_prompt_hint, /2020 年毕业后第一份工作/);
const afterDecline = collectionQuestionPool(
[educationStart, educationEnd],
[inviteDeclinedTopic],
);
assert.doesNotMatch(afterDecline.map((item) => item.prompt).join("\n"), /年前后/);
assert.equal(afterDecline[0]?.domain, "career");
});
test("dated occupation answer plus the replay ledger opens the training gate with one holdout", () => {
const remapped = applyOccupationCollectLedgerNorm(OCCUPATION_FOCUS, [{
domain: "career" as const,
eventKind: "career_entry" as const,
datePrecision: "month" as const,
occurredFrom: "2024-04-01",
occurredTo: null,
}]);
const evidence = [
educationStart,
educationEnd,
health2024,
...remapped.map((item, index) => ({
id: index === 0 ? "e-occupation-note" : "e-career-2024",
status: "confirmed" as const,
domain: item.domain,
datePrecision: item.datePrecision,
occurredFrom: item.occurredFrom,
occurredTo: item.occurredTo,
eventKind: item.eventKind,
summary: index === 0 ? "程序员" : "2024年4月开始做程序员",
})),
];
const gate = trainingScoreableGate(evidence);
assert.equal(gate.holdoutCount, 1);
assert.equal(gate.trainingCount, 3);
assert.ok(gate.trainingDomainCount >= 2);
assert.equal(gate.open, true);
assert.equal(gate.holdoutStatus, "reserved");
});
test("four dated month events persist a leftover discriminator card", async () => {
resetDeliveryTurnGuardForTests();
const dossier = fourEventDossier();
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.equal(decision.nextAction, "ask_candidate_discriminator");
const accounting = idleHandlers(dossier, { allowFocus: true });
const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
}));
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
const focusCalls = accounting.calls.filter((item) => item.fn === "set_agentic_rectification_conversation_focus");
assert.equal(focusCalls.length > 0, true);
assert.equal(focusCalls[0]?.args.p_intent, "distinguish_candidates");
const schema = focusCalls[0]?.args.p_expected_answer_schema;
const copy = parseAgentChoiceCopy(schema);
assert.ok(copy, "persisted focus must carry a choice schema");
assert.equal(isRenderableChoiceOpenQuestion({
question_id: String(focusCalls[0]?.args.p_question_id ?? ""),
prompt: copy.prompt,
status: "created",
kind: "choice",
focus_id: FOCUS_ID,
probe_id: typeof (schema as { probe_id?: unknown } | undefined)?.probe_id === "string"
? (schema as { probe_id: string }).probe_id
: null,
}), true);
assert.equal(persisted.choiceReady, true);
});
test("idle gap copy joins the evidence recap instead of opening a second turn", () => {
const joined = composeIdleGapIntoSpoken("记下了。", "现在记下的是2016 年 9 月上大学和2020 年 6 月毕业。再来一件不是上学的、记得大概年月的事就能开始筛,比如第一份工作、谈恋爱或结婚。");
assert.match(joined, /^记下了。现在记下的是/);
assert.match(joined, /就能开始筛/);
assert.equal(composeIdleGapIntoSpoken(joined, "现在记下的是重复。"), joined);
assert.equal(
composeIdleGapIntoSpoken("这次给出的范围 04:4904:53。", "这次给出的范围 04:4904:53。"),
"这次给出的范围 04:4904:53。",
);
});
test("undated occupation answer keeps the training gate closed and writes a precise gap", async () => {
resetDeliveryTurnGuardForTests();
const evidence = [educationStart, educationEnd, occupationNote];
const declinedSkippedTopics = askedPoolTopics();
assert.equal(collectionQuestionPool(evidence, declinedSkippedTopics).length, 0);
const gap = preciseGapNarration(evidence, declinedSkippedTopics);
assert.match(gap, /现在记下的是/);
assert.match(gap, /就能开始筛/);
assert.doesNotMatch(gap, /领域|做不了|还差 \d+ 件/);
const careerOrLoveOrFamily = ["第一份工作", "谈恋爱", "家里"].filter((token) => gap.includes(token));
assert.ok(careerOrLoveOrFamily.length >= 2, gap);
const dossier: DecisionDossier = {
evidence,
conversationSummary: {
activeFocus: null,
declinedSkippedTopics: declinedSkippedTopics,
},
latestResult: null,
case: { acceptedTime: null, status: "collecting_evidence" },
};
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
assert.equal(trainingScoreableGate(evidence).open, false);
assert.equal(interviewCollectWaiting({
stopReason: decision.stopReason,
sessionOutcome: decision.sessionOutcome,
questionMissing: true,
}), true);
assert.equal(rectificationQuestionGapState({
liveQuestionVisible: false,
questionMissing: true,
questionLoadFailed: false,
collectWaiting: true,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: 0,
}), "collect_waiting");
const accounting = idleHandlers(dossier);
const { result: idle } = await warnLines(() => persistNextInterviewIfIdle({
accounting: accounting.client,
userId: USER_ID,
caseId: CASE_ID,
}));
const persisted = idle as Awaited<ReturnType<typeof persistNextInterviewIfIdle>>;
assert.equal(
accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"),
false,
);
assert.equal(persisted.terminalNote, true);
assert.match(persisted.hostNarration ?? "", /现在记下的是/);
assert.match(persisted.hostNarration ?? "", /就能开始筛/);
assert.doesNotMatch(persisted.hostNarration ?? "", /领域|做不了|还差 \d+ 件/);
const agentAccounting = idleHandlers(dossier);
const result = await runV9AgentTurn({
userId: USER_ID,
caseId: CASE_ID,
sessionId: SESSION_ID,
requestId: "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
action: "evidence",
message: "程序员",
modelName: "gpt-4o-mini",
accounting: agentAccounting.client,
billing: {
reserve: async () => ({ success: true, status: 200 }),
complete: async () => true,
release: async () => true,
},
emit: async () => {},
buildAgent: async () => fakeAgentStream([
{ type: "start" },
{ type: "tool-call", payload: { toolName: "skill", args: { name: RECTIFICATION_SKILL_NAME } } },
{ type: "tool-result", payload: { toolName: "skill" } },
{ type: "tool-call", payload: { toolName: "rectification-read-case", args: { caseId: CASE_ID } } },
{ type: "tool-result", payload: { toolName: "rectification-read-case" } },
{ type: "text-delta", payload: { text: "记下了。" } },
{ type: "finish" },
]) as never,
});
assert.equal(result.ok, true);
assert.match(result.answerText, /现在记下的是/);
assert.match(result.answerText, /就能开始筛/);
assert.doesNotMatch(result.answerText, /领域|做不了|还差 \d+ 件/);
const finalized = agentAccounting.calls.find((item) => item.fn === "finalize_agentic_rectification_turn");
const appended = agentAccounting.calls.find((item) => (
item.fn === "append_agentic_rectification_turn"
&& typeof item.args.p_assistant_message === "string"
&& String(item.args.p_assistant_message).includes("就能开始筛")
));
assert.ok(finalized || appended, "gap copy must land on the evidence turn");
});