Skip to content

Commit aac2a7d

Browse files
committed
Apply codex review nits to rename pass
Use a path-separator-terminated source prefix in ``_rename_builder_labels`` so two source paths that share a string prefix (e.g. ``/Sources/protoA`` and ``/Sources/protoAB``) cannot cross-contaminate when both feed the same set of envs. The world-id guard alone is not sufficient in that case because both sources contribute to the same ``world_roots``; only the boundary on ``startswith`` separates them. Slice index for ``swap`` keeps the original "after the source root" semantics, so no other behavior changes. Tighten the TODO on ``_scope_custom_frequencies`` to point a future reader at the exact SHA in ``source/isaaclab_newton/setup.py``'s ``newton @ git+...@<sha>`` URL, since Newton's package version string does not encode the SHA. Drop the internal-TODO bullet from the user-facing CHANGELOG entry and instead document the boundary- prefix tightening there alongside the original rename fix. Add tests: * ``test_sparse_env_ids`` — non-contiguous env ids map to the right per-env root. * ``TestRenamePass2Generality`` — multiple coexisting custom frequencies, multiple string columns at one frequency, and a registered-but-empty string column all behave correctly. * ``TestRenameMultiSource`` — explicit regression for the prefix overlap fix; both sources feed the same envs so the world-id guard cannot mask the boundary bug.
1 parent e79e17e commit aac2a7d

3 files changed

Lines changed: 155 additions & 15 deletions

File tree

source/isaaclab_newton/docs/CHANGELOG.rst

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,10 @@ Fixed
1212
now also walks string-typed custom-attribute columns whose frequency declares a
1313
``references="world"`` companion, rewriting their per-row source-path prefix to
1414
the destination world root in the same pass that handles built-in label arrays.
15-
Adds ``constraint_mimic`` to that built-in pass for completeness.
16-
17-
* Added a ``TODO`` to :func:`~isaaclab_newton.cloner.newton_replicate._scope_custom_frequencies`
18-
pointing at the upstream Newton fix
19-
(`newton-physics/newton#2659 <https://github.com/newton-physics/newton/pull/2659>`_,
20-
commit ``1d22547``). Once the IsaacLab Newton pin includes that commit,
21-
``parse_usd`` scopes the walk to ``root_path`` itself and the wrapper becomes
22-
a redundant identity check on already-in-scope prims.
15+
Adds ``constraint_mimic`` to that built-in pass for completeness. The prefix
16+
match uses a path-separator boundary so a source path that is a string prefix
17+
of another (e.g. ``/Sources/protoA`` vs ``/Sources/protoAB``) does not
18+
cross-contaminate during the rename.
2319

2420

2521
0.5.26 (2026-04-29)

source/isaaclab_newton/isaaclab_newton/cloner/newton_replicate.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,15 @@ def _scope_custom_frequencies(builder: ModelBuilder, root_path: str) -> None:
3333
Frequencies with ``usd_prim_filter = None`` are skipped; Newton excludes them
3434
from the traversal loop entirely so they carry no contamination risk.
3535
36-
TODO: Remove once IsaacLab's Newton pin includes
37-
https://github.com/newton-physics/newton/pull/2659 (commit 1d22547, "Scope
38-
USD custom-frequency parsing"). After that bump, ``parse_usd`` scopes the
39-
walk to ``root_path`` itself and this wrapper becomes a redundant identity
40-
check on already-in-scope prims. Safe to keep until the bump lands.
36+
TODO: Remove once IsaacLab's Newton pin includes upstream commit
37+
``1d22547`` (https://github.com/newton-physics/newton/pull/2659,
38+
"Scope USD custom-frequency parsing"). To check, look at the SHA in
39+
``source/isaaclab_newton/setup.py``'s ``newton @ git+https://github.com/
40+
newton-physics/newton.git@<sha>`` URL and confirm it is at or after
41+
``1d22547e704855bfbb67777e281ac23eaab95af3``. After that bump,
42+
``parse_usd`` scopes the walk to ``root_path`` itself and this wrapper
43+
becomes a redundant identity check on already-in-scope prims. Safe to
44+
keep until the bump lands.
4145
4246
Args:
4347
builder: A proto ``ModelBuilder`` that has already had custom attributes
@@ -216,17 +220,22 @@ def _rename_builder_labels(
216220
"""
217221
# per-source, per-world renaming (strict prefix swap), compact style preserved
218222
for i, src_path in enumerate(sources):
219-
src_prefix_len = len(src_path.rstrip("/"))
223+
# Boundary-terminated prefix prevents over-matching when one source path is a
224+
# prefix of another (e.g. ``/Sources/protoA`` vs ``/Sources/protoAB``).
225+
src_prefix = src_path.rstrip("/") + "/"
226+
src_prefix_len = len(src_prefix) - 1 # slice index for ``swap`` keeps the leading "/"
220227
swap = lambda name, new_root: new_root + name[src_prefix_len:] # noqa: E731
221228
world_cols = torch.nonzero(mapping[i], as_tuple=True)[0].tolist()
222229
# Map Newton world IDs (sequential) to destination paths using env_ids
223230
world_roots = {int(env_ids[c]): destinations[i].format(int(env_ids[c])) for c in world_cols}
224231

225232
def _rename_pair(values, worlds):
233+
# ``values`` and ``worlds`` are required to be the same length by the call
234+
# sites above; ``min`` is defensive against a future builder oddity.
226235
n = min(len(values), len(worlds))
227236
for k in range(n):
228237
world_id = int(worlds[k])
229-
if world_id in world_roots and isinstance(values[k], str) and values[k].startswith(src_path):
238+
if world_id in world_roots and isinstance(values[k], str) and values[k].startswith(src_prefix):
230239
values[k] = swap(values[k], world_roots[world_id])
231240

232241
# Pass 1: built-in label arrays. Each has a paired ``*_world`` int column.

source/isaaclab_newton/test/cloner/test_rename_builder_labels.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

143278
if __name__ == "__main__":
144279
unittest.main()

0 commit comments

Comments
 (0)