Skip to content
EvalKit v0.1.0

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.

  1. Install the package

    A library and a CLI. There is no hosted service and nothing to log into.

    pip install evalkit
  2. Scaffold and run a suite

    Sixty seconds to a first result against the bundled example system.

    evalkit init && evalkit run evalkit_suite.yaml
  3. 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
  4. 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.

Three tiers of determinism Deterministic evaluators return the same score on two identical runs. Backend-dependent evaluators are reproducible only if the backend is pinned. LLM judges can return different scores for identical input. run 1 run 2 exact_match pure function of the output 1.00 1.00 = deterministic faithfulness depends on its support backend 0.75 0.75 = only if the backend is pinned llm_judge 1.00 0.00 non-deterministic, always
The third row is not a bug to be fixed. It is a property to be carried, so that a threshold built on top of it can be set with the noise in mind.

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 truth

What 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.

Deterministic does not mean correct.
EvaluatorReliably catchesCannot see
faithfulnessClaims with no support in the sources; hallucinated entitiesNegation, a swapped number inside otherwise-copied wording, contradiction
answer_relevanceEmpty answers, refusals, off-topic answersA fluent answer that is subtly beside the point
semantic_similarityRewordings that share vocabularySynonyms with no shared tokens — “car” and “automobile”
HashEmbeddingLexical overlapMeaning. 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 truth

Ninety 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.

Exit codes. The third is deliberately distinct.
CodeMeaning
0Every declared threshold passed. With none declared: every case passed.
1A threshold failed, or a case failed.
2Usage 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-password

The 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.

Package layout and what each module owns.
ModuleContents
coreEvalCase, Dataset, Evaluator, SystemUnderTest, EvalSuite, Run, Thresholds, cost tables, the registry.
evaluatorsExact and normalised match, regex, containment, JSON validity and schema, numeric tolerance, semantic similarity, faithfulness, factuality, citation correctness and support.
ragRecall@k, precision@k, F1@k, hit rate, MRR, MAP, NDCG; answer and context relevance; retrieval, generation and end-to-end kept as separate suites.
agentsTyped trajectories — assistant, tool_call, tool_result, final — with expected tools, forbidden tools, argument matching, ordering, length budgets and arbitrary predicates.
judgesLLM judge and pairwise comparison, both marked non-deterministic; calibration against human labels with Cohen’s kappa.
statisticsSeeded bootstrap confidence intervals, exact McNemar for paired pass/fail, paired permutation tests, case-level regression deltas.
reportingJSON (also the baseline format), JSONL, JUnit XML, Markdown, terminal summary.
providersChat and embedding protocols, deterministic mocks, optional Anthropic and OpenAI adapters.
clirun, 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.

Test count by suite. Python 3.12.13.
SuiteTestsFocus
suite_run60Execution, thresholds, regression gates, determinism warnings
judges58Scale normalisation, position bias, calibration statistics
providers49Protocols, deterministic mocks, vendor adapters via fake clients
agents48Trajectory matching, forbidden tools, argument modes
statistics48Bootstrap, McNemar, permutation, regression deltas
evaluators_structured47JSON validity, schema validation, numeric tolerance
evaluators_text47Match semantics, containment modes, the registry
cli45Exit codes, report formats, the calibrate command
evaluators_grounding40Faithfulness, factuality, citations
rag35Retrieval metrics, generation, three-stage separation
reporting30JSON, JSONL, JUnit XML, Markdown, terminal
evaluators_semantic28Similarity methods, embedding backends
types26Serialisation round trips, trajectory model
config26YAML/JSON suite loading, typo rejection
case_dataset22Case identity, dataset loading and filtering
examples20Every bundled example, executed
cost17Configurable 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.

Apple silicon, macOS 15.1, Python 3.12.13. 2,000 cases × 7 repeats, best-of.
Phaseµs / case
case construction0.9
system invoke (no-op)0.7
evaluator: exact_match1.0
evaluator: normalized_exact_match2.2
evaluator: semantic_similarity4.2
suite.run, 1 evaluator4.5
suite.run, 3 evaluators12.4
metrics aggregation1.7
render JUnit1.3
render JSON33.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.