/** * Candidate-discriminator contract shared by engine parsers, follow-ups, and CI. * * A distinguish probe must split at least two candidates into at least two * expected outcomes with positive information gain. Question wording is not * part of the contract. */ const CLOCK = /^(?:[01]?\d|2[0-3]):[0-5]\d$/; export type DistinguishContractProbe = Readonly<{ role?: string | null; phase?: string | null; information_gain?: number; informationGain?: number; candidate_ids?: readonly unknown[] | null; candidateIds?: readonly unknown[] | null; expected_outcomes?: readonly unknown[] | null; expectedOutcomes?: readonly unknown[] | null; }>; export function isDistinguishRole(probe: DistinguishContractProbe): boolean { return probe.role === "distinguish" || probe.phase === "candidate_discriminator"; } function asClock(value: unknown): string | null { if (typeof value !== "string") return null; const time = value.slice(0, 5); return CLOCK.test(time) ? (time.length === 5 ? time : time.padStart(5, "0")) : null; } function outcomeRows(probe: DistinguishContractProbe): readonly Readonly>[] { const rows = probe.expected_outcomes ?? probe.expectedOutcomes ?? []; if (!Array.isArray(rows)) return []; return rows.flatMap((row) => ( row && typeof row === "object" && !Array.isArray(row) ? [row as Readonly>] : [] )); } export function candidateIdsFromProbe(probe: DistinguishContractProbe): string[] { const direct = probe.candidate_ids ?? probe.candidateIds ?? []; const ids: string[] = []; const seen = new Set(); const push = (value: unknown) => { const time = asClock(value); if (!time || seen.has(time)) return; seen.add(time); ids.push(time); }; if (Array.isArray(direct)) { for (const item of direct) push(item); } for (const row of outcomeRows(probe)) { for (const key of ["supports", "conflicts", "supportsCandidateIds", "conflictsCandidateIds"]) { const values = row[key]; if (!Array.isArray(values)) continue; for (const item of values) push(item); } } return ids; } export function distinguishContractErrors(probe: DistinguishContractProbe): string[] { if (!isDistinguishRole(probe)) return []; const errors: string[] = []; const outcomes = outcomeRows(probe); if (outcomes.length < 2) errors.push("distinguish_empty_expected_outcomes"); if (candidateIdsFromProbe(probe).length < 2) errors.push("distinguish_empty_candidate_ids"); const gainRaw = "information_gain" in probe ? probe.information_gain : probe.informationGain; const gain = typeof gainRaw === "number" && Number.isFinite(gainRaw) ? gainRaw : 0; if (gain <= 0) errors.push("distinguish_non_positive_information_gain"); return errors; } export function isValidDistinguishProbe(probe: DistinguishContractProbe): boolean { return isDistinguishRole(probe) && distinguishContractErrors(probe).length === 0; }