fix(rectification): do not hold delivery on exhausted or closed-ceiling paths (BUG-688)
Independent Staging Quality Gate / validate (push) Successful in 10m3s
Independent Staging Quality Gate / publish (push) Successful in 3m53s

Style questions still precede a converging range card when a renderable
followup exists. Exhausted, closed-ceiling, and holdout-unavailable exits
deliver immediately. Hold at most once per Case. Restore the single-gate
exit assertion and stop treating the tie-break ack as an exit carrier.
This commit is contained in:
jesse-ux
2026-09-14 21:43:21 +08:00
parent ada2cfb15a
commit 963c147c58
10 changed files with 198 additions and 92 deletions
+4
View File
@@ -1,5 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-09-14 — 风格参考题问不出时仍会给出范围卡
生时校正里,带年月的题问完后仍会先问还没用过的风格参考题。如果这道题这一轮问不出来(比如候选在该分盘上只有一种上升星座),或已经问过一轮,会直接给出范围卡,不会停在没有题目的等待里。Skill 版本不变。
## 2026-09-14 — 给范围卡之前先问两道风格参考题
生时校正里,带年月的题问完后、给出范围卡之前,会先连着问还没用过的风格参考题(相处方式、做事风格)。某条线答过「没有」不会把对应的风格题一起关掉。点「再答两道参考题」后,题会出现在对话里。范围卡不再列出你已经说过没有或跳过的线。Skill 版本不变。
+16
View File
@@ -10709,3 +10709,19 @@
- 相关记录:BUG-661、BUG-662
- 复发自:无
- 修复版本:待发布
## BUG-688 | 风格题前置把耗尽与收口路径的交付也压住了
- 状态:resolved
- 首次发现:2026-09-14
- 最近更新:2026-09-14
- 影响面:`decideRectification` `shouldHoldForTieBreak`、耗尽 / closed-ceiling 出口
- 用户现象:带年月题和定向补事都问完、本应出范围卡时,若风格参考题拿不出来,会话停在「还在区分」且没有题。门禁上 6 条「该交付却不交付」变红。
- 触发条件:`pendingTieBreak` 为真,但风格题因分盘只有一种上升星座、焦点落库失败或已 hold 过一次而无法渲染;路径是 exhausted / closed-ceiling / 无载体收口。
- 根因:BUG-686 把「还有没问的风格题」做成无条件延迟交付,插在所有交付分支之前,没有出口、没有次数上限。`6fd7925a` 只把断言改绿,行为未修。
- 修复:只有真正拿得到可渲染风格题时才 holdexhausted、closed-ceiling、holdout 不可用的收口不 hold;同一 Case 最多 hold 一轮。恢复「恰好一条交付闸」和「参考题确认语不算出口」两条断言。
- 验证:`frontend/tests/rectification-tiebreak-before-card-20260914.test.ts``rectification-exhaustion-exit-20260906.test.ts``rectification-probe-pool-exhausted-20260911.test.ts`
- 防复发:任何「延迟交付」的守卫都必须带出口,且不得作用于耗尽与收口路径。验收必须比对 `npm test` 失败数与基线。不得把参考题确认语算作出载体。
- 相关记录:BUG-686、BUG-656、BUG-674、BUG-680、BUG-597
- 复发自:BUG-686(风格题前置扩大到所有交付分支)
- 修复版本:待发布
@@ -0,0 +1,21 @@
# 进度 · 参考题前置不得压住出口(2026-09-14)
## 范围
- 分支:`codex/rectification-tiebreak-hold-exit-20260914`(基于 `origin/staging` @ `ada2cfb1`
- 任务单:`docs/tasks/TASK-rectification-tiebreak-hold-exit-fix-20260914.md`
- BUG-688 resolved(复发自 BUG-686
- 未改 `active_rectification_event_engine.py`、确认门、并列判据、Skill 版本
## 完成
- T1`shouldHoldForTieBreak` 要求 `tieBreakFollowupReady`(可渲染风格题);`exhausted``user_stopped` 不 holdclosed-ceiling`coverageBlocks && engineOffers && !narrowingOpen`)和 holdout 不可用的收口不 hold。同一 Case 已 hold 过一轮(已问过 `varga.d9` / `varga.d10` 或焦点带 `tie_break_round`)不再 hold。
- T2:恢复 `last closed-ceiling card…``assert.equal(gates.length, 1)``exitAppendCalls()` 去掉 `只微调排序`。文案放宽(「范围已经收到 / 能问的都问完了」)保留。
- D3 已做,未走让步。
## 验收数字
- 定向:`rectification-tiebreak-before-card-20260914.test.ts` 12 条 + `rectification-probe-pool-exhausted-20260911.test.ts` 10 条全绿。`rectification-exhaustion-exit-20260906.test.ts` 本机因 Skill SHA 软链无法导入(Windows 基线缺口),Linux 门禁会跑。
- `tsc --noEmit`0 错
- `npx eslint` 改动文件:0 error2 条既有 unused-vars warning,未顺手改)
- Skill 版本不变
+1 -1
View File
@@ -184,7 +184,7 @@
| `TASK-rectification-targeted-card-dead-20260913.md` | `PROGRESS-rectification-targeted-card-dead-20260913.md` | **P0**:定向补事卡在快照投影里拿不到 `choice_card`(承接焦点分支不重建 `choice_frame`),卡片看得见点不动、流程停在采集等待态;模型还会把定向题改写成口述题(BUG-669~671)。先于 tie-break 修复单执行 | 待验收 | `codex/rectification-targeted-card-dead-20260913` |
| `TASK-rectification-tiebreak-hold-exit-fix-20260914.md` | `PROGRESS-rectification-tiebreak-hold-exit-20260914.md` | **P0 回归修复单**:BUG-686 的风格题前置把耗尽与收口路径的交付也压住了,`npm test` 由 27 红涨到 33(6 条「该交付却不交付」)。后续 `6fd7925a` 只改断言把它们改绿:4 条文案放宽可接受,2 条实质弱化(「恰好一条交付闸」退化成短路、参考题确认语被算作出口载体)必须恢复;行为层根因未动。hold 要带出口、不得作用于 exhausted/closed-ceiling,最多 hold 一次(BUG-688)。尚未部署,未影响线上 | 待执行 | `codex/rectification-tiebreak-hold-exit-20260914` |
| `TASK-rectification-tiebreak-hold-exit-fix-20260914.md` | `PROGRESS-rectification-tiebreak-hold-exit-20260914.md` | **P0 回归修复单**:BUG-686 的风格题前置把耗尽与收口路径的交付也压住了,`npm test` 由 27 红涨到 33(6 条「该交付却不交付」)。后续 `6fd7925a` 只改断言把它们改绿:4 条文案放宽可接受,2 条实质弱化(「恰好一条交付闸」退化成短路、参考题确认语被算作出口载体)必须恢复;行为层根因未动。hold 要带出口、不得作用于 exhausted/closed-ceiling,最多 hold 一次(BUG-688)。尚未部署,未影响线上 | 待验收 | `codex/rectification-tiebreak-hold-exit-20260914` |
| `TASK-rectification-cluster-width-research-20260914.md` | `PROGRESS-rectification-cluster-width-research-20260914.md` | **研究单**:上一轮证明调权重改不动交付区间宽度——所有方案宽度中位数都等于整个搜索窗。先确认 sweep 的宽度口径是否含淘汰(M0),再画簇结构像(M1),最后量三个改法:放宽簇上限、按分差决定是否合并、交付区间改分位覆盖(M2)。真值覆盖率不得下降 | 待执行 | `codex/rectification-cluster-width-research-20260914` |
+1 -1
View File
@@ -234,7 +234,7 @@ The birth-time rectification session is the consultation transcript plus a house
| `question-gap`, delivered | no current question, or the current question is a dead choice card, and the session already delivered a range (`completed_with_range` / `provisional_range` / adopt outcomes, or `tied_first`); range card or range line plus an exit note. Never “没有拿到下一个问题。” | enabled |
| `verified_idle` | one closing line `postAdoptVerifyDone` under the still-visible range card (same assistant column); no spinner, no reload | enabled |
| `choice-pending` | the answered card (`data-selected` fill, a top row “正在记录…”) and the same live row from “正在记录本次选择…” through the follow-up turn | enabled (typing queues), stop visible |
| `candidates` | one range-delivery card titled “目前范围 …(对照了 N 件经历)”, a caption under the title that only names still-open lines (or “能问的都问完了”); optional “再答两道参考题微调排序” only when unused D9/D10 remain; if those were already asked and the top two are still within one point, a line “这两分钟按现有信息分不开,参考题已经用过。”; then up to three compare columns (highest posterior first; “更像这个” adopts); a closed “查看验证报告” fold. Unused style questions are asked before this card appears, whether or not the top two are tied. | enabled |
| `candidates` | one range-delivery card titled “目前范围 …(对照了 N 件经历)”, a caption under the title that only names still-open lines (or “能问的都问完了”); optional “再答两道参考题微调排序” only when unused D9/D10 remain; if those were already asked and the top two are still within one point, a line “这两分钟按现有信息分不开,参考题已经用过。”; then up to three compare columns (highest posterior first; “更像这个” adopts); a closed “查看验证报告” fold. On a normal convergence, unused style questions are asked before this card. Exhausted and closed-ceiling exits still deliver the card if a style question cannot be rendered. | enabled |
| `adopting` | “正在采用 HH:MM…” through the follow-up turn | enabled (typing queues), stop visible |
| `confirmed` | “已确认校正时间:HH:MM” | enabled |
| `readonly` | “该校正已结束,只能查看历史。” and “再次校正” | disabled |
@@ -232,6 +232,10 @@ export type DecideRectificationInput = Readonly<{
openingCandidateRange?: readonly [string, string] | null;
/** Unasked D9/D10 style questions remain. */
pendingTieBreak?: boolean;
/** A renderable style followup exists this turn (choice_frame present). */
tieBreakFollowupReady?: boolean;
/** This Case already entered a style-question hold (at most one round). */
tieBreakAlreadyHeld?: boolean;
}>;
function classifyStop(
@@ -372,9 +376,6 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
&& engineOffers
&& !narrowingOpen
) {
if (shouldHoldForTieBreak(input, separation)) {
return holdForTieBreak(separation, holdout, range, rangeDeliveryCapability, stopReason);
}
return offerRangeWithoutAdopt(separation, holdout, range, rangeDeliveryCapability);
}
return collect(
@@ -411,9 +412,6 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
}
if (rangeDeliveryCapability.canAdopt && input.methodCoverageAll) {
if (shouldHoldForTieBreak(input, separation)) {
return holdForTieBreak(separation, holdout, range, rangeDeliveryCapability, stopReason);
}
return finish("adopt_representative", {
input,
separation,
@@ -468,9 +466,6 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability);
}
if (holdout === "unavailable") {
if (shouldHoldForTieBreak(input, separation)) {
return holdForTieBreak(separation, holdout, range, rangeDeliveryCapability, stopReason);
}
return offerRangeWithoutAdopt(separation, holdout, range, rangeDeliveryCapability);
}
if (shouldHoldForTieBreak(input, separation)) {
@@ -589,12 +584,17 @@ function stillNeedNarrowing(input: DecideRectificationInput): boolean {
}
export function shouldHoldForTieBreak(
input: Pick<DecideRectificationInput, "pendingTieBreak" | "userStopped">,
input: Pick<
DecideRectificationInput,
"pendingTieBreak" | "tieBreakFollowupReady" | "tieBreakAlreadyHeld" | "userStopped"
>,
separation: CandidateSeparation,
kind?: "user_stopped" | "offer" | "exhausted",
): boolean {
if (kind === "user_stopped" || input.userStopped === true) return false;
if (input.pendingTieBreak !== true) return false;
if (kind === "user_stopped" || kind === "exhausted" || input.userStopped === true) return false;
if (input.tieBreakAlreadyHeld === true) return false;
const ready = input.tieBreakFollowupReady ?? input.pendingTieBreak;
if (ready !== true) return false;
return separation.ranked.length >= 2;
}
@@ -42,6 +42,7 @@ import {
isRemainingEvidenceCollect,
tieBreakGateInput,
tieBreakPersonalityAvailable,
tieBreakPersonalityFollowup,
} from "./method-followup";
import { buildConfirmationGate } from "./confirmation-gate";
import {
@@ -763,24 +764,53 @@ function windowWidenSuggestedFromDossier(dossier: DecisionDossier, evidenceFinge
});
}
function isStyleProbeKey(value: unknown): boolean {
const key = String(value ?? "");
return key.startsWith("varga.d9") || key.startsWith("varga.d10");
}
function tieBreakHoldFlags(
dossier: DecisionDossier,
catalog: ReturnType<typeof rectificationFollowupCatalog>,
birthDate?: string | null,
): {
pendingTieBreak: boolean;
tieBreakFollowupReady: boolean;
tieBreakAlreadyHeld: boolean;
} {
const gate = tieBreakGateInput({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
catalog,
birthDate,
accepted: Boolean(dossier.case.acceptedTime),
stage: dossier.case.stage,
blockScan: dossier.case.blockScan,
reportedBirthTime: dossier.case.reportedBirthTime,
candidateRange: dossier.case.candidateRange,
});
const followup = tieBreakPersonalityFollowup(gate);
const asked = catalog.askedProbeKeys ?? [];
const answered = catalog.answeredProbes ?? [];
const schema = dossier.conversationSummary.activeFocus?.expectedAnswerSchema;
const alreadyHeld = asked.some(isStyleProbeKey)
|| answered.some((item) => isStyleProbeKey(item.semantic_key))
|| Boolean(schema && typeof schema === "object" && schema.tie_break_round === true);
return {
pendingTieBreak: tieBreakPersonalityAvailable(gate),
tieBreakFollowupReady: Boolean(followup?.choice_frame),
tieBreakAlreadyHeld: alreadyHeld,
};
}
export function decideFromDossier(
dossier: DecisionDossier,
options?: DecideFromDossierOptions,
): RectificationDecision {
const inference = previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null);
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const pendingTieBreak = tieBreakPersonalityAvailable(tieBreakGateInput({
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: dossier.conversationSummary.declinedSkippedTopics,
catalog,
birthDate: options?.birthDate,
accepted: Boolean(dossier.case.acceptedTime),
stage: dossier.case.stage,
blockScan: dossier.case.blockScan,
reportedBirthTime: dossier.case.reportedBirthTime,
candidateRange: dossier.case.candidateRange,
}));
const tieBreakHold = tieBreakHoldFlags(dossier, catalog, options?.birthDate);
const oosBlindPrompts = refinementFromDecisionReceipt(
dossier.latestResult?.decisionReceipt ?? null,
).oos_blind_prompts;
@@ -892,7 +922,7 @@ export function decideFromDossier(
),
windowWidenSuggested,
openingCandidateRange: openingRangeFromCandidateRange(dossier.case.candidateRange),
pendingTieBreak,
...tieBreakHold,
...narrowingExhaustion(dossier, inference, options, catalog),
}),
droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped),
@@ -907,18 +937,7 @@ export function decideAfterInferenceChange(input: {
snapshotCurrent?: boolean;
}): RectificationDecision {
const catalog = rectificationFollowupCatalog(input.dossier.latestResult, input.dossier.evidence);
const pendingTieBreak = tieBreakPersonalityAvailable(tieBreakGateInput({
evidence: input.dossier.evidence,
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: input.dossier.conversationSummary.declinedSkippedTopics,
catalog,
birthDate: input.birthDate,
accepted: Boolean(input.dossier.case.acceptedTime),
stage: input.dossier.case.stage,
blockScan: input.dossier.case.blockScan,
reportedBirthTime: input.dossier.case.reportedBirthTime,
candidateRange: input.dossier.case.candidateRange,
}));
const tieBreakHold = tieBreakHoldFlags(input.dossier, catalog, input.birthDate);
const collecting = buildMethodFollowupPlan({
evidence: input.dossier.evidence,
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
@@ -974,7 +993,7 @@ export function decideAfterInferenceChange(input: {
evidenceLedgerFingerprint(input.dossier.evidence as never),
),
openingCandidateRange: openingRangeFromCandidateRange(input.dossier.case.candidateRange),
pendingTieBreak,
...tieBreakHold,
});
}
const training = input.state.events.filter((item) => item.usage === "training");
@@ -1052,7 +1071,7 @@ export function decideAfterInferenceChange(input: {
evidenceLedgerFingerprint(input.dossier.evidence as never),
),
openingCandidateRange: openingRangeFromCandidateRange(input.dossier.case.candidateRange),
pendingTieBreak,
...tieBreakHold,
...narrowingExhaustion(input.dossier, input.state, undefined, catalog),
}),
droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped),
@@ -554,7 +554,7 @@ function assistantAppendCalls(calls: Array<{ fn: string; args: Record<string, un
function exitAppendCalls(calls: Array<{ fn: string; args: Record<string, unknown> }>) {
return assistantAppendCalls(calls).filter((item) => (
GATE_SENTENCE.test(String(item.args.p_assistant_message))
|| /就能开始筛|现在记下的是|范围还能再收一截|目前范围|当前范围|范围已经收到|能问的都问完了|只微调排序/.test(
|| /就能开始筛|现在记下的是|范围还能再收一截|目前范围|当前范围|范围已经收到|能问的都问完了/.test(
String(item.args.p_assistant_message),
)
));
@@ -858,30 +858,21 @@ test("last closed-ceiling card concatenates the gate into one append", async ()
expectedRevision: 6,
});
const gates = exitAppendCalls(accounting.calls);
// 原值: 答完最后一道区分卡立刻 append 1 条含「目前范围」的交付闸
// 新值: 交付旁白改收口「能问的都问完了」;若风格参考题未用完则先问参考题(0 条交付闸)
// 原因: BUG-686 / BUG-687
assert.ok(
gates.length === 1
|| /只微调排序|能问的都问完了|范围已经收到|目前范围/.test(applied.narration ?? ""),
JSON.stringify({
gates: gates.map((item) => item.args.p_assistant_message),
narration: applied.narration,
}),
);
// 原值: 恰好 1 条交付闸,旁白含「目前范围」
// 新值: 恰好 1 条交付闸,旁白可含「范围已经收到 / 能问的都问完了」
// 原因: BUG-688 恢复「恰好一条」;文案放宽来自 BUG-687
assert.equal(gates.length, 1, JSON.stringify(gates.map((item) => item.args.p_assistant_message)));
assert.doesNotMatch(applied.narration, /领域不限|做不了|材料不够|还差 \d+ 件/);
assert.match(
applied.narration,
/范围还能再收一截|目前范围|就能开始筛|当前范围|范围已经收到|能问的都问完了|只微调排序/,
/范围还能再收一截|目前范围|就能开始筛|当前范围|范围已经收到|能问的都问完了/,
);
assert.equal(
(String(gates[0]?.args.p_assistant_message).match(
/范围还能再收一截|目前范围|就能开始筛|范围已经收到|能问的都问完了/g,
) ?? []).length > 0,
true,
);
if (gates.length === 1) {
assert.equal(
(String(gates[0]?.args.p_assistant_message).match(
/范围还能再收一截|目前范围|就能开始筛|范围已经收到|能问的都问完了|只微调排序/g,
) ?? []).length > 0,
true,
);
}
});
test("closed ceiling with a leftover discriminator persists the card not the gate", async () => {
@@ -784,20 +784,16 @@ test("T3: skipped persist still leaves a non-empty carrier; 没有了 delivers t
declinedTopics: TARGETED_ALL_DECLINED,
});
const delivered = decideFromDossier(allDeclined, { birthDate: "1997-08-08" });
// 原值: 全部定向补事拒答后立刻 offer_provisional_range / ready_to_adopt / complete_with_range
// 新值: 风格参考题未用完时先 ask_candidate_discriminator
// 原因: BUG-686 出卡前先问参考题
// 原值: 风格题前置把 closed-ceiling 也 hold 成 ask_candidate_discriminator
// 新值: 耗尽/收口路径直接交付
// 原因: BUG-688 D2
assert.ok(
delivered.heldForTieBreak === true
|| delivered.nextAction === "offer_provisional_range"
delivered.nextAction === "offer_provisional_range"
|| delivered.nextAction === "ready_to_adopt"
|| delivered.nextAction === "complete_with_range",
delivered.nextAction,
);
// 原值: canOfferRange true
// 新值: 卡住参考题时 false
// 原因: BUG-686
assert.equal(delivered.canOfferRange, delivered.heldForTieBreak !== true);
assert.equal(delivered.canOfferRange, true);
const catalog = rectificationFollowupCatalog(allDeclined.latestResult, allDeclined.evidence);
assert.equal(
targetedCollectPool(
@@ -821,11 +817,15 @@ test("T4: exhausted refresh and declined targeted collect titles the card 目前
declinedTopics: TARGETED_ALL_DECLINED,
});
const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" });
// 原值: offer_provisional_range / ready_to_adopt / complete_with_range
// 新值: 未用的 D10 风格参考题先占位,nextAction=ask_candidate_discriminator
// 原因: BUG-686 出卡前先问参考题
assert.equal(decision.heldForTieBreak, true, decision.nextAction);
assert.equal(decision.nextAction, "ask_candidate_discriminator");
// 原值: heldForTieBreak 把 closed-ceiling 压成 ask_candidate_discriminator
// 新值: 定向补事关完后交付范围卡
// 原因: BUG-688 D2 耗尽/收口不得 hold
assert.ok(
decision.nextAction === "offer_provisional_range"
|| decision.nextAction === "ready_to_adopt"
|| decision.nextAction === "complete_with_range",
decision.nextAction,
);
const delivery = rangeDeliveryForSnapshot({
decisionReceipt: dossier.latestResult?.decisionReceipt,
candidates: dossier.latestResult?.candidates,
@@ -851,17 +851,28 @@ test("T4: exhausted refresh and declined targeted collect titles the card 目前
assert.doesNotMatch(delivery.narrow_hint ?? "", /能把 04:48 和 05:07 分开/);
assert.doesNotMatch(delivery.narrow_hint ?? "", /这次给出|最终/);
const publicAction = publicNextAction(decision);
// 原值: can_offer_range truenextUser 为出卡
// 新值: 卡住参考题时不出卡
// 原因: BUG-686
assert.equal(publicAction.can_offer_range, false);
assert.equal(publicAction.can_offer_range, true);
assert.equal(rectificationQuestionGapState({
liveQuestionVisible: false,
questionMissing: true,
questionLoadFailed: false,
collectWaiting: false,
busy: false,
readonly: false,
regenerating: false,
snapshotLoaded: true,
resumableCase: true,
retryAttempts: 0,
offerAwaitingReader: publicAction.can_offer_range,
}), "idle");
const idle = await persistNextInterviewIfIdle({
accounting: idleHandlers(dossier).client,
userId: USER_ID,
caseId: CASE_ID,
});
assert.match(idle.hostNarration ?? "", /只微调排序|目前范围|能问的都问完了/);
assert.match(idle.hostNarration ?? "", /目前范围|范围已经收到|能问的都问完了/);
assert.doesNotMatch(idle.hostNarration ?? "", /这次给出|最终/);
assert.doesNotMatch(idle.hostNarration ?? "", /平时做事|月宿性格/);
});
test("T0: GET-selected receipt probe is rejected until refresh merges it into inference_state", async () => {
@@ -121,6 +121,29 @@ function tiedDecision(overrides: Partial<Parameters<typeof decideRectification>[
});
}
/** Normal convergence: unique first place, methods covered, holdout passed. */
function convergingDecision(overrides: Partial<Parameters<typeof decideRectification>[0]> = {}) {
return decideRectification({
engineCeiling: OPEN_ENGINE_CAPABILITY_CEILING,
methodCoverageAll: true,
trainingGateOpen: true,
candidateScores: [
{ time: "04:53", score: 24 },
{ time: "05:00", score: 14 },
{ time: "05:06", score: 13 },
],
discriminatorProbe: null,
holdoutValidation: "passed",
datedEventCount: 3,
datedDomainCount: 3,
targetedCollectExhausted: true,
refreshExhausted: true,
tieBreakFollowupReady: true,
pendingTieBreak: true,
...overrides,
});
}
test("a tie-break snapshot turn is merged into messages with its question", () => {
type Message = {
role: "assistant";
@@ -198,8 +221,8 @@ test("a delivery card plus an unanswered focus is not an idle gap", () => {
);
});
test("a first-place tie with unused personality questions holds delivery", () => {
const decision = tiedDecision({ pendingTieBreak: true });
test("a converging case with unused personality questions holds delivery", () => {
const decision = convergingDecision();
assert.equal(decision.heldForTieBreak, true);
assert.notEqual(decision.nextAction, "complete_with_range");
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), false);
@@ -231,19 +254,40 @@ test("the same tie delivers after both personality questions are used", () => {
});
test("a two-point lead still asks personality questions before delivery", () => {
const decision = tiedDecision({
pendingTieBreak: true,
candidateScores: [
{ time: "04:53", score: 16 },
{ time: "05:00", score: 14 },
{ time: "05:06", score: 13 },
],
});
const decision = convergingDecision();
assert.equal(decision.heldForTieBreak, true);
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), false);
assert.equal(tieBreakPersonalityFollowup(planInput())?.choice_kind, "varga_style");
});
test("pending style flag without a renderable followup delivers", () => {
const decision = convergingDecision({
pendingTieBreak: true,
tieBreakFollowupReady: false,
});
assert.equal(decision.heldForTieBreak ?? false, false);
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
});
test("an exhausted first-place tie delivers even if style questions remain", () => {
const decision = tiedDecision({
pendingTieBreak: true,
tieBreakFollowupReady: true,
});
assert.equal(decision.heldForTieBreak ?? false, false);
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
});
test("already held once without a followup delivers", () => {
const decision = convergingDecision({
pendingTieBreak: true,
tieBreakFollowupReady: true,
tieBreakAlreadyHeld: true,
});
assert.equal(decision.heldForTieBreak ?? false, false);
assert.equal(sessionOutcomeAllowsDelivery(decision.sessionOutcome), true);
});
test("a layer with only one rising sign does not occupy the style slot", () => {
const onlyD10 = {
...planInput(),
@@ -262,7 +306,7 @@ test("a layer with only one rising sign does not occupy the style slot", () => {
test("holding for a tie-break does not mark a delivery turn", () => {
resetDeliveryTurnGuardForTests();
const decision = tiedDecision({ pendingTieBreak: true });
const decision = convergingDecision();
assert.equal(decision.heldForTieBreak, true);
markDeliveryTurn({ caseId: "case-1", resultId: "result-1" });
assert.equal(alreadyDelivered({