From 696477ca77014eec58488c150505a05d5174ecd3 Mon Sep 17 00:00:00 2001 From: Jeremy Schoemaker Date: Mon, 24 Aug 2026 10:51:15 -0500 Subject: [PATCH 1/2] Restore schema declarations lost in the regenerated schema.xml The schema regeneration in 985d0940 dropped three declarations that MuJoCo 3.12 still accepts, breaking previously working PyMJCF models: - sensor lost its contact child element, so parsing a model with raises KeyError while raw MuJoCo loads it fine. - jointinparent on the nine actuator elements degraded from type="reference" reference_namespace="joint" to type="string", so attach() no longer prefixes the joint name and the composed model fails to compile with "unknown transmission target". - custom/numeric data degraded from a float array to a string, so add('numeric', name='x', data=[1, 2, 3]) raises ValueError. Restore the three declarations as they were before the regeneration and add regression tests covering all three. --- dm_control/mjcf/schema.xml | 42 ++++++++++++++++++++++++++-------- dm_control/mjcf/schema_test.py | 41 +++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/dm_control/mjcf/schema.xml b/dm_control/mjcf/schema.xml index cf376fa8..d9c13bd1 100644 --- a/dm_control/mjcf/schema.xml +++ b/dm_control/mjcf/schema.xml @@ -2193,7 +2193,7 @@ - + @@ -2232,7 +2232,7 @@ - + @@ -2260,7 +2260,7 @@ - + @@ -2291,7 +2291,7 @@ - + @@ -2322,7 +2322,7 @@ - + @@ -2372,7 +2372,7 @@ - + @@ -2400,7 +2400,7 @@ - + @@ -2431,7 +2431,7 @@ - + @@ -2486,7 +2486,7 @@ - + @@ -3128,6 +3128,28 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -3182,7 +3204,7 @@ - + diff --git a/dm_control/mjcf/schema_test.py b/dm_control/mjcf/schema_test.py index 1eda0e8c..6f52630d 100644 --- a/dm_control/mjcf/schema_test.py +++ b/dm_control/mjcf/schema_test.py @@ -187,5 +187,46 @@ def constructible(spec_node, path): + '\n'.join(failures[:20])) +class SchemaRegressionTest(absltest.TestCase): + """Declarations that MuJoCo accepts must stay representable in PyMJCF.""" + + def test_contact_sensor_parses_and_compiles(self): + xml_string = """ + + + + + + + + + + + """ + root = mjcf.from_xml_string(xml_string) + physics = mjcf.Physics.from_mjcf_model(root) + self.assertEqual(physics.model.nsensor, 1) + self.assertEqual(root.find('sensor', 'cs').geom1.name, 'g') + + def test_jointinparent_is_scoped_on_attach(self): + child = mjcf.RootElement(model='child') + body = child.worldbody.add('body', name='b') + body.add('joint', name='j', type='hinge') + body.add('geom', name='g', size=[0.1]) + child.actuator.add('general', name='a', jointinparent='j') + parent = mjcf.RootElement(model='parent') + parent.attach(child) + self.assertIn('jointinparent="child/j"', parent.to_xml_string()) + physics = mjcf.Physics.from_mjcf_model(parent) + self.assertEqual(physics.model.nu, 1) + + def test_custom_numeric_accepts_array_data(self): + root = mjcf.RootElement(model='m') + root.custom.add('numeric', name='x', data=[1, 2, 3]) + physics = mjcf.Physics.from_mjcf_model(root) + self.assertEqual(physics.model.nnumericdata, 3) + + if __name__ == '__main__': absltest.main() From de77a71ff40c088134928739d18a55eebd565a6d Mon Sep 17 00:00:00 2001 From: Jeremy Schoemaker Date: Fri, 11 Sep 2026 08:39:56 -0500 Subject: [PATCH 2/2] mjcf: declare reference_namespace on the contact sensor site attribute The restored contact sensor declared site as a bare type="reference" while every sibling reference in the same element carried a namespace. It was the only bare reference in the whole file, which makes it the odd one out against MuJoCo's own generated schema and against the nine jointinparent declarations this PR exists to restore. The behavioral claim it was reported under does not hold, and the reason is schema.py:152-153: other_kwargs['reference_namespace'] = ( attribute_xml.get('reference_namespace') or name) An absent namespace falls back to the attribute's own name, and the attribute is named site, so the resolved namespace is "site" either way. Measured: attaching a child whose contact sensor is configured by site produces byte-identical XML with and without the declaration, the reference resolves to an _AttachableElement in both cases, and it follows a rename of the target in both cases. So it does not leave the child site unprefixed and cannot fail compilation. That also means an attach regression for this cannot fail, so instead of shipping one that only looks like verification, the added test asserts the invariant that can actually break: no attribute of type="reference" may omit its namespace. Without this commit it fails with ['site'] has length of 1. That is the same class of loss as the jointinparent regression, caught on the file rather than on one code path, so the next regeneration that drops a namespace is caught even where the fallback happens to mask it. schema_test.py: 8 passed. --- dm_control/mjcf/schema.xml | 2 +- dm_control/mjcf/schema_test.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/dm_control/mjcf/schema.xml b/dm_control/mjcf/schema.xml index d9c13bd1..2754cede 100644 --- a/dm_control/mjcf/schema.xml +++ b/dm_control/mjcf/schema.xml @@ -3141,7 +3141,7 @@ - + diff --git a/dm_control/mjcf/schema_test.py b/dm_control/mjcf/schema_test.py index 6f52630d..1c1332d8 100644 --- a/dm_control/mjcf/schema_test.py +++ b/dm_control/mjcf/schema_test.py @@ -221,6 +221,24 @@ def test_jointinparent_is_scoped_on_attach(self): physics = mjcf.Physics.from_mjcf_model(parent) self.assertEqual(physics.model.nu, 1) + def test_every_reference_declares_its_namespace(self): + """Guards the invariant a regeneration is most likely to drop. + + `schema.py` falls back to the attribute's own name when + `reference_namespace` is absent, so a missing one is invisible whenever the + two happen to coincide and silently wrong when they do not. Asserting on the + file keeps every reference explicit, which is what MuJoCo's own generated + schema does. + """ + tree = ET.parse(_SCHEMA_PATH) + bare = sorted( + attribute.get('name') + for attribute in tree.iter('attribute') + if attribute.get('type') == 'reference' + and attribute.get('reference_namespace') is None + ) + self.assertEmpty(bare) + def test_custom_numeric_accepts_array_data(self): root = mjcf.RootElement(model='m') root.custom.add('numeric', name='x', data=[1, 2, 3])