import { SCORE_DELTA, STRONG_CONFLICT_ELIMINATION_COUNT, type AnswerClass, type ConflictProbe, type ProbeOutcome, type ScoreDirection, } from "./types.ts"; export type ProbeApplyResult = Readonly<{ scores: Readonly>; eliminated_ids: readonly string[]; kind: "informative" | "low_information"; deltas: Readonly>; strong_conflict_counts: Readonly>; }>; export function outcomeForAnswer(probe: ConflictProbe, answer: AnswerClass): ProbeOutcome | null { return probe.expected_outcomes.find((item) => item.answer_class === answer) ?? null; } export function directionFor( candidateId: string, outcome: ProbeOutcome, choiceKind?: ConflictProbe["choice_kind"], ): ScoreDirection { const halfWeight = outcome.answer_class === "weak_yes" && choiceKind !== "varga_style"; if (outcome.supports.includes(candidateId)) return halfWeight ? "weak_support" : "support"; if (outcome.conflicts.includes(candidateId)) return halfWeight ? "weak_conflict" : "conflict"; return "neutral"; } /** * Pure reducer: every active candidate is updated from the same probe outcome. * Probe answers move scores; only repeated cumulative conflict can eliminate. * Unsure answers are low-information. */ export function applyProbeOutcome( scores: Readonly>, probe: ConflictProbe, answer: AnswerClass, options: { eliminatedIds?: ReadonlySet; strongConflictCounts?: Readonly>; } = {}, ): ProbeApplyResult { const eliminated = new Set(options.eliminatedIds ?? []); const outcome = outcomeForAnswer(probe, answer); const deltas: Record = {}; const next: Record = {}; const conflictCounts: Record = 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, strong_conflict_counts: conflictCounts, }; } for (const [id, score] of Object.entries(scores)) { const direction = directionFor(id, outcome, probe.choice_kind); if (direction === "conflict") conflictCounts[id] = (conflictCounts[id] ?? 0) + 1; if (eliminated.has(id)) { next[id] = score; deltas[id] = 0; continue; } const delta = SCORE_DELTA[direction]; deltas[id] = delta; next[id] = score + delta; } 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 { scores: next, eliminated_ids: [...eliminated], kind: changed ? "informative" : "low_information", deltas, strong_conflict_counts: conflictCounts, }; }