A score you cannot reproduce is not a measurement.
EvalKit is pytest for AI systems: a testing framework for LLM, RAG and agent pipelines. Define cases and evaluators in Python or YAML, run them against any system, and gate CI on the result.
This page describes the engineering model — what the library does, how it is put together, and what it refuses to pretend it knows.
00Install
Python 3.10 or newer. The core has zero required dependencies — PyYAML and the vendor SDKs are optional extras, and nothing is imported at module scope.
-
Install the package
A library and a CLI. There is no hosted service and nothing to log into.
pip install evalkit -
Scaffold and run a suite
Sixty seconds to a first result against the bundled example system.
evalkit init && evalkit run evalkit_suite.yaml -
Or declare the suite yourself
Cases, evaluators and the thresholds that gate the build, in one file.
name: qa system: my_pipeline:answer # 'module:attribute' dataset: cases.jsonl evaluators: - normalized_exact_match - type: semantic_similarity threshold: 0.6 required: false # measured, but does not gate thresholds: min_pass_rate: 0.9 max_latency_p95_ms: 2000 max_cost_usd: 0.50 max_new_failures: 0 # needs --baseline -
Gate CI on it
Compare against a stored baseline and emit a JUnit report the runner understands.
evalkit run suite.yaml --baseline baseline.json --report junit:results.xml
01The engineering model
Three commitments shape the design. They explain the choices that would otherwise look pedantic — why a score carries a flag nobody asked for, why a gate fails on the first run, why the benchmark prints a number that makes the library look slow.
- 1 · Determinism is declared, never assumed
- Every evaluator states whether it is deterministic, and the flag propagates into every score, every report, and a run-level warning. Nothing here quietly averages a stable measurement together with a coin flip.
- 2 · A judge is an instrument, not an oracle
- A model grading another model is a measurement with error. Before you gate CI on one, measure how often it agrees with a human — and how much of that agreement is luck.
- 3 · A gate that cannot fire is worse than no gate
- A threshold you believe is protecting you while it sits inert is the most expensive kind of green build. Declared gates that lack the data to evaluate fail; they are never skipped.
02What it is not
The category is crowded, and the distinction is the point.
- Not a dashboard. The core artifact is a library and a CLI that exit nonzero. There is no hosted UI, no account, and nothing to log into.
- Not an observability product. It does not trace your production traffic or sample live requests. It runs a declared suite against a system you hand it.
- Not a benchmark leaderboard. It ships no opinion about which model is best. Your cases, your evaluators, your thresholds.
- Not tied to a vendor. No SDK is imported at module scope. The core has zero required dependencies, and the whole test suite runs offline against deterministic mocks.
What is left is the part that belongs in your repository: a suite that runs in CI, fails when the system gets worse, and names the cases that broke.
03Determinism is the flagship
The most important thing EvalKit tracks about a score is whether it means the same thing twice. An evaluator that returns 1.00 today and 0.00 tomorrow on byte-identical input is not measuring your system; it is measuring the weather.
So every evaluator declares a flag, and the flag travels with the score.
A run using any non-deterministic evaluator carries a warning, and the run itself reports deterministic: false:
! non-deterministic evaluators used (llm_judge); scores may differ
between identical runs and are not ground truthWhat the deterministic evaluators cannot see
Being reproducible is not the same as being right. The offline backends are proxies, and the documentation lists what they miss rather than implying coverage they do not have.
| Evaluator | Reliably catches | Cannot see |
|---|---|---|
| faithfulness | Claims with no support in the sources; hallucinated entities | Negation, a swapped number inside otherwise-copied wording, contradiction |
| answer_relevance | Empty answers, refusals, off-topic answers | A fluent answer that is subtly beside the point |
| semantic_similarity | Rewordings that share vocabulary | Synonyms with no shared tokens — “car” and “automobile” |
| HashEmbedding | Lexical overlap | Meaning. It is feature hashing, so embedding code paths are testable offline — not a semantic model |
These are tripwires, not oracles. They catch regressions cheaply and reproducibly. When you need judgement, use a judge — and calibrate it first.
04Calibrating a judge before trusting it
The second commitment in practice. evalkit calibrate runs a judge over human-labelled examples and reports accuracy, the full confusion matrix, Cohen’s kappa and per-label F1.
Kappa is the one that matters, because accuracy flatters a judge that has learned nothing. Consider a judge that answers “looks good” to everything, run against a dataset that is nine passes and one fail — the shape of most real eval sets:
accuracy 0.900
Cohen's kappa 0.000 (chance agreement)
confusion matrix (rows = human, cols = judge):
fail pass
fail 0 1
pass 0 9
! accuracy 0.90 but kappa 0.00: the dataset is skewed and much of the
agreement is chance — do not treat this judge as ground truthNinety percent accurate, zero skill. It clears --min-accuracy 0.85 and fails --min-kappa 0.4, exiting nonzero. It never caught the one real failure — the column of zeros under fail is the whole story, which is why the matrix is printed rather than summarised away.
What calibration cannot do
It measures agreement with your labellers on your dataset. It does not make the judge deterministic, and it does not transfer to a different distribution of answers. Re-run it whenever the judge prompt, the model, or the dataset changes — all three invalidate the measurement.
05Gates that fail loudly
The third commitment. A suite declares thresholds; the CLI exits nonzero when one breaks.
| Code | Meaning |
|---|---|
| 0 | Every declared threshold passed. With none declared: every case passed. |
| 1 | A threshold failed, or a case failed. |
| 2 | Usage or configuration error — the suite never ran. |
A suite that failed to load must never look like a suite that ran and passed. Hence the third code.
Absolute gates — pass rate, mean score, error rate, latency percentiles, cost, per-evaluator floors — hold regardless of history. Relative gates compare against a baseline run: max_regression, max_score_regression, max_new_failures.
A relative gate with no baseline fails. It is not skipped and it does not quietly pass. Which means the very first run — the one that creates the baseline — fails those checks by design; --no-fail is how you capture it. That is a deliberate annoyance, traded for never shipping a gate that was inert the whole time.
A failing run names the cases rather than moving an aggregate:
thresholds:
[PASS] min_pass_rate 0.6667 >= 0.6000
[PASS] max_latency_p95_ms 25.0ms <= 200.0ms
[FAIL] max_regression: pass rate moved -0.3333 (1.0000 -> 0.6667)
[FAIL] max_new_failures: 2 case(s) regressed from pass to fail:
refund-window, reset-passwordThe bundled regression example makes the point sharply: the broken version is also faster and cheaper. A dashboard averaging latency and cost would have reported an improvement.
06What the library contains
Thirty-one evaluators across nine modules, fifty-one names in the public API. The core is pure standard library — PyYAML is an extra for YAML suites, vendor SDKs are extras, and nothing is imported at module scope.
| Module | Contents |
|---|---|
| core | EvalCase, Dataset, Evaluator, SystemUnderTest, EvalSuite, Run, Thresholds, cost tables, the registry. |
| evaluators | Exact and normalised match, regex, containment, JSON validity and schema, numeric tolerance, semantic similarity, faithfulness, factuality, citation correctness and support. |
| rag | Recall@k, precision@k, F1@k, hit rate, MRR, MAP, NDCG; answer and context relevance; retrieval, generation and end-to-end kept as separate suites. |
| agents | Typed trajectories — assistant, tool_call, tool_result, final — with expected tools, forbidden tools, argument matching, ordering, length budgets and arbitrary predicates. |
| judges | LLM judge and pairwise comparison, both marked non-deterministic; calibration against human labels with Cohen’s kappa. |
| statistics | Seeded bootstrap confidence intervals, exact McNemar for paired pass/fail, paired permutation tests, case-level regression deltas. |
| reporting | JSON (also the baseline format), JSONL, JUnit XML, Markdown, terminal summary. |
| providers | Chat and embedding protocols, deterministic mocks, optional Anthropic and OpenAI adapters. |
| cli | run, validate, compare, calibrate, list-evaluators, bench, init. |
07Statistics that admit uncertainty
Eval suites are small. A pass rate of 0.84 on 50 cases is not meaningfully different from 0.80, and reporting the point estimate alone invites teams to chase noise.
Two runs of the same suite are paired data, so the test is chosen from the data: exact McNemar for binary pass/fail, where only the discordant pairs carry information; a paired sign-flip permutation test for continuous scores. Bootstrap intervals are seeded, so an interval never changes underneath a code review.
pass_rate: B is worse by -0.3333 [-0.6667, +0.0000] — p=0.5000
(not significant, mcnemar-exact, n=6; 0 better / 2 worse / 4 tied)Read that carefully: two cases regressed and p = 0.50. Both are true. Six cases cannot statistically separate a real regression from noise, and the two named cases are still real bugs. The statistics tell you what the aggregate supports; the case list tells you what broke. Gate on the case list.
Roughly, resolving a pass-rate difference of d needs on the order of 1/d² cases. Which is why max_regression: 0.01 on a 100-case suite is not a quality gate — it is a coin flip with a threshold on it.
08Test results
646 tests, all passing. 96% statement coverage (3,683 statements, 152 uncovered). The distribution follows the risk: the modules where a silent error would quietly bless a broken system carry the most tests.
| Suite | Tests | Focus |
|---|---|---|
| suite_run | 60 | Execution, thresholds, regression gates, determinism warnings |
| judges | 58 | Scale normalisation, position bias, calibration statistics |
| providers | 49 | Protocols, deterministic mocks, vendor adapters via fake clients |
| agents | 48 | Trajectory matching, forbidden tools, argument modes |
| statistics | 48 | Bootstrap, McNemar, permutation, regression deltas |
| evaluators_structured | 47 | JSON validity, schema validation, numeric tolerance |
| evaluators_text | 47 | Match semantics, containment modes, the registry |
| cli | 45 | Exit codes, report formats, the calibrate command |
| evaluators_grounding | 40 | Faithfulness, factuality, citations |
| rag | 35 | Retrieval metrics, generation, three-stage separation |
| reporting | 30 | JSON, JSONL, JUnit XML, Markdown, terminal |
| evaluators_semantic | 28 | Similarity methods, embedding backends |
| types | 26 | Serialisation round trips, trajectory model |
| config | 26 | YAML/JSON suite loading, typo rejection |
| case_dataset | 22 | Case identity, dataset loading and filtering |
| examples | 20 | Every bundled example, executed |
| cost | 17 | Configurable price tables, unpriced-model warnings |
The six bundled examples are executable documentation, and the twenty tests in test_examples run every one of them — asserting the exact numbers their READMEs quote. An example cannot drift from the code without turning the suite red.
09Measured overhead
The framework’s own cost is measured separately from model and network latency, because a real suite is dominated by the model by three or four orders of magnitude and would hide a framework regression completely. The system under test here is a no-op mock.
| Phase | µs / case |
|---|---|
| case construction | 0.9 |
| system invoke (no-op) | 0.7 |
| evaluator: exact_match | 1.0 |
| evaluator: normalized_exact_match | 2.2 |
| evaluator: semantic_similarity | 4.2 |
| suite.run, 1 evaluator | 4.5 |
| suite.run, 3 evaluators | 12.4 |
| metrics aggregation | 1.7 |
| render JUnit | 1.3 |
| render JSON | 33.2 |
Roughly 80,000 cases per second, single-threaded, on the three-evaluator suite.
The framework is not your bottleneck, and no speedup is claimed. At 12.4 µs per case, a 1,000-case suite spends about 12 ms inside EvalKit; one LLM call is 500–5,000 ms. Note also that rendering the JSON report costs more than running the suite — it serialises every case’s full output, which is the price of the report also being the baseline format. These numbers are for comparing commits, not libraries; any cross-framework overhead comparison is measuring hardware and mock design.
10Using it
The Python API is primary; suites can equally be declared in YAML for CI.
from evalkit import EvalSuite, evaluator
# a custom evaluator is a plain function
@evaluator("no_pii", threshold=1.0)
def no_pii(case, output):
return not SSN_RE.search(output.text)
suite = EvalSuite("qa",
evaluators=["normalized_exact_match", no_pii()],
thresholds={"min_pass_rate": 0.9})
suite.add("What is the capital of France?", expected="Paris")
run = suite.run(my_system)
assert run.passed
run.save("baseline.json")Anything model-graded must say so, and the flag is a claim you are making:
@evaluator("tone", deterministic=False)
def tone(case, output): ...In CI, the JUnit report emits two suites: one testcase per eval case, and one testcase per declared threshold. That second suite is what makes a failure legible — max_cost_usd $0.0412 > $0.0300 shows up as a named failing test rather than a bare nonzero exit.