Cross-Backend Config Guard

When replaying across backends (train on one backend, eval on another), UniLab automatically checks whether the target backend config is compatible with the policy contract captured at training time, so a mismatched config can’t silently load a broken policy. This happens with no manual steps.

An example that replays successfully

For go2_joystick_flat, the MuJoCo and Motrix owners agree on every guarded field, so a cross-backend replay passes directly:

# 1) Train in MuJoCo, producing a checkpoint
uv run train --algo ppo --task go2_joystick_flat --sim mujoco

# 2) Replay the same checkpoint across backends in Motrix — the guard passes, playback runs
uv run eval  --algo ppo --task go2_joystick_flat --sim mujoco --load-run -1

How the guard chain works

  1. At train time: ExperimentTracker snapshots the contract fields that define policy I/O into contract_snapshot in run_config.json (the checkpoint format is untouched, so historical checkpoints stay compatible).

  2. At replay time: eval loads the target backend owner config selected by --sim (e.g. src/unilab/conf/ppo/task/go2_joystick_flat/motrix.yaml) and injects training.play_only=true. If the task has no owner config for the requested backend, eval falls back to a sibling backend owner of the same task and re-applies the requested backend through the allowlisted training.sim_backend override (train still requires the owner config to exist); the guard chain below is unaffected.

  3. Before env creation: the play entrypoints (rsl_rl / appo / sac / flashsac) call resolve_sim2sim_config, comparing the target config against the source run’s contract snapshot field by field.

  4. At weight load: policy_load_dim_guard wraps checkpoint loading, re-raising cryptic tensor shape-mismatch errors as a clear sim2sim diagnostic.

What the guard covers

Fields are classified by dotted path into three tiers (see src/unilab/utils/sim2sim.py):

Tier

Behavior

Fields

DENYLIST

Mismatch → CrossBackendIncompatibleError, aborts

algo.obs_groups, Manager-Based env.observations / env.actions / policy and critic group mapping, algo.actor.hidden_dims / algo.critic.hidden_dims, algo.actor.obs_normalization / algo.critic.obs_normalization / algo.obs_normalization, env.commands.motion.params.sampling_mode, plus the historical snapshot key env.control_config.action_scale (retired from current owner configs)

WARNING_LIST

Prints a warning, continues

reward.*, env.control_config.simulate_action_latency

ALLOWLIST

Free to override, not checked

training.sim_backend, env.scene, training.play_steps, env.noise_config

When DENYLIST fields differ

If the target backend’s DENYLIST fields differ from training (e.g. a task whose two owners use different action_scale values), the guard aborts before env creation and lists the diverging fields. Two ways to resolve:

  • Align the contract (recommended): make the target owner’s DENYLIST fields match the training backend, then replay.

  • Force through (at your own risk): uv run eval ... training.sim2sim_strict=false downgrades DENYLIST mismatches to warnings. It does not bypass the snapshot-integrity checks below.

Minimum supported snapshot format: the guard fails closed (raises CrossBackendIncompatibleError) when the source run’s run_config.json is missing, is not valid JSON, has no contract_snapshot mapping, or contains keys outside the current contract schema — including the retired legacy keys env.sampling_mode, algo.policy.actor_hidden_dims, algo.policy.critic_hidden_dims, and algo.empirical_normalization, which are no longer aliased. Starting from the first release that ships this change, only canonical contract_snapshot mappings written by training runs on the 1.3.5 baseline schema are supported, and the breaking change is not backported to 1.3.5 patch releases. There is no offline migration tool: to reuse an older run, retrain the policy with the current version (checkpoint weights are unchanged) and replay from the new run.

Manager-Based snapshots store the complete typed observation and action declarations from Hydra. A snapshot from before those fields existed cannot prove that its policy I/O is equivalent to a Manager-Based target, so asymmetric presence fails closed. Set training.sim2sim_strict=false only as an explicit user override; the load-time dimension guard still remains active.

See also