@@ -139,6 +139,141 @@ def test_world_outside_env_ids_left_untouched(self):
139139 self ._rename (b )
140140 self .assertEqual (b .body_label [- 1 ], f"{ _SRC } /body_99" )
141141
142+ def test_sparse_env_ids (self ):
143+ """Non-contiguous ``env_ids`` (e.g. [10, 20, 30]) must rewrite using the right per-env root."""
144+ worlds = [10 , 20 , 30 ]
145+ b = newton .ModelBuilder ()
146+ SolverMuJoCo .register_custom_attributes (b )
147+ _inject_builtins (b , ("body" ,), _SRC , worlds )
148+ env_ids = torch .tensor (worlds , dtype = torch .int32 )
149+ mapping = torch .ones (1 , len (worlds ), dtype = torch .bool )
150+ _rename_builder_labels (b , [_SRC ], [_DST ], env_ids , mapping )
151+ for k , w in enumerate (b .body_world ):
152+ self .assertEqual (b .body_label [k ], f"/World/envs/env_{ int (w )} /body_{ int (w )} " )
153+
154+
155+ class TestRenamePass2Generality (unittest .TestCase ):
156+ """Pass 2 must generalize across coexisting frequencies and multiple string columns."""
157+
158+ def setUp (self ):
159+ self .worlds = [0 , 1 ]
160+ self .env_ids = torch .tensor (self .worlds , dtype = torch .int32 )
161+ self .mapping = torch .ones (1 , len (self .worlds ), dtype = torch .bool )
162+
163+ def _register_synthetic_freq (self , builder , freq_name , world_attr_name , str_attr_names ):
164+ """Register a ``syn:<freq_name>`` custom frequency, a ``references="world"`` int companion, and one or more ``dtype=str`` columns at it."""
165+ freq = f"syn:{ freq_name } "
166+ builder .add_custom_frequency (newton .ModelBuilder .CustomFrequency (name = freq_name , namespace = "syn" ))
167+ builder .add_custom_attribute (
168+ newton .ModelBuilder .CustomAttribute (
169+ name = world_attr_name ,
170+ frequency = freq ,
171+ dtype = int ,
172+ default = 0 ,
173+ namespace = "syn" ,
174+ references = "world" ,
175+ )
176+ )
177+ for n in str_attr_names :
178+ builder .add_custom_attribute (
179+ newton .ModelBuilder .CustomAttribute (
180+ name = n ,
181+ frequency = freq ,
182+ dtype = str ,
183+ default = "" ,
184+ namespace = "syn" ,
185+ )
186+ )
187+
188+ def _populate (self , builder , freq , world_attr_name , str_attr_names , worlds ):
189+ wa = builder .custom_attributes [f"syn:{ world_attr_name } " ]
190+ if wa .values is None :
191+ wa .values = []
192+ for w in worlds :
193+ wa .values .append (w )
194+ for n in str_attr_names :
195+ sa = builder .custom_attributes [f"syn:{ n } " ]
196+ if sa .values is None :
197+ sa .values = []
198+ for w in worlds :
199+ sa .values .append (f"{ _SRC } /{ n } _{ w } " )
200+ builder ._custom_frequency_counts [freq ] = builder ._custom_frequency_counts .get (freq , 0 ) + len (worlds )
201+
202+ def test_two_coexisting_custom_frequencies (self ):
203+ """Each registered ``references='world'`` companion must drive its own frequency's str columns."""
204+ b = newton .ModelBuilder ()
205+ self ._register_synthetic_freq (b , "freqA" , "freqA_world" , ["freqA_label" ])
206+ self ._register_synthetic_freq (b , "freqB" , "freqB_world" , ["freqB_label" ])
207+ self ._populate (b , "syn:freqA" , "freqA_world" , ["freqA_label" ], self .worlds )
208+ self ._populate (b , "syn:freqB" , "freqB_world" , ["freqB_label" ], self .worlds )
209+ _rename_builder_labels (b , [_SRC ], [_DST ], self .env_ids , self .mapping )
210+ for n in ("freqA_label" , "freqB_label" ):
211+ wa = b .custom_attributes [f"syn:{ n .split ('_' )[0 ]} _world" ].values
212+ sa = b .custom_attributes [f"syn:{ n } " ].values
213+ for k , w in enumerate (wa ):
214+ self .assertEqual (sa [k ], f"/World/envs/env_{ int (w )} /{ n } _{ int (w )} " )
215+
216+ def test_multiple_string_columns_at_one_frequency (self ):
217+ """Two str columns sharing one frequency must both be rewritten using the shared world companion."""
218+ b = newton .ModelBuilder ()
219+ self ._register_synthetic_freq (b , "freqA" , "freqA_world" , ["freqA_label" , "freqA_alt" ])
220+ self ._populate (b , "syn:freqA" , "freqA_world" , ["freqA_label" , "freqA_alt" ], self .worlds )
221+ _rename_builder_labels (b , [_SRC ], [_DST ], self .env_ids , self .mapping )
222+ wa = b .custom_attributes ["syn:freqA_world" ].values
223+ for n in ("freqA_label" , "freqA_alt" ):
224+ sa = b .custom_attributes [f"syn:{ n } " ].values
225+ for k , w in enumerate (wa ):
226+ self .assertEqual (sa [k ], f"/World/envs/env_{ int (w )} /{ n } _{ int (w )} " )
227+
228+ def test_empty_values_pass_through (self ):
229+ """A registered-but-empty string column must not crash the rename pass."""
230+ b = newton .ModelBuilder ()
231+ self ._register_synthetic_freq (b , "freqA" , "freqA_world" , ["freqA_label" ])
232+ # values stay None (registered, never populated)
233+ _rename_builder_labels (b , [_SRC ], [_DST ], self .env_ids , self .mapping )
234+ # Fully populate after the no-op rename: ensures the early-return guard didn't corrupt state.
235+ self ._populate (b , "syn:freqA" , "freqA_world" , ["freqA_label" ], self .worlds )
236+ self .assertEqual (len (b .custom_attributes ["syn:freqA_label" ].values ), len (self .worlds ))
237+
238+
239+ class TestRenameMultiSource (unittest .TestCase ):
240+ """Multi-source handling must not cross-contaminate when source paths share a string prefix."""
241+
242+ def test_prefix_overlap_does_not_cross_contaminate (self ):
243+ """Sources whose paths share a string prefix and that both feed the same envs must not cross-rename.
244+
245+ Common IL pattern: a robot proto and an object proto both feed every env. If the two source
246+ paths share a string prefix (``/Sources/protoA`` and ``/Sources/protoAB``), iter 0
247+ (``src=protoA``) sees the protoAB rows for the same world ids it owns and would over-match
248+ them under a non-boundary ``startswith``. The world-id guard alone does not catch this case
249+ because both sources contribute to the same set of worlds.
250+ """
251+ sources = ["/Sources/protoA" , "/Sources/protoAB" ]
252+ # 2 envs, both fed by both sources.
253+ env_ids = torch .tensor ([0 , 1 ], dtype = torch .int32 )
254+ mapping = torch .tensor ([[1 , 1 ], [1 , 1 ]], dtype = torch .bool )
255+ b = newton .ModelBuilder ()
256+ SolverMuJoCo .register_custom_attributes (b )
257+ # One body row from each source per env: 4 rows total, world ids interleaved.
258+ b .body_label .extend (
259+ [
260+ f"{ sources [0 ]} /body" , # row 0: protoA, world 0
261+ f"{ sources [1 ]} /body" , # row 1: protoAB, world 0
262+ f"{ sources [0 ]} /body" , # row 2: protoA, world 1
263+ f"{ sources [1 ]} /body" , # row 3: protoAB, world 1
264+ ]
265+ )
266+ b .body_world .extend ([0 , 0 , 1 , 1 ])
267+ _rename_builder_labels (b , sources , ["/World/envs/env_{}" , "/World/envs/env_{}" ], env_ids , mapping )
268+ # Each row must end up under its own per-env root with the suffix preserved verbatim.
269+ # Without the "/" boundary on ``startswith``, iter 0 (src=protoA) would match rows 1 and 3
270+ # because ``/Sources/protoAB/body``.startswith(``/Sources/protoA``) is True, rewriting them
271+ # to ``/World/envs/env_<w>/B/body`` (wrong suffix).
272+ self .assertEqual (b .body_label [0 ], "/World/envs/env_0/body" )
273+ self .assertEqual (b .body_label [1 ], "/World/envs/env_0/body" )
274+ self .assertEqual (b .body_label [2 ], "/World/envs/env_1/body" )
275+ self .assertEqual (b .body_label [3 ], "/World/envs/env_1/body" )
276+
142277
143278if __name__ == "__main__" :
144279 unittest .main ()
0 commit comments