Skip to main content

paired-eval

paired-eval

Evaluate models, agents and harnesses: program checks first, rubrics for the rest, honest paired statistics.

PyPI CI Python 3.9+ Dependencies: none License: MIT

Website · 中文 · Docs · Changelog

You ran model A and model B on the same 40 tasks. A scored 0.72, B scored 0.65. Is A better? Usually you cannot tell from those two numbers — and an LLM judge's "A is better" is not evidence either. paired-eval answers with a paired test on the per-task results, a confidence interval, and a verdict that says significant / bounded null (with the effect it rules out) / uninformative / powerless — never a bare "p > 0.05".

pip install paired-eval
import paired_eval as pe

a = [1, 0, 1, 1, 0, 1, 1, 0]          # per-task pass/fail (or scores) for system A
b = [1, 0, 0, 1, 0, 0, 1, 0]          # same tasks, same order, for system B
print(pe.interpret(pe.paired_compare(a, b))["text"])

Pure standard-library Python ≥ 3.9, no dependencies. You inject both the model (call(prompt) -> str) and the judge (judge(prompt, system, schema) -> dict); no vendor binding.

Three things it does, and why each exists:

Because
Paired statistics — per-round McNemar + per-task permutation, Holm correction, bootstrap CI, sample-size planning Unpaired means on 40 tasks hide a 0.3 effect behind noise; paired tests on the same tasks do not
Program gate, then rubric — an answer that fails a programmatic check scores 0 and the judge is never called Judges get fooled on "does it work"; programs do not. Spend judge calls only on ranking answers that already work
Saturation & ceiling diagnostics — tasks both systems always pass carry no information; a system at 1.0 has no headroom Most "no difference" results are really "no informative tasks", and the remedy is different

What it evaluates

The three objects ask three different questions and hold different things constant; mixing them up yields nothing.

Evaluate Hold constant Vary What one A/B looks like
model same tasks, same scaffold the model {"A": call_a, "B": call_b}
harness same model prompt / scaffold / tool wiring {"strict": strict prefix, "bare": bare prompt}
agent same model, same scaffold the strategy {"single": one pass, "self-check": draft then self-correct}
their interaction 2×2 factorial both factors main effects on one scale, ceilings flagged automatically

How it verifies: programmatic checks first, judges only for what they cannot cover

a unique ground truth ──→ exact (numeric / choice / set / \boxed{}) or a programmatic check(response) -> bool
only sources of fact  ──→ retrieval (verify against search) / trajectory (ground each claim in what the agent saw)
only a quality bar    ──→ rubric (per-criterion binary judging, weighted; the rubric must pass a canary: a bluffing answer must not score high)

Layers stack — the program gates, the rubric scores. Failing the gate scores 0 and never calls the judge: you do not pay a judge for an answer that is already wrong, and the judge cannot be fooled on "does it work" — it only ranks quality among candidates that do.

import paired_eval as pe

task = {"id": "sum-explained", "instruction": "What is 12×12? Explain, and give the result starting with 'Answer: '",
        "verification": {"class": "gated",
                         "gate":  {"class": "exact", "gold": "144", "kind": "numeric", "marker": "Answer"},
                         "score": {"class": "rubric", "criteria": [{"text": "explains the calculation", "weight": 1}]}}}
r = pe.evaluate(task, response=answer, llm=judge)
print(r["score"], r["verdict"])        # wrong -> 0.0 'gated_out' (judge never called); right -> the rubric score

Other evaluate routes: exact (programmatic, no LLM), retrieval, trajectory, rubric; rubric_canary checks whether a rubric can be gamed by a bluffing answer.

Ten seconds (an offline demo of the statistics layer)

python3 -m paired_eval --lang en        # or pe.set_language("en") once in code

strict / bare here are two stub functions, not models — they replay one real failure mechanism (under a bare prompt the correct JSON gets wrapped in markdown fences and fails to parse), only to show the shape of a report without any API. The output is real (a test keeps it identical to the current code):

informative sample: 2/4 tasks informative (always-pass 2, always-fail 0)
refusals: {'strict': 0, 'bare': 0}
at ceiling (1.000): strict — no headroom; effects measured against it as the reference are compressed
bare vs strict: Δ=-0.500 CI95=[-1.000,+0.000] | per-task p=0.506 per-round McNemar=0.00781 Holm=0.00781 | discordant 0:8 concentration=0.50 | significant: Δ=-0.500 CI95=[-1.000,+0.000] p=0.0078 (n=16)

Two tasks were solved by both systems every time and carry no information (informative sample 2/4); all 8 discordant pairs favour strict, spread over 2 tasks (concentration 0.50); the per-task permutation p is 0.506 because with 2 informative tasks its minimum attainable p is 0.5, while per-round McNemar uses all 16 paired units. The last clause is a verdict you can paste into a report.

With real models: one A/B per object

1. Mix verification classes by what can be verified. Ground truth → programmatic; ground truth plus a quality bar → gated; only sources → trajectory:

my_tasks = [
    {"id": "date", "instruction": "Write 5 March 2024 as an ISO 8601 date, starting with 'Answer: '",
     "verification": {"class": "exact", "gold": "2024-03-05", "marker": "Answer"}},
    {"id": "sum-explained", "instruction": "What is 12×12? Explain, and give the result starting with 'Answer: '",
     "verification": {"class": "gated",
                      "gate":  {"class": "exact", "gold": "144", "kind": "numeric", "marker": "Answer"},
                      "score": {"class": "rubric", "criteria": [{"text": "explains the calculation", "weight": 1}]}}},
    {"id": "summary", "instruction": "Write a one-sentence summary using only the source. Source: X's 2023 revenue was 41.2bn.",
     "observations": [{"tool_call_id": "t1", "tool": "doc", "observation": "X's 2023 revenue was 41.2bn."}],
     "verification": {"class": "trajectory", "grounding_policy": "must_ground"}},
]

2. Plug in models and a judge, and turn the task set into what the paired pipeline eats. The adapter is examples/adapter_openai_compat.py (standard library only, any compatible endpoint); bench_tasks binarises each task's evaluate score at an explicit threshold — "pass" means ≥ this much, and that belongs in the report.

from examples.adapter_openai_compat import make_call, make_llm   # standard-library-only OpenAI-compatible adapter

call_a, call_b = make_call(model="model-a"), make_call(model="model-b")
judge = make_llm(model="judge-model")
tasks = pe.bench_tasks(my_tasks, threshold=1.0, llm=judge)

3. Three objects, each holding its own variable constant. n is repeats per task per system; interleaving rotates the order.

STRICT = "Follow the format exactly, no extra text. Task: "

def self_check(call):                     # agent strategy: draft, then self-correct against the instruction (one extra call, no tools)
    return lambda p: call(f"Instruction: {p}\nDraft: {call(p)}\nCheck the draft against the instruction; output only the corrected answer.")

runs = {
    "model":   pe.run_interleaved({"A": pe.make_model(call_a), "B": pe.make_model(call_b)},
                                  tasks=tasks, n=6, prompt_prefix=STRICT),
    "harness": pe.run_interleaved({"strict": pe.make_model(lambda p: call_a(STRICT + p)),
                                   "bare": pe.make_model(call_a)}, tasks=tasks, n=6, prompt_prefix=""),
    "agent":   pe.run_interleaved({"single": pe.make_model(call_a),
                                   "self-check": pe.make_model(self_check(call_a))}, tasks=tasks, n=6, prompt_prefix=""),
}
for axis, run in runs.items():
    print(axis, pe.report(run["reports"], refusals=run["refusals"], lang="en")["text"], sep="\n")

Plan the sample size before running, and let interpret state the conclusion afterwards:

pe.required_tasks(0.30, 0.10)         # A wins 30% / loses 10% of tasks: paired tasks needed for 80% power
pe.required_pairs(0.15, 0.30)         # continuous scores, mean diff 0.15, sd 0.30 -> runs the real permutation test (~20 s)
pe.detectable_effect(31)              # the inverse: the smallest one-sided win rate 31 tasks can detect
pe.interpret(pe.paired_compare(scores_a, scores_b), lang="en")["text"]   # afterwards: what this result can and cannot say

screen_tasks / screen_graded screen for tasks that discriminate, in two stages; near-ceiling tasks (> 0.9) are excluded by default — they cannot be screened reliably at any affordable number of runs.

Evaluating a real agent run

The agent axis needs the agent's actual tool observations, not a stub. AgentXRay already normalises Claude Code / Codex / OpenClaw / Hermes / OMP / Gemini CLI logs into one shape; paired_eval.adapters.agentxray turns that export into a trajectory task, so every claim in the agent's final answer is checked against what the agent actually saw:

import json
from paired_eval.adapters import agentxray as ax

sess = json.load(open("tests/fixtures/agentxray-codex-session.json"))   # = curl http://localhost:3800/api/codex/sessions/<id>
task = ax.trajectory_task(sess)              # id, instruction, observations[], verification: trajectory
r = pe.evaluate(task, response=ax.final_answer(sess), observations=task["observations"], llm=judge)
print(r["score"])                            # grounding rate of the final answer

Two runs of the same instruction under two agents (or two harnesses) become one paired unit: score each, then pe.paired_compare.

Reading the report

Field Meaning
informative sample Tasks both systems always pass or always fail carry no information; read the MDE against the informative count
refusals Dropped calls per system — the refusal rate is part of the result
at ceiling / at floor A system at 1.0 or 0.0 has no headroom; effects against it are compressed, interactions uninterpretable
Δ, CI95 Effect size with a bootstrap 95% interval
per-task p / per-round McNemar / Holm The two paired tests; Holm correction when several systems are compared
discordant a:b, concentration Direction and spread of the disagreements; 1.0 = all from one task
verdict significant · bounded null (with the ruled-out effect) · uninformative · powerless (with what is missing)

"p > 0.05" means three different things with different remedies: too few units is uninformative; too few discordant pairs is powerless (add rounds, or tasks that separate the systems); enough units and still nothing is a bounded null — report the effect it rules out, never "no difference".

How it relates to other tools

They are frameworks for running evaluations; paired-eval sits downstream and does not duplicate task libraries or model backends. Descriptions are taken from each project's own README.

Tool What it does Relation
lm-evaluation-harness 60+ academic benchmarks over many model backends; per-metric standard errors Produces per-task scores → feed them to paired_compare / interpret
Inspect Eval framework: prompt engineering, tool use, multi-turn dialog, model-graded components; 200+ pre-built evals Same; scorer output is per-sample and pairs naturally
promptfoo Side-by-side model/prompt comparison with assertions; red teaming Overlaps on "compare"; paired-eval adds stacked verifiers and the statistical verdict layer
openai/evals Registry of template-based evals fed by JSON data Same downstream relation

API overview

import paired_eval as pe:

Group Entry points
Verifiers evaluate (exact / retrieval / trajectory / rubric / gated) evaluate_pair evaluate_batch validate_task grade_answer extract_claims verify_trajectory run_rubric rubric_canary
Paired A/B make_model bench_tasks judge_check run_interleaved run_paired run_repeated report pairwise_compare reliability_matrix saturation
Statistics paired_compare mcnemar_exact holm_adjust wilson_ci pass_hat_k required_tasks required_pairs detectable_effect p_floor min_units_for_alpha interpret
Screening screen_tasks screen_graded · built-in ALL_TASKS (31 Chinese smoke tasks, for examples and self-tests)
Adapters make_resilient throttled_pmap Meter set_language · paired_eval.adapters.agentxray — AgentXRay session export → trajectory task (see below)

Scope and status

  • Is: a method and toolkit for evaluating models / agents / harnesses — stackable verifiers (program gate + rubric score), honest statistics for paired comparison, sample-size planning, task screening.
  • Is not: a large task library (the 31 built-in tasks are examples and self-tests), an evaluation platform, a model client, or a rubric generator (the autorubric half is not here — this evaluates whether a rubric can be fooled). Run large suites with the frameworks above and hand their per-task scores to this.
  • Status: 0.4.0, single author, API may change. Reports in English and Chinese (set_language); code comments and built-in tasks are Chinese.
  • docs/findings.md is a case study done with this toolbox on one model pair and three task families: harness and agent effects of about +0.6–0.75, model effect < 10%, the two with diminishing but stackable returns. The numbers are instance-specific and show how a conclusion should be written.

Docs · Contributing · License

Docs index · Methodology lessons (what each primitive guards against) · 中文 README · CONTRIBUTING.md · MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

paired_eval-0.4.0.tar.gz (146.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

paired_eval-0.4.0-py3-none-any.whl (65.5 kB view details)

Uploaded Python 3

File details

Details for the file paired_eval-0.4.0.tar.gz.

File metadata

  • Download URL: paired_eval-0.4.0.tar.gz
  • Upload date:
  • Size: 146.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for paired_eval-0.4.0.tar.gz
Algorithm Hash digest
SHA256 952983de0d302556c2b2e6e71ade35b0346bead6f7c34e34d67c58614ad398d9
MD5 c3b3826e65606c24a4746c7006653b7d
BLAKE2b-256 e7a746f4933aba8f989022ce92f9390070da1acd4ae15db9f017c3614c6056a1

See more details on using hashes here.

Provenance

The following attestation bundles were made for paired_eval-0.4.0.tar.gz:

Publisher: release.yml on alloevil/paired-eval

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file paired_eval-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: paired_eval-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 65.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for paired_eval-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 319bc30cc32ee2f2e45b7149ce3926b95cb86711b796d116197acbab0c861eb1
MD5 5706088745875e44884a9e8007fc26ec
BLAKE2b-256 1057c7f3700259f525d4a02f5a8e80ef22b90c314264a1b2135cd9a79cad455d

See more details on using hashes here.

Provenance

The following attestation bundles were made for paired_eval-0.4.0-py3-none-any.whl:

Publisher: release.yml on alloevil/paired-eval

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.4.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page