Files
santifer--career-ops/eval-golden.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

265 lines
11 KiB
JavaScript

#!/usr/bin/env node
/**
* eval-golden.mjs — golden-set eval harness for cheap-model routing (#1354)
*
* The *mechanism* in this file is design-invariant: load labeled golden cases,
* obtain each candidate model's `---SCORE_SUMMARY---` block (replayed from a
* recorded fixture for $0 deterministic CI, or live via openai-eval.mjs),
* compare it to the reference label, and exit 0/1 on an aggregate threshold.
*
* Labels are frozen reference (Claude-tier) verdicts — the metric is
* agreement-with-reference, not absolute correctness (see evals/README.md).
* ARCHETYPE agreement is the gate; SCORE is a secondary tolerance-banded signal.
*
* The remaining knobs are surfaced as named constants so they stay trivial to
* tune, with safe v1 defaults:
* - SCORE agreement → SCORE_TOLERANCE (±band; distance-to-reference)
* - CI gate threshold → MIN_ARCHETYPE_AGREEMENT
* - per-model $/run → COST_PER_RUN_USD (empty until routing rates agreed)
*
* Usage:
* node eval-golden.mjs --replay --model cheap-stub # offline, deterministic ($0)
* node eval-golden.mjs --live --model gpt-4o-mini # calls openai-eval.mjs (needs key + cv.md)
* npm run eval:golden -- --replay --model cheap-stub
*/
import { readFileSync, readdirSync, existsSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
import { join, dirname } from 'path';
import { tmpdir } from 'os';
import { fileURLToPath } from 'url';
import { spawnSync } from 'child_process';
const ROOT = dirname(fileURLToPath(import.meta.url));
const GOLDEN_DIR = join(ROOT, 'evals', 'golden');
// ---------------------------------------------------------------------------
// Tunable knobs (#1354), with safe v1 defaults — see evals/README.md.
// ---------------------------------------------------------------------------
/** Max |candidate.score - label.score| still counted as agreement.
* v1: a ±band, per the distance-to-reference metric (not exact match). */
const SCORE_TOLERANCE = 0.5;
/** Fraction of cases whose ARCHETYPE must match the label for the gate to pass.
* ARCHETYPE exact-match is the clean 0/1 signal hinted at in the issue.
* v1 default: 0.8 — tunable when this is wired into the CI job. */
const MIN_ARCHETYPE_AGREEMENT = 0.8;
/** Rough $/run per model id, for the cost column. Empty until the routing
* provider rates are agreed (#1354). */
const COST_PER_RUN_USD = {};
// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`eval-golden.mjs — golden-set eval harness (#1354)
--replay Replay recorded fixtures (default; offline, $0, deterministic)
--live Call the model live via openai-eval.mjs (needs key + cv.md)
--model <id> Candidate model id to evaluate (default: cheap-stub)
--golden <dir> Golden-set directory (default: evals/golden)
--fixtures <dir> Replay fixtures directory (default: sibling of --golden)
--help Show this help
`);
process.exit(0);
}
const mode = args.includes('--live') ? 'live' : 'replay';
const model = argValue('--model') || 'cheap-stub';
const goldenDir = argValue('--golden') || GOLDEN_DIR;
// Keep fixtures next to the golden set so a custom --golden dir resolves its
// own fixtures (the default lands on evals/fixtures); override with --fixtures.
const fixtureDir = argValue('--fixtures') || join(dirname(goldenDir), 'fixtures');
/**
* Read the value following a `--flag` token in argv.
*
* @param {string} flag - The flag whose following value to return.
* @returns {string|undefined} The value, or undefined if the flag is absent/last.
*/
function argValue(flag) {
const i = args.indexOf(flag);
return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
}
/**
* Flatten a model id into a filesystem-safe fixture token.
*
* Provider ids like "deepseek/deepseek-chat" contain slashes that would be read
* as path separators; collapse any non-[A-Za-z0-9._-] run to a single "-" so the
* fixture stays a single flat file.
*
* @param {string} m - The candidate model id.
* @returns {string} A path-safe token for the fixture filename.
*/
function fixtureModelId(m) {
return m.replace(/[^A-Za-z0-9._-]+/g, '-');
}
// ---------------------------------------------------------------------------
// Shared SCORE_SUMMARY parser — same contract every *-eval.mjs already emits.
// ---------------------------------------------------------------------------
/**
* Parse the machine-readable summary block produced by the eval scripts.
*
* @param {string} text - Raw model output containing a SCORE_SUMMARY block.
* @returns {{score: number, archetype: string}} Parsed score/archetype; score
* is NaN and archetype is "unknown" when the block is missing or malformed.
*/
function parseSummary(text) {
const block = text.match(/---SCORE_SUMMARY---\s*([\s\S]*?)---END_SUMMARY---/);
const field = (key) => {
const m = block && block[1].match(new RegExp(`${key}:\\s*(.+)`));
return m ? m[1].trim() : '';
};
return {
score: parseFloat(field('SCORE')),
archetype: (field('ARCHETYPE') || 'unknown').toLowerCase(),
};
}
// ---------------------------------------------------------------------------
// Obtain one candidate completion (replay fixture or live openai-eval call).
// ---------------------------------------------------------------------------
/**
* Return the candidate model's raw evaluation text for one golden case.
*
* In replay mode this reads a recorded fixture so the gate is offline and
* deterministic; in live mode it shells out to openai-eval.mjs, reusing the
* real prompt-assembly path rather than duplicating it here.
*
* @param {{id: string, jd: string}} testCase - The golden case being run.
* @returns {string} Raw model output (expected to contain a SCORE_SUMMARY block).
*/
function getCompletion(testCase) {
if (mode === 'replay') {
// Slash-form provider ids (e.g. "deepseek/deepseek-chat") must not become
// path separators, or the fixture lands in a phantom subdirectory. Sanitize
// to a flat filename — record fixtures under the same sanitized name.
const fixture = join(fixtureDir, `${testCase.id}__${fixtureModelId(model)}.txt`);
if (!existsSync(fixture)) {
throw new Error(`missing replay fixture: ${fixture} — record it or run --live`);
}
return readFileSync(fixture, 'utf8');
}
// live: write the JD to a temp file and run the existing evaluator.
const dir = mkdtempSync(join(tmpdir(), 'eval-golden-'));
try {
const jdFile = join(dir, 'jd.txt');
writeFileSync(jdFile, testCase.jd);
const res = spawnSync(process.execPath,
[join(ROOT, 'openai-eval.mjs'), '--file', jdFile, '--model', model, '--no-save'],
{ encoding: 'utf8', env: process.env, timeout: 360000 });
if (res.status !== 0) {
throw new Error(`openai-eval.mjs exited ${res.status}: ${(res.stderr || '').slice(0, 200)}`);
}
return res.stdout || '';
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
/**
* Median of a numeric array (0 for an empty array).
*
* @param {number[]} xs - Values to summarize.
* @returns {number} The median value.
*/
function median(xs) {
if (xs.length === 0) return 0;
const s = [...xs].sort((a, b) => a - b);
const mid = Math.floor(s.length / 2);
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
}
// ---------------------------------------------------------------------------
// Run
// ---------------------------------------------------------------------------
if (!existsSync(goldenDir)) {
console.error(`❌ golden-set directory not found: ${goldenDir}`);
process.exit(1);
}
let cases;
try {
cases = readdirSync(goldenDir)
.filter((f) => f.endsWith('.json'))
.map((f) => {
const parsed = JSON.parse(readFileSync(join(goldenDir, f), 'utf8'));
if (typeof parsed?.id !== 'string' ||
typeof parsed?.jd !== 'string' ||
typeof parsed?.label?.archetype !== 'string' ||
typeof parsed?.label?.score !== 'number') {
throw new Error(`invalid golden case ${f}: need string id/jd and label.{archetype:string, score:number}`);
}
return parsed;
});
} catch (err) {
console.error(`❌ ${err.message || err}`);
process.exit(1);
}
if (cases.length === 0) {
console.error(`❌ no golden cases (*.json) in ${goldenDir}`);
process.exit(1);
}
console.log(`\ngolden-set eval — model "${model}" (${mode}), ${cases.length} case(s)`);
console.log(`(row ✅ needs both archetype + score; the gate counts archetype agreement only)\n`);
let archetypeHits = 0;
const deltas = [];
const latencies = [];
for (const tc of cases) {
const t0 = Date.now();
let parsed;
try {
parsed = parseSummary(getCompletion(tc));
} catch (err) {
console.log(` ❌ ${tc.id}: ${err.message}`);
deltas.push(NaN);
continue;
}
const latencyMs = Date.now() - t0;
latencies.push(latencyMs);
const archetypeMatch = parsed.archetype === String(tc.label.archetype).toLowerCase();
const delta = Math.abs(parsed.score - tc.label.score);
const scoreOk = Number.isFinite(delta) && delta <= SCORE_TOLERANCE;
if (archetypeMatch) archetypeHits++;
deltas.push(delta);
const ok = archetypeMatch && scoreOk;
console.log(
` ${ok ? '✅' : '❌'} ${tc.id}: ` +
`archetype ${parsed.archetype} vs ${String(tc.label.archetype).toLowerCase()} ` +
`(${archetypeMatch ? 'match' : 'MISS'}); ` +
`score ${parsed.score} vs ${tc.label.score}${Number.isFinite(delta) ? delta.toFixed(2) : 'n/a'}); ` +
`${mode === 'live' ? `${latencyMs}ms` : 'replay'}`,
);
}
const agreement = archetypeHits / cases.length;
const finiteDeltas = deltas.filter(Number.isFinite);
const meanDelta = finiteDeltas.length ? finiteDeltas.reduce((a, b) => a + b, 0) / finiteDeltas.length : NaN;
// Cases whose SCORE was missing/malformed produce a NaN delta and drop out of
// the mean — surface that count so a model can't hide failures behind a low mean.
const unscored = cases.length - finiteDeltas.length;
const cost = COST_PER_RUN_USD[model];
console.log('\n ── summary ──');
console.log(` archetype agreement : ${(agreement * 100).toFixed(0)}% (gate ≥ ${(MIN_ARCHETYPE_AGREEMENT * 100).toFixed(0)}%)`);
console.log(` mean |Δscore| : ${Number.isFinite(meanDelta) ? meanDelta.toFixed(2) : 'n/a'} over ${finiteDeltas.length}/${cases.length} scored${unscored ? ` (${unscored} unscored)` : ''} (tolerance ±${SCORE_TOLERANCE})`);
if (mode === 'live') console.log(` median latency : ${median(latencies)}ms`);
console.log(` est. $/run : ${cost != null ? `$${cost}` : 'n/a — TODO(#1354)'}`);
const passed = agreement >= MIN_ARCHETYPE_AGREEMENT;
console.log(`\n ${passed ? '✅ PASS' : '❌ FAIL'} — archetype agreement ${passed ? 'meets' : 'below'} gate\n`);
process.exit(passed ? 0 : 1);