598 lines
22 KiB
TypeScript
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,
|
|
};
|
|
}
|