fix(rectification): keep candidate state coherent
Independent Staging Quality Gate / validate (push) Failing after 15m32s
Independent Staging Quality Gate / publish (push) Has been skipped

This commit is contained in:
Jesse_Chen
2026-08-27 13:59:38 +08:00
parent 518119acbd
commit 5f855c610c
30 changed files with 2109 additions and 167 deletions
@@ -1,10 +1,18 @@
import { SCORE_DELTA, type AnswerClass, type ConflictProbe, type ProbeOutcome, type ScoreDirection } from "./types.ts";
import {
SCORE_DELTA,
STRONG_CONFLICT_ELIMINATION_COUNT,
type AnswerClass,
type ConflictProbe,
type ProbeOutcome,
type ScoreDirection,
} from "./types.ts";
export type ProbeApplyResult = Readonly<{
scores: Readonly<Record<string, number>>;
eliminated_ids: readonly string[];
kind: "informative" | "low_information";
deltas: Readonly<Record<string, number>>;
strong_conflict_counts: Readonly<Record<string, number>>;
}>;
export function outcomeForAnswer(probe: ConflictProbe, answer: AnswerClass): ProbeOutcome | null {
@@ -19,38 +27,62 @@ export function directionFor(candidateId: string, outcome: ProbeOutcome): ScoreD
/**
* Pure reducer: every active candidate is updated from the same probe outcome.
* A strong conflict eliminates that candidate. Unsure answers are low-information.
* Probe answers move scores; only repeated cumulative conflict can eliminate.
* Unsure answers are low-information.
*/
export function applyProbeOutcome(
scores: Readonly<Record<string, number>>,
probe: ConflictProbe,
answer: AnswerClass,
options: { eliminatedIds?: ReadonlySet<string>; eliminateBelow?: number } = {},
options: {
eliminatedIds?: ReadonlySet<string>;
strongConflictCounts?: Readonly<Record<string, number>>;
} = {},
): ProbeApplyResult {
const eliminated = new Set(options.eliminatedIds ?? []);
const outcome = outcomeForAnswer(probe, answer);
const deltas: Record<string, number> = {};
const next: Record<string, number> = {};
const conflictCounts: Record<string, number> = Object.fromEntries(
Object.keys(scores).map((id) => [id, options.strongConflictCounts?.[id] ?? 0]),
);
if (!outcome || answer === "unsure") {
for (const [id, score] of Object.entries(scores)) {
next[id] = score;
deltas[id] = 0;
}
return { scores: next, eliminated_ids: [...eliminated], kind: "low_information", deltas };
return {
scores: next,
eliminated_ids: [...eliminated],
kind: "low_information",
deltas,
strong_conflict_counts: conflictCounts,
};
}
for (const [id, score] of Object.entries(scores)) {
const direction = directionFor(id, outcome);
if (direction === "conflict") conflictCounts[id] = (conflictCounts[id] ?? 0) + 1;
if (eliminated.has(id)) {
next[id] = score;
deltas[id] = 0;
continue;
}
const direction = directionFor(id, outcome);
const delta = SCORE_DELTA[direction];
deltas[id] = delta;
next[id] = score + delta;
if (direction === "conflict" || next[id] < (options.eliminateBelow ?? -4)) {
eliminated.add(id);
}
}
const newlyEliminable = Object.keys(next).filter((id) => (
!eliminated.has(id)
&& (conflictCounts[id] ?? 0) >= STRONG_CONFLICT_ELIMINATION_COUNT
));
const stillActive = Object.keys(next).filter((id) => (
!eliminated.has(id) && !newlyEliminable.includes(id)
));
const survivor = stillActive.length === 0
? newlyEliminable.sort((left, right) => next[right]! - next[left]! || left.localeCompare(right))[0]
: null;
for (const id of newlyEliminable) {
if (id !== survivor) eliminated.add(id);
}
const changed = Object.values(deltas).some((value) => value !== 0);
return {
@@ -58,5 +90,6 @@ export function applyProbeOutcome(
eliminated_ids: [...eliminated],
kind: changed ? "informative" : "low_information",
deltas,
strong_conflict_counts: conflictCounts,
};
}
@@ -60,10 +60,13 @@ export function buildInferenceState(input: {
const trainingPrior = subtractHoldout(prior, input.candidates, events, input.event_ledger);
const answers = mergeAnswers(input.previous?.answered_probes ?? [], input.answered_probes ?? []);
const seenRoundIds = new Set((previous?.rounds ?? []).map((item) => item.probe_id));
const eliminated = new Set(
(previous?.candidates ?? []).filter((item) => item.status === "eliminated").map((item) => item.id),
);
// Rebuild elimination from the current answer ledger. Superseded or removed
// answers must be able to revive a candidate that no longer reaches the threshold.
const eliminated = new Set<string>();
let scores = { ...trainingPrior };
let conflictCounts: Record<string, number> = Object.fromEntries(
input.candidates.map((item) => [item.id, 0]),
);
const rounds = [...(previous?.rounds ?? [])];
for (const answer of answers) {
@@ -77,8 +80,12 @@ export function buildInferenceState(input: {
if (!probe) continue;
if (holdoutKeys.has(`${probe.domain}:${probe.year}`)) continue;
const before = { ...scores };
const applied = applyProbeOutcome(scores, probe, answer.answer_class, { eliminatedIds: eliminated });
const applied = applyProbeOutcome(scores, probe, answer.answer_class, {
eliminatedIds: eliminated,
strongConflictCounts: conflictCounts,
});
scores = { ...applied.scores };
conflictCounts = { ...applied.strong_conflict_counts };
for (const id of applied.eliminated_ids) eliminated.add(id);
if (seenRoundIds.has(probe.id)) continue;
seenRoundIds.add(probe.id);
@@ -102,9 +109,6 @@ export function buildInferenceState(input: {
input.candidates.map((item) => ({ id: item.id, time: item.time, score: scores[item.id] ?? 0 })),
input.transition_times ?? [],
);
const conflictCounts = new Map(
(previous?.candidates ?? []).map((item) => [item.id, item.strong_conflict_count]),
);
const rankedIds = rankScoreIds(scores, eliminated);
const candidates: InferenceCandidate[] = input.candidates.map((item) => {
const clustered = clusters.find((row) => row.member_ids.includes(item.id));
@@ -122,7 +126,7 @@ export function buildInferenceState(input: {
? "equivalent"
: "active",
rank: rank >= 0 ? rank + 1 : input.candidates.length,
strong_conflict_count: conflictCounts.get(item.id) ?? 0,
strong_conflict_count: conflictCounts[item.id] ?? 0,
};
});
@@ -223,6 +223,26 @@ export function askedKeysFromLedgerEvidence(
return [...keys];
}
export function askedEventProbeKeysFromLedgerEvidence(
evidence: readonly Readonly<{
status?: string | null;
domain?: string | null;
occurredFrom?: string | null;
occurredTo?: string | null;
}>[],
): string[] {
const keys = new Set<string>();
for (const item of evidence) {
if (item.status && !LIVE_EVIDENCE.has(item.status)) continue;
if (!item.domain) continue;
for (const date of [item.occurredFrom, item.occurredTo]) {
const year = date?.match(/^(\d{4})/)?.[1];
if (year) keys.add(`${item.domain}.${year}`);
}
}
return [...keys];
}
export function volunteeredDomainsFromEvidence(
evidence: readonly Readonly<{
status?: string | null;
@@ -263,7 +283,14 @@ export function buildCandidateContrastPacket(input: {
const fromEngine = (input.engineProbes ?? []).flatMap((probe) => {
const built = probeFromEngine(probe, input.candidateSetVersion, input.calculationResultId ?? null);
if (!built) return [];
if (asked.has(built.semanticKey) || asked.has(built.candidateSplitHash) || asked.has(built.probeId)) {
const eventKey = built.domain && built.year ? `${built.domain}.${built.year}` : null;
const isStructured = built.choiceKind === "varga_style" || built.semanticKey.startsWith("varga.");
if (
asked.has(built.semanticKey)
|| asked.has(built.candidateSplitHash)
|| asked.has(built.probeId)
|| (!isStructured && eventKey && asked.has(eventKey))
) {
return [];
}
return [built];
@@ -1,4 +1,5 @@
import type { InferenceState } from "./types.ts";
import { candidateSetId } from "./build-state.ts";
import { INFERENCE_ALGORITHM_VERSION, type InferenceState } from "./types.ts";
export type InferenceTransitionSnapshot = Readonly<{
id?: string;
@@ -18,10 +19,148 @@ export type InferenceTransitionSnapshot = Readonly<{
roundKind?: "informative" | "low_information" | null;
}>;
const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/;
const PHASES = new Set([
"intake", "event_collection", "candidate_generation", "candidate_scoring",
"discrimination", "holdout_validation", "completed", "stopped",
]);
const RESULT_STATUSES = new Set([
"converged", "credible_range", "completed_with_range", "insufficient_evidence",
"max_rounds_reached", "validation_failed", "discriminating",
]);
const CANDIDATE_STATUSES = new Set(["active", "eliminated", "winner", "equivalent"]);
const EVENT_PRECISIONS = new Set(["day", "month", "year", "unknown"]);
const EVENT_USAGES = new Set(["training", "holdout", "unused"]);
const ANSWER_CLASSES = new Set(["yes", "weak_yes", "no", "unsure"]);
const ANSWER_SOURCES = new Set(["choice", "evidence", "declined"]);
const ROUND_KINDS = new Set(["informative", "low_information"]);
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isClockRange(value: unknown): value is readonly [string, string] {
return Array.isArray(value)
&& value.length === 2
&& typeof value[0] === "string"
&& typeof value[1] === "string"
&& CLOCK.test(value[0])
&& CLOCK.test(value[1])
&& value[0] <= value[1];
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function isStringArray(value: unknown): value is readonly string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string");
}
function isNumberRecord(value: unknown): boolean {
return isRecord(value) && Object.values(value).every(isFiniteNumber);
}
function isInferenceEvent(value: unknown): boolean {
return isRecord(value)
&& typeof value.id === "string" && value.id.length > 0
&& typeof value.domain === "string" && value.domain.length > 0
&& (value.year === null || Number.isInteger(value.year))
&& typeof value.precision === "string" && EVENT_PRECISIONS.has(value.precision)
&& typeof value.usage === "string" && EVENT_USAGES.has(value.usage);
}
function isProbeOutcome(value: unknown): boolean {
return isRecord(value)
&& typeof value.answer_class === "string" && ANSWER_CLASSES.has(value.answer_class)
&& isStringArray(value.supports)
&& isStringArray(value.conflicts);
}
function isConflictProbe(value: unknown): boolean {
return isRecord(value)
&& typeof value.id === "string" && value.id.length > 0
&& typeof value.semantic_key === "string" && value.semantic_key.length > 0
&& typeof value.candidate_split_hash === "string" && value.candidate_split_hash.length > 0
&& typeof value.domain === "string" && value.domain.length > 0
&& Number.isInteger(value.year)
&& typeof value.question === "string" && value.question.length > 0
&& isStringArray(value.candidate_ids)
&& Array.isArray(value.expected_outcomes) && value.expected_outcomes.every(isProbeOutcome)
&& isFiniteNumber(value.information_gain)
&& typeof value.source === "string" && value.source.length > 0;
}
function isProbeAnswer(value: unknown): boolean {
return isRecord(value)
&& typeof value.probe_id === "string" && value.probe_id.length > 0
&& typeof value.semantic_key === "string" && value.semantic_key.length > 0
&& typeof value.candidate_split_hash === "string" && value.candidate_split_hash.length > 0
&& typeof value.answer_class === "string" && ANSWER_CLASSES.has(value.answer_class)
&& typeof value.classified_from === "string" && ANSWER_SOURCES.has(value.classified_from);
}
function isRoundTrace(value: unknown): boolean {
return isRecord(value)
&& Number.isInteger(value.round) && Number(value.round) >= 0
&& typeof value.phase === "string" && PHASES.has(value.phase)
&& (value.probe_id === null || typeof value.probe_id === "string")
&& isNumberRecord(value.scores_before)
&& isNumberRecord(value.scores_after)
&& (value.score_deltas === undefined || isNumberRecord(value.score_deltas))
&& isFiniteNumber(value.entropy_before)
&& isFiniteNumber(value.entropy_after)
&& isStringArray(value.eliminated_ids)
&& (value.winner_id === null || typeof value.winner_id === "string")
&& typeof value.kind === "string" && ROUND_KINDS.has(value.kind);
}
function isInferenceCandidate(value: unknown): boolean {
if (!isRecord(value)
|| typeof value.id !== "string" || value.id.length === 0
|| typeof value.time !== "string" || !CLOCK.test(value.time)
|| !isClockRange(value.cluster_range)
|| value.time < value.cluster_range[0] || value.time > value.cluster_range[1]
|| !isFiniteNumber(value.prior_score)
|| !isFiniteNumber(value.posterior_score)
|| !isFiniteNumber(value.probability) || value.probability < 0 || value.probability > 1
|| typeof value.status !== "string" || !CANDIDATE_STATUSES.has(value.status)
|| !Number.isInteger(value.rank) || Number(value.rank) < 1
|| !Number.isInteger(value.strong_conflict_count) || Number(value.strong_conflict_count) < 0) return false;
return true;
}
export function asInferenceState(value: unknown): InferenceState | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const state = value as InferenceState;
return state.algorithm_version && Array.isArray(state.candidates) ? state : null;
if (!isRecord(value)
|| value.algorithm_version !== INFERENCE_ALGORITHM_VERSION
|| typeof value.candidate_set_id !== "string" || value.candidate_set_id.trim().length === 0
|| !Number.isInteger(value.revision) || Number(value.revision) < 0
|| typeof value.phase !== "string" || !PHASES.has(value.phase)
|| typeof value.result_status !== "string" || !RESULT_STATUSES.has(value.result_status)
|| typeof value.range_start !== "string" || !CLOCK.test(value.range_start)
|| typeof value.range_end !== "string" || !CLOCK.test(value.range_end)
|| value.range_start > value.range_end
|| !Array.isArray(value.candidates) || value.candidates.length === 0
|| !value.candidates.every(isInferenceCandidate)
|| new Set(value.candidates.map((item) => (item as Readonly<Record<string, unknown>>).id)).size !== value.candidates.length
|| new Set(value.candidates.map((item) => (item as Readonly<Record<string, unknown>>).time)).size !== value.candidates.length
|| value.candidate_set_id !== candidateSetId(
value.range_start,
value.range_end,
value.candidates.map((item) => (item as Readonly<Record<string, unknown>>).time as string),
)
|| !Array.isArray(value.events) || !value.events.every(isInferenceEvent)
|| !Array.isArray(value.probes) || !value.probes.every(isConflictProbe)
|| !Array.isArray(value.answered_probes) || !value.answered_probes.every(isProbeAnswer)
|| !Array.isArray(value.rounds) || !value.rounds.every(isRoundTrace)
|| !(value.last_inference_round === undefined
|| value.last_inference_round === null
|| isRoundTrace(value.last_inference_round))
|| !isFiniteNumber(value.entropy)
|| !(value.representative_time === null
|| (typeof value.representative_time === "string" && CLOCK.test(value.representative_time)))
|| !(value.credible_range === null || isClockRange(value.credible_range))) return null;
return value as InferenceState;
}
/**
@@ -38,9 +177,11 @@ export function composeInferenceReceipt(
: {};
if (!transition) return receipt;
if (resultId && transition.resultId && transition.resultId !== resultId) return receipt;
const inferenceState = asInferenceState(transition.inferenceState);
if (!inferenceState || transition.revision !== inferenceState.revision) return receipt;
return {
...receipt,
inference_state: transition.inferenceState,
inference_state: inferenceState,
decision_state_fingerprint: transition.decisionStateFingerprint,
};
}
@@ -12,6 +12,7 @@ export const CONVERGENCE_LEAD = 0.2;
export const CONVERGENCE_TOP_SHARE = 0.7;
export const STABLE_WINNER_ROUNDS = 2;
export const MIN_TRAINING_EVENTS = 3;
export const STRONG_CONFLICT_ELIMINATION_COUNT = 3;
export type RectificationPhase =
| "intake"
@@ -144,7 +144,6 @@ function deriveCandidateRange(input: {
function baselineFingerprint(baseline: V9BaselineSnapshot): string {
const canonical = [
baseline.birth_date,
baseline.birth_place_label,
String(baseline.latitude),
String(baseline.longitude),
baseline.timezone_id,
@@ -154,7 +153,6 @@ function baselineFingerprint(baseline: V9BaselineSnapshot): string {
baseline.declared_window_start ?? "",
baseline.declared_window_end ?? "",
baseline.reported_birth_time ?? "",
baseline.active_birth_time ?? "",
String(baseline.uncertainty_before_minutes ?? ""),
String(baseline.uncertainty_after_minutes ?? ""),
].join("|");
@@ -209,6 +209,7 @@ type Hypothesis = Readonly<{
a: string;
b: string;
neither: string;
unsure: string;
}>;
function eventLockPrompt(period: string, family: string): string {
@@ -228,6 +229,25 @@ function eventHypothesis(
a: OPTION_A,
b: OPTION_B,
neither: PRIMARY_C,
unsure: SECONDARY_D,
};
}
function withStyleOptionLabels(
hypothesis: Hypothesis,
styleOptions: readonly EventProbeStyleOption[],
): Hypothesis {
const label = (answerClass: string, fallback: string) => clippedCopy(
styleOptions.find((item) => item.answer_class === answerClass)?.label,
4,
80,
) ?? fallback;
return {
...hypothesis,
a: label("yes", hypothesis.a),
b: label("weak_yes", hypothesis.b),
neither: label("no", hypothesis.neither),
unsure: label("unsure", hypothesis.unsure),
};
}
@@ -247,6 +267,7 @@ function hypothesisFor(
a: "长期偏对外、领导或经营",
b: "长期偏研究、技术或幕后转化",
neither: "都不是,或职业经常变",
unsure: SECONDARY_D,
};
}
if (theme === "horary") {
@@ -257,6 +278,7 @@ function hypothesisFor(
a: "记得第一次认真问起的大概时间",
b: "有问起,但时间很模糊",
neither: "没有专门问起过",
unsure: SECONDARY_D,
};
}
if (theme === "nakshatra_trait") {
@@ -267,18 +289,19 @@ function hypothesisFor(
a: "更干脆、外放、说做就做",
b: "更慢热、内收、反复权衡",
neither: "都不像,或两边都有",
unsure: SECONDARY_D,
};
}
if (theme === "active_focus") {
const domain = followup.domain;
const probe = pickProbe(probes, domain, followup);
const period = probe?.year_label ?? lifePeriodLabel(evidence ?? [], domain);
return eventHypothesis(
return withStyleOptionLabels(eventHypothesis(
period,
probe?.event_family ?? "刚才那件待确认的经历",
probe?.user_meaning ?? "先承接当前问题,不要另开领域清单。题干自己写。",
probe ? AGE_BAND[probe.domain]?.varga ?? null : null,
);
), followup.style_options ?? probe?.style_options ?? []);
}
const domain = followupDomain(followup);
const probe = pickProbe(probes, domain, followup);
@@ -302,26 +325,17 @@ function hypothesisFor(
: probe?.user_meaning
?? "用一件带年份的具体生平分开还在比的时间窗。题干自己写,年份不得发明。";
void observations;
if (kind === "varga_style" && styleOptions.length >= 2) {
if (kind === "varga_style") {
const career = domain === "career" || followup.ask_theme === "career_style";
if (styleOptions.length >= 3) {
return {
prompt: career ? "长期工作更接近哪一类?" : "这段关系更接近哪一种相处?",
why,
varga,
a: styleOptions[0].label,
b: styleOptions[1].label,
neither: styleOptions[2].label,
};
}
return {
return withStyleOptionLabels({
prompt: career ? "长期工作更接近哪一类?" : "这段关系更接近哪一种相处?",
why,
varga,
a: styleOptions[0].label,
b: styleOptions[1].label,
a: OPTION_A,
b: OPTION_B,
neither: STYLE_NEITHER,
};
unsure: SECONDARY_D,
}, styleOptions);
}
if (kind === "event_quality") {
const exam = (probe?.domain ?? domain) === "education"
@@ -332,12 +346,12 @@ function hypothesisFor(
why,
exam ? varga ?? "D5 / D24" : varga,
);
if (exam) {
return { ...hypothesis, a: QUALITY_A, b: QUALITY_B, neither: QUALITY_C };
}
return hypothesis;
return withStyleOptionLabels(
exam ? { ...hypothesis, a: QUALITY_A, b: QUALITY_B, neither: QUALITY_C } : hypothesis,
styleOptions,
);
}
return eventHypothesis(period, family, why, varga);
return withStyleOptionLabels(eventHypothesis(period, family, why, varga), styleOptions);
}
export function buildChoiceFrame(
@@ -369,7 +383,7 @@ export function buildChoiceFrame(
option_a_hint: hypothesis.a,
option_b_hint: hypothesis.b,
neither_label: hypothesis.neither,
unsure_label: SECONDARY_D,
unsure_label: hypothesis.unsure,
choice_mode: CHOICE_MODE,
stop_label: CHOICE_STOP_LABEL,
stop_message: CHOICE_STOP_MESSAGE,
@@ -6,7 +6,6 @@
*/
import {
askedKeysFromLedgerEvidence,
buildCandidateContrastPacket,
selectDiscriminatorProbe,
volunteeredDomainsFromEvidence,
@@ -18,7 +17,11 @@ import {
type RectificationDecision,
} from "../core/rectification-decision.ts";
import type { InferenceState } from "../core/types.ts";
import { askedProbeKeysFromReceipt, previousInferenceFromReceipt } from "./inference-adapter";
import {
askedDiscriminatorKeys,
authoritativeCandidateProjection,
previousInferenceFromReceipt,
} from "./inference-adapter";
import {
blockingMethodsCovered,
buildMethodFollowupPlan,
@@ -58,10 +61,14 @@ export type DecisionDossier = Readonly<{
decisionReceipt: Readonly<Record<string, unknown>> | null;
selectionAllowed?: boolean;
candidates?: readonly Readonly<{
candidateId?: string;
time: string;
rank?: number;
relativeSupport?: number;
posterior_score?: number;
tiedMinuteCount?: number;
}>[];
representativeTime?: string | null;
evidenceLedgerFingerprint?: string | null;
candidateRangeFingerprint?: string | null;
} | null;
@@ -71,15 +78,8 @@ export type DecisionDossier = Readonly<{
turns?: readonly Readonly<{ role: string; text: string | null }>[];
}>;
function candidateScoresFromDossier(latest: DecisionDossier["latestResult"]) {
const inference = previousInferenceFromReceipt(latest?.decisionReceipt ?? null);
if (inference && inference.candidates.length > 0) {
return inference.candidates.map((item) => ({ time: item.time, score: item.posterior_score }));
}
return (latest?.candidates ?? []).map((item) => ({
time: item.time,
score: item.relativeSupport ?? item.posterior_score ?? 0,
}));
export function candidateScoresFromDossier(latest: DecisionDossier["latestResult"]) {
return authoritativeCandidateProjection(latest).scores;
}
function holdoutStatusFromInference(inference: ReturnType<typeof previousInferenceFromReceipt>) {
@@ -122,10 +122,10 @@ export function contrastPacketFromDossier(dossier: DecisionDossier): CandidateCo
],
candidateTimes: candidateScores.map((item) => item.time),
transitions: windowScan?.transitions ?? [],
askedKeys: [
...askedProbeKeysFromReceipt(dossier.latestResult?.decisionReceipt),
...askedKeysFromLedgerEvidence(dossier.evidence),
],
askedKeys: askedDiscriminatorKeys(
dossier.latestResult?.decisionReceipt,
dossier.evidence,
),
volunteeredDomains: volunteeredDomainsFromEvidence(dossier.evidence),
});
}
@@ -206,10 +206,9 @@ export function decideAfterInferenceChange(input: {
methodCoverageAll: blockingMethodsCovered(collecting.methods),
trainingGateOpen: training.length >= MIN_ACCEPTANCE_EVENTS
&& trainingDomains.size >= MIN_ACCEPTANCE_DOMAINS,
candidateScores: input.state.candidates.map((item) => ({
time: item.time,
score: item.posterior_score,
})),
candidateScores: input.state.candidates
.filter((item) => item.status !== "eliminated")
.map((item) => ({ time: item.time, score: item.posterior_score })),
discriminatorProbe: selectDiscriminatorProbe(contrastPacketFromState(input.state)),
holdoutValidation: holdoutStatusFromState(input.state),
inferenceCredibleRange: input.state.credible_range,
@@ -227,10 +226,22 @@ export function overlayPublicDecision<T extends object>(
completionStatus: ReturnType<typeof publicDecisionFields>["completion_status"];
} {
const fields = publicDecisionFields(decision);
const projection = authoritativeCandidateProjection(snapshot as DecisionDossier["latestResult"]);
const inconsistent = !projection.consistent;
return {
...snapshot,
...fields,
selectionAllowed: fields.selection_allowed,
...(projection.fromInference
? {
candidates: projection.candidates,
representativeTime: projection.representativeTime,
representative_time: projection.representativeTime,
credibleRange: projection.credibleRange,
credible_range: projection.credibleRange,
}
: {}),
...(inconsistent ? { can_adopt: false, selection_allowed: false } : {}),
selectionAllowed: inconsistent ? false : fields.selection_allowed,
validated: fields.validated,
completionStatus: fields.completion_status,
};
@@ -10,7 +10,10 @@ import {
import { isDuplicateProbe } from "../core/duplicate-probes.ts";
import { probeFromEngine } from "../core/probes-from-engine.ts";
import { selectHighestGainProbe } from "../core/select-probe.ts";
import { askedKeysFromLedgerEvidence } from "../core/candidate-contrast-packet.ts";
import { askedEventProbeKeysFromLedgerEvidence } from "../core/candidate-contrast-packet.ts";
import { rankActive } from "../core/convergence-evaluator.ts";
import { rangeFromTimes } from "../core/credible-range.ts";
import { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
import type { AnswerClass, ConflictProbe, InferenceState } from "../core/types.ts";
import {
isHoldoutVerificationQuote,
@@ -51,18 +54,121 @@ export function askedProbeKeysFromReceipt(
export function askedDiscriminatorKeys(
receipt: Readonly<Record<string, unknown>> | null | undefined,
evidence: readonly Readonly<{
status?: string | null;
domain?: string | null;
eventKind?: string | null;
summary?: string | null;
occurredFrom?: string | null;
occurredTo?: string | null;
}>[] = [],
): string[] {
return [
...askedProbeKeysFromReceipt(receipt),
...askedKeysFromLedgerEvidence(evidence),
...askedEventProbeKeysFromLedgerEvidence(evidence),
];
}
export { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
export { previousInferenceFromReceipt };
type CandidateSnapshotRow = Readonly<{
candidateId?: string;
time: string;
rank?: number;
relativeSupport?: number;
posterior_score?: number;
tiedMinuteCount?: number;
}>;
type CandidateSnapshotSource<T extends CandidateSnapshotRow> = Readonly<{
decisionReceipt?: Readonly<Record<string, unknown>> | null;
candidates?: readonly T[];
}> | null | undefined;
type ProjectedCandidate<T extends CandidateSnapshotRow> = T & Readonly<{
rank?: number;
relativeSupport?: number;
posterior_score?: number;
}>;
type CandidateProjection<T extends CandidateSnapshotRow> = Readonly<{
fromInference: boolean;
consistent: boolean;
candidates: readonly ProjectedCandidate<T>[];
scores: readonly Readonly<{ id?: string; time: string; score: number }>[];
representativeTime: string | null;
credibleRange: readonly [string, string] | null;
}>;
export function authoritativeCandidateProjection<T extends CandidateSnapshotRow>(
latest: CandidateSnapshotSource<T>,
): CandidateProjection<T> {
const inference = previousInferenceFromReceipt(latest?.decisionReceipt ?? null);
if (!inference || inference.candidates.length === 0) {
return {
fromInference: true,
consistent: false,
candidates: [],
scores: [],
representativeTime: null,
credibleRange: null,
};
}
const active = rankActive(inference.candidates);
const persisted = latest?.candidates ?? [];
const matchedPersistedIndexes = inference.candidates.map((item) => persisted.findIndex((row) => (
row.candidateId === item.id || row.time === item.time
)));
const completeCandidateSet = inference.candidates.length === persisted.length
&& matchedPersistedIndexes.every((index) => index >= 0)
&& new Set(matchedPersistedIndexes).size === persisted.length
&& new Set(inference.candidates.map((item) => item.id)).size === inference.candidates.length
&& new Set(inference.candidates.map((item) => item.time)).size === inference.candidates.length;
const candidates = active.flatMap((item, index) => {
const source = persisted.find((row) => row.candidateId === item.id)
?? persisted.find((row) => row.time === item.time);
if (!source) return [];
return [{
...source,
rank: index + 1,
relativeSupport: Math.max(0, Math.min(100, Math.round(item.posterior_score))),
posterior_score: item.posterior_score,
}];
});
const representativeTime = active[0]?.time ?? null;
const activePoints = active.flatMap((item) => [item.cluster_range[0], item.time, item.cluster_range[1]]);
const authoritativeRange = rangeFromTimes(activePoints);
const receiptRange = inference.credible_range;
const activeRangesValid = Boolean(authoritativeRange) && active.every((item) => {
const clusterRange = rangeFromTimes(item.cluster_range);
return clusterRange?.[0] === item.cluster_range[0]
&& clusterRange[1] === item.cluster_range[1]
&& rangeFromTimes([item.time])?.[0] === item.time
&& item.time >= clusterRange[0]
&& item.time <= clusterRange[1]
&& item.cluster_range[0] >= authoritativeRange![0]
&& item.cluster_range[1] <= authoritativeRange![1];
});
const receiptRangeMatches = Boolean(authoritativeRange && receiptRange)
&& receiptRange![0] <= receiptRange![1]
&& receiptRange![0] === authoritativeRange![0]
&& receiptRange![1] === authoritativeRange![1];
const consistent = active.length > 0
&& completeCandidateSet
&& candidates.length === active.length
&& inference.representative_time === representativeTime
&& activeRangesValid
&& receiptRangeMatches;
return {
fromInference: true,
consistent,
candidates: consistent ? candidates : [],
scores: consistent
? active.map((item) => ({ id: item.id, time: item.time, score: item.posterior_score }))
: [],
representativeTime: consistent ? representativeTime : null,
credibleRange: consistent ? authoritativeRange : null,
};
}
export function compactInferenceProjection(state: InferenceState | null | undefined): Record<string, unknown> | null {
if (!state) return null;
@@ -428,7 +428,8 @@ function action(
return { id, user_meaning };
}
const USER_STOP_PATTERN = /暂时想不到了|没有更多|没有其它|没有其他|想不起来了|先这样|没有了|没了/;
const USER_STOP_PATTERN = /结束校正|不想继续|直接给结果|就到这里/;
const USER_STOP_NEGATION_PATTERN = /(?:不是|并非|不要|别|还没|未).{0,8}(?:结束校正|不想继续|直接给结果|就到这里)/;
export function latestUserStoppedCollecting(
turns: readonly Readonly<{ role: string; text: string | null }>[],
@@ -438,7 +439,7 @@ export function latestUserStoppedCollecting(
if (turn.role !== "user") continue;
const text = turn.text?.trim() ?? "";
if (!text) continue;
return USER_STOP_PATTERN.test(text);
return USER_STOP_PATTERN.test(text) && !USER_STOP_NEGATION_PATTERN.test(text);
}
return false;
}
@@ -1020,7 +1021,7 @@ export function buildMethodFollowupPlan(input: {
),
source: "precision_stage",
});
} else if (stage === "d9_refine" && !declined.has("relationship")) {
} else if (stage === "d9_refine" && !relationshipCovered && !declined.has("relationship")) {
next = makeFollowup({
method_id: "d9_relationship",
intent: "distinguish_candidates",
@@ -1083,7 +1084,7 @@ export function buildMethodFollowupPlan(input: {
const d12 = input.observations?.find((item) => item.layer === "d12");
const d11 = input.observations?.find((item) => item.layer === "d11");
const d30 = input.observations?.find((item) => item.layer === "d30");
if (d9?.candidates_differ && !declined.has("relationship")) {
if (d9?.candidates_differ && !relationshipCovered && !declined.has("relationship")) {
next = makeFollowup({
method_id: "d9_relationship",
intent: "distinguish_candidates",
@@ -81,6 +81,7 @@ function expectedAnswerSchemaFor(
choice_kind: frame.choice_kind ?? followup.choice_kind ?? "existence",
};
const state = previousInferenceFromReceipt(decisionReceipt ?? null);
if (decisionReceipt?.inference_state !== undefined && !state) return null;
const stamped = stampChoiceSchemaWithProbe(
schema,
state,
@@ -124,12 +125,6 @@ export async function persistServerOwnedFocus(input: {
return { status: skip, focus: input.activeFocus, questionId: null, prompt: null };
}
const questionId = stableFollowupQuestionId(followup);
const schema = expectedAnswerSchemaFor(frame, questionId, input.decisionReceipt, followup);
if (!schema?.choice) {
return { status: "skipped", focus: input.activeFocus, questionId, prompt: null };
}
const copy = serverOwnedChoiceCopy(frame);
const prompt = copy?.prompt ?? null;
const answeredKeys = new Set(askedProbeKeysFromReceipt(input.decisionReceipt));
if (followup.semantic_key && answeredKeys.has(followup.semantic_key)) {
return {
@@ -139,6 +134,12 @@ export async function persistServerOwnedFocus(input: {
prompt: null,
};
}
const schema = expectedAnswerSchemaFor(frame, questionId, input.decisionReceipt, followup);
if (!schema?.choice) {
return { status: "skipped", focus: input.activeFocus, questionId, prompt: null };
}
const copy = serverOwnedChoiceCopy(frame);
const prompt = copy?.prompt ?? null;
const active = input.activeFocus;
if (
active