Metadata-Version: 2.4
Name: mjcf-doctor
Version: 0.2.1
Summary: Catch the MuJoCo model errors the engine accepts silently — it loads, it renders, and it is still wrong
Author: ericzhubonnie-glitch
License: MIT
Project-URL: Homepage, https://github.com/ericzhubonnie-glitch/mjcf-doctor
Keywords: mujoco,mjcf,robotics,simulation,debugging,linter
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: License :: OSI Approved :: MIT License
Classifier: Intended Audience :: Science/Research
Classifier: Topic :: Scientific/Engineering
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: mujoco>=3.1
Requires-Dist: numpy>=1.21
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Dynamic: license-file

# mjcf-doctor

[![ci](https://github.com/ericzhubonnie-glitch/mjcf-doctor/actions/workflows/ci.yml/badge.svg)](https://github.com/ericzhubonnie-glitch/mjcf-doctor/actions/workflows/ci.yml)
[![pypi](https://img.shields.io/pypi/v/mjcf-doctor)](https://pypi.org/project/mjcf-doctor/)
[![python](https://img.shields.io/pypi/pyversions/mjcf-doctor)](https://pypi.org/project/mjcf-doctor/)

**Your MuJoCo model loaded fine. That doesn't mean it's correct.**

`mjcf-doctor` checks the things the engine silently accepts but that will bite you at runtime — a collision mask that makes nothing collide, contacts so soft they let bodies pass through each other, inertia values off by 10⁶ because someone exported kg·mm² instead of kg·m².

```console
$ mjcf-doctor check hello_robot_stretch_3/scene.xml

scene.xml  — 1 error, 4 warning
  ERROR initial_penetration    base_link:geom9 / link_DW3_wrist_pitch:geom87
                               48.28 mm overlap at keyframe 0 (205% of the geom size)
  WARN  initial_penetration    link_right_wheel:geom34 / link_DW3_wrist_pitch:geom87
                               14.02 mm overlap at keyframe 0 (112% of the geom size)
  WARN  runtime_penetration    base_link:geom9 / link_DW3_wrist_pitch:geom87
                               up to 48.28 mm penetration between moving links
  INFO  contact_default_soft   <global>   contact time constant 0.02s (engine default)
```

That is a real finding from a real model in Google DeepMind's Menagerie, now filed upstream as [mujoco_menagerie#341](https://github.com/google-deepmind/mujoco_menagerie/issues/341): at the pose every rollout starts from, 48 mm of the wrist sits inside the base's collision hull, producing **398 kN** of internal contact force on a 23.7 kg robot and throwing the wrist joint to 37 rad/s on the first step. See [`AUDIT.md`](AUDIT.md) for all 82 models.

![hello_robot_stretch_3: 398 kN of self-contact at t=0, gone with three excludes](https://raw.githubusercontent.com/ericzhubonnie-glitch/mjcf-doctor/main/docs/stretch3_before_after.png)

## Why this exists

MuJoCo validates a lot at load time — it will reject zero-mass bodies, non-positive inertia, zero joint axes, degenerate geometry, and a thousand other things. The error messages are honest about it.

The problem is what it *doesn't* reject:

| What you wrote | MuJoCo's response | What actually happens |
|---|---|---|
| `contype=1 conaffinity=0` on everything | loads fine | nothing ever collides; objects fall through the floor |
| `diaginertia="1e-9"` | loads fine (silently truncated to 0) | that link rotates uncontrollably |
| Two links overlap by 30 mm at t=0 | loads fine | the solver fires a ~1 kN repulsive force |
| A concave visual mesh reused as the collision geom | loads fine | collision happens on the convex hull — the cavity is filled in |
| Default `solref="0.02 1"` in a peg-in-hole task | loads fine | the peg sinks 2 mm into the hole wall |

None of these produce a warning. You find out three hours later when your policy won't converge.

## Install

```bash
pip install mjcf-doctor
```

Requires Python >= 3.10 (MuJoCo stopped publishing cp39 wheels).

## Usage

```bash
mjcf-doctor check robot.xml              # full check (static + 1-2 s of simulation)
mjcf-doctor check models/                # scan a whole directory
mjcf-doctor check robot.xml --static     # static rules only, no simulation
mjcf-doctor check robot.xml -v           # show the *why* and the *fix* for each finding
mjcf-doctor check robot.xml --json       # machine-readable output for CI

mjcf-doctor audit mujoco_menagerie/      # scan a whole model collection, aggregate stats
mjcf-doctor audit models/ --html out/report.html --markdown out/report.md --json out/report.json
```

Exit code is `1` if any ERROR is found, so it drops straight into CI:

```yaml
- run: mjcf-doctor check assets/robot.xml
```

## `--fix`: the one class of finding that is safe to rewrite

Most of what this tool reports should not be machine-edited. A soft `solref`, a
`ctrlrange` wider than the joint, a large `timestep` — the right value is a design
decision, and a tool that silently picks one is doing your engineering for you.
A body that starts 48 mm inside another body is different: the fix is almost always
one line of `<exclude>`, and it is mechanical.

```bash
mjcf-doctor check robot.xml --fix --dry-run   # print the diff, write nothing
mjcf-doctor check robot.xml --fix             # append the <exclude> lines
```

It only touches self-collisions between links of the same kinematic tree, only
when the overlap is deeper than 20 mm (the ERROR cut-off), and it never edits,
reorders, or deletes anything else in the file — the diff you see is the whole
change. Pairs that merely graze each other are left alone: for a dexterous hand
whose fingers interpenetrate 8 mm at `qpos0`, excluding finger-vs-finger contact
would be the wrong repair — the initial pose is.

On `hello_robot_stretch_3` from Menagerie this writes exactly the three lines
discussed in [issue #341](https://github.com/google-deepmind/mujoco_menagerie/issues/341):

```diff
     <exclude body1="rubber_tip_right" body2="rubber_tip_left"/>
+    <exclude body1="base_link" body2="link_DW3_wrist_pitch"/>
+    <exclude body1="link_DW3_wrist_pitch" body2="link_right_wheel"/>
+    <exclude body1="link_DW3_wrist_pitch" body2="link_aruco_right_base"/>
   </contact>
```

and the first simulation step goes from 14 contacts / 68 kN to one caster resting
on the floor.

## Audit a whole collection

```console
$ mjcf-doctor audit ~/mujoco_menagerie --duration 1.0 --html audit.html

82 model(s)  (82 files, 0 failed to load, 0 fragments skipped)
1 model(s) with errors  12 with warnings  ·  1 errors, 20 warnings total

findings by rule
  INFO  contact_default_soft       74 hits   74/82  models ██████████ 90%
  INFO  mesh_collision_hull        36 hits   36/82  models █████ 44%
  INFO  environment_compliance     24 hits   24/82  models ███ 29%
  INFO  no_limit                   85 hits   19/82  models ██ 23%
  WARN  ctrlrange_over_joint       96 hits   14/82  models ██ 17%
  WARN  timestep_large              5 hits    5/82  models █ 6%
  WARN  runtime_penetration         4 hits    4/82  models █ 5%
  ERROR initial_penetration         6 hits    2/82  models █ 2%
```

`--markdown` and `--json` write the same numbers in other formats; `--html` produces
a single self-contained file with the tables and bar charts, no CDN, no JS.

## What it checks

**Static** (model only, no simulation)

| Code | Level | Catches |
|---|---|---|
| `collision_matrix_empty` | ERROR | `contype`/`conaffinity` bitmasks that and to zero for every pair — nothing can ever collide. Silent when the model defines `<pair>` contacts or has no free joint |
| `inertia_too_small` / `inertia_too_large` | ERROR / WARN | inertia inconsistent with mass, measured as radius of gyration `sqrt(I/m)`; catches the kg·mm² → kg·m² unit error |
| `ctrlrange_over_joint` | WARN | position targets outside the joint's travel (motor actuators are skipped — their `ctrlrange` is in newtons) |
| `parent_child_contact` | WARN | self-collision enabled between adjacent links (`filterparent` disabled) |
| `timestep_large` | WARN | step size that will overshoot stiff contacts |
| `soft_contact` | WARN | `solref` time constant deliberately set above the 0.02 s engine default |
| `contact_default_soft` | INFO | still on the stock `solref` — fine for locomotion, wrong for sub-millimetre work |
| `mesh_collision_hull` | INFO | detailed mesh used as the collision geom: costlier to build, and concavities get filled by the hull |
| `no_limit` | INFO | driven joints with no range |
| `ctrl_unlimited` | INFO | actuators with no `ctrlrange` |
| `collision_disabled` | INFO | no pair can collide, but nothing floats either — probably a kinematics demo |

**Dynamic** (actually runs the simulation)

| Code | Level | Catches |
|---|---|---|
| `initial_penetration` | ERROR / WARN | geometry already overlapping at the starting pose, including non-adjacent links that `filterparent` misses |
| `runtime_penetration` | WARN | two *moving* links crushed into each other during the run |
| `jitter` | WARN | a joint whose velocity flips sign on >50% of steps with a small amplitude — the model is buzzing |
| `nan_diverged` | ERROR | blow-up, with the step it happened |
| `initial_environment_overlap` | INFO | body starts embedded in the floor/table — compliance, not a modelling error |
| `environment_compliance` | INFO | loaded body sinks into a static geom; how far the surface lies |
| `zero_pose_only_overlap` | INFO | overlaps at `qpos=0` that vanish at keyframe 0 — the model expects you to load the keyframe |
| `energy_growth` | INFO | passive system gaining energy |

## Design rules

1. **Never re-report what the engine already rejects.** If MuJoCo refuses to load it, `mjcf-doctor` stays quiet. Duplicating engine errors teaches users to ignore your output.
2. **One false positive kills the tool.** Thresholds are deliberately conservative. Rules that fired on most of the official models were narrowed or deleted, not tuned to look useful: a warning that shows up on every model is a default, not a finding.
3. **Separate "wrong" from "expected".** A quadruped sinking 19 mm into the ground with the stock soft contact is normal; a forearm embedded 30 mm in its own wrist is not. Penetration against the world is INFO, penetration into yourself is an ERROR.
4. **Every finding ships with a why and a fix.** A linter that says "solref too large" and stops is just a spellchecker. Each message explains the physical consequence and the concrete change.

## Evidence, not folklore

Rule thresholds were set against measurements, not vibes. Two examples from building this:

- **"High-poly collision meshes are 40× slower" is false.** Mesh geoms collide through a convex hull computed at compile time, so triangle count never enters the solver loop. Measured on MuJoCo 3.16: a 20 000-triangle / 10 104-vertex sphere costs 1.8 µs per step, an analytic sphere costs 2.5 µs. What actually hurts is that the hull fills every concavity. The rule now says that instead.
- **"Running the checker over Menagerie is a good benchmark" only if you count false positives.** The first audit pass flagged `collision_matrix_empty` on Apollo (it uses `<pair>` contact tables) and on a welded 2R demo (nothing can fall, so nothing needs to collide). Both were fixed before the report was written — see [`AUDIT.md`](AUDIT.md).

## Roadmap

- [x] `audit` subcommand with aggregate statistics
- [x] Self-contained HTML report (`--html`)
- [x] Menagerie-wide audit report — [`AUDIT.md`](AUDIT.md)
- [x] `--fix` for the mechanically-safe case: appending `<exclude>` for links that start embedded in each other
- [ ] GitHub Action wrapper
- [ ] Visual overlay: collision geoms, COM, inertia ellipsoids

## License

MIT
