fix(rectification): deliver range cards on tied first place instead of falling back to collect (BUG-680/681/682)
Independent Staging Quality Gate / validate (push) Successful in 17m18s
Independent Staging Quality Gate / publish (push) Successful in 13m52s

Refresh persist failures no longer count as attempts. tied_first completes with a range before stillNeedNarrowing. Delivery narration and cards share DELIVERY_OUTCOMES. The question gap gets a delivered terminal so the unavailable copy does not appear after a range is given.
This commit is contained in:
jesse-ux
2026-09-14 15:41:04 +08:00
parent e59ef9fbfd
commit d5a8db0a4e
16 changed files with 644 additions and 9 deletions
@@ -130,12 +130,25 @@ export const ADOPT_OUTCOMES: ReadonlySet<DecisionSessionOutcome> = new Set([
"validated_range",
]);
/** Range-delivery card and delivery copy share this set. ADOPT_OUTCOMES stays the adopt gate. */
export const DELIVERY_OUTCOMES: ReadonlySet<DecisionSessionOutcome> = new Set([
...ADOPT_OUTCOMES,
"completed_with_range",
"provisional_range",
]);
export function sessionOutcomeAllowsAdopt(
outcome: DecisionSessionOutcome | string | null | undefined,
): outcome is DecisionSessionOutcome {
return typeof outcome === "string" && ADOPT_OUTCOMES.has(outcome as DecisionSessionOutcome);
}
export function sessionOutcomeAllowsDelivery(
outcome: DecisionSessionOutcome | string | null | undefined,
): outcome is DecisionSessionOutcome {
return typeof outcome === "string" && DELIVERY_OUTCOMES.has(outcome as DecisionSessionOutcome);
}
export function publicCanAdopt(decision: Pick<RectificationDecision, "canAdopt" | "sessionOutcome">): boolean {
return decision.canAdopt && sessionOutcomeAllowsAdopt(decision.sessionOutcome);
}
@@ -151,7 +164,8 @@ export function deliveryNarrationAllowed(
): boolean {
if (publicCanAdopt(decision)) return true;
const action = nextAction ?? decision.nextAction;
return decision.selectionAllowed === true && action === "offer_provisional_range";
if (decision.selectionAllowed === true && action === "offer_provisional_range") return true;
return decision.selectionAllowed === true && sessionOutcomeAllowsDelivery(decision.sessionOutcome);
}
export type CompletionStatus =
@@ -317,6 +331,14 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
}
return collect(separation, holdout, range, probe, capability, stopReason);
}
if (
stopClass?.kind === "exhausted"
&& stopClass.reason === "tied_first"
&& input.trainingGateOpen !== false
&& separation.ranked.length > 0
) {
return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason);
}
if (coverageBlocks) {
const engineOffers = input.engineCeiling.acceptanceAllowed
|| input.engineCeiling.proposeAllowed;
@@ -15,6 +15,7 @@ import {
nonConvergingRangeNarration,
publicCanAdopt,
publicNextAction,
sessionOutcomeAllowsDelivery,
type RectificationDecision,
} from "../core/rectification-decision.ts";
import {
@@ -272,6 +273,12 @@ function shouldSkipFollowupPersist(input: {
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
}): boolean {
if (input.accepted) return false;
if (
input.stopReason === "tied_first"
&& sessionOutcomeAllowsDelivery(input.sessionOutcome)
) {
return true;
}
if (!input.canAdopt) return false;
if (
input.nextAction === "ask_fact_collection"
@@ -565,7 +565,7 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: {
{ ...state, revision: state.revision + 1 },
"no_new_probes",
);
await persistRefreshAttempt({
const recorded = await persistRefreshAttempt({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
@@ -573,6 +573,20 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: {
previous: state,
next: attempted,
});
if (!recorded) {
console.warn(JSON.stringify({
event: "rectification_refresh_attempt_persist_failed",
case_id: input.caseId,
candidate_set_id: state.candidate_set_id,
answer_count: state.answered_probes.length,
}));
return {
dossier: input.dossier,
state,
refreshed: false,
attemptRecorded: false,
};
}
return {
dossier: applyRefreshedProbesToDossier(input.dossier, attempted),
state: attempted,
@@ -597,11 +611,17 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: {
askableCount: askable.length,
});
if (!written) {
console.warn(JSON.stringify({
event: "rectification_refresh_attempt_persist_failed",
case_id: input.caseId,
candidate_set_id: state.candidate_set_id,
answer_count: state.answered_probes.length,
}));
return {
dossier: applyRefreshedProbesToDossier(input.dossier, nextState),
state: nextState,
dossier: input.dossier,
state,
refreshed: false,
attemptRecorded: true,
attemptRecorded: false,
};
}
const persistableEventProbes = result.eventProbes.filter((probe) => {