mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 19:49:14 +00:00
Part A (#3500): dashscope_api_key and google_api_key are now GBrainConfig file-plane fields folded into the gateway env by buildGatewayConfig (DASHSCOPE_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY), with process-env GEMINI_API_KEY accepted as an alias for the canonical Google name. Honors the deferral note in brain-score-recommendations.ts by adding the matching HOSTED_EMBED_KEY_CONFIG entries in the same change. A new sweep guard asserts every KNOWN_CONFIG_KEYS *_api_key field reaches the gateway env (the #121/#2662/#3500 recurring bug class). Part B (#3510): google:gemini-1.5-pro (retired by Google) removed from the google recipe chat allowlist first — so recipe-vs-default guard tests catch every dead default — then replaced at the default sites: takes-quality DEFAULT_MODEL_PANEL and grade-takes ensemble docstring use google:gemini-2.0-flash (and openai:gpt-5.2 for the equally-dead gpt-4o), the takes-quality pricing allowlist swaps in gemini-2.0-flash + gpt-5.2, and cross-modal slot C moves to deepseek:deepseek-v4-pro (same replacement as PR #3501). New test/default-model-panels.test.ts pins the takes-quality panel to recipe chat lists, canonical pricing, the budget allowlist, and three distinct providers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
848 lines
30 KiB
TypeScript
848 lines
30 KiB
TypeScript
/**
|
|
* gbrain eval cross-modal — multi-model quality gate (v0.27.x).
|
|
*
|
|
* Three different-provider frontier models score the OUTPUT against the TASK
|
|
* on a fixed dimension list. Verdict: PASS (exit 0) / FAIL (exit 1) /
|
|
* INCONCLUSIVE (exit 2; <2/3 model successes).
|
|
*
|
|
* Reuses `src/core/ai/gateway.ts` for provider config + auth (T1+T2). Bypasses
|
|
* `connectEngine()` via the cli.ts no-DB branch (T3=A) so onboarding works
|
|
* before `gbrain init`. Receipts are bound to (slug, SKILL.md sha-8) so
|
|
* `gbrain skillify check` can detect stale audits (T10=A).
|
|
*
|
|
* Cost guardrails (T11=B):
|
|
* - Default cycles = 3 in TTY, 1 in non-TTY (limits scripted bulk spend).
|
|
* - Cost-estimate prints to stderr before each cycle.
|
|
* - `--budget-usd` hard cap is a v0.27.x follow-up TODO.
|
|
*/
|
|
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { createHash } from 'crypto';
|
|
|
|
import { gbrainPath, loadConfig } from '../core/config.ts';
|
|
import { configureGateway, isAvailable } from '../core/ai/gateway.ts';
|
|
import { runWithLimit } from '../core/worker-pool.ts';
|
|
import { resolveCycleDefault, cycleDefaultSuffix } from '../core/eval/cycle-default.ts';
|
|
import {
|
|
DEFAULT_DIMENSIONS,
|
|
DEFAULT_SLOTS,
|
|
estimateCost,
|
|
runEval,
|
|
} from '../core/cross-modal-eval/runner.ts';
|
|
import type {
|
|
ProgressEvent,
|
|
RunEvalResult,
|
|
SlotConfig,
|
|
} from '../core/cross-modal-eval/runner.ts';
|
|
|
|
const HELP = `gbrain eval cross-modal — multi-model quality gate
|
|
|
|
USAGE:
|
|
gbrain eval cross-modal --task "<description>" --output <path-or-skill-slug> [flags]
|
|
gbrain eval cross-modal --batch <jsonl> [--limit N] [--output <receipt-path>] [flags] (v0.40.1.0 Track D)
|
|
|
|
REQUIRED (single-task mode):
|
|
--task "..." What the OUTPUT was meant to achieve.
|
|
--output <path> File whose content gets scored. Pass a skill slug
|
|
shortcut (e.g. \`--output skills/my-skill/SKILL.md\`)
|
|
to bind the receipt to that skill (T10).
|
|
|
|
REQUIRED (batch mode, v0.40.1.0 Track D / T3):
|
|
--batch <jsonl> LongMemEval-shape JSONL (output of \`gbrain eval
|
|
longmemeval --output\`). Each row: {question, hypothesis,
|
|
question_id, ...}. Summary rows (kind:by_type_summary)
|
|
are filtered out. Mutually exclusive with --task.
|
|
|
|
BATCH FLAGS:
|
|
--limit N Slice the first N rows (default 10).
|
|
--concurrent N Semaphore cap; max N questions in-flight at once
|
|
(default 3, ceiling = 3 questions x 3 model slots =
|
|
9 parallel API calls). Per D6.
|
|
--max-usd FLOAT Refuse to start if estimated cost exceeds this
|
|
ceiling without --yes (default 5.00). Per D10.
|
|
--yes Bypass the --max-usd refusal. Required for
|
|
non-interactive (CI / cron) runs over the budget.
|
|
--output PATH Where to write the SUMMARY receipt (NOT where to
|
|
read agent response). Default:
|
|
~/.gbrain/eval-receipts/cross-modal-batch-<sha8>.json
|
|
|
|
FLAGS:
|
|
--slug <name> Receipt filename slug. Defaults to inferred slug
|
|
from --output path (skills/<slug>/SKILL.md → <slug>),
|
|
or a content sha for ad-hoc inputs.
|
|
--dimensions "d1,d2,..." Comma-separated dimension list. Default: 5 standard
|
|
dimensions (goal, depth, sourcing, specificity, useful).
|
|
--cycles N 1-3. Default: 3 in TTY, 1 in non-TTY (T11). Each
|
|
cycle is 3 model calls; verdict aggregates over them.
|
|
--slot-a-model <id> Override default 'openai:gpt-5.2'.
|
|
--slot-b-model <id> Override default 'anthropic:claude-opus-4-7'.
|
|
--slot-c-model <id> Override default 'deepseek:deepseek-v4-pro'.
|
|
--receipt-dir <path> Default: gbrainPath('eval-receipts').
|
|
--max-tokens N Output token budget per call. Default: 4000.
|
|
--json Emit final aggregate as JSON to stdout (progress to stderr).
|
|
--help, -h Show this help.
|
|
|
|
EXIT CODES:
|
|
0 PASS — every dim mean >=7 AND no model scored any dim <5.
|
|
1 FAIL — at least one dim mean <7 OR at least one model scored a dim <5.
|
|
2 INCONCLUSIVE — fewer than 2/3 models returned parseable scores. Receipt
|
|
is still written for forensics; the gate is not authoritative.
|
|
|
|
CONFIGURATION:
|
|
Models resolve via the gbrain AI gateway. Configure with:
|
|
gbrain providers test # see what's configured
|
|
gbrain config # set keys
|
|
Or set env vars: OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY,
|
|
TOGETHER_API_KEY, etc. The gateway reads from \`~/.gbrain/config.json\` plus
|
|
process.env.
|
|
|
|
EXAMPLES:
|
|
gbrain eval cross-modal \\
|
|
--task "Skillify SKILL.md teaches the 11-item meta-skill checklist" \\
|
|
--output skills/skillify/SKILL.md
|
|
|
|
gbrain eval cross-modal \\
|
|
--task "PR description sells the value of cross-modal eval" \\
|
|
--output /tmp/pr-description.md \\
|
|
--cycles 1
|
|
`;
|
|
|
|
interface ParsedArgs {
|
|
help: boolean;
|
|
task?: string;
|
|
output?: string;
|
|
slug?: string;
|
|
dimensions?: string[];
|
|
cycles?: number;
|
|
slotAModel?: string;
|
|
slotBModel?: string;
|
|
slotCModel?: string;
|
|
receiptDir?: string;
|
|
maxTokens?: number;
|
|
json: boolean;
|
|
// v0.40.1.0 Track D / T3 — batch mode over LongMemEval-shape JSONL.
|
|
batch?: string;
|
|
limit?: number;
|
|
concurrent?: number;
|
|
maxUsd?: number;
|
|
yes: boolean;
|
|
}
|
|
|
|
function parseArgs(args: string[]): ParsedArgs {
|
|
const out: ParsedArgs = { help: false, json: false, yes: false };
|
|
for (let i = 0; i < args.length; i++) {
|
|
const arg = args[i]!;
|
|
const next = args[i + 1];
|
|
switch (arg) {
|
|
case '--help':
|
|
case '-h':
|
|
out.help = true;
|
|
break;
|
|
case '--task':
|
|
if (next === undefined) break;
|
|
out.task = next;
|
|
i++;
|
|
break;
|
|
case '--output':
|
|
if (next === undefined) break;
|
|
out.output = next;
|
|
i++;
|
|
break;
|
|
case '--batch':
|
|
if (next === undefined) break;
|
|
out.batch = next;
|
|
i++;
|
|
break;
|
|
case '--limit':
|
|
if (next === undefined) break;
|
|
out.limit = parseIntStrict(next);
|
|
i++;
|
|
break;
|
|
case '--concurrent':
|
|
if (next === undefined) break;
|
|
out.concurrent = parseIntStrict(next);
|
|
i++;
|
|
break;
|
|
case '--max-usd':
|
|
if (next === undefined) break;
|
|
out.maxUsd = parseFloatStrict(next);
|
|
i++;
|
|
break;
|
|
case '--yes':
|
|
out.yes = true;
|
|
break;
|
|
case '--slug':
|
|
if (next === undefined) break;
|
|
out.slug = next;
|
|
i++;
|
|
break;
|
|
case '--dimensions':
|
|
if (next === undefined) break;
|
|
out.dimensions = next.split(',').map(s => s.trim()).filter(Boolean);
|
|
i++;
|
|
break;
|
|
case '--cycles':
|
|
if (next === undefined) break;
|
|
out.cycles = parseIntStrict(next);
|
|
i++;
|
|
break;
|
|
case '--slot-a-model':
|
|
if (next === undefined) break;
|
|
out.slotAModel = next;
|
|
i++;
|
|
break;
|
|
case '--slot-b-model':
|
|
if (next === undefined) break;
|
|
out.slotBModel = next;
|
|
i++;
|
|
break;
|
|
case '--slot-c-model':
|
|
if (next === undefined) break;
|
|
out.slotCModel = next;
|
|
i++;
|
|
break;
|
|
case '--receipt-dir':
|
|
if (next === undefined) break;
|
|
out.receiptDir = next;
|
|
i++;
|
|
break;
|
|
case '--max-tokens':
|
|
if (next === undefined) break;
|
|
out.maxTokens = parseIntStrict(next);
|
|
i++;
|
|
break;
|
|
case '--json':
|
|
out.json = true;
|
|
break;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function parseIntStrict(s: string): number {
|
|
const m = String(s).trim();
|
|
if (!/^\d+$/.test(m)) {
|
|
throw new Error(`expected positive integer, got: ${s}`);
|
|
}
|
|
return parseInt(m, 10);
|
|
}
|
|
|
|
function parseFloatStrict(s: string): number {
|
|
const m = String(s).trim();
|
|
const n = Number(m);
|
|
if (!Number.isFinite(n) || n < 0) {
|
|
throw new Error(`expected non-negative number, got: ${s}`);
|
|
}
|
|
return n;
|
|
}
|
|
|
|
// v0.41.15.0 (T4) — `runWithLimit` migrated to the shared worker-pool
|
|
// helper at src/core/worker-pool.ts. Re-exported here so callers that
|
|
// import from eval-cross-modal.ts keep working without a shim. The
|
|
// helper's API is opts-object shape — callers that built against the
|
|
// pre-v0.41.15 positional signature must update at the call site
|
|
// (no back-compat overload; codex #15).
|
|
export { runWithLimit };
|
|
|
|
function inferSlugFromOutputPath(path: string): string | undefined {
|
|
// skills/<slug>/SKILL.md or .../skills/<slug>/...
|
|
const m = path.replace(/\\/g, '/').match(/(?:^|\/)skills\/([^/]+)\/SKILL\.md$/);
|
|
return m ? m[1] : undefined;
|
|
}
|
|
|
|
function isTTY(): boolean {
|
|
return Boolean(process.stdout.isTTY);
|
|
}
|
|
|
|
/**
|
|
* Configure the AI gateway from `~/.gbrain/config.json` + process.env.
|
|
*
|
|
* Mirrors the body of `cli.ts:connectEngine()` minus the DB connect — we call
|
|
* this from the no-DB branch so the gateway is ready when runEval starts.
|
|
* Returns true on success; false (and prints a hint) when no config is found.
|
|
*/
|
|
function configureGatewayForCli(): boolean {
|
|
const config = loadConfig();
|
|
if (!config) {
|
|
// No config file is fine for the eval command — env vars alone may serve.
|
|
// We still call configureGateway so gateway recipes can read the env map.
|
|
configureGateway({
|
|
embedding_model: undefined,
|
|
embedding_dimensions: undefined,
|
|
expansion_model: undefined,
|
|
chat_model: undefined,
|
|
chat_fallback_chain: undefined,
|
|
base_urls: undefined,
|
|
provider_chat_options: undefined,
|
|
env: { ...process.env },
|
|
});
|
|
return true;
|
|
}
|
|
configureGateway({
|
|
embedding_model: config.embedding_model,
|
|
embedding_dimensions: config.embedding_dimensions,
|
|
expansion_model: config.expansion_model,
|
|
chat_model: config.chat_model,
|
|
chat_fallback_chain: config.chat_fallback_chain,
|
|
base_urls: config.provider_base_urls,
|
|
provider_chat_options: config.provider_chat_options,
|
|
env: { ...process.env },
|
|
});
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* v0.40.1.0 Track D / T3 (per D5) — DI seam for hermetic batch tests.
|
|
* Mirrors the `runEvalLongMemEval(args, {client?})` pattern at
|
|
* eval-longmemeval.ts:299. Default code path uses the imported runEval;
|
|
* tests pass `opts.runEval` returning canned RunEvalResult.
|
|
*/
|
|
export interface RunCrossModalOpts {
|
|
runEval?: typeof runEval;
|
|
}
|
|
|
|
export async function runEvalCrossModal(args: string[], opts: RunCrossModalOpts = {}): Promise<number> {
|
|
const parsed = parseArgs(args);
|
|
|
|
if (parsed.help) {
|
|
process.stdout.write(HELP);
|
|
return 0;
|
|
}
|
|
|
|
// v0.40.1.0 Track D / T3 — --batch vs --task mutex.
|
|
if (parsed.batch && parsed.task) {
|
|
process.stderr.write('Error: --batch and --task are mutually exclusive\n');
|
|
return 1;
|
|
}
|
|
|
|
// Batch path — short-circuit before the single-task validation below.
|
|
if (parsed.batch) {
|
|
return runBatchMode(parsed, opts);
|
|
}
|
|
|
|
if (!parsed.task) {
|
|
process.stderr.write('Error: --task "<description>" is required (or --batch <jsonl>)\n\n');
|
|
process.stderr.write(HELP);
|
|
return 1;
|
|
}
|
|
if (!parsed.output) {
|
|
process.stderr.write('Error: --output <path> is required\n\n');
|
|
process.stderr.write(HELP);
|
|
return 1;
|
|
}
|
|
|
|
if (!existsSync(parsed.output)) {
|
|
process.stderr.write(`Error: --output path not found: ${parsed.output}\n`);
|
|
return 1;
|
|
}
|
|
|
|
const outputContent = readFileSync(parsed.output, 'utf-8');
|
|
if (outputContent.trim().length === 0) {
|
|
process.stderr.write(`Error: --output file is empty: ${parsed.output}\n`);
|
|
return 1;
|
|
}
|
|
|
|
const slug = parsed.slug ?? inferSlugFromOutputPath(parsed.output);
|
|
// #1784: resolve the cycle default once; annotate the cost banner below when
|
|
// it's the silent non-TTY fallback so the 1-vs-3 difference isn't a surprise.
|
|
const cycleDef = resolveCycleDefault(parsed.cycles, isTTY());
|
|
const cycles = cycleDef.cycles;
|
|
const dimensions = parsed.dimensions ?? DEFAULT_DIMENSIONS;
|
|
const receiptDir = parsed.receiptDir ?? gbrainPath('eval-receipts');
|
|
const maxTokens = parsed.maxTokens ?? 4000;
|
|
|
|
const slots: SlotConfig[] = [
|
|
{ id: 'A', model: parsed.slotAModel ?? DEFAULT_SLOTS[0]!.model },
|
|
{ id: 'B', model: parsed.slotBModel ?? DEFAULT_SLOTS[1]!.model },
|
|
{ id: 'C', model: parsed.slotCModel ?? DEFAULT_SLOTS[2]!.model },
|
|
];
|
|
|
|
// Configure the AI gateway. Without this, every chat() call throws
|
|
// "AI gateway is not configured" because the cli.ts no-DB branch skips
|
|
// connectEngine (T3=A).
|
|
configureGatewayForCli();
|
|
|
|
// Probe whether the gateway can serve `chat`. If not, we can't run.
|
|
if (!isAvailable('chat')) {
|
|
process.stderr.write(
|
|
'Error: AI gateway has no usable chat provider. ' +
|
|
'Configure one of OPENAI_API_KEY / ANTHROPIC_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY ' +
|
|
'in your shell or run `gbrain config` to set keys.\n',
|
|
);
|
|
return 1;
|
|
}
|
|
|
|
// Cost estimate (T11=B).
|
|
const cost = estimateCost(slots, cycles, maxTokens);
|
|
process.stderr.write(
|
|
`[eval cross-modal] estimated cost: ~$${cost.perCycleUSD.toFixed(2)}/cycle, ` +
|
|
`~$${cost.perRunMaxUSD.toFixed(2)} max for ${cycles} cycle(s)${cycleDefaultSuffix(cycleDef)}.\n`,
|
|
);
|
|
for (const note of cost.notes) {
|
|
process.stderr.write(`[eval cross-modal] note: ${note}\n`);
|
|
}
|
|
|
|
// Progress reporter (stderr only).
|
|
const onProgress = (ev: ProgressEvent) => {
|
|
switch (ev.kind) {
|
|
case 'cycle_start':
|
|
process.stderr.write(`[eval cross-modal] cycle ${ev.cycle}/${ev.total} starting...\n`);
|
|
break;
|
|
case 'slot_done': {
|
|
const status = ev.ok ? 'ok' : 'failed';
|
|
process.stderr.write(
|
|
`[eval cross-modal] slot ${ev.slotId} (${ev.modelId}) ${status} in ${ev.ms}ms\n`,
|
|
);
|
|
break;
|
|
}
|
|
case 'cycle_end':
|
|
process.stderr.write(`[eval cross-modal] cycle ${ev.cycle} verdict: ${ev.verdict}\n`);
|
|
break;
|
|
}
|
|
};
|
|
|
|
const runEvalFn = opts.runEval ?? runEval;
|
|
let result: RunEvalResult;
|
|
try {
|
|
result = await runEvalFn({
|
|
task: parsed.task,
|
|
output: outputContent,
|
|
slug,
|
|
dimensions,
|
|
slots,
|
|
cycles,
|
|
receiptDir,
|
|
maxTokens,
|
|
onProgress,
|
|
});
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
process.stderr.write(`[eval cross-modal] runtime error: ${msg}\n`);
|
|
return 1;
|
|
}
|
|
|
|
// Final summary to stderr (always) + JSON to stdout (when --json).
|
|
const verdict = result.finalAggregate.verdict;
|
|
process.stderr.write('\n');
|
|
process.stderr.write(`[eval cross-modal] ${result.finalAggregate.verdictMessage}\n`);
|
|
process.stderr.write(`[eval cross-modal] receipt: ${result.finalReceiptPath}\n`);
|
|
|
|
if (parsed.json) {
|
|
process.stdout.write(
|
|
JSON.stringify(
|
|
{
|
|
verdict,
|
|
aggregate: result.finalAggregate,
|
|
cycles: result.cycles.map(c => ({
|
|
cycle: c.cycle,
|
|
receipt_path: c.receipt_path,
|
|
verdict: c.aggregate.verdict,
|
|
overall: c.aggregate.overall,
|
|
})),
|
|
finalReceiptPath: result.finalReceiptPath,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
process.stdout.write('\n');
|
|
}
|
|
|
|
if (verdict === 'pass') return 0;
|
|
if (verdict === 'inconclusive') return 2;
|
|
return 1;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// v0.40.1.0 Track D / T3 — Batch mode over LongMemEval-shape JSONL.
|
|
//
|
|
// Reads {question, hypothesis, ...} rows, slices to --limit, fans out via
|
|
// runWithLimit semaphore, aggregates per-question verdicts into a single
|
|
// summary receipt. Per-question receipts land in a tempdir and are deleted
|
|
// at end of run (per D10 — keeps ~/.gbrain/eval-receipts/ clean).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface BatchRow {
|
|
question_id: string;
|
|
question: string;
|
|
hypothesis: string;
|
|
/**
|
|
* Gold answer from the benchmark dataset, when the upstream eval emits
|
|
* it (eval-longmemeval does). Folded into the judge task so CORRECTNESS
|
|
* is verifiable — without it a judge panel that sees only
|
|
* {question, hypothesis} cannot validate a terse factual answer against
|
|
* a haystack it never saw.
|
|
*/
|
|
answer?: string;
|
|
}
|
|
|
|
/**
|
|
* v0.40.1.0 Track D (codex CDX-1) — upstream-error row from
|
|
* `gbrain eval longmemeval`. Carries `question`+`question_type` and an
|
|
* `error` field but no usable hypothesis. Counted in the batch summary's
|
|
* `upstream_error_count` so the denominator includes failed rows, never
|
|
* silently dropped (which would let the gate pass on a surviving subset).
|
|
*/
|
|
interface UpstreamErrorRow {
|
|
question_id: string;
|
|
question: string;
|
|
question_type?: string;
|
|
error: string;
|
|
}
|
|
|
|
interface BatchReadResult {
|
|
rows: BatchRow[];
|
|
upstream_errors: UpstreamErrorRow[];
|
|
malformed_count: number;
|
|
}
|
|
|
|
export interface BatchSummary {
|
|
schema_version: 1;
|
|
kind: 'cross_modal_batch_summary';
|
|
timestamp: string;
|
|
/** Sum of scored + upstream_error + malformed rows. Real denominator. */
|
|
total: number;
|
|
pass_count: number;
|
|
fail_count: number;
|
|
inconclusive_count: number;
|
|
/** Per-question runtime errors from the cross-modal scoring layer. */
|
|
error_count: number;
|
|
/**
|
|
* v0.40.1.0 Track D (codex CDX-1) — rows that arrived from the upstream
|
|
* eval already failed (longmemeval emitted an error row with no usable
|
|
* hypothesis). Counted in `total` so the denominator can't bypass the gate.
|
|
*/
|
|
upstream_error_count: number;
|
|
/**
|
|
* v0.40.1.0 Track D (codex CDX-1) — JSONL rows that didn't have the
|
|
* required shape (missing question or hypothesis, not a tagged error row).
|
|
* Counted in `total`; treated as ERROR for exit precedence.
|
|
*/
|
|
malformed_count: number;
|
|
verdict: 'pass' | 'fail' | 'inconclusive' | 'error';
|
|
est_cost_usd: number;
|
|
slots: SlotConfig[];
|
|
cycles_per_question: number;
|
|
concurrent: number;
|
|
per_question: Array<{
|
|
question_id: string;
|
|
verdict: 'pass' | 'fail' | 'inconclusive' | 'error' | 'upstream_error';
|
|
error?: string;
|
|
final_aggregate?: unknown;
|
|
}>;
|
|
}
|
|
|
|
function readBatchRows(path: string): BatchReadResult {
|
|
if (!existsSync(path)) {
|
|
throw new Error(`--batch file not found: ${path}`);
|
|
}
|
|
const raw = readFileSync(path, 'utf8');
|
|
const rows: BatchRow[] = [];
|
|
const upstream_errors: UpstreamErrorRow[] = [];
|
|
let lineNo = 0;
|
|
let summarySkipped = 0;
|
|
let parseErrors = 0;
|
|
let malformed_count = 0;
|
|
for (const line of raw.split('\n')) {
|
|
lineNo++;
|
|
if (!line.trim()) continue;
|
|
let obj: any;
|
|
try {
|
|
obj = JSON.parse(line);
|
|
} catch {
|
|
parseErrors++;
|
|
process.stderr.write(`[eval cross-modal batch] skipping invalid JSON at line ${lineNo}\n`);
|
|
continue;
|
|
}
|
|
if (!obj || typeof obj !== 'object') continue;
|
|
// Skip the by_type_summary tail row — metadata, not a question.
|
|
if (obj.kind === 'by_type_summary') {
|
|
summarySkipped++;
|
|
continue;
|
|
}
|
|
// v0.40.1.0 Track D (codex CDX-1): upstream error rows from
|
|
// `gbrain eval longmemeval` carry an `error` field and an empty/missing
|
|
// hypothesis. Treat them as upstream_error verdicts in the batch summary
|
|
// so they count in the denominator instead of silently disappearing.
|
|
if (typeof obj.error === 'string' && obj.error.length > 0) {
|
|
upstream_errors.push({
|
|
question_id: typeof obj.question_id === 'string' ? obj.question_id : `line-${lineNo}`,
|
|
question: typeof obj.question === 'string' ? obj.question : '',
|
|
...(typeof obj.question_type === 'string' ? { question_type: obj.question_type } : {}),
|
|
error: obj.error,
|
|
});
|
|
continue;
|
|
}
|
|
if (typeof obj.question !== 'string' || typeof obj.hypothesis !== 'string') {
|
|
// Row missing required fields — malformed, count it. We count instead
|
|
// of silently dropping so the batch summary can surface the loss.
|
|
malformed_count++;
|
|
process.stderr.write(
|
|
`[eval cross-modal batch] skipping malformed row at line ${lineNo}: ` +
|
|
`missing question or hypothesis field\n`,
|
|
);
|
|
continue;
|
|
}
|
|
rows.push({
|
|
question_id: typeof obj.question_id === 'string' ? obj.question_id : `line-${lineNo}`,
|
|
question: obj.question,
|
|
hypothesis: obj.hypothesis,
|
|
...(typeof obj.answer === 'string' && obj.answer.length > 0 ? { answer: obj.answer } : {}),
|
|
});
|
|
}
|
|
if (summarySkipped > 0) {
|
|
process.stderr.write(`[eval cross-modal batch] filtered ${summarySkipped} summary row(s)\n`);
|
|
}
|
|
if (upstream_errors.length > 0) {
|
|
process.stderr.write(
|
|
`[eval cross-modal batch] ${upstream_errors.length} upstream-error row(s) detected; ` +
|
|
`they will count in the batch denominator as ERROR verdicts.\n`,
|
|
);
|
|
}
|
|
if (malformed_count > 0) {
|
|
process.stderr.write(
|
|
`[eval cross-modal batch] ${malformed_count} malformed row(s) skipped. ` +
|
|
`Batch will FAIL — re-run upstream eval to fix.\n`,
|
|
);
|
|
}
|
|
if (parseErrors > 0) {
|
|
process.stderr.write(`[eval cross-modal batch] skipped ${parseErrors} corrupt line(s)\n`);
|
|
// Corrupt JSON lines roll into malformed for exit-precedence purposes.
|
|
malformed_count += parseErrors;
|
|
}
|
|
return { rows, upstream_errors, malformed_count };
|
|
}
|
|
|
|
async function runBatchMode(parsed: ParsedArgs, opts: RunCrossModalOpts): Promise<number> {
|
|
// Defaults specific to batch mode.
|
|
const limit = parsed.limit ?? 10;
|
|
const concurrent = parsed.concurrent ?? 3;
|
|
const cycles = parsed.cycles ?? 1; // default 1 in batch to bound cost
|
|
const dimensions = parsed.dimensions ?? DEFAULT_DIMENSIONS;
|
|
const maxTokens = parsed.maxTokens ?? 4000;
|
|
const maxUsd = parsed.maxUsd ?? 5.0;
|
|
|
|
const slots: SlotConfig[] = [
|
|
{ id: 'A', model: parsed.slotAModel ?? DEFAULT_SLOTS[0]!.model },
|
|
{ id: 'B', model: parsed.slotBModel ?? DEFAULT_SLOTS[1]!.model },
|
|
{ id: 'C', model: parsed.slotCModel ?? DEFAULT_SLOTS[2]!.model },
|
|
];
|
|
|
|
// v0.40.1.0 Track D (codex CDX-2): --limit must be >= 1. Passing
|
|
// --limit 0 would let an empty result fall through to PASS with
|
|
// total:0 — a direct CI bypass. Fail fast.
|
|
if (limit < 1) {
|
|
process.stderr.write(
|
|
`Error: --limit must be >= 1 (got ${limit}). --limit 0 would bypass the gate.\n`,
|
|
);
|
|
return 1;
|
|
}
|
|
|
|
// Read + slice rows BEFORE configuring the gateway so a malformed batch
|
|
// file fails fast without spending any setup time.
|
|
let rows: BatchRow[];
|
|
let upstreamErrors: UpstreamErrorRow[];
|
|
let malformedCount: number;
|
|
try {
|
|
const result = readBatchRows(parsed.batch!);
|
|
rows = result.rows;
|
|
upstreamErrors = result.upstream_errors;
|
|
malformedCount = result.malformed_count;
|
|
} catch (err) {
|
|
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
return 1;
|
|
}
|
|
// Total usable inputs = scored rows + upstream errors (CDX-1: both count
|
|
// in the denominator). Zero usable inputs means there's nothing to do
|
|
// AND nothing to flag — the upstream eval produced no output at all.
|
|
if (rows.length === 0 && upstreamErrors.length === 0) {
|
|
process.stderr.write(`Error: --batch file has zero usable rows\n`);
|
|
return 1;
|
|
}
|
|
if (limit < rows.length) rows = rows.slice(0, limit);
|
|
|
|
// Pre-flight cost estimate. Refuse if over --max-usd without --yes.
|
|
const perQuestion = estimateCost(slots, cycles, maxTokens);
|
|
const estTotal = perQuestion.perRunMaxUSD * rows.length;
|
|
process.stderr.write(
|
|
`[eval cross-modal batch] estimated cost: ~$${estTotal.toFixed(2)} ` +
|
|
`for ${rows.length} questions x ${cycles} cycle(s) x 3 slots ` +
|
|
`(per-question ~$${perQuestion.perRunMaxUSD.toFixed(2)}, concurrent=${concurrent})\n`,
|
|
);
|
|
if (estTotal > maxUsd && !parsed.yes) {
|
|
process.stderr.write(
|
|
`Error: estimated cost $${estTotal.toFixed(2)} exceeds --max-usd $${maxUsd.toFixed(2)}; ` +
|
|
`pass --yes to proceed or lower --limit / --cycles.\n`,
|
|
);
|
|
return 1;
|
|
}
|
|
|
|
// Configure gateway (same path as single-task mode). When runEval is
|
|
// injected (test mode), skip the gateway availability gate — the injected
|
|
// function handles its own backend, so requiring an API key here would
|
|
// make hermetic unit tests impossible.
|
|
if (!opts.runEval) {
|
|
configureGatewayForCli();
|
|
if (!isAvailable('chat')) {
|
|
process.stderr.write(
|
|
'Error: AI gateway has no usable chat provider. ' +
|
|
'Configure one of OPENAI_API_KEY / ANTHROPIC_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY.\n',
|
|
);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
// Per-question receipts land in a tempdir; we delete the tempdir at the
|
|
// end of the batch (per D10 — keeps ~/.gbrain/eval-receipts/ clean).
|
|
const batchTempDir = mkdtempSync(join(tmpdir(), 'gbrain-batch-receipts-'));
|
|
const runEvalFn = opts.runEval ?? runEval;
|
|
|
|
try {
|
|
const results = await runWithLimit({
|
|
items: rows,
|
|
limit: concurrent,
|
|
fn: async (row, idx) => {
|
|
process.stderr.write(`[eval cross-modal batch] ${idx + 1}/${rows.length} ${row.question_id} starting...\n`);
|
|
return await runEvalFn({
|
|
// With a gold answer the judges can actually verify correctness;
|
|
// without one they see only {question, hypothesis} and cannot.
|
|
task: row.answer
|
|
? `${row.question}\n\nExpected answer (gold label from the benchmark dataset): ${row.answer}`
|
|
: row.question,
|
|
output: row.hypothesis,
|
|
slug: row.question_id,
|
|
dimensions,
|
|
slots,
|
|
cycles,
|
|
receiptDir: batchTempDir,
|
|
maxTokens,
|
|
});
|
|
},
|
|
});
|
|
|
|
// Aggregate verdicts.
|
|
let pass = 0, fail = 0, inconclusive = 0, errored = 0;
|
|
const perQuestionResults: BatchSummary['per_question'] = [];
|
|
for (let i = 0; i < results.length; i++) {
|
|
const r = results[i]!;
|
|
const qid = rows[i]!.question_id;
|
|
if (!r.ok) {
|
|
errored++;
|
|
// Helper's error field is `unknown` (was `Error` pre-v0.41.15);
|
|
// narrow at the use site.
|
|
const errMsg = r.error instanceof Error ? r.error.message : String(r.error);
|
|
perQuestionResults.push({ question_id: qid, verdict: 'error', error: errMsg });
|
|
continue;
|
|
}
|
|
const v = r.value.finalAggregate.verdict;
|
|
if (v === 'pass') pass++;
|
|
else if (v === 'fail') fail++;
|
|
else inconclusive++;
|
|
perQuestionResults.push({
|
|
question_id: qid,
|
|
verdict: v,
|
|
final_aggregate: r.value.finalAggregate,
|
|
});
|
|
}
|
|
|
|
// v0.40.1.0 Track D (codex CDX-1): upstream errors from the upstream
|
|
// eval are folded into per_question with verdict 'upstream_error' so
|
|
// the audit trail is complete. They count in the ERROR exit precedence.
|
|
for (const ue of upstreamErrors) {
|
|
perQuestionResults.push({
|
|
question_id: ue.question_id,
|
|
verdict: 'upstream_error',
|
|
error: ue.error,
|
|
});
|
|
}
|
|
|
|
// v0.40.1.0 Track D (codex CDX-1): malformed rows can't be scored AND
|
|
// can't be cited (no question text). They count toward total + ERROR
|
|
// exit code so a corrupt JSONL can't silently shrink the denominator.
|
|
|
|
const upstreamErrorCount = upstreamErrors.length;
|
|
const totalDenom = rows.length + upstreamErrorCount + malformedCount;
|
|
|
|
// Exit precedence (fail-loud convention; CDX-1 widens "ERROR" to include
|
|
// upstream and malformed):
|
|
// any error / upstream_error / malformed → 2
|
|
// else any FAIL → 1
|
|
// else any INCONCLUSIVE → 2
|
|
// else 0 (all PASS)
|
|
let batchVerdict: BatchSummary['verdict'];
|
|
let exitCode: number;
|
|
if (errored > 0 || upstreamErrorCount > 0 || malformedCount > 0) {
|
|
batchVerdict = 'error';
|
|
exitCode = 2;
|
|
} else if (fail > 0) { batchVerdict = 'fail'; exitCode = 1; }
|
|
else if (inconclusive > 0) { batchVerdict = 'inconclusive'; exitCode = 2; }
|
|
else { batchVerdict = 'pass'; exitCode = 0; }
|
|
|
|
const summary: BatchSummary = {
|
|
schema_version: 1,
|
|
kind: 'cross_modal_batch_summary',
|
|
timestamp: new Date().toISOString(),
|
|
total: totalDenom,
|
|
pass_count: pass,
|
|
fail_count: fail,
|
|
inconclusive_count: inconclusive,
|
|
error_count: errored,
|
|
upstream_error_count: upstreamErrorCount,
|
|
malformed_count: malformedCount,
|
|
verdict: batchVerdict,
|
|
est_cost_usd: estTotal,
|
|
slots,
|
|
cycles_per_question: cycles,
|
|
concurrent,
|
|
per_question: perQuestionResults,
|
|
};
|
|
|
|
// Write summary to --output or default path.
|
|
const summaryPath = parsed.output ??
|
|
join(gbrainPath('eval-receipts'), `cross-modal-batch-${batchSha8(summary)}.json`);
|
|
// Ensure receipts dir exists (the inline ad-hoc default path bypasses
|
|
// the per-cycle runEval mkdir).
|
|
try {
|
|
const summaryDir = summaryPath.substring(0, summaryPath.lastIndexOf('/'));
|
|
if (summaryDir && !existsSync(summaryDir)) {
|
|
mkdirSync(summaryDir, { recursive: true });
|
|
}
|
|
} catch { /* best-effort */ }
|
|
writeFileSync(summaryPath, JSON.stringify(summary, null, 2) + '\n', 'utf8');
|
|
|
|
process.stderr.write(
|
|
`\n[eval cross-modal batch] verdict=${batchVerdict} ` +
|
|
`pass=${pass} fail=${fail} inconclusive=${inconclusive} ` +
|
|
`error=${errored} upstream_error=${upstreamErrorCount} malformed=${malformedCount} ` +
|
|
`(total ${totalDenom})\n`,
|
|
);
|
|
process.stderr.write(`[eval cross-modal batch] summary receipt: ${summaryPath}\n`);
|
|
|
|
if (parsed.json) {
|
|
process.stdout.write(JSON.stringify(summary, null, 2));
|
|
process.stdout.write('\n');
|
|
}
|
|
|
|
return exitCode;
|
|
} finally {
|
|
// Clean up the per-question receipt tempdir (per D10).
|
|
try {
|
|
rmSync(batchTempDir, { recursive: true, force: true });
|
|
} catch (err) {
|
|
process.stderr.write(
|
|
`[eval cross-modal batch] warning: tempdir cleanup failed: ` +
|
|
`${err instanceof Error ? err.message : String(err)}\n`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function batchSha8(summary: BatchSummary): string {
|
|
return createHash('sha256')
|
|
.update(JSON.stringify({ ts: summary.timestamp, n: summary.total, ids: summary.per_question.map(p => p.question_id) }))
|
|
.digest('hex')
|
|
.slice(0, 8);
|
|
}
|