fix(rectification): ask tie-break questions before the range card (BUG-685/686/687)

Close leads hold delivery until unused D9/D10 style questions are asked. Tie-break POST merges the new turn into the transcript. Range copy no longer lists declined lines.
This commit is contained in:
jesse-ux
2026-09-14 18:23:54 +08:00
parent 1c424bf6d5
commit 7f28bfecbb
22 changed files with 833 additions and 86 deletions
@@ -197,6 +197,8 @@ export type RectificationDecision = Readonly<{
droppedProbes: readonly DroppedProbe[];
stopReason?: EvidenceStopReason | null;
terminationCopy?: string | null;
/** Close lead with unused D9/D10 style questions: ask those before delivering. */
heldForTieBreak?: boolean;
}>;
export type DecideRectificationInput = Readonly<{
@@ -228,6 +230,8 @@ export type DecideRectificationInput = Readonly<{
targetedCollectExhausted?: boolean;
/** Opening search window from `case.candidateRange`. Omit in helper/unit paths. */
openingCandidateRange?: readonly [string, string] | null;
/** Unasked D9/D10 style questions remain. */
pendingTieBreak?: boolean;
}>;
function classifyStop(
@@ -329,7 +333,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
}
if (userStopped && separation.ranked.length > 0) {
return completeWithRange(separation, holdout, range, "user_stopped", rangeDeliveryCapability);
return deliverRange(input, separation, holdout, range, "user_stopped", rangeDeliveryCapability);
}
if (input.snapshotCurrent === false) {
@@ -346,7 +350,8 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
&& !probe
&& input.targetedCollectExhausted !== false
) {
return completeWithRange(
return deliverRange(
input,
separation,
holdout,
range,
@@ -367,6 +372,9 @@ 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(
@@ -382,7 +390,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
return collect(separation, holdout, range, probe, capability, stopClass.reason);
}
if (stopClass?.kind === "exhausted" && stopClass.reason === "user_uncertainty_too_high") {
return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
}
if (!separation.sufficient) {
if (probe) {
@@ -400,7 +408,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
return collect(separation, holdout, range, probe, waitToNarrowCapability(capability), stopReason);
}
if (stopClass?.kind === "exhausted") {
return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
}
if (rangeDeliveryCapability.canAdopt && input.methodCoverageAll) {
return finish("adopt_representative", {
@@ -413,10 +421,10 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
stopReason: "probe_pool_exhausted",
});
}
return completeWithRange(separation, holdout, range, "offer", rangeDeliveryCapability);
return deliverRange(input, separation, holdout, range, "offer", rangeDeliveryCapability);
}
if (stopClass?.kind === "exhausted") {
return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
}
if (input.accepted) {
return finish(confirmationAllowed ? "awaiting_confirmation" : "adopt_representative", {
@@ -448,9 +456,12 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
if (probe) {
return discriminateOrExhaust(input, separation, holdout, range, probe, rangeDeliveryCapability);
}
return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability);
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);
}
return finish("adopt_representative", {
@@ -479,7 +490,7 @@ function discriminateOrExhaust(
stopReason: EvidenceStopReason | null = null,
): RectificationDecision {
if (budgetExhausted(input)) {
return completeWithRange(separation, holdout, range, "exhausted", capability, stopReason);
return deliverRange(input, separation, holdout, range, "exhausted", capability, stopReason);
}
return discriminate(separation, holdout, range, probe, capability, stopReason);
}
@@ -565,6 +576,61 @@ function stillNeedNarrowing(input: DecideRectificationInput): boolean {
return input.refreshExhausted === false || input.targetedCollectExhausted === false;
}
export function shouldHoldForTieBreak(
input: Pick<DecideRectificationInput, "pendingTieBreak" | "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 (separation.ranked.length < 2) return false;
return separation.lead <= 1;
}
function holdForTieBreak(
separation: CandidateSeparation,
holdout: HoldoutValidationStatus,
range: readonly [string, string] | null,
capability: DeliveryCapability,
stopReason: EvidenceStopReason | null = null,
): RectificationDecision {
return {
phase: "discrimination",
nextAction: "ask_candidate_discriminator",
sessionOutcome: "discriminate_candidates",
resultStatus: "discriminating",
canOfferRange: false,
...waitToNarrowCapability(capability),
precisionStage: "theme_refine",
activeFocusPolicy: "keep",
completionStatus: null,
validated: false,
credibleRange: range,
representativeTime: separation.representativeTime,
separation,
probe: null,
holdoutValidation: holdout,
droppedProbes: [],
heldForTieBreak: true,
...(stopReason ? { stopReason } : {}),
};
}
function deliverRange(
input: DecideRectificationInput,
separation: CandidateSeparation,
holdout: HoldoutValidationStatus,
range: readonly [string, string] | null,
kind: "user_stopped" | "offer" | "exhausted",
capability: DeliveryCapability,
stopReason: EvidenceStopReason | null = null,
): RectificationDecision {
if (shouldHoldForTieBreak(input, separation, kind)) {
return holdForTieBreak(separation, holdout, range, capability, stopReason);
}
return completeWithRange(separation, holdout, range, kind, capability, stopReason);
}
function clockMinute(value: string): string {
const match = /^(\d{1,2}):(\d{2})/.exec(value.trim());
if (!match) return value.trim().slice(0, 5);