Architecture¶
How evalstand is put together, and why the pieces are divided where they are.
This page is for someone changing the code, or deciding whether the design will hold up under a change they have in mind. If you only want to use the tool, Quickstart and Writing evals are the pages you want.
The shape of a run¶
Five stages, in order. Each one hands off a value and stops caring what happens next.
authoring registration execution scoring reporting
────────── ──────────── ───────── ─────── ─────────
evaluate() ──► Registry ──► run_eval() ──► Scorer ──► console
in *_eval.py (in api.py) (runner.py) protocol markdown
│ html
├── llm.py ──► cache.py TUI
└── tracing.py storage
The two entry points — pytest and evalstand run — join this pipeline at the
same place. Neither has an execution path of its own, which is the point of
ADR 0008.
The vocabulary¶
Four words with four distinct meanings, defined once in models.py and not
treated as synonyms anywhere:
| Term | What it is |
|---|---|
| Case | One input, and what a correct answer looks like. Written by the user. |
| Result | What happened when one Case met the Task once. |
| Run | Every Result for one Eval, plus its totals and provenance. |
| Batch | Every Run from one invocation. |
The distinction that matters most in practice: with repeat=3, one Case
produces three Results. Any design that collapses those into a single
pass/fail has to invent an aggregation rule — any, all, majority — and that is
a judgement the user never made.
Registration is not execution¶
evaluate() is called at module top level, which makes it look like it should
run the eval right there. It does not. It records an Eval into a module-level
registry and returns, performing no I/O, starting no event loop, and making no
model calls.
That deferral buys three things that are otherwise unreachable:
pytest -k q1can select a single case, because Cases exist as collected items before anything runs.- Watch mode can ask which Evals a changed file declares without running them.
import qa_evaldoes not spend money.
The full reasoning is ADR 0004.
The runner owns concurrency¶
runner.py executes an Eval's cases against an asyncio.Semaphore, collects
Results, and computes totals. Two rules govern it.
One bad case never takes down the others. A raising task, a timeout, a broken scorer — each is recorded on its own Result and the run continues. Discarding 29 good measurements because the 30th timed out would throw away time and money already spent.
Ordering is by case, not by completion. Cases finish in whatever order the provider answers. Results are collected back into declaration order, because a report whose rows shuffle between runs cannot be read or diffed.
The reason execution lives here rather than in the pytest plugin is that
pytest runs items strictly one after another. A per-item runtest can never
be concurrent, so every capability in this module would be unreachable through
the path most users actually run.
Two caches, and why they are not one¶
This is the distinction most likely to be collapsed by a well-meaning change, so it is worth stating plainly.
cache.py |
cassettes.py |
|
|---|---|---|
| Purpose | Avoid paying twice for the same call | Pin real provider behaviour for tests |
| Keyed on | Call identity | A named fixture |
| Safe to delete? | Always — degrades to a miss | No — breaks the suite |
| Cost of losing it | Money | Correctness |
The cache is an optimisation. Cassettes are a correctness fixture: they let tests run offline against a payload shape that genuinely came back from a provider, rather than one a test author believed a provider returns. That is not academic — a streamed-cost bug early in the project existed precisely because a hand-written mock agreed with the wrong assumption.
repeat=N with N > 1 bypasses the cache unconditionally. Repeating a call
to measure variance, and then being served the same cached answer three times,
would report zero variance with total confidence.
Storage is load-bearing; the cache is not¶
The same asymmetry again, one layer up. storage.py holds the history the tool
exists to provide, so:
- Migrations are forward-only and atomic, each step in its own transaction.
- A database newer than the code is refused, naming both versions. Opening it best-effort would let a newer column go unread and a comparison silently answer the wrong question.
- One transaction per Run. A crash mid-batch keeps every run that finished.
recording.py is the seam between running and storing, and it exists so the two
can fail independently: a storage failure must never cost a measurement. By
the time a run is written, the money is already spent, so a full disk degrades
to a warning and a lost history entry — not a lost result.
Provenance is the one thing checked before execution. Discovering a run cannot
be recorded after paying for it would be the worst possible ordering, so a dirty
tree without --allow-dirty refuses up front.
The database is project-local and gitignored (ADR 0005): history is only meaningful relative to a codebase, and CI therefore starts every build with none.
Traces are a tree¶
Every model call is captured automatically, and trace(name) gives any
operation a node of its own. A ContextVar holds the currently open node;
entering a span sets it and keeps the token, leaving resets that token in a
finally.
That single rule is what survives both asyncio.gather — where contextvars are
copied per task, so siblings share a parent for free — and an exception
mid-call, where the finally stops a raising span corrupting the parentage of
the one after it. The alternatives (inferring parentage from the async call
stack, or a mutable stack of open nodes) both break under concurrency.
ADR 0006 has the comparison.
This is also a deliberate difference from the implementation that inspired the project, whose traces are a flat list. A tree puts a judge scorer's call beneath the task call it judges rather than beside it.
Missing data is None, never zero¶
llm.py reports None when a token count or a price is unavailable.
A zero cost is a claim that the call was free. A run total built from such
claims understates real spend while looking authoritative, and the person
reading it has no way to tell. The reporting layer carries this through: an
unmeasured value renders as unknown rather than as 0.00.
One provider interface, borrowed¶
Every model call goes through LiteLLM rather than provider SDKs or a hand-rolled adapter per vendor. An eval tool is provider-agnostic by nature, and a user comparing two models across vendors is the normal case, not the exotic one. ADR 0007 records what that costs as well as what it buys.
Reporting is four renderers over one model¶
reporting/ holds console.py, markdown.py and html.py; the TUI in tui/
is the fourth. All four read the same Run, and none of them computes a score,
a total, or a pass count of its own — those come from the runner. A renderer
that recomputed a total could disagree with the one the gate checked.
html.py emits a self-contained page with no external references and no
JavaScript, because its job is to be openable from a CI artifact page where
nothing else will load.
The public surface is seven names¶
Run and Batch are deliberately not exported: users read those in reports,
they never construct them. Widening this list needs an ADR — the cap exists to
force the question rather than to answer it.
Where to look for what¶
| I want to change… | Start in |
|---|---|
| What a user writes in an eval file | api.py |
| How cases are executed, timed, or limited | runner.py |
What pytest collects, or an exit code |
plugin.py |
A scorer, or the Scorer protocol |
scorers/ |
| The history schema | storage.py + migrations/ |
| What a report looks like | reporting/ |
| The live table, or watch mode | tui/ |
| Model calls, cost, or token accounting | llm.py |
The decisions behind all of this¶
Every non-obvious choice above has an ADR recording the alternatives that were rejected and why. The decision index lists them.