mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
2b7b98f685
Pack text into a continuous sentinel-delimited stream (↵ = U+21B5) so
wrapLines fills every row to `cols` instead of leaving dead right-margin.
Adds reflow/dereflow, renderTextToPngsReflow{,MultiCol} variants, a full
test suite, and an A/B eval harness with L1 OCR + L2 session results.
266 lines
9.7 KiB
JavaScript
266 lines
9.7 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* eval/run-eval.mjs — Top-level orchestrator
|
||
*
|
||
* Runs corpus extraction, then L1 and/or L2 evals, then writes a combined
|
||
* summary report.
|
||
*
|
||
* Usage:
|
||
* node eval/run-eval.mjs [--level 1|2|all] [--dry-run] [--confirm] [options]
|
||
*
|
||
* Examples:
|
||
* # Dry run (no API key needed):
|
||
* node eval/run-eval.mjs --dry-run
|
||
*
|
||
* # Cost estimate only (no API calls, no --confirm needed):
|
||
* node eval/run-eval.mjs --estimate-only
|
||
*
|
||
* # Real L1-only run:
|
||
* node eval/run-eval.mjs --level 1 --confirm
|
||
*
|
||
* # Full run:
|
||
* node eval/run-eval.mjs --level all --confirm
|
||
*/
|
||
|
||
import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
|
||
import { join, resolve, dirname } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { spawnSync } from 'node:child_process';
|
||
import { parseArgs } from 'node:util';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// CLI
|
||
// ---------------------------------------------------------------------------
|
||
const { values: args } = parseArgs({
|
||
options: {
|
||
'level': { type: 'string', default: 'all' }, // 1 | 2 | all
|
||
'dry-run': { type: 'boolean', default: false },
|
||
'confirm': { type: 'boolean', default: false },
|
||
'estimate-only': { type: 'boolean', default: false },
|
||
'max-blocks': { type: 'string', default: '20' },
|
||
'max-sessions': { type: 'string', default: '10' },
|
||
'model': { type: 'string', default: 'claude-sonnet-4-5' },
|
||
'judge-model': { type: 'string', default: '' },
|
||
'corpus-dir': { type: 'string', default: join(__dirname, 'corpus') },
|
||
'out-dir': { type: 'string', default: join(__dirname, 'results') },
|
||
'skip-extract': { type: 'boolean', default: false },
|
||
'verbose': { type: 'boolean', default: false },
|
||
'help': { type: 'boolean', default: false },
|
||
},
|
||
allowPositionals: false,
|
||
});
|
||
|
||
if (args.help) {
|
||
console.log(`
|
||
Usage: node eval/run-eval.mjs [options]
|
||
|
||
Options:
|
||
--level 1|2|all Which eval level(s) to run (default: all)
|
||
--dry-run Run without API calls (fake scores, no API key needed)
|
||
--confirm Confirm real API spend (required for live runs)
|
||
--estimate-only Print cost estimate and exit (no API calls)
|
||
--max-blocks N L1: max text blocks (default: 20)
|
||
--max-sessions N L2: max sessions (default: 10)
|
||
--model NAME Anthropic model (default: claude-sonnet-4-5)
|
||
--judge-model NAME Judge model for L2 (default: same as --model)
|
||
--corpus-dir DIR Corpus directory (default: eval/corpus)
|
||
--out-dir DIR Results output directory (default: eval/results)
|
||
--skip-extract Skip corpus extraction (use existing corpus)
|
||
--verbose Verbose output
|
||
--help Show this help
|
||
`);
|
||
process.exit(0);
|
||
}
|
||
|
||
const LEVEL = args['level'];
|
||
const DRY_RUN = args['dry-run'];
|
||
const CONFIRMED = args['confirm'];
|
||
const ESTIMATE_ONLY = args['estimate-only'];
|
||
|
||
const log = (...a) => console.log('[run-eval]', ...a);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/** Run a node script as a child process, passing through stdout/stderr. */
|
||
function runScript(scriptPath, extraArgs = []) {
|
||
const argv = [scriptPath, ...extraArgs];
|
||
log(`Running: node ${argv.join(' ')}`);
|
||
const result = spawnSync('node', argv, {
|
||
stdio: 'inherit',
|
||
cwd: resolve(__dirname, '..'),
|
||
shell: false,
|
||
});
|
||
if (result.status !== 0) {
|
||
log(`ERROR: ${scriptPath} exited with status ${result.status}`);
|
||
process.exit(result.status ?? 1);
|
||
}
|
||
}
|
||
|
||
/** Build the common flag array for sub-scripts. */
|
||
function commonFlags() {
|
||
const flags = [];
|
||
if (DRY_RUN) flags.push('--dry-run');
|
||
if (CONFIRMED) flags.push('--confirm');
|
||
if (args.verbose) flags.push('--verbose');
|
||
flags.push('--corpus-dir', resolve(args['corpus-dir']));
|
||
flags.push('--out-dir', resolve(args['out-dir']));
|
||
flags.push('--model', args.model);
|
||
return flags;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Estimate-only mode: just print cost and exit
|
||
// ---------------------------------------------------------------------------
|
||
|
||
if (ESTIMATE_ONLY) {
|
||
log('Estimate-only mode: extracting corpus and computing cost estimate …');
|
||
|
||
// Extract corpus first if needed
|
||
if (!existsSync(join(resolve(args['corpus-dir']), 'text-blocks.json'))) {
|
||
runScript(join(__dirname, 'extract-corpus.mjs'), [
|
||
'--max-blocks', args['max-blocks'],
|
||
'--max-sessions', args['max-sessions'],
|
||
'--out-dir', resolve(args['corpus-dir']),
|
||
]);
|
||
}
|
||
|
||
const { printCostEstimate } = await import('./lib/cost.mjs');
|
||
const blocksPath = join(resolve(args['corpus-dir']), 'text-blocks.json');
|
||
const sessionsPath = join(resolve(args['corpus-dir']), 'sessions.json');
|
||
|
||
const l1Blocks = existsSync(blocksPath) ? JSON.parse(readFileSync(blocksPath, 'utf8')) : [];
|
||
const l2Sessions = existsSync(sessionsPath) ? JSON.parse(readFileSync(sessionsPath, 'utf8')) : [];
|
||
|
||
printCostEstimate({ l1Blocks, l2Sessions }, args.model);
|
||
process.exit(0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Step 1: Corpus extraction
|
||
// ---------------------------------------------------------------------------
|
||
|
||
if (!args['skip-extract']) {
|
||
log('Step 1/3: Extracting corpus …');
|
||
runScript(join(__dirname, 'extract-corpus.mjs'), [
|
||
'--max-blocks', args['max-blocks'],
|
||
'--max-sessions', args['max-sessions'],
|
||
'--out-dir', resolve(args['corpus-dir']),
|
||
...(args.verbose ? ['--verbose'] : []),
|
||
]);
|
||
} else {
|
||
log('Step 1/3: Skipping corpus extraction (--skip-extract)');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Step 2: Run requested eval levels
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const runL1 = LEVEL === '1' || LEVEL === 'all';
|
||
const runL2 = LEVEL === '2' || LEVEL === 'all';
|
||
|
||
if (runL1) {
|
||
log('Step 2/3: Running L1 OCR fidelity eval …');
|
||
runScript(join(__dirname, 'eval-l1-ocr.mjs'), [
|
||
...commonFlags(),
|
||
'--max-blocks', args['max-blocks'],
|
||
]);
|
||
}
|
||
|
||
if (runL2) {
|
||
log('Step 2/3: Running L2 session replay eval …');
|
||
const judgeFlag = args['judge-model']
|
||
? ['--judge-model', args['judge-model']]
|
||
: [];
|
||
runScript(join(__dirname, 'eval-l2-session.mjs'), [
|
||
...commonFlags(),
|
||
'--max-sessions', args['max-sessions'],
|
||
...judgeFlag,
|
||
]);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Step 3: Write combined summary report
|
||
// ---------------------------------------------------------------------------
|
||
|
||
log('Step 3/3: Writing combined report …');
|
||
|
||
const OUT_DIR = resolve(args['out-dir']);
|
||
mkdirSync(OUT_DIR, { recursive: true });
|
||
|
||
const l1ReportPath = join(OUT_DIR, 'l1-report.md');
|
||
const l2ReportPath = join(OUT_DIR, 'l2-report.md');
|
||
const l1JsonPath = join(OUT_DIR, 'l1-results.json');
|
||
const l2JsonPath = join(OUT_DIR, 'l2-results.json');
|
||
|
||
const l1Results = existsSync(l1JsonPath) ? JSON.parse(readFileSync(l1JsonPath, 'utf8')) : null;
|
||
const l2Results = existsSync(l2JsonPath) ? JSON.parse(readFileSync(l2JsonPath, 'utf8')) : null;
|
||
|
||
const now = new Date().toISOString();
|
||
const dryNote = DRY_RUN ? ' *(dry run — scores are simulated)*' : '';
|
||
|
||
const combinedLines = [
|
||
`# Reflow Eval — Combined Summary Report`,
|
||
``,
|
||
`**Generated:** ${now}${dryNote} `,
|
||
`**Model:** ${args.model} `,
|
||
`**Levels run:** ${[runL1 && 'L1', runL2 && 'L2'].filter(Boolean).join(', ')}`,
|
||
``,
|
||
`## Overview`,
|
||
``,
|
||
l1Results ? [
|
||
`### L1: OCR Fidelity`,
|
||
``,
|
||
`| | Baseline | Reflow | Δ |`,
|
||
`|--|---------|--------|---|`,
|
||
`| Mean char accuracy | ${(l1Results.baselineAgg.meanAccuracy * 100).toFixed(2)}% | ${(l1Results.reflowAgg.meanAccuracy * 100).toFixed(2)}% | ${((l1Results.reflowAgg.meanAccuracy - l1Results.baselineAgg.meanAccuracy) * 100).toFixed(2)}pp |`,
|
||
`| Macro accuracy | ${(l1Results.baselineAgg.macroAccuracy * 100).toFixed(2)}% | ${(l1Results.reflowAgg.macroAccuracy * 100).toFixed(2)}% | ${((l1Results.reflowAgg.macroAccuracy - l1Results.baselineAgg.macroAccuracy) * 100).toFixed(2)}pp |`,
|
||
`| Image savings | — | ${l1Results.imageSavingsPct.toFixed(1)}% | |`,
|
||
``,
|
||
`Full L1 report: [l1-report.md](l1-report.md)`,
|
||
``,
|
||
].join('\n') : '*(L1 not run)*',
|
||
|
||
``,
|
||
|
||
l2Results ? [
|
||
`### L2: Session Replay`,
|
||
``,
|
||
`| | Value |`,
|
||
`|--|------|`,
|
||
`| Mean judge score | ${(l2Results.meanScore * 100).toFixed(1)}% |`,
|
||
`| Pass rate (≥ 0.75) | ${(l2Results.passRate * 100).toFixed(1)}% |`,
|
||
`| Image savings | ${l2Results.imageSavingsPct.toFixed(1)}% |`,
|
||
``,
|
||
`Full L2 report: [l2-report.md](l2-report.md)`,
|
||
``,
|
||
].join('\n') : '*(L2 not run)*',
|
||
|
||
``,
|
||
`## Shipping Gate`,
|
||
``,
|
||
`Reflow is **safe to ship** if ALL of the following hold:`,
|
||
``,
|
||
`- [ ] L1 mean accuracy delta ≥ −2pp (reflow OCR not materially worse)`,
|
||
`- [ ] L1 macro accuracy ≥ 95% (overall character fidelity high)`,
|
||
`- [ ] L2 mean judge score ≥ 0.80 (task comprehension preserved)`,
|
||
`- [ ] L2 pass rate ≥ 80% (failures are rare outliers)`,
|
||
``,
|
||
`If any gate fails, investigate the failing sessions/blocks before shipping.`,
|
||
``,
|
||
`## How to Interpret`,
|
||
``,
|
||
`See [README.md](README.md) for full guidance on running and interpreting each level.`,
|
||
``,
|
||
DRY_RUN ? `> ⚠️ **Dry-run mode**: all scores are simulated. Re-run with \`--confirm\` to get real scores.` : '',
|
||
];
|
||
|
||
const combinedPath = join(OUT_DIR, 'summary.md');
|
||
writeFileSync(combinedPath, combinedLines.join('\n'), 'utf8');
|
||
|
||
log(`Combined summary written to ${combinedPath}`);
|
||
log('Done.');
|