366 lines
13 KiB
TypeScript
366 lines
13 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 { askedKeysFromLedgerEvidence } from "../core/candidate-contrast-packet.ts";
|
|
import type { AnswerClass, ConflictProbe, InferenceState } from "../core/types.ts";
|
|
import {
|
|
isHoldoutVerificationQuote,
|
|
parseChoiceKeyFromUserMessage,
|
|
type ChoiceKey,
|
|
} from "./choice-card.ts";
|
|
import type { DiscriminatingEventProbe } 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 === "day" || value === "month" || value === "year") return value;
|
|
return "unknown";
|
|
}
|
|
|
|
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<{
|
|
domain?: string | null;
|
|
eventKind?: string | null;
|
|
summary?: string | null;
|
|
}>[] = [],
|
|
): string[] {
|
|
return [
|
|
...askedProbeKeysFromReceipt(receipt),
|
|
...askedKeysFromLedgerEvidence(evidence),
|
|
];
|
|
}
|
|
|
|
export { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
|
|
|
|
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;
|
|
status?: "resolved" | "declined" | "skipped";
|
|
userMessage?: string | null;
|
|
}): ChoiceKey | null {
|
|
const explicit = input.choiceKey?.trim().toUpperCase();
|
|
if (explicit === "A" || explicit === "B" || explicit === "C" || explicit === "D") return explicit;
|
|
const fromMessage = parseChoiceKeyFromUserMessage(input.userMessage);
|
|
if (fromMessage) return fromMessage;
|
|
if (input.status === "declined") return "C";
|
|
if (input.status === "skipped") return "D";
|
|
if (input.status === "resolved") return "B";
|
|
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) {
|
|
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;
|
|
status?: "resolved" | "declined" | "skipped";
|
|
userMessage?: string | null;
|
|
schema?: unknown;
|
|
questionId?: string | null;
|
|
domain?: string | null;
|
|
},
|
|
): ChoiceWithoutEvidenceResult {
|
|
if (!hasChoiceSchema(input.schema) && !input.choiceKey && !parseChoiceKeyFromUserMessage(input.userMessage)) {
|
|
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);
|
|
if (!choiceKey) {
|
|
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 = classifyChoiceAnswer(choiceKey, input.schema);
|
|
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),
|
|
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),
|
|
answerClass,
|
|
probeId: probe.id,
|
|
};
|
|
}
|