Files
Jyotisha/frontend/src/lib/rectification-agentic/core/apply-probe-outcome.ts
T
2026-08-29 00:15:21 +08:00

101 lines
3.3 KiB
TypeScript

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 {
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<Record<string, number>>,
probe: ConflictProbe,
answer: AnswerClass,
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,
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,
};
}