openpencil/scripts/ab-corpus/run.ts
Fini 51878c3894 feat(scripts): ab-corpus harness with multi-provider model adapters
Harness at scripts/ab-corpus/ wires the pen-ai-skills corpus evaluator to
real model endpoints and pen-mcp handlers:

run.ts            — CLI entry (--dry-run / --live / --models A,B,C / --only ID)
apply.ts          — ApplyFn impl dispatching tool_call → element handler
                    and batch_design DSL → handleBatchDesign, against a
                    fresh tmp .op per run (isolated, auto-cleanup)
build-prompt.ts   — B variant strips elements.md + appends batch_design
                    <op_tool> format instruction; T keeps elements + adds
                    element-tool PRIMARY / batch_design FALLBACK
                    instruction. Uniform <op_tool> wrapper in both arms
                    isolates "tool set width" as the only A/B variable.
stub-model.ts     — fixture-based offline model for --dry-run
real-model.ts     — router by model id (minimax* / gpt-*/o* / glm-5.1 /
                    glm-* / kimi-*)
clients/
  openai-compat.ts — generic chat/completions POST
  minimax.ts       — api.minimax.io/v1, MINIMAX_API_KEY
  codex-cli.ts     — spawns `codex exec` (GPT-5.4 via Codex Pro sub)
  bailian.ts       — coding.dashscope.aliyuncs.com/v1 CP,
                     DASHSCOPE_BAILIAN_CODING_KEY (hosts glm-4.7, kimi-k2.5)
  glm.ts           — open.bigmodel.cn/api/coding/paas/v4 official CP,
                     GLM_OFFICIAL_CODING_KEY
write-report.ts   — Report → report.md + report.json in out dir;
                    4-way routing breakdown table per model

Kept entirely outside packages/ — scripts are a local dev tool, not part
of the published SDK. API keys never hit disk or git.

v1 run results logged separately in openpencil-docs
superpowers/notes/2026-04-20-ab-v1-results.md (5 models × 24 prompts).
2026-04-20 23:53:23 +08:00

151 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
loadCorpus,
parseModelOutput,
scoreRun,
aggregate,
type ScoreRow,
} from '@zseven-w/pen-ai-skills';
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;
}
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(),
};
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 === '--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]\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',
'ab-v0',
);
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`);
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);
}
}
process.stderr.write(` · ${prompt.id}\n`);
}
writeJsonl(join(args.outDir, 'scores.jsonl'), rows);
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`);
}
function writeJsonl(path: string, rows: ScoreRow[]): void {
const body = rows.map((r) => JSON.stringify(r)).join('\n') + '\n';
mkdirSync(dirname(path), { recursive: true });
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('node:fs').writeFileSync(path, body, 'utf-8');
}
main().catch((err) => {
process.stderr.write(`\nFATAL: ${err instanceof Error ? err.stack : String(err)}\n`);
process.exit(1);
});