/** * 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>; 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 = 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 = 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): 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, 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 { return publicNextAction(decision); }