Files
gstack/test/skill-e2e-arm-benchmark.test.ts
T
Garry Tanandcapy-ai[bot] 4eff47c5cf v1.92.2.0 evals: no-gstack arms for the model panel, arm-benchmark, safety and routing evals (#3708)
* feat(eval-plan): results --check refuses a results table without its no-gstack rows

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(model-panel): a <model>#no-gstack arm per model, identity-keyed cells, one campaign, results.md (GST-468)

Each panel model also runs as <model>#no-gstack: the same fixture, ticket,
tools, limits, route and reservation, with no skill folder and no routing
line. The arm identity keys the ledger attempt, test name, row and summary;
candidate folders are named after the fixture project and the seed commit
has a neutral author. summarize reads one campaign (run id and PREREG.md
sha256) and refuses a duplicate cell; suggestionLine reads the skill arms
only, so the preregistered particle-1 line is unchanged. renderResults writes
results.md, which passes gstack-eval-plan results --check against the new
test/fixtures/model-panel/PREREG.md (supersedes GSTA-173 for later runs).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* feat(arm-benchmark): no-gstack arm on a pinned model, 3 paired trials, both-order paired judge, spend ledger (GST-469)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(evals): no-gstack arm for the safety-rule and router evals (GST-470)

GSTACK_SAFETY_ARM and GSTACK_ROUTER_ARM take a third value, none: the same
fixture, tools and user request with no skill excerpt, rule text, step file
or installed skill. Under none every behavioral check is recorded as an
observation (none-arm-observations.jsonl) and never fails the case or writes
contract-violations.jsonl; harness preconditions still assert.

The four safety prompt builders (ship-evidence, codex-boundary,
codex-consult, design-risk) and the router fixtures move into helpers; the
rule, removed, current and before prompts stay byte-identical, proven
against reference copies of main's code. pair-agent and ios-demo refuse
none with the reason (NO_NONE_ARM). GSTACK_ROUTER_ARM now throws on an
unknown value instead of silently running current.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: changelog fragment for the no-gstack arms in existing evals

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(arm-benchmark): arm-pair paired-judge calibration run (bar met 6/6, $7.22)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(paid-shards): keep loop-named panel and arm-benchmark cases selectable by --case

The lanes moved each cell's testName into helpers, so the static detector no
longer saw a computed registration and B4 failed for all eight cases. Same
runtime test names; the paid runs are unaffected.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(tests): keep FREE_FIXTURES and the placeholder allowlist current for the no-gstack arms

scripts/judge-calibration.ts is now an arm-benchmark paid touchfile, so it can no
longer be a free consumer of test/fixtures/judge-calibration/** (CI free-suite 8).
The eval-plan Step 5 fence names <prereg-path> and <results-path>; allowlist both
as PATH placeholders (CI free-suite 19).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(eval-plan): cover a complete table after a partial one, --json output and the results usage error

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(eval-plan): keep the Step 7 coverage test as reviewed

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* chore: name the changelog fragment after the branch slug

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: arm-benchmark project folder names and the arm-pair calibration corpus

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: keep the judge-calibration fixture README as reviewed

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(skills): describe eval-plan results --check

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-31 22:41:36 +00:00

324 lines
15 KiB
TypeScript

/**
* WS2 — with-skill vs without-skill agentic arm benchmark (periodic, paid).
*
* Role: a standalone RESEARCH INSTRUMENT, not a release gate. gstack skills
* cost ~13K tokens per invocation and nothing else measures whether they earn
* it. Each named build-shaped task runs ARM_TRIALS with/without pairs through
* real `claude -p` sessions on the pinned ARM_MODEL against the same seeded
* fixture repo — one arm with the behavioral-layer skill installed
* (project-scope .claude/skills + a CLAUDE.md routing line, the proven
* opus-47 pattern; `claude -p` does NOT auto-load SKILL.md), one arm with no
* gstack at all (`claude-opus-5-5#no-gstack` in the preregistration) — and the
* `git diff` each arm leaves behind is scored. Metric order is
* diff-quality-first: checks, then the 0-3 over-engineering score, then LOC.
* Results inform strategy, they do not gate releases — no assertion here
* compares arm scores. Preregistration: test/fixtures/arm-benchmark/PREREG.md.
*
* Scoring (GST-469): the paired judge (test/helpers/arm-pair-judge.ts) sees
* both diffs of a pair as A and B, in both orders, and a win counts only when
* both orders agree. It decides only once its calibration (`arm-pair` in
* scripts/judge-calibration.ts) meets the PREREG bar; until then the
* per-diff armJudge decides and the paired result is the diagnostic.
* results.md names the decider and passes `gstack-eval-plan results --check`.
*
* Spend: every cell and judge call reserves in <out>/spend.json under
* ARM_SPEND_CAP_USD before it starts and settles to measured cost after (a
* call with no reported cost stays charged at its reservation). A refused
* reservation prints STOP: reason=budget_exhausted, skips the remaining
* trials and fails the case. <out> is GSTACK_ARM_BENCHMARK_OUT (set it to
* share one ledger across a staged pilot and full run), else a per-run
* directory beside the eval store.
*
* The skill under test (`build-discipline`) is assembled at runtime from the
* two behavioral sections WS3/WS7 added — the reuse ladder (## Search Before
* Building) and the bounded closer (## Voice) — EXTRACTED from a rendered
* SKILL.md (ship/), never copied whole (CLAUDE.md fixture rule).
*
* Failure taxonomy (CEO review finding 2):
* - zero-diff arm -> VALID scored cell (LOC 0, judge scores it "none").
* - harvest failure -> cell FAILED, harvest: null recorded.
* - wrong model (asked or billed) -> INFRA cell, excluded from aggregates.
* - judge output malformed (never re-asked) -> judge_error cell or pair:
* excluded from aggregates, surfaced in the run report, never silently
* dropped.
*
* The harness (tasks, fixtures, skill assembly, arm setup, diff capture,
* ledger, results.md) lives in test/helpers/arm-benchmark-harness.ts, shared
* with the FREE selftest at test/arm-benchmark-selftest.test.ts — which runs
* in `bun run test` on every PR so this paid instrument can never burn money
* on broken fixtures or plumbing. Everything needing a live model is here,
* inside the EVALS_TIER=periodic describes.
*/
import { describe, test, expect, afterAll } from 'bun:test';
import { runSkillTest } from './helpers/session-runner';
import type { SkillTestResult } from './helpers/session-runner';
import {
runId, selectedTests, logCost, recordE2E,
createEvalCollector, finalizeEvalCollector,
} from './helpers/e2e-helpers';
import { describeE2ETier } from './helpers/e2e-gate';
import { getProjectEvalDir } from './helpers/eval-store';
import { armJudge, type ArmJudgeScore } from './helpers/llm-judge';
import { armPairJudge, meteredJudgeCall, pairJudgeDecides, type PairVerdict } from './helpers/arm-pair-judge';
import { readLedger, setCap, summarize as spendSummary, renderSpend } from '../lib/spend-ledger';
import {
ARM_JUDGE_DIFF_CAP, ARM_MODEL, ARM_TRIALS, ARM_SPEND_CAP_USD, ARM_CELL_RESERVE_USD,
TASK_TEST_TIMEOUT_MS, SKILL_NAME, TASKS,
setupArm, captureStagedDiff, runChecks, armRunOptions, armCellInfra, billedModels, metered, renderArmResults, ArmBudgetStop,
type Arm, type ArmTask, type DiffHarvest,
} from './helpers/arm-benchmark-harness';
import * as fs from 'fs';
import * as path from 'path';
const OUT = process.env.GSTACK_ARM_BENCHMARK_OUT || path.join(path.dirname(getProjectEvalDir()), 'arm-benchmark', runId);
const SPEND = path.join(OUT, 'spend.json');
let stopped: string | null = null;
// --- Cell runner + reporting ---
interface CellResult {
task: string;
trial: number;
arm: Arm;
exitReason: string;
infra: string | null;
harvest: DiffHarvest | null;
harvestError: string | null;
judge: ArmJudgeScore | null;
judgeError: string | null;
/** Deterministic functional-check outcome ('none' = task has no oracle).
* Correctness comes before LOC in the metric order — a refusal, a broken
* implementation, and working code must be distinguishable in the cells. */
checks: 'pass' | 'fail' | 'none';
consulted: boolean;
costUsd: number | null;
tokens: number;
turns: number;
}
interface PairResult {
task: string;
trial: number;
cells: CellResult[];
/** x = with-skill, y = without-skill. */
verdict: PairVerdict | null;
error: string | null;
}
const evalCollector = createEvalCollector('e2e-arm-benchmark');
const allPairs: PairResult[] = [];
function skillConsulted(result: SkillTestResult): boolean {
return result.toolCalls.some((tc) =>
(tc.tool === 'Skill' && String((tc.input as { skill?: unknown })?.skill ?? '').includes(SKILL_NAME))
|| JSON.stringify(tc.input ?? {}).includes(`.claude/skills/${SKILL_NAME}`));
}
/** The diff a judge sees. Truncation is loud: a clipped patch can hide the construct being scored. */
function judgeDiff(label: string, patch: string): string {
if (patch.length > ARM_JUDGE_DIFF_CAP) {
console.warn(`[arm-benchmark ${label}] judge diff truncated to ${ARM_JUDGE_DIFF_CAP}B of ${patch.length}B — the judgement may miss constructs past the cap.`);
}
return patch.slice(0, ARM_JUDGE_DIFF_CAP);
}
async function runArmCell(task: ArmTask, arm: Arm, trial: number): Promise<CellResult> {
const dirs = setupArm(task, arm);
try {
const opts = armRunOptions(task, arm, dirs.dir, trial);
const attempt = `${opts.testName}:${runId}`;
const result = await metered(SPEND, attempt, ARM_CELL_RESERVE_USD, ARM_MODEL, async () => {
const r = await runSkillTest({ ...opts, testName: opts.testName, runId });
return { value: r, costUsd: r.costEstimate.estimatedCost > 0 ? r.costEstimate.estimatedCost : null };
});
logCost(`arm-benchmark ${task.fixture} ${arm} t${trial}`, result);
const infra = armCellInfra(result.model, billedModels(result.transcript));
// Harvest taxonomy: a capture failure marks the cell failed with
// harvest: null recorded — never silently dropped.
let harvest: DiffHarvest | null = null;
let harvestError: string | null = null;
try {
harvest = captureStagedDiff(dirs.dir, dirs.seedSha);
} catch (err) {
harvestError = err instanceof Error ? err.message : String(err);
}
const checks = runChecks(task, dirs.dir);
// Per-diff judge (the no-loss diagnostic, and the decider until the
// paired judge is calibrated). INFRA cells are not judged.
let judge: ArmJudgeScore | null = null;
let judgeError: string | null = null;
const judgeDiffTruncated = harvest !== null && harvest.patch.length > ARM_JUDGE_DIFF_CAP;
if (harvest && !infra) {
try {
judge = await armJudge(task.ticket, judgeDiff(opts.testName, harvest.patch), { call: meteredJudgeCall(SPEND, attempt) });
} catch (err) {
if (err instanceof ArmBudgetStop) throw err;
judgeError = err instanceof Error ? err.message : String(err);
}
}
const consulted = skillConsulted(result);
recordE2E(evalCollector, opts.testName, 'Arm Benchmark', result, {
passed: result.exitReason === 'success' && harvest !== null && !infra,
harvest: harvest
? {
filesChanged: harvest.filesChanged,
insertions: harvest.insertions,
deletions: harvest.deletions,
net: harvest.net,
}
: null,
judge_scores: judge
? { over_engineering: judge.over_engineering, ...(checks !== 'none' ? { checks_pass: checks === 'pass' ? 1 : 0 } : {}) }
: undefined,
judge_reasoning: judge
? `construct: ${judge.construct} | ${judge.reasoning}${judgeDiffTruncated ? ` | diff truncated to ${ARM_JUDGE_DIFF_CAP}B` : ''}`
: judgeError ? `judge_error: ${judgeError}` : undefined,
error: infra ?? harvestError ?? undefined,
});
return {
task: task.key,
trial,
arm,
exitReason: result.exitReason,
infra,
harvest,
harvestError,
judge,
judgeError,
checks,
consulted,
costUsd: result.costEstimate.estimatedCost > 0 ? result.costEstimate.estimatedCost : null,
tokens: result.costEstimate.estimatedTokens,
turns: result.costEstimate.turnsUsed,
};
} finally {
fs.rmSync(dirs.dir, { recursive: true, force: true });
fs.rmSync(dirs.originDir, { recursive: true, force: true });
}
}
/** One trial: both cells concurrently, then the paired judge in both orders. */
async function runPair(task: ArmTask, trial: number): Promise<PairResult> {
const settled = await Promise.allSettled([runArmCell(task, 'with-skill', trial), runArmCell(task, 'without-skill', trial)]);
const failure = settled.find((s): s is PromiseRejectedResult => s.status === 'rejected');
if (failure) throw failure.reason;
const cells = settled.map((s) => (s as PromiseFulfilledResult<CellResult>).value);
const [withCell, withoutCell] = cells as [CellResult, CellResult];
let verdict: PairVerdict | null = null;
let error: string | null = null;
if (withCell.harvest && withoutCell.harvest && !withCell.infra && !withoutCell.infra) {
const label = `${task.key}-pair-t${trial}`;
try {
verdict = await armPairJudge(task.ticket, judgeDiff(label, withCell.harvest.patch), judgeDiff(label, withoutCell.harvest.patch),
{ call: meteredJudgeCall(SPEND, `${label}:${runId}`) });
} catch (err) {
if (err instanceof ArmBudgetStop) throw err;
error = err instanceof Error ? err.message : String(err);
}
} else {
error = 'not judged: a cell has no harvest or is INFRA';
}
const pair = { task: task.key, trial, cells, verdict, error };
allPairs.push(pair);
return pair;
}
function cellLine(c: CellResult): string {
const score = c.infra ? c.infra
: c.judge ? `${c.judge.over_engineering}/3 (${c.judge.construct})`
: c.judgeError ? 'judge_error' : 'unscored';
const loc = c.harvest
? `+${c.harvest.insertions}/-${c.harvest.deletions} net ${c.harvest.net} in ${c.harvest.filesChanged} file(s)`
: `harvest FAILED: ${c.harvestError}`;
return ` t${c.trial} ${c.arm.padEnd(14)} checks=${c.checks} per-diff=${score} loc=${loc} turns=${c.turns} `
+ `tokens=${(c.tokens / 1000).toFixed(1)}k cost=$${(c.costUsd ?? 0).toFixed(2)} consulted=${c.consulted}`;
}
function pairLine(p: PairResult): string {
if (!p.verdict) return ` t${p.trial} paired: ${p.error}`;
const winner = p.verdict.winner === 'x' ? 'with-skill' : p.verdict.winner === 'y' ? 'without-skill' : 'tie';
return ` t${p.trial} paired: ${winner} (orders: ${p.verdict.orders.map((o) => `${o.order}=${o.preferred}`).join(', ') || 'both empty'}; `
+ `mean over_engineering with=${p.verdict.mean.x.toFixed(1)} without=${p.verdict.mean.y.toFixed(1)})`;
}
function printTaskReport(task: ArmTask, pairs: PairResult[]): void {
console.log(`\n[arm-benchmark ${task.key}] checks first, then over_engineering, then LOC.`);
for (const p of pairs) {
for (const c of p.cells) console.log(cellLine(c));
console.log(pairLine(p));
}
}
// --- Paid arm runs (periodic tier) ---
const describePaid = describeE2ETier('periodic');
function describeArmTask(task: ArmTask, fn: () => void) {
const anySelected = selectedTests === null || selectedTests.includes(task.key);
(anySelected ? describePaid : describe.skip)(`Arm benchmark: ${task.key}`, fn);
}
for (const task of TASKS) {
describeArmTask(task, () => {
test(task.key, async () => {
fs.mkdirSync(OUT, { recursive: true });
if (readLedger(SPEND).cap_usd === null) setCap(SPEND, ARM_SPEND_CAP_USD);
const pairs: PairResult[] = [];
for (let trial = 1; trial <= ARM_TRIALS && !stopped; trial++) {
try {
pairs.push(await runPair(task, trial));
} catch (err) {
if (!(err instanceof ArmBudgetStop)) throw err;
stopped = err.message;
process.stderr.write(err.message + '\n');
}
}
printTaskReport(task, pairs);
// Harness mechanics only, per arm and trial. Score direction is
// deliberately unasserted: this is a research instrument.
expect(stopped, `arm benchmark stopped: ${stopped}`).toBeNull();
for (const c of pairs.flatMap((p) => p.cells)) {
const who = `${c.arm} trial ${c.trial}`;
expect(c.exitReason, `${who} did not finish cleanly`).toBe('success');
expect(c.harvest, `${who} harvest failed: ${c.harvestError}`).not.toBeNull();
// The A/B is vacuous unless the skill arm consulted the skill and the
// no-gstack arm could not have.
if (c.arm === 'with-skill') expect(c.consulted, `${who} never consulted ${SKILL_NAME} — vacuous comparison`).toBe(true);
else expect(c.consulted, `${who} references the skill it should not have`).toBe(false);
}
}, TASK_TEST_TIMEOUT_MS);
});
}
afterAll(async () => {
if (allPairs.length > 0) {
const decider = pairJudgeDecides();
const results = renderArmResults({
runId,
decider: decider.decides ? `paired (${decider.reason})` : `per-diff (${decider.reason})`,
cells: allPairs.flatMap((p) => p.cells).map((c) => ({
task: c.task, trial: c.trial, arm: c.arm, infra: c.infra, checks: c.checks,
perDiff: c.judge?.over_engineering ?? null, judgeError: c.judgeError !== null, costUsd: c.costUsd,
})),
pairs: allPairs.map((p) => ({
task: p.task,
trial: p.trial,
winner: !p.verdict ? null : p.verdict.winner === 'x' ? 'with-skill' : p.verdict.winner === 'y' ? 'without-skill' : 'tie',
mean: p.verdict ? { 'with-skill': p.verdict.mean.x, 'without-skill': p.verdict.mean.y } : null,
})),
});
fs.writeFileSync(path.join(OUT, 'results.md'), results);
console.log('\n[arm-benchmark aggregate] research instrument — informs strategy, gates nothing.');
console.log(results);
console.log(' ' + renderSpend(spendSummary(readLedger(SPEND))));
console.log(` results: ${path.join(OUT, 'results.md')} (check with gstack-eval-plan results --check test/fixtures/arm-benchmark/PREREG.md <results.md>)`);
}
await finalizeEvalCollector(evalCollector);
});