- D1 引导窗口题每个校正最多 2 道(前端 GUIDED_WINDOW_CASE_LIMIT;引擎 GUIDED_COLLECT_LIMIT 未动:event_probes.py 属冻结评分身份,改它需重新冻结) - D2 七条定向线与跳过线重问问完即出卡,没问到的引导窗口不再挡卡,出卡后也不再挂窗口题 - D3 卡头加副标题「最可能 HH:MM」 - D4 前两列相差 ≥5 个百分点才显示相对可能性,否则一句「这几个时刻目前区分不开……」 - 离线回放 scripts/research/fewer_probes_card_replay.py:真值不降、宽度中位 ±1 分钟、提问 11.4→7.8 - Skill 10.0.29 → 10.0.30;DESIGN / VOICE / CHANGELOG / PROGRESS / 真机清单 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
1066 lines
37 KiB
TypeScript
1066 lines
37 KiB
TypeScript
/**
|
|
* Single authoritative rectification decision.
|
|
*
|
|
* phase, nextAction, canOfferRange, canAdopt, and canConfirmExactMinute are
|
|
* computed here. precision_stage, selection_allowed, and active_focus policy
|
|
* are derived from this object and must not be judged independently.
|
|
*/
|
|
|
|
import type { CandidateDiscriminatorProbe } from "./candidate-contrast-packet.ts";
|
|
import {
|
|
evaluateCandidateSeparation,
|
|
type CandidateScoreRow,
|
|
type CandidateSeparation,
|
|
} from "./candidate-separation.ts";
|
|
import { rangeFromTimes } from "./credible-range.ts";
|
|
import type { DroppedProbe } from "../v9/probe-question-contract.ts";
|
|
import { EFFECTIVE_ANSWER_SAFETY_CAP } from "../../birth-time-dynamic-stop-policy.ts";
|
|
import { RECTIFICATION_POLICY } from "../../rectification-policy.ts";
|
|
import {
|
|
DEFAULT_MAX_DISCRIMINATION_ROUNDS,
|
|
type RectificationPhase,
|
|
type ResultStatus,
|
|
} from "./types.ts";
|
|
import {
|
|
REPRESENTATIVE_MINUTE_DISCLAIMER,
|
|
nonConvergingRangeNarration,
|
|
} from "../user-copy.ts";
|
|
import { normalizeBirthTimeSource, type ProvenanceSource } from "../birth-time-provenance.ts";
|
|
|
|
export { REPRESENTATIVE_MINUTE_DISCLAIMER, nonConvergingRangeNarration };
|
|
|
|
export type RectificationNextActionType =
|
|
| "ask_fact_collection"
|
|
| "ask_block_choice"
|
|
| "ask_window_widen"
|
|
| "ask_candidate_discriminator"
|
|
| "ask_holdout_validation"
|
|
| "offer_provisional_range"
|
|
| "complete_with_range"
|
|
| "ready_to_adopt";
|
|
|
|
export type HoldoutValidationStatus = "not_started" | "passed" | "failed" | "unavailable";
|
|
|
|
export type EvidenceStopReason =
|
|
| "insufficient_dated_events"
|
|
| "insufficient_domains"
|
|
| "tied_first"
|
|
| "user_uncertainty_too_high"
|
|
| "probe_pool_exhausted";
|
|
|
|
export type StopClass =
|
|
| Readonly<{ kind: "keep_collecting"; reason: "insufficient_dated_events" | "insufficient_domains" }>
|
|
| Readonly<{ kind: "exhausted"; reason: "tied_first" | "user_uncertainty_too_high" }>
|
|
| Readonly<{ kind: "user_stopped" }>;
|
|
|
|
export type EngineCapabilityCeiling = Readonly<{
|
|
acceptanceAllowed: boolean;
|
|
selectionAllowed: boolean;
|
|
proposeAllowed: boolean;
|
|
confirmationAllowed: boolean;
|
|
}>;
|
|
|
|
type DeliveryCapability = Readonly<{
|
|
canAdopt: boolean;
|
|
selectionAllowed: boolean;
|
|
proposeAllowed: boolean;
|
|
canConfirmExactMinute: boolean;
|
|
}>;
|
|
|
|
const CLOSED_ENGINE_CAPABILITY_CEILING: EngineCapabilityCeiling = {
|
|
acceptanceAllowed: false,
|
|
selectionAllowed: false,
|
|
proposeAllowed: false,
|
|
confirmationAllowed: false,
|
|
};
|
|
|
|
export function engineCapabilityCeilingFromReceipt(value: unknown): EngineCapabilityCeiling {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
return CLOSED_ENGINE_CAPABILITY_CEILING;
|
|
}
|
|
const row = value as Readonly<Record<string, unknown>>;
|
|
const acceptanceAllowed = row.acceptance_allowed;
|
|
const selectionAllowed = row.selection_allowed;
|
|
const proposeAllowed = row.propose_allowed;
|
|
const confirmationAllowed = row.confirmation_allowed;
|
|
if (
|
|
typeof acceptanceAllowed !== "boolean"
|
|
|| typeof selectionAllowed !== "boolean"
|
|
|| typeof proposeAllowed !== "boolean"
|
|
|| typeof confirmationAllowed !== "boolean"
|
|
|| acceptanceAllowed !== selectionAllowed
|
|
|| (row.accept_allowed !== undefined
|
|
&& (typeof row.accept_allowed !== "boolean" || row.accept_allowed !== selectionAllowed))
|
|
|| (row.confirm_allowed !== undefined
|
|
&& (typeof row.confirm_allowed !== "boolean" || row.confirm_allowed !== confirmationAllowed))
|
|
|| (row.display_allowed !== undefined
|
|
&& (typeof row.display_allowed !== "boolean" || (selectionAllowed && !row.display_allowed)))
|
|
|| (proposeAllowed && !selectionAllowed)
|
|
|| (confirmationAllowed && !selectionAllowed)
|
|
) {
|
|
return CLOSED_ENGINE_CAPABILITY_CEILING;
|
|
}
|
|
return { acceptanceAllowed, selectionAllowed, proposeAllowed, confirmationAllowed };
|
|
}
|
|
|
|
export const RECTIFICATION_TERMINATION_COPY = "当前最优结果是候选时间段,而不是已经确认的唯一出生分钟。临时代表时间仅用于下一轮验证与比较。";
|
|
|
|
/** Standalone range-delivery floor; the exact-minute confirmation gate remains 4/3. */
|
|
export const MIN_STANDALONE_DATED_EVENTS = 3;
|
|
export const MIN_STANDALONE_DATED_DOMAINS = 2;
|
|
|
|
export type DecisionSessionOutcome =
|
|
| "collect_evidence"
|
|
| "compare_blocks"
|
|
| "widen_window"
|
|
| "discriminate_candidates"
|
|
| "validate_holdout"
|
|
| "provisional_range"
|
|
| "provisional_range_user_stopped"
|
|
| "completed_with_range"
|
|
| "validated_range"
|
|
| "exact_minute_confirmed"
|
|
| "adopt_representative"
|
|
| "awaiting_confirmation";
|
|
|
|
/** Public `can_adopt` is true only in these outcomes. Internal `decision.canAdopt` may still be true during collect. */
|
|
export const ADOPT_OUTCOMES: ReadonlySet<DecisionSessionOutcome> = new Set([
|
|
"adopt_representative",
|
|
"provisional_range_user_stopped",
|
|
"awaiting_confirmation",
|
|
"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);
|
|
}
|
|
|
|
/**
|
|
* Delivery copy (`目前范围` / representative-candidate close) is only
|
|
* allowed when the public adopt flag is on, or the session is explicitly
|
|
* offering a selectable range. Internal `canAdopt` during collect is not enough.
|
|
*/
|
|
export function deliveryNarrationAllowed(
|
|
decision: Pick<RectificationDecision, "canAdopt" | "sessionOutcome" | "nextAction" | "selectionAllowed">,
|
|
nextAction?: string | null,
|
|
): boolean {
|
|
if (publicCanAdopt(decision)) return true;
|
|
const action = nextAction ?? decision.nextAction;
|
|
if (decision.selectionAllowed === true && action === "offer_provisional_range") return true;
|
|
return decision.selectionAllowed === true && sessionOutcomeAllowsDelivery(decision.sessionOutcome);
|
|
}
|
|
|
|
export type CompletionStatus =
|
|
| "provisional_range_user_stopped"
|
|
| "validated_range"
|
|
| "exact_minute_confirmed";
|
|
|
|
export type DerivedPrecisionStage = "collect_events" | "theme_refine" | "ready_to_adopt";
|
|
|
|
export type RectificationDecision = Readonly<{
|
|
phase: RectificationPhase;
|
|
nextAction: RectificationNextActionType;
|
|
sessionOutcome: DecisionSessionOutcome;
|
|
resultStatus: ResultStatus;
|
|
canOfferRange: boolean;
|
|
canAdopt: boolean;
|
|
canConfirmExactMinute: boolean;
|
|
selectionAllowed: boolean;
|
|
proposeAllowed: boolean;
|
|
precisionStage: DerivedPrecisionStage;
|
|
activeFocusPolicy: "keep" | "close";
|
|
completionStatus: CompletionStatus | null;
|
|
validated: boolean;
|
|
credibleRange: readonly [string, string] | null;
|
|
representativeTime: string | null;
|
|
separation: CandidateSeparation;
|
|
probe: CandidateDiscriminatorProbe | null;
|
|
holdoutValidation: HoldoutValidationStatus;
|
|
droppedProbes: readonly DroppedProbe[];
|
|
stopReason?: EvidenceStopReason | null;
|
|
terminationCopy?: string | null;
|
|
/** Close lead with unused D9/D10 style questions: ask those before delivering. */
|
|
heldForTieBreak?: boolean;
|
|
/** Declared-time source for copy. Missing means approximate. */
|
|
birthTimeSource?: "hospital_record" | "approximate" | "period_only";
|
|
/**
|
|
* D1 as measured this turn (width ≤ 10, top-two display gap > 3, no exact
|
|
* tie). Reported, never a delivery condition on its own (D2). Null on the
|
|
* helper/unit path, which does not pass the flag.
|
|
*/
|
|
precisionGateMet?: boolean | null;
|
|
}>;
|
|
|
|
export type DecideRectificationInput = Readonly<{
|
|
methodCoverageAll: boolean;
|
|
confirmationAllowed?: boolean;
|
|
userStopped?: boolean;
|
|
snapshotCurrent?: boolean;
|
|
trainingGateOpen?: boolean;
|
|
candidateScores: readonly CandidateScoreRow[];
|
|
discriminatorProbe?: CandidateDiscriminatorProbe | null;
|
|
nakshatraBoundaryProbe?: CandidateDiscriminatorProbe | null;
|
|
holdoutValidation?: HoldoutValidationStatus;
|
|
accepted?: boolean;
|
|
inferenceCredibleRange?: readonly [string, string] | null;
|
|
engineCeiling: EngineCapabilityCeiling;
|
|
datedMethodCollectOpen?: boolean;
|
|
/** Server-persisted inference counters; never infer these from chat text. */
|
|
inferenceRounds?: number;
|
|
effectiveAnswerCount?: number;
|
|
plateauRounds?: number;
|
|
/** Minimum standalone delivery floor; distinct from the 4/3 confirmation gate. */
|
|
datedEventCount?: number;
|
|
datedDomainCount?: number;
|
|
userUncertaintyHigh?: boolean;
|
|
caseStage?: "minute" | "block_scan";
|
|
blockScanDeclined?: boolean;
|
|
windowWidenSuggested?: boolean;
|
|
refreshExhausted?: boolean;
|
|
targetedCollectExhausted?: boolean;
|
|
/** D1: width ≤ 10, top-two display gap > 3, not an exact first-place tie. */
|
|
precisionGateMet?: boolean;
|
|
/**
|
|
* The lines that may hold the card are asked out: skip-retry and the
|
|
* targeted seven (production also requires the engine refresh). Since D2
|
|
* (2026-09-26) unasked guided boundary windows are not part of this — see
|
|
* `cardHoldingLinesExhausted`.
|
|
*/
|
|
guidedCollectExhausted?: 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;
|
|
/** 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(
|
|
input: DecideRectificationInput,
|
|
separation: CandidateSeparation,
|
|
): StopClass | null {
|
|
if (
|
|
input.datedEventCount !== undefined
|
|
&& input.datedEventCount < MIN_STANDALONE_DATED_EVENTS
|
|
) {
|
|
return { kind: "keep_collecting", reason: "insufficient_dated_events" };
|
|
}
|
|
if (
|
|
input.datedDomainCount !== undefined
|
|
&& input.datedDomainCount < MIN_STANDALONE_DATED_DOMAINS
|
|
) {
|
|
return { kind: "keep_collecting", reason: "insufficient_domains" };
|
|
}
|
|
if (separation.ranked.length === 0 && !input.discriminatorProbe) {
|
|
return { kind: "keep_collecting", reason: "insufficient_dated_events" };
|
|
}
|
|
if (input.userStopped === true) return { kind: "user_stopped" };
|
|
if (separation.tiedForFirst) return { kind: "exhausted", reason: "tied_first" };
|
|
if (input.userUncertaintyHigh === true) {
|
|
return { kind: "exhausted", reason: "user_uncertainty_too_high" };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function deliveryCapability(input: {
|
|
stopClass: StopClass | null;
|
|
separation: CandidateSeparation;
|
|
holdout: HoldoutValidationStatus;
|
|
engineCeiling: EngineCapabilityCeiling;
|
|
confirmationAllowed: boolean;
|
|
trainingGateOpen: boolean;
|
|
}): DeliveryCapability {
|
|
const locallySelectable = input.separation.ranked.length > 0
|
|
&& input.trainingGateOpen
|
|
&& input.stopClass?.kind !== "keep_collecting";
|
|
return {
|
|
canAdopt: locallySelectable && input.engineCeiling.acceptanceAllowed,
|
|
selectionAllowed: locallySelectable && input.engineCeiling.selectionAllowed,
|
|
proposeAllowed: locallySelectable && input.engineCeiling.proposeAllowed,
|
|
canConfirmExactMinute: locallySelectable
|
|
&& input.separation.sufficient
|
|
&& input.holdout === "passed"
|
|
&& input.confirmationAllowed
|
|
&& input.engineCeiling.confirmationAllowed,
|
|
};
|
|
}
|
|
|
|
/** Dated reverse-inference only. Yearless D9/D10 / nakshatra never occupy this slot (BUG-651). */
|
|
export function isDatedDiscriminatorProbe(
|
|
probe: CandidateDiscriminatorProbe | null | undefined,
|
|
): probe is CandidateDiscriminatorProbe {
|
|
if (!probe) return false;
|
|
if (probe.choiceKind === "varga_style") return false;
|
|
return (probe.year ?? 0) > 0;
|
|
}
|
|
|
|
export function decideRectification(input: DecideRectificationInput): RectificationDecision {
|
|
// D1 is reported on every exit, not just the delivering ones.
|
|
return {
|
|
...decideRectificationInner(input),
|
|
precisionGateMet: input.precisionGateMet ?? null,
|
|
};
|
|
}
|
|
|
|
function decideRectificationInner(input: DecideRectificationInput): RectificationDecision {
|
|
if (input.caseStage === "block_scan") {
|
|
return decideBlockScan(input);
|
|
}
|
|
const separation = evaluateCandidateSeparation(input.candidateScores);
|
|
const probe = isDatedDiscriminatorProbe(input.discriminatorProbe) ? input.discriminatorProbe : null;
|
|
const holdout = input.holdoutValidation ?? "unavailable";
|
|
const userStopped = input.userStopped === true;
|
|
const confirmationAllowed = input.confirmationAllowed === true;
|
|
const range = input.inferenceCredibleRange
|
|
?? rangeFromTimes(separation.credibleRange)
|
|
?? (separation.representativeTime
|
|
? [separation.representativeTime, separation.representativeTime] as const
|
|
: null);
|
|
const stopClass = classifyStop(input, separation);
|
|
const stopReason = stopClass && "reason" in stopClass ? stopClass.reason : null;
|
|
const canDiscriminateDespiteCoverage = Boolean(probe)
|
|
&& !separation.sufficient
|
|
&& input.trainingGateOpen !== false;
|
|
const coverageBlocks = (!input.methodCoverageAll && !canDiscriminateDespiteCoverage)
|
|
|| input.trainingGateOpen === false;
|
|
const capability = deliveryCapability({
|
|
stopClass,
|
|
separation,
|
|
holdout,
|
|
engineCeiling: input.engineCeiling,
|
|
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 deliverRange(input, separation, holdout, range, "user_stopped", rangeDeliveryCapability);
|
|
}
|
|
|
|
if (input.snapshotCurrent === false) {
|
|
if (probe && !userStopped && input.trainingGateOpen !== false) {
|
|
return discriminateOrExhaust(input, separation, holdout, range, probe, rangeDeliveryCapability);
|
|
}
|
|
// New dated evidence makes the snapshot stale. Do not carry exhaustion
|
|
// stop reasons into collect: those would keep the UI in the delivered gap.
|
|
return collect(separation, holdout, range, probe, capability);
|
|
}
|
|
if (
|
|
stopClass?.kind === "exhausted"
|
|
&& stopClass.reason === "tied_first"
|
|
&& input.trainingGateOpen !== false
|
|
&& separation.ranked.length > 0
|
|
&& !probe
|
|
&& input.targetedCollectExhausted !== false
|
|
) {
|
|
if (!mayDeliverOnPrecision(input)) {
|
|
return collect(
|
|
separation,
|
|
holdout,
|
|
range,
|
|
probe,
|
|
waitToNarrowCapability(rangeDeliveryCapability),
|
|
stopClass.reason,
|
|
);
|
|
}
|
|
return deliverRange(
|
|
input,
|
|
separation,
|
|
holdout,
|
|
range,
|
|
"exhausted",
|
|
rangeDeliveryCapability,
|
|
stopClass.reason,
|
|
);
|
|
}
|
|
if (coverageBlocks) {
|
|
const engineOffers = input.engineCeiling.acceptanceAllowed
|
|
|| input.engineCeiling.proposeAllowed;
|
|
const narrowingOpen = stillNeedNarrowing(input);
|
|
if (
|
|
stopClass?.kind !== "keep_collecting"
|
|
&& input.trainingGateOpen !== false
|
|
&& separation.ranked.length > 0
|
|
&& !probe
|
|
&& engineOffers
|
|
&& !narrowingOpen
|
|
) {
|
|
return offerRangeWithoutAdopt(separation, holdout, range, rangeDeliveryCapability);
|
|
}
|
|
return collect(
|
|
separation,
|
|
holdout,
|
|
range,
|
|
probe,
|
|
narrowingOpen ? waitToNarrowCapability(capability) : capability,
|
|
stopReason,
|
|
);
|
|
}
|
|
if (stopClass?.kind === "keep_collecting") {
|
|
return collect(separation, holdout, range, probe, capability, stopClass.reason);
|
|
}
|
|
if (stopClass?.kind === "exhausted" && stopClass.reason === "user_uncertainty_too_high") {
|
|
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
|
|
}
|
|
if (!separation.sufficient) {
|
|
if (probe) {
|
|
return discriminateOrExhaust(input, separation, holdout, range, probe, rangeDeliveryCapability, stopReason);
|
|
}
|
|
if (holdout === "not_started" && !capability.canAdopt) {
|
|
return holdoutValidation(separation, range, capability);
|
|
}
|
|
// coverageBlocks already collected when the training gate is closed.
|
|
// Dated-pool empty is not delivery until refresh and targeted collect are
|
|
// exhausted (BUG-654). D6: the precision gate never short-circuits a
|
|
// collect line. Personality still does not occupy this slot.
|
|
// Omitted flags mean the helper/unit path: do not wait. Production
|
|
// decideFromDossier always passes explicit booleans.
|
|
if (stillNeedNarrowing(input)) {
|
|
return collect(separation, holdout, range, probe, waitToNarrowCapability(capability), stopReason);
|
|
}
|
|
if (stopClass?.kind === "exhausted") {
|
|
if (!mayDeliverOnPrecision(input)) {
|
|
return collect(
|
|
separation,
|
|
holdout,
|
|
range,
|
|
probe,
|
|
waitToNarrowCapability(rangeDeliveryCapability),
|
|
stopReason,
|
|
);
|
|
}
|
|
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
|
|
}
|
|
if (rangeDeliveryCapability.canAdopt && input.methodCoverageAll) {
|
|
if (!mayDeliverOnPrecision(input)) {
|
|
return collect(
|
|
separation,
|
|
holdout,
|
|
range,
|
|
probe,
|
|
waitToNarrowCapability(rangeDeliveryCapability),
|
|
"probe_pool_exhausted",
|
|
);
|
|
}
|
|
return finish("adopt_representative", {
|
|
input,
|
|
separation,
|
|
holdout,
|
|
range,
|
|
probe: null,
|
|
capability: rangeDeliveryCapability,
|
|
stopReason: "probe_pool_exhausted",
|
|
});
|
|
}
|
|
if (!mayDeliverOnPrecision(input)) {
|
|
return collect(
|
|
separation,
|
|
holdout,
|
|
range,
|
|
probe,
|
|
waitToNarrowCapability(rangeDeliveryCapability),
|
|
stopReason,
|
|
);
|
|
}
|
|
return deliverRange(input, separation, holdout, range, "offer", rangeDeliveryCapability);
|
|
}
|
|
if (stopClass?.kind === "exhausted") {
|
|
if (!mayDeliverOnPrecision(input)) {
|
|
return collect(
|
|
separation,
|
|
holdout,
|
|
range,
|
|
probe,
|
|
waitToNarrowCapability(rangeDeliveryCapability),
|
|
stopReason,
|
|
);
|
|
}
|
|
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability, stopClass.reason);
|
|
}
|
|
if (input.accepted) {
|
|
if (shouldHoldForTieBreak(input, separation)) {
|
|
return holdForTieBreak(separation, holdout, range, rangeDeliveryCapability, stopReason);
|
|
}
|
|
return finish(confirmationAllowed ? "awaiting_confirmation" : "adopt_representative", {
|
|
input,
|
|
separation,
|
|
holdout,
|
|
range,
|
|
probe: null,
|
|
capability: rangeDeliveryCapability,
|
|
});
|
|
}
|
|
if (input.datedMethodCollectOpen === true && !input.userStopped) {
|
|
return collect(separation, holdout, range, null, capability, stopReason);
|
|
}
|
|
if (confirmationAllowed) {
|
|
if (shouldHoldForTieBreak(input, separation)) {
|
|
return holdForTieBreak(separation, holdout, range, rangeDeliveryCapability, stopReason);
|
|
}
|
|
return finish("awaiting_confirmation", {
|
|
input,
|
|
separation,
|
|
holdout,
|
|
range,
|
|
probe: null,
|
|
capability: rangeDeliveryCapability,
|
|
});
|
|
}
|
|
if (holdout === "not_started" && !capability.canAdopt) {
|
|
return holdoutValidation(separation, range, capability);
|
|
}
|
|
if (holdout === "failed") {
|
|
if (probe) {
|
|
return discriminateOrExhaust(input, separation, holdout, range, probe, rangeDeliveryCapability);
|
|
}
|
|
return deliverRange(input, separation, holdout, range, "exhausted", rangeDeliveryCapability);
|
|
}
|
|
if (holdout === "unavailable") {
|
|
return offerRangeWithoutAdopt(separation, holdout, range, rangeDeliveryCapability);
|
|
}
|
|
if (shouldHoldForTieBreak(input, separation)) {
|
|
return holdForTieBreak(separation, holdout, range, rangeDeliveryCapability, stopReason);
|
|
}
|
|
return finish("adopt_representative", {
|
|
input,
|
|
separation,
|
|
holdout,
|
|
range,
|
|
probe: null,
|
|
capability: rangeDeliveryCapability,
|
|
});
|
|
}
|
|
|
|
function budgetExhausted(input: DecideRectificationInput): boolean {
|
|
return (input.inferenceRounds ?? 0) >= DEFAULT_MAX_DISCRIMINATION_ROUNDS
|
|
|| (input.effectiveAnswerCount ?? 0) >= EFFECTIVE_ANSWER_SAFETY_CAP
|
|
|| (input.plateauRounds ?? 0) >= RECTIFICATION_POLICY.maxPlateauRounds;
|
|
}
|
|
|
|
function discriminateOrExhaust(
|
|
input: DecideRectificationInput,
|
|
separation: CandidateSeparation,
|
|
holdout: HoldoutValidationStatus,
|
|
range: readonly [string, string] | null,
|
|
probe: CandidateDiscriminatorProbe,
|
|
capability: DeliveryCapability,
|
|
stopReason: EvidenceStopReason | null = null,
|
|
): RectificationDecision {
|
|
if (budgetExhausted(input)) {
|
|
return deliverRange(input, separation, holdout, range, "exhausted", capability, stopReason);
|
|
}
|
|
return discriminate(separation, holdout, range, probe, capability, stopReason);
|
|
}
|
|
|
|
function decideBlockScan(input: DecideRectificationInput): RectificationDecision {
|
|
const closed: DeliveryCapability = {
|
|
canAdopt: false,
|
|
selectionAllowed: false,
|
|
proposeAllowed: false,
|
|
canConfirmExactMinute: false,
|
|
};
|
|
const separation = evaluateCandidateSeparation([]);
|
|
const range = input.inferenceCredibleRange ?? (["00:00", "23:59"] as const);
|
|
if (input.blockScanDeclined === true) {
|
|
return collect(separation, "unavailable", range, null, closed, "insufficient_dated_events");
|
|
}
|
|
if (input.trainingGateOpen === false) {
|
|
const stopReason: EvidenceStopReason = (input.datedDomainCount ?? 0) < MIN_STANDALONE_DATED_DOMAINS
|
|
&& (input.datedEventCount ?? 0) >= MIN_STANDALONE_DATED_EVENTS
|
|
? "insufficient_domains"
|
|
: "insufficient_dated_events";
|
|
return collect(separation, "unavailable", range, null, closed, stopReason);
|
|
}
|
|
return askBlockChoice(separation, range, closed);
|
|
}
|
|
|
|
function askBlockChoice(
|
|
separation: CandidateSeparation,
|
|
range: readonly [string, string] | null,
|
|
capability: DeliveryCapability,
|
|
): RectificationDecision {
|
|
return {
|
|
phase: "event_collection",
|
|
nextAction: "ask_block_choice",
|
|
sessionOutcome: "compare_blocks",
|
|
resultStatus: "insufficient_evidence",
|
|
canOfferRange: false,
|
|
...capability,
|
|
precisionStage: "collect_events",
|
|
activeFocusPolicy: "keep",
|
|
completionStatus: null,
|
|
validated: false,
|
|
credibleRange: range,
|
|
representativeTime: null,
|
|
separation,
|
|
probe: null,
|
|
holdoutValidation: "unavailable",
|
|
droppedProbes: [],
|
|
};
|
|
}
|
|
|
|
function askWindowWiden(
|
|
separation: CandidateSeparation,
|
|
range: readonly [string, string] | null,
|
|
): RectificationDecision {
|
|
const closed: DeliveryCapability = {
|
|
canAdopt: false,
|
|
selectionAllowed: false,
|
|
proposeAllowed: false,
|
|
canConfirmExactMinute: false,
|
|
};
|
|
return {
|
|
phase: "discrimination",
|
|
nextAction: "ask_window_widen",
|
|
sessionOutcome: "widen_window",
|
|
resultStatus: "discriminating",
|
|
canOfferRange: false,
|
|
...closed,
|
|
precisionStage: "theme_refine",
|
|
activeFocusPolicy: "keep",
|
|
completionStatus: null,
|
|
validated: false,
|
|
credibleRange: range,
|
|
representativeTime: separation.representativeTime,
|
|
separation,
|
|
probe: null,
|
|
holdoutValidation: "unavailable",
|
|
droppedProbes: [],
|
|
};
|
|
}
|
|
|
|
function stillNeedNarrowing(input: DecideRectificationInput): boolean {
|
|
return input.refreshExhausted === false || input.targetedCollectExhausted === false;
|
|
}
|
|
|
|
/**
|
|
* D2 + D6. The precision gate only holds the card back **while there is still
|
|
* a question to ask**: delivery is `userStopped || everyLineAsked`, where
|
|
* `everyLineAsked` is the skip retry, the engine refresh, and the targeted
|
|
* seven. Product 2026-09-26 D2: guided boundary windows (at most two per
|
|
* Case, `GUIDED_WINDOW_CASE_LIMIT`) are asked when they come up, but no longer hold the card — once
|
|
* the targeted seven and their one re-ask are asked the card goes out with
|
|
* the gate unmet. Once all of them are
|
|
* asked out there is nothing left to collect, so the reader gets the card
|
|
* under the existing rules whether or not D1 is met — D2 says the card, the
|
|
* adopt button, and the three body lines do not change, and carry no
|
|
* "gate unmet" marker. `precisionGateMet` is still computed and published on
|
|
* the decision (see `RectificationDecision.precisionGateMet`), it just no
|
|
* longer decides the timing on its own: what changed this round is that there
|
|
* are more question sources, so "every line asked" happens later.
|
|
*
|
|
* Omitting both gate flags means the helper/unit path: the gate does not
|
|
* participate at all and the pre-gate (317e9f18) behaviour stands.
|
|
*/
|
|
export function mayDeliverOnPrecision(input: DecideRectificationInput): boolean {
|
|
if (input.userStopped === true) return true;
|
|
if (input.precisionGateMet === undefined && input.guidedCollectExhausted === undefined) {
|
|
return true;
|
|
}
|
|
return input.guidedCollectExhausted === true
|
|
&& input.refreshExhausted !== false
|
|
&& input.targetedCollectExhausted !== false;
|
|
}
|
|
|
|
export function shouldHoldForTieBreak(
|
|
input: Pick<
|
|
DecideRectificationInput,
|
|
"pendingTieBreak" | "tieBreakFollowupReady" | "tieBreakAlreadyHeld" | "userStopped"
|
|
>,
|
|
separation: CandidateSeparation,
|
|
kind?: "user_stopped" | "offer" | "exhausted",
|
|
): boolean {
|
|
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;
|
|
}
|
|
|
|
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);
|
|
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,
|
|
canAdopt: false,
|
|
selectionAllowed: false,
|
|
proposeAllowed: false,
|
|
canConfirmExactMinute: false,
|
|
};
|
|
}
|
|
|
|
function collect(
|
|
separation: CandidateSeparation,
|
|
holdout: HoldoutValidationStatus,
|
|
range: readonly [string, string] | null,
|
|
probe: CandidateDiscriminatorProbe | null,
|
|
capability: DeliveryCapability,
|
|
stopReason: EvidenceStopReason | null = null,
|
|
): RectificationDecision {
|
|
return {
|
|
phase: "event_collection",
|
|
nextAction: "ask_fact_collection",
|
|
sessionOutcome: "collect_evidence",
|
|
resultStatus: "insufficient_evidence",
|
|
canOfferRange: false,
|
|
...capability,
|
|
precisionStage: "collect_events",
|
|
activeFocusPolicy: "keep",
|
|
completionStatus: null,
|
|
validated: false,
|
|
credibleRange: range,
|
|
representativeTime: separation.representativeTime,
|
|
separation,
|
|
probe,
|
|
holdoutValidation: holdout,
|
|
droppedProbes: [],
|
|
stopReason,
|
|
};
|
|
}
|
|
|
|
function offerRangeWithoutAdopt(
|
|
separation: CandidateSeparation,
|
|
holdout: HoldoutValidationStatus,
|
|
range: readonly [string, string] | null,
|
|
capability: DeliveryCapability,
|
|
): RectificationDecision {
|
|
return {
|
|
phase: "discrimination",
|
|
nextAction: "offer_provisional_range",
|
|
sessionOutcome: capability.canAdopt ? "adopt_representative" : "provisional_range",
|
|
resultStatus: "insufficient_evidence",
|
|
canOfferRange: true,
|
|
...capability,
|
|
precisionStage: "theme_refine",
|
|
activeFocusPolicy: "close",
|
|
completionStatus: null,
|
|
validated: false,
|
|
credibleRange: range,
|
|
representativeTime: separation.representativeTime,
|
|
separation,
|
|
probe: null,
|
|
holdoutValidation: holdout,
|
|
droppedProbes: [],
|
|
};
|
|
}
|
|
|
|
export function isNonConvergingRangeOffer(decision: Pick<
|
|
RectificationDecision,
|
|
"canOfferRange" | "canAdopt" | "canConfirmExactMinute" | "nextAction"
|
|
>): boolean {
|
|
return decision.canOfferRange
|
|
&& !decision.canAdopt
|
|
&& !decision.canConfirmExactMinute
|
|
&& decision.nextAction === "offer_provisional_range";
|
|
}
|
|
|
|
function discriminate(
|
|
separation: CandidateSeparation,
|
|
holdout: HoldoutValidationStatus,
|
|
range: readonly [string, string] | null,
|
|
probe: CandidateDiscriminatorProbe,
|
|
capability: DeliveryCapability,
|
|
stopReason: EvidenceStopReason | null = null,
|
|
): RectificationDecision {
|
|
return {
|
|
phase: "discrimination",
|
|
nextAction: "ask_candidate_discriminator",
|
|
sessionOutcome: "discriminate_candidates",
|
|
resultStatus: "discriminating",
|
|
canOfferRange: false,
|
|
...capability,
|
|
precisionStage: "theme_refine",
|
|
activeFocusPolicy: "keep",
|
|
completionStatus: null,
|
|
validated: false,
|
|
credibleRange: range,
|
|
representativeTime: separation.representativeTime,
|
|
separation,
|
|
probe,
|
|
holdoutValidation: holdout,
|
|
droppedProbes: [],
|
|
...(stopReason ? { stopReason } : {}),
|
|
};
|
|
}
|
|
|
|
function holdoutValidation(
|
|
separation: CandidateSeparation,
|
|
range: readonly [string, string] | null,
|
|
capability: DeliveryCapability,
|
|
): RectificationDecision {
|
|
return {
|
|
phase: "holdout_validation",
|
|
nextAction: "ask_holdout_validation",
|
|
sessionOutcome: "validate_holdout",
|
|
resultStatus: "discriminating",
|
|
canOfferRange: false,
|
|
...capability,
|
|
precisionStage: "theme_refine",
|
|
activeFocusPolicy: "keep",
|
|
completionStatus: null,
|
|
validated: false,
|
|
credibleRange: range,
|
|
representativeTime: separation.representativeTime,
|
|
separation,
|
|
probe: null,
|
|
holdoutValidation: "not_started",
|
|
droppedProbes: [],
|
|
};
|
|
}
|
|
|
|
function completeWithRange(
|
|
separation: CandidateSeparation,
|
|
holdout: HoldoutValidationStatus,
|
|
range: readonly [string, string] | null,
|
|
kind: "user_stopped" | "offer" | "exhausted",
|
|
capability: DeliveryCapability,
|
|
stopReason: EvidenceStopReason | null = null,
|
|
): RectificationDecision {
|
|
const userStopped = kind === "user_stopped";
|
|
const terminal = kind !== "offer";
|
|
const nextAction = terminal ? "complete_with_range" : "offer_provisional_range";
|
|
const sessionOutcome = capability.canAdopt
|
|
? "adopt_representative"
|
|
: userStopped
|
|
? "provisional_range_user_stopped"
|
|
: terminal
|
|
? "completed_with_range"
|
|
: "provisional_range";
|
|
return {
|
|
phase: terminal ? "completed" : "discrimination",
|
|
nextAction,
|
|
sessionOutcome,
|
|
resultStatus: "completed_with_range",
|
|
canOfferRange: true,
|
|
...capability,
|
|
precisionStage: "ready_to_adopt",
|
|
activeFocusPolicy: "close",
|
|
completionStatus: userStopped ? "provisional_range_user_stopped" : null,
|
|
validated: false,
|
|
credibleRange: range,
|
|
representativeTime: separation.representativeTime,
|
|
separation,
|
|
probe: null,
|
|
holdoutValidation: holdout,
|
|
droppedProbes: [],
|
|
stopReason,
|
|
terminationCopy: terminal ? RECTIFICATION_TERMINATION_COPY : null,
|
|
};
|
|
}
|
|
|
|
function finish(
|
|
fallbackOutcome: "adopt_representative" | "awaiting_confirmation" | "validated_range" | "exact_minute_confirmed",
|
|
input: {
|
|
input: DecideRectificationInput;
|
|
separation: CandidateSeparation;
|
|
holdout: HoldoutValidationStatus;
|
|
range: readonly [string, string] | null;
|
|
probe: CandidateDiscriminatorProbe | null;
|
|
capability: DeliveryCapability;
|
|
stopReason?: EvidenceStopReason | null;
|
|
},
|
|
): RectificationDecision {
|
|
const completionStatus: CompletionStatus | null = input.capability.canConfirmExactMinute && input.input.accepted
|
|
? "exact_minute_confirmed"
|
|
: input.holdout === "passed"
|
|
? "validated_range"
|
|
: input.input.userStopped === true
|
|
? "provisional_range_user_stopped"
|
|
: null;
|
|
const kind = input.capability.canConfirmExactMinute
|
|
? (input.input.accepted ? "exact_minute_confirmed" : "awaiting_confirmation")
|
|
: input.holdout === "passed"
|
|
? "validated_range"
|
|
: fallbackOutcome;
|
|
const sessionOutcome = !input.capability.canAdopt && kind === "adopt_representative"
|
|
? "completed_with_range"
|
|
: kind;
|
|
return {
|
|
phase: "completed",
|
|
nextAction: sessionOutcome === "completed_with_range" ? "complete_with_range" : "ready_to_adopt",
|
|
sessionOutcome,
|
|
resultStatus: kind === "awaiting_confirmation" || kind === "exact_minute_confirmed"
|
|
? "converged"
|
|
: "completed_with_range",
|
|
canOfferRange: true,
|
|
...input.capability,
|
|
precisionStage: "ready_to_adopt",
|
|
activeFocusPolicy: "close",
|
|
completionStatus,
|
|
validated: completionStatus === "validated_range" || completionStatus === "exact_minute_confirmed",
|
|
credibleRange: input.range,
|
|
representativeTime: input.separation.representativeTime,
|
|
separation: input.separation,
|
|
probe: input.probe,
|
|
holdoutValidation: input.holdout,
|
|
droppedProbes: [],
|
|
...(input.stopReason ? { stopReason: input.stopReason } : {}),
|
|
};
|
|
}
|
|
|
|
export function sessionKindFromNextAction(
|
|
type: RectificationNextActionType,
|
|
): DecisionSessionOutcome {
|
|
if (type === "ask_fact_collection") return "collect_evidence";
|
|
if (type === "ask_block_choice") return "compare_blocks";
|
|
if (type === "ask_window_widen") return "widen_window";
|
|
if (type === "ask_candidate_discriminator") return "discriminate_candidates";
|
|
if (type === "ask_holdout_validation") return "validate_holdout";
|
|
if (type === "offer_provisional_range") return "provisional_range";
|
|
if (type === "complete_with_range") return "completed_with_range";
|
|
return "adopt_representative";
|
|
}
|
|
|
|
export function offerSessionKinds(): readonly string[] {
|
|
return [
|
|
"adopt_representative",
|
|
"awaiting_confirmation",
|
|
"provisional_range",
|
|
"provisional_range_user_stopped",
|
|
"completed_with_range",
|
|
"validated_range",
|
|
"exact_minute_confirmed",
|
|
];
|
|
}
|
|
|
|
export function publicNextAction(decision: RectificationDecision): Readonly<{
|
|
type: RectificationNextActionType;
|
|
session_outcome: DecisionSessionOutcome;
|
|
completion_status: CompletionStatus | null;
|
|
validated: boolean;
|
|
can_offer_range: boolean;
|
|
can_adopt: boolean;
|
|
can_confirm_exact_minute: boolean;
|
|
selection_allowed: boolean;
|
|
propose_allowed: boolean;
|
|
precision_stage: DerivedPrecisionStage;
|
|
representative_time: string | null;
|
|
credible_range: readonly [string, string] | null;
|
|
stop_reason: EvidenceStopReason | null;
|
|
precision_gate_met: boolean | null;
|
|
birth_time_source?: ProvenanceSource;
|
|
}> {
|
|
return {
|
|
type: decision.nextAction,
|
|
session_outcome: decision.sessionOutcome,
|
|
completion_status: decision.completionStatus,
|
|
validated: decision.validated,
|
|
can_offer_range: decision.canOfferRange,
|
|
can_adopt: publicCanAdopt(decision),
|
|
can_confirm_exact_minute: decision.canConfirmExactMinute,
|
|
selection_allowed: decision.selectionAllowed,
|
|
propose_allowed: decision.proposeAllowed,
|
|
precision_stage: decision.precisionStage,
|
|
representative_time: decision.representativeTime,
|
|
credible_range: decision.credibleRange,
|
|
stop_reason: decision.stopReason ?? null,
|
|
precision_gate_met: decision.precisionGateMet ?? null,
|
|
birth_time_source: normalizeBirthTimeSource(decision.birthTimeSource),
|
|
};
|
|
}
|
|
|
|
export function publicDecisionFields(
|
|
decision: RectificationDecision,
|
|
): ReturnType<typeof publicNextAction> {
|
|
return publicNextAction(decision);
|
|
}
|