The VLA evaluation grammar¶
How the research community actually evaluates VLA policies on benchmarks — the shared pattern, the one-to-one translations per (model × benchmark), and the canonical terminology. Extracted 2026-07-01 from the real eval entry points of openpi, starVLA (VLA-JEPA's base), openvla, lerobot, and allenai/vla-evaluation-harness, verified against files, not memory. This is the spec our harness aligns to.
1. The nine components (every framework has all nine; only the names differ)¶
| Component | openpi | starVLA | OpenVLA | lerobot | vla-eval (allenai) |
|---|---|---|---|---|---|
| Checkpoint | train-config name + ckpt dir | --ckpt_path (HF id/dir) |
pretrained_checkpoint |
--policy.path |
checkpoint/config_name (YAML) |
| Normalization stats | "norm stats" assets/<asset_id>/norm_stats.json |
dataset_statistics.json + server-side PolicyNormProcessor; unnorm_key |
unnorm_key into model.norm_stats (baked in ckpt) |
dataset stats + Normalizer/UnnormalizerProcessorStep |
unnorm_key/unnorm_type passthrough |
| Policy wrapper | Policy + LiberoInputs/Outputs |
PolicyServerWrapper + model2{bench}_client.py |
get_model/get_vla_action |
PreTrainedPolicy |
ModelServer.predict() |
| Serving split | policy server (websocket+msgpack, handshake metadata) | same pattern, port 10093 | in-process | in-process (lerobot-eval) |
mandatory vla-eval serve + Docker benchmark |
| Env construction | LIBERO benchmark_dict[suite](); gymnasium.make |
identical LIBERO code | identical (the origin) | EnvConfig registry → make_env |
Benchmark.get_tasks() |
| Chunk handling | action_horizon, ActionChunkBroker, replan_steps / open_loop_horizon |
action_chunk_size, AdaptiveEnsembler |
none (chunk of 1) | chunk_size vs n_action_steps |
chunk_size + action_ensemble (newest/average/ema) |
| Success criterion | env done |
same | same | is_success terminal info |
EpisodeResult = {"success": bool} |
| Trials × seeds | 50 trials/task, seed=7, init_states[episode_idx], num_steps_wait=10 |
identical | identical (origin) | eval.n_episodes, seed=1000 |
episodes_per_task=50, params.seed=7 |
| Recording | mp4/episode + console SR | mp4 + logs | wandb + mp4 | videos + JSON | SQLite keyed on eval_id, shard merge |
The invariant that dominates everything (stated loudest by starVLA's client-side
[TRAIN/TEST CONSISTENCY CHECK] banner): eval-time observation construction must byte-match
training-time preprocessing — image size/rotation/crop, camera count and order, state
on/off and dim order, unnorm stats, chunk size. Mismatches never raise; they surface as a
mysteriously low success rate. This is the pain the per-step rollout schema exists to make
debuggable.
Canonical LIBERO protocol constants (OpenVLA-origin, inherited verbatim by openpi/starVLA/vla-eval):
50 trials/task · seed=7 · num_steps_wait=10 dummy steps · 256px render, 180° rotation,
pad-resize 224 · max_steps: spatial 220 / object 280 / goal 300 / libero_10 520 / libero_90 400
(we use 250 for spatial as the non-truncating cross-policy cap — VLA-JEPA's published cap; documented deviation).
2. One-to-one translations: {π0, OpenVLA, VLA-JEPA} × {LIBERO, ALOHA-sim, DROID}¶
Structural fact first: DROID has no simulator. DROID eval = the physical Franka rig
(droid.robot_env.RobotEnv, success judged by a human) or community alternatives (RoboArena
distributed real-robot eval; nascent real-to-sim efforts). SimplerEnv covers Google-Robot +
WidowX/Bridge only — not DROID. DROID's role in a modern stack is data (ingest, fine-tune,
analyze), not benchmark. ALOHA's sim story is gym-aloha (AlohaTransferCube/Insertion).
| LIBERO (robosuite/MuJoCo) | ALOHA-sim (gym-aloha) | DROID | |
|---|---|---|---|
| π0/π0.5 (openpi) | ✅ pi05_libero (SOTA) |
✅ pi0_aloha_sim |
⚠️ real-robot only (pi05_droid et al.) |
| OpenVLA | ✅ openvla-7b-finetuned-libero-* |
❌ can't express 14-dim bimanual (OFT territory) | ⚠️ no ckpt; base evals via SimplerEnv proxies |
| VLA-JEPA (starVLA) | ✅ three routes (below) | ❌ no ckpt, no starVLA ALOHA bench | ❌ no sim, no ckpt |
π0 × LIBERO¶
- Serve:
uv run scripts/serve_policy.py --env=LIBERO policy:checkpoint --policy.config=pi05_libero --policy.dir=gs://openpi-assets/checkpoints/pi05_libero - Client obs dict:
observation/image(agentview 256 → rot180 → pad-resize 224),observation/wrist_image,observation/state= concat(eef_pos, quat2axisangle(eef_quat), gripper_qpos) → 8-dim,prompt - Action: 7-dim delta-EEF+gripper; chunk
action_horizon=10, executereplan_steps=5 - Env: LIBERO OffScreenRenderEnv, protocol constants above
π0 × ALOHA-sim¶
- Serve:
serve_policy.py --env=ALOHA_SIM(ckptpi0_aloha_sim) - Env:
gymnasium.make("gym_aloha/AlohaTransferCube-v0", obs_type="pixels_agent_pos") - Obs:
{"state": 14-dim joints, "images": {"cam_high": 224 CHW}}; action 14-dim joints, 50 Hz,ActionChunkBroker(action_horizon=10)
π0 × DROID (real rig; documented for completeness)¶
RobotEnv(action_space="joint_velocity", gripper_action_space="position"), 15 Hz- Obs: exterior ZED + wrist cam (pad-resize 224) +
joint_position(7) +gripper_position(1) - Action: 8-dim (7 joint-velocity + gripper pos);
open_loop_horizon=8; norm-stats asset_iddroid
OpenVLA × LIBERO (implemented in this repo — harness/policies/openvla.py)¶
- In-process:
AutoModelForVision2Seq+predict_action(unnorm_key=<suite>[_no_noops], do_sample=False) - Single 224 image (rot180, center-crop area 0.9 if trained w/ augs), no proprio, no chunking
- Gripper: normalize [0,1]→[-1,1] then invert (RLDS 0=close vs env -1=open)
VLA-JEPA × LIBERO (implemented — harness/policies/vla_jepa.py = route 1)¶
- starVLA policy server (the official repo path):
deployment/model_server/server_policy.py+ websocket client; server-side unnorm viadataset_statistics.json;{"image": [agentview, wrist], "lang": str}@224; chunk from ckpt config; DDIM 10 steps - allenai vla-eval:
configs/model_servers/starvla/*.yaml+ LIBERO benchmark configs - lerobot now ships
policies/vla_jepa/(port of ginwind/VLA-JEPA;chunk_size=7) + aliberoenv type →lerobot-eval --env.type=liberowith no starVLA server infra
3. Terminology lexicon (use these words)¶
- episode: the universal eval unit. rollout = the act of running one. trajectory = a dataset record (e.g. DROID
trajectory.h5). Don't interchange. - benchmark → task suite → task → episode/trial: the hierarchy (vla-eval formalizes it; LIBERO's own code confusingly calls suites "benchmarks").
- success rate (SR): per-task, then aggregate. CALVIN uses avg rollout length instead.
- trials:
num_trials_per_task(=episodes_per_task); canonical 50. - action chunk: the predicted action sequence. Predicted length:
action_horizon(openpi) /action_chunk_size(starVLA) /chunk_size(lerobot). Executed-before-replan:replan_stepsoropen_loop_horizon(openpi) /n_action_steps(lerobot). Overloaded even within openpi — always disambiguate predicted vs executed. - proprio(ceptive) state:
observation/state(openpi) /agent_pos(gym) /observation.state(lerobot). OpenVLA takes none. - embodiment: robot platform + action-space identity (openpi encodes as norm-stats
asset_id:franka,trossen,droid, ...). - unnorm_key / dataset statistics: OpenVLA coined
unnorm_key; starVLA + vla-eval adopted verbatim; openpi says "norm stats". - policy server / remote inference: openpi's coinage; websocket+msgpack+handshake-metadata is the de-facto standard (starVLA port 10093; vla-eval mandates the split).
- init states: LIBERO's fixed per-task initial states, indexed by episode for reproducibility.
- language instruction:
prompt(openpi) /lang(starVLA) /task_description(vla-eval, SimplerEnv) /task(lerobot). - closed-loop eval vs vla-eval's "realtime"/Sim2Live (env keeps moving during inference latency).
- SimplerEnv: "visual matching" and "variant aggregation" — the two named real-to-sim setups.
4. Where this repo maps on¶
| Grammar component | Here |
|---|---|
| Policy wrapper | harness/rollout/policy.py::Policy (reset/act) |
| In-process route | policies/openvla.py |
| Policy-server route | policies/vla_jepa.py (websocket client, server-side style unnorm) |
| Env construction + protocol | rollout/libero_runner.py + config.py (canonical constants) |
| Chunk handling | inside each policy adapter (one action out per act) |
| Recording | writer.RolloutWriter → per-step ROLLOUT_SCHEMA parquet (+frames/mp4) |
| Trials × seeds | RolloutConfig (50 trials, seed 7, init_state_id in the episode key) |
What the per-step parquet adds that none of the five frameworks record: the failure forensics layer — every step's action/gripper/eef/object state queryable after the fact, so "SR dropped" decomposes into policy failures (re-grasp, drop) vs harness failures (bad unnorm ⇒ saturated actions; bad init state ⇒ object teleport; preprocessing drift) instead of a silent scalar.
5. Our stance: in-process by default, no policy server¶
The ecosystem's websocket "policy server" solves three problems we don't have:
1. Real-robot network boundary (openpi's origin) — out of scope; our evals are sim.
2. Dependency isolation (starVLA's reason: model env vs benchmark env) — disproven for our
cases: our A100 run executed robosuite + MuJoCo EGL + torch 2.2 + a 7B VLA in ONE process
(see NOTES.md), and lerobot ships vla_jepa + a libero env in one package.
3. Batched serving at scale (vla-eval's shard×server design) — @daft.cls(gpus=1.0) on a
Modal GPU is the model server: Daft does placement/concurrency/batching over episode-spec
rows; Modal does GPU provisioning. The container boundary replaces the socket.
For Genesis a socket is actively harmful: its native mode is GPU-parallel batched envs sharing the CUDA context with the policy — observations never leave the device. In-process is the only pattern that preserves that (and enables a future batched runner: one UDF call = B episodes stepping in lockstep in one Genesis scene).
The Policy ABC is the seam. In-process adapters are the default route for every model we
target: OpenVLA (HF predict_action), VLA-JEPA (the lerobot policies/vla_jepa port), π0
(openpi's policy_config.create_trained_policy(...).infer(obs) — the server is just a wrap
around this). The websocket client adapter is retained as an escape hatch: for models whose
env genuinely cannot co-install, and to evaluate anything already served by an openpi/starVLA
server without porting it. Envs sit behind the runner's gym-shaped seam
(set_init_state/step/check_success/close) — MuJoCo/robosuite today, Genesis as a second
backend later.
Positioning vs allenai/vla-eval: they buy generality with a mandatory network boundary and per-benchmark Docker; we buy reproducibility with one opinionated process — Python, Daft, Modal, PyTorch, MuJoCo/Genesis — where the whole evaluation is a DataFrame operation. Their protocol stays reachable through the adapter if we ever want to sit behind their harness. Trade named honestly: a sim segfault kills the worker, not an env client — absorbed by one-parquet-part-per-episode (crash loses nothing) + Daft/Modal row-level retries.