Skip to content

Validating the physics

StableTested and in active use. Breaking changes will be called out.

A physics engine that is nearly right is worse than one that is obviously wrong, because the failure shows up as an agent that learns something slightly different. So physics3d is checked against live MuJoCo rather than against recorded fixtures.

Tests build the same model in both engines, put both in the same state, step both and compare. mujoco is a pixi dependency pinned at 3.12, so the reference runs in the same process — the tests call mj_step through Python interop, and a failure can be localised by reading MuJoCo’s own arrays next to ours.

Terminal window
pixi run mojo run -I . tests/physics3d/test_euler_fields_vs_mujoco.mojo
pixi run mojo run -I . tests/physics3d/test_humanoid_fk_vs_mujoco.mojo
pixi run mojo run -I . tests/physics3d/test_sensordata_vs_mujoco.mojo

tests/physics3d/ holds 271 test files; 131 of them are *_vs_mujoco gates against the runtime. The rest check what MuJoCo cannot answer for us: bit-exactness between code paths, GPU against CPU, solver convergence, batched against single-environment parity, loader diagnostics.

The environment ports carry their own layer on top. tests/dm_control/ has 69 test files, 41 of them *_vs_dm_control gates that compare model, physics, observation and reward against the reference task, and the robot ports have tests/robots/test_so_arm101_vs_mujoco.mojo and tests/robots/test_unitree_g1_vs_mujoco.mojo.

FamilyExamplesChecks
Kinematics*_fk_vs_mujoco, test_inertiafromgeom_vs_mujocobody frames, derived inertia
Integrationtest_euler_fields_vs_mujoco, test_implicitfast_free_body_vs_mujocoa full step per integrator
Collisiontest_box_box_sweep, test_mesh_manifold_vs_mujoco, test_hfield_vs_mujoco, test_contact_order_is_mujocoscontact sets, positions, depths, order
Equalitytest_connect_equality_vs_mujoco, test_weld_rows_pyramidal_vs_mujoco, test_joint_equality_vs_mujococonnect, weld, joint rows
Tendonstest_spatial_tendon_transmission_vs_mujoco, test_wrap_tendon_vs_mujoco, test_fixed_tendon_limit_vs_mujocolengths, Jacobians, wrapping, limits
Friction and nosliptest_elliptic_condim46_vs_mujoco, test_noslip_vs_mujoco, test_noslip_elliptic_vs_mujocoboth cones at every condim; the noslip pass
Compositiontest_attach_vs_mujoco, test_attach_angle_units_vs_mujoco<attach> expansion against MuJoCo’s own flattening
Sensors and raystest_sensor_table_vs_mujoco, test_frame_sensors_vs_mujoco, test_ray_model_vs_mujocosensordata layout and values, mj_ray
Loadertest_validate_vs_mujoco, test_silent_attrs_are_loudwhat MuJoCo refuses, we refuse; what we cannot model, we say

Forward kinematics is checked first and separately, because everything else depends on it. If body frames are wrong, every downstream comparison is meaningless.

Terminal window
pixi run test-smoke # fast curated CPU gates — what CI runs
pixi run test-cpu # every non-GPU test; hours
pixi run test-physics3d-mujoco # the live 3.12 gates; ~2 h, local
pixi run -e nvidia test-physics3d-gpu # blocked-kernel gates vs the CPU oracles
pixi run -e apple test-gpu # all *_gpu tests on Metal
pixi run -e nvidia test-gpu # all *_gpu tests on CUDA

test-physics3d-mujoco runs the manifest in tests/manifests/physics3d-mujoco.txt: the audit’s own gates plus the parser, collision, constraint, ray and sensor gates. Each file compiles for several minutes, so budget two hours. test-physics3d-gpu (tests/manifests/physics3d-gpu.txt) needs no MuJoCo at all: it holds the blocked NVIDIA kernels and the cooperative kernels against the CPU path, mostly bit for bit.

The engine was audited against the 3.12.0 sources subsystem by subsystem, each row confirmed against the runtime, and the loader was then taught to count and print every MJCF feature it does not serve. The measure of coverage is therefore not a feature list but a tally: load every model in the tree and count the prints.

On 2026-09-13 that tally was zero across 83 Menagerie scenes and every dm_control asset, with test_validate_vs_mujoco passing on all of them.

That zero is a statement about the models, not the engine. Features nothing in those trees declares are still unserved — flex, SDF geoms, several actuator types, 21 sensor kinds among them; see 3D physics.

MuJoCo releases disagree on behaviour, not only on style: 3.12 widened the multi-contact alignment tolerances, rewrote the box-box collider and changed how mesh support points are seeded. When debugging a mismatch, read the source of the version the runtime actually is, and diff the routine across releases before transcribing it.

Terminal window
pixi run python -c "import mujoco; print(mujoco.__version__)" # 3.12.0

The development tree keeps several releases’ sources side by side in a local, untracked references/ directory for that purpose. The same directory holds the reference model trees (MuJoCo Menagerie, dm_control), and many live gates read their XML from it — which is one reason those gates run locally and not in CI.

The environments carry their own comparison path: the Gymnasium wrappers run the Python original in the same process, so a native environment and its reference can be stepped side by side, and every DeepMind Control task has a parity gate against MuJoCo and the dm_control reference task.

The Atari 2600 emulator plays the same role for the native arcade engines — the emulator runs original ROMs, so the reimplementations are checked against real hardware behaviour rather than against a plausible-looking training curve.

Where the environments deliberately differ

Section titled “Where the environments deliberately differ”

Not every divergence is a bug. Documented ones:

ItemDifference
Humanoid observation45 dimensions, omitting contact forces and inertia (Gymnasium: 348–376)
HalfCheetah angle penalty0.0, matching dm_control, not the earlier 0.5
CarRacing frictionslip-based tire model, harder than Gymnasium’s

A workable order:

  1. Forward kinematics first. If body frames differ, stop there.
  2. Check the model build — inertia, total mass, mesh hulls, the contact set at the start state — before suspecting the solver.
  3. Check what the reference converged to. A model that ships a low <option iterations> stops MuJoCo early; compare against MuJoCo run to convergence as well as MuJoCo as shipped.
  4. Isolate the integrator. Step once with contacts disabled.
  5. Only then the solver. Contact solving is the most likely place for a legitimate difference, since cone choice and iteration count both matter.

For gradient-level debugging elsewhere in the stack, finite differences need eps ≈ 1e-2 on deep chains, and must never run on TF32.