"""Pure-torch checks for the upward recovery reward and per-env curriculum.""" import math import torch from s10_supermimic.core.imagine import continuation_value from s10_supermimic.core.upward import ( BAD_ORIENTATION_START_DEG, HISTORY_LENGTH, PROMOTE_SUCCESSES, TILT_STAGES_DEG, bad_orientation_mask, bad_orientation_value, init_upward_state, reset_root_state_upward, upward_curriculum, upward_curriculum_stats, upward_video_tilt_limit, ) from s10_supermimic.core.world_model import GRAVITY def gravity_at(deg: float, direction: float = 0.0) -> torch.Tensor: """Projected gravity for a tilt ``deg`` in a fixed horizontal direction.""" t = math.radians(deg) return torch.tensor([[ math.sin(t) * math.cos(direction), math.sin(t) * math.sin(direction), -math.cos(t), ]]) def test_severity_is_monotonic() -> None: values = torch.cat([bad_orientation_value(gravity_at(d)) for d in torch.arange(0.0, 180.5, 0.5).tolist()]) assert (values[1:] >= values[:-1] - 1e-6).all(), "severity must be monotonic" assert values.max() <= 1.0 + 1e-5, values.max() assert values.min() >= 0.0, values.min() assert bad_orientation_value(gravity_at(BAD_ORIENTATION_START_DEG)).item() < 1e-6 assert bad_orientation_value(gravity_at(180.0)).item() > 0.99 v105 = bad_orientation_value(gravity_at(105.0)).item() v110 = bad_orientation_value(gravity_at(110.0)).item() v120 = bad_orientation_value(gravity_at(120.0)).item() assert v105 < v110 < v120, (v105, v110, v120) def test_severity_gradient_is_finite() -> None: for g in (gravity_at(0.0), gravity_at(90.0), gravity_at(180.0)): x = g.clone().requires_grad_(True) bad_orientation_value(x).sum().backward() assert torch.isfinite(x.grad).all(), x.grad def test_mask_keeps_bad_set() -> None: assert bad_orientation_mask(gravity_at(60.0)).item() assert not bad_orientation_mask(gravity_at(30.0)).item() assert not bad_orientation_mask(gravity_at(50.0, math.pi / 4)).item() assert bad_orientation_mask(gravity_at(180.0)).item() def test_upward_continuation_does_not_use_v4_fall_proxy() -> None: state = torch.zeros(3, 38) state[0, GRAVITY] = torch.tensor([0.0, 0.0, -1.0]) state[1, GRAVITY] = torch.tensor([0.0, 0.0, 0.0]) state[2, GRAVITY] = torch.tensor([0.0, 0.0, 1.0]) upward = continuation_value(state, {"upright_mode": "upward"}) v4 = continuation_value(state, {}) assert torch.allclose(upward, torch.ones(3)), upward assert v4[0] > 0.99 and v4[2] < 1e-4, v4 class _Data: def __init__(self, n: int): self.root_link_pos_w = torch.zeros(n, 3) self.projected_gravity_b = torch.tensor([[0.0, 0.0, -1.0]] * n) class _Robot: def __init__(self, n: int): self.data = _Data(n) class _Terrain: def __init__(self, n: int): self.terrain_levels = torch.zeros(n, dtype=torch.long) self.terrain_types = torch.zeros(n, dtype=torch.long) self.terrain_origins = torch.zeros(6, 1, 3) self.env_origins = torch.zeros(n, 3) class _Scene: def __init__(self, n: int): self.robot = _Robot(n) self.terrain = _Terrain(n) self.env_origins = torch.zeros(n, 3) def __getitem__(self, key): return self.robot class _Termination: def __init__(self, n: int): self.terminated = torch.zeros(n, dtype=torch.bool) class _Env: def __init__(self, n: int): self.num_envs = n self.device = "cpu" self.scene = _Scene(n) self.episode_length_buf = torch.ones(n, dtype=torch.long) self.termination_manager = _Termination(n) def _episode(env: _Env, recovered: torch.Tensor) -> None: """Advance one finished episode for every env and run the curriculum.""" env.termination_manager.terminated[:] = ~recovered env.scene.robot.data.projected_gravity_b[:] = torch.where( recovered[:, None], torch.tensor([0.0, 0.0, -1.0]), torch.tensor([0.0, 0.0, 1.0])) upward_curriculum(env, torch.arange(env.num_envs)) def test_per_env_rolling_window_advances_and_demotes() -> None: env = _Env(4) outcomes = torch.zeros(4, dtype=torch.bool) for episode in range(HISTORY_LENGTH): outcomes[0] = True # 20/20 -> advance outcomes[1] = False # 0/20 -> stays at level 0 outcomes[2] = episode < 10 # 10/20 -> neutral outcomes[3] = episode < PROMOTE_SUCCESSES # exactly 16/20 -> advance _episode(env, outcomes) stats = upward_curriculum_stats(env) assert env._upward_tilt_levels.tolist() == [1, 0, 0, 1] assert env.scene.terrain.terrain_levels.tolist() == [1, 0, 0, 1] assert stats["tilt_level_q75"] == 1 # Advanced envs reset their window and need 20 new episodes; only envs 1 # (0/20) and 2 (10/20) are ready after the first block. assert abs(stats["recovery_window_rate"] - 0.25) < 1e-6 assert abs(stats["recovery_window_ready"] - 0.5) < 1e-6 assert math.isclose(upward_video_tilt_limit(env), math.radians(135.0)) # The history is per-env: env 1 now gets 20 successes and advances alone; # env 2 stays neutral with 10 successes. for episode in range(HISTORY_LENGTH): outcomes[0] = True outcomes[1] = True outcomes[2] = episode < 10 outcomes[3] = True _episode(env, outcomes) assert env._upward_tilt_levels.tolist() == [2, 1, 0, 2] assert env.scene.terrain.terrain_levels.tolist() == [2, 1, 0, 2] # A full 20-episode failure window demotes env 0 from level 2 to level 1. for _ in range(HISTORY_LENGTH): outcomes[:] = False outcomes[3] = True # keep env 3 at level 2 _episode(env, outcomes) assert env._upward_tilt_levels.tolist() == [1, 0, 0, 2] # Terrain has six levels and is independent of the three tilt levels. assert env.scene.terrain.terrain_levels.tolist() == [1, 0, 0, 3] # Before 20 episodes no env is eligible to move. fresh = _Env(4) stats = upward_curriculum_stats(fresh) assert stats["recovery_window_ready"] == 0.0 assert stats["tilt_level_q75"] == 0 outcomes = torch.ones(4, dtype=torch.bool) for _ in range(HISTORY_LENGTH - 1): _episode(fresh, outcomes) assert fresh._upward_tilt_levels.tolist() == [0, 0, 0, 0] _episode(fresh, outcomes) assert fresh._upward_tilt_levels.tolist() == [1, 1, 1, 1] class _Asset: def __init__(self, n: int): self.data = type("Data", (), {})() self.data.default_root_state = torch.zeros(n, 13) self.data.default_root_state[:, 3] = 1.0 # identity wxyz quaternion self.pose = None self.velocity = None def write_root_link_pose_to_sim(self, pose, env_ids): self.pose = pose.clone() def write_root_link_velocity_to_sim(self, velocity, env_ids): self.velocity = velocity.clone() class _ResetScene: def __init__(self, n: int): self.asset = _Asset(n) self.env_origins = torch.zeros(n, 3) def __getitem__(self, key): return self.asset class _ResetEnv: def __init__(self, n: int): self.num_envs = n self.device = "cpu" self.scene = _ResetScene(n) def test_reset_supports_per_env_and_play_fallback_limits() -> None: env = _ResetEnv(2) pose_range = {"x": (-0.1, 0.1), "y": (-0.1, 0.1), "z": (0.0, 0.2), "roll": (-math.pi, math.pi), "pitch": (-math.pi, math.pi), "yaw": (-math.pi, math.pi)} ids = torch.arange(2) reset_root_state_upward(env, ids, pose_range) assert env.scene.asset.pose.shape == (2, 7) assert env.scene.asset.velocity.shape == (2, 6) assert torch.isfinite(env.scene.asset.pose).all() env._upward_tilt_limits = torch.tensor( [math.radians(60.0), math.radians(180.0)]) reset_root_state_upward(env, ids, pose_range) assert torch.isfinite(env.scene.asset.pose).all() def main() -> None: test_severity_is_monotonic() test_severity_gradient_is_finite() test_mask_keeps_bad_set() test_upward_continuation_does_not_use_v4_fall_proxy() test_per_env_rolling_window_advances_and_demotes() test_reset_supports_per_env_and_play_fallback_limits() print("Upward reward / continuation / per-env curriculum checks passed") if __name__ == "__main__": main()