Files
gbrain/src/commands/eval.ts
T
a1a2671c21 v0.28.4 feat(skillpack): enhance skillify with cross-modal eval quality gate (#674)
* feat(skillpack): enhance skillify with cross-modal eval quality gate

Updates skillify from v1.0.0 to v2.0.0 with the key innovation:
cross-modal evaluation runs BEFORE tests (step 3) to establish
quality, then tests lock in the proven-good behavior.

Key changes:
- 11-item checklist (was 10) - adds cross-modal eval as step 3
- Cross-modal eval uses 3 models to score output on 5 dimensions
- Quality gate: all dimensions ≥ 7 average before proceeding to tests
- Prevents locking in mediocrity through tests-first approach
- References cross-modal-review skill for eval pipeline
- Updated all gbrain-specific paths (bun test, scripts/*.ts)
- Maintains compatibility with gbrain check-resolvable workflow

The meta-skill for turning raw features into properly-skilled,
tested, resolvable capabilities. Cross-modal eval ensures output
quality before tests cement the behavior.

* feat: skillify hardened via 2 cross-modal eval cycles (8.1/10)

Applied top improvements from GPT-5.5 + Opus 4-7 + DeepSeek V4 Pro:
- Named 3 frontier models explicitly with provider table
- Inlined eval prompt template with CONTEXT param + scoring calibration
- Defined aggregation math: mean >= 7 AND no single dim < 5
- Added eval receipt JSON schema
- Structured 3-cycle fix loop with before/after delta tracking
- Added worked example (summarize-pr, end-to-end)
- Added cost guardrails (skip < 200 tokens, max 9 API calls)
- Added representative input selection rule
- Added SKILL.md frontmatter template (copy-paste ready)
- Added Phase 0 decision gate (is this worth skillifying?)

Also includes cross-modal-eval runner recipe with robust JSON
parsing for LLMs that return malformed JSON (3-tier repair).

* chore(recipes): remove cross-modal-eval.mjs

Superseded by `gbrain eval cross-modal` (next commit). The .mjs script
was the original PR's hand-rolled provider stack; the replacement reuses
src/core/ai/gateway.ts so config/auth/model-aliasing comes from the
canonical recipe registry instead of a parallel stack.

No code references the .mjs (it was invoked by skill prose only), so
this delete is independently safe to bisect through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(eval): cross-modal-eval core module + unit tests

Pure-logic foundation for the new `gbrain eval cross-modal` command
(wired in the next commit). All five modules are self-contained — no
CLI surface, no I/O outside the receipt writer's mkdirSync. Imported
from src/core/ai/gateway.ts at runtime via gwChat (no config impact
at load time).

Modules:
  - json-repair.ts:    parseModelJSON 4-strategy fallback chain.
                       Adversarial nuclear-option throws rather than
                       fabricating scores (Q6 + Q3 in plan).
  - aggregate.ts:      verdict logic. PASS = (>=2 successes) AND
                       (every dim mean >= 7) AND (every dim min
                       across models >= 5). INCONCLUSIVE when <2/3
                       models returned parseable scores — closes the
                       v1 .mjs `Object.values({}).every(...) === true`
                       empty-array silent-PASS bug (Q2 + Q3).
  - receipt-name.ts:   receipt filename binds (slug, sha8 of SKILL.md)
                       so `gbrain skillify check` can detect stale
                       audits (T10 in plan).
  - receipt-write.ts:  thin wrapper over writeFileSync that auto-mkdirs
                       the parent directory. Standalone module because
                       gbrainPath() does NOT auto-mkdir (T5 plan
                       correction — Codex caught this).
  - runner.ts:         orchestrator. Promise.allSettled across 3 slots
                       per cycle; up to 3 cycles; stops early on PASS
                       or INCONCLUSIVE. Default slots: openai:gpt-4o /
                       anthropic:claude-opus-4-7 / google:gemini-1.5-pro.
                       estimateCost() exports a small per-model
                       pricing table (drifts; refresh alongside
                       model-family bumps).

Tests (32 cases total, all green):
  - json-repair.test.ts:  10 cases (clean JSON, fences, trailing
                          commas, single quotes, embedded newlines,
                          mismatched braces, nuclear-option success
                          + adversarial throws, empty input,
                          numeric-shorthand scores).
  - aggregate.test.ts:    8 cases pinning Q2/Q3/dedup. The 0-of-3
                          INCONCLUSIVE case is the regression guard
                          for the v1 silent-PASS bug.
  - cli.test.ts:          12 cases on receipt-name / receipt-write /
                          GBRAIN_HOME isolation. Uses withEnv()
                          helper for env mutation (R1 isolation rule).

Verifies bisect-clean: typecheck passes, all 32 unit cases green.
The runner.ts import of gateway.chat() is dead until commit 3 wires
the CLI surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(eval): wire `gbrain eval cross-modal` CLI subcommand

User-facing surface for the multi-model quality gate. Three different-
provider frontier models score the OUTPUT against the TASK on a 5-dim
rubric. Verdict drives exit code: 0 PASS, 1 FAIL, 2 INCONCLUSIVE
(<2/3 models returned parseable scores per Q3 in plan).

Wiring touches three files:

  - src/commands/eval-cross-modal.ts (new, ~290 lines)
    CLI handler. Self-configures the AI gateway from loadConfig() +
    process.env so it works without `gbrain init` (the cli.ts no-DB
    branch bypasses connectEngine()). Defaults: cycles=3 in TTY,
    cycles=1 in non-TTY (T11 partial cost guardrail — limits scripted
    bulk spend; full --budget-usd hard cap is a v0.27.x TODO). Prints
    estimated max-cost-per-cycle to stderr before each run. Uses
    gbrainPath('eval-receipts') for receipt directory.

  - src/cli.ts (no-DB dispatch branch, 5-line addition)
    Special-cases `eval cross-modal` BEFORE the existing
    handleCliOnly path that requires connectEngine(). Mirrors the
    `dream` no-DB pattern but doesn't even attempt the connect — the
    command never touches the DB. New users can run the gate before
    `gbrain init` (T3 in plan).

  - src/commands/eval.ts (sub-subcommand dispatch)
    Adds `cross-modal` alongside `export`/`prune`/`replay`. The
    cli.ts branch takes precedence in the user-facing path; this
    branch only fires when callers re-enter runEvalCommand with an
    existing engine. Engine is intentionally unused — the handler
    self-routes.

  - test/e2e/cross-modal-eval.test.ts (new, 4 cases)
    Mocked-fetch E2E. Lives at test/e2e/* (NOT *.serial.test.ts) per
    plan T8: test/e2e/* is exempt from the test-isolation lint and
    already runs serially via scripts/run-e2e.sh, so the
    mock.module() call doesn't need a quarantine rename. Cases:
    PASS / FAIL (mean<7) / FAIL (min<5 — Q2 floor) / INCONCLUSIVE
    (2 mock 5xx — Q3 contract).

The runner from commit 2 now has live callers. typecheck passes;
the 4 E2E cases all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(skillify): add informational 11th item (cross-modal eval)

Promotes the skillify contract from 10 to 11 items. The 11th item
(cross-modal eval) is `required:false` per T7 in the plan — a
missing or stale receipt surfaces in the audit output but does not
fail the gate. Existing skills keep their current required-score;
the bump is additive, not breaking.

Changes:

  - src/commands/skillify.ts
    Header jsdoc updated 10-item -> 11-item. No code-flow changes.

  - src/commands/skillify-check.ts (the per-skill audit; not
    src/commands/skillpack-check.ts which is a different command —
    plan T6 corrected the conflation in the original plan)
    New informational item at position 11. Reuses
    findReceiptForSkill() helper from
    src/core/cross-modal-eval/receipt-name.ts to detect:
      * found  — receipt matches current SKILL.md sha-8
      * stale  — receipt exists for an older SKILL.md
      * missing — no receipt yet
    Audit output cases pass through to existing pretty/JSON formats.

  - src/core/skillify/templates.ts
    Scaffolded SKILL.md now includes a "Phase 3: Cross-modal eval
    (informational)" section with copy-paste `gbrain eval cross-modal`
    invocation, pass criteria, and receipt-naming convention. Helps
    new skill authors discover the gate.

  - test/skillify-scaffold.test.ts
    New T9 case verifies the scaffold emits the Phase 3 section,
    points at the correct command, documents the receipt path, and
    appends exactly one resolver row. Replaces the original plan's
    `gbrain skillify scaffold demo-eleven` shell verification (which
    Codex caught as invalid + repo-mutating).

Verifies: typecheck passes; scaffold test 19/19 (was 18, +1 T9 case).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: skillify v1.1.0 + cross-modal-eval references

Documentation catches up with the new behavior shipped in commits 1-4.

  - skills/skillify/SKILL.md (1.0.0 -> 1.1.0)
    Full rewrite. Frontmatter version is additive (T7 in plan); the
    11th item is informational, not breaking. Phase 3 now points at
    `gbrain eval cross-modal` with copy-paste invocation, default
    slot table, pass criteria, receipt-naming convention, cycles +
    cost guardrails (T11 partial cap), provider configuration via
    the AI gateway, and the cycle-1/2/3 fix loop. Adds Output Format
    section (skills-conformance.test.ts requires it). Drops the
    original `(or lib/cross-modal-eval.ts)` parenthetical (Q5 plan
    correction — that path never existed).

  - skills/cross-modal-review/SKILL.md
    Adds 4-line Relationship section pointing at `gbrain eval
    cross-modal` (D3 plan reciprocal). Distinguishes the manual
    second-opinion gate (this skill) from the automated multi-model
    score-and-iterate gate (the new command).

  - CLAUDE.md
    Key Files entries for src/commands/eval-cross-modal.ts and the
    five new src/core/cross-modal-eval/* modules. Commands list
    gains the `gbrain eval cross-modal` entry under v0.27.x. Notes
    the non-TTY default 1-cycle behavior + the gbrainPath('eval-
    receipts') resolution.

  - TODOS.md
    Four v0.27.x follow-ups filed under a new "cross-modal-eval"
    section: full --budget-usd cap (T11 follow-up), subagent
    integration (recovers cross-process rate-leases T4 deferred),
    skill adoption telemetry (revisit T7=C with data after 30 days),
    docs/cross-modal-eval.md user guide.

  - llms-full.txt
    Regenerated via `bun run build:llms` to match the CLAUDE.md
    edits — sync guard at test/build-llms.test.ts requires this.

Verifies: typecheck passes; skills-conformance 199/199 green;
build-llms 7/7 green; full unit fast loop 3861/3861 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.28.4)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: garrytan-agents <garrytan-agents@users.noreply.github.com>
Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 20:39:56 -07:00

370 lines
13 KiB
TypeScript

/**
* gbrain eval — Retrieval Evaluation Command
*
* Runs search quality benchmarks against user-defined ground truth (qrels).
* Supports single-config runs and A/B comparison mode for tuning parameters.
*
* Usage:
* gbrain eval --qrels <path|json>
* gbrain eval --qrels <path> --config-a <path|json> --config-b <path|json>
* gbrain eval --qrels <path> --strategy hybrid --rrf-k 30 --k 5
*/
import { readFileSync, existsSync } from 'fs';
import type { BrainEngine } from '../core/engine.ts';
import {
runEval,
parseQrels,
type EvalConfig,
type EvalReport,
type QueryResult,
} from '../core/search/eval.ts';
export async function runEvalCommand(engine: BrainEngine, args: string[]): Promise<void> {
// v0.25.0 — sub-subcommand dispatch. Bare `gbrain eval --qrels ...`
// falls through to the legacy IR-metrics flow so existing callers
// don't break.
const sub = args[0];
if (sub === 'export') {
const { runEvalExport } = await import('./eval-export.ts');
return runEvalExport(engine, args.slice(1));
}
if (sub === 'prune') {
const { runEvalPrune } = await import('./eval-prune.ts');
return runEvalPrune(engine, args.slice(1));
}
if (sub === 'replay') {
const { runEvalReplay } = await import('./eval-replay.ts');
return runEvalReplay(engine, args.slice(1));
}
if (sub === 'cross-modal') {
// No-DB sub-subcommand. The cli.ts dispatcher routes the user-facing
// path before connectEngine, so this branch only fires when callers
// already have an engine and re-enter via runEvalCommand. Engine is
// intentionally unused.
const { runEvalCrossModal } = await import('./eval-cross-modal.ts');
process.exit(await runEvalCrossModal(args.slice(1)));
}
const opts = parseArgs(args);
if (opts.help) {
printHelp();
return;
}
if (!opts.qrels) {
console.error('Error: --qrels <path|json> is required\n');
printHelp();
process.exit(1);
}
let qrels;
try {
qrels = parseQrels(opts.qrels);
} catch (err: any) {
console.error(`Error loading qrels: ${err.message}`);
process.exit(1);
}
if (qrels.length === 0) {
console.error('Error: qrels file contains no queries');
process.exit(1);
}
const k = opts.k ?? 5;
const configA = buildConfig(opts, 'a');
const { createProgress } = await import('../core/progress.ts');
const { getCliOptions, cliOptsToProgressOptions } = await import('../core/cli-options.ts');
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
if (opts.configB || opts.configBPath) {
// A/B comparison mode
const configB = buildConfig(opts, 'b');
progress.start('eval.ab', qrels.length * 2);
const onProgress = (_done: number, _total: number, q: string) => progress.tick(1, q);
const [reportA, reportB] = await Promise.all([
runEval(engine, qrels, configA, k, { onProgress }),
runEval(engine, qrels, configB, k, { onProgress }),
]);
progress.finish();
printABTable(reportA, reportB, k);
} else {
// Single-run mode
progress.start('eval.single', qrels.length);
const report = await runEval(engine, qrels, configA, k, {
onProgress: (_done, _total, q) => progress.tick(1, q),
});
progress.finish();
printSingleTable(report);
}
}
// ─────────────────────────────────────────────────────────────────
// Argument parsing
// ─────────────────────────────────────────────────────────────────
interface ParsedArgs {
help: boolean;
qrels?: string;
configAPath?: string;
configBPath?: string;
configB?: EvalConfig;
strategy?: EvalConfig['strategy'];
rrfK?: number;
expand?: boolean;
dedupCosine?: number;
dedupTypeRatio?: number;
dedupMaxPerPage?: number;
limit?: number;
k?: number;
}
function parseArgs(args: string[]): ParsedArgs {
const opts: ParsedArgs = { help: false };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const next = args[i + 1];
switch (arg) {
case '--help': case '-h': opts.help = true; break;
case '--qrels': opts.qrels = next; i++; break;
case '--config-a': opts.configAPath = next; i++; break;
case '--config-b': opts.configBPath = next; i++; break;
case '--strategy': opts.strategy = next as EvalConfig['strategy']; i++; break;
case '--rrf-k': opts.rrfK = parseInt(next, 10); i++; break;
case '--expand': opts.expand = true; break;
case '--no-expand': opts.expand = false; break;
case '--dedup-cosine': opts.dedupCosine = parseFloat(next); i++; break;
case '--dedup-type-ratio': opts.dedupTypeRatio = parseFloat(next); i++; break;
case '--dedup-max-per-page': opts.dedupMaxPerPage = parseInt(next, 10); i++; break;
case '--limit': opts.limit = parseInt(next, 10); i++; break;
case '--k': opts.k = parseInt(next, 10); i++; break;
}
}
return opts;
}
function buildConfig(opts: ParsedArgs, side: 'a' | 'b'): EvalConfig {
const pathOpt = side === 'a' ? opts.configAPath : opts.configBPath;
// Start from file or inline JSON if provided
let base: EvalConfig = {};
if (pathOpt) {
base = loadConfigFile(pathOpt);
}
// CLI flags override config file (only for side A — side B comes entirely from its config file)
if (side === 'a') {
if (opts.strategy !== undefined) base.strategy = opts.strategy;
if (opts.rrfK !== undefined) base.rrf_k = opts.rrfK;
if (opts.expand !== undefined) base.expand = opts.expand;
if (opts.dedupCosine !== undefined) base.dedup_cosine_threshold = opts.dedupCosine;
if (opts.dedupTypeRatio !== undefined) base.dedup_type_ratio = opts.dedupTypeRatio;
if (opts.dedupMaxPerPage !== undefined) base.dedup_max_per_page = opts.dedupMaxPerPage;
if (opts.limit !== undefined) base.limit = opts.limit;
// Defaults for side A
if (!base.name) base.name = 'Config A';
if (!base.strategy) base.strategy = 'hybrid';
} else {
if (!base.name) base.name = 'Config B';
if (!base.strategy) base.strategy = 'hybrid';
}
return base;
}
function loadConfigFile(pathOrJson: string): EvalConfig {
const trimmed = pathOrJson.trimStart();
if (trimmed.startsWith('{')) {
return JSON.parse(pathOrJson) as EvalConfig;
}
if (!existsSync(pathOrJson)) {
console.error(`Config file not found: ${pathOrJson}`);
process.exit(1);
}
return JSON.parse(readFileSync(pathOrJson, 'utf-8')) as EvalConfig;
}
// ─────────────────────────────────────────────────────────────────
// Output formatting
// ─────────────────────────────────────────────────────────────────
function printSingleTable(report: EvalReport): void {
const { config, k, queries } = report;
const label = config.name ?? config.strategy ?? 'hybrid';
console.log(`\ngbrain eval — ${queries.length} quer${queries.length === 1 ? 'y' : 'ies'} · strategy: ${label} · k=${k}\n`);
const COL_QUERY = 36;
const COL_NUM = 7;
const header = padR('Query', COL_QUERY) + padL(`P@${k}`, COL_NUM) + padL(`R@${k}`, COL_NUM) + padL('MRR', COL_NUM) + padL(`nDCG@${k}`, COL_NUM);
const divider = '─'.repeat(header.length);
console.log(header);
console.log(divider);
for (const q of queries) {
console.log(
padR(truncate(q.query, COL_QUERY - 1), COL_QUERY) +
padL(fmt(q.precision_at_k), COL_NUM) +
padL(fmt(q.recall_at_k), COL_NUM) +
padL(fmt(q.mrr), COL_NUM) +
padL(fmt(q.ndcg_at_k), COL_NUM),
);
}
console.log(divider);
console.log(
padR('Mean', COL_QUERY) +
padL(fmt(report.mean_precision), COL_NUM) +
padL(fmt(report.mean_recall), COL_NUM) +
padL(fmt(report.mean_mrr), COL_NUM) +
padL(fmt(report.mean_ndcg), COL_NUM),
);
console.log('');
}
function printABTable(reportA: EvalReport, reportB: EvalReport, k: number): void {
const labelA = reportA.config.name ?? 'Config A';
const labelB = reportB.config.name ?? 'Config B';
const n = reportA.queries.length;
console.log(`\ngbrain eval — ${n} quer${n === 1 ? 'y' : 'ies'} · A/B comparison · k=${k}\n`);
const COL_QUERY = 34;
const COL_METRIC = 8;
const COLS_PER_SIDE = 3; // P@k, MRR, nDCG@k
// Header line 1: section labels
const aLabel = ` ${labelA} `.slice(0, COL_METRIC * COLS_PER_SIDE - 2);
const bLabel = ` ${labelB} `.slice(0, COL_METRIC * COLS_PER_SIDE - 2);
const line1 =
' '.repeat(COL_QUERY) +
padR(`── ${aLabel} `, COL_METRIC * COLS_PER_SIDE) +
padR(`── ${bLabel} `, COL_METRIC * COLS_PER_SIDE) +
` Δ nDCG`;
console.log(line1);
// Header line 2: metric names
const metricHeader = (suffix: string) =>
padL(`P@${k}`, COL_METRIC) + padL('MRR', COL_METRIC) + padL(`nDCG@${k}`, COL_METRIC);
const line2 =
padR('Query', COL_QUERY) +
metricHeader('A') +
' ' + metricHeader('B') +
' ' + padL('Δ nDCG', 10);
console.log(line2);
console.log('─'.repeat(line2.length));
for (let i = 0; i < reportA.queries.length; i++) {
const qa = reportA.queries[i];
const qb = reportB.queries[i];
const delta = qb.ndcg_at_k - qa.ndcg_at_k;
const deltaStr = delta > 0 ? `+${fmt(delta)}` : fmt(delta);
console.log(
padR(truncate(qa.query, COL_QUERY - 1), COL_QUERY) +
padL(fmt(qa.precision_at_k), COL_METRIC) +
padL(fmt(qa.mrr), COL_METRIC) +
padL(fmt(qa.ndcg_at_k), COL_METRIC) +
' ' +
padL(fmt(qb.precision_at_k), COL_METRIC) +
padL(fmt(qb.mrr), COL_METRIC) +
padL(fmt(qb.ndcg_at_k), COL_METRIC) +
' ' + padL(deltaStr, 10),
);
}
const divider = '─'.repeat(line2.length);
console.log(divider);
const meanDelta = reportB.mean_ndcg - reportA.mean_ndcg;
const meanDeltaStr = (meanDelta > 0 ? '+' : '') + fmt(meanDelta);
const winner = meanDelta > 0 ? ' ✓ B wins' : meanDelta < 0 ? ' ✓ A wins' : ' tie';
console.log(
padR('Mean', COL_QUERY) +
padL(fmt(reportA.mean_precision), COL_METRIC) +
padL(fmt(reportA.mean_mrr), COL_METRIC) +
padL(fmt(reportA.mean_ndcg), COL_METRIC) +
' ' +
padL(fmt(reportB.mean_precision), COL_METRIC) +
padL(fmt(reportB.mean_mrr), COL_METRIC) +
padL(fmt(reportB.mean_ndcg), COL_METRIC) +
' ' + padL(meanDeltaStr + winner, 10),
);
console.log('');
}
// ─────────────────────────────────────────────────────────────────
// Formatting helpers
// ─────────────────────────────────────────────────────────────────
function fmt(n: number): string {
return n.toFixed(2);
}
function padR(s: string, width: number): string {
return s.length >= width ? s.slice(0, width) : s + ' '.repeat(width - s.length);
}
function padL(s: string, width: number): string {
return s.length >= width ? s.slice(0, width) : ' '.repeat(width - s.length) + s;
}
function truncate(s: string, max: number): string {
return s.length > max ? s.slice(0, max - 1) + '…' : s;
}
function printHelp(): void {
console.log(`
gbrain eval — measure and compare retrieval quality
USAGE
gbrain eval --qrels <path>
gbrain eval --qrels <path> --config-a <path> --config-b <path>
OPTIONS
--qrels <path|json> Path to qrels JSON file (required)
Or inline JSON: '[{"query":"...","relevant":["slug"]}]'
--config-a <path|json> Config for strategy A (default: hybrid with defaults)
--config-b <path|json> Config for strategy B (triggers A/B mode)
--strategy <s> Search strategy: hybrid | keyword | vector
--rrf-k <n> Override RRF K constant (default: 60)
--expand / --no-expand Enable/disable multi-query expansion
--dedup-cosine <f> Override cosine dedup threshold (default: 0.85)
--dedup-type-ratio <f> Override type ratio cap (default: 0.6)
--dedup-max-per-page <n> Override max chunks per page (default: 2)
--limit <n> Max results to fetch per query (default: 10)
--k <n> Metric cutoff depth (default: 5)
QRELS FORMAT
{
"version": 1,
"queries": [
{
"query": "who founded NovaMind",
"relevant": ["people/sarah-chen", "companies/novamind"],
"grades": { "people/sarah-chen": 3, "companies/novamind": 2 }
}
]
}
"grades" is optional — enables graded nDCG. Without it, binary relevance is used.
CONFIG FORMAT
{ "name": "rrf-k-30", "strategy": "hybrid", "rrf_k": 30, "expand": false }
EXAMPLES
gbrain eval --qrels ./my-queries.json
gbrain eval --qrels ./qrels.json --strategy keyword
gbrain eval --qrels ./qrels.json --rrf-k 30
gbrain eval --qrels ./qrels.json --config-a baseline.json --config-b experiment.json
`.trim());
}