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
@@ -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),