Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/unilab/envs/mdp/ray_observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ class RayPatternCfg:
attachment_body_name: str | None = None
attachment_site_name: str | None = None
alignment: RayAlignment = "body"
caster: str = "uni_ray"
caster: str = "uniray"
sensor_offset: Sequence[float] = (0.0, 0.0, 0.0)
min_distance: float = 0.0
include_normal: bool = False
Expand Down
32 changes: 31 additions & 1 deletion src/unilab/utils/sim2sim.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,32 @@ def _strip_visualization_only_fields(value: Any) -> Any:
return stripped


# The UniSim ray-caster plugin identity was renamed ``uni_ray`` → ``uniray``
# (uniray 0.1.1 / unisim-core 2.2+). The rename changes no ray numerics and no
# policy I/O, so contract comparisons treat the two spellings as the same
# caster instead of rejecting every checkpoint trained before the rename.
CASTER_VALUE_ALIASES: dict[str, str] = {"uni_ray": "uniray"}


def _normalize_caster_aliases(value: Any) -> Any:
"""Recursively canonicalize renamed caster identity strings."""
if OmegaConf.is_config(value):
value = OmegaConf.to_container(value, resolve=True)
if isinstance(value, dict):
return {
str(key): (
CASTER_VALUE_ALIASES.get(child, child)
if key == "caster" and isinstance(child, str)
else _normalize_caster_aliases(child)
)
for key, child in value.items()
}
if isinstance(value, (list, tuple)):
normalized = [_normalize_caster_aliases(child) for child in value]
return tuple(normalized) if isinstance(value, tuple) else normalized
return value


def _values_equal(a: Any, b: Any) -> bool:
return bool(_normalize(a) == _normalize(b))

Expand Down Expand Up @@ -196,7 +222,9 @@ def resolve_sim2sim_config(
DENYLIST field differs, including asymmetric presence for
:data:`ENV_STRUCTURAL_DENYLIST` paths. Playback-visualization-only
action fields (:data:`VISUALIZATION_ONLY_ACTION_FIELDS`) are exempt from
the ``env.actions`` comparison. A source run whose contract snapshot
the ``env.actions`` comparison, and renamed ray-caster identities
(:data:`CASTER_VALUE_ALIASES`) are canonicalized on both sides of every
comparison. A source run whose contract snapshot
is missing, malformed, or uses keys outside the current schema fails closed
with the same error regardless of ``strict``.
"""
Expand Down Expand Up @@ -227,6 +255,8 @@ def resolve_sim2sim_config(
if path == "env.actions":
source_value = _strip_visualization_only_fields(source_value)
target_value = _strip_visualization_only_fields(target_value)
source_value = _normalize_caster_aliases(source_value)
target_value = _normalize_caster_aliases(target_value)
if _values_equal(source_value, target_value):
continue
line = _diff_line(path, source_value, target_value)
Expand Down
8 changes: 4 additions & 4 deletions tests/envs/mdp/test_ray_observation.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,15 +336,15 @@ def test_ray_pattern_configs_validate_geometry_values() -> None:
)


def test_ray_scan_binds_and_traces_without_importing_uni_ray(monkeypatch) -> None:
def test_ray_scan_binds_and_traces_without_importing_uniray_internals(monkeypatch) -> None:
class _Blocker:
def find_module(self, fullname, path=None):
if fullname == "uni_ray" or fullname.startswith("uni_ray."):
if fullname in {"uni_ray", "uniray"} or fullname.startswith(("uni_ray.", "uniray.")):
return self
return None

def load_module(self, fullname):
raise AssertionError(f"uni_ray was imported: {fullname}")
raise AssertionError(f"ray caster package was imported: {fullname}")

sys.meta_path.insert(0, _Blocker())
try:
Expand Down Expand Up @@ -835,7 +835,7 @@ def test_ray_scan_uses_only_public_backend_and_factory_boundaries(monkeypatch) -
collision = env.backend.get_ray_collision_description()
assert collision.scene is env.backend.scene
assert collision is env.backend.collision
assert term.ray_pattern.caster == "uni_ray"
assert term.ray_pattern.caster == "uniray"
source = (_REPO_ROOT / "src/unilab/envs/mdp/ray_observation.py").read_text(encoding="utf-8")
for banned in (
"import uni_ray",
Expand Down
6 changes: 3 additions & 3 deletions tests/envs/test_manager_based_rl_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -1748,10 +1748,10 @@ def test_mujoco_host_bridge_packed_reset_lifecycle(
def test_mujoco_tensor_ray_observation_uses_public_rich_query_contract(
tmp_path: Path,
) -> None:
"""Exercise the real MuJoCo/UniSim/uni_ray perception path end to end."""
"""Exercise the real MuJoCo/UniSim/uniray perception path end to end."""

pytest.importorskip("uni_ray", reason="local uni_ray is required")
pytest.importorskip("warp", reason="warp-lang is required by uni_ray")
pytest.importorskip("uniray", reason="local uniray is required")
pytest.importorskip("warp", reason="warp-lang is required by uniray")

def build(pattern: mdp.RayPatternCfg):
model = tmp_path / "ray-rich-query.xml"
Expand Down
48 changes: 48 additions & 0 deletions tests/training/test_sim2sim_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,54 @@ def test_visualization_only_fields_do_not_mask_policy_mismatches(tmp_path):
resolve_sim2sim_config(tmp_path, target)


def test_renamed_ray_caster_identity_is_canonicalized(tmp_path):
# uniray 0.1.1 renamed the plugin identity uni_ray -> uniray without
# changing ray numerics or policy I/O; checkpoints trained before the
# rename must keep playing against confs that use the new spelling.
source = _manager_cfg()
OmegaConf.update(
source,
"env.observations.policy.terms.forward_scan.params.ray_pattern.caster",
"uni_ray",
merge=False,
)
_write_sidecar(tmp_path, extract_contract_snapshot(source))

target = _manager_cfg()
OmegaConf.update(
target,
"env.observations.policy.terms.forward_scan.params.ray_pattern.caster",
"uniray",
merge=False,
)

assert resolve_sim2sim_config(tmp_path, target) is target


def test_ray_caster_change_beyond_the_alias_still_fails_closed(tmp_path):
# Only the documented rename is exempt; switching to a different caster
# remains a policy-defining mismatch.
source = _manager_cfg()
OmegaConf.update(
source,
"env.observations.policy.terms.forward_scan.params.ray_pattern.caster",
"uni_ray",
merge=False,
)
_write_sidecar(tmp_path, extract_contract_snapshot(source))

target = _manager_cfg()
OmegaConf.update(
target,
"env.observations.policy.terms.forward_scan.params.ray_pattern.caster",
"fake",
merge=False,
)

with pytest.raises(CrossBackendIncompatibleError, match="env\\.observations"):
resolve_sim2sim_config(tmp_path, target)


def test_denylist_mismatch_raises_with_field_in_message(tmp_path):
_write_sidecar(tmp_path, extract_contract_snapshot(_mujoco_cfg()))
target = _mujoco_cfg()
Expand Down
Loading