Verdikt
Your eval printed a success rate. Verdikt tells you whether you're allowed to believe it.
A CPU-only decision layer for robot-policy evaluation. It reads the eval JSON, dataset files and run configs you already have — and refuses to let you draw a conclusion the data does not support.
The problem, in one picture
Two policies. Twenty episodes each. One scored 35%, the other 70%. Ship the winner?
No. At n=20 that comparison is p = 0.056 — the intervals overlap, and the difference is
not significant. Meanwhile "0/20 = 0%" actually means "below 13.9%", and the two 0/20 arms
are statistically indistinguishable from each other (p = 1.000).
Every number above was produced by the command below, from files that already existed.
verdikt compare results/*.json --baseline diffusion_50k
What Verdikt does
| Command | Question it answers | Exit |
|---|---|---|
verdikt doctor |
Is my training stack about to fail silently? | 0 / 2 |
verdikt lint |
Is this dataset going to waste my GPU-hours? (10 rules) | 0 / 2 |
verdikt ingest |
Turn any harness's eval output into one canonical table | 0 |
verdikt plan |
How many episodes do I actually need? | 0 |
verdikt manifest / diff |
Are these two runs even comparable? | 0 / 3 |
verdikt compare |
Is checkpoint B really better than A? (paired or unpaired) | 0 / 1 / 2 / 3 |
verdikt watch |
Can I stop this eval early? | 0 / 2 |
verdikt report |
Give me something to hand my lead | 0 / 1 / 2 / 3 |
The four-state verdict
Most gates are binary: pass or fail. Success rates are stochastic, so a binary gate on a binomial produces constant false alarms. Verdikt returns four states, so "I can't tell yet" is a first-class answer instead of a silent pass:
| Exit | State | Meaning |
|---|---|---|
0 |
BETTER | no regression — ship it |
1 |
REGRESSION | candidate is significantly worse |
2 |
UNDERPOWERED | cannot decide at this n — here is the n that would |
3 |
NOT COMPARABLE | a confound makes the comparison meaningless |
Install
Requirements: Python 3.10+. No GPU. No robot. No simulator. Nothing to configure.
pip install verdikt-eval
That is the whole install, on Windows, Linux and macOS alike — Verdikt is pure CPU Python, so
Apple Silicon and Intel both just work. Prefer an isolated tool install? pipx install verdikt-eval or uv tool install verdikt-eval.
On the name: the distribution is
verdikt-evalbecause plainverdiktis held on PyPI by an unrelated placeholder. The import package and the command are unaffected —pip install verdikt-evalgives youimport verdiktand$ verdikt, the same splitpython-dateutiluses.
Optional extras: pip install "verdikt-eval[hub,gpu,wandb]" — HuggingFace dataset resolution,
NVIDIA device detection for doctor, and Weights & Biases write-back.
From source (for development):
git clone https://github.com/muhammadmahadazher/Verdikt && cd Verdikt
pip install -e ".[dev]" && pytest -q
Quickstart — 60 seconds, on results you already have
# 1. is my stack lying to me?
verdikt doctor --train-config runs/act/checkpoints/last/pretrained_model/train_config.json
# 2. what do my existing eval files actually say?
verdikt compare "results/*.json" --baseline diffusion
# 3. how many episodes would settle it?
verdikt plan --p0 0.35 --mde 0.35 --power 0.80
# 4. were these two runs ever comparable?
verdikt manifest runs/act --policy-id act && verdikt manifest runs/smolvla --policy-id smolvla
verdikt diff runs/act/manifest.json runs/smolvla/manifest.json
Verdikt reads lerobot-eval output natively. For any other harness, describe your columns
once and everything downstream works:
verdikt ingest my_evals.csv --adapter csv --map success=passed,policy_id=model
How many episodes do you actually need?
This is the design decision that makes Verdikt worth installing.
statsmodels will tell you 31 episodes per arm are enough to separate 35% from 70% at 80%
power. Run Fisher's exact test at n=31 and the realised power is 0.741. A planner that
plans with the normal approximation and decides with an exact test under-recommends rollouts —
the exact opposite of its purpose.
Verdikt computes power by exact enumeration through the test that will issue the verdict. No simulation error, no approximation gap.
| Comparison | Verdikt (exact) | Normal approximation | Realised power at that n |
|---|---|---|---|
| 35% vs 70% | 37/arm | 31/arm | 0.741 ❌ |
| 35% vs 50% | 183/arm | 170/arm | 0.778 ❌ |
And the number nobody wants to hear:
| Your budget | Smallest difference you can detect vs a 35% baseline |
|---|---|
| n = 20 | 47 percentage points |
| n = 50 | 30 pp |
| n = 100 | 20 pp |
| n = 200 | 15 pp |
At n=20, power to detect 35%-vs-70% is 0.468 — worse than a coin flip.
Stop the eval as soon as the answer is in
Every other command here takes claims away. This one gives GPU-hours back.
verdikt watch runs/act/eval_info.json runs/upstream/eval_info.json --replay
replayed 800 random orderings of 200 episodes
reached a verdict in 100% of orderings
median stopping point 14 episodes (90th percentile 17)
median saving 93% of the episodes you ran
Peeking at a p-value as episodes arrive and stopping when it dips below 0.05 badly inflates
false positives — it is the most common way an evaluation fools itself. watch uses a test
martingale instead: capital starts at 1 and is wagered on each episode pair, so under the
null it is a martingale and Ville's inequality bounds the error at α across every possible
stopping time. You may look as often as you like.
Measured on the 800 real rollouts in this repo, not borrowed from a paper:
| comparison | median stop | saving |
|---|---|---|
| smolvla vs upstream | 13 / 200 | 94% |
| act vs upstream | 14 / 200 | 93% |
| diffusion vs upstream | 23 / 200 | 88% |
| act vs diffusion | 39 / 200 | 80% |
| act vs smolvla (genuinely no difference) | never stops | 0% — correctly declines |
That last row is the important one. When there is nothing to find, the test does not find
something; it reports CONTINUE and says plainly that this is absence of evidence, not
evidence of equivalence.
The false-positive rate is verified by simulation as a release gate, not a diagnostic —
20,000 null runs per configuration, across four base rates, two α levels and run lengths up
to 600. If empirical FPR ever exceeds α, watch does not ship.
Pair the episodes when it actually helps
If both policies were evaluated on the same scenes, you can compare them episode by episode:
verdikt compare "eval/*.json" --baseline production --paired
A paired test looks only at the episodes where the two arms disagreed, so shared scene-to-scene difficulty stops costing you power. That is a large win when the policies solve many of the same scenes.
It is not universally better, and Verdikt says so. McNemar uses only the discordant pairs; Fisher uses both full margins. When the two arms share few successes there is nothing for pairing to cancel, and the unpaired test is stronger. Measured on 50 real PushT episodes where ACT scored 0/50 and diffusion 13/50 — no shared successes at all:
| test | p |
|---|---|
| unpaired (Fisher) | 0.00010 |
| paired (McNemar) | 0.00024 |
So --paired prints a warning when the contingency table shows pairing is unlikely to pay,
rather than letting you assume it always does.
Verdikt refuses to pair unless it can justify the alignment. With a per-episode seed
column it pairs on the seed — including when the two arms recorded the same scenes in a
different order. Without seeds it stops:
paired comparison needs episodes that are known to be the same scene, and this source
records no per-episode seed. re-run the evaluation with a fixed --seed and identical
batch size for both policies, then pass --assume-aligned to confirm you did — verdikt
will not assume it for you.
Pairing episode 7 of one run against episode 7 of another is meaningless unless they were the same scene, and that is the sort of assumption that silently produces a confident wrong answer. It stays the caller's statement, never the tool's guess.
The assumption is measured, not asserted. Two lerobot-eval runs of the same policy at the
same seed and batch size agree to a median of 5e-4 in per-episode reward — the signature of the
same scene replayed, since different scenes would differ by O(0.1). Rollouts are not bit
reproducible on GPU, and one episode in fifty diverged badly; that noise is real but does not
bias McNemar. Full measurement: docs/pairing_evidence.md.
Gate a merge on evidence, not on a point estimate
- uses: muhammadmahadazher/Verdikt@main
with:
results: "eval/*.json"
baseline: production
min-lower-bound: "0.60" # the CI lower bound must clear 60% - not the estimate
The action writes a job summary with the full table, intervals and group letters, and exposes
verdict, exit-code and required-n as outputs. Its behaviour follows the four states:
| Verdict | Default in CI | Why |
|---|---|---|
BETTER |
✅ pass | no regression, and the design could have found one |
REGRESSION |
❌ fail | the candidate is significantly worse |
UNDERPOWERED |
⚠️ warn | "we can't tell yet" is a reason to run more episodes, not to block a merge — flip fail-on-underpowered if you disagree |
NOT COMPARABLE |
❌ fail | a confound makes the number meaningless |
Three scenarios run against the committed corpus on every push (gate-selftest.yml) — a genuine improvement passes, a real regression is caught, and the canonical 35%-vs-70%-at-n=20 case warns rather than blocks.
Put the verdict where your team already looks
verdikt report "eval/*.json" --baseline production \
-o report.html --modelcard MODEL_CARD.md \
--wandb acme/robot-policies/3kf9a2xq
W&B stores and plots your numbers; it has no opinion about whether a difference is real. This attaches the opinion to the run: verdict and exit code as summary fields, an arm table, and the HTML report plus model card as a versioned artifact.
Every rate written to W&B carries its n and both interval bounds, and a 0/n arm carries
its one-sided bound rather than a hard zero — because once a bare number is on a dashboard it
ends up in a slide deck. Use --wandb-dry-run to see the exact payload before sending it.
Structural refusals
These are enforced in the formatter, not left to the caller's discipline. They cannot be forgotten in a hurry:
- 🚫 A success rate never prints without
nand an interval. - 🚫
0/nnever prints as "0%". It prints its exact one-sided bound (0/20 → ≤ 13.9%). - 🚫 There is no
--min-successflag. Gating a stochastic binomial on a point estimate is the malpractice this tool exists to stop. Only--min-lower-boundand--noninferiority --marginexist. - 🚫 The Wald interval is not implemented. It under-covers at small n and collapses to
[0, 0]at k=0. Asking for it raises an error explaining why. - 🚫 Confounded arms are suppressed, not ranked.
- 🚫 Changing the test after seeing data is blocked when a pre-registered
plan.jsonis supplied — that's test-shopping, and at the margin it flips verdicts.
That last one is not hypothetical. On real data from the case study below:
act vs diffusion Fisher p = 0.008316 SIGNIFICANT
Barnard p = 0.009984 not significant
Bonferroni-corrected alpha = 0.008333
Two defensible exact tests, opposite verdicts, same data. Verdikt always names the test that produced the number and flags when the alternative would disagree.
Case study: auditing my own published benchmark
Verdikt was validated by pointing it at vla-on-a-budget — my own published study comparing ACT, Diffusion Policy and SmolVLA — and correcting its headline.
| Claim as published | What Verdikt returns |
|---|---|
| "Diffusion 35% vs upstream 70%" | p = 0.056 — not significant at n=20 |
| "ACT 0%" | honestly: ≤ 13.9% (one-sided 95%) |
| "ACT vs SmolVLA" | p = 1.000 — indistinguishable |
| SmolVLA ranked beside the others | NOT COMPARABLE — 10× fewer samples seen |
That last row is arithmetic, not inference:
verdikt diff runs/act/manifest.json runs/smolvla/manifest.json
samples_seen 1.6e+06 1.6e+05 COMPUTE_CONFOUND 10.0x
what differs
normalization_mode
VISUAL: MEAN_STD -> IDENTITY
=> act and smolvla are NOT comparable as an architecture result (exit 3)
The study's qualitative finding survives — generative action heads beat deterministic regression on multimodal demonstrations. The precision of the headline does not. That correction is now published in the study itself.
Then we re-ran the evaluation at n=200 and watched the fog clear
Same four checkpoints, same task, ten times the episodes — 800 rollouts of inference, no retraining. At n=20 the tool could only say "two groups, and one comparison is too close to call." At n=200 every pair separates and three distinct performance tiers appear:
| policy | n=20 | n=200 | 95% CI at n=200 | group |
|---|---|---|---|---|
| upstream diffusion | 70% | 65.5% | [58.7, 71.7] | c |
| diffusion 50k | 35% | 24.0% | [18.6, 30.4] | b |
| act 50k | 0% | 1.0% | [0.3, 3.6] | a |
| smolvla 20k | 0% | 0.0% (≤1.5%) | [0.0, 1.9] | a |
Note what n=20 got wrong in both directions: diffusion looked like 35% and is really 24%; ACT looked like a flat 0% and actually solves 1% of episodes. Neither error was detectable from the smaller sample — which is the entire argument for computing required-N before you run the eval rather than after.
This corpus ships in the repo as tests/fixtures/pusht_n200/, so every statistical feature
is demonstrated on real robot-policy rollouts rather than synthetic numbers.
FAQ
Isn't this just a few scipy calls?
The math is commodity, and pretending otherwise would be dishonest. The value is that the correct math is always applied: version-pinned adapters, opinionated defaults that make malpractice structurally impossible, a four-state exit code, and named tests with disagreement warnings. The moat is being correct and finished, not being clever.
Does it work with my eval harness?
Natively with lerobot-eval output. Anything else works through the generic CSV/JSON mapper
in one line. Adapters declare the schema version they parse and fail loudly on unknown
versions rather than silently mis-mapping a field.
Why not just use Weights & Biases?
W&B stores and plots your numbers; it does not tell you whether a difference is real. Verdikt is the decision layer on top and writes back into it.
Are rollouts really independent Bernoulli trials?
Often not. Session drift, reused object placements and operator fatigue induce correlation that makes naive intervals narrower than the truth. This is a correctness ceiling, stated here rather than buried: when seeds or session IDs are available Verdikt detects intra-run correlation and widens rather than pretends. Single-seed results always carry a warning.
Does it need a GPU, a robot, or a simulator?
None of the three. It reads files. Every command runs on a laptop in seconds.
🔬 Experimental: how much can a deterministic policy even explain?
verdikt profile <dataset> --experimental
Demonstrations are often multimodal — from the same state, several actions are all correct. A deterministic head must pick one, so it drifts toward the conditional mean, which may be an action no demonstrator ever took. That is the usual story for why ACT plateaus where diffusion succeeds. Measuring it rigorously is much harder than measuring it.
Three failure modes are designed around, because the obvious implementation hits all three:
| Trap | What goes wrong | Fix |
|---|---|---|
| Neighbours aren't independent | k-NN inside an episode returns consecutive frames of the same trajectory | exclude ±15 frames of the same episode |
| A Gaussian null | collapses on heavy tails — FPR 0.47 under t(3) | permutation null resampled from the data's own residuals |
| The bound doesn't bind the policy | proprioception excludes the object pose the policy actually sees | require ≥2 embeddings, refuse when they disagree |
Calibration gate (docs/calibrate_profile.py) — 16 cells, three seeds each, on data that is
unimodal by construction, so every detection is a false positive:
| noise | Gaussian | t(8) | t(5) | t(3) |
|---|---|---|---|---|
| FPR (episode-blocked) | 0.038–0.044 | 0.059–0.067 | 0.044 | 0.056–0.064 |
Nominal α = 0.05, ship threshold 0.07, worst cell 0.067. It ships.
And on the real lerobot/pusht it refuses to answer — which is the point:
embedding anchors AMR (L2) MAD (L1) multimodal eff. dim
observation.state 400 0.041 0.160 15.5% 2.0
state + velocity 400 0.018 0.108 4.5% 2.0
INSUFFICIENT EVIDENCE
the embeddings disagree on multimodality (4.5% vs 15.5%); the higher reading is
explained by a feature the smaller embedding is missing rather than by competing
actions, so no dataset-level claim is supported
Position alone says 15% multimodal; adding velocity drops it to the null level. Those were the
same states revisited at different phases of motion — not competing actions. A tool that
averaged the two, or reported the first, would have invented a fact. Every line is tagged
[provisional]: it is a bound under the embeddings shown, never a success-rate prediction and
never an architecture recommendation.
What Verdikt deliberately does not do
Scope discipline is a feature. Verdikt will never:
- run policies, train, or execute rollouts — every eval runner is an input, not a competitor
- define a new dataset format, benchmark suite, or simulator
- cluster ~20 failed rollouts into "failure modes" (numerology at that sample size)
- guess your VRAM ceiling (a false GO is worse than no answer)
- host a leaderboard
- extrapolate an "iso-sample projection" between a pretrained VLA and a from-scratch policy — refusal is defensible, extrapolation is not
Roadmap
| Status | Feature |
|---|---|
| ✅ | ingest · plan · compare · doctor · manifest / diff |
| ✅ | lint — ten dataset rules, each with a deliberately-corrupted test fixture |
| ✅ | report — self-contained HTML + LeRobot-format model card |
| ✅ | watch — anytime-valid sequential stopping, past its 20,000-run false-positive gate |
| ✅ | gate — GitHub Action wrapping the four-state exit code, dogfooded in CI |
| ✅ | W&B write-back |
| 🔬 | profile — dataset multimodality bound, --experimental only, past its calibration gate |
Contributing
Issues and pull requests are welcome — see CONTRIBUTING.md.
The highest-value contribution is an adapter for your eval harness: drop a golden fixture
in tests/fixtures/adapters/, and the parser is usually 30 minutes of work.
Verdikt is released under the Apache License 2.0 — permissive, patent-granting, and usable inside a commercial pipeline. That is deliberate: the closest prior art on rigorous robot-policy evaluation ships under a non-commercial license, which keeps it out of exactly the CI pipelines that need it most.
Citing
@software{verdikt2026,
author = {Azher, Muhammad Mahad},
title = {Verdikt: a decision layer for robot-policy evaluation},
year = {2026},
url = {https://github.com/muhammadmahadazher/Verdikt},
license = {Apache-2.0}
}
Related projects
- vla-on-a-budget — the benchmark study Verdikt audits
- OpenVocab-4D — open-vocabulary 4D scene understanding on an 8 GB laptop
- LeRobot — the robotics library Verdikt reads
docs/make_figures.py and asserted in tests/. None of them were typed by hand.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file verdikt_eval-0.3.1.tar.gz.
File metadata
- Download URL: verdikt_eval-0.3.1.tar.gz
- Upload date:
- Size: 101.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0fa4c190306ed3d227b4e6c96e738ad4e9818357b6baf2b4f2bdce25348ae12f
|
|
| MD5 |
6e8e78f038396960d5c3370c17d646d3
|
|
| BLAKE2b-256 |
458e3580ff5d7b7442bcb68c75e2296034b6e777241b27f47656c10bbdaca75f
|
Provenance
The following attestation bundles were made for verdikt_eval-0.3.1.tar.gz:
Publisher:
release.yml on muhammadmahadazher/Verdikt
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
verdikt_eval-0.3.1.tar.gz -
Subject digest:
0fa4c190306ed3d227b4e6c96e738ad4e9818357b6baf2b4f2bdce25348ae12f - Sigstore transparency entry: 2386195481
- Sigstore integration time:
-
Permalink:
muhammadmahadazher/Verdikt@47087c8435e05ffbd4d7b91b5566424599847dd6 -
Branch / Tag:
refs/tags/v0.3.1 - Owner: https://github.com/muhammadmahadazher
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@47087c8435e05ffbd4d7b91b5566424599847dd6 -
Trigger Event:
push
-
Statement type:
File details
Details for the file verdikt_eval-0.3.1-py3-none-any.whl.
File metadata
- Download URL: verdikt_eval-0.3.1-py3-none-any.whl
- Upload date:
- Size: 83.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
20dd9bb8043e714cccf33ca9da20304b8cfcdef4d6acefbb44115bebc8ae20b8
|
|
| MD5 |
328a998d947acaa0fa70127697cfe642
|
|
| BLAKE2b-256 |
7f063a328a07d9ecd23e8d55ea755d09bd231686b4c4e2ac4587af0b30cc7c0f
|
Provenance
The following attestation bundles were made for verdikt_eval-0.3.1-py3-none-any.whl:
Publisher:
release.yml on muhammadmahadazher/Verdikt
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
verdikt_eval-0.3.1-py3-none-any.whl -
Subject digest:
20dd9bb8043e714cccf33ca9da20304b8cfcdef4d6acefbb44115bebc8ae20b8 - Sigstore transparency entry: 2386195547
- Sigstore integration time:
-
Permalink:
muhammadmahadazher/Verdikt@47087c8435e05ffbd4d7b91b5566424599847dd6 -
Branch / Tag:
refs/tags/v0.3.1 - Owner: https://github.com/muhammadmahadazher
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@47087c8435e05ffbd4d7b91b5566424599847dd6 -
Trigger Event:
push
-
Statement type: