Status: settled for v1, and now implemented. This document records the design decisions made 2026-07-24; changes from here should go through discussion + a doc update first.
Where the shipped tool refined a decision, this document has been updated to match it — see As built at the end for the deltas worth knowing.
evaling is an open-source command-line tool for evaluating LLM prompts. Its core
job: run one or more prompt variants against one or more models over a set
of test cases, score the outputs against a scorecard, and present a clear
comparison so the user can decide which prompt/model combination to ship.
- Make A/B-comparing prompt variants and models a one-command operation.
- Terminal-first UX: readable tables/matrices, sensible defaults, no required web UI.
- Reproducible runs: results are stored and can be re-rendered, diffed, and shared.
- Extensible scoring: built-in checks, LLM judges, and user-defined Python scorers.
- Usable by humans (CLI, HTML reports) and by agents/programs (Python API, MCP server).
- Robust automated test suite from day one; no provider network calls in tests.
- Not a hosted service, dashboard, or team collaboration platform.
- Not a prompt-management/versioning system (users keep prompts in their own repo).
- Not a tracing/observability tool for production traffic.
- No fine-tuning or training features.
Three layers, strictly ordered:
- Core library (
evalingPython package) — config loading, templating, the run engine, providers, scorers, storage, exports. Public, documented API: everything the CLI can do is doable programmatically. - CLI — a thin wrapper over the core library (argument parsing + rendering only).
- MCP server (
evaling mcp) — a second thin wrapper over the same core, exposing eval operations as MCP tools. Ships as an optional extra (evaling[mcp]) so the base install stays small.
No feature may be implemented in the CLI or MCP layer if it belongs in the core.
| Concept | Description |
|---|---|
| Eval config | A YAML file describing an eval: prompts, models, test cases, scorecard. |
| Prompt variant | A named prompt template: an ordered list of messages (multi-turn supported), each with typed content parts. Text parts are Jinja2 templates. |
| Model | A provider + model id + parameters (temperature, max tokens, etc.). |
| Test case | Input variables and file attachments for the template, plus optional expected output, reference data, and human_label for scoring/calibration. |
| Scorer | A function that grades a model output for a test case, producing a score and pass/fail. |
| Scorecard | A named set of weighted criteria, each backed by a scorer; aggregates to per-cell and per-run scores. |
| Run | One execution of the full matrix: variants × models × test cases. Persisted on disk with outputs, scores, timing, token usage, and cost. |
- Eval configs are YAML (
eval.yamlby default). - Prompts can be defined inline or referenced as external files
(e.g.
prompts/summarize-v2.yaml). - Text content is templated with Jinja2 (variables, conditionals, loops), rendered with strict-undefined so typos in variable names fail loudly.
- Test cases can be listed inline or loaded from CSV/JSONL files.
- API keys come from environment variables (e.g.
ANTHROPIC_API_KEY), never from config files.
Eval definition vs workspace settings. The eval config (models, variants, cases, scorecard, judges, thresholds) is portable and version-controlled: anyone who clones the repo gets the same eval. Workspace settings (output directory, cache directory, concurrency, cache on/off) are machine/user concerns and resolve in layers, most specific wins:
- CLI flags (e.g.
--output-dir) - Environment variables (
EVALING_OUTPUT_DIR,EVALING_CACHE_DIR,EVALING_CONCURRENCY, …) settings:block in the eval config (shareable project defaults)- User config at
~/.config/evaling/config.yaml - Built-in defaults (runs in
.evaling/runs/, cache in.evaling/cache/)
-
Prompts are multi-turn: an ordered list of
{role, content}messages (system/user/assistant). Single-turn is just the one-message case. -
Message content is a list of typed content parts:
text,image,file(PDF/documents),audio. Text parts go through Jinja2; binary parts are declared references, never inlined in templates:cases: - vars: {question: "What breed is this dog?"} files: {photo: ./fixtures/dog1.jpg} prompt: - role: user content: - text: "{{ question }}" - image: "{{ files.photo }}"
-
v1 media types: images, PDFs, audio, and video. Provider adapters translate parts to each API's native format and must fail with a clear error when a provider or model doesn't support a part type (e.g. audio on a text-only model) — at config validation time where possible, not mid-run. Video is fully typed/stored but supported only by the
mockandcommandproviders until an API provider accepts it. -
In CSV/JSONL datasets, a
file://value marks a column as a file reference. -
Binary files are hashed by content: cache keys include the content hash, and run storage keeps binaries content-addressed so repeated runs don't duplicate them.
- Models are called via direct provider APIs through a small, pluggable
Providerinterface. v1 providers:- Anthropic (first-class)
- OpenAI (first-class)
- OpenAI-compatible (base URL + key: covers Ollama, vLLM, OpenRouter, and most hosted/local backends)
command— shell out to any CLI or script (e.g.claude -p, an agent harness): request on stdin, response on stdout. Lets evaling evaluate anything, not just chat APIs.- Mock — deterministic fake provider; ships in the package (used by the test suite and available to users for dry runs).
- MCP is not a model-calling transport in v1 (it is not an inference protocol). The pluggable provider interface must, however, allow future transports (MCP sampling, arbitrary HTTP) without engine changes.
- Per-model parameters: temperature, max tokens, system prompt override, etc.
- Rate limiting and retry with backoff. Retries are configurable per model
(
max_retries,timeout_s); rate limiting is per model as well (max_concurrency,requests_per_minute), because one global limit is the wrong control when a matrix mixes a local model with a rate-limited hosted one. - Concurrency: requests run in parallel with a configurable limit.
Built-in scorers (v1):
exact— output equals expected value.contains/not-contains— substring checks (case-sensitivity configurable).regex— output matches a pattern.json-valid/json-schema— output parses as JSON / matches a schema.llm-judge— an autorater grades the output (see below).python— user-supplied Python function for custom scoring.- Agreement scorers for judge calibration: exact agreement and within-N
against
human_label. Correlation and Cohen's kappa are not built in —evaling calibrateproduces the run and the export to compute them from; see evaluating-judges.md.
Scorecard: users define quality as named, weighted criteria, each backed by a scorer:
scorecard:
- criterion: accuracy
weight: 3
scorer: {type: llm-judge, judge: quality-judge}
- criterion: format
weight: 1
scorer: {type: json-schema, schema: ./schemas/answer.json}Per case: per-criterion scores and pass/fail. Per run: weighted aggregate per variant × model cell, which feeds thresholds (4.7).
Autorater (llm-judge): a judge is a first-class prompt — a template + judge
model + rubric, answering with a score and a rationale as JSON. The verdict is
parsed leniently — fenced blocks and surrounding prose are tolerated — and a
reply that yields no score fails that criterion rather than the run. No
provider structured-output API is used. Judge definitions live in config and
are reusable across criteria.
Evaluating the autorater (meta-evals): because a judge is just a
prompt + model, evaling evaluates judges with its own machinery. Test cases carry
an optional human_label; users run judge-prompt variants over a calibration set
of (output, human label) pairs and use the agreement scorers to find which rubric
best matches human judgment. Requirements: the human_label field, the agreement
scorers, and a documented recipe/example in the repo.
Global flags (all commands): -c/--config PATH, -o/--output-dir PATH,
--cache-dir PATH, --no-color, -q/--quiet, -v/--verbose, --json
(machine-readable stdout for scripting).
Commands:
evaling init— scaffold an exampleeval.yamland directory layout.evaling run [CONFIG]— run the eval matrix; stream progress; print summary.- Matrix filtering:
--model NAME,--variant NAME,--case ID— each repeatable, to run any sub-matrix. --dry-run— validate config, render all prompts, print request count and cost estimate; makes no model calls. (Doubles as a CI lint for eval configs.)--max-cost USD.--no-cache(bypass cache),--resume RUN_ID(continue an interrupted run).--baseline RUN_ID(override the regression-gate baseline).--label NAME— human-friendly run name.--html PATH— write the HTML report at the end of the run.--concurrency N.
- Matrix filtering:
evaling show <run> [--failures] [--case ID]— re-render a stored run.evaling compare <run-a> <run-b> [--html PATH]— diff two runs.evaling list [--limit N]— list stored runs.evaling export <run> --format json|csv|md|html [--out PATH]— render a stored run (4.8).evaling baseline set <run>— pin the blessed baseline run.evaling mcp— start the MCP server (4.6).
Run references: anywhere a command takes a run, accept: full run id
(timestamp-sortable, e.g. 2026-07-24T1530-a1b2), a --label name, latest,
or baseline (the pinned baseline).
UX requirements:
- Summary view: matrix of variants × models with aggregate scorecard scores, cost, latency.
- Detail view: drill into a single case's output(s) side by side.
- Exit code reflects pass/fail thresholds (4.7) so
evaling runworks as a CI gate. - Show estimated request count before a run;
--max-costguard for large matrices. - Respect
NO_COLOR; degrade gracefully in non-TTY environments.
evaling mcpstarts an MCP server (stdio) exposing core operations as tools.- Primary use case: agent-driven prompt iteration — an MCP client (e.g. Claude Code) tweaks a prompt, runs the eval, reads scores, and iterates.
- CI is explicitly not the target for MCP mode; CI uses the CLI (exit codes + JSON/HTML exports).
- Design principle: the consumer is an LLM — responses must be token-frugal. Summaries by default; drill-down on demand; pagination on anything unbounded.
- Tools (v1):
run_eval(config_path, models?, variants?, cases?, label?, no_cache?, max_cost_usd?)— blocking: runs to completion, emitting MCP progress notifications, and returns the summary (run id, aggregate matrix, failure count, cost).get_run(run_id, detail=summary|failures|full, page?).get_case_result(run_id, variant, model, case_id)— full detail for one cell.compare_runs(run_a, run_b)— per-cell deltas, regressions highlighted.list_runs(limit?),set_baseline(run_id).render_prompt(config_path, variant, case_id)— fully-rendered messages, no model calls (same core function as--dry-run).
- The MCP layer contains no logic beyond tool schemas and calls into the core library.
Both modes, configurable per eval:
- Absolute: fail if aggregate score / pass rate drops below a configured threshold.
- Regression vs baseline: fail if results are worse than a designated
baseline run (pinned by run id or a
baselinealias users can point at a blessed run).
evaling run exits non-zero on threshold failure and says why.
Every run is persisted locally (default .evaling/runs/<run-id>/) as plain files:
run.json # metadata: id, timestamps, config hash, aggregate scores, totals (cost, tokens)
config.snapshot.yaml # exact config used, for reproducibility
results.jsonl # one record per variant×model×case: messages sent, output,
# per-scorer results, usage, cost, latency, error
artifacts/ # content-addressed binary inputs/outputs
results.jsonlis append-as-completed: an interrupted run keeps all finished records and can be resumed.- Stored files are the source of truth; all exports are views over them:
json/csv— machine-readable, for CI and downstream analysis.md— paste-into-a-PR summary.html— a single self-contained file (inline CSS/JS, embedded data and images; opens from disk, no server): summary matrix, per-case drill-down with side-by-side outputs, failures-first sorting, judge rationales visible. Also available forcompare.
- Response caching, on by default: identical (model, params, rendered
messages, file content hashes) requests are served from a local cache, making
scorer iteration free.
--no-cachebypasses; cache location under.evaling/.
- Language/tooling: Python ≥3.10;
uvfor project management;rufffor lint/format;pytestfor tests. Installable viauv tool install evaling/pipx install evaling; published to PyPI once public. - Testing: unit tests for config parsing, templating, content parts, providers, scorers, scorecard aggregation, storage, exports, CLI; integration tests run the full pipeline (including MCP server mode) against the mock provider — the suite never makes network calls. CI on GitHub Actions across supported Python versions. Coverage tracked; target ≥90% on core modules.
- Reliability: a single failing request must not abort a run — record the error, continue, and report it in the summary.
- Documentation: documentation is part of every change, not a follow-up task.
README.mdstays current at all times: what the tool does, install, a minimal working example, and links intodocs/. Any change that alters user-facing behavior updates the README in the same commit.- Detailed docs live in
docs/as markdown, one file per topic:docs/README.md— documentation index.docs/tutorial.md— the full walkthrough: install through CI gating.docs/getting-started.md— install, first eval, reading results.docs/configuration.md— fulleval.yamlreference, settings layering, environment variables.docs/prompts.md— variants, Jinja2 templating, multi-turn messages, multimodal content parts.docs/providers.md— built-in providers, thecommandprovider, adding a provider.docs/scoring.md— scorers, scorecards, LLM judges, thresholds.docs/evaluating-judges.md— the meta-eval recipe (human_label+ agreement scorers).docs/cli.md— command and flag reference.docs/mcp.md— MCP server setup and tool reference.docs/ci.md— CI recipes: gating, baselines, HTML report artifacts.docs/storage.md— run directory format, caching, exports.docs/secrets.md— where API keys come from and how they're protected.docs/troubleshooting.md— symptoms, causes, fixes.docs/python-api.md— using evaling as a library.docs/architecture.md— internal structure and design rationale.
- Each file is created alongside the milestone that implements its topic and updated in the same commit as any behavior change to that topic. Stale docs are treated as bugs.
- Distribution: semantic versioning; changelog; minimal dependency footprint.
- License: MIT (adopted before the repo goes public).
| Decision | Choice |
|---|---|
| Config format / templating | YAML + Jinja2 (strict undefined) |
| Conversations | Multi-turn in v1 |
| Binary inputs | Images + PDFs + audio in v1, via typed content parts |
| Providers (v1) | Anthropic, OpenAI, OpenAI-compatible, command, mock |
| Model calling via MCP | No (not an inference protocol); provider interface stays pluggable |
| Driving evaling via MCP | Yes — evaling mcp (optional extra) in v1, aimed at agent iteration, not CI |
| Cache default | On (opt-out via --no-cache) |
| CI gating | Both absolute thresholds and regression-vs-baseline |
| HTML report | Yes — single self-contained file, for export and compare |
| Autorater evaluation | Meta-evals via human_label + agreement scorers |
| Settings resolution | flags > env (EVALING_*) > config settings: > user config > defaults |
| Output directory | Configurable at every layer; default .evaling/runs/ |
| MCP entry point | evaling mcp subcommand (stdio) |
MCP run_eval |
Blocking, with progress notifications; async deferred |
| License | MIT |
settings: # project defaults; all overridable by env/flags
output_dir: .evaling/runs
concurrency: 8
cache: true
models:
- id: claude-sonnet-5
provider: anthropic
params: {max_tokens: 1024}
- id: local-llama
provider: openai-compatible
base_url: http://localhost:11434/v1
variants:
- name: concise
prompt: prompts/concise.yaml
- name: detailed
prompt: prompts/detailed.yaml
cases:
file: cases.jsonl
scorecard:
- criterion: accuracy
weight: 3
scorer: {type: llm-judge, judge: quality-judge}
- criterion: format
weight: 1
scorer: {type: json-schema, schema: schemas/answer.json}
judges:
quality-judge:
model: claude-sonnet-5
rubric: prompts/judge-rubric.yaml
thresholds:
min_pass_rate: 0.9
baseline: regression # fail if worse than pinned baselineEverything above shipped. The refinements worth calling out, because they differ from what a reader of the original draft would assume:
- Secrets have a file, not just environment variables. Keys may come from
a gitignored
.evaling.secrets.yaml(or~/.config/evaling/secrets.yaml, or$EVALING_SECRETS) as well as the environment, which always wins. Secrets are never read fromeval.yaml, never written intoos.environ, and are redacted from output. Seedocs/secrets.md. - MCP is an optional extra.
pip install 'evaling[mcp]'. The base install keeps its small dependency footprint. - Limits are per model, not per provider. See §4.3 above.
- Two commands were added for findability:
evaling validate(the same work asrun --dry-run, under a name people look for) andevaling cache(info/clear). - Video remains typed, stored, and hashed like any other attachment, and
is accepted by the
mockandcommandproviders. No first-class API provider accepts it yet; the capability check rejects it before a request is sent rather than failing mid-run. - Platform support is verified, not assumed. CI runs Linux (3.10–3.13), macOS, and Windows on every change. All file I/O is explicitly UTF-8 with fixed newlines, so runs are portable between platforms.
- The docs are tested. YAML examples are validated against the real schema,
docs/cli.mdis checked against actual--helpoutput, links are resolved, and the worked examples inexamples/are executed end to end on every commit. "Stale docs are treated as bugs" is enforced by CI rather than by good intentions.
Evaluating production data that humans may not read. Two parts: a datasource interface users implement, and a mode that keeps the data out of every artifact. Shipped; the decisions below were taken 2026-07-24, before implementation, and each notes where the result differed.
Decided 2026-07-30. Renaming it to something broader — "privacy mode", or naming it after the large-dataset half — was considered and rejected.
The feature does one specific thing: it keeps case data out of every artifact a run produces, for data nobody is permitted to read. "Privacy mode" would promise more than that, and the mode makes no formal guarantee about what an aggregate reveals. The large-dataset half is a separate feature that no-look happens to build on, and it has its own page.
docs/no-look.md maps the name onto the established vocabulary — eyes-off
evaluation, data minimization — so a reader arriving with either term finds
it, and so does anyone fitting it into a governance process.
Case data must not survive on the machine in human-readable form. Holding it in memory for the duration of a run is acceptable; leaving anything readable behind afterwards is not.
As implemented, no case data is written to disk at all. Not written and
deleted, not written encrypted — never written. In a no-look run, results.jsonl
holds scores and metadata, run.json holds aggregates, config.snapshot.yaml
has inline cases stripped, artifacts/ stays empty because attachments are
never archived, and the response cache is disabled for the run. No case data
reaches a temporary file either: evaling writes run metadata and archived
artifacts temp-then-rename, for atomicity, and neither carries case data in a
no-look run.
This is a stronger guarantee than the original requirement asked for, and it is what made the encryption design below unnecessary.
The original plan was to encrypt temporary artifacts with a key generated in memory at run start and never persisted, so that anything left behind by a crash, an OOM kill, or a power loss would be permanently unreadable — cleanup on exit being exactly what a killed process cannot do.
No component turned out to need to spool case data, so nothing was implemented
and no cryptography dependency was taken. The design is recorded here because
it remains the answer if a future feature does need to spool — the most likely
candidate being very large media attachments streamed from a source, where a
page of them may not fit in memory. Such a feature should take cryptography
as an optional extra rather than a core dependency.
Three honest limits, none of which evaling can close:
- The operating system may page memory to disk. Case data is held in memory during a run, and swap is outside any application's control. A machine handling data this sensitive should have encrypted swap, or none.
- The
commandprovider hands case data to a subprocess. That is what the provider is for, and what the script does with the data — including writing it somewhere — is the author's responsibility, not evaling's. - Attachment source files are already on disk. evaling reads them and does not copy them; they belong to the caller and their handling is the caller's concern.
Deliberately refused rather than best-effort, for two reasons.
It cannot be made correct against a live source. A source that is still
receiving writes will shift rows under cursor pagination, mutate rows already
evaluated, drop rows, or move the underlying population ("the last 24 hours"
is a different set at 09:00 and 14:00). Any of these produces a run whose
halves describe different data, and the failure is silent: no error, plausible
numbers, wrong conclusion. A stable: true flag is a promise the tool cannot
verify, and trusting it converts a user's mistaken belief into a corrupted
result.
It conflicts with the ephemeral-key decision above. Resuming requires reading what the previous process wrote, which requires the key that process deliberately destroyed. Supporting both would mean persisting the key, which defeats the guarantee that leftovers are unreadable.
Long private runs therefore rely on --max-cost and on narrowing the matrix
rather than on recovery. If resume becomes necessary later, the honest designs
are (a) verify the source by re-walking its prefix and comparing a rolling
digest of case content — cheap, since it costs API pages but no model calls —
or (b) snapshot the case set locally on first fetch so evaling guarantees
stability instead of trusting a claim. Either would need a user-supplied key
to coexist with encryption at rest.
File-backed and inline case runs are unaffected: their config fingerprint is verifiable, so resume remains supported there.
A judge sends case data to a second model provider. That is a compliance decision only the user can make, so evaling permits it and documents the consequence plainly rather than blocking it. Judge rationales quote the data being graded and are therefore suppressed from stored artifacts in no-look mode.
Small groups can be re-identifying, but the threshold is the user's judgment, and a run over a single case is a legitimate thing to do. evaling stays unopinionated and instead documents sample-size guidance, since the more common error is drawing conclusions from too few cases: at n=30 a pass rate carries roughly ±18 points at 95% confidence, n=100 gives ±10, n=400 gives ±5, and n=1000 gives ±3.