The first 12-prompt × 2-model × 2-variant sweep (48 API calls) ran 29 minutes before I killed it. A Kimi call on the 10th prompt hung indefinitely — no client-side timeout — and the harness writes scores.jsonl + report.md ONLY at the end, so partial progress was unrecoverable. Lost 9/12 completed prompts because the aggregate step never ran. Two fixes: 1. openai-compat.ts: AbortController with default 120s timeout (overridable via AB_CORPUS_CALL_TIMEOUT_MS env). When a call exceeds the budget, the harness catches the abort, records it as __HARNESS_ERROR__ (routing=garbage, M1=false), and moves on. Verified by dialing the timeout to 60s — GLM-5.1's first call took >60s, got aborted cleanly, run continued to completion instead of hanging. 2. run.ts: append each ScoreRow to scores.jsonl immediately after scoring. Truncate at start (so re-runs overwrite). Lost-work window now bounded to "the currently-executing API call," not "everything since the run started." report.md and report.json still write once at the end (aggregate needs the full set) but scores.jsonl alone is enough for any partial-run analysis. Post-hardening validation (live 方舟 CP runs): - mobile-upload-dropzone → add_upload_dropzone_v0 ✓ right-tool - dashboard-dark-modal → add_modal_shell_v1 (theme=dark) ✓ Second one is the first end-to-end proof that the v1 theme-aware tool family routes correctly with a real LLM — GLM-5.1 inferred \`theme: "dark"\` from the natural-language prompt.
167 lines
6.1 KiB
TypeScript
167 lines
6.1 KiB
TypeScript
#!/usr/bin/env bun
|
||
/**
|
||
* Element-tools A/B corpus eval — harness entry point.
|
||
*
|
||
* Usage:
|
||
* bun scripts/ab-corpus/run.ts --dry-run # stub model, exercises pipeline
|
||
* bun scripts/ab-corpus/run.ts --dry-run --out ./tmp-out # custom output dir
|
||
* bun scripts/ab-corpus/run.ts --models minimax-m2,glm-5 # real run (needs API keys)
|
||
* bun scripts/ab-corpus/run.ts --only mobile-filter-chips # single prompt
|
||
*
|
||
* Spec: ~/workspace/openpencil-docs/superpowers/plans/2026-04-20-element-tools-ab-corpus.md
|
||
*/
|
||
|
||
import { mkdirSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { parseModelOutput, scoreRun, aggregate, type ScoreRow } from '@zseven-w/pen-ai-skills';
|
||
// Node-only: pulls in `node:fs`, so it's NOT re-exported from the
|
||
// package barrel (which must stay browser-safe for the embedded
|
||
// orchestrator's design-parser). Package.json `exports` only declares
|
||
// the main entry, so sub-path imports via the package name fail at
|
||
// runtime — use a relative path to the source file instead. Harness
|
||
// runs under Bun from the repo root so this path is stable.
|
||
import { loadCorpus } from '../../packages/pen-ai-skills/src/corpus/corpus-loader';
|
||
import { applyToFreshDoc } from './apply';
|
||
import { stubModelCall, type ModelCall } from './stub-model';
|
||
import { realModelCall } from './real-model';
|
||
import { writeReport } from './write-report';
|
||
|
||
interface CliArgs {
|
||
dryRun: boolean;
|
||
models: string[];
|
||
only?: string;
|
||
outDir: string;
|
||
corpus: 'ab-v0' | 'ab-v1';
|
||
}
|
||
|
||
function parseArgs(argv: string[]): CliArgs {
|
||
const args: CliArgs = {
|
||
dryRun: false,
|
||
// Default matches plan §2 after the 2026-04-20 update: user supplied
|
||
// MiniMax M2.7 as the weak-model candidate and Codex CLI (GPT-5.4)
|
||
// as the reference ceiling. Claude / GLM / KIMI are not in the
|
||
// default set until keys / adapters land.
|
||
models: ['gpt-5.4', 'minimax-m2.7'],
|
||
outDir: defaultOutDir(),
|
||
corpus: 'ab-v0',
|
||
};
|
||
for (let i = 0; i < argv.length; i += 1) {
|
||
const a = argv[i];
|
||
if (a === '--dry-run') args.dryRun = true;
|
||
else if (a === '--models') args.models = (argv[++i] ?? '').split(',').filter(Boolean);
|
||
else if (a === '--only') args.only = argv[++i];
|
||
else if (a === '--out') args.outDir = argv[++i] ?? args.outDir;
|
||
else if (a === '--corpus') {
|
||
const v = argv[++i];
|
||
if (v !== 'ab-v0' && v !== 'ab-v1') {
|
||
process.stderr.write(`--corpus must be 'ab-v0' or 'ab-v1', got: ${v}\n`);
|
||
process.exit(1);
|
||
}
|
||
args.corpus = v;
|
||
} else if (a === '--help' || a === '-h') {
|
||
printUsage();
|
||
process.exit(0);
|
||
}
|
||
}
|
||
return args;
|
||
}
|
||
|
||
function defaultOutDir(): string {
|
||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||
return join(fileURLToPath(new URL('.', import.meta.url)), 'runs', ts);
|
||
}
|
||
|
||
function printUsage(): void {
|
||
process.stderr.write(
|
||
`Usage: bun scripts/ab-corpus/run.ts [--dry-run] [--models ID,ID,...] [--only prompt-id] [--out DIR] [--corpus ab-v0|ab-v1]\n`,
|
||
);
|
||
}
|
||
|
||
async function main(): Promise<void> {
|
||
const args = parseArgs(process.argv.slice(2));
|
||
const corpusDir = join(
|
||
fileURLToPath(new URL('.', import.meta.url)),
|
||
'..',
|
||
'..',
|
||
'packages',
|
||
'pen-ai-skills',
|
||
'corpus',
|
||
args.corpus,
|
||
);
|
||
const prompts = loadCorpus(corpusDir).filter((p) => (args.only ? p.id === args.only : true));
|
||
if (prompts.length === 0) {
|
||
process.stderr.write(
|
||
`No prompts matched (--only=${args.only ?? 'none'}). Corpus dir: ${corpusDir}\n`,
|
||
);
|
||
process.exit(1);
|
||
}
|
||
|
||
mkdirSync(args.outDir, { recursive: true });
|
||
process.stderr.write(
|
||
`Running ${prompts.length} prompts × ${args.models.length} models × 2 variants = ${prompts.length * args.models.length * 2} runs\n`,
|
||
);
|
||
process.stderr.write(`Output: ${args.outDir}\n`);
|
||
process.stderr.write(`Mode: ${args.dryRun ? 'DRY-RUN (stub model)' : 'LIVE'}\n\n`);
|
||
|
||
// Stream scores to scores.jsonl as each run completes so a
|
||
// kill-at-minute-30 (hung API call, accidental ^C) doesn't lose
|
||
// all results. Final report.md still requires a full sweep for
|
||
// aggregate counts — but scores.jsonl alone is useful for any
|
||
// partial-run analysis.
|
||
const scoresPath = join(args.outDir, 'scores.jsonl');
|
||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||
const fs = require('node:fs') as typeof import('node:fs');
|
||
// Truncate on start so a re-run into the same dir overwrites.
|
||
fs.writeFileSync(scoresPath, '', 'utf-8');
|
||
|
||
const rows: ScoreRow[] = [];
|
||
for (const prompt of prompts) {
|
||
for (const model of args.models) {
|
||
for (const variant of ['B', 'T'] as const) {
|
||
const call: ModelCall = {
|
||
model,
|
||
prompt,
|
||
variant,
|
||
systemPrompt: '<resolved in dispatcher>',
|
||
userPrompt: prompt.prompt,
|
||
};
|
||
let raw: string;
|
||
try {
|
||
raw = args.dryRun ? await stubModelCall(call) : await realModelCall(call);
|
||
} catch (err) {
|
||
// Network / subprocess failure → treat as garbage so the
|
||
// run still scores (M1=false, routing='garbage' for obvious
|
||
// treatment). Beats aborting a 96-run sweep over one
|
||
// transient failure.
|
||
raw = `__HARNESS_ERROR__: ${err instanceof Error ? err.message : String(err)}`;
|
||
}
|
||
const parsed = parseModelOutput(raw);
|
||
const row = await scoreRun({
|
||
prompt,
|
||
parsed,
|
||
apply: applyToFreshDoc,
|
||
model,
|
||
variant,
|
||
});
|
||
rows.push(row);
|
||
// Append this row to scores.jsonl immediately — durable
|
||
// partial state for crash recovery.
|
||
fs.appendFileSync(scoresPath, JSON.stringify(row) + '\n', 'utf-8');
|
||
}
|
||
}
|
||
process.stderr.write(` · ${prompt.id}\n`);
|
||
}
|
||
|
||
const report = aggregate(rows);
|
||
const { mdPath, jsonPath } = writeReport(args.outDir, report);
|
||
process.stderr.write(`\nReport: ${mdPath}\n`);
|
||
process.stderr.write(`JSON: ${jsonPath}\n`);
|
||
process.stderr.write(`Scores: ${join(args.outDir, 'scores.jsonl')}\n`);
|
||
}
|
||
|
||
main().catch((err) => {
|
||
process.stderr.write(`\nFATAL: ${err instanceof Error ? err.stack : String(err)}\n`);
|
||
process.exit(1);
|
||
});
|