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.
145 lines
5.8 KiB
TypeScript
145 lines
5.8 KiB
TypeScript
/**
|
||
* 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)}`;
|
||
}
|