|
| 1 | +import type { Decision } from "@corbits/system-one"; |
| 2 | + |
| 3 | +import { caseName } from "./evaluate.js"; |
| 4 | +import { DEFAULTS, decideBatch, toOutcome, type Ask, type Judge, type Sentence } from "./jev.js"; |
| 5 | +import { mapPool, runOnce, type RunSpec } from "./runner.js"; |
| 6 | +import { toTraced, type Runnable } from "./runs.js"; |
| 7 | +import { toMs, type Duration } from "./timeout.js"; |
| 8 | +import type { Outcome } from "./types.js"; |
| 9 | + |
| 10 | +/** A case with a known verdict per check, e.g. hand-labeled or from `must`. */ |
| 11 | +export type Labeled<I = unknown, E = unknown> = { |
| 12 | + name?: string; |
| 13 | + input: I; |
| 14 | + expect?: E; |
| 15 | + /** The expected verdict per check name. */ |
| 16 | + labels: Readonly<Record<string, boolean>>; |
| 17 | +}; |
| 18 | + |
| 19 | +export type CalibrateOptions<I, O, E> = { |
| 20 | + run: Runnable<I, O>; |
| 21 | + cases: readonly Labeled<I, E>[]; |
| 22 | + checks: Record<string, Ask | Sentence | string>; |
| 23 | + judge: Judge; |
| 24 | + concurrency?: number; |
| 25 | + /** Bounds each case run. The Jev call is bounded by `judge.timeout`. */ |
| 26 | + timeout?: Duration; |
| 27 | +}; |
| 28 | + |
| 29 | +export type CalibrationRow = { |
| 30 | + threshold: number; |
| 31 | + agrees: number; |
| 32 | + unsure: number; |
| 33 | + disagrees: number; |
| 34 | +}; |
| 35 | + |
| 36 | +export type CalibrationCheck = { |
| 37 | + rows: CalibrationRow[]; |
| 38 | + /** The most permissive threshold with no confident disagreements. */ |
| 39 | + recommended: number; |
| 40 | + /** The override to paste into the check or judge. */ |
| 41 | + setting: string; |
| 42 | +}; |
| 43 | + |
| 44 | +export type Calibration = { |
| 45 | + name: string; |
| 46 | + total: number; |
| 47 | + checks: Record<string, CalibrationCheck>; |
| 48 | + markdown(): string; |
| 49 | +}; |
| 50 | + |
| 51 | +/** The sweep. 0.5 is chance for a binary; 0.95 leaves almost nothing unsure. */ |
| 52 | +const GRID = [0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95]; |
| 53 | + |
| 54 | +function r3(n: number): number { |
| 55 | + return Math.round(n * 1000) / 1000; |
| 56 | +} |
| 57 | + |
| 58 | +function isUnsure(o: Outcome): boolean { |
| 59 | + return o.explanation?.startsWith("unsure (") === true; |
| 60 | +} |
| 61 | + |
| 62 | +/** |
| 63 | + * The sweep owns the bars: a check's own `sure`/`yes`/`no` overrides would |
| 64 | + * outrank every candidate judge (check-level wins over judge-level in |
| 65 | + * `thresholdsFor`), making every row identical and the recommendation a no-op. |
| 66 | + */ |
| 67 | +function sweepable(check: Ask | Sentence | string): Ask | Sentence | string { |
| 68 | + if (typeof check === "string") return check; |
| 69 | + const copy: Record<string, unknown> = { ...check }; |
| 70 | + delete copy["sure"]; |
| 71 | + delete copy["yes"]; |
| 72 | + delete copy["no"]; |
| 73 | + return copy as Ask | Sentence; |
| 74 | +} |
| 75 | + |
| 76 | +/** |
| 77 | + * Run a labeled sample through Jev once and sweep the pass bars, so |
| 78 | + * thresholds become a per-workload choice instead of a constant. Yes/no |
| 79 | + * checks sweep `yes` with `no` mirrored; `ask` sweeps `sure`. |
| 80 | + */ |
| 81 | +export async function calibrate<I, O, E = O>( |
| 82 | + name: string, |
| 83 | + options: CalibrateOptions<I, O, E>, |
| 84 | +): Promise<Calibration> { |
| 85 | + if (name.trim() === "") throw new Error("calibrate needs a name"); |
| 86 | + const names = Object.keys(options.checks); |
| 87 | + if (names.length === 0) throw new Error(`calibrate "${name}" has no checks`); |
| 88 | + const run = toTraced(options.run); |
| 89 | + const timeoutMs = options.timeout === undefined ? undefined : toMs(options.timeout, "timeout"); |
| 90 | + const concurrency = options.concurrency ?? 4; |
| 91 | + if (!Number.isInteger(concurrency) || concurrency < 1) { |
| 92 | + throw new Error(`calibrate "${name}": concurrency must be a whole number >= 1`); |
| 93 | + } |
| 94 | + |
| 95 | + type Sample = { decision: Decision; label: boolean }; |
| 96 | + const samples: Record<string, Sample[]> = {}; |
| 97 | + for (const checkName of names) samples[checkName] = []; |
| 98 | + |
| 99 | + const spec: RunSpec<I, O, E> = { |
| 100 | + cases: [], |
| 101 | + run, |
| 102 | + checks: {}, |
| 103 | + repeats: 1, |
| 104 | + concurrency: 1, |
| 105 | + ...(timeoutMs === undefined ? {} : { timeoutMs }), |
| 106 | + }; |
| 107 | + |
| 108 | + await mapPool(options.cases, concurrency, async (labeled) => { |
| 109 | + const key = caseName(labeled); |
| 110 | + for (const checkName of names) { |
| 111 | + if (labeled.labels[checkName] === undefined) { |
| 112 | + throw new Error(`calibrate "${name}": case "${key}" has no label for check "${checkName}"`); |
| 113 | + } |
| 114 | + } |
| 115 | + const evalCase = { |
| 116 | + name: key, |
| 117 | + input: labeled.input, |
| 118 | + ...(labeled.expect === undefined ? {} : { expect: labeled.expect }), |
| 119 | + }; |
| 120 | + const runResult = await runOnce(spec, evalCase); |
| 121 | + const decisions = await decideBatch(options.judge, options.checks, runResult); |
| 122 | + for (const checkName of names) { |
| 123 | + const decision = decisions[checkName]; |
| 124 | + if (decision === undefined) { |
| 125 | + throw new Error(`calibrate "${name}": Jev returned no decision for "${checkName}"`); |
| 126 | + } |
| 127 | + samples[checkName]?.push({ decision, label: labeled.labels[checkName] as boolean }); |
| 128 | + } |
| 129 | + }); |
| 130 | + |
| 131 | + const checks: Record<string, CalibrationCheck> = {}; |
| 132 | + for (const checkName of names) { |
| 133 | + const sample = samples[checkName] ?? []; |
| 134 | + const check = options.checks[checkName] as Ask | Sentence | string; |
| 135 | + const isQuestion = |
| 136 | + typeof check !== "string" && |
| 137 | + (("options" in check && check.options !== undefined) || |
| 138 | + ("levels" in check && check.levels !== undefined)); |
| 139 | + const base = options.judge.thresholds ?? DEFAULTS; |
| 140 | + const rows: CalibrationRow[] = []; |
| 141 | + for (const threshold of GRID) { |
| 142 | + const judge: Judge = { |
| 143 | + config: {}, |
| 144 | + thresholds: isQuestion |
| 145 | + ? { yes: base.yes, no: base.no, sure: threshold } |
| 146 | + : { yes: threshold, no: r3(1 - threshold), sure: base.sure }, |
| 147 | + }; |
| 148 | + let agrees = 0; |
| 149 | + let unsure = 0; |
| 150 | + let disagrees = 0; |
| 151 | + for (const s of sample) { |
| 152 | + const o = toOutcome(sweepable(check), s.decision, judge); |
| 153 | + if (isUnsure(o)) unsure += 1; |
| 154 | + else if (o.pass === s.label) agrees += 1; |
| 155 | + else disagrees += 1; |
| 156 | + } |
| 157 | + rows.push({ threshold, agrees, unsure, disagrees }); |
| 158 | + } |
| 159 | + const clean = rows.find((row) => row.disagrees === 0); |
| 160 | + const recommended = |
| 161 | + clean?.threshold ?? rows.reduce((a, b) => (b.disagrees <= a.disagrees ? b : a)).threshold; |
| 162 | + const setting = isQuestion |
| 163 | + ? `{ sure: ${recommended} }` |
| 164 | + : `{ yes: ${recommended}, no: ${r3(1 - recommended)} }`; |
| 165 | + checks[checkName] = { rows, recommended, setting }; |
| 166 | + } |
| 167 | + |
| 168 | + const data = { name, total: options.cases.length, checks }; |
| 169 | + return { ...data, markdown: () => toMarkdown(data) }; |
| 170 | +} |
| 171 | + |
| 172 | +function toMarkdown(data: { |
| 173 | + name: string; |
| 174 | + total: number; |
| 175 | + checks: Record<string, CalibrationCheck>; |
| 176 | +}): string { |
| 177 | + const lines = [`# calibrate: ${data.name}`, ``, `- cases: ${data.total}`, ``]; |
| 178 | + for (const [checkName, t] of Object.entries(data.checks)) { |
| 179 | + lines.push( |
| 180 | + `## ${checkName}`, |
| 181 | + ``, |
| 182 | + `| threshold | agrees | unsure | disagrees |`, |
| 183 | + `| --- | --- | --- | --- |`, |
| 184 | + ); |
| 185 | + for (const row of t.rows) { |
| 186 | + lines.push(`| ${row.threshold} | ${row.agrees} | ${row.unsure} | ${row.disagrees} |`); |
| 187 | + } |
| 188 | + lines.push(``, `recommended: \`${t.setting}\``, ``); |
| 189 | + } |
| 190 | + return lines.join("\n"); |
| 191 | +} |
0 commit comments