Showing a choice card is no longer treated as completion. Distinguish probes require real candidate groups, holdout stays out of scoring, and ordinary sessions can finish with a credible range instead of an exact-minute gate. Co-authored-by: Cursor <cursoragent@cursor.com>
80 lines
2.9 KiB
TypeScript
80 lines
2.9 KiB
TypeScript
/**
|
|
* 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<Record<string, unknown>>[] {
|
|
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<Record<string, unknown>>]
|
|
: []
|
|
));
|
|
}
|
|
|
|
export function candidateIdsFromProbe(probe: DistinguishContractProbe): string[] {
|
|
const direct = probe.candidate_ids ?? probe.candidateIds ?? [];
|
|
const ids: string[] = [];
|
|
const seen = new Set<string>();
|
|
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;
|
|
}
|