Code, configs and paper builders for Mulligan: Performance-Guided Data Collection for Efficient On-Robot Learning. Project page: mulligan.page. Paper: https://arxiv.org/abs/2610.05882.
Mulligan improves a robot policy over rounds of supervised deployment. Each round, an operator places the objects and takes over when the policy fails. Failures tend to concentrate in a small part of the initial-state space, so Mulligan starts each round's episodes at the previous round's observed failures and at states not yet tried, instead of sampling them uniformly. It also trains a value function on all collected data, failures included, and uses it to rerank sampled action chunks (HiL-IDQL). On three real tasks (Insert Marker, Thread Nut, Route Cable), scored on 2,550 blinded held-out episodes, and two simulated Square tasks (Square-Narrow, Square-Broad), this beats uniform initial-state sampling at the same collection budget.
This repository holds the learners, the simulation and real-robot pipelines, the RLPD and HiL-SERL
baselines, the locked start manifests and evaluation grids, the configs of every released checkpoint, and
the code that rebuilds every figure and table of the paper. The data and checkpoints are on Hugging Face
under mulligan (223 dataset and 180 model repositories). The Policy
Arena at arena.mulligan.page browses them.
Contents: What you can reproduce · Layout · Setup · Data and models · Run the experiments · Tests · License · Citation · Appendix
The paper's human-in-the-loop data cannot be collected again as it was, but everything downstream of the recorded data can be recomputed. The release supports four tiers:
- Rebuild a figure or table from frozen inputs, with one command, without a GPU or any account.
- Recompute its numbers from the public Hugging Face datasets.
- Retrain / re-evaluate from released datasets and checkpoints with pinned configs.
- Re-run the protocol: collect new data or run new robot evaluations with the released collection, routing, start-selection and blind-evaluation code. A new campaign gives new results.
The appendix lists the tiers each paper figure and table supports; docs/reproduce.md has the commands and compute.
- Human-in-the-loop collection. Every real and simulated DAgger round, and the HiL-SERL operator sessions, had a live SpaceMouse operator. A new operator makes different interventions, so the data and results differ. The released recordings are the paper's data. The simulation replay operator re-drives the recorded human segments headless; it reproduces the protocol, not the data.
- Real-robot evaluations. They depend on the physical scene, the operator who resets it and judges outcomes, and human-reconciled labels. The collectors and the blind evaluator are runnable protocol code for a new campaign (docs/real_robot_eval.md).
- Gemini stage labels (A10, A15). The labeling model changes over time; the release ships the frozen labels and the labeler.
- Archived-only results. A7, A20-A24 rebuild from archived aggregates only (tier 1).
- The paper's RLPD curves were run with robosuite 1.4.1; this release's simulator is robosuite 1.5.2, so an RLPD re-run can differ slightly (docs/baselines.md).
mulligan/ Python package: agents and networks, training, simulation (sim/), real robot (real/),
start-state selection (sampling/), baselines (baselines/), release manifests and verifier (release/)
paper/ figure and table builders (python -m paper.figures, paper/appendix/), frozen paper data, text statistics
configs/ every run setting: sim recipes (incl. the RLPD and HiL-SERL baselines) and start designs, real trainer/eval/sampler configs (configs/README.md)
scripts/ shell entry points: sim rounds and training, HiL-SERL operator sessions, robot environment
data/ locked start manifests: real (data/real/manifests) and simulation (data/sim/start_manifests)
release/ manifests of the public datasets and models with pinned revisions, the paper result points
robot/ separate uv project for the Franka/ZED robot workstation
arena/ Policy Arena: self-deploy web app (Convex backend, Python client) and the read-only snapshot
docs/ documentation (start at docs/README.md)
tests/ offline, network, gpu and hardware tests
One uv project (Python 3.12) covers everything except the robot
workstation: simulation, real-robot training and evaluation code, both baselines and the paper builders.
Supported platforms: Linux x86_64 and macOS arm64 (the only two in uv.lock).
# Debian/Ubuntu system packages (OpenCV, video decoding, headless MuJoCo)
sudo apt-get install -y libgl1 libglib2.0-0 ffmpeg libegl1
export MUJOCO_GL=egl # headless rendering; omit on a desktop or macOS
git clone <repository-url> mulligan && cd mulligan
uv sync --frozen # sim and real training/eval, both baselines, paper figures
uv sync --frozen --extra teleop # plus SpaceMouse teleopEach uv sync installs exactly the listed extras and removes the others, so name all you need in one
command. Extras: teleop (SpaceMouse and keyboard teleop in simulation),
stage-labeling (Gemini stage labeler, needs GEMINI_API_KEY), or --all-extras. Run the commands
below with uv run ... or after source .venv/bin/activate; the commands in this README omit uv run.
- GPU. On Linux, torch comes from the PyTorch CUDA 12.8 index, so GPU work needs a driver that
supports CUDA 12.8; the wheels also run on CPU. The simulation recipes train with bf16 autocast and need
an Ampere or newer GPU (docs/compute.md). jax preallocates 75% of
GPU memory; set
XLA_PYTHON_CLIENT_PREALLOCATE=falsewhen torch shares the GPU (the baseline trainers set it). - macOS. torch runs on CPU/MPS and jax on CPU. Interactive MuJoCo viewers (sim teleop, sim DAgger,
the HiL-SERL actor) need
mjpythoninstead ofpython:uv run mjpython -m mulligan.sim.collect.teleop .... FFmpeg:brew install ffmpeg. - Paper teaser. Chrome or Chromium (
MULLIGAN_CHROME, orgoogle-chrome/chromiumonPATH) and Ghostscript (gs). Without them, build the figures with--exclude teaser.
Lock details, pinned versions and the expected import warnings: docs/install.md.
The Franka/ZED station runs a second uv project, robot/, because the ZED SDK's pyzed 4.2 needs numpy
1.x. It installs this repository (editable, with the teleop extra) plus the pinned DROID fork, gym,
zerorpc and pyzed. It needs Linux x86_64, about 9 GB of disk, ZED SDK 4.2 under /usr/local/zed,
the DROID/Polymetis stack on the NUC (docs/hardware/droid_fork.md)
and a station file at ~/.config/droid/station.env
(configs/real/station.env.example).
bash scripts/sync_robot_env.sh # build or repair robot/.venv and check the imports
uv run --project robot --frozen python -m mulligan.real.eval.manifest_eval --helpAlways pass --project robot for robot commands. Station setup (cameras, DROID fork, SpaceMouse,
operator display): docs/station.md.
arena/ is the web app the project used for its real-robot evaluations (pairwise sessions,
Bradley-Terry leaderboards, outcome and stage labeling, dataset statistics, Hugging Face sign-in).
You can deploy your own, with your own Convex backend and a Vercel or Cloudflare Pages frontend
(docs/arena.md); the same tree also builds the read-only Mulligan snapshot. Both
need bun 1.3 and Node 20.19+, 22.13+ or 24:
cd arena
bun install --frozen-lockfile
bun test # backend, UI and release-adapter tests
bun run package:release data/release.json # the snapshot: type-check, build, stage in dist-release/Commands, layout and checks: arena/README.md.
# imports and the patched Square environments (prints "ok" after some expected robosuite warnings)
python -c "from mulligan.sim.envs import register_square_environments; register_square_environments(); import lerobot, torchcodec, torch, cv2; print('ok')"
python -c "import torch; print('cuda', torch.cuda.is_available())"
# start design: rebuilds the locked Square-Narrow R1 starts bit for bit (CPU, seconds)
python -m mulligan.sampling.sim_design --config configs/sim/square_narrow/r01_sampler.yaml --out outputs/check/narrow_r01 --check
# end to end on CPU (a few minutes): data, 200 training steps,
# a replayed DAgger round, a 10-start grid evaluation and a plot
pytest tests/sim/test_smoke.py -m networkAll data and checkpoints are public under the mulligan
organization; reads are anonymous (set HF_TOKEN only for your own private repos). Everything in this
repository reads a repo at its release pin, never at main.
IDs are lowercase kebab case, {task}-..., with two-digit indexes. Task tokens: real-marker-d2
(Insert Marker), real-square-d2 (Thread Nut), real-routing-d2 (Route Cable), sim-square-narrow,
sim-square-broad. cNN is a collection increment, rNN a model round, bNN a recorded evaluation
session.
| Kind | Pattern | Example |
|---|---|---|
| Raw collection | {task}-c00-teleop-mixed, {task}-cNN-dagger-mixed |
mulligan/real-marker-d2-c03-dagger-mixed |
| Training view of a collection | {task}-cNN-{teleop,dagger}-{arm} |
mulligan/sim-square-narrow-c01-dagger-mulligan |
| Round evaluation | {task}-rNN-eval (Cable: real-routing-d2-r00-r05-eval) |
mulligan/real-square-d2-r05-eval |
| Policy rollouts | {eval}-bNN-{arm}-policy-rollouts, sim {task}-cNN-{arm}-policy-rollouts |
mulligan/sim-square-narrow-c02-auto-iql-n32-policy-rollouts |
| Simulation evaluation bundle | {task}-r00-r03-eval |
mulligan/sim-square-broad-r00-r03-eval |
| Real DP actor / IDQL critic | {task}-rNN-{arm}-dp, {task}-rNN-mulligan-idql-critic |
mulligan/real-marker-d2-r05-mulligan-idql-critic |
Simulation agent (folders seed-1 ... seed-5) |
{task}-rNN-{arm}-{idql,divl} |
mulligan/sim-square-broad-r01-mulligan-divl |
Arms: baseline (HG-DAgger), mulligan (HG-DAgger+Mulligan), sobol (R0 Sobol demonstrations),
mulligan-no-cf (no-counterfactual ablation). The full table, roles, tiers and the Route Cable round axes
are in docs/data.md.
release/revisions.json maps every repo to its release revision (tag release-1, the single
commit of every dataset, model and paper-evidence repo). Model and checkpoint IDs are written
hf://<org>/<name>[@<revision>][/<subfolder>] e.g. hf://mulligan/sim-square-narrow-r01-mulligan-divl/seed-1
(hf://<org>/<name>[/<subfolder>][@<revision>] is accepted too). <revision> is a commit sha, a tag or
refs/pr/<N>. Without @<revision>, a mulligan/* repo resolves to its pin in release/revisions.json;
tests/release/test_revision_consistency.py fails if any file pins a mulligan/* repo elsewhere. The
manifests release/datasets.json and release/models.json list every repo with its training data,
evaluations and provenance (docs/data.md).
Trainers, evaluators and the verifier download what they need at the pinned revisions on first use, so downloading ahead is optional (useful on a cluster without network on the compute nodes):
python -m mulligan.release.download repo mulligan/real-marker-d2-r05-eval --include "meta/*" # one repo
python -m mulligan.release.download models --task real-marker-d2 --round 5 --dry-run # list, no download
python -m mulligan.release.download models --task real-marker-d2 --round 5 # Marker R5 checkpoints
python -m mulligan.release.download datasets --task real-marker-d2 --round 5 --meta-only # its datasets, metadata only
python -m mulligan.release.download datasets --task sim-square-narrow --round 1 # Square-Narrow R1 dataFilters (--task, --round, --method, --role for datasets, --kind for models) combine with AND.
python -m mulligan.sim.recipes show <recipe> and the dataset_revisions of a real config list the exact
training data of one checkpoint.
Cache. Checkpoints and download go to the huggingface_hub cache (HF_HUB_CACHE, default
~/.cache/huggingface/hub; --cache-dir or --local-dir DIR to put them elsewhere). LeRobot datasets that
the trainers read go to HF_LEROBOT_HOME (default ~/.cache/huggingface/lerobot). Outputs of the
pipeline scripts go to outputs/ (MULLIGAN_OUTPUT_DIR).
Simulation agent (IDQL or DIVL head; Best-of-N with num_action_samples candidates):
from mulligan.release.hub import resolve_checkpoint
from mulligan.utils.load_pretrained import load_policy_from_checkpoint
path = resolve_checkpoint("hf://mulligan/sim-square-narrow-r01-mulligan-divl/seed-1") # pinned revision
policy, preprocessor, postprocessor = load_policy_from_checkpoint(checkpoint_path=path, device="cuda")
policy.eval()python -m mulligan.sim.eval.grid_eval eval --artifact-path <hf:// id> ... runs it on an evaluation grid
(docs/sim.md).
Real DP actor, or an IDQL critic with the DP actor it reranks (Best-of-N):
from mulligan.real.policy.loader import load_policy_by_model_id
dp = load_policy_by_model_id("hf://mulligan/real-marker-d2-r05-mulligan-dp", policy_id=0, device="cuda")
bon = load_policy_by_model_id("hf://mulligan/real-marker-d2-r05-mulligan-idql-critic", policy_id=1, device="cuda")
bon.policy.num_action_samples = 32 # N as deployed in R5 (configs/real/marker_d2/r05_eval.yaml)A critic repo holds the critic only; its metadata.json names the DP actor it reranks and the loader
fetches that DP too. Each returned PolicyEntry holds the policy and its pre/post-processing
(docs/real_robot.md).
Every pipeline step below names the released artifact it produced. The training configs already read the
released datasets, and eval_cell.sh evaluates the released checkpoints by default, so you can start
anywhere:
| To skip | Use |
|---|---|
| Recording R0 demonstrations or a DAgger round | the *-c00-teleop-mixed / *-cNN-dagger-mixed datasets (the split training views are *-cNN-*-{arm}) |
| Splitting a collection | the per-arm training views, e.g. mulligan/sim-square-narrow-c01-dagger-mulligan |
| Training | the checkpoint repos (hf://mulligan/sim-...-{idql,divl}/seed-N, hf://mulligan/real-...-{dp,idql-critic}) |
| Simulation grid evaluation | the evaluation bundles mulligan/sim-square-{narrow,broad}-r00-r03-eval (per-start outcomes of every seed) |
| Start design | the locked starts in git: data/sim/start_manifests/, data/real/manifests/ |
| A real evaluation and its review | the round datasets mulligan/real-*-rNN-eval and mulligan/real-routing-d2-r00-r05-eval (reviewed outcomes, meta/episode_provenance.parquet) |
| Recomputing a paper number | release/paper-results.json and the frozen paper evidence |
Each track below is a numbered pipeline. Every step gives the command, its inputs, its outputs and the released artifact that replaces it. All commands run from the repository root. Compute budgets are in docs/compute.md; per-figure commands are in docs/reproduce.md.
Configs: configs/sim/recipes.json (58 training recipes, the RLPD and HiL-SERL baselines, the locked
grids, the round specs) and
configs/sim/<task>/rNN_sampler.yaml (configs/sim/README.md). A recipe id is the
model repo stem, e.g. square-narrow-r01-mulligan; python -m mulligan.sim.recipes list --paper "Fig. 5"
lists the recipes behind one paper result. <task> is square_narrow or square_broad; N is the round.
-
R0 data. Record the R0 demonstrations with a SpaceMouse (
uv sync --frozen --extra teleop; human, about 200/400 episodes), then split them by arm:python -m mulligan.sim.collect.teleop --r0-preset square_narrow # mjpython on macOS scripts/sim/split_round.sh --task square_narrow --round 0In:
data/sim/start_manifests/<task>/r00/. Out:outputs/sim/data/sim-<task>-c00-teleop-mixed, thenoutputs/sim/splits/. Released:mulligan/sim-<task>-c00-teleop-{mixed,baseline,sobol}. To split the released recording instead, fetch it first:python -m mulligan.release.download repo mulligan/sim-square-narrow-c00-teleop-mixed --local-dir outputs/sim/data. -
Train the round's agents (one GPU; per seed about 1.9 GPU-h Square-Narrow / 3.0 GPU-h Square-Broad for the IDQL agent, 0.65 / 0.97 GPU-h for the DIVL head):
scripts/sim/train_cell.sh square-narrow-r00-sobol --seed 1 # IDQL agent scripts/sim/train_divl_heads.sh square-narrow-r00-sobol --seed 1 --parent local # DIVL head on it
In: the recipe's datasets at their pins. Out:
outputs/sim/train/<recipe>/{idql_agent,divl_head}/seed-1/. Released:mulligan/sim-<task>-rNN-<arm>-{idql,divl}, folderseed-N. Without--seed, all five seeds. -
Evaluate on the locked grid (8,000 starts Square-Narrow, a few GPU-hours; 30,000 starts Square-Broad, run as 4 shards):
scripts/sim/eval_cell.sh square-narrow-r00-sobol --seed 1 --checkpoint local # your checkpoint scripts/sim/eval_cell.sh square-narrow-r01-mulligan --seed 1 --shard 1 --n-shards 160 # released, 50 starts
The grid is generated and hash-checked on first use. Out:
outputs/sim/eval/<recipe>/<stage>/seed-N/n<N>/results.json. Released: the bundlesmulligan/sim-<task>-r00-r03-eval. -
Policy rollouts for the next start design (one GPU, within 4 h per 100 episodes):
scripts/sim/collect_round.sh rollouts --task square_narrow --round 1. In: the previous round's seed-1 agents. Out:outputs/sim/data/sim-<task>-cNN-*-policy-rollouts. Released: the same names undermulligan/. -
Start design (Alg. 2) (CPU, seconds):
python -m mulligan.sampling.sim_design --config configs/sim/square_narrow/r01_sampler.yaml --out outputs/sim/design/narrow_r01 --check
In: the rollout audits and earlier starts in
data/sim/start_manifests/. Out: the round's start lists;--checkcompares them with the locked files bit for bit. Released:data/sim/start_manifests/<task>/rNN/. -
Blinded DAgger round with a SpaceMouse operator, or replaying the recorded human segments headless:
scripts/sim/collect_round.sh dagger --task square_narrow --round 1 # human scripts/sim/collect_round.sh dagger --task square_narrow --round 1 --operator replay # headless scripts/sim/split_round.sh --task square_narrow --round 1
In: the locked starts and the previous round's agents (N=32). Out:
outputs/sim/data/sim-<task>-cNN-dagger-mixed, its ledger inoutputs/sim/ledgers/(the split needs it; the released mixed collections do not carry one), and the per-arm splits. Released:mulligan/sim-<task>-cNN-dagger-{mixed,baseline,mulligan,mulligan-no-cf,...}. The autonomous baselines collect withscripts/sim/collect_round.sh autonomous <recipe>. -
Next round. For N = 0, 1, 2: train and evaluate round N (steps 2-3, recipes
square-narrow-r0N-*,square-broad-r0N-*), then collect round N+1 (steps 4-6). Finish with steps 2-3 for R3. -
Aggregate. The paper reports the mean over five training seeds of each seed's success rate, with a Student-t 95% interval over seeds:
import json from mulligan.release.verify_results import sim_rule seeds = [] for s in range(1, 6): r = json.load(open(f"outputs/sim/eval/square-narrow-r01-mulligan/divl_head/seed-{s}/n32/results.json")) seeds.append((round(r["overall_success_rate"] * r["num_points"]), r["num_points"])) print(sim_rule(seeds)) # (mean, low, high)
All 58 recipes x 5 seeds are about 900 GPU-h. Evaluations are unseeded by default;
--eval-seed S --env-seed S makes a re-run repeatable (docs/sim.md).
Pipeline details, the replay operator and R0 flags: docs/sim.md.
Configs: configs/real/<task>/ with <task> in marker_d2, square_d2, routing_d2: one trainer
config per released checkpoint (rNN_<arm>_dp.yaml, rNN_critic.yaml), one rNN_eval.yaml per evaluated
round and one rNN_sampler.yaml per Marker/Nut collection round (configs/real/README.md).
Each trainer config's paper block says which figure, round, arm and evaluation sessions it belongs to.
In repo names below, <task> is written marker-d2, square-d2 or routing-d2. The examples use
Insert Marker. Collection and evaluation need the robot environment and a station; training needs only
the main environment and one GPU.
-
R0 demonstrations (robot, human): teleoperate from the locked R0 start list, then split by arm.
uv run --project robot --frozen python -m mulligan.real.collect.teleop --task-name marker_d2 --save-data \ --dataset-name marker-r0 \ --initial-states-manifest data/real/manifests/marker_d2/r00/marker_d2_r0_three_arm_uniform100_sobol100_evalsobol50_scrambled.json python -m mulligan.real.data.split --source-repo <you>/marker-r0 --source-root ./data/marker-r0 \ --ledger ./data/marker-r0/meta/teleop_manifest_ledger.jsonl --split-key manifest_source \ --expected-success-per-arm baseline_uniform=100,mulligan_sobol=100,eval_heldout=50 \ --target baseline_uniform=<you>/marker-r0-baseline --target mulligan_sobol=<you>/marker-r0-sobol \ --target eval_heldout=<you>/marker-r0-validation --output-root outputs/real/splitsOut:
./data/marker-r0and one dataset per start source (the held-out 50 become the validation view). Released:mulligan/real-<task>-c00-teleop-{mixed,baseline,sobol,validation}. -
Train the round's DP actors and IDQL critic (one GPU each; DP median 3.1-4.3 h, critic 3.7-6.6 h):
python -m mulligan.real.train.launch configs/real/marker_d2/r05_mulligan_dp.yaml --output-dir outputs/real/marker_r05_dp python -m mulligan.real.train.launch configs/real/marker_d2/r05_critic.yaml --output-dir outputs/real/marker_r05_critic
In: the pinned datasets and episode selectors of the config.
--print-argvshows the trainer command; flags after--override it. Released:mulligan/real-<task>-rNN-<arm>-dp,mulligan/real-<task>-rNN-mulligan-idql-critic. -
Blind held-out evaluation (robot, human resetting the scene; 50 starts per policy):
uv run --project robot --frozen python -m mulligan.real.eval.manifest_eval --environment marker_d2 \ --initial-states-manifest data/real/manifests/marker_d2/r05/marker_d2_r5_eval_heldout_independent_sobol.json \ --fixed-policy baseline=hf://mulligan/real-marker-d2-r05-baseline-dp \ --fixed-policy mulligan=hf://mulligan/real-marker-d2-r05-mulligan-dp \ --fixed-policy iql=hf://mulligan/real-marker-d2-r05-mulligan-idql-critic --fixed-policy-num-action-samples iql=32 \ --random-seed 2026070704 --dataset-name marker-r5-heldout --hf-repo-id <you>/marker-r5-heldoutThe arms, manifest and seed of every paper session are in
configs/real/<task>/rNN_eval.yaml. Released:mulligan/real-<task>-rNN-eval. -
Outcome review:
python -m mulligan.tools.outcome_review --repo-id <you>/marker-r5-heldout --filter all --push(cv2 editor; corrects outcomes and marks outcome and sub-goal frames). Released datasets are already reviewed. -
Held-out ingest (CPU): pairs the arms by start and writes Wilson intervals, paired deltas, exact McNemar tests and a figure. It reads a released evaluation dataset at its pin, or your own eval repo at Hub
main:python -m mulligan.real.lifecycle.heldout_eval mulligan/real-square-d2-r02-eval --out outputs/real/heldout/square_d2_r02 python -m mulligan.real.lifecycle.heldout_eval <you>/marker-r5-heldout --out outputs/real/heldout/marker_r5
HeldoutEvalConfig+runis the Python API (sub-goal scoring, custom labels);python -m mulligan.real.lifecycle.routing_d2_lineagerebuilds the six Cable rounds with sub-goal scores (docs/real_robot_eval.md). The released round datasets also hold the paired outcomes directly (meta/episode_provenance.parquet). -
Stage labels (optional; Gemini,
--extra stage-labeling):python -m mulligan.real.stage_labeling.prepare_events, then...stage_labeling.labeland...stage_labeling.apply_cascade, thenpython -m mulligan.real.stage_labeling.stage_eval_batteryfor per-arm stage shares and rung conversions (the inputs of the per-stage results, App. B.7; mulligan/real/stage_labeling/DESIGN.md). The joined labels (labels_joined.csv) are also an input of the next start design. The paper's labels are frozen in the paper evidence. -
Start design (Alg. 2) for the next round (Marker and Nut), here the Marker R1 design from the R0 evaluation:
python -m mulligan.sampling.real_design --config configs/real/marker_d2/r01_sampler.yaml \ --inputs-root . --input eval_outcomes=<paired_round_outcomes.csv> --input stage_labels=<labels.csv> \ --out outputs/real/marker_r01_manifest.jsonRun it from the repository root: the earlier manifests the design reads ship in
data/real/manifests/(the config'sinputsname them relative to--inputs-root .). The evaluation outcome table and the stage labels are yours; repeat--inputto replace a list input such assupport_manifests. The locked manifests indata/real/manifests/are the released output. -
Blinded DAgger round (robot, operator with a SpaceMouse), then split by arm:
uv run --project robot --frozen python -m mulligan.real.collect.blind_dagger --task-name marker_d2 \ --arm baseline_uniform=hf://mulligan/real-marker-d2-r01-baseline-dp \ --arm mulligan_sobol=hf://mulligan/real-marker-d2-r01-mulligan-dp \ --initial-states-manifest data/real/manifests/marker_d2/r02/marker_d2_r2_promote25_fill25_blind_dagger.json \ --protocol-quota-targets no_cf=50,with_cf=50 \ --protocol-quota-arms 'no_cf=baseline_uniform,mulligan_sobol;with_cf=mulligan_sobol' \ --protocol-quota-selection-mode soft_weighted \ --protocol-quota-ledger ./data/marker-r2/meta/protocol_quota_ledger.jsonl \ --dataset-name marker-r2 python -m mulligan.data.split_protocol_quota --source-repo <you>/marker-r2 --source-root ./data/marker-r2 \ --manifest data/real/manifests/marker_d2/r02/marker_d2_r2_promote25_fill25_blind_dagger.json \ --ledger ./data/marker-r2/meta/protocol_quota_ledger.jsonl --expected-per-protocol no_cf=50,with_cf=50 \ --target no_cf.baseline_uniform=<you>/marker-r2-baseline --target no_cf.mulligan_sobol=<you>/marker-r2-mulligan-no-cf \ --target with_cf.mulligan_sobol=<you>/marker-r2-mulligan --output-root outputs/real/splitsThe protocol split gives the paper's per-arm training views (HG-DAgger: no-CF baseline episodes; HG-DAgger+Mulligan: with-CF; the no-CF ablation).
python -m mulligan.real.data.splitinstead splits a collection by arm only (--split-key arm_key). Released:mulligan/real-<task>-cNN-dagger-{mixed,baseline,mulligan,mulligan-no-cf}. -
Next round: train on the new views (step 2) and repeat steps 3-8 for R1-R5.
-
Final evaluations: the paper's evaluation of each round is the
rNN_eval.yamlsession list; Route Cable evaluated all six rounds in one 15-arm session on the lineage manifest (data/real/manifests/routing_d2/lineage/).
Collector options, loading and the Best-of-N path: docs/real_robot.md; evaluation details and outputs: docs/real_robot_eval.md; station: docs/station.md.
Both are recipes in configs/sim/recipes.json (python -m mulligan.sim.recipes list --family rlpd,
--family hilserl) and train with train_cell.sh like the IDQL recipes (docs/baselines.md).
-
RLPD, seeds 1-5 (one GPU; per seed 2.8-4.5 h for 300k Square-Narrow steps, 8.1-13.9 h for 1M Square-Broad steps):
scripts/sim/train_cell.sh square-narrow-rlpd --seed 1 # also square-broad-rlpd, square-narrow-rlpd-robomimic-ph, square-broad-rlpd-mimicgen-coreIn: the recipe's offline demos, fetched and hash-checked on first use. Out:
outputs/sim/train/<recipe>/rlpd_agent/seed-N/eval.jsonl(the learning curve). No checkpoints are released; the paper's curves are inpaper/data/online_rl/. -
HiL-SERL: a no-human run, a fork once its policy starts succeeding (150k Square-Narrow, 200k Square-Broad), then the operator session (learner and eval watcher on the GPU machine, actor at the SpaceMouse):
scripts/sim/train_cell.sh square-narrow-hilserl scripts/hilserl/fork.sh outputs/sim/train/square-narrow-hilserl/hilserl_agent/seed-1 outputs/hilserl/narrow_fork150k 150000 scripts/hilserl/learner.sh square-narrow-hilserl outputs/hilserl/narrow_fork150k scripts/hilserl/eval_watcher.sh square-narrow-hilserl outputs/hilserl/narrow_fork150k scripts/hilserl/actor.sh square-narrow-hilserl outputs/hilserl/narrow_fork150k <learner host>
CPU only; no accounts. The frozen paper evidence is read from the mulligan/paper-evidence dataset at its pin:
python -m paper.figures # 42 figure files -> paper/build/figs
python -m paper.appendix.build --check # appendix tables vs the manuscript; run after paper.figures
python -m paper.figures --check # figure bytes vs paper/figures_manifest.json
python -m paper.figures --only headline_real_sim # one figure; --list shows all entriesWithout the evidence, python -m paper.figures --keep-going builds what it can and lists the rest with
the reason; add --exclude teaser without Chrome. The numbers quoted in the text print with
python -m paper.stats.<module> (for example paper.stats.final_round, paper.stats.sim_contrasts).
Recompute the paper's numbers from the public data (CPU; about 0.5 GB, a few minutes cold):
python -m mulligan.release.verify_results --out report.jsonIt recomputes the 110 headline result points, the 2,550 evaluation episodes and the 988 / 1,009 collection successes from per-episode records and exits non-zero on any mismatch (docs/data.md). Figures and inputs: docs/paper_figures.md.
pytest -m "not network and not gpu and not hardware" -n 8 # offline suite, CPU (what CI runs)
pytest -m network # also reads public Hugging Face repos, anonymously
pytest -m gpu # needs CUDAMarkers: network (Hugging Face or other downloads, including the Arena build's bun install), gpu
(needs CUDA), hardware (robot or SpaceMouse) and slow. Tests that read the paper evidence read it from
MULLIGAN_PAPER_EVIDENCE (a local mirror) when set and are network tests otherwise; MULLIGAN_HF_CACHE=DIR shares one HF cache
across network runs; MULLIGAN_BITWISE_PARITY=1 makes the real-robot equivalence tests compare bitwise
(only on the machine class the references were recorded on). Details: docs/README.md.
The code is released under the MIT License (LICENSE). The Hugging Face datasets and models are MIT at their release revisions (docs/data.md).
MimicGen, a dependency used for the Square-Broad environment, is under the NVIDIA Source Code License,
which permits non-commercial use only (research or evaluation). uv sync installs it; commercial users
must remove it or obtain a license from NVIDIA. Vendored code (EXPO and RLPD, MIT) and other third-party
terms are listed in THIRD_PARTY_NOTICES.md. Upstream DROID, which the
robot project installs from a fork, states no license; the release ships none of its code.
@misc{mulligan2026,
title = {Mulligan: Performance-Guided Data Collection for Efficient On-Robot Learning},
author = {Lars Ankile and Perry Dong and Rohan Bhowmik and Aneesh Muppidi and David D. Yuan and
Shuran Song and Chelsea Finn},
year = {2026},
eprint = {2610.05882},
archivePrefix = {arXiv},
primaryClass = {cs.RO},
url = {https://arxiv.org/abs/2610.05882}
}The columns are the four tiers of What you can reproduce. "yes" means the tier works as described; "partial" is explained in the notes. Rows use the release IDs of docs/reproduce.md (F = main-text figure, A = appendix item), which has the commands and compute of each row; the paper section is in parentheses.
Tier 1 needs the frozen paper evidence for most rows ("yes (E)"): the builders read it from the
mulligan/paper-evidence dataset at its pin, or from a local copy at MULLIGAN_PAPER_EVIDENCE. Without
either (offline), these rebuild: Fig. 2, Fig. 3 (reads released videos from
Hugging Face), the Fig. 4 reset card, the 2,550 episode count, the A3 Cable
snapshot check, the A4 significance and A5 DIVL tables, the A18 RLPD ablation numbers, the A29 compute table and
the A30/A33 best-of-N checks, plus the Fig. 5 and Fig. 7 numbers quoted in the text
(paper.stats.final_round, paper.stats.sim_contrasts).
| Paper experiment | 1 Rebuild | 2 Recompute from HF | 3 Retrain / re-evaluate | 4 Re-run protocol | Notes |
|---|---|---|---|---|---|
| F1 teaser (Fig. 1) | yes (E) | - | - | - | Bucket counts digitized from a raster |
| F2 RLPD vs HiL-IDQL+Mulligan vs HiL-SERL (Fig. 2) | yes | - | RLPD: yes | HiL-SERL: yes | RLPD re-runs use robosuite 1.5.2 (see above) |
| F3 task sequences (Fig. 3) | yes (network) | - | - | - | Frames from released episodes |
| F4 reset card and ranges (Fig. 4) | card: yes; ranges: yes (E) | - | - | - | |
| F5 real headline (Fig. 5 top) | yes (E) | yes | yes | yes | 75 of 76 real checkpoints retrain from exactly their public training episodes; one is approximate |
| F5 simulation headline (Fig. 5 bottom) | yes (E) | yes | yes | yes | 58 recipes, 5 seeds each; DAgger rounds replay headless or run with a new operator |
| F6 throughput (Fig. 6) | yes (E) | partial | as F5 | as F5 | Success counts recomputed; durations from frozen evidence |
| F7 sim ablations, panels 1-2 (Fig. 7) | yes (E) | partial | yes | - | Per-seed counts in the public eval bundles |
| F7 real counterfactual ablation, panels 3-4 (Fig. 7) | yes (E) | - | yes | yes | |
| F8 collection success (Fig. 8) | yes (E) | yes | - | yes | 988 / 1,009 from the public collection ledgers |
| F9 intervention burden (Fig. 9) | yes (E) | - | - | yes | |
| N: 2,550 evaluation episodes | yes | yes | - | - | |
| Alg. 1 and Alg. 2 (Sec. 3, App. F) | - | - | sim starts: yes | yes | Sim start selection rebuilds the locked R1-R3 starts bit for bit; real locked manifests need recorded inputs not all in the release |
| A1, A2, A6 real detail, statistics, speed (App. B.1-B.3, D) | yes (E) | partial | as F5 | as F5 | Counts recomputed; paired tests from frozen evidence |
| A3 Cable snapshot and endpoint tests (App. B.2) | yes | partial | - | - | Cable points recomputed from the public label history |
| A4, A5, A8 sim significance, DIVL, sampler (App. B.2, J.4, B.5) | A4, A5: yes; A8: yes (E) | partial | yes | - | |
| A7 sim speed and throughput (App. B.4) | yes (E) | - | - | - | Archived aggregate |
| A9 Marker and Nut counterfactual ablation (App. B.6) | yes (E) | - | yes | yes | |
| A10, A15 substage, stage labels (App. B.7, C.3) | yes (E) | - | - | labeler only | Gemini labels are frozen; a new labeling run differs |
| A11-A13 collection, burden, data ledger (App. B.8-B.10, E.2) | yes (E) | partial | - | yes | |
| A14, A26-A28 authored tables and illustrations (App. C, E, F.4, H) | yes (E) | - | - | - | Authored |
| A16 RECAP (App. I.3) | yes (E) | - | - | - | The RECAP learner and recipe are not released |
| A17 HiL-SERL sessions (App. I.4) | yes (E) | - | no-human run | yes | Operator sessions give new results |
| A18 RLPD ablations (App. I.4) | yes | - | yes | - | Quoted numbers recomputed from the frozen per-seed eval curves |
| A19 real value learning, FQE (App. J.1) | yes (E) | - | critics: yes | - | FQE primitives only |
| A21-A23 critic objective, DIVL regimes, REDQ (App. J.3-J.5) | yes (E) | - | - | - | Archived aggregates |
| A24 bucket success rates (App. F.1) | yes (E) | - | - | - | Digitized from an archived raster |
| A25, A34 Sobol coverage, eval-grid term (App. F.2, F.5) | yes (E) | - | yes | - | The start design with the eval-grid term rebuilds the locked starts bit for bit |
| A29 training compute (App. G.7) | yes | - | - | - | Frozen run extract |
| A30, A33 best-of-N cost, choice of N (App. G.4, J.6) | yes | - | - | - | Frozen summaries, hash-checked |
| A32 critic training data (App. J.2) | yes (E) | - | yes | - | Human-only DIVL heads behind the released R3 agents |