fix(rectification): persist refreshed probes only when the engine has new questions (BUG-655)

Empty refreshes were writing inference rows, and GET-selected probe keys could miss inference_state, so persist rejected the next card.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-12 01:51:57 +08:00
co-authored by Cursor
parent 6c9a089620
commit 512be9b76e
6 changed files with 450 additions and 23 deletions
@@ -7,7 +7,7 @@
*/
import { probeFromEngine } from "../core/probes-from-engine.ts";
import type { ConflictProbe, InferenceState } from "../core/types.ts";
import type { ConflictProbe, InferenceState, ProbeAnswer } from "../core/types.ts";
import { askedDiscriminatorKeys, previousInferenceFromReceipt } from "./inference-adapter.ts";
import {
runV9CandidateScore,
@@ -71,21 +71,55 @@ function mergeEventProbes(
return [...byKey.values()];
}
function isRefreshableDatedConflict(probe: ConflictProbe): boolean {
if (probe.year <= 0 || probe.choice_kind === "varga_style" || probe.source === "nakshatra_boundary") {
return false;
}
return true;
}
function probeIdPatternFromAnswered(answered: readonly ProbeAnswer[]): "simple" | "hashed" {
for (const item of answered) {
if (!item.probe_id || !item.semantic_key) continue;
if (item.probe_id === `probe:${item.semantic_key}` || item.probe_id === item.semantic_key) {
return "simple";
}
if (
item.candidate_split_hash
&& item.probe_id === `probe:${item.semantic_key}:${item.candidate_split_hash}`
) {
return "hashed";
}
}
return "simple";
}
export function alignedProbeId(probe: ConflictProbe, answered: readonly ProbeAnswer[]): string {
return probeIdPatternFromAnswered(answered) === "hashed"
? `probe:${probe.semantic_key}:${probe.candidate_split_hash}`
: `probe:${probe.semantic_key}`;
}
function alignedIncomingProbe(probe: ConflictProbe, answered: readonly ProbeAnswer[]): ConflictProbe {
const id = alignedProbeId(probe, answered);
return probe.id === id ? probe : { ...probe, id };
}
function mergeConflictProbes(
existing: readonly ConflictProbe[],
incoming: readonly ConflictProbe[],
answered: readonly ProbeAnswer[] = [],
): 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);
if (!isRefreshableDatedConflict(probe)) continue;
const next = alignedIncomingProbe(probe, answered);
const current = byKey.get(next.semantic_key);
if (!current || next.information_gain > current.information_gain) {
byKey.set(next.semantic_key, next);
}
}
return [...byKey.values()];
@@ -103,6 +137,35 @@ function askedKeysFromState(state: InferenceState, dossier: DecisionDossier): st
])];
}
function mappedAskableProbes(
previous: InferenceState,
eventProbes: readonly DiscriminatingEventProbe[],
extra: readonly ConflictProbe[],
dossier: DecisionDossier,
): ConflictProbe[] {
const asked = new Set(askedKeysFromState(previous, dossier));
const existingKeys = new Set(previous.probes.map((item) => item.semantic_key));
const askable: ConflictProbe[] = [];
const incoming = [
...extra.filter(isRefreshableDatedConflict),
...eventProbes.flatMap((probe) => {
if (!isRefreshableDatedProbe(probe)) return [];
const mapped = probeFromEngine(probe);
return mapped ? [mapped] : [];
}),
];
for (const probe of incoming) {
const aligned = alignedIncomingProbe(probe, previous.answered_probes);
if (existingKeys.has(aligned.semantic_key)) continue;
if (asked.has(aligned.semantic_key) || asked.has(aligned.id) || asked.has(aligned.candidate_split_hash)) {
continue;
}
existingKeys.add(aligned.semantic_key);
askable.push(aligned);
}
return askable;
}
function activeCandidateTimes(state: InferenceState): string[] {
return [...new Set(
state.candidates
@@ -166,7 +229,7 @@ async function defaultRefreshDiscriminatorProbes(
state: withRefreshCount(
input.state,
nextCount,
mergeConflictProbes(input.state.probes, incoming),
mergeConflictProbes(input.state.probes, incoming, input.state.answered_probes),
),
eventProbes,
candidateSetId: input.state.candidate_set_id,
@@ -244,6 +307,40 @@ function shouldRefreshDatedPool(input: {
return true;
}
function refreshWriteBlockedReason(
previous: InferenceState,
result: RefreshDiscriminatorProbesResult,
askableCount: number,
): string | null {
if (askableCount <= 0) return "no_new_probes";
if (
previous.candidates.length === 0
|| !Array.isArray(result.state.candidates)
|| result.state.candidates.length === 0
) {
return "empty_candidates";
}
if (
result.state.candidate_set_id !== previous.candidate_set_id
|| result.candidateSetId !== previous.candidate_set_id
) {
return "candidate_set_changed";
}
return null;
}
function refreshPersistBlockedReason(input: {
previous: InferenceState;
next: InferenceState;
askableCount: number;
}): string | null {
if (input.askableCount <= 0) return "no_new_probes";
if (input.next.candidates.length === 0) return "empty_candidates";
if (input.previous.candidates.length === 0) return "empty_candidates";
if (input.next.candidate_set_id !== input.previous.candidate_set_id) return "candidate_set_changed";
return null;
}
async function persistRefreshedInference(input: {
accounting: AccountingClient;
userId: string;
@@ -251,9 +348,22 @@ async function persistRefreshedInference(input: {
dossier: DecisionDossier;
previous: InferenceState;
next: InferenceState;
}): Promise<void> {
askableCount: number;
}): Promise<boolean> {
const blocked = refreshPersistBlockedReason(input);
if (blocked) {
console.warn(JSON.stringify({
event: "rectification_refresh_persist_skipped",
case_id: input.caseId,
reason: blocked,
candidates: input.next.candidates.length,
candidate_set_id: input.next.candidate_set_id,
refresh_count: input.next.refresh_count ?? 0,
}));
return false;
}
const last = input.next.answered_probes.at(-1);
if (!last) return;
if (!last) return false;
const evidenceFp = input.dossier.latestResult?.evidenceLedgerFingerprint
?? evidenceLedgerFingerprint(input.dossier.evidence as never);
try {
@@ -282,12 +392,14 @@ async function persistRefreshedInference(input: {
idempotencyKey: `refresh_probes:${input.next.candidate_set_id}:${input.next.refresh_count ?? 1}`,
candidateSetId: input.next.candidate_set_id,
});
return true;
} catch (error) {
console.warn(
`[rectification-v9] persist refreshed probes failed case=${input.caseId} reason=${
error instanceof Error ? error.message : String(error)
}`,
);
return false;
}
}
@@ -321,17 +433,44 @@ export async function refreshDatedDiscriminatorPoolIfNeeded(input: {
dossier: input.dossier,
state,
});
await persistRefreshedInference({
const askable = mappedAskableProbes(state, result.eventProbes, result.state.probes, input.dossier);
const blocked = refreshWriteBlockedReason(state, result, askable.length);
if (blocked) {
console.warn(JSON.stringify({
event: "rectification_refresh_persist_skipped",
case_id: input.caseId,
reason: blocked,
candidates: result.state.candidates.length,
candidate_set_id: result.state.candidate_set_id,
refresh_count: state.refresh_count ?? 0,
}));
return { dossier: input.dossier, state, refreshed: false };
}
const nextState: InferenceState = {
...state,
probes: mergeConflictProbes(state.probes, askable, state.answered_probes),
refresh_count: (state.refresh_count ?? 0) + 1,
refresh_answer_count: state.answered_probes.length,
};
const written = await persistRefreshedInference({
accounting: input.accounting,
userId: input.userId,
caseId: input.caseId,
dossier: input.dossier,
previous: state,
next: result.state,
next: nextState,
askableCount: askable.length,
});
if (!written) {
return { dossier: input.dossier, state, refreshed: false };
}
const persistableEventProbes = result.eventProbes.filter((probe) => {
const key = probe.semantic_key?.trim() ?? `${probe.domain}.${probe.year}`;
return askable.some((item) => item.semantic_key === key);
});
return {
dossier: applyRefreshedProbesToDossier(input.dossier, result.state, result.eventProbes),
state: result.state,
dossier: applyRefreshedProbesToDossier(input.dossier, nextState, persistableEventProbes),
state: nextState,
refreshed: true,
};
}