2026-04-26 11:39:14 +00:00
|
|
|
|
#!/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';
|
2026-04-27 00:15:00 +00:00
|
|
|
|
import { join } from 'node:path';
|
2026-04-26 11:39:14 +00:00
|
|
|
|
import { fileURLToPath } from 'node:url';
|
feat(ab-corpus): bootstrap ab-v3 with token cost + composite difficulty
ab-v3 succeeds ab-v1 (frozen 2026-04-28). Carries forward all 40
v1 obvious yaml files unchanged so the v1↔v3 overlap stays
comparable, then layers in two new dimensions.
**1. Token cost.** All clients (openai-compat, ark, bailian,
deepseek, minimax, codex-cli, stub-model) now return a
`ChatCallResult { content, usage }` instead of bare string.
Provider usage stats (`prompt_tokens` / `completion_tokens`) plumb
through realModelCall → run.ts → scoreRun → ScoreRow.{prompt,completion}Tokens.
aggregate adds avgPromptTokens{Baseline,Treatment} +
avgCompletionTokens{Baseline,Treatment} per ModelSummary.
write-report emits a new "Token cost" table with Δ columns so
narrow-tools-saves-tokens (the ab-v2 hypothesis) is measurable.
avgUsage skips rows with 0/0 usage so codex-cli (CLI doesn't
surface tokens) and harness errors don't deflate the average to
near-zero — they show '—' instead.
**2. Composite difficulty.** New 'composite' value alongside
obvious / optional. Composite prompts express multi-tool intents
where no single expected_tool_if_any applies. classifyRouting
routes composite-treatment runs into multi-tool / fallback /
garbage (3-bucket sum to 1, distinct from obvious's 4-bucket
right/wrong/fallback/garbage). aggregate adds m6_multi_tool +
m6_fallback + m6_garbage; write-report emits a "Composite routing"
table that gracefully degrades to a placeholder when no composite
yaml exists yet.
Harness side: scripts/ab-corpus/run.ts accepts --corpus ab-v3
(enum + parseArgs guard); dry-run on the v1-mirror corpus produces
a 160-row report including populated token table.
Tests: 4 new aggregate cases (composite, token avg with skip-zero,
NaN-when-no-data) + 4 new score-run cases (composite routing
multi-tool/fallback/garbage/baseline-n/a) + 2 new score-run cases
(usage plumbing) + 2 new openai-compat cases (usage parsing,
missing-usage fallback). Existing 5 retry tests updated for new
return shape. 3727 → 3740 vitest tests, all green; tsc + format
clean.
Token-cost docs and composite docs go straight into types.ts /
score-run.ts / aggregate.ts JSDoc — keeps the contract close to
the code that owns it.
2026-04-28 23:45:00 +00:00
|
|
|
|
import {
|
|
|
|
|
|
parseModelOutput,
|
|
|
|
|
|
scoreRun,
|
|
|
|
|
|
aggregate,
|
|
|
|
|
|
type ScoreRow,
|
|
|
|
|
|
type TokenUsage,
|
|
|
|
|
|
} from '@zseven-w/pen-ai-skills';
|
2026-04-26 11:39:14 +00:00
|
|
|
|
// 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;
|
feat(ab-corpus): bootstrap ab-v3 with token cost + composite difficulty
ab-v3 succeeds ab-v1 (frozen 2026-04-28). Carries forward all 40
v1 obvious yaml files unchanged so the v1↔v3 overlap stays
comparable, then layers in two new dimensions.
**1. Token cost.** All clients (openai-compat, ark, bailian,
deepseek, minimax, codex-cli, stub-model) now return a
`ChatCallResult { content, usage }` instead of bare string.
Provider usage stats (`prompt_tokens` / `completion_tokens`) plumb
through realModelCall → run.ts → scoreRun → ScoreRow.{prompt,completion}Tokens.
aggregate adds avgPromptTokens{Baseline,Treatment} +
avgCompletionTokens{Baseline,Treatment} per ModelSummary.
write-report emits a new "Token cost" table with Δ columns so
narrow-tools-saves-tokens (the ab-v2 hypothesis) is measurable.
avgUsage skips rows with 0/0 usage so codex-cli (CLI doesn't
surface tokens) and harness errors don't deflate the average to
near-zero — they show '—' instead.
**2. Composite difficulty.** New 'composite' value alongside
obvious / optional. Composite prompts express multi-tool intents
where no single expected_tool_if_any applies. classifyRouting
routes composite-treatment runs into multi-tool / fallback /
garbage (3-bucket sum to 1, distinct from obvious's 4-bucket
right/wrong/fallback/garbage). aggregate adds m6_multi_tool +
m6_fallback + m6_garbage; write-report emits a "Composite routing"
table that gracefully degrades to a placeholder when no composite
yaml exists yet.
Harness side: scripts/ab-corpus/run.ts accepts --corpus ab-v3
(enum + parseArgs guard); dry-run on the v1-mirror corpus produces
a 160-row report including populated token table.
Tests: 4 new aggregate cases (composite, token avg with skip-zero,
NaN-when-no-data) + 4 new score-run cases (composite routing
multi-tool/fallback/garbage/baseline-n/a) + 2 new score-run cases
(usage plumbing) + 2 new openai-compat cases (usage parsing,
missing-usage fallback). Existing 5 retry tests updated for new
return shape. 3727 → 3740 vitest tests, all green; tsc + format
clean.
Token-cost docs and composite docs go straight into types.ts /
score-run.ts / aggregate.ts JSDoc — keeps the contract close to
the code that owns it.
2026-04-28 23:45:00 +00:00
|
|
|
|
corpus: 'ab-v0' | 'ab-v1' | 'ab-v3';
|
2026-04-26 11:39:14 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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];
|
feat(ab-corpus): bootstrap ab-v3 with token cost + composite difficulty
ab-v3 succeeds ab-v1 (frozen 2026-04-28). Carries forward all 40
v1 obvious yaml files unchanged so the v1↔v3 overlap stays
comparable, then layers in two new dimensions.
**1. Token cost.** All clients (openai-compat, ark, bailian,
deepseek, minimax, codex-cli, stub-model) now return a
`ChatCallResult { content, usage }` instead of bare string.
Provider usage stats (`prompt_tokens` / `completion_tokens`) plumb
through realModelCall → run.ts → scoreRun → ScoreRow.{prompt,completion}Tokens.
aggregate adds avgPromptTokens{Baseline,Treatment} +
avgCompletionTokens{Baseline,Treatment} per ModelSummary.
write-report emits a new "Token cost" table with Δ columns so
narrow-tools-saves-tokens (the ab-v2 hypothesis) is measurable.
avgUsage skips rows with 0/0 usage so codex-cli (CLI doesn't
surface tokens) and harness errors don't deflate the average to
near-zero — they show '—' instead.
**2. Composite difficulty.** New 'composite' value alongside
obvious / optional. Composite prompts express multi-tool intents
where no single expected_tool_if_any applies. classifyRouting
routes composite-treatment runs into multi-tool / fallback /
garbage (3-bucket sum to 1, distinct from obvious's 4-bucket
right/wrong/fallback/garbage). aggregate adds m6_multi_tool +
m6_fallback + m6_garbage; write-report emits a "Composite routing"
table that gracefully degrades to a placeholder when no composite
yaml exists yet.
Harness side: scripts/ab-corpus/run.ts accepts --corpus ab-v3
(enum + parseArgs guard); dry-run on the v1-mirror corpus produces
a 160-row report including populated token table.
Tests: 4 new aggregate cases (composite, token avg with skip-zero,
NaN-when-no-data) + 4 new score-run cases (composite routing
multi-tool/fallback/garbage/baseline-n/a) + 2 new score-run cases
(usage plumbing) + 2 new openai-compat cases (usage parsing,
missing-usage fallback). Existing 5 retry tests updated for new
return shape. 3727 → 3740 vitest tests, all green; tsc + format
clean.
Token-cost docs and composite docs go straight into types.ts /
score-run.ts / aggregate.ts JSDoc — keeps the contract close to
the code that owns it.
2026-04-28 23:45:00 +00:00
|
|
|
|
if (v !== 'ab-v0' && v !== 'ab-v1' && v !== 'ab-v3') {
|
|
|
|
|
|
process.stderr.write(`--corpus must be 'ab-v0', 'ab-v1', or 'ab-v3', got: ${v}\n`);
|
2026-04-26 11:39:14 +00:00
|
|
|
|
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(
|
feat(ab-corpus): bootstrap ab-v3 with token cost + composite difficulty
ab-v3 succeeds ab-v1 (frozen 2026-04-28). Carries forward all 40
v1 obvious yaml files unchanged so the v1↔v3 overlap stays
comparable, then layers in two new dimensions.
**1. Token cost.** All clients (openai-compat, ark, bailian,
deepseek, minimax, codex-cli, stub-model) now return a
`ChatCallResult { content, usage }` instead of bare string.
Provider usage stats (`prompt_tokens` / `completion_tokens`) plumb
through realModelCall → run.ts → scoreRun → ScoreRow.{prompt,completion}Tokens.
aggregate adds avgPromptTokens{Baseline,Treatment} +
avgCompletionTokens{Baseline,Treatment} per ModelSummary.
write-report emits a new "Token cost" table with Δ columns so
narrow-tools-saves-tokens (the ab-v2 hypothesis) is measurable.
avgUsage skips rows with 0/0 usage so codex-cli (CLI doesn't
surface tokens) and harness errors don't deflate the average to
near-zero — they show '—' instead.
**2. Composite difficulty.** New 'composite' value alongside
obvious / optional. Composite prompts express multi-tool intents
where no single expected_tool_if_any applies. classifyRouting
routes composite-treatment runs into multi-tool / fallback /
garbage (3-bucket sum to 1, distinct from obvious's 4-bucket
right/wrong/fallback/garbage). aggregate adds m6_multi_tool +
m6_fallback + m6_garbage; write-report emits a "Composite routing"
table that gracefully degrades to a placeholder when no composite
yaml exists yet.
Harness side: scripts/ab-corpus/run.ts accepts --corpus ab-v3
(enum + parseArgs guard); dry-run on the v1-mirror corpus produces
a 160-row report including populated token table.
Tests: 4 new aggregate cases (composite, token avg with skip-zero,
NaN-when-no-data) + 4 new score-run cases (composite routing
multi-tool/fallback/garbage/baseline-n/a) + 2 new score-run cases
(usage plumbing) + 2 new openai-compat cases (usage parsing,
missing-usage fallback). Existing 5 retry tests updated for new
return shape. 3727 → 3740 vitest tests, all green; tsc + format
clean.
Token-cost docs and composite docs go straight into types.ts /
score-run.ts / aggregate.ts JSDoc — keeps the contract close to
the code that owns it.
2026-04-28 23:45:00 +00:00
|
|
|
|
`Usage: bun scripts/ab-corpus/run.ts [--dry-run] [--models ID,ID,...] [--only prompt-id] [--out DIR] [--corpus ab-v0|ab-v1|ab-v3]\n`,
|
2026-04-26 11:39:14 +00:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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`);
|
|
|
|
|
|
|
2026-04-27 00:15:00 +00:00
|
|
|
|
// 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');
|
|
|
|
|
|
|
2026-04-29 01:50:00 +00:00
|
|
|
|
// Per-prompt parallelism: 5 models × 2 variants = 10 calls fan out
|
|
|
|
|
|
// concurrently per prompt. Prompts themselves stay sequential so
|
|
|
|
|
|
// ARK / Bailian rate-limits aren't hammered by 50× concurrent
|
|
|
|
|
|
// dispatch, and the `· prompt.id` progress line still marks one
|
|
|
|
|
|
// prompt at a time. Each provider's client is fetch-based (or a
|
|
|
|
|
|
// codex subprocess) with no shared mutable state, so concurrent
|
|
|
|
|
|
// calls are safe. Cuts wall-clock from sequential 13h → ~1.5h on
|
|
|
|
|
|
// ab-v3 (520 runs) since the slowest single call (~120s ARK
|
|
|
|
|
|
// timeout) sets the per-prompt floor instead of the per-call one.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Each finished row appends to scores.jsonl from inside runOne so
|
|
|
|
|
|
// a crash mid-batch keeps every completed row durable — buffering
|
|
|
|
|
|
// until Promise.all settles would let an ARK 120s timeout hold 9
|
|
|
|
|
|
// already-finished rows hostage. fs.appendFileSync is a sync
|
|
|
|
|
|
// syscall under Node's single-threaded event loop, so concurrent
|
|
|
|
|
|
// runOne completions serialize into non-interleaved writes
|
|
|
|
|
|
// automatically (no mutex needed).
|
|
|
|
|
|
async function runOne(
|
|
|
|
|
|
model: string,
|
|
|
|
|
|
variant: 'B' | 'T',
|
|
|
|
|
|
prompt: (typeof prompts)[number],
|
|
|
|
|
|
): Promise<ScoreRow> {
|
|
|
|
|
|
const call: ModelCall = {
|
|
|
|
|
|
model,
|
|
|
|
|
|
prompt,
|
|
|
|
|
|
variant,
|
|
|
|
|
|
systemPrompt: '<resolved in dispatcher>',
|
|
|
|
|
|
userPrompt: prompt.prompt,
|
|
|
|
|
|
};
|
|
|
|
|
|
let raw: string;
|
|
|
|
|
|
let usage: TokenUsage = { promptTokens: 0, completionTokens: 0 };
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = args.dryRun ? await stubModelCall(call) : await realModelCall(call);
|
|
|
|
|
|
raw = res.content;
|
|
|
|
|
|
usage = res.usage;
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// Network / subprocess failure → treat as garbage so the
|
|
|
|
|
|
// run still scores (M1=false, routing='garbage' for obvious
|
|
|
|
|
|
// treatment). Beats aborting a 520-run sweep over one
|
|
|
|
|
|
// transient failure. usage stays 0/0 — aggregate skips zero
|
|
|
|
|
|
// rows when computing token averages so a flaky cell
|
|
|
|
|
|
// doesn't drag the model's average down to ~0.
|
|
|
|
|
|
raw = `__HARNESS_ERROR__: ${err instanceof Error ? err.message : String(err)}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
const parsed = parseModelOutput(raw);
|
|
|
|
|
|
const row = await scoreRun({
|
|
|
|
|
|
prompt,
|
|
|
|
|
|
parsed,
|
|
|
|
|
|
apply: applyToFreshDoc,
|
|
|
|
|
|
model,
|
|
|
|
|
|
variant,
|
|
|
|
|
|
usage,
|
|
|
|
|
|
});
|
|
|
|
|
|
fs.appendFileSync(scoresPath, JSON.stringify(row) + '\n', 'utf-8');
|
|
|
|
|
|
return row;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-26 11:39:14 +00:00
|
|
|
|
const rows: ScoreRow[] = [];
|
|
|
|
|
|
for (const prompt of prompts) {
|
2026-04-29 01:50:00 +00:00
|
|
|
|
const promptCalls: Promise<ScoreRow>[] = [];
|
2026-04-26 11:39:14 +00:00
|
|
|
|
for (const model of args.models) {
|
|
|
|
|
|
for (const variant of ['B', 'T'] as const) {
|
2026-04-29 01:50:00 +00:00
|
|
|
|
promptCalls.push(runOne(model, variant, prompt));
|
2026-04-26 11:39:14 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-29 01:50:00 +00:00
|
|
|
|
const newRows = await Promise.all(promptCalls);
|
|
|
|
|
|
rows.push(...newRows);
|
2026-04-26 11:39:14 +00:00
|
|
|
|
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);
|
|
|
|
|
|
});
|