e41eaace8d
Co-authored-by: Cursor <cursoragent@cursor.com>
101 lines
3.3 KiB
TypeScript
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,
|
|
};
|
|
}
|