diff --git a/src/unilab/envs/mdp/ray_observation.py b/src/unilab/envs/mdp/ray_observation.py index 50bea0414..69d1297ac 100644 --- a/src/unilab/envs/mdp/ray_observation.py +++ b/src/unilab/envs/mdp/ray_observation.py @@ -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 diff --git a/src/unilab/utils/sim2sim.py b/src/unilab/utils/sim2sim.py index a3d3df5f7..e8d711fb8 100644 --- a/src/unilab/utils/sim2sim.py +++ b/src/unilab/utils/sim2sim.py @@ -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)) @@ -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``. """ @@ -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) diff --git a/tests/envs/mdp/test_ray_observation.py b/tests/envs/mdp/test_ray_observation.py index 6bcd08fe0..e0393d4cb 100644 --- a/tests/envs/mdp/test_ray_observation.py +++ b/tests/envs/mdp/test_ray_observation.py @@ -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: @@ -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", diff --git a/tests/envs/test_manager_based_rl_env.py b/tests/envs/test_manager_based_rl_env.py index fe4aa46ec..816790964 100644 --- a/tests/envs/test_manager_based_rl_env.py +++ b/tests/envs/test_manager_based_rl_env.py @@ -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" diff --git a/tests/training/test_sim2sim_resolver.py b/tests/training/test_sim2sim_resolver.py index a2abe98c1..c731eb575 100644 --- a/tests/training/test_sim2sim_resolver.py +++ b/tests/training/test_sim2sim_resolver.py @@ -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()