fix(rectification): keep tied-first from delivering while probes remain (BUG-683/684)
Independent Staging Quality Gate / validate (push) Successful in 13m48s
Independent Staging Quality Gate / publish (push) Successful in 2m6s

tied_first is only terminal when there is no dated probe and targeted collect is exhausted. An unnarrowed opening window cannot announce adopt. Delivered dead choice cards take the delivered gap instead of the repair copy.
This commit is contained in:
jesse-ux
2026-09-14 17:06:51 +08:00
parent e42a62dfac
commit 9da42c99d2
12 changed files with 402 additions and 25 deletions
@@ -1584,6 +1584,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
collectWaiting: collectWaiting && !deadChoice,
sessionOutcome: interviewSessionOutcome ?? candidateResult?.sessionOutcome ?? null,
stopReason: interviewStopReason,
deadChoice,
questionId: currentQuestion?.question_id ?? null,
hasChoiceCard: Boolean(choiceCard),
busy,
readonly,
regenerating: regeneratingMessageKey !== null,
@@ -226,6 +226,8 @@ export type DecideRectificationInput = Readonly<{
windowWidenSuggested?: boolean;
refreshExhausted?: boolean;
targetedCollectExhausted?: boolean;
/** Opening search window from `case.candidateRange`. Omit in helper/unit paths. */
openingCandidateRange?: readonly [string, string] | null;
}>;
function classifyStop(
@@ -316,18 +318,23 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
confirmationAllowed,
trainingGateOpen: input.trainingGateOpen !== false,
});
const rangeDeliveryCapability = withholdAdoptOnUnnarrowedRange(
capability,
range,
input.openingCandidateRange,
);
if (input.windowWidenSuggested === true && input.trainingGateOpen !== false && !userStopped) {
return askWindowWiden(separation, range);
}
if (userStopped && separation.ranked.length > 0) {
return completeWithRange(separation, holdout, range, "user_stopped", capability);
return completeWithRange(separation, holdout, range, "user_stopped", rangeDeliveryCapability);
}
if (input.snapshotCurrent === false) {
if (probe && !userStopped && input.trainingGateOpen !== false) {
return discriminateOrExhaust(input, separation, holdout, range, probe, capability, stopReason);
return discriminateOrExhaust(input, separation, holdout, range, probe, rangeDeliveryCapability, stopReason);
}
return collect(separation, holdout, range, probe, capability, stopReason);
}
@@ -336,8 +343,17 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
&& stopClass.reason === "tied_first"
&& input.trainingGateOpen !== false
&& separation.ranked.length > 0
&& !probe
&& input.targetedCollectExhausted !== false
) {
return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason);
return completeWithRange(
separation,
holdout,
range,
"exhausted",
rangeDeliveryCapability,
stopClass.reason,
);
}
if (coverageBlocks) {
const engineOffers = input.engineCeiling.acceptanceAllowed
@@ -351,7 +367,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
&& engineOffers
&& !narrowingOpen
) {
return offerRangeWithoutAdopt(separation, holdout, range, capability);
return offerRangeWithoutAdopt(separation, holdout, range, rangeDeliveryCapability);
}
return collect(
separation,
@@ -366,11 +382,11 @@ 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", capability, stopClass.reason);
return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
}
if (!separation.sufficient) {
if (probe) {
return discriminateOrExhaust(input, separation, holdout, range, probe, capability, stopReason);
return discriminateOrExhaust(input, separation, holdout, range, probe, rangeDeliveryCapability, stopReason);
}
if (holdout === "not_started" && !capability.canAdopt) {
return holdoutValidation(separation, range, capability);
@@ -384,23 +400,23 @@ 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", capability, stopClass.reason);
return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
}
if (capability.canAdopt && input.methodCoverageAll) {
if (rangeDeliveryCapability.canAdopt && input.methodCoverageAll) {
return finish("adopt_representative", {
input,
separation,
holdout,
range,
probe: null,
capability,
capability: rangeDeliveryCapability,
stopReason: "probe_pool_exhausted",
});
}
return completeWithRange(separation, holdout, range, "offer", capability);
return completeWithRange(separation, holdout, range, "offer", rangeDeliveryCapability);
}
if (stopClass?.kind === "exhausted") {
return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason);
return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
}
if (input.accepted) {
return finish(confirmationAllowed ? "awaiting_confirmation" : "adopt_representative", {
@@ -409,7 +425,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
holdout,
range,
probe: null,
capability,
capability: rangeDeliveryCapability,
});
}
if (input.datedMethodCollectOpen === true && !input.userStopped) {
@@ -422,7 +438,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
holdout,
range,
probe: null,
capability,
capability: rangeDeliveryCapability,
});
}
if (holdout === "not_started" && !capability.canAdopt) {
@@ -430,12 +446,12 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
}
if (holdout === "failed") {
if (probe) {
return discriminateOrExhaust(input, separation, holdout, range, probe, capability);
return discriminateOrExhaust(input, separation, holdout, range, probe, rangeDeliveryCapability);
}
return completeWithRange(separation, holdout, range, "exhausted", capability);
return completeWithRange(separation, holdout, range, "exhausted", rangeDeliveryCapability);
}
if (holdout === "unavailable") {
return offerRangeWithoutAdopt(separation, holdout, range, capability);
return offerRangeWithoutAdopt(separation, holdout, range, rangeDeliveryCapability);
}
return finish("adopt_representative", {
input,
@@ -443,7 +459,7 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
holdout,
range,
probe: null,
capability,
capability: rangeDeliveryCapability,
});
}
@@ -549,6 +565,34 @@ function stillNeedNarrowing(input: DecideRectificationInput): boolean {
return input.refreshExhausted === false || input.targetedCollectExhausted === false;
}
function clockMinute(value: string): string {
const match = /^(\d{1,2}):(\d{2})/.exec(value.trim());
if (!match) return value.trim().slice(0, 5);
return `${match[1].padStart(2, "0")}:${match[2]}`;
}
export function rangeMatchesOpeningWindow(
range: readonly [string, string] | null | undefined,
opening: readonly [string, string] | null | undefined,
): boolean {
if (!range || !opening) return false;
return clockMinute(range[0]) === clockMinute(opening[0])
&& clockMinute(range[1]) === clockMinute(opening[1]);
}
function withholdAdoptOnUnnarrowedRange(
capability: DeliveryCapability,
range: readonly [string, string] | null,
opening: readonly [string, string] | null | undefined,
): DeliveryCapability {
if (!rangeMatchesOpeningWindow(range, opening)) return capability;
return {
...capability,
canAdopt: false,
canConfirmExactMinute: false,
};
}
function waitToNarrowCapability(capability: DeliveryCapability): DeliveryCapability {
return {
...capability,
@@ -742,10 +786,13 @@ function finish(
: input.holdout === "passed"
? "validated_range"
: fallbackOutcome;
const sessionOutcome = !input.capability.canAdopt && kind === "adopt_representative"
? "completed_with_range"
: kind;
return {
phase: "completed",
nextAction: "ready_to_adopt",
sessionOutcome: kind,
nextAction: sessionOutcome === "completed_with_range" ? "complete_with_range" : "ready_to_adopt",
sessionOutcome,
resultStatus: kind === "awaiting_confirmation" || kind === "exact_minute_confirmed"
? "converged"
: "completed_with_range",
@@ -60,6 +60,7 @@ import {
} from "./collection-question-pool.ts";
import { evidenceLedgerFingerprint } from "./tool-service";
import { followupCaseArgs, blockScanDeclinedForFingerprint } from "./block-scan.ts";
import { openingRangeFromCandidateRange } from "../user-copy.ts";
import {
representativeNearWindowEdge,
} from "./search-window.ts";
@@ -876,6 +877,7 @@ export function decideFromDossier(
options?.currentEvidenceFingerprint ?? evidenceLedgerFingerprint(dossier.evidence as never),
),
windowWidenSuggested,
openingCandidateRange: openingRangeFromCandidateRange(dossier.case.candidateRange),
...narrowingExhaustion(dossier, inference, options, catalog),
}),
droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped),
@@ -944,6 +946,7 @@ export function decideAfterInferenceChange(input: {
input.dossier,
evidenceLedgerFingerprint(input.dossier.evidence as never),
),
openingCandidateRange: openingRangeFromCandidateRange(input.dossier.case.candidateRange),
});
}
const training = input.state.events.filter((item) => item.usage === "training");
@@ -1020,6 +1023,7 @@ export function decideAfterInferenceChange(input: {
input.dossier,
evidenceLedgerFingerprint(input.dossier.evidence as never),
),
openingCandidateRange: openingRangeFromCandidateRange(input.dossier.case.candidateRange),
...narrowingExhaustion(input.dossier, input.state, undefined, catalog),
}),
droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped),
@@ -294,6 +294,10 @@ export type RectificationQuestionGapInput = Readonly<{
collectWaiting?: boolean;
sessionOutcome?: string | null;
stopReason?: string | null;
/** Choice/targeted stem that cannot render a card. Delivery still takes this over repair. */
deadChoice?: boolean;
questionId?: string | null;
hasChoiceCard?: boolean;
busy: boolean;
readonly: boolean;
regenerating: boolean;
@@ -384,14 +388,35 @@ export function interviewChoiceCardUnavailable(input: Readonly<{
return false;
}
let lastDeliveredDeadChoiceLog = "";
export function interviewDeliveredGap(input: Readonly<{
questionMissing: boolean;
sessionOutcome?: string | null;
stopReason?: string | null;
deadChoice?: boolean;
questionId?: string | null;
hasChoiceCard?: boolean;
}>): boolean {
if (!input.questionMissing) return false;
if (sessionOutcomeAllowsDelivery(input.sessionOutcome)) return true;
return input.stopReason === "tied_first" || input.stopReason === "user_uncertainty_too_high";
const questionGone = input.questionMissing || input.deadChoice === true;
if (!questionGone) return false;
const delivered = sessionOutcomeAllowsDelivery(input.sessionOutcome)
|| input.stopReason === "tied_first"
|| input.stopReason === "user_uncertainty_too_high";
if (delivered && input.deadChoice === true) {
const payload = JSON.stringify({
event: "rectification_delivered_state_dead_choice",
session_outcome: input.sessionOutcome ?? null,
stop_reason: input.stopReason ?? null,
question_id: input.questionId ?? null,
has_choice_card: input.hasChoiceCard === true,
});
if (payload !== lastDeliveredDeadChoiceLog) {
lastDeliveredDeadChoiceLog = payload;
console.warn(payload);
}
}
return delivered;
}
export function interviewCollectWaiting(input: Readonly<{