voyage/lib/review/gold-scorer.mjs
Kjell Tore Guttormsen 440594f1b2 feat(eval): SKAL-1·4b offline gold-scored output eval
Scores committed agent-run fixtures against the golden corpus at
(file, rule_key) granularity, building on the deterministic coordinator
contract (4a). Offline: committed reviewer payloads, no live agent spawn,
no LLM, no network (the LLM-in-the-loop grading is the separate 4c tier).

- lib/review/gold-scorer.mjs: scoreFindings (precision/recall/f1 at
  (file,rule_key) granularity, line+severity ignored) + scoreVerdict; pure,
  with documented vacuous-set conventions.
- tests/fixtures/bakeoff-rich/runs/run-perfect.json: committed run that
  reproduces all 5 seeded gold findings through runContract.
- tests/lib/gold-eval.test.mjs: the scoring RUN (precision/recall/f1 = 1.0,
  verdict == expected_verdict BLOCK, nothing suppressed/skipped).
- lib/util/test-census.mjs: third census category (goldEval) — a scoring run
  is neither behavior coverage nor a doc-pin; honest-count invariant now 3-way.
- docs/eval-corpus/README.md: 4b moved from Future hardening to implemented.

Suite 809 -> 822 (820/0/2). gold-scorer covers TP+FP+FN+degenerate paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJQYC5vpkJWxndS55vQQZ6
2026-06-30 09:00:33 +02:00

89 lines
3.3 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// lib/review/gold-scorer.mjs
// SKAL-1·4b — offline gold-scored output eval scorer.
//
// Scores a recorded agent-run's findings against a golden corpus record
// (voyage-eval-gold/1, see docs/eval-corpus/README.md) at (file, rule_key)
// granularity. line and severity are deliberately NOT part of the match key:
// the eval asks "did the run surface this issue at all", and (file, rule_key)
// is the identity pair a delivered diff + the catalogue uniquely determine.
//
// Pairs with coordinator-contract.mjs (4a): that module turns committed
// reviewer payloads into the run findings scored here. This module is the
// offline counterpart to the live LLM-in-the-loop eval (4c) — pure: no I/O,
// no LLM, no network, no time, no randomness.
//
// Vacuous-set conventions (documented so the degenerate numbers are honest):
// - precision when nothing is predicted (tp+fp === 0) -> 1 (no false positives)
// - recall when there is nothing to find (tp+fn === 0) -> 1 (found all zero)
// - f1 collapses to 0 whenever precision or recall is 0, so it stays the
// honest single-number summary for the degenerate run/gold cases.
// NUL separator: file paths and rule_keys never contain it, so the join is
// an injective (file, rule_key) -> string key.
const SEP = '';
function pairKey(f) {
return `${f.file}${SEP}${f.rule_key}`;
}
// Human-readable form of a pair key (for matched/missed/spurious reporting).
function pairLabel(key) {
return key.replace(SEP, ' ');
}
// Set of unique (file, rule_key) pair keys from a findings list. Findings
// missing either field are skipped (they cannot identify a pair).
function pairSet(findings) {
const s = new Set();
for (const f of findings ?? []) {
if (f && typeof f.file === 'string' && typeof f.rule_key === 'string') {
s.add(pairKey(f));
}
}
return s;
}
/**
* Score run findings against gold findings at (file, rule_key) granularity.
* @param {object[]|null|undefined} runFindings findings produced by the recorded run
* @param {object[]|null|undefined} goldFindings the golden corpus findings
* @returns {{
* tp: number, fp: number, fn: number,
* precision: number, recall: number, f1: number,
* matched: string[], missed: string[], spurious: string[]
* }} matched/missed/spurious are "<file> <rule_key>" labels.
*/
export function scoreFindings(runFindings, goldFindings) {
const runPairs = pairSet(runFindings);
const goldPairs = pairSet(goldFindings);
const matched = [];
const spurious = [];
for (const p of runPairs) {
(goldPairs.has(p) ? matched : spurious).push(pairLabel(p));
}
const missed = [];
for (const p of goldPairs) {
if (!runPairs.has(p)) missed.push(pairLabel(p));
}
const tp = matched.length;
const fp = spurious.length;
const fn = missed.length;
const precision = tp + fp === 0 ? 1 : tp / (tp + fp);
const recall = tp + fn === 0 ? 1 : tp / (tp + fn);
const f1 = precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall);
return { tp, fp, fn, precision, recall, f1, matched, missed, spurious };
}
/**
* Exact match of a run's coordinator verdict against the gold expected_verdict.
* @param {string} runVerdict
* @param {string} goldVerdict
* @returns {boolean}
*/
export function scoreVerdict(runVerdict, goldVerdict) {
return runVerdict === goldVerdict;
}