openpencil/scripts/ab-corpus/write-report.ts
Fini 95566e4ed2 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-29 07:45:00 +08:00

145 lines
5.8 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.

/**
* Render an aggregated Report as both a markdown file (human review) and
* a json file (machine-diffable + fed to downstream plotting). Only
* writes; computes nothing — aggregation is the scorer's job.
*/
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Report } from '@zseven-w/pen-ai-skills';
export function writeReport(outDir: string, report: Report): { mdPath: string; jsonPath: string } {
const jsonPath = join(outDir, 'report.json');
writeFileSync(jsonPath, JSON.stringify(report, null, 2), 'utf-8');
const mdPath = join(outDir, 'report.md');
writeFileSync(mdPath, renderMarkdown(report), 'utf-8');
return { mdPath, jsonPath };
}
function renderMarkdown(r: Report): string {
const lines: string[] = [];
lines.push(`# Element Tools A/B Report`, '');
lines.push(`Generated: ${r.generatedAt}`);
lines.push(`Total runs: ${r.totalRuns}`, '');
lines.push(`## By model`, '');
lines.push(
'| Model | N (B) | N (T) | M1 B | M1 T | Δ M1 (pp) | M3 B | M3 T | Δ M3 (pp) | Right tool | Wrong tool | Fallback | Garbage |',
);
lines.push(
'|-------|-------|-------|------|------|-----------|------|------|-----------|------------|------------|----------|---------|',
);
for (const m of r.byModel) {
lines.push(
`| ${m.model} | ${m.runCountBaseline} | ${m.runCountTreatment} | ${pct(m.m1_baseline)} | ${pct(m.m1_treatment)} | ${signed(m.m1_delta_pp)} | ${pct(m.m3_baseline)} | ${pct(m.m3_treatment)} | ${signed(m.m3_delta_pp)} | ${pct(m.m5_right_tool)} | ${pct(m.m5_wrong_tool)} | ${pct(m.m5_fallback)} | ${pct(m.m5_garbage)} |`,
);
}
lines.push('');
lines.push(
'_Right / Wrong / Fallback / Garbage = routing breakdown on the `difficulty: obvious` treatment subset; all four rates sum to 100%. Right = tool named in `expected_tool_if_any`. Wrong = routed to a different `add_*_v0` (schema constraint worked, intent match failed). Fallback = emitted `batch_design` DSL. Garbage = output unparseable — counted here so right-tool never looks rosy when most outputs fail to parse._',
'',
);
lines.push(`## Composite routing (treatment arm only)`, '');
const hasComposite = r.byModel.some(
(m) =>
!Number.isNaN(m.m6_multi_tool) || !Number.isNaN(m.m6_fallback) || !Number.isNaN(m.m6_garbage),
);
if (!hasComposite) {
lines.push(
'_No `difficulty: composite` prompts in this run — all metrics would be NaN. Add composite yaml fixtures to corpus/ab-v3/ to populate this section._',
'',
);
} else {
lines.push('| Model | Multi-tool | Fallback | Garbage |');
lines.push('|-------|------------|----------|---------|');
for (const m of r.byModel) {
lines.push(
`| ${m.model} | ${pct(m.m6_multi_tool)} | ${pct(m.m6_fallback)} | ${pct(m.m6_garbage)} |`,
);
}
lines.push(
'',
'_Composite prompts have no `expected_tool_if_any`. Multi-tool = at least one element-tool call (the target behavior). Fallback = batch_design DSL. Garbage = unparseable._',
'',
);
}
lines.push(`## Token cost (provider-reported usage)`, '');
const hasUsage = r.byModel.some(
(m) =>
!Number.isNaN(m.avgPromptTokensBaseline) ||
!Number.isNaN(m.avgPromptTokensTreatment) ||
!Number.isNaN(m.avgCompletionTokensBaseline) ||
!Number.isNaN(m.avgCompletionTokensTreatment),
);
if (!hasUsage) {
lines.push(
'_No usage data — providers in this run did not surface token counts (or all rows were harness errors)._',
'',
);
} else {
lines.push(
'| Model | Prompt B | Prompt T | Δ Prompt | Completion B | Completion T | Δ Completion |',
);
lines.push(
'|-------|----------|----------|----------|--------------|--------------|--------------|',
);
for (const m of r.byModel) {
lines.push(
`| ${m.model} | ${num(m.avgPromptTokensBaseline)} | ${num(m.avgPromptTokensTreatment)} | ${signedNum(m.avgPromptTokensTreatment, m.avgPromptTokensBaseline)} | ${num(m.avgCompletionTokensBaseline)} | ${num(m.avgCompletionTokensTreatment)} | ${signedNum(m.avgCompletionTokensTreatment, m.avgCompletionTokensBaseline)} |`,
);
}
lines.push(
'',
"_Mean tokens per call, only counting rows where the provider returned usage. Codex CLI doesn't surface usage so its rows show '—'. Δ = T B (negative means narrow tools saved tokens, the hypothesis behind A/B v2)._",
'',
);
}
lines.push(`## By category`, '');
lines.push('| Category | M1 B | M1 T | Δ M1 (pp) |');
lines.push('|----------|------|------|-----------|');
for (const c of r.byCategory) {
lines.push(
`| ${c.category} | ${pct(c.m1_baseline)} | ${pct(c.m1_treatment)} | ${signed(c.m1_delta_pp)} |`,
);
}
lines.push('');
lines.push(`## Tool usage (treatment arm only)`, '');
if (r.byTool.length === 0) {
lines.push(
"_No element tools were invoked in the treatment arm. Check decision-tree prompt phrasing and the weak model's tool-use capabilities._",
);
} else {
lines.push('| Tool | Invocations | Successful |');
lines.push('|------|-------------|------------|');
for (const t of r.byTool) {
lines.push(`| \`${t.tool}\` | ${t.invocations} | ${t.successfulInvocations} |`);
}
}
lines.push('');
return lines.join('\n');
}
function pct(n: number): string {
if (Number.isNaN(n)) return '—';
return `${(n * 100).toFixed(1)}%`;
}
function signed(n: number): string {
if (Number.isNaN(n)) return '—';
const sign = n > 0 ? '+' : '';
return `${sign}${n.toFixed(1)}`;
}
function num(n: number): string {
if (Number.isNaN(n)) return '—';
return n.toFixed(0);
}
function signedNum(treatment: number, baseline: number): string {
if (Number.isNaN(treatment) || Number.isNaN(baseline)) return '—';
const d = treatment - baseline;
const sign = d > 0 ? '+' : '';
return `${sign}${d.toFixed(0)}`;
}