mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 23:14:33 +01:00
* 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>
144 lines
7.7 KiB
TypeScript
144 lines
7.7 KiB
TypeScript
/**
|
|
* Model panel (periodic, paid): particle-1, its open-weight base DeepSeek V4.1
|
|
* Flash, Sonnet 5.5 and Opus 5.5 each run the same five gstack-shaped tasks
|
|
* PANEL_TRIALS times in Claude Code, once with the build-discipline skill and
|
|
* once as `<model>#no-gstack` (the same task, tools, limits and route with no
|
|
* skill folder and no routing line), as preregistered in
|
|
* test/fixtures/model-panel/PREREG.md. Each cell is scored by a hidden
|
|
* deterministic check; gateway arms reach their models through OpenRouter
|
|
* with no Anthropic credential in the child env. A research instrument that
|
|
* gates nothing: assertions cover the harness, never which model wins.
|
|
*
|
|
* Spend: every cell reserves its arm's worst case in a shared ledger
|
|
* (<out>/spend.json, cap PANEL_SPEND_CAP_USD) before it starts and settles to
|
|
* the measured cost after, so a staged run (one task first, then the rest)
|
|
* stays under one cap; a refused reservation stops the panel with
|
|
* STOP: reason=budget_exhausted and keeps every row already written, and a
|
|
* cell that died without usage stays charged at its reservation (unknown).
|
|
* Rows append to <out>/rows.jsonl, each tagged with its campaign (run id and
|
|
* PREREG.md sha256); <out>/summary.json holds the campaign's per-arm summary
|
|
* and the preregistered suggestion line, and <out>/results.md its table, one
|
|
* row per arm identity, which must pass `gstack-eval-plan results --check`
|
|
* before any number is published. <out> is GSTACK_MODEL_PANEL_OUT, defaulting
|
|
* beside the eval store; a staged run resumes one campaign by setting
|
|
* GSTACK_MODEL_PANEL_CAMPAIGN to the first stage's run id.
|
|
*/
|
|
import { describe, test, expect, afterAll } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { runSkillTest } from './helpers/session-runner';
|
|
import { runId, selectedTests } from './helpers/e2e-helpers';
|
|
import { describeE2ETier } from './helpers/e2e-gate';
|
|
import { getProjectEvalDir } from './helpers/eval-store';
|
|
import { reserve, settle, readLedger, setCap, summarize as spendSummary, renderSpend, SpendCapExceeded } from '../lib/spend-ledger';
|
|
import {
|
|
PANEL_ARMS, PANEL_TASKS, PANEL_TRIALS, PANEL_MAX_TURNS, PANEL_TIMEOUT_MS, PANEL_CONCURRENCY, PANEL_ALLOWED_TOOLS, PANEL_SPEND_CAP_USD,
|
|
setupCell, runCheck, armEnv, usageFromTranscript, cellCostUsd, cellKeys, classifyCell, summarize, suggestionLine, renderResults, panelPreregSha,
|
|
type PanelArm, type PanelCampaign, type PanelRow, type PanelTask,
|
|
} from './helpers/model-panel-harness';
|
|
import { SKILL_NAME } from './helpers/arm-benchmark-harness';
|
|
|
|
const OUT = process.env.GSTACK_MODEL_PANEL_OUT || path.join(path.dirname(getProjectEvalDir()), 'model-panel');
|
|
const ROWS = path.join(OUT, 'rows.jsonl');
|
|
const SPEND = path.join(OUT, 'spend.json');
|
|
const CAMPAIGN: PanelCampaign = { runId: process.env.GSTACK_MODEL_PANEL_CAMPAIGN || runId, preregSha: panelPreregSha() };
|
|
let stopped: string | null = null;
|
|
|
|
function consulted(result: Awaited<ReturnType<typeof runSkillTest>>): boolean {
|
|
return result.toolCalls.some((tc) => tc.tool === 'Skill' && String((tc.input as { skill?: unknown })?.skill ?? '').includes(SKILL_NAME));
|
|
}
|
|
|
|
async function runCell(task: PanelTask, arm: PanelArm, trial: number): Promise<PanelRow | null> {
|
|
if (stopped) return null;
|
|
const cell = cellKeys(task, arm, trial, CAMPAIGN.runId);
|
|
try {
|
|
reserve(SPEND, cell.attempt, arm.reserveUsd, { cap: PANEL_SPEND_CAP_USD, label: arm.model });
|
|
} catch (err) {
|
|
if (!(err instanceof SpendCapExceeded)) throw err;
|
|
stopped = err.message;
|
|
process.stderr.write('STOP: reason=budget_exhausted ' + err.message + '\n');
|
|
return null;
|
|
}
|
|
const dirs = setupCell(task, arm.loadout);
|
|
const started = Date.now();
|
|
let settled = false;
|
|
try {
|
|
const result = await runSkillTest({
|
|
prompt: task.ticket,
|
|
workingDirectory: dirs.dir,
|
|
maxTurns: PANEL_MAX_TURNS,
|
|
allowedTools: PANEL_ALLOWED_TOOLS,
|
|
timeout: PANEL_TIMEOUT_MS,
|
|
testName: cell.testName,
|
|
runId,
|
|
model: arm.model,
|
|
env: armEnv(arm),
|
|
});
|
|
const usage = usageFromTranscript(result.transcript);
|
|
const costUsd = usage ? cellCostUsd(arm, usage) : null;
|
|
settle(SPEND, cell.attempt, costUsd ?? 'unknown');
|
|
settled = true;
|
|
const cls = classifyCell(arm, result.exitReason, usage);
|
|
const scored = cls.verdict === 'scored' ? runCheck(task, dirs.dir) : null;
|
|
const row: PanelRow = {
|
|
...CAMPAIGN, task: task.id, arm: arm.identity, loadout: arm.loadout, model: arm.model, route: arm.route, trial,
|
|
verdict: scored ? (scored.pass ? 'pass' : 'fail') : 'infra',
|
|
reason: scored ? scored.detail : (cls as { reason: string }).reason,
|
|
exitReason: result.exitReason, costUsd, usage, durationMs: Date.now() - started, consulted: consulted(result),
|
|
};
|
|
fs.appendFileSync(ROWS, JSON.stringify(row) + '\n');
|
|
console.log('[model-panel] ' + task.id + ' ' + arm.identity + ' t' + trial + ' ' + row.verdict + ' $' + (costUsd ?? 0).toFixed(4) + ' turns=' + (usage?.turns ?? 0) + ' ' + row.reason);
|
|
return row;
|
|
} finally {
|
|
if (!settled) settle(SPEND, cell.attempt, 'unknown');
|
|
fs.rmSync(dirs.dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function pool<T>(jobs: Array<() => Promise<T>>, limit: number): Promise<T[]> {
|
|
const out: T[] = new Array(jobs.length);
|
|
let next = 0;
|
|
await Promise.all(Array.from({ length: Math.min(limit, jobs.length) }, async () => {
|
|
while (next < jobs.length) {
|
|
const i = next++;
|
|
out[i] = await jobs[i]();
|
|
}
|
|
}));
|
|
return out;
|
|
}
|
|
|
|
const describePaid = describeE2ETier('periodic');
|
|
|
|
for (const task of PANEL_TASKS) {
|
|
const selected = selectedTests === null || selectedTests.includes(task.id);
|
|
(selected ? describePaid : describe.skip)('Model panel: ' + task.id, () => {
|
|
test(task.id, async () => {
|
|
fs.mkdirSync(OUT, { recursive: true });
|
|
if (readLedger(SPEND).cap_usd === null) setCap(SPEND, PANEL_SPEND_CAP_USD);
|
|
const jobs = PANEL_ARMS.flatMap((arm) => Array.from({ length: PANEL_TRIALS }, (_, i) => () => runCell(task, arm, i + 1)));
|
|
const rows = (await pool(jobs, PANEL_CONCURRENCY)).filter((r): r is PanelRow => r !== null);
|
|
expect(stopped, 'panel stopped: ' + stopped).toBeNull();
|
|
expect(rows).toHaveLength(PANEL_ARMS.length * PANEL_TRIALS);
|
|
for (const row of rows) expect(['pass', 'fail', 'infra']).toContain(row.verdict);
|
|
for (const row of rows.filter((r) => r.loadout === 'no-gstack')) expect(row.consulted, row.arm + ' t' + row.trial + ' consulted a skill').toBe(false);
|
|
}, Math.ceil((PANEL_ARMS.length * PANEL_TRIALS) / PANEL_CONCURRENCY) * PANEL_TIMEOUT_MS + 300_000);
|
|
});
|
|
}
|
|
|
|
afterAll(() => {
|
|
if (!fs.existsSync(ROWS)) return;
|
|
const rows = fs.readFileSync(ROWS, 'utf-8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l) as PanelRow);
|
|
const summaries = summarize(rows, CAMPAIGN);
|
|
const measuredOn = new Date().toISOString().slice(0, 10);
|
|
const suggestion = suggestionLine(summaries, measuredOn);
|
|
const spend = spendSummary(readLedger(SPEND));
|
|
fs.writeFileSync(path.join(OUT, 'summary.json'), JSON.stringify({ campaign: CAMPAIGN, rows: summaries.reduce((n, s) => n + s.cells, 0), summaries, suggestion, spend }, null, 2) + '\n');
|
|
fs.writeFileSync(path.join(OUT, 'results.md'), renderResults(summaries, { ...CAMPAIGN, measuredOn }));
|
|
console.log('\n[model-panel] research instrument; gates nothing. Campaign ' + CAMPAIGN.runId + ', results ' + path.join(OUT, 'results.md'));
|
|
for (const s of summaries) {
|
|
console.log(' ' + s.label.padEnd(20) + ' ' + s.passes + '/' + s.completed + ' passed (95% ' + (s.low * 100).toFixed(0) + '-' + (s.high * 100).toFixed(0) + '%), infra ' + s.infra + ', $' + s.costPerTaskUsd.toFixed(4) + ' per task');
|
|
}
|
|
console.log(' suggestion: ' + (suggestion.line ?? 'none (' + suggestion.reason + ')'));
|
|
console.log(' ' + renderSpend(spend));
|
|
});
|