@@ -93,3 +93,28 @@ def test_instantiate_class_term_ignores_non_scene_entity_params() -> None:
9393 instantiate_class_term (term_cfg , env ) # type: ignore[arg-type]
9494 # Sanity: params untouched.
9595 assert term_cfg .params == {"threshold" : 0.5 , "names" : ("a" ,), "ranges" : (- 1.0 , 1.0 )}
96+
97+
98+ def test_instantiate_class_term_resolves_asset_cfg_before_class_init () -> None :
99+ """Regression: stateful class terms like ``feet_swing_height`` cache
100+ ``asset_cfg.link_ids`` in their ``__init__``. The resolve pass MUST run before
101+ the class is instantiated — otherwise the init reads ``None`` and crashes
102+ (caught in production by an 8-GPU G1 training run that hit
103+ ``ValueError: SceneEntityCfg(name='robot') has no link_ids``).
104+ """
105+ captured : dict [str , tuple [int , ...] | None ] = {}
106+
107+ class _RecordingTerm :
108+ def __init__ (self , cfg , env ) -> None : # type: ignore[no-untyped-def]
109+ # Mirrors what feet_swing_height.__init__ does — read the cfg at init time.
110+ captured ["link_ids" ] = cfg .params ["asset_cfg" ].link_ids
111+
112+ def __call__ (self , env ) -> None : # noqa: ARG002 - placeholder
113+ return None
114+
115+ env = _FakeEnv (link_names = ["base" , "left_foot" , "right_foot" ], joint_names = [])
116+ asset_cfg = SceneEntityCfg (name = "robot" , link_names = ("left_foot" , "right_foot" ))
117+ term_cfg = ManagerTermBaseCfg (func = _RecordingTerm , params = {"asset_cfg" : asset_cfg })
118+ instantiate_class_term (term_cfg , env ) # type: ignore[arg-type]
119+ # The recording term's init saw fully resolved link_ids.
120+ assert captured ["link_ids" ] == (1 , 2 )
0 commit comments