fix(rectification): refresh remaining probes and targeted collect before delivering range (BUG-653/654)
Independent Staging Quality Gate / validate (push) Successful in 13m54s
Independent Staging Quality Gate / publish (push) Successful in 10m50s

Dated-choice exhaustion is not convergence. Refresh probes from remaining
active candidates, then ask a targeted collect, then deliver. Skill 10.0.24.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-11 18:28:39 +08:00
co-authored by Cursor
parent 9941c34d4b
commit 6c9a089620
58 changed files with 2109 additions and 295 deletions
@@ -176,6 +176,8 @@ function publicLatestResult(
credibleRange: projected.credible_range ?? decision.credibleRange,
credible_range: projected.credible_range ?? decision.credibleRange,
skill_verification_report: toolProjection.skill_verification_report,
evidence: dossier.evidence,
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
});
const withDelivery = {
...projected,
+1
View File
@@ -3329,6 +3329,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.rectification-range-delivery__likelihood,
.rectification-range-delivery__block p,
.rectification-range-delivery__more,
.rectification-range-delivery__narrow,
.rectification-range-delivery__shared,
.rectification-range-delivery__column > p {
margin: 0;
@@ -32,7 +32,7 @@ export function RectificationRangeDelivery({
const columns = delivery?.columns ?? [];
const title = range
? eventCount > 0
? `${RECTIFICATION_USER_COPY.rangeDeliveryTitle} ${range[0]}–${range[1]} · ${rangeDeliveryEventCopy(eventCount)}`
? `${RECTIFICATION_USER_COPY.rangeDeliveryTitle} ${range[0]}–${range[1]}(${rangeDeliveryEventCopy(eventCount)})`
: `${RECTIFICATION_USER_COPY.rangeDeliveryTitle} ${range[0]}–${range[1]}`
: RECTIFICATION_USER_COPY.rangeDeliveryTitle;
const markdown = delivery?.verification_markdown
@@ -44,6 +44,9 @@ export function RectificationRangeDelivery({
<div className="rectification-candidates-heading">
<strong>{title}</strong>
</div>
{delivery?.narrow_hint ? (
<p className="rectification-range-delivery__narrow">{delivery.narrow_hint}</p>
) : null}
{sharedTraits.length > 0 ? (
<ul className="rectification-range-delivery__shared">
{sharedTraits.map((line) => (
@@ -190,6 +190,10 @@ export function buildInferenceState(input: {
representative_time: top?.time ?? null,
credible_range: unionStillValidRange(candidates),
holdout_passed: holdoutPassed,
...(typeof previous?.refresh_count === "number" ? { refresh_count: previous.refresh_count } : {}),
...(typeof previous?.refresh_answer_count === "number"
? { refresh_answer_count: previous.refresh_answer_count }
: {}),
...(transitions ? { transitions } : {}),
};
const decision = evaluateConvergence({ ...draft, holdout_passed: holdoutPassed });
@@ -141,7 +141,7 @@ export function publicCanAdopt(decision: Pick<RectificationDecision, "canAdopt"
}
/**
* Delivery copy (`这次给出的范围` / representative-candidate close) is only
* 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.
*/
@@ -210,6 +210,8 @@ export type DecideRectificationInput = Readonly<{
caseStage?: "minute" | "block_scan";
blockScanDeclined?: boolean;
windowWidenSuggested?: boolean;
refreshExhausted?: boolean;
targetedCollectExhausted?: boolean;
}>;
function classifyStop(
@@ -318,16 +320,25 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
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, capability);
}
return collect(separation, holdout, range, probe, capability, stopReason);
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);
@@ -343,7 +354,13 @@ export function decideRectification(input: DecideRectificationInput): Rectificat
return holdoutValidation(separation, range, capability);
}
// coverageBlocks already collected when the training gate is closed.
// An open leftover collect must not block S3 delivery (BUG-651).
// Dated-pool empty is not delivery until refresh and targeted collect are
// exhausted (BUG-654). 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") {
return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason);
}
@@ -506,6 +523,20 @@ function askWindowWiden(
};
}
function stillNeedNarrowing(input: DecideRectificationInput): boolean {
return input.refreshExhausted === false || input.targetedCollectExhausted === false;
}
function waitToNarrowCapability(capability: DeliveryCapability): DeliveryCapability {
return {
...capability,
canAdopt: false,
selectionAllowed: false,
proposeAllowed: false,
canConfirmExactMinute: false,
};
}
function collect(
separation: CandidateSeparation,
holdout: HoldoutValidationStatus,
@@ -143,6 +143,8 @@ export type InferenceState = Readonly<{
representative_time: string | null;
credible_range: readonly [string, string] | null;
holdout_passed?: boolean | null;
refresh_count?: number;
refresh_answer_count?: number;
transitions?: readonly Readonly<{
layer: string;
at: string;
@@ -139,7 +139,7 @@ export const RECTIFICATION_USER_COPY = {
compareFailedRetry: "候选比较这次没跑成,下一句话时会自动再试。",
lastSuccessfulCompareRange: "这是按上一次成功比较给出的范围。",
deferredCareerWindow: "下一次事业变动的预测窗口留在采用后的核对阶段。",
rangeDeliveryTitle: "这次给出的范围",
rangeDeliveryTitle: "目前范围",
rangeDeliveryMoreLikeThis: "更像这个",
rangeDeliveryRelativeLikelihood: "相对可能性",
rangeDeliveryNoWindow: "未来一年没有明显的时段",
@@ -417,7 +417,7 @@ export function nonConvergingRangeNarration(
export function deliveryTurnNarration(input: RangeNarrationInput = {}): string {
const rangeText = formatClockRange(input.credibleRange ?? null);
const sentence1 = rangeText
? `这次给出的范围 ${rangeText}。`
? `目前范围 ${rangeText}。`
: "当前几个候选还分不开。";
const count = typeof input.eventCount === "number" && input.eventCount >= 0 ? input.eventCount : 0;
const percent = typeof input.fitPercent === "number" && Number.isFinite(input.fitPercent)
@@ -25,7 +25,7 @@ import {
RECTIFICATION_USER_COPY,
withLastSuccessfulCompareNotice,
} from "../user-copy.ts";
import { moreCollectHint, preciseGapNarration } from "./collection-question-pool.ts";
import { moreCollectHint, preciseGapNarration, rangeNarrowHint } from "./collection-question-pool.ts";
import {
applyChoiceWithoutEvidence,
previousInferenceFromReceipt,
@@ -86,6 +86,7 @@ import {
planWithDateReliability,
spokenCollectFallbackFollowup,
spokenFollowupForUser,
targetedCollectFollowup,
ledgerHasConfirmedDatedEvent,
type MethodCoverage,
type MethodFollowup,
@@ -97,6 +98,7 @@ import { mutateCaseForBlockChoice, mutateCaseForWidenWindow, rescoreStaleMinuteS
import type { SessionOutcomeKind } from "./confirmation-gate";
import { prospectiveWindowsNarration, refinementFromDecisionReceipt } from "./refinement-packet";
import { projectCurrentQuestion } from "./turn-decision";
import { refreshDatedDiscriminatorPoolIfNeeded } from "./refresh-discriminator-probes.ts";
const EXHAUSTION_DELIVERY_ACTIONS = new Set([
"offer_provisional_range",
@@ -127,7 +129,8 @@ export function looksLikeTerminalNoteNarration(text: string | null | undefined):
|| trimmed.includes("范围还能再收一截")
|| /还差(?: \d+ 件)?带月份的经历/.test(trimmed)
|| trimmed.includes("两件事的日期还没对清")
|| trimmed.includes("这次给出的范围");
|| trimmed.includes("这次给出的范围")
|| trimmed.includes("目前范围");
}
function terminalNoteHostPresent(input: {
@@ -308,9 +311,12 @@ function adoptHostNarration(input: {
eventCount: datedEventCount(inference),
fitPercent: fit?.percent ?? null,
});
const hint = moreCollectHint(
const catalog = rectificationFollowupCatalog(input.dossier.latestResult, input.dossier.evidence);
const hint = rangeNarrowHint(
catalog.remainingLayers,
input.dossier.evidence,
input.dossier.conversationSummary.declinedSkippedTopics,
catalog.remainingSplitTimes,
);
if (!hint || delivered.includes(hint)) return delivered;
return `${delivered} ${hint}`.replace(/\s+/g, " ").trim();
@@ -790,6 +796,7 @@ export async function persistNextInterviewAfterChoice(input: {
birthDate?: string | null;
askedTurnId?: string | null;
narrateAdopt?: AdoptNarrationWriter;
skipRefresh?: boolean;
}): Promise<{
hostNarration: string;
choiceReady: boolean;
@@ -799,59 +806,70 @@ export async function persistNextInterviewAfterChoice(input: {
followup?: MethodFollowup | null;
terminalNote?: boolean;
}> {
const latest = input.dossier.latestResult
? {
...input.dossier.latestResult,
decisionReceipt: {
...(input.dossier.latestResult.decisionReceipt ?? {}),
...(input.decisionState ? { inference_state: input.decisionState } : {}),
},
}
: {
decisionReceipt: input.decisionState ? { inference_state: input.decisionState } : null,
};
const birthDate = input.birthDate ?? null;
const liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState);
const decision = input.decision ?? decideAfterInferenceChange({
dossier: input.dossier,
state: input.decisionState,
let liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState);
let decisionState = input.decisionState;
let decision = input.decision ?? decideAfterInferenceChange({
dossier: liveDossier,
state: decisionState,
userStopped: false,
birthDate,
});
const catalog = rectificationFollowupCatalog(latest, input.dossier.evidence);
const sessionOutcome = typeof input.nextAction.session_outcome === "string"
? input.nextAction.session_outcome as SessionOutcomeKind
if (!input.skipRefresh) {
const refreshed = await refreshDatedDiscriminatorPoolIfNeeded({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: liveDossier,
state: decisionState,
hasDatedProbe: Boolean(decision.probe),
});
liveDossier = refreshed.dossier;
decisionState = refreshed.state;
if (refreshed.refreshed) {
decision = decideAfterInferenceChange({
dossier: liveDossier,
state: decisionState,
userStopped: false,
birthDate,
});
}
}
const catalog = rectificationFollowupCatalog(liveDossier.latestResult, liveDossier.evidence);
const nextAction = publicNextAction(decision);
const sessionOutcome = typeof nextAction.session_outcome === "string"
? nextAction.session_outcome as SessionOutcomeKind
: "collect_evidence";
const plan = planWithDateReliability(buildMethodFollowupPlan({
evidence: input.dossier.evidence,
evidence: liveDossier.evidence,
activeFocus: null,
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: input.dossier.conversationSummary.declinedSkippedTopics,
declinedTopics: liveDossier.conversationSummary.declinedSkippedTopics,
closedCollectFocuses: liveDossier.conversationSummary.declinedSkippedTopics,
sessionOutcome,
...catalog,
birthDate,
accepted: Boolean(input.dossier.case.acceptedTime),
candidatesSeparated: input.nextAction.type !== "ask_candidate_discriminator"
&& input.nextAction.type !== "ask_holdout_validation",
accepted: Boolean(liveDossier.case.acceptedTime),
candidatesSeparated: nextAction.type !== "ask_candidate_discriminator"
&& nextAction.type !== "ask_holdout_validation",
...followupCaseArgs({
stage: input.dossier.case.stage,
blockScan: input.dossier.case.blockScan,
reportedBirthTime: input.dossier.case.reportedBirthTime,
candidateRange: input.dossier.case.candidateRange,
stage: liveDossier.case.stage,
blockScan: liveDossier.case.blockScan,
reportedBirthTime: liveDossier.case.reportedBirthTime,
candidateRange: liveDossier.case.candidateRange,
}),
}), input.dossier.evidence, input.askedTurnId);
}), liveDossier.evidence, input.askedTurnId);
const followup = interviewToPersist(plan);
if (shouldSkipFollowupPersist({
canAdopt: input.nextAction.can_adopt,
nextAction: input.nextAction.type,
canAdopt: nextAction.can_adopt,
nextAction: nextAction.type,
followup,
methods: plan.methods,
accepted: Boolean(input.dossier.case.acceptedTime),
accepted: Boolean(liveDossier.case.acceptedTime),
stopReason: decision.stopReason ?? null,
sessionOutcome: input.nextAction.session_outcome,
evidence: input.dossier.evidence,
declinedTopics: input.dossier.conversationSummary.declinedSkippedTopics,
}) && deliveryNarrationAllowed(decision, input.nextAction.type)) {
sessionOutcome: nextAction.session_outcome,
evidence: liveDossier.evidence,
declinedTopics: liveDossier.conversationSummary.declinedSkippedTopics,
}) && deliveryNarrationAllowed(decision, nextAction.type)) {
const facts = adoptDeliveryFacts(decision, liveDossier);
const fallback = adoptHostNarration({
dossier: liveDossier,
@@ -882,7 +900,7 @@ export async function persistNextInterviewAfterChoice(input: {
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
decisionReceipt: latest.decisionReceipt,
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
followup,
askedTurnId: input.askedTurnId ?? null,
});
@@ -914,7 +932,7 @@ export async function persistNextInterviewAfterChoice(input: {
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
decisionReceipt: latest.decisionReceipt,
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
followup: {
...spokenFollowup,
user_prompt_hint: spoken,
@@ -947,7 +965,7 @@ export async function persistNextInterviewAfterChoice(input: {
caseId: input.caseId,
dossier: liveDossier,
decision,
decisionReceipt: latest.decisionReceipt,
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
}
@@ -965,7 +983,7 @@ export async function persistNextInterviewAfterChoice(input: {
caseId: input.caseId,
dossier: liveDossier,
decision,
decisionReceipt: latest.decisionReceipt,
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
}
@@ -984,7 +1002,7 @@ export async function persistNextInterviewAfterChoice(input: {
caseId: input.caseId,
dossier: liveDossier,
decision,
decisionReceipt: latest.decisionReceipt,
decisionReceipt: liveDossier.latestResult?.decisionReceipt,
askedTurnId: input.askedTurnId ?? null,
});
}
@@ -1297,10 +1315,36 @@ export async function persistNextInterviewIfIdle(input: {
} catch {
birthDate = null;
}
const decision = decideFromDossier(dossier, {
let decision = decideFromDossier(dossier, {
birthDate,
snapshotCurrent: rescored.snapshotCurrent,
});
const refreshed = await refreshDatedDiscriminatorPoolIfNeeded({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier,
state: previousInferenceFromReceipt(dossier.latestResult?.decisionReceipt ?? null),
userStopped: input.userStopped,
hasDatedProbe: Boolean(decision.probe),
});
if (refreshed.refreshed) {
const latest = dossier.latestResult;
const overlay = refreshed.dossier.latestResult;
dossier = {
...dossier,
latestResult: latest && overlay
? {
...latest,
decisionReceipt: overlay.decisionReceipt ?? latest.decisionReceipt ?? null,
}
: latest,
};
decision = decideFromDossier(dossier, {
birthDate,
snapshotCurrent: rescored.snapshotCurrent,
});
}
const catalog = rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const plan = buildMethodFollowupPlan({
evidence: dossier.evidence,
@@ -1416,6 +1460,7 @@ export async function persistNextInterviewIfIdle(input: {
birthDate,
askedTurnId: input.askedTurnId ?? null,
narrateAdopt: input.narrateAdopt,
skipRefresh: true,
});
return finishIdle({
persisted: Boolean(nextInterview.hostNarration) || nextInterview.choiceReady,
@@ -1452,7 +1497,12 @@ async function persistExhaustionCollect(input: {
declinedTopics: dossier.conversationSummary.declinedSkippedTopics,
answeredProbes: catalog.answeredProbes,
eventProbes: catalog.eventProbes,
});
}) ?? targetedCollectFollowup(
catalog.remainingLayers,
dossier.evidence,
dossier.conversationSummary.declinedSkippedTopics,
catalog.remainingSplitTimes,
);
const trainingGate = trainingScoreableGate(dossier.evidence);
const ceiling = engineCapabilityCeilingFromReceipt(receipt);
const inference = previousInferenceFromReceipt(receipt);
@@ -1592,9 +1642,11 @@ async function persistExhaustionCollect(input: {
terminalNote: true,
};
}
const hint = moreCollectHint(
const hint = rangeNarrowHint(
catalog.remainingLayers,
dossier.evidence,
dossier.conversationSummary.declinedSkippedTopics,
catalog.remainingSplitTimes,
);
const range = nonConvergingRangeNarration({
credibleRange: decision.credibleRange ?? input.decision.credibleRange,
@@ -89,4 +89,4 @@ export function evidenceWritesAllowed(
export const MAX_RESUMABLE_CASES_PER_USER = 1;
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
export const RECTIFICATION_SKILL_VERSION = "10.0.23";
export const RECTIFICATION_SKILL_VERSION = "10.0.24";
@@ -18,7 +18,7 @@ export const COLLECT_KIND_ORDER = [
export type CollectKind = (typeof COLLECT_KIND_ORDER)[number];
export type CollectionPoolKind = "invite" | "anchor" | "generic";
export type CollectionPoolKind = "invite" | "anchor" | "generic" | "targeted";
export type CollectionEvidence = Readonly<{
status: string;
@@ -40,6 +40,7 @@ export type CollectionPoolItem = Readonly<{
domain: string;
targetKind: string | null;
year: number | null;
examples?: readonly string[];
}>;
const KIND_EXAMPLES: Readonly<Record<CollectKind, string>> = {
@@ -451,6 +452,186 @@ export function moreCollectHint(
return `如果还记得${exampleText},范围还能再收一截。`;
}
export const REMAINING_LAYER_DOMAIN: Readonly<Record<string, CollectKind>> = {
d9: "relationship",
d10: "career",
d4: "relocation",
d5: "education",
d24: "education",
d7: "family",
d12: "family",
d2: "finance",
d11: "finance",
d30: "health_pressure",
};
const TARGETED_EXAMPLES: Readonly<Record<CollectKind, readonly [string, string]>> = {
education: ["哪年升学或毕业", "哪年考试发挥明显变过"],
career: ["哪年换工作", "哪年岗位性质变过"],
relocation: ["哪年搬家", "哪年换城市或出国"],
relationship: ["哪年结婚或订婚", "哪年确定长期关系"],
family: ["家里哪年添丁", "哪年长辈住院"],
finance: ["哪年收入明显变过", "哪年有过大笔进出"],
health_pressure: ["哪年住院或手术", "哪年身体明显垮过一截"],
};
const SCAN_LAYER_FLAGS: ReadonlyArray<readonly [string, string]> = [
["d9", "d9_candidates_differ"],
["d10", "d10_candidates_differ"],
["d4", "d4_candidates_differ"],
["d5", "d5_candidates_differ"],
["d24", "d24_candidates_differ"],
["d7", "d7_candidates_differ"],
["d12", "d12_candidates_differ"],
["d2", "d2_candidates_differ"],
["d11", "d11_candidates_differ"],
["d30", "d30_candidates_differ"],
];
const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
function clockValue(value: string | null | undefined): string | null {
const clock = (value ?? "").slice(0, 5);
return CLOCK.test(clock) ? clock : null;
}
export function remainingSplitLayers(input: {
transitions?: readonly Readonly<{ layer?: string; at?: string }>[];
scanFlags?: Readonly<Record<string, unknown>> | null;
activeTimes?: readonly string[];
}): string[] {
const active = [...new Set((input.activeTimes ?? []).map((item) => clockValue(item)).filter((item): item is string => Boolean(item)))].sort();
const fromTransitions: string[] = [];
for (const row of input.transitions ?? []) {
const layer = typeof row.layer === "string" ? row.layer.trim().toLowerCase() : "";
if (!layer || !(layer in REMAINING_LAYER_DOMAIN)) continue;
const at = clockValue(row.at);
if (active.length >= 2 && at && (at < active[0] || at > active[active.length - 1])) continue;
if (!fromTransitions.includes(layer)) fromTransitions.push(layer);
}
if (fromTransitions.length > 0) return fromTransitions;
const flags = input.scanFlags ?? {};
const fromScan: string[] = [];
for (const [layer, flag] of SCAN_LAYER_FLAGS) {
if (flags[flag] === true && !fromScan.includes(layer)) fromScan.push(layer);
}
return fromScan;
}
export function remainingSplitTimes(
activeTimes: readonly string[] = [],
): readonly [string, string] | null {
const clocks = [...new Set(activeTimes.map((item) => clockValue(item)).filter((item): item is string => Boolean(item)))].sort();
if (clocks.length < 2) return null;
return [clocks[0], clocks[clocks.length - 1]];
}
export function isTargetedCollectDeclined(
topics: readonly CollectionTopic[] = [],
): boolean {
return topics.some((topic) => {
const status = topicStatus(topic);
if (status !== "declined" && status !== "skipped") return false;
const questionId = topicQuestionId(topic);
const kind = topicKind(topic);
const domain = topicDomain(topic);
return questionId.startsWith("collect:targeted:")
|| kind.startsWith("targeted:")
|| domain === "targeted";
});
}
function collectDeclinedKinds(topics: readonly CollectionTopic[]): ReadonlySet<CollectKind> {
const declined = new Set<CollectKind>();
for (const topic of topics) {
const status = topicStatus(topic);
if (status !== "declined" && status !== "skipped") continue;
const intent = typeof topic.intent === "string" ? topic.intent : "";
const questionId = topicQuestionId(topic);
const collectIntent = intent === "collect_method_evidence"
|| questionId.startsWith("collect:");
if (!collectIntent) continue;
if (questionId.startsWith("collect:invite:")) continue;
if (questionId.startsWith("collect:other:")) continue;
const kind = normalizeCollectKind(topicDomain(topic))
?? (questionId.startsWith("collect:generic:")
? normalizeCollectKind(questionId.split(":")[2] ?? "")
: null);
if (kind) declined.add(kind);
}
return declined;
}
function remainingTargetedDomains(
layers: readonly string[],
evidence: readonly CollectionEvidence[],
declined: ReadonlySet<CollectKind>,
): CollectKind[] {
const covered = coveredCollectKinds(evidence);
const domains: CollectKind[] = [];
for (const layer of layers) {
const domain = REMAINING_LAYER_DOMAIN[layer];
if (!domain || declined.has(domain) || covered.has(domain)) continue;
if (!domains.includes(domain)) domains.push(domain);
}
return domains;
}
export function targetedCollectPool(
remainingLayers: readonly string[],
evidence: readonly CollectionEvidence[],
declinedTopics: readonly CollectionTopic[] = [],
splitTimes?: readonly [string, string] | null,
): CollectionPoolItem[] {
if (isTargetedCollectDeclined(declinedTopics)) return [];
const declined = collectDeclinedKinds(declinedTopics);
const domains = remainingTargetedDomains(remainingLayers, evidence, declined);
if (domains.length === 0) return [];
const examples = domains.length === 1
? [...TARGETED_EXAMPLES[domains[0]]]
: domains.map((domain) => TARGETED_EXAMPLES[domain][0]);
const uniqueExamples = [...new Set(examples)];
if (uniqueExamples.length < 2) return [];
const shown = uniqueExamples.slice(0, Math.max(2, Math.min(domains.length, uniqueExamples.length)));
const pair = splitTimes && splitTimes[0] && splitTimes[1]
? splitTimes
: null;
const splitText = pair ? `能把 ${pair[0]} 和 ${pair[1]} 分开` : "还能把剩下的候选分开";
const prompt = `还有${Math.min(domains.length, shown.length)}条线${splitText}:${shown.join("、")}。记得哪件说哪件,年月大概就行。`;
const primary = domains[0];
return [{
kind: "targeted",
value: 1.5,
prompt,
key: `collect:targeted:${primary}`,
domain: primary,
targetKind: `targeted:${primary}`,
year: null,
examples: shown,
}];
}
export function targetedCollectHint(item: CollectionPoolItem | null | undefined): string | null {
if (!item) return null;
const examples = item.examples?.filter(Boolean) ?? [];
if (examples.length >= 2) {
return `还能再收窄:如果记得${examples.slice(0, 2).join("、")}`;
}
const body = item.prompt.replace(/[。??]$/, "");
return `还能再收窄:如果记得${body}`;
}
/** Card copy ignores a declined targeted collect so the delivered range still says what would narrow it. */
export function rangeNarrowHint(
remainingLayers: readonly string[],
evidence: readonly CollectionEvidence[],
declinedTopics: readonly CollectionTopic[] = [],
splitTimes?: readonly [string, string] | null,
): string {
const item = targetedCollectPool(remainingLayers, evidence, [], splitTimes)[0]
?? targetedCollectPool(remainingLayers, evidence, declinedTopics, splitTimes)[0];
return targetedCollectHint(item) ?? `还能再收窄:${moreCollectHint(evidence, declinedTopics)}`;
}
export const COLLECT_FLOW_BANNED_PHRASES = [
"任何领域",
"领域不限",
@@ -36,6 +36,7 @@ import {
blockingMethodsCovered,
buildMethodFollowupPlan,
datedMethodCollectOpen,
eventFamilyForDiscriminator,
exhaustionSpokenCollectFollowup,
isRemainingEvidenceCollect,
} from "./method-followup";
@@ -46,10 +47,16 @@ import {
MIN_ACCEPTANCE_EVENTS,
trainingScoreableGate,
} from "./evidence-model";
import { refinementFromDecisionReceipt, type DiscriminatingEventProbe } from "./refinement-packet";
import { refinementFromDecisionReceipt, EVENT_PROBE_DOMAINS, type DiscriminatingEventProbe } from "./refinement-packet";
import { windowScanFromDecisionReceipt } from "./varga-observations";
import type { DroppedProbe } from "./probe-question-contract.ts";
import { RECTIFICATION_POLICY } from "../../rectification-policy.ts";
import {
remainingSplitLayers,
remainingSplitTimes,
targetedCollectPool,
isTargetedCollectDeclined,
} from "./collection-question-pool.ts";
import { evidenceLedgerFingerprint } from "./tool-service";
import { followupCaseArgs, blockScanDeclinedForFingerprint } from "./block-scan.ts";
import {
@@ -261,6 +268,55 @@ export function contrastPacketFromLatestResult(
});
}
function eventProbeFromInference(probe: ConflictProbe): DiscriminatingEventProbe | null {
if (probe.year <= 0 || probe.choice_kind === "varga_style" || probe.source === "nakshatra_boundary") {
return null;
}
const domain = EVENT_PROBE_DOMAINS.includes(probe.domain as (typeof EVENT_PROBE_DOMAINS)[number])
? probe.domain as (typeof EVENT_PROBE_DOMAINS)[number]
: null;
if (!domain) return null;
return {
year: probe.year,
year_label: `${probe.year} 年前后`,
domain,
event_family: eventFamilyForDiscriminator(domain, probe.choice_kind),
source: probe.source === "dasha_activation" || probe.source === "dasha_boundary"
|| probe.source === "known_event_quality"
? probe.source
: "dasha_boundary",
tracks: ["vimshottari", "narayana"],
tracks_agree: true,
unique_minute_claim: false,
user_meaning: probe.question,
role: "distinguish",
information_gain: probe.information_gain,
semantic_key: probe.semantic_key,
candidate_split_hash: probe.candidate_split_hash,
candidate_ids: probe.candidate_ids,
expected_outcomes: probe.expected_outcomes,
...(probe.choice_kind ? { choice_kind: probe.choice_kind } : {}),
...(probe.style_options?.length ? { style_options: probe.style_options } : {}),
...(probe.target_evidence_id ? { target_evidence_id: probe.target_evidence_id } : {}),
};
}
function mergeDiscriminatingEventProbes(
...groups: ReadonlyArray<readonly DiscriminatingEventProbe[] | undefined>
): DiscriminatingEventProbe[] {
const byKey = new Map<string, DiscriminatingEventProbe>();
for (const group of groups) {
for (const probe of group ?? []) {
const key = probe.semantic_key?.trim() ?? `${probe.domain}.${probe.year}`;
const current = byKey.get(key);
if (!current || (probe.information_gain ?? 0) > (current.information_gain ?? 0)) {
byKey.set(key, probe);
}
}
}
return [...byKey.values()];
}
function mergeEngineProbes(
...groups: ReadonlyArray<readonly EngineContrastProbe[] | undefined>
): EngineContrastProbe[] {
@@ -304,12 +360,27 @@ export function rectificationFollowupCatalog(
topCandidateTimes,
})
: null;
const windowScan = windowScanFromDecisionReceipt(receipt);
const activeTimes = (inference?.candidates ?? [])
.filter((item) => item.status === "active" || item.status === "equivalent" || item.status === "winner")
.map((item) => item.time);
const remainingLayers = remainingSplitLayers({
transitions: inference?.transitions ?? windowScan?.transitions ?? [],
scanFlags: windowScan,
activeTimes: activeTimes.length ? activeTimes : topCandidateTimes,
});
const answeredIds = new Set((inference?.answered_probes ?? []).map((item) => item.probe_id));
const fromInference = (inference?.probes ?? []).flatMap((probe) => {
if (answeredIds.has(probe.id)) return [];
const mapped = eventProbeFromInference(probe);
return mapped ? [mapped] : [];
});
return {
contrastPacket: contrastPacketFromLatestResult(latest ?? null, evidence),
topCandidateTimes,
askedProbeKeys: askedKeys,
answeredProbes: inference?.answered_probes ?? [],
eventProbes: refinement.discriminating_event_probes,
eventProbes: mergeDiscriminatingEventProbes(refinement.discriminating_event_probes, fromInference),
eventClarificationProbes: refinement.event_clarification_probes,
evidenceCollectionProbes: refinement.evidence_collection_probes,
precisionStage: refinement.precision_stage?.current ?? null,
@@ -318,6 +389,8 @@ export function rectificationFollowupCatalog(
holdoutEvents: (inference?.events ?? [])
.filter((item) => item.usage === "holdout")
.map((item) => ({ domain: item.domain, year: item.year })),
remainingLayers,
remainingSplitTimes: remainingSplitTimes(activeTimes.length ? activeTimes : topCandidateTimes),
};
}
@@ -417,8 +490,35 @@ export type DecideFromDossierOptions = Readonly<{
currentEvidenceFingerprint?: string | null;
birthDate?: string | null;
snapshotCurrent?: boolean;
refreshExhausted?: boolean;
targetedCollectExhausted?: boolean;
}>;
function narrowingExhaustion(
dossier: DecisionDossier,
inference: InferenceState | null,
options?: Pick<DecideFromDossierOptions, "refreshExhausted" | "targetedCollectExhausted">,
catalog?: ReturnType<typeof rectificationFollowupCatalog>,
) {
const live = catalog ?? rectificationFollowupCatalog(dossier.latestResult, dossier.evidence);
const declined = dossier.conversationSummary.declinedSkippedTopics;
const targeted = targetedCollectPool(
live.remainingLayers,
dossier.evidence,
declined,
live.remainingSplitTimes,
);
const remainingLayers = live.remainingLayers;
const refreshed = (inference?.refresh_count ?? 0) >= 1;
// No remaining split layers: BUG-651 pool-empty delivery still holds.
// Remaining layers without a refresh: wait (BUG-653 accident).
return {
refreshExhausted: options?.refreshExhausted ?? (refreshed || remainingLayers.length === 0),
targetedCollectExhausted: options?.targetedCollectExhausted
?? (isTargetedCollectDeclined(declined) || targeted.length === 0),
};
}
function userInterviewAnswers(
answers: InferenceState["answered_probes"] | undefined,
) {
@@ -660,6 +760,8 @@ export function decideFromDossier(
sessionOutcome: "collect_evidence",
answeredProbes: catalog.answeredProbes,
eventProbes: catalog.eventProbes,
remainingLayers: catalog.remainingLayers,
remainingSplitTimes: catalog.remainingSplitTimes,
...followupCaseArgs({
stage: dossier.case.stage,
blockScan: dossier.case.blockScan,
@@ -755,6 +857,7 @@ export function decideFromDossier(
options?.currentEvidenceFingerprint ?? evidenceLedgerFingerprint(dossier.evidence as never),
),
windowWidenSuggested,
...narrowingExhaustion(dossier, inference, options, catalog),
}),
droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped),
};
@@ -775,6 +878,8 @@ export function decideAfterInferenceChange(input: {
sessionOutcome: "collect_evidence",
answeredProbes: catalog.answeredProbes,
eventProbes: catalog.eventProbes,
remainingLayers: catalog.remainingLayers,
remainingSplitTimes: catalog.remainingSplitTimes,
...followupCaseArgs({
stage: input.dossier.case.stage,
blockScan: input.dossier.case.blockScan,
@@ -894,6 +999,7 @@ export function decideAfterInferenceChange(input: {
input.dossier,
evidenceLedgerFingerprint(input.dossier.evidence as never),
),
...narrowingExhaustion(input.dossier, input.state, undefined, catalog),
}),
droppedProbes: mergeDroppedProbes(gated.dropped, nakshatra.dropped),
};
@@ -18,6 +18,11 @@ import {
sharedTraitLine,
} from "../user-copy.ts";
import { previousInferenceFromReceipt } from "./inference-adapter.ts";
import {
rangeNarrowHint,
remainingSplitLayers,
remainingSplitTimes,
} from "./collection-question-pool.ts";
import {
parseEventDashaLedgerByTime,
parseProspectiveWindowsByTime,
@@ -74,6 +79,7 @@ export type RangeDeliveryProjection = Readonly<{
more_count: number;
more_label: string | null;
verification_markdown: string | null;
narrow_hint: string | null;
}>;
export type PublicCandidateClock = Readonly<{
@@ -315,6 +321,16 @@ export function buildRangeDelivery(input: {
eventDashaLedgerByTime?: Readonly<Record<string, readonly EventDashaLedgerRow[]>>;
prospectiveWindowsByTime?: Readonly<Record<string, readonly ProspectiveWindow[]>>;
eventDashaLedger?: readonly EventDashaLedgerRow[];
evidence?: readonly Readonly<{
status: string;
domain: string;
datePrecision: string;
occurredFrom: string | null;
occurredTo: string | null;
eventKind?: string | null;
summary?: string | null;
}>[];
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
}): RangeDeliveryProjection {
const inference = input.inference;
const range = input.credibleRange
@@ -384,6 +400,22 @@ export function buildRangeDelivery(input: {
more_count: moreCount,
more_label: moreCount > 0 ? RANGE_DELIVERY_MORE_MINUTES(moreCount) : null,
verification_markdown: input.verificationMarkdown ?? null,
narrow_hint: rangeNarrowHint(
remainingSplitLayers({
transitions: inference?.transitions ?? windowScan?.transitions ?? [],
scanFlags: windowScan,
activeTimes: inference?.candidates
.filter((item) => item.status !== "eliminated")
.map((item) => item.time) ?? clocks.map((item) => item.time),
}),
input.evidence ?? [],
input.declinedTopics ?? [],
remainingSplitTimes(
inference?.candidates
.filter((item) => item.status !== "eliminated")
.map((item) => item.time) ?? clocks.map((item) => item.time),
),
),
};
}
@@ -429,6 +461,16 @@ export function rangeDeliveryForSnapshot(snapshot: {
skillVerificationReport?: unknown;
event_fit_rate?: unknown;
eventFitRate?: unknown;
evidence?: readonly Readonly<{
status: string;
domain: string;
datePrecision: string;
occurredFrom: string | null;
occurredTo: string | null;
eventKind?: string | null;
summary?: string | null;
}>[];
declinedTopics?: readonly Readonly<Record<string, unknown>>[];
} | null | undefined): RangeDeliveryProjection {
const receipt = snapshot?.decisionReceipt ?? snapshot?.decision_receipt ?? null;
const inference = previousInferenceFromReceipt(receipt);
@@ -453,6 +495,8 @@ export function rangeDeliveryForSnapshot(snapshot: {
eventDashaLedgerByTime: refinement.event_dasha_ledger_by_time,
prospectiveWindowsByTime: refinement.prospective_windows_by_time,
eventDashaLedger: refinement.event_dasha_ledger,
evidence: snapshot?.evidence,
declinedTopics: snapshot?.declinedTopics,
});
}
@@ -589,5 +633,10 @@ export function parseRangeDelivery(value: unknown): RangeDeliveryProjection | nu
: moreCount > 0 ? RANGE_DELIVERY_MORE_MINUTES(moreCount) : null,
verification_markdown: verificationMarkdownFromUnknown(row.verification_markdown)
?? verificationMarkdownFromUnknown(row.verificationMarkdown),
narrow_hint: typeof row.narrow_hint === "string" && row.narrow_hint.trim()
? row.narrow_hint.trim()
: typeof row.narrowHint === "string" && row.narrowHint.trim()
? row.narrowHint.trim()
: null,
};
}
@@ -519,6 +519,7 @@ export function engineRequestBody(input: {
events: readonly V9EngineEvent[];
askedProbeKeys?: readonly string[];
columnTimes?: readonly string[];
refreshProbes?: boolean;
}): Record<string, unknown> {
const snapshot = input.baselineBirthSnapshot;
const birthDate = String(snapshot.birth_date ?? "");
@@ -553,6 +554,7 @@ export function engineRequestBody(input: {
local_time_status: snapshot.local_time_status,
...(askedProbeKeys.length ? { asked_probe_keys: askedProbeKeys } : {}),
...(columnTimes.length ? { column_times: columnTimes } : {}),
...(input.refreshProbes === true ? { refresh_probes: true } : {}),
};
}
@@ -667,6 +669,7 @@ export async function runV9CandidateScore(input: {
events: readonly V9EngineEvent[];
askedProbeKeys?: readonly string[];
columnTimes?: readonly string[];
refreshProbes?: boolean;
}): Promise<V9EngineScoreResult> {
const data = await postEngine("/api/rectification/v5/score", engineRequestBody(input));
const candidates = readCandidates(data.candidate_decisions, input.candidateRange);
@@ -46,7 +46,8 @@
* Remaining dated dasha distinguish probes are asked first. Yearless
* D9/D10 style and nakshatra_boundary stay out of the dated pool and
* never occupy ask_candidate_discriminator (BUG-629, BUG-651). When
* the dated pool is empty after the training gate, deliver the range.
* the dated pool is empty after the training gate, refresh remaining
* candidates then ask targeted collect before delivering the range.
* If holdout is already reserved but training is still short,
* keep collecting a dated event instead of discriminating.
* Once blocking methods are covered, move into candidate discrimination.
@@ -91,6 +92,7 @@ import {
import {
collectionQuestionPool,
isInviteCollectTopic,
targetedCollectPool,
type CollectionPoolItem,
} from "./collection-question-pool.ts";
@@ -1033,6 +1035,10 @@ function followupEventFamily(domain: string, kind: string): string {
return EXISTENCE_EVENT_FAMILY[domain] ?? "这段经历是否发生过";
}
export function eventFamilyForDiscriminator(domain: string, choiceKind?: string): string {
return followupEventFamily(domain, choiceKind ?? "existence");
}
function followupOwnedProbe(
item: Omit<MethodFollowup, "must_not_label" | "choice_frame">,
): DiscriminatingEventProbe | null {
@@ -1327,13 +1333,15 @@ export function followupFromPoolItem(item: CollectionPoolItem): MethodFollowup {
const theme = domain in REVERSE_VERIFY_THEME
? REVERSE_VERIFY_THEME[domain as keyof typeof REVERSE_VERIFY_THEME]
: "dated_event";
const kindHint = item.kind === "anchor" && item.targetKind && item.year != null
? `anchor:${item.targetKind}:${item.year}`
: item.kind === "generic"
? `generic:${domain}`
: domain in REVERSE_VERIFY_KIND
? REVERSE_VERIFY_KIND[domain as keyof typeof REVERSE_VERIFY_KIND]
: null;
const kindHint = item.kind === "targeted"
? `targeted:${domain}`
: item.kind === "anchor" && item.targetKind && item.year != null
? `anchor:${item.targetKind}:${item.year}`
: item.kind === "generic"
? `generic:${domain}`
: domain in REVERSE_VERIFY_KIND
? REVERSE_VERIFY_KIND[domain as keyof typeof REVERSE_VERIFY_KIND]
: null;
return {
method_id: methodId,
intent: "collect_method_evidence",
@@ -1358,6 +1366,16 @@ export function nextCollectionFollowup(
return top ? followupFromPoolItem(top) : null;
}
export function targetedCollectFollowup(
remainingLayers: readonly string[],
evidence: readonly MethodFollowupEvidence[],
declinedTopics: readonly Readonly<Record<string, unknown>>[] = [],
splitTimes?: readonly [string, string] | null,
): MethodFollowup | null {
const top = targetedCollectPool(remainingLayers, evidence, declinedTopics, splitTimes)[0];
return top ? followupFromPoolItem(top) : null;
}
export function nextDatedCollectFollowup(
evidence: readonly MethodFollowupEvidence[],
declined: ReadonlySet<string>,
@@ -1398,9 +1416,10 @@ export function isRemainingEvidenceCollect(
key.startsWith("collect:invite:")
|| key.startsWith("collect:anchor:")
|| key.startsWith("collect:generic:")
|| key.startsWith("collect:targeted:")
) return true;
const hint = followup.kind_hint ?? "";
if (hint === "invite_more" || hint.startsWith("anchor:") || hint.startsWith("generic:")) return true;
if (hint === "invite_more" || hint.startsWith("anchor:") || hint.startsWith("generic:") || hint.startsWith("targeted:")) return true;
return typeof followup.domain === "string"
&& REMAINING_EVIDENCE_COLLECT_DOMAINS.has(followup.domain);
}
@@ -1936,6 +1955,8 @@ export function buildMethodFollowupPlan(input: {
blockScan?: BlockScanPayload | null;
reportedTime?: string | null;
candidateRange?: { start_time: string; end_time: string } | null;
remainingLayers?: readonly string[];
remainingSplitTimes?: readonly [string, string] | null;
}): MethodFollowupPlan {
const makeFollowup = (
item: Omit<MethodFollowup, "must_not_label" | "choice_frame">,
@@ -2057,6 +2078,7 @@ export function buildMethodFollowupPlan(input: {
})
: emptyRankedCatalog();
const rankedDiscriminators = rankedCatalog.locked;
const datedPoolEmpty = rankedDiscriminators.length === 0;
const personalityDiscriminators = rankedCatalog.personality;
const yearlessDiscriminators = rankedCatalog.yearless;
const bestDiscriminator = rankedDiscriminators[0] ?? null;
@@ -2501,7 +2523,6 @@ export function buildMethodFollowupPlan(input: {
: null;
if (!next) {
const precisionCard = takeRenderableDistinguish(precisionStageFollowup());
const datedPoolEmpty = rankedDiscriminators.length === 0;
const datedPrecision = Boolean(
precisionCard?.choice_frame && followupLocksDatedPeriod(precisionCard),
);
@@ -2512,11 +2533,23 @@ export function buildMethodFollowupPlan(input: {
} else if (
datedPoolEmpty
&& meetsAcceptanceEventQuality(input.evidence)
&& sessionOutcome === "discriminate_candidates"
&& (
sessionOutcome === "discriminate_candidates"
|| sessionOutcome === "collect_evidence"
)
&& (next = targetedCollectFollowup(
input.remainingLayers ?? [],
input.evidence,
[
...(input.declinedTopics ?? []),
...(input.closedCollectFocuses ?? []),
],
input.remainingSplitTimes,
))
) {
// BUG-651: discriminating with no dated probe must not pick yearless
// personality or leftover method collect as the next discriminator.
next = null;
// BUG-651: no yearless personality as the next discriminator.
// BUG-654: after the dated pool is empty, ask targeted collect first.
// If the targeted pool is empty, fall through to horary / leftover.
} else if ((renderableYearless = firstRenderableYearlessFollowup())) {
next = renderableYearless;
} else if (
@@ -2698,6 +2731,21 @@ export function buildMethodFollowupPlan(input: {
});
if (leftoverCollect) {
next = makeFollowup(leftoverCollect);
} else if (
meetsAcceptanceEventQuality(input.evidence)
&& datedPoolEmpty
) {
const targeted = targetedCollectFollowup(
input.remainingLayers ?? [],
input.evidence,
[
...(input.declinedTopics ?? []),
...(input.closedCollectFocuses ?? []),
],
input.remainingSplitTimes,
);
if (targeted) next = makeFollowup(targeted);
else if (pendingHoldout) next = makeFollowup(pendingHoldout, false);
} else if (pendingHoldout) {
next = makeFollowup(pendingHoldout, false);
}
@@ -0,0 +1,337 @@
/**
* Refresh dated discriminator probes from remaining active candidates.
*
* Choice answers only rescore in TypeScript. The engine probe list is static
* until this path runs. It must not change candidate_set_id or the answer
* ledger (BUG-587 / BUG-594 / BUG-653).
*/
import { probeFromEngine } from "../core/probes-from-engine.ts";
import type { ConflictProbe, InferenceState } from "../core/types.ts";
import { askedDiscriminatorKeys, previousInferenceFromReceipt } from "./inference-adapter.ts";
import {
runV9CandidateScore,
toEngineEvents,
} from "./engine-client.ts";
import { refinementFromDecisionReceipt, type DiscriminatingEventProbe } from "./refinement-packet.ts";
import { isTargetedCollectDeclined } from "./collection-question-pool.ts";
import { trainingScoreableGate } from "./evidence-model.ts";
import {
evidenceLedgerFingerprint,
inferenceFingerprintForState,
loadV9CaseCompute,
persistV9InferenceState,
scorableEvidence,
type AccountingClient,
} from "./tool-service.ts";
import type { DecisionDossier } from "./decision-from-dossier.ts";
export const MAX_DISCRIMINATOR_REFRESHES = 2;
export type RefreshDiscriminatorProbesInput = Readonly<{
accounting: AccountingClient;
userId: string;
caseId: string;
dossier: DecisionDossier;
state: InferenceState;
}>;
export type RefreshDiscriminatorProbesResult = Readonly<{
state: InferenceState;
eventProbes: readonly DiscriminatingEventProbe[];
candidateSetId: string;
refreshCount: number;
}>;
type RefreshImpl = (input: RefreshDiscriminatorProbesInput) => Promise<RefreshDiscriminatorProbesResult>;
function isRefreshableDatedProbe(probe: DiscriminatingEventProbe): boolean {
if ((probe.year ?? 0) <= 0) return false;
if (probe.choice_kind === "varga_style") return false;
return true;
}
function mergeEventProbes(
existing: readonly DiscriminatingEventProbe[] | undefined,
extra: readonly DiscriminatingEventProbe[] = [],
): DiscriminatingEventProbe[] {
const byKey = new Map<string, DiscriminatingEventProbe>();
for (const probe of existing ?? []) {
const key = probe.semantic_key?.trim() ?? `${probe.domain}.${probe.year}`;
byKey.set(key, probe);
}
for (const probe of extra) {
if (!isRefreshableDatedProbe(probe)) continue;
const key = probe.semantic_key?.trim() ?? `${probe.domain}.${probe.year}`;
const current = byKey.get(key);
if (!current || (probe.information_gain ?? 0) > (current.information_gain ?? 0)) {
byKey.set(key, probe);
}
}
return [...byKey.values()];
}
function mergeConflictProbes(
existing: readonly ConflictProbe[],
incoming: readonly ConflictProbe[],
): ConflictProbe[] {
const byKey = new Map<string, ConflictProbe>();
for (const probe of existing) {
byKey.set(probe.semantic_key, probe);
}
for (const probe of incoming) {
if (probe.year <= 0 || probe.choice_kind === "varga_style" || probe.source === "nakshatra_boundary") {
continue;
}
const current = byKey.get(probe.semantic_key);
if (!current || probe.information_gain > current.information_gain) {
byKey.set(probe.semantic_key, probe);
}
}
return [...byKey.values()];
}
function askedKeysFromState(state: InferenceState, dossier: DecisionDossier): string[] {
const fromAnswers = state.answered_probes.flatMap((item) => [
item.probe_id,
item.semantic_key,
item.candidate_split_hash,
]);
return [...new Set([
...fromAnswers,
...askedDiscriminatorKeys(dossier.latestResult?.decisionReceipt, dossier.evidence),
])];
}
function activeCandidateTimes(state: InferenceState): string[] {
return [...new Set(
state.candidates
.filter((item) => item.status === "active" || item.status === "equivalent" || item.status === "winner")
.map((item) => item.time.slice(0, 5)),
)];
}
function withRefreshCount(
state: InferenceState,
refreshCount: number,
probes: readonly ConflictProbe[],
): InferenceState {
return {
...state,
probes,
refresh_count: refreshCount,
refresh_answer_count: state.answered_probes.length,
};
}
async function defaultRefreshDiscriminatorProbes(
input: RefreshDiscriminatorProbesInput,
): Promise<RefreshDiscriminatorProbesResult> {
const nextCount = (input.state.refresh_count ?? 0) + 1;
const empty: RefreshDiscriminatorProbesResult = {
state: withRefreshCount(input.state, nextCount, input.state.probes),
eventProbes: [],
candidateSetId: input.state.candidate_set_id,
refreshCount: nextCount,
};
const times = activeCandidateTimes(input.state);
if (times.length < 2) return empty;
let compute;
try {
compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId);
} catch {
return empty;
}
const candidateRange = compute.candidateRange;
const scorable = scorableEvidence(input.dossier.evidence as never);
const events = toEngineEvents(scorable);
if (events.length === 0) return empty;
try {
const score = await runV9CandidateScore({
baselineBirthSnapshot: compute.baselineBirthSnapshot,
candidateRange,
events,
askedProbeKeys: askedKeysFromState(input.state, input.dossier),
columnTimes: times,
refreshProbes: true,
});
const eventProbes = mergeEventProbes(
refinementFromDecisionReceipt(score.decisionReceipt).discriminating_event_probes,
);
const incoming = eventProbes.flatMap((probe) => {
const mapped = probeFromEngine(probe);
return mapped ? [mapped] : [];
});
return {
state: withRefreshCount(
input.state,
nextCount,
mergeConflictProbes(input.state.probes, incoming),
),
eventProbes,
candidateSetId: input.state.candidate_set_id,
refreshCount: nextCount,
};
} catch (error) {
console.warn(
`[rectification-v9] refresh discriminator probes failed case=${input.caseId} reason=${
error instanceof Error ? error.message : String(error)
}`,
);
return empty;
}
}
let refreshImpl: RefreshImpl = defaultRefreshDiscriminatorProbes;
export function setRefreshDiscriminatorProbesForTests(impl: RefreshImpl | null): void {
refreshImpl = impl ?? defaultRefreshDiscriminatorProbes;
}
export function resetRefreshDiscriminatorProbesForTests(): void {
refreshImpl = defaultRefreshDiscriminatorProbes;
}
export async function refreshDiscriminatorProbes(
input: RefreshDiscriminatorProbesInput,
): Promise<RefreshDiscriminatorProbesResult> {
return refreshImpl(input);
}
export function applyRefreshedProbesToDossier(
dossier: DecisionDossier,
state: InferenceState,
extraEventProbes: readonly DiscriminatingEventProbe[] = [],
): DecisionDossier {
const latest = dossier.latestResult;
const receipt = latest?.decisionReceipt ?? {};
const existing = refinementFromDecisionReceipt(receipt).discriminating_event_probes;
return {
...dossier,
latestResult: {
...(latest ?? {}),
decisionReceipt: {
...receipt,
inference_state: state,
discriminating_event_probes: mergeEventProbes(existing, extraEventProbes),
},
},
};
}
function shouldRefreshDatedPool(input: {
dossier: DecisionDossier;
state: InferenceState | null;
userStopped?: boolean;
hasDatedProbe: boolean;
}): boolean {
if (input.userStopped === true) return false;
if (input.dossier.case.acceptedTime) return false;
if (!input.state) return false;
if (input.hasDatedProbe) return false;
const refreshCount = input.state.refresh_count ?? 0;
if (refreshCount >= MAX_DISCRIMINATOR_REFRESHES) return false;
if (
refreshCount >= 1
&& input.state.answered_probes.length <= (input.state.refresh_answer_count ?? 0)
) {
return false;
}
if (!trainingScoreableGate(input.dossier.evidence).open) return false;
if (isTargetedCollectDeclined(input.dossier.conversationSummary.declinedSkippedTopics)) {
return false;
}
return true;
}
async function persistRefreshedInference(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
dossier: DecisionDossier;
previous: InferenceState;
next: InferenceState;
}): Promise<void> {
const last = input.next.answered_probes.at(-1);
if (!last) return;
const evidenceFp = input.dossier.latestResult?.evidenceLedgerFingerprint
?? evidenceLedgerFingerprint(input.dossier.evidence as never);
try {
await persistV9InferenceState(input.accounting, input.userId, input.caseId, {
expectedRevision: input.previous.revision,
probeId: last.probe_id,
openProbeId: last.probe_id,
semanticKey: last.semantic_key,
candidateSplitHash: last.candidate_split_hash,
answerClass: last.answer_class,
rawAnswer: "refresh_probes",
inferenceState: input.next as unknown as Record<string, unknown>,
posteriorBefore: Object.fromEntries(
input.previous.candidates.map((item) => [item.time, item.posterior_score]),
),
posteriorAfter: Object.fromEntries(
input.next.candidates.map((item) => [item.time, item.posterior_score]),
),
scoreDeltas: {},
decisionStateFingerprint: inferenceFingerprintForState(
input.caseId,
evidenceFp,
input.next,
),
reason: "supersede",
idempotencyKey: `refresh_probes:${input.next.candidate_set_id}:${input.next.refresh_count ?? 1}`,
candidateSetId: input.next.candidate_set_id,
});
} catch (error) {
console.warn(
`[rectification-v9] persist refreshed probes failed case=${input.caseId} reason=${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
export async function refreshDatedDiscriminatorPoolIfNeeded(input: {
accounting: AccountingClient;
userId: string;
caseId: string;
dossier: DecisionDossier;
state?: InferenceState | null;
userStopped?: boolean;
hasDatedProbe: boolean;
}): Promise<{
dossier: DecisionDossier;
state: InferenceState | null;
refreshed: boolean;
}> {
const state = input.state
?? previousInferenceFromReceipt(input.dossier.latestResult?.decisionReceipt ?? null);
if (!shouldRefreshDatedPool({
dossier: input.dossier,
state,
userStopped: input.userStopped,
hasDatedProbe: input.hasDatedProbe,
}) || !state) {
return { dossier: input.dossier, state, refreshed: false };
}
const result = await refreshDiscriminatorProbes({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: input.dossier,
state,
});
await persistRefreshedInference({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: input.dossier,
previous: state,
next: result.state,
});
return {
dossier: applyRefreshedProbesToDossier(input.dossier, result.state, result.eventProbes),
state: result.state,
refreshed: true,
};
}