-
Notifications
You must be signed in to change notification settings - Fork 0
feat(evaluation-system): add eval framework with graders, runner, report #36
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| node_modules/ | ||
| .eval-cache/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| # Guidelines | ||
|
|
||
| - **Run the prompt-eval suite alongside `bun test` when changing harness prompts.** When you edit an agent prompt — anything under `src/workflows/strapped-run/**` or the step prose in a skill `SKILL.md` — run `bun run eval --suite <dir>` (see `src/eval/` and the "Prompt evaluation suite" section of `plugins/strapped/conventions.md`) and keep it green alongside `bun test`. The eval suite grades a prompt on correctness / cost / latency through `claude -p`; it is the evidence that a prompt change is a real improvement (use `--ab` to compare a baseline vs a candidate and gate on a correctness regression past `--tolerance`). It is a HEAVY, opt-in layer (needs a real `claude` + spends cost), so it is intentionally NOT part of `bun test` — run it yourself for prompt work. `bun test` stays hermetic/offline and remains the gate for everything else. | ||
|
|
||
| - **Bump the plugin version with the tool, never by hand.** `claude plugin update` compares `version` in `plugins/strapped/.claude-plugin/plugin.json` only — an unbumped version leaves installed copies silently pinned to a stale commit after a change merges. Bump ONLY via `bun tools/version.ts bump <major|minor|patch>` (it rewrites the one semver deterministically); do not hand-edit plugin.json. Pick the level by a conventional-commit rule: a breaking change → `major`, a new user-facing feature (skill/workflow/script behavior) → `minor`, a fix or internal change → `patch`. Before committing, run `bun tools/version.ts check` (alias `bun run version:check`) — it fails when a changed *generated artifact* (the committed deployables under `plugins/strapped/`) is not accompanied by a version bump above the base. Prompt/skill/docs-only changes with NO generated-artifact change are at your discretion — the guard exits 0 for them, so bump when the change is user-facing and skip an internal-only tweak. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,70 @@ | ||
| // Content-addressed response cache for eval results. The key is a SHA-256 over a | ||
| // CANONICAL (sorted-key) JSON of every request field that changes the model's | ||
| // output — prompt, both system-prompt knobs, model, schema, tool policy, and | ||
| // settings. Include every such field or an A/B would collide; a changed prompt | ||
| // naturally misses. Results are stored one JSON file per key; no eviction. | ||
|
|
||
| import { createHash } from 'node:crypto' | ||
| import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' | ||
| import { join } from 'node:path' | ||
| import type { Cache, EvalRequest, EvalResult } from './types.ts' | ||
|
|
||
| /** Recursively sort object keys so key order never perturbs the hash. */ | ||
| function sortKeys(value: unknown): unknown { | ||
| if (Array.isArray(value)) return value.map(sortKeys) | ||
| if (value !== null && typeof value === 'object') { | ||
| const sorted: Record<string, unknown> = {} | ||
| for (const key of Object.keys(value as Record<string, unknown>).sort()) { | ||
| sorted[key] = sortKeys((value as Record<string, unknown>)[key]) | ||
| } | ||
| return sorted | ||
| } | ||
| return value | ||
| } | ||
|
|
||
| /** Stable, key-sorted JSON of the output-affecting fields of a request. */ | ||
| function canonicalRequest(req: EvalRequest): string { | ||
| return JSON.stringify( | ||
| sortKeys({ | ||
| prompt: req.prompt, | ||
| systemPrompt: req.systemPrompt ?? null, | ||
| appendSystemPrompt: req.appendSystemPrompt ?? null, | ||
| model: req.model, | ||
| schema: req.schema, | ||
| tools: req.tools ?? null, | ||
| settings: req.settings ?? null, | ||
| }) | ||
| ) | ||
| } | ||
|
|
||
| /** Hex SHA-256 cache key over a request's output-affecting fields. */ | ||
| export function cacheKey(req: EvalRequest): string { | ||
| return createHash('sha256').update(canonicalRequest(req)).digest('hex') | ||
| } | ||
|
|
||
| /** File-backed response cache: one `<dir>/<key>.json` per stored result. */ | ||
| export class ResponseCache implements Cache { | ||
| constructor(private readonly dir: string) {} | ||
|
|
||
| private path(key: string): string { | ||
| return join(this.dir, `${key}.json`) | ||
| } | ||
|
|
||
| /** Return the stored result (marked `cached:true`) or `null` on miss/corruption. */ | ||
| get(key: string): EvalResult | null { | ||
| const p = this.path(key) | ||
| if (!existsSync(p)) return null | ||
| try { | ||
| const result = JSON.parse(readFileSync(p, 'utf8')) as EvalResult | ||
| return { ...result, cached: true } | ||
| } catch { | ||
| return null | ||
| } | ||
| } | ||
|
|
||
| /** Persist a result under `key`, creating the cache dir on first write. */ | ||
| set(key: string, result: EvalResult): void { | ||
| mkdirSync(this.dir, { recursive: true }) | ||
| writeFileSync(this.path(key), JSON.stringify(result, null, 2)) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| // The eval CASE abstraction: a single, self-contained prompt-evaluation unit. A | ||
| // case carries the prompt, the schema it is forced against, isolation knobs | ||
| // (system prompt / tools / settings), the models it targets, its correctness | ||
| // graders, and — for A/B — an optional baseline+candidate prompt pair. A SUITE is | ||
| // just a directory of modules that export cases. | ||
| // | ||
| // Design decision (research §"prompts are eval inputs"): a case owns a VERBATIM | ||
| // baseline prompt snapshot copied from the live stage plus a candidate variant to | ||
| // A/B. Cases never import the live stage builders — prompts are inputs, not code. | ||
|
|
||
| import type { Grader } from './grade.ts' | ||
| import type { EvalRequest, JsonSchema } from './types.ts' | ||
|
|
||
| /** One labelled prompt variant for A/B comparison. */ | ||
| export interface Variant { | ||
| /** Short label shown in the report (e.g. `baseline`, `terser`). */ | ||
| label: string | ||
| /** The full prompt text for this variant. */ | ||
| prompt: string | ||
| } | ||
|
|
||
| /** A baseline↔candidate variant pair a case can be A/B'd on. */ | ||
| export interface CaseVariants { | ||
| baseline: Variant | ||
| candidate: Variant | ||
| } | ||
|
|
||
| /** A single prompt-evaluation case. */ | ||
| export interface EvalCase { | ||
| /** Unique id (also a `--filter` target). */ | ||
| id: string | ||
| /** Tags for `--filter` selection (e.g. `planner`, `reviewer`). */ | ||
| tags: string[] | ||
| /** Replace the CLI's default system prompt for isolation (`--system-prompt`). */ | ||
| systemPrompt?: string | ||
| /** Layer strapped context on the real system prompt (`--append-system-prompt`). */ | ||
| appendSystemPrompt?: string | ||
| /** The user prompt under evaluation. */ | ||
| prompt: string | ||
| /** The schema forced via `--json-schema` and validated against the output. */ | ||
| schema: JsonSchema | ||
| /** Models this case targets in a plain/matrix run; the runner falls back to a default. */ | ||
| models?: string[] | ||
| /** Opt into a bounded toolset; omit for a single-shot, tool-free run. */ | ||
| tools?: string[] | ||
| /** Inline settings JSON (`--settings`); defaults to `'{}'` in the engine. */ | ||
| settings?: string | ||
| /** The correctness graders aggregated into this case's score. */ | ||
| graders: Grader[] | ||
| /** Optional A/B prompt pair — required for a case to appear in `--ab` mode. */ | ||
| variants?: CaseVariants | ||
| } | ||
|
|
||
| /** | ||
| * Identity helper for authoring a case module — validates nothing beyond types, | ||
| * but gives suites a single import and a stable authoring surface. | ||
| */ | ||
| export function defineCase(spec: EvalCase): EvalCase { | ||
| return spec | ||
| } | ||
|
|
||
| /** | ||
| * Lower a case (at one model, optionally with a variant's prompt substituted) | ||
| * into the single-shot `EvalRequest` the D1 engine consumes. The prompt override | ||
| * is how A/B swaps baseline vs candidate while holding everything else fixed. | ||
| */ | ||
| export function toRequest(c: EvalCase, model: string, promptOverride?: string): EvalRequest { | ||
| return { | ||
| prompt: promptOverride ?? c.prompt, | ||
| systemPrompt: c.systemPrompt, | ||
| appendSystemPrompt: c.appendSystemPrompt, | ||
| model, | ||
| schema: c.schema, | ||
| tools: c.tools, | ||
| settings: c.settings, | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| // `bun src/eval/cli.ts` — the eval suite entrypoint (npm: `bun run eval`). Loads | ||
| // case modules from a suite directory, runs them in one of three modes, and | ||
| // prints a report. This is the HEAVY, opt-in test layer for prompt changes — it | ||
| // is deliberately NOT part of `bun test` (that stays hermetic/offline). | ||
| // | ||
| // Exit code: a plain/matrix run is REPORT-ONLY (always 0). Only `--ab` gates — | ||
| // exit 1 iff some case's Δcorrectness dips past `--tolerance` (a regression). An | ||
| // absent `claude` CLI is a graceful skip: print a notice, exit 0, never fail. | ||
| // | ||
| // Flags: --suite <dir> --filter <tag|id> --ab --matrix --models a,b | ||
| // --json --tolerance <pct> | ||
| // | ||
| // `main` returns { code, output } (does not exit) so tests can drive the exact | ||
| // exit-code semantics through the injected spawn; the bottom guard is the only | ||
| // place that touches the process. | ||
|
|
||
| import { readdirSync } from 'node:fs' | ||
| import { join, resolve } from 'node:path' | ||
| import type { EvalCase } from './case.ts' | ||
| import { isAvailable } from './engine.ts' | ||
| import { formatReport, toJSON, type Report } from './report.ts' | ||
| import { | ||
| DEFAULT_MODEL, | ||
| runAB, | ||
| runMatrix, | ||
| runSuite, | ||
| type ABResult, | ||
| type MatrixResult, | ||
| } from './runner.ts' | ||
| import type { Cache, Spawn } from './types.ts' | ||
|
|
||
| export interface EvalFlags { | ||
| suite?: string | ||
| filter?: string | ||
| ab: boolean | ||
| matrix: boolean | ||
| models?: string[] | ||
| json: boolean | ||
| tolerance: number | ||
| } | ||
|
|
||
| /** Hand-rolled argv parser (no dependency). Unknown flags are ignored. */ | ||
| export function parseArgs(argv: readonly string[]): EvalFlags { | ||
| const flags: EvalFlags = { ab: false, matrix: false, json: false, tolerance: 0 } | ||
| for (let i = 0; i < argv.length; i++) { | ||
| switch (argv[i]) { | ||
| case '--suite': | ||
| flags.suite = argv[++i] | ||
| break | ||
| case '--filter': | ||
| flags.filter = argv[++i] | ||
| break | ||
| case '--ab': | ||
| flags.ab = true | ||
| break | ||
| case '--matrix': | ||
| flags.matrix = true | ||
| break | ||
| case '--models': | ||
| flags.models = (argv[++i] ?? '').split(',').filter(Boolean) | ||
| break | ||
| case '--json': | ||
| flags.json = true | ||
| break | ||
| case '--tolerance': | ||
| flags.tolerance = Number(argv[++i] ?? '0') | ||
| break | ||
| default: | ||
| break | ||
| } | ||
| } | ||
| return flags | ||
| } | ||
|
|
||
| /** Load every case exported by the modules in a suite directory (sorted). */ | ||
| export async function loadSuite(dir: string): Promise<EvalCase[]> { | ||
| const abs = resolve(dir) | ||
| const files = readdirSync(abs) | ||
| .filter(f => /\.(ts|mjs|js)$/.test(f) && !f.endsWith('.d.ts')) | ||
| .sort() | ||
| const cases: EvalCase[] = [] | ||
| for (const file of files) { | ||
| const mod = (await import(join(abs, file))) as { cases?: unknown; default?: unknown } | ||
| const exported = mod.cases ?? mod.default | ||
| if (Array.isArray(exported)) cases.push(...(exported as EvalCase[])) | ||
| else if (exported) cases.push(exported as EvalCase) | ||
| } | ||
| return cases | ||
| } | ||
|
|
||
| /** A case matches a filter if the filter equals its id or one of its tags. */ | ||
| function matchesFilter(c: EvalCase, filter: string): boolean { | ||
| return c.id === filter || c.tags.includes(filter) | ||
| } | ||
|
|
||
| /** | ||
| * A/B gate: exit 1 iff any case regressed correctness past the tolerance. A | ||
| * tolerance of `5` means "a 5-percentage-point dip is allowed"; anything worse | ||
| * gates. Cost/latency wins never gate — a human judges the trade from the report. | ||
| */ | ||
| export function abExitCode(results: readonly ABResult[], tolerancePct: number): number { | ||
| const tol = tolerancePct / 100 | ||
| return results.some(r => r.deltaCorrectness < -tol) ? 1 : 0 | ||
| } | ||
|
|
||
| export interface CliDeps { | ||
| spawn?: Spawn | ||
| cache?: Cache | ||
| /** Test override: supply cases directly instead of loading from `--suite`. */ | ||
| cases?: EvalCase[] | ||
| } | ||
|
|
||
| export interface CliResult { | ||
| code: number | ||
| output: string | ||
| } | ||
|
|
||
| /** | ||
| * Parse argv, run the selected mode, and return an exit code + text to print. | ||
| * Never touches the process — the bottom guard does that. An absent CLI short | ||
| * circuits to a skip notice + exit 0 before any case runs. | ||
| */ | ||
| export async function main(argv: readonly string[], deps: CliDeps = {}): Promise<CliResult> { | ||
| const flags = parseArgs(argv) | ||
| const spawn = deps.spawn | ||
|
|
||
| if (!isAvailable(spawn)) { | ||
| return { code: 0, output: 'eval: claude CLI unavailable — skipping suite (exit 0)\n' } | ||
| } | ||
|
|
||
| let cases = deps.cases ?? (flags.suite ? await loadSuite(flags.suite) : []) | ||
| if (flags.filter) cases = cases.filter(c => matchesFilter(c, flags.filter as string)) | ||
|
|
||
| if (cases.length === 0) { | ||
| return { code: 0, output: 'eval: no cases matched (nothing to run)\n' } | ||
| } | ||
|
|
||
| let report: Report | ||
| let code = 0 | ||
|
|
||
| if (flags.ab) { | ||
| const results: ABResult[] = [] | ||
| for (const c of cases) { | ||
| if (!c.variants) continue | ||
| results.push(runAB(c, c.variants.baseline, c.variants.candidate, { model: flags.models?.[0] ?? DEFAULT_MODEL, spawn, cache: deps.cache })) | ||
| } | ||
| report = { mode: 'ab', cases: results } | ||
| code = abExitCode(results, flags.tolerance) | ||
| } else if (flags.matrix) { | ||
| const grids: MatrixResult[] = [] | ||
| for (const c of cases) { | ||
| grids.push(await runMatrix(c, flags.models ?? c.models ?? [], { spawn, cache: deps.cache })) | ||
| } | ||
| report = { mode: 'matrix', cases: grids } | ||
| } else { | ||
| const results = await runSuite(cases, { models: flags.models, spawn, cache: deps.cache }) | ||
| report = { mode: 'suite', cases: results } | ||
| } | ||
|
|
||
| const output = flags.json ? `${JSON.stringify(toJSON(report), null, 2)}\n` : `${formatReport(report)}\n` | ||
| return { code, output } | ||
| } | ||
|
|
||
| if (import.meta.main) { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| main(process.argv.slice(2)) | ||
| .then(({ code, output }) => { | ||
| process.stdout.write(output) | ||
| process.exit(code) | ||
| }) | ||
| .catch((e: unknown) => { | ||
| process.stderr.write(`eval: ${e instanceof Error ? e.message : String(e)}\n`) | ||
| process.exit(2) | ||
| }) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In
--abmode the model isflags.models?.[0] ?? DEFAULT_MODEL, which overrides a case's ownc.models[0]. A case declaringmodels: ['claude-opus-4-8']is silently A/B'd at haiku when--modelsis omitted. Fall back to the case's declared model beforeDEFAULT_MODEL. (D2 review finding.)