|
| 1 | +// Benchmark proposal scorer (#9262, harness #9216, epic #8534) — multi-class scoring built ON the existing |
| 2 | +// confusion-matrix and Pareto-floor primitives, deliberately NOT beside them. |
| 3 | +// |
| 4 | +// REUSE, NOT REIMPLEMENTATION. `scoreBacktest` (backtest-score.ts) and `compareBacktestScores` |
| 5 | +// (backtest-compare.ts) remain the scoring core; this module is an adapter that reshapes (proposal, ground |
| 6 | +// truth) pairs into the exact inputs those functions already take. A second scoring implementation is |
| 7 | +// precisely the drift this module exists to prevent, so the anti-drift guarantee is mechanical rather than |
| 8 | +// aspirational: an equivalent binary case scored here and scored through the internal backtest path produces |
| 9 | +// the identical report, asserted directly in the tests. |
| 10 | +// |
| 11 | +// The reuse works by borrowing the primitive's positive-class slot per action: for action A, the "positive" |
| 12 | +// class is "the realized action was A" and the classifier's answer is "the agent proposed A". The primitive |
| 13 | +// names that slot `reversed`/`confirmed` because its first caller scored rule reversals; the name is |
| 14 | +// vestigial here and never surfaces in this module's own output. The `ruleId` slot carries the ACTION name, |
| 15 | +// which makes each per-action report self-labeling and makes `compareBacktestScores`' rule-mismatch throw |
| 16 | +// double as a guard against accidentally comparing two different actions. |
| 17 | +// |
| 18 | +// AGGREGATION — the recorded decision (#9262 requirement 2). MACRO is the headline; MICRO is published |
| 19 | +// alongside. They answer different questions and the difference is not cosmetic here: |
| 20 | +// |
| 21 | +// Micro pools the counts across actions. Under single-label multi-class scoring — one prediction per work |
| 22 | +// unit — every decided unit contributes exactly one TP or one FP, so micro precision, micro recall and |
| 23 | +// plain accuracy are all the SAME number. That makes it an honest "how often was it right overall", and |
| 24 | +// also makes it dominated by whichever action is most frequent. In this corpus that is overwhelmingly |
| 25 | +// `merge`, so an agent that answers "merge" to everything scores well on micro while being worthless. |
| 26 | +// |
| 27 | +// Macro averages the per-action metrics, so each action counts equally regardless of frequency, and the |
| 28 | +// answer-merge-to-everything agent is immediately exposed by its floor-level `close`/`request_changes` |
| 29 | +// numbers. Since the benchmark's question is "can this agent make MAINTAINER decisions" — including the |
| 30 | +// rare, expensive ones — macro is the number that answers it, and therefore the headline. |
| 31 | +// |
| 32 | +// Both are published because a benchmark that reports only its headline invites the reader to reconstruct |
| 33 | +// the other one wrongly. An action with no realized instances has null metrics and is EXCLUDED from the |
| 34 | +// macro mean rather than counted as 0 — a metric nobody could measure must not drag an average down. |
| 35 | +// |
| 36 | +// Same purity contract as the rest of this module family: no IO, no randomness, no wall-clock reads. |
| 37 | + |
| 38 | +import type { BacktestCase } from "./backtest-corpus.js"; |
| 39 | +import { scoreBacktest, type BacktestScoreReport } from "./backtest-score.js"; |
| 40 | +import { compareBacktestScores, type BacktestComparison } from "./backtest-compare.js"; |
| 41 | +import type { BenchmarkActionKind, BenchmarkProposal } from "./benchmark-proposal.js"; |
| 42 | +import { scoreableGroundTruths, type BenchmarkGroundTruthSet } from "./benchmark-ground-truth.js"; |
| 43 | + |
| 44 | +/** Every action scored, in a fixed order so two reports are directly comparable field by field. */ |
| 45 | +export const SCORED_ACTIONS: readonly BenchmarkActionKind[] = ["merge", "close", "request_changes", "label", "hold"]; |
| 46 | + |
| 47 | +export type BenchmarkScoreReport = { |
| 48 | + schemaVersion: 1; |
| 49 | + snapshotRef: string; |
| 50 | + /** WHO was scored — carried through from the proposals, opaque here exactly as in #9215's EvalScoreRecord. */ |
| 51 | + subjectId: string; |
| 52 | + /** One-vs-rest report per action, keyed by action, each produced by the shared `scoreBacktest`. */ |
| 53 | + perAction: Record<BenchmarkActionKind, BacktestScoreReport>; |
| 54 | + /** The HEADLINE (see the module header's recorded decision). `actionsScored` is how many actions had a |
| 55 | + * non-null metric and therefore entered the mean — a macro number over 2 of 5 actions is a different |
| 56 | + * claim than one over 5, and hiding that would be the kind of unexamined average this file argues against. */ |
| 57 | + macro: { precision: number | null; recall: number | null; actionsScored: number }; |
| 58 | + /** Pooled counts. Under single-label scoring micro precision === micro recall === accuracy; all three are |
| 59 | + * the same number and it is reported once, honestly labeled, rather than three times as if independent. */ |
| 60 | + micro: { precision: number | null; recall: number | null; accuracy: number | null }; |
| 61 | + /** #9215's coverage semantics. Abstentions are NEVER folded into errors: they lower coverage, which is a |
| 62 | + * different (and recoverable) thing than being wrong. */ |
| 63 | + coverage: { |
| 64 | + decided: number; |
| 65 | + abstained: number; |
| 66 | + /** `decided / (decided + abstained)`; null when the agent faced nothing at all — never 0, which would |
| 67 | + * read as "answered nothing it was asked" rather than "was asked nothing". */ |
| 68 | + coverage: number | null; |
| 69 | + /** Ground-truth units excluded before scoring began (#9261's `unresolved`) — published so a reader can |
| 70 | + * see the denominator shrink rather than discovering it in a footnote. */ |
| 71 | + unresolvedExcluded: number; |
| 72 | + /** Proposals for work units not in this snapshot's ground truth. Ignored for scoring (a submitter |
| 73 | + * cannot inflate anything by padding), but COUNTED, because a nonzero value means the agent is |
| 74 | + * answering a different question than the one asked. */ |
| 75 | + unscorableProposals: number; |
| 76 | + }; |
| 77 | +}; |
| 78 | + |
| 79 | +function ratio(numerator: number, denominator: number): number | null { |
| 80 | + return denominator > 0 ? Math.round((numerator / denominator) * 1000) / 1000 : null; |
| 81 | +} |
| 82 | + |
| 83 | +/** Mean of the non-null values, or null when none are — an unmeasurable metric leaves the average rather |
| 84 | + * than entering it as 0. */ |
| 85 | +function macroMean(values: ReadonlyArray<number | null>): { mean: number | null; counted: number } { |
| 86 | + const present = values.filter((value): value is number => value !== null); |
| 87 | + if (present.length === 0) return { mean: null, counted: 0 }; |
| 88 | + return { mean: Math.round((present.reduce((sum, value) => sum + value, 0) / present.length) * 1000) / 1000, counted: present.length }; |
| 89 | +} |
| 90 | + |
| 91 | +/** |
| 92 | + * Score one agent's proposals for one snapshot against #9261's realized ground truth. |
| 93 | + * |
| 94 | + * Denominator discipline, in the order it is applied: |
| 95 | + * 1. `unresolved` ground truth leaves entirely (#9261) — never a correct abstention, never an error. |
| 96 | + * 2. A scoreable unit the agent DID NOT answer counts as an abstention, identically to an explicit |
| 97 | + * `{kind: "abstain"}`. Silence and a declared abstention are the same act; scoring them differently |
| 98 | + * would reward whichever one an agent's emitter happened to produce. |
| 99 | + * 3. Abstentions lower coverage and are absent from every confusion-matrix count. |
| 100 | + */ |
| 101 | +export function scoreBenchmarkProposals(input: { |
| 102 | + subjectId: string; |
| 103 | + groundTruth: BenchmarkGroundTruthSet; |
| 104 | + proposals: readonly BenchmarkProposal[]; |
| 105 | +}): BenchmarkScoreReport { |
| 106 | + const scoreable = scoreableGroundTruths(input.groundTruth); |
| 107 | + const scoreableIds = new Set(scoreable.map((truth) => truth.workUnitId)); |
| 108 | + |
| 109 | + // Last proposal per work unit wins, so a resubmission is a correction rather than a double entry. |
| 110 | + const proposalByUnit = new Map<string, BenchmarkProposal>(); |
| 111 | + let unscorableProposals = 0; |
| 112 | + for (const proposal of input.proposals) { |
| 113 | + if (!scoreableIds.has(proposal.workUnitId)) { |
| 114 | + unscorableProposals += 1; |
| 115 | + continue; |
| 116 | + } |
| 117 | + proposalByUnit.set(proposal.workUnitId, proposal); |
| 118 | + } |
| 119 | + |
| 120 | + // The decided set: scoreable units the agent actually answered with an action. |
| 121 | + const decided: Array<{ realized: BenchmarkActionKind; predicted: BenchmarkActionKind }> = []; |
| 122 | + let abstained = 0; |
| 123 | + for (const truth of scoreable) { |
| 124 | + const proposal = proposalByUnit.get(truth.workUnitId); |
| 125 | + if (!proposal || proposal.prediction.kind === "abstain") { |
| 126 | + abstained += 1; |
| 127 | + continue; |
| 128 | + } |
| 129 | + decided.push({ realized: truth.action, predicted: proposal.prediction.action.kind }); |
| 130 | + } |
| 131 | + |
| 132 | + // One-vs-rest through the SHARED primitive. The synthetic cases carry the action in the `ruleId` slot so |
| 133 | + // each report is self-labeling and a cross-action comparison throws rather than silently succeeding. |
| 134 | + const perAction = {} as Record<BenchmarkActionKind, BacktestScoreReport>; |
| 135 | + for (const action of SCORED_ACTIONS) { |
| 136 | + const cases: BacktestCase[] = decided.map((pair, index) => ({ |
| 137 | + ruleId: action, |
| 138 | + targetKey: String(index), |
| 139 | + outcome: pair.realized, |
| 140 | + label: pair.realized === action ? "reversed" : "confirmed", |
| 141 | + firedAt: input.groundTruth.frozenAt, |
| 142 | + decidedAt: input.groundTruth.horizonEnd, |
| 143 | + metadata: { predicted: pair.predicted }, |
| 144 | + })); |
| 145 | + perAction[action] = scoreBacktest(action, cases, (backtestCase) => |
| 146 | + (backtestCase.metadata as { predicted: BenchmarkActionKind }).predicted === action ? "reversed" : "confirmed", |
| 147 | + ); |
| 148 | + } |
| 149 | + |
| 150 | + const macroPrecision = macroMean(SCORED_ACTIONS.map((action) => perAction[action].precision)); |
| 151 | + const macroRecall = macroMean(SCORED_ACTIONS.map((action) => perAction[action].recall)); |
| 152 | + // Pooled: exactly one TP or FP per decided unit, so this single number IS precision, recall and accuracy. |
| 153 | + const correct = decided.filter((pair) => pair.predicted === pair.realized).length; |
| 154 | + const micro = ratio(correct, decided.length); |
| 155 | + |
| 156 | + return { |
| 157 | + schemaVersion: 1, |
| 158 | + snapshotRef: input.groundTruth.snapshotRef, |
| 159 | + subjectId: input.subjectId, |
| 160 | + perAction, |
| 161 | + macro: { |
| 162 | + precision: macroPrecision.mean, |
| 163 | + recall: macroRecall.mean, |
| 164 | + // The two means are taken over the same actions whenever both are defined; report the precision |
| 165 | + // side's count, which is the one the headline precision is an average of. |
| 166 | + actionsScored: macroPrecision.counted, |
| 167 | + }, |
| 168 | + micro: { precision: micro, recall: micro, accuracy: micro }, |
| 169 | + coverage: { |
| 170 | + decided: decided.length, |
| 171 | + abstained, |
| 172 | + coverage: ratio(decided.length, decided.length + abstained), |
| 173 | + unresolvedExcluded: input.groundTruth.coverage.unresolved, |
| 174 | + unscorableProposals, |
| 175 | + }, |
| 176 | + }; |
| 177 | +} |
| 178 | + |
| 179 | +export type BenchmarkComparison = { |
| 180 | + subjectId: string; |
| 181 | + perAction: Record<BenchmarkActionKind, BacktestComparison>; |
| 182 | + regressedActions: BenchmarkActionKind[]; |
| 183 | + improvedActions: BenchmarkActionKind[]; |
| 184 | + /** The Pareto floor, extended to the multi-class case (#9262 requirement 4): ANY regressed action decides |
| 185 | + * the verdict, even alongside improvements elsewhere. Gaining on `merge` while losing on `close` is a |
| 186 | + * trade, and the floor's entire purpose is that a trade is not a win. */ |
| 187 | + verdict: "improved" | "regressed" | "unchanged"; |
| 188 | +}; |
| 189 | + |
| 190 | +/** |
| 191 | + * Compare two benchmark score reports under the Pareto floor, per action, via the SHARED comparator. |
| 192 | + * |
| 193 | + * Every per-action verdict comes from `compareBacktestScores`, so the null-handling ("unknown stays |
| 194 | + * unknown": an axis with a null on either side is excluded from both lists) is inherited rather than |
| 195 | + * re-derived. Throws on a subject mismatch — comparing two different agents' reports as if they were one |
| 196 | + * agent's before/after is a caller bug, the same posture the primitive takes on a rule mismatch. |
| 197 | + */ |
| 198 | +export function compareBenchmarkScores(baseline: BenchmarkScoreReport, candidate: BenchmarkScoreReport): BenchmarkComparison { |
| 199 | + if (baseline.subjectId !== candidate.subjectId) { |
| 200 | + throw new Error(`cannot compare benchmark scores for different subjects: ${baseline.subjectId} vs ${candidate.subjectId}`); |
| 201 | + } |
| 202 | + const perAction = {} as Record<BenchmarkActionKind, BacktestComparison>; |
| 203 | + const regressedActions: BenchmarkActionKind[] = []; |
| 204 | + const improvedActions: BenchmarkActionKind[] = []; |
| 205 | + for (const action of SCORED_ACTIONS) { |
| 206 | + const comparison = compareBacktestScores(baseline.perAction[action], candidate.perAction[action]); |
| 207 | + perAction[action] = comparison; |
| 208 | + if (comparison.verdict === "regressed") regressedActions.push(action); |
| 209 | + else if (comparison.verdict === "improved") improvedActions.push(action); |
| 210 | + } |
| 211 | + return { |
| 212 | + subjectId: baseline.subjectId, |
| 213 | + perAction, |
| 214 | + regressedActions, |
| 215 | + improvedActions, |
| 216 | + verdict: regressedActions.length > 0 ? "regressed" : improvedActions.length > 0 ? "improved" : "unchanged", |
| 217 | + }; |
| 218 | +} |
0 commit comments