Files
Jyotisha/frontend/src/lib/rectification-agentic/v9/inference-adapter.ts
T
Jesse_Chen e404b6f42b
Independent Staging Quality Gate / validate (push) Successful in 13m25s
Independent Staging Quality Gate / publish (push) Successful in 9m26s
fix(rectification): guarantee nonterminal turn exits
2026-09-01 13:35:51 +08:00

598 lines
22 KiB
TypeScript

import {
answersFromEvidence,
applyAnswerToState,
applyHoldoutAnswer,
applySupersedeAnswer,
buildInferenceState,
classifyChoiceAnswer,
type EngineEventInput,
} from "../core/build-state.ts";
import { isDuplicateProbe } from "../core/duplicate-probes.ts";
import { probeFromEngine } from "../core/probes-from-engine.ts";
import { selectHighestGainProbe } from "../core/select-probe.ts";
import { askedEventProbeKeysFromLedgerEvidence } from "../core/candidate-contrast-packet.ts";
import { rankActive } from "../core/convergence-evaluator.ts";
import { rangeFromTimes, unionStillValidRange } from "../core/credible-range.ts";
import {
previousInferenceFromReceipt as parsePreviousInferenceFromReceipt,
} from "../core/compose-receipt.ts";
import type { AnswerClass, ConflictProbe, InferenceState, ProbeAnswer } from "../core/types.ts";
import { datedPrecision } from "./evidence-model.ts";
import {
isHoldoutVerificationQuote,
type ChoiceKey,
} from "./choice-card.ts";
import {
parseNakshatraBoundary,
type DiscriminatingEventProbe,
type NakshatraBoundary,
} from "./refinement-packet.ts";
function yearFrom(value: string | null | undefined): number | null {
if (!value || value.length < 4 || !/^\d{4}/.test(value)) return null;
const year = Number(value.slice(0, 4));
return year >= 1900 && year <= 2100 ? year : null;
}
function asPrecision(value: string | null | undefined): EngineEventInput["precision"] {
if (!value) return "unknown";
return datedPrecision(value);
}
export function askedProbeKeysFromReceipt(
receipt: Readonly<Record<string, unknown>> | null | undefined,
): string[] {
const inference = receipt?.inference_state;
if (!inference || typeof inference !== "object" || Array.isArray(inference)) return [];
const answers = (inference as { answered_probes?: unknown }).answered_probes;
if (!Array.isArray(answers)) return [];
const keys: string[] = [];
for (const item of answers) {
if (!item || typeof item !== "object") continue;
const row = item as Record<string, unknown>;
if (typeof row.probe_id === "string") keys.push(row.probe_id);
if (typeof row.semantic_key === "string") keys.push(row.semantic_key);
if (typeof row.candidate_split_hash === "string") keys.push(row.candidate_split_hash);
}
return keys;
}
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),
...askedEventProbeKeysFromLedgerEvidence(evidence),
];
}
const NAKSHATRA_BOUNDARY_SOURCE = "nakshatra_boundary";
export function nakshatraBoundaryProbe(
state: InferenceState | null | undefined,
boundary: NakshatraBoundary | null | undefined,
): ConflictProbe | null {
if (!state || !boundary?.near_boundary) return null;
const optionA = boundary.options.find((item) => item.key === "A") ?? null;
const optionB = boundary.options.find((item) => item.key === "B") ?? null;
if (
!optionA?.traits.length
|| !optionB?.traits.length
|| optionA.time_bias === optionB.time_bias
) return null;
const active = rankActive(state.candidates)
.slice()
.sort((left, right) => left.time.localeCompare(right.time));
if (active.length < 2) return null;
const pivot = Math.ceil(active.length / 2);
const earlier = active.slice(0, pivot).map((item) => item.id);
const later = active.slice(pivot).map((item) => item.id);
if (earlier.length === 0 || later.length === 0) return null;
const semanticKey = `nakshatra-boundary:${state.candidate_set_id}`;
const candidateSplitHash = `${semanticKey}:${earlier.join(",")}|${later.join(",")}`;
const candidatesFor = (bias: "earlier" | "later") => bias === "earlier" ? earlier : later;
const conflictsFor = (bias: "earlier" | "later") => bias === "earlier" ? later : earlier;
const optionLabel = (key: "A" | "B", traits: readonly string[]) => `${key} 组:${traits.join("、")}`;
return {
id: `probe:${semanticKey}`,
semantic_key: semanticKey,
candidate_split_hash: candidateSplitHash,
domain: "appearance",
year: 0,
question: boundary.user_meaning
?? "升点靠近两段日常节奏的交界。平时做事时,哪一组更像你?这只用来偏置时间窗,不能确认唯一分钟。",
candidate_ids: active.map((item) => item.id),
expected_outcomes: [
{
answer_class: "yes",
supports: candidatesFor(optionA.time_bias),
conflicts: conflictsFor(optionA.time_bias),
},
{
answer_class: "weak_yes",
supports: candidatesFor(optionB.time_bias),
conflicts: conflictsFor(optionB.time_bias),
},
{ answer_class: "no", supports: [], conflicts: [] },
{ answer_class: "unsure", supports: [], conflicts: [] },
],
information_gain: 0.01,
source: NAKSHATRA_BOUNDARY_SOURCE,
choice_kind: "varga_style",
style_options: [
{
label: optionLabel("A", optionA.traits),
answer_class: "yes",
sign: optionA.time_bias === "earlier" ? "较早时间窗" : "较晚时间窗",
},
{
label: optionLabel("B", optionB.traits),
answer_class: "weak_yes",
sign: optionB.time_bias === "earlier" ? "较早时间窗" : "较晚时间窗",
},
{ label: "两组都不太像我", answer_class: "no" },
{ label: "一时说不好", answer_class: "unsure" },
],
};
}
export function withNakshatraBoundaryProbe(
state: InferenceState | null,
boundary: NakshatraBoundary | null | undefined,
): InferenceState | null {
if (!state) return null;
const probe = nakshatraBoundaryProbe(state, boundary);
const existingIndexes = state.probes.flatMap((item, index) => (
item.source === NAKSHATRA_BOUNDARY_SOURCE ? [index] : []
));
if (!probe || isDuplicateProbe(probe, state.answered_probes)) {
if (existingIndexes.length === 0) return state;
return {
...state,
probes: state.probes.filter((item) => item.source !== NAKSHATRA_BOUNDARY_SOURCE),
};
}
const existing = existingIndexes.length > 0 ? state.probes[existingIndexes[0]!] : null;
if (
existingIndexes.length === 1
&& existing?.id === probe.id
&& existing.semantic_key === probe.semantic_key
&& existing.candidate_split_hash === probe.candidate_split_hash
) return state;
const probes = state.probes.filter((item) => item.source !== NAKSHATRA_BOUNDARY_SOURCE);
const insertAt = existingIndexes[0] ?? probes.length;
return {
...state,
probes: [
...probes.slice(0, insertAt),
probe,
...probes.slice(insertAt),
],
};
}
export function previousInferenceFromReceipt(
receipt: Readonly<Record<string, unknown>> | null | undefined,
): InferenceState | null {
return withNakshatraBoundaryProbe(
parsePreviousInferenceFromReceipt(receipt),
parseNakshatraBoundary(receipt?.nakshatra_boundary),
);
}
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 activeSpan = rangeFromTimes(activePoints);
const stillValidRange = unionStillValidRange(inference.candidates);
const receiptRange = inference.credible_range;
const activeRangesValid = Boolean(activeSpan) && 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] >= activeSpan![0]
&& item.cluster_range[1] <= activeSpan![1];
});
const receiptRangeMatches = Boolean(stillValidRange && receiptRange)
&& receiptRange![0] <= receiptRange![1]
&& receiptRange![0] === stillValidRange![0]
&& receiptRange![1] === stillValidRange![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 ? stillValidRange : null,
};
}
export function compactInferenceProjection(state: InferenceState | null | undefined): Record<string, unknown> | null {
if (!state) return null;
const next = selectHighestGainProbe(state.probes, state.answered_probes);
return {
algorithm_version: state.algorithm_version,
candidate_set_id: state.candidate_set_id,
revision: state.revision,
phase: state.result_status === "discriminating" && state.phase === "event_collection"
? "discrimination"
: state.phase,
result_status: state.result_status,
entropy: state.entropy,
representative_time: state.representative_time,
credible_range: state.credible_range,
candidates: state.candidates.map((item) => ({
id: item.id,
time: item.time,
probability: item.probability,
posterior_score: item.posterior_score,
status: item.status,
rank: item.rank,
cluster_range: item.cluster_range,
})),
next_probe: next
? {
semantic_key: next.semantic_key,
information_gain: next.information_gain,
question: next.question,
domain: next.domain,
year: next.year,
}
: null,
answered_probe_count: state.answered_probes.length,
last_inference_round: state.last_inference_round
? {
kind: state.last_inference_round.kind,
entropy_before: state.last_inference_round.entropy_before,
entropy_after: state.last_inference_round.entropy_after,
eliminated_ids: state.last_inference_round.eliminated_ids,
score_deltas: state.last_inference_round.score_deltas ?? {},
}
: null,
informative_round_count: state.rounds.filter((item) => item.kind === "informative").length,
};
}
export function buildCaseInferenceState(input: {
range: { start_time: string; end_time: string };
candidates: readonly Readonly<{ candidateId: string; time: string; relativeSupport: number }>[];
evidence: readonly Readonly<{
id: string;
domain: string;
occurredFrom: string | null;
datePrecision: string;
}>[];
probes: readonly DiscriminatingEventProbe[];
extraProbes?: readonly ConflictProbe[];
previous?: InferenceState | null;
transitionTimes?: readonly string[];
eventLedger?: Readonly<Record<string, Readonly<Record<string, number>>>>;
}): InferenceState {
const events = input.evidence.map((item) => ({
id: item.id,
domain: item.domain,
year: yearFrom(item.occurredFrom),
precision: asPrecision(item.datePrecision),
}));
const probes = [
...input.probes.flatMap((probe) => {
const mapped = probeFromEngine(probe);
return mapped ? [mapped] : [];
}),
...(input.extraProbes ?? []),
];
return buildInferenceState({
range_start: input.range.start_time,
range_end: input.range.end_time,
candidates: input.candidates.map((item) => ({
id: item.time,
time: item.time,
relative_support: item.relativeSupport,
})),
events,
probes,
previous: input.previous,
answered_probes: answersFromEvidence(probes, events),
transition_times: input.transitionTimes,
event_ledger: input.eventLedger,
});
}
function asRecord(value: unknown): Readonly<Record<string, unknown>> | null {
return value && typeof value === "object" && !Array.isArray(value)
? value as Readonly<Record<string, unknown>>
: null;
}
function asText(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
export function hasChoiceSchema(schema: unknown): boolean {
const row = asRecord(schema);
if (!row) return false;
if (asRecord(row.choice)) return true;
return Boolean(asText(row.semantic_key) || asText(row.probe_id) || asText(row.candidate_split_hash));
}
export function isHoldoutChoiceSchema(
schema: unknown,
questionId?: string | null,
userMessage?: string | null,
): boolean {
if (userMessage && isHoldoutVerificationQuote(userMessage)) return true;
if (questionId?.endsWith(":holdout")) return true;
const row = asRecord(schema);
return row?.scoring === false;
}
export function resolveChoiceKey(input: {
choiceKey?: string | null;
}): ChoiceKey | null {
const explicit = input.choiceKey?.trim().toUpperCase();
if (explicit === "A" || explicit === "B" || explicit === "C" || explicit === "D") return explicit;
return null;
}
export function matchProbeForChoice(
state: InferenceState,
schema: unknown,
domain?: string | null,
): ConflictProbe | null {
const row = asRecord(schema);
const probeId = asText(row?.probe_id);
const semanticKey = asText(row?.semantic_key);
const splitHash = asText(row?.candidate_split_hash);
const probes = state.probes;
if (probeId || semanticKey || splitHash) {
return probes.find((item) => (
(!probeId || item.id === probeId || item.semantic_key === probeId || probeId === `probe:${item.semantic_key}`)
&& (!semanticKey || item.semantic_key === semanticKey || item.id === semanticKey)
&& (!splitHash || item.candidate_split_hash === splitHash)
)) ?? null;
}
const unanswered = domain
? probes.filter((item) => item.domain === domain)
: probes;
return selectHighestGainProbe(unanswered.length > 0 ? unanswered : probes, state.answered_probes);
}
function probeMatchesPreferred(
probe: ConflictProbe,
preferred: { semantic_key?: string | null; candidate_split_hash?: string | null; probe_id?: string | null },
): boolean {
const semanticKey = preferred.semantic_key?.trim() || null;
const splitHash = preferred.candidate_split_hash?.trim() || null;
const probeId = preferred.probe_id?.trim() || null;
if (probeId && (probe.id === probeId || probe.semantic_key === probeId)) return true;
if (semanticKey && (probe.semantic_key === semanticKey || probe.id === semanticKey)) return true;
if (splitHash && probe.candidate_split_hash === splitHash) return true;
return false;
}
export function stampChoiceSchemaWithProbe(
schema: Readonly<Record<string, unknown>>,
state: InferenceState | null,
questionId: string,
preferred?: {
semantic_key?: string | null;
candidate_split_hash?: string | null;
probe_id?: string | null;
},
): Record<string, unknown> {
if (!hasChoiceSchema(schema)) return { ...schema };
const scoring = schema.scoring === false || questionId.endsWith(":holdout") ? false : true;
const preferredKey = preferred?.semantic_key?.trim() || asText(schema.semantic_key);
const preferredSplit = preferred?.candidate_split_hash?.trim() || asText(schema.candidate_split_hash);
const preferredId = preferred?.probe_id?.trim() || asText(schema.probe_id);
const matched = state?.probes.find((probe) => probeMatchesPreferred(probe, {
semantic_key: preferredKey,
candidate_split_hash: preferredSplit,
probe_id: preferredId,
})) ?? null;
if (preferredKey && !matched) {
if (state) {
const unstamped = { ...schema };
delete unstamped.probe_id;
delete unstamped.semantic_key;
delete unstamped.candidate_split_hash;
return { ...unstamped, scoring };
}
return {
...schema,
probe_id: preferredId ?? `probe:${preferredKey}`,
semantic_key: preferredKey,
candidate_split_hash: preferredSplit ?? preferredKey,
scoring,
};
}
const next = matched ?? (state ? selectHighestGainProbe(state.probes, state.answered_probes) : null);
if (!next) {
if (!preferredKey) return { ...schema };
return {
...schema,
probe_id: preferredId ?? `probe:${preferredKey}`,
semantic_key: preferredKey,
candidate_split_hash: preferredSplit ?? preferredKey,
scoring,
};
}
return {
...schema,
probe_id: next.id,
semantic_key: next.semantic_key,
candidate_split_hash: next.candidate_split_hash,
scoring,
};
}
export type ChoiceWithoutEvidenceResult = Readonly<{
applied: boolean;
reason: "applied" | "no_choice" | "holdout" | "no_probe" | "already_answered" | "stale_probe" | "superseded";
state: InferenceState;
answerClass: AnswerClass | null;
probeId: string | null;
}>;
export function applyChoiceWithoutEvidence(
state: InferenceState,
input: {
choiceKey?: string | null;
userMessage?: string | null;
schema?: unknown;
questionId?: string | null;
domain?: string | null;
classifiedFrom?: Extract<ProbeAnswer["classified_from"], "choice" | "declined">;
},
): ChoiceWithoutEvidenceResult {
if (!hasChoiceSchema(input.schema) && !input.choiceKey) {
return { applied: false, reason: "no_choice", state, answerClass: null, probeId: null };
}
if (isHoldoutChoiceSchema(input.schema, input.questionId, input.userMessage)) {
const choiceKey = resolveChoiceKey(input);
const answerClass = choiceKey ? classifyChoiceAnswer(choiceKey, input.schema) : null;
if (!answerClass) {
return { applied: false, reason: "holdout", state, answerClass: null, probeId: null };
}
return {
applied: true,
reason: "holdout",
state: applyHoldoutAnswer(state, answerClass),
answerClass,
probeId: asText(asRecord(input.schema)?.probe_id),
};
}
const choiceKey = resolveChoiceKey(input);
const declined = input.classifiedFrom === "declined";
if (!choiceKey && !declined) {
return { applied: false, reason: "no_choice", state, answerClass: null, probeId: null };
}
const schema = asRecord(input.schema);
const submittedProbeId = asText(schema?.probe_id);
const hasSubmittedProbeIdentity = Boolean(
submittedProbeId || asText(schema?.semantic_key) || asText(schema?.candidate_split_hash),
);
const probe = matchProbeForChoice(state, input.schema, input.domain);
if (!probe) {
return { applied: false, reason: hasSubmittedProbeIdentity ? "stale_probe" : "no_probe", state, answerClass: null, probeId: submittedProbeId };
}
const lastAnsweredId = state.answered_probes.at(-1)?.probe_id ?? null;
const answerClass = declined ? "unsure" : classifyChoiceAnswer(choiceKey!, input.schema);
if (!answerClass) {
return { applied: false, reason: "no_choice", state, answerClass: null, probeId: probe.id };
}
const existing = state.answered_probes.find((item) => (
item.probe_id === probe.id || item.semantic_key === probe.semantic_key
));
if (existing) {
if (existing.answer_class === answerClass) {
return { applied: false, reason: "already_answered", state, answerClass, probeId: probe.id };
}
if (probe.id === lastAnsweredId) {
return {
applied: true,
reason: "superseded",
state: applySupersedeAnswer(state, probe.id, answerClass, input.classifiedFrom),
answerClass,
probeId: probe.id,
};
}
return { applied: false, reason: "stale_probe", state, answerClass, probeId: probe.id };
}
if (isDuplicateProbe(probe, state.answered_probes)) {
return { applied: false, reason: "already_answered", state, answerClass, probeId: probe.id };
}
return {
applied: true,
reason: "applied",
state: applyAnswerToState(state, probe.id, answerClass, input.classifiedFrom),
answerClass,
probeId: probe.id,
};
}