From cd1a2b1cde96717b2a76b104a2da18223b64f5d9 Mon Sep 17 00:00:00 2001 From: DeepMind Date: Mon, 2 Dec 2019 08:25:21 -0800 Subject: [PATCH] Initial commit PiperOrigin-RevId: 283351838 Change-Id: Ia2b24db0c8f0b32bd2c02f98414db5713d60d47b --- .gitignore | 11 + .gitmodules | 3 + CONTRIBUTING.md | 31 ++ LICENSE | 202 +++++++++ README.md | 67 +++ RELEASE_NOTES.md | 5 + dm_env_rpc/__init__.py | 20 + dm_env_rpc/_version.py | 20 + dm_env_rpc/v1/__init__.py | 15 + dm_env_rpc/v1/connection.py | 125 ++++++ dm_env_rpc/v1/connection_test.py | 106 +++++ dm_env_rpc/v1/dm_env_adaptor.py | 206 +++++++++ dm_env_rpc/v1/dm_env_adaptor_test.py | 347 +++++++++++++++ dm_env_rpc/v1/dm_env_rpc.proto | 319 ++++++++++++++ dm_env_rpc/v1/dm_env_rpc_test.py | 74 ++++ dm_env_rpc/v1/dm_env_utils.py | 81 ++++ dm_env_rpc/v1/dm_env_utils_test.py | 170 ++++++++ dm_env_rpc/v1/spec_manager.py | 122 ++++++ dm_env_rpc/v1/spec_manager_test.py | 155 +++++++ dm_env_rpc/v1/tensor_utils.py | 187 +++++++++ dm_env_rpc/v1/tensor_utils_test.py | 338 +++++++++++++++ docs/v1/2x2.png | Bin 0 -> 236 bytes docs/v1/2x3.png | Bin 0 -> 564 bytes docs/v1/appendix.md | 212 ++++++++++ docs/v1/glossary.md | 38 ++ docs/v1/index.md | 6 + docs/v1/overview.md | 221 ++++++++++ docs/v1/reference.md | 397 ++++++++++++++++++ docs/v1/single_agent_connect_and_step.png | Bin 0 -> 33227 bytes docs/v1/single_agent_sequence_transitions.png | Bin 0 -> 47793 bytes docs/v1/single_agent_world_destruction.png | Bin 0 -> 56503 bytes docs/v1/state_transitions.graphviz | 19 + docs/v1/state_transitions.png | Bin 0 -> 26090 bytes examples/catch_environment.py | 257 ++++++++++++ examples/catch_human_agent.py | 150 +++++++ examples/catch_test.py | 193 +++++++++ setup.py | 125 ++++++ third_party/api-common-protos | 1 + 38 files changed, 4223 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 RELEASE_NOTES.md create mode 100644 dm_env_rpc/__init__.py create mode 100644 dm_env_rpc/_version.py create mode 100644 dm_env_rpc/v1/__init__.py create mode 100644 dm_env_rpc/v1/connection.py create mode 100644 dm_env_rpc/v1/connection_test.py create mode 100644 dm_env_rpc/v1/dm_env_adaptor.py create mode 100644 dm_env_rpc/v1/dm_env_adaptor_test.py create mode 100644 dm_env_rpc/v1/dm_env_rpc.proto create mode 100644 dm_env_rpc/v1/dm_env_rpc_test.py create mode 100644 dm_env_rpc/v1/dm_env_utils.py create mode 100644 dm_env_rpc/v1/dm_env_utils_test.py create mode 100644 dm_env_rpc/v1/spec_manager.py create mode 100644 dm_env_rpc/v1/spec_manager_test.py create mode 100644 dm_env_rpc/v1/tensor_utils.py create mode 100644 dm_env_rpc/v1/tensor_utils_test.py create mode 100644 docs/v1/2x2.png create mode 100644 docs/v1/2x3.png create mode 100644 docs/v1/appendix.md create mode 100644 docs/v1/glossary.md create mode 100644 docs/v1/index.md create mode 100644 docs/v1/overview.md create mode 100644 docs/v1/reference.md create mode 100644 docs/v1/single_agent_connect_and_step.png create mode 100644 docs/v1/single_agent_sequence_transitions.png create mode 100644 docs/v1/single_agent_world_destruction.png create mode 100644 docs/v1/state_transitions.graphviz create mode 100644 docs/v1/state_transitions.png create mode 100644 examples/catch_environment.py create mode 100644 examples/catch_human_agent.py create mode 100644 examples/catch_test.py create mode 100644 setup.py create mode 160000 third_party/api-common-protos diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09d69aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Ignore byte-compiled Python code +*.py[cod] + +# Ignore protobuf bindings. +**/*pb2*.py + +# Ignore directories created during the build/installation process +*.egg-info/ +.eggs/ +build/ +dist/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..46be88b --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "third_party/api-common-protos"] + path = third_party/api-common-protos + url = https://github.com/googleapis/api-common-protos diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fbadeda --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# How to Contribute + +We'd love to accept your patches and contributions to this project, however this +library is widely used in our research code, so we are unlikely to be able to +accept breaking changes to the interface. + +There are just a few small guidelines you need to follow. + +## Contributor License Agreement + +Contributions to this project must be accompanied by a Contributor License +Agreement. You (or your employer) retain the copyright to your contribution; +this simply gives us permission to use and redistribute your contributions as +part of the project. Head over to to see +your current agreements on file or to sign a new one. + +You generally only need to submit a CLA once, so if you've already submitted one +(even if it was for a different project), you probably don't need to do it +again. + +## Code reviews + +All submissions, including submissions by project members, require review. We +use GitHub pull requests for this purpose. Consult +[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more +information on using pull requests. + +## Community Guidelines + +This project follows +[Google's Open Source Community Guidelines](https://opensource.google/conduct/). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..99803f6 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# `dm_env_rpc`: A networking protocol for agent-environment communication. + +`dm_env_rpc` is a remote procedure call (RPC) protocol for communicating between +machine learning agents and environments. It uses [gRPC](http://www.grpc.io) as +the underlying communication framework, specifically its +[bidirectional streaming](http://grpc.io/docs/guides/concepts/#bidirectional-streaming-rpc) +RPC variant. + +This package also contains an implementation of +[`dm_env`](http://www.github.com/deepmind/dm_env), a Python interface for +interacting with such environments. + +Please see the documentation for more detailed information on the semantics of +the protocol and how to use it. The examples sub-directory also provides +examples of RL environments implemented using the `dm_env_rpc` protocol. + +## Intended audience + +Games can make for interesting AI research platforms, for example as +reinforcement learning (RL) environments. However, exposing a game as an RL +environment can be a subtle, fraught process. We aim to provide a protocol that +allows agents and environments to communicate in a standardized way, without +specialized knowledge about how the other side works. Game developers can expose +their games as environments with minimal domain knowledge and researchers can +test their agents on a large library of different games. + +This protocol also removes the need for agents and environments to run in the +same process or even on the same machine, allowing agents and environments to +have very different technology stacks and requirements. + +## Documentation + +* [Protocol overview](docs/v1/overview.md) +* [Protocol reference](docs/v1/reference.md) +* [Appendix](docs/v1/appendix.md) +* [Glossary](docs/v1/glossary.md) + +## Installation + +Note: You may optionally wish to create a +[Python Virtual Environment](https://docs.python.org/3/tutorial/venv.html) to +prevent conflicts with your system's Python environment. + +`dm_env_rpc` can be installed from [PyPi](https://pypi.org/project/dm-env-rpc/) +using `pip`: + +```bash +$ pip install dm-env-rpc +``` + +To also install the dependencies for the `examples/`, install with: + +```bash +$ pip install dm-env-rpc[examples] +``` + +Alternatively, you can install `dm_env_rpc` by cloning a local copy of our +GitHub repository: + +```bash +$ git clone --recursive https://github.com/deepmind/dm_env_rpc.git +$ pip install ./dm_env_rpc +``` + +## Notice + +This is not an officially supported Google product diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..cf294ce --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,5 @@ +# Release Notes + +## [1.0.0b1] + +* Initial beta release. diff --git a/dm_env_rpc/__init__.py b/dm_env_rpc/__init__.py new file mode 100644 index 0000000..50147d8 --- /dev/null +++ b/dm_env_rpc/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2019 DeepMind Technologies Limited. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +"""A networking protocol for agent-environment communication.""" +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +from dm_env_rpc._version import __version__ diff --git a/dm_env_rpc/_version.py b/dm_env_rpc/_version.py new file mode 100644 index 0000000..e7f9926 --- /dev/null +++ b/dm_env_rpc/_version.py @@ -0,0 +1,20 @@ +# Copyright 2019 DeepMind Technologies Limited. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +"""Package version for dm_env_rpc. + +Kept in separate file so it can be used during installation. +""" + +__version__ = '1.0.0b1' # https://www.python.org/dev/peps/pep-0440/ diff --git a/dm_env_rpc/v1/__init__.py b/dm_env_rpc/v1/__init__.py new file mode 100644 index 0000000..19b2c51 --- /dev/null +++ b/dm_env_rpc/v1/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2019 DeepMind Technologies Limited. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +"""Initial version of dm_env_rpc networking protocol.""" diff --git a/dm_env_rpc/v1/connection.py b/dm_env_rpc/v1/connection.py new file mode 100644 index 0000000..7cdb7bb --- /dev/null +++ b/dm_env_rpc/v1/connection.py @@ -0,0 +1,125 @@ +# Copyright 2019 DeepMind Technologies Limited. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +"""A helper class to manage a connection to a dm_env_rpc server. + +This helper class allows sending the Request message types and receiving the +Response message types without wrapping in an EnvironmentRequest or unwrapping +from an EnvironmentResponse. It also turns error messages in to exceptions. + +For most calls (such as create, join, etc.): + + with connection.Connection(grpc_channel) as channel: + create_response = channel.send(dm_env_rpc_pb2.CreateRequest(settings={ + 'players': 5 + }) + +For the `extension` message type, you must send an Any proto and you'll get back +an Any proto. It is up to you to wrap and unwrap these to concrete proto types +that you know how to handle. + + with connection.Connection(grpc_channel) as channel: + request = struct_pb2.Struct() + ... + request_any = any_pb2.Any() + request_any.Pack(request) + response_any = channel.send(request_any) + response = my_type_pb2.MyType() + response_any.Unpack(response) + + +Any errors encountered in the EnvironmentResponse are turned into Python +exceptions, so explicit error handling code isn't needed per call. +""" + +import queue + +from dm_env_rpc.v1 import dm_env_rpc_pb2 +from dm_env_rpc.v1 import dm_env_rpc_pb2_grpc + + +class _StreamReaderWriter(object): + """Helper class for reading/writing gRPC streams.""" + + def __init__(self, stub): + self._requests = queue.Queue() + self._stream = stub.Process(iter(self._requests.get, None)) + + def write(self, request): + """Asynchronously sends `request` to the stream.""" + self._requests.put(request) + + def read(self): + """Returns the response from stream. Blocking.""" + return next(self._stream) + + +class Connection(object): + """A helper class for interacting with dm_env_rpc servers.""" + + def __init__(self, channel): + """Manages a connection to a dm_env_rpc server. + + Args: + channel: A grpc channel to connect to the dm_env_rpc server over. + """ + self._stream = _StreamReaderWriter( + dm_env_rpc_pb2_grpc.EnvironmentStub(channel)) + self._type_to_field = { + field.message_type.name: field.name + for field in dm_env_rpc_pb2.EnvironmentRequest.DESCRIPTOR.fields + } + + def send(self, request): + """Sends the given request to the dm_env_rpc server and returns the response. + + The request should be an instance of one of the dm_env_rpc Request messages, + such + as CreateWorldRequest. Based on the type the correct payload for the + EnvironmentRequest will be constructed and set to the dm_env_rpc server. + + Blocks until the server sends back its response. + + Args: + request: An instance of a dm_env_rpc Request type, such as + CreateWorldRequest. + + Returns: + The response the dm_env_rpc server returned for the given RPC call, + unwrapped + from the EnvironmentStream message. For instance if `request` had type + `CreateWorldRequest` this returns a message of type `CreateWorldResponse`. + + Raises: + ValueError: The dm_env_rpc server responded to the request with an error. + """ + field_name = self._type_to_field[type(request).__name__] + environment_request = dm_env_rpc_pb2.EnvironmentRequest() + getattr(environment_request, field_name).CopyFrom(request) + self._stream.write(environment_request) + response = self._stream.read() + if response.HasField('error'): + raise ValueError(str(response.error)) + return getattr(response, field_name) + + def close(self): + """Closes the connection. Call when the connection is no longer needed.""" + if self._stream: + del self._stream + + def __exit__(self, *args, **kwargs): + self.close() + + def __enter__(self): + return self diff --git a/dm_env_rpc/v1/connection_test.py b/dm_env_rpc/v1/connection_test.py new file mode 100644 index 0000000..aa3ff51 --- /dev/null +++ b/dm_env_rpc/v1/connection_test.py @@ -0,0 +1,106 @@ +# Copyright 2019 DeepMind Technologies Limited. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +"""Tests for Connection.""" + +import contextlib + +from absl.testing import absltest +from absl.testing import parameterized +import mock + +from google.protobuf import any_pb2 +from google.protobuf import struct_pb2 +from google.rpc import status_pb2 +from dm_env_rpc.v1 import connection as dm_env_rpc_connection +from dm_env_rpc.v1 import dm_env_rpc_pb2 +from dm_env_rpc.v1 import tensor_utils + +_SUEZ_SERVICE = dm_env_rpc_pb2.DESCRIPTOR.services_by_name['Environment'] + +_CREATE_REQUEST = dm_env_rpc_pb2.CreateWorldRequest( + settings={'foo': tensor_utils.pack_tensor('bar')}) +_CREATE_RESPONSE = dm_env_rpc_pb2.CreateWorldResponse() + +_BAD_CREATE_REQUEST = dm_env_rpc_pb2.CreateWorldRequest() +_TEST_ERROR = dm_env_rpc_pb2.EnvironmentResponse( + error=status_pb2.Status(message='A test error.')) + +_EXTENSION_REQUEST = struct_pb2.Value(string_value='extension request') +_EXTENSION_RESPONSE = struct_pb2.Value(number_value=555) + + +def _wrap_in_any(proto): + any_proto = any_pb2.Any() + any_proto.Pack(proto) + return any_proto + + +_REQUEST_RESPONSE_PAIRS = { + dm_env_rpc_pb2.EnvironmentRequest( + create_world=_CREATE_REQUEST).SerializeToString(): + dm_env_rpc_pb2.EnvironmentResponse(create_world=_CREATE_RESPONSE), + dm_env_rpc_pb2.EnvironmentRequest( + create_world=_BAD_CREATE_REQUEST).SerializeToString(): + _TEST_ERROR, + dm_env_rpc_pb2.EnvironmentRequest( + extension=_wrap_in_any(_EXTENSION_REQUEST)).SerializeToString(): + dm_env_rpc_pb2.EnvironmentResponse( + extension=_wrap_in_any(_EXTENSION_RESPONSE)), +} + + +def _process(request_iterator): + for request in request_iterator: + yield _REQUEST_RESPONSE_PAIRS.get(request.SerializeToString(), _TEST_ERROR) + + +@contextlib.contextmanager +def _create_mock_channel(): + """Mocks out gRPC and returns a channel to be passed to Connection.""" + with mock.patch.object(dm_env_rpc_connection, + 'dm_env_rpc_pb2_grpc') as mock_grpc: + mock_stub_class = mock.MagicMock() + mock_stub_class.Process = _process + mock_grpc.EnvironmentStub.return_value = mock_stub_class + yield mock.MagicMock() + + +class ConnectionTests(parameterized.TestCase): + + def test_create(self): + with _create_mock_channel() as mock_channel: + with dm_env_rpc_connection.Connection(mock_channel) as connection: + response = connection.send(_CREATE_REQUEST) + self.assertEqual(_CREATE_RESPONSE, response) + + def test_error(self): + with _create_mock_channel() as mock_channel: + with dm_env_rpc_connection.Connection(mock_channel) as connection: + with self.assertRaisesRegex(ValueError, 'test error'): + connection.send(_BAD_CREATE_REQUEST) + + def test_extension(self): + with _create_mock_channel() as mock_channel: + with dm_env_rpc_connection.Connection(mock_channel) as connection: + request = any_pb2.Any() + request.Pack(_EXTENSION_REQUEST) + response = connection.send(request) + expected_response = any_pb2.Any() + expected_response.Pack(_EXTENSION_RESPONSE) + self.assertEqual(expected_response, response) + + +if __name__ == '__main__': + absltest.main() diff --git a/dm_env_rpc/v1/dm_env_adaptor.py b/dm_env_rpc/v1/dm_env_adaptor.py new file mode 100644 index 0000000..a559ad6 --- /dev/null +++ b/dm_env_rpc/v1/dm_env_adaptor.py @@ -0,0 +1,206 @@ +# Copyright 2019 DeepMind Technologies Limited. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +"""An implementation of a dm_env environment using dm_env_rpc.""" + +import dm_env + +from dm_env_rpc.v1 import dm_env_rpc_pb2 +from dm_env_rpc.v1 import dm_env_utils +from dm_env_rpc.v1 import spec_manager + +# Default observation names for common RL concepts. By default the dm_env +# wrapper will use these for reward and discount if available, but this behavior +# can be overridden. +DEFAULT_REWARD_KEY = 'reward' +DEFAULT_DISCOUNT_KEY = 'discount' + + +class DmEnvAdaptor(dm_env.Environment): + """An implementation of dm_env using dm_env_rpc as the data protocol.""" + + def __init__(self, connection, specs, requested_observations=None): + """Initializes the environment with the provided dm_env_rpc connection. + + Args: + connection: An instance of Connection already connected to a dm_env_rpc + server and after a successful JoinWorldRequest has been sent. + specs: A dm_env_rpc ActionObservationSpecs message for the environment. + requested_observations: The observation names to be requested from the + environment when step is called. If None is specified then all + observations will be requested. + """ + self._dm_env_rpc_specs = specs + self._action_specs = spec_manager.SpecManager(specs.actions) + self._observation_specs = spec_manager.SpecManager(specs.observations) + self._connection = connection + self._last_state = dm_env_rpc_pb2.EnvironmentStateType.TERMINATED + + if requested_observations is None: + requested_observations = self._observation_specs.names() + requested_observations = set(requested_observations) + + self._is_reward_requested = DEFAULT_REWARD_KEY in requested_observations + self._is_discount_requested = DEFAULT_DISCOUNT_KEY in requested_observations + + self._default_reward_spec = None + self._default_discount_spec = None + if DEFAULT_REWARD_KEY in self._observation_specs.names(): + self._default_reward_spec = dm_env_utils.tensor_spec_to_dm_env_spec( + self._observation_specs.name_to_spec(DEFAULT_REWARD_KEY)) + requested_observations.add(DEFAULT_REWARD_KEY) + if DEFAULT_DISCOUNT_KEY in self._observation_specs.names(): + self._default_discount_spec = ( + dm_env_utils.tensor_spec_to_dm_env_spec( + self._observation_specs.name_to_spec(DEFAULT_DISCOUNT_KEY))) + requested_observations.add(DEFAULT_DISCOUNT_KEY) + + unsupported_observations = requested_observations.difference( + self._observation_specs.names()) + if unsupported_observations: + raise ValueError('Unsupported observations requested: {}'.format( + unsupported_observations)) + self._requested_observation_uids = [ + self._observation_specs.name_to_uid(name) + for name in requested_observations + ] + + # Not strictly necessary but it makes the unit tests deterministic. + self._requested_observation_uids.sort() + + def reset(self): + """Implements dm_env.Environment.reset.""" + response = self._connection.send(dm_env_rpc_pb2.ResetRequest()) + if self._dm_env_rpc_specs != response.specs: + raise RuntimeError('Environment changed spec after reset') + self._last_state = dm_env_rpc_pb2.EnvironmentStateType.INTERRUPTED + return self.step({}) + + def step(self, actions): + """Implements dm_env.Environment.step.""" + step_response = self._connection.send( + dm_env_rpc_pb2.StepRequest( + requested_observations=self._requested_observation_uids, + actions=self._action_specs.pack(actions))) + + observations = self._observation_specs.unpack(step_response.observations) + + if (step_response.state == dm_env_rpc_pb2.EnvironmentStateType.RUNNING and + self._last_state == dm_env_rpc_pb2.EnvironmentStateType.RUNNING): + step_type = dm_env.StepType.MID + elif step_response.state == dm_env_rpc_pb2.EnvironmentStateType.RUNNING: + step_type = dm_env.StepType.FIRST + elif self._last_state == dm_env_rpc_pb2.EnvironmentStateType.RUNNING: + step_type = dm_env.StepType.LAST + else: + raise RuntimeError('Environment transitioned from {} to {}'.format( + self._last_state, step_response.state)) + + self._last_state = step_response.state + + reward = self.reward( + state=step_response.state, + step_type=step_type, + observations=observations) + discount = self.discount( + state=step_response.state, + step_type=step_type, + observations=observations) + if not self._is_reward_requested: + observations.pop(DEFAULT_REWARD_KEY, None) + if not self._is_discount_requested: + observations.pop(DEFAULT_DISCOUNT_KEY, None) + return dm_env.TimeStep(step_type, reward, discount, observations) + + def reward(self, state, step_type, observations): + """Returns the reward for the given observation state. + + Override in inherited classes to give different reward functions. + + Args: + state: A dm_env_rpc EnvironmentStateType enum describing the state of the + environment. + step_type: The dm_env StepType describing the state of the environment. + observations: The unpacked observations dictionary mapping string keys to + scalars and NumPy arrays. + + Returns: + A reward for the given step. The shape and type matches that returned by + `self.reward_spec()`. + """ + if step_type == dm_env.StepType.FIRST: + return None + elif self._default_reward_spec: + return observations[DEFAULT_REWARD_KEY] + else: + return 0.0 + + def discount(self, state, step_type, observations): + """Returns the discount for the given observation state. + + Override in inherited classes to give different discount functions. + + Args: + state: A dm_env_rpc EnvironmentStateType enum describing the state of the + environment. + step_type: The dm_env StepType describing the state of the environment. + observations: The unpacked observations dictionary mapping string keys to + scalars and NumPy arrays. + + Returns: + The discount for the given step. The shape and type matches that returned + by `self.discount_spec()`. + """ + if self._default_discount_spec: + return observations[DEFAULT_DISCOUNT_KEY] + if step_type == dm_env.StepType.FIRST: + return None + elif (state == dm_env_rpc_pb2.EnvironmentStateType.RUNNING or + state == dm_env_rpc_pb2.EnvironmentStateType.INTERRUPTED): + return 1.0 + else: + return 0.0 + + def observation_spec(self): + """Implements dm_env.Environment.observation_spec.""" + specs = {} + for uid in self._requested_observation_uids: + name = self._observation_specs.uid_to_name(uid) + specs[name] = dm_env_utils.tensor_spec_to_dm_env_spec( + self._observation_specs.uid_to_spec(uid)) + if not self._is_reward_requested: + specs.pop(DEFAULT_REWARD_KEY, None) + if not self._is_discount_requested: + specs.pop(DEFAULT_DISCOUNT_KEY, None) + return specs + + def action_spec(self): + """Implements dm_env.Environment.action_spec.""" + return dm_env_utils.dm_env_spec(self._action_specs) + + def reward_spec(self): + """Implements dm_env.Environment.reward_spec.""" + return (self._default_reward_spec or + super(DmEnvAdaptor, self).reward_spec()) + + def discount_spec(self): + """Implements dm_env.Environment.discount_spec.""" + return (self._default_discount_spec or + super(DmEnvAdaptor, self).discount_spec()) + + def close(self): + """Implements dm_env.Environment.close.""" + # Leaves the world if we were joined. If not, this will be a no-op anyway. + self._connection.send(dm_env_rpc_pb2.LeaveWorldRequest()) + self._connection = None diff --git a/dm_env_rpc/v1/dm_env_adaptor_test.py b/dm_env_rpc/v1/dm_env_adaptor_test.py new file mode 100644 index 0000000..6a37a68 --- /dev/null +++ b/dm_env_rpc/v1/dm_env_adaptor_test.py @@ -0,0 +1,347 @@ +# Copyright 2019 DeepMind Technologies Limited. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +"""Tests for dm_env_rpc/dm_env adaptor.""" + +from absl.testing import absltest +from absl.testing import parameterized +import dm_env +from dm_env import specs +import mock +import numpy as np + +from dm_env_rpc.v1 import dm_env_adaptor +from dm_env_rpc.v1 import dm_env_rpc_pb2 +from dm_env_rpc.v1 import tensor_utils + +_SAMPLE_STEP_REQUEST = dm_env_rpc_pb2.StepRequest( + requested_observations=[1, 2], + actions={ + 1: tensor_utils.pack_tensor(4, dtype=dm_env_rpc_pb2.UINT8), + 2: tensor_utils.pack_tensor('hello') + }) +_SAMPLE_STEP_RESPONSE = dm_env_rpc_pb2.StepResponse( + state=dm_env_rpc_pb2.EnvironmentStateType.RUNNING, + observations={ + 1: tensor_utils.pack_tensor(5, dtype=dm_env_rpc_pb2.UINT8), + 2: tensor_utils.pack_tensor('goodbye') + }) +_TERMINATED_STEP_RESPONSE = dm_env_rpc_pb2.StepResponse( + state=dm_env_rpc_pb2.EnvironmentStateType.TERMINATED, + observations={ + 1: tensor_utils.pack_tensor(5, dtype=dm_env_rpc_pb2.UINT8), + 2: tensor_utils.pack_tensor('goodbye') + }) +_SAMPLE_SPEC = dm_env_rpc_pb2.ActionObservationSpecs( + actions={ + 1: dm_env_rpc_pb2.TensorSpec(dtype=dm_env_rpc_pb2.UINT8, name='foo'), + 2: dm_env_rpc_pb2.TensorSpec(dtype=dm_env_rpc_pb2.STRING, name='bar') + }, + observations={ + 1: dm_env_rpc_pb2.TensorSpec(dtype=dm_env_rpc_pb2.UINT8, name='foo'), + 2: dm_env_rpc_pb2.TensorSpec(dtype=dm_env_rpc_pb2.STRING, name='bar') + }, +) +_SAMPLE_SPEC_REORDERED = dm_env_rpc_pb2.ActionObservationSpecs( + observations={ + 2: dm_env_rpc_pb2.TensorSpec(dtype=dm_env_rpc_pb2.STRING, name='bar'), + 1: dm_env_rpc_pb2.TensorSpec(dtype=dm_env_rpc_pb2.UINT8, name='foo') + }, + actions={ + 2: dm_env_rpc_pb2.TensorSpec(dtype=dm_env_rpc_pb2.STRING, name='bar'), + 1: dm_env_rpc_pb2.TensorSpec(dtype=dm_env_rpc_pb2.UINT8, name='foo') + }, +) +# Ensures the equality check in reset() works if the dictionary elements are +# created in a different order. +_SAMPLE_RESET_RESPONSE = dm_env_rpc_pb2.ResetResponse( + specs=_SAMPLE_SPEC_REORDERED) +_RESET_CHANGES_SPEC_RESPONSE = dm_env_rpc_pb2.ResetResponse( + specs=dm_env_rpc_pb2.ActionObservationSpecs()) + +_RESERVED_SPEC = dm_env_rpc_pb2.ActionObservationSpecs( + actions={}, + observations={ + 1: + dm_env_rpc_pb2.TensorSpec( + dtype=dm_env_rpc_pb2.UINT8, + name=dm_env_adaptor.DEFAULT_REWARD_KEY), + 2: + dm_env_rpc_pb2.TensorSpec( + dtype=dm_env_rpc_pb2.STRING, + name=dm_env_adaptor.DEFAULT_DISCOUNT_KEY) + }) +_RESERVED_STEP_RESPONSE = dm_env_rpc_pb2.StepResponse( + state=dm_env_rpc_pb2.EnvironmentStateType.RUNNING, + observations={ + 1: tensor_utils.pack_tensor(5, dtype=dm_env_rpc_pb2.UINT8), + 2: tensor_utils.pack_tensor('goodbye') + }) + + +class DmEnvAdaptorTests(parameterized.TestCase): + + def setUp(self): + super(DmEnvAdaptorTests, self).setUp() + self._connection = mock.MagicMock() + self._env = dm_env_adaptor.DmEnvAdaptor(self._connection, _SAMPLE_SPEC) + + def test_requested_observations(self): + requested_observations = ['foo'] + filtered_env = dm_env_adaptor.DmEnvAdaptor(self._connection, _SAMPLE_SPEC, + requested_observations) + + expected_filtered_step_request = dm_env_rpc_pb2.StepRequest( + requested_observations=[1], + actions={ + 1: tensor_utils.pack_tensor(4, dtype=dm_env_rpc_pb2.UINT8), + 2: tensor_utils.pack_tensor('hello') + }) + + self._connection.send = mock.MagicMock(return_value=_SAMPLE_STEP_RESPONSE) + filtered_env.step({'foo': 4, 'bar': 'hello'}) + + self._connection.send.assert_called_once_with( + expected_filtered_step_request) + + def test_invalid_requested_observations(self): + requested_observations = ['invalid'] + with self.assertRaisesRegex(ValueError, + 'Unsupported observations requested'): + dm_env_adaptor.DmEnvAdaptor(self._connection, _SAMPLE_SPEC, + requested_observations) + + def test_requested_observation_spec(self): + requested_observations = ['foo'] + filtered_env = dm_env_adaptor.DmEnvAdaptor(self._connection, _SAMPLE_SPEC, + requested_observations) + observation_names = [name for name in filtered_env.observation_spec()] + self.assertEqual(requested_observations, observation_names) + + def test_first_running_step(self): + self._connection.send = mock.MagicMock(return_value=_SAMPLE_STEP_RESPONSE) + timestep = self._env.step({'foo': 4, 'bar': 'hello'}) + + self._connection.send.assert_called_once_with(_SAMPLE_STEP_REQUEST) + self.assertEqual(dm_env.StepType.FIRST, timestep.step_type) + self.assertEqual(None, timestep.reward) + self.assertEqual(None, timestep.discount) + self.assertEqual({'foo': 5, 'bar': 'goodbye'}, timestep.observation) + + def test_mid_running_step(self): + self._connection.send = mock.MagicMock(return_value=_SAMPLE_STEP_RESPONSE) + self._env.step({'foo': 4, 'bar': 'hello'}) + self._connection.send.assert_called_once_with(_SAMPLE_STEP_REQUEST) + timestep = self._env.step({'foo': 4, 'bar': 'hello'}) + + self.assertEqual(dm_env.StepType.MID, timestep.step_type) + self.assertEqual(0.0, timestep.reward) + self.assertEqual(1.0, timestep.discount) + self.assertEqual({'foo': 5, 'bar': 'goodbye'}, timestep.observation) + + def test_last_step(self): + self._connection.send = mock.MagicMock(return_value=_SAMPLE_STEP_RESPONSE) + self._env.step({'foo': 4, 'bar': 'hello'}) + self._connection.send.assert_called_once_with(_SAMPLE_STEP_REQUEST) + self._connection.send = mock.MagicMock( + return_value=_TERMINATED_STEP_RESPONSE) + timestep = self._env.step({'foo': 4, 'bar': 'hello'}) + + self.assertEqual(dm_env.StepType.LAST, timestep.step_type) + self.assertEqual(0.0, timestep.reward) + self.assertEqual(0.0, timestep.discount) + self.assertEqual({'foo': 5, 'bar': 'goodbye'}, timestep.observation) + + def test_illegal_state_transition(self): + self._connection.send = mock.MagicMock( + return_value=_TERMINATED_STEP_RESPONSE) + with self.assertRaisesRegex(RuntimeError, 'Environment transitioned'): + self._env.step({}) + + def test_reset(self): + self._connection.send = mock.MagicMock(return_value=_SAMPLE_STEP_RESPONSE) + self._env.step({'foo': 4, 'bar': 'hello'}) + self._connection.send.assert_called_once_with(_SAMPLE_STEP_REQUEST) + self._connection.send = mock.MagicMock( + side_effect=[_SAMPLE_RESET_RESPONSE, _SAMPLE_STEP_RESPONSE]) + timestep = self._env.reset() + + self.assertEqual(dm_env.StepType.FIRST, timestep.step_type) + self.assertEqual(None, timestep.reward) + self.assertEqual(None, timestep.discount) + self.assertEqual({'foo': 5, 'bar': 'goodbye'}, timestep.observation) + + def test_reset_changes_spec_raises_error(self): + self._connection.send = mock.MagicMock(return_value=_SAMPLE_STEP_RESPONSE) + self._env.step({'foo': 4, 'bar': 'hello'}) + self._connection.send.assert_called_once_with(_SAMPLE_STEP_REQUEST) + self._connection.send = mock.MagicMock( + side_effect=[_RESET_CHANGES_SPEC_RESPONSE, _SAMPLE_STEP_RESPONSE]) + with self.assertRaisesRegex(RuntimeError, 'changed spec'): + self._env.reset() + + def test_observation_spec(self): + expected_spec = { + 'foo': specs.Array(shape=(), dtype=np.uint8, name='foo'), + 'bar': specs.Array(shape=(), dtype=np.str_, name='bar') + } + self.assertEqual(expected_spec, self._env.observation_spec()) + + def test_action_spec(self): + expected_spec = { + 'foo': specs.Array(shape=(), dtype=np.uint8, name='foo'), + 'bar': specs.Array(shape=(), dtype=np.str_, name='bar') + } + self.assertEqual(expected_spec, self._env.action_spec()) + + def test_cant_step_after_close(self): + self._connection.send = mock.MagicMock( + return_value=dm_env_rpc_pb2.LeaveWorldResponse()) + self._env.close() + with self.assertRaisesRegex(AttributeError, 'send'): + self._env.step({}) + + def test_reward_spec_default(self): + self.assertEqual( + specs.Array(shape=(), dtype=np.float64), self._env.reward_spec()) + + def test_discount_spec_default(self): + self.assertEqual( + specs.BoundedArray( + shape=(), dtype=np.float64, minimum=0.0, maximum=1.0), + self._env.discount_spec()) + + def test_close_leaves_world(self): + self._connection.send = mock.MagicMock( + return_value=dm_env_rpc_pb2.LeaveWorldResponse()) + self._env.close() + self._connection.send.assert_called_once_with( + dm_env_rpc_pb2.LeaveWorldRequest()) + + def test_close_errors_when_cannot_leave_world(self): + self._connection.send = mock.MagicMock(side_effect=ValueError('foo')) + with self.assertRaisesRegex(ValueError, 'foo'): + self._env.close() + + +class OverrideRewardDiscount(dm_env_adaptor.DmEnvAdaptor): + + def __init__(self): + self.connection = mock.MagicMock() + self.reward = mock.MagicMock() + self.discount = mock.MagicMock() + super(OverrideRewardDiscount, self).__init__(self.connection, _SAMPLE_SPEC) + + +class RewardDiscountOverrideTests(parameterized.TestCase): + + def test_override_reward(self): + env = OverrideRewardDiscount() + env.reward.return_value = 0.5 + env.connection.send.return_value = _SAMPLE_STEP_RESPONSE + timestep = env.step({}) + self.assertEqual(0.5, timestep.reward) + env.reward.assert_called() + self.assertEqual(dm_env_rpc_pb2.EnvironmentStateType.RUNNING, + env.reward.call_args[1]['state']) + self.assertEqual(dm_env.StepType.FIRST, + env.reward.call_args[1]['step_type']) + self.assertDictEqual({ + 'foo': 5, + 'bar': 'goodbye' + }, env.reward.call_args[1]['observations']) + + def test_override_discount(self): + env = OverrideRewardDiscount() + env.discount.return_value = 0.5 + env.connection.send.return_value = _SAMPLE_STEP_RESPONSE + timestep = env.step({}) + self.assertEqual(0.5, timestep.discount) + env.discount.assert_called() + self.assertEqual(dm_env_rpc_pb2.EnvironmentStateType.RUNNING, + env.discount.call_args[1]['state']) + self.assertEqual(dm_env.StepType.FIRST, + env.discount.call_args[1]['step_type']) + self.assertDictEqual({ + 'foo': 5, + 'bar': 'goodbye' + }, env.discount.call_args[1]['observations']) + + +class ReservedKeywordTests(parameterized.TestCase): + + def setUp(self): + super(ReservedKeywordTests, self).setUp() + self._connection = mock.MagicMock() + self._env = dm_env_adaptor.DmEnvAdaptor(self._connection, _RESERVED_SPEC) + + def test_reward_spec(self): + self.assertEqual( + specs.Array(shape=(), dtype=np.uint8), self._env.reward_spec()) + + def test_discount_spec(self): + self.assertEqual( + specs.Array(shape=(), dtype=np.str_), self._env.discount_spec()) + + def test_reward_from_reserved_keyword(self): + self._connection.send = mock.MagicMock(return_value=_RESERVED_STEP_RESPONSE) + self._env.step({}) # Reward is None for first step. + timestep = self._env.step({}) + + self.assertEqual(5, timestep.reward) + + def test_discount(self): + self._connection.send = mock.MagicMock(return_value=_RESERVED_STEP_RESPONSE) + timestep = self._env.step({}) + + self.assertEqual('goodbye', timestep.discount) + + +class EnvironmentAutomaticallyRequestsReservedKeywords(parameterized.TestCase): + + def setUp(self): + super(EnvironmentAutomaticallyRequestsReservedKeywords, self).setUp() + self._connection = mock.MagicMock() + self._env = dm_env_adaptor.DmEnvAdaptor( + self._connection, _RESERVED_SPEC, requested_observations=[]) + self._connection.send = mock.MagicMock(return_value=_RESERVED_STEP_RESPONSE) + + def test_reward_spec_unrequested(self): + self.assertEqual( + specs.Array(shape=(), dtype=np.uint8), self._env.reward_spec()) + + def test_discount_spec_unrequested(self): + self.assertEqual( + specs.Array(shape=(), dtype=np.str_), self._env.discount_spec()) + + def test_does_not_give_back_unrequested_observations(self): + timestep = self._env.step({}) + self.assertEqual({}, timestep.observation) + + def test_first_reward_none(self): + timestep = self._env.step({}) + self.assertIsNone(timestep.reward) + + def test_reward_piped_correctly(self): + self._env.step({}) # Reward is None for first step. + timestep = self._env.step({}) + self.assertEqual(5, timestep.reward) + + def test_discount_piped_correctly(self): + timestep = self._env.step({}) + self.assertEqual('goodbye', timestep.discount) + + +if __name__ == '__main__': + absltest.main() diff --git a/dm_env_rpc/v1/dm_env_rpc.proto b/dm_env_rpc/v1/dm_env_rpc.proto new file mode 100644 index 0000000..ec77cc7 --- /dev/null +++ b/dm_env_rpc/v1/dm_env_rpc.proto @@ -0,0 +1,319 @@ +// Copyright 2019 DeepMind Technologies Limited. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// =========================================================================== +syntax = "proto3"; + +package dm_env_rpc.v1; + +import "google/protobuf/any.proto"; +import "google/rpc/status.proto"; + +// A potentially multi-dimensional array of data, laid out in row-major format. +// Note, only one data channel should be used at a time. +message Tensor { + message Int8Array { + bytes array = 1; + } + message Int32Array { + repeated int32 array = 1; + } + message Int64Array { + repeated int64 array = 1; + } + message Uint8Array { + bytes array = 1; + } + message Uint32Array { + repeated uint32 array = 1; + } + message Uint64Array { + repeated uint64 array = 1; + } + message FloatArray { + repeated float array = 1; + } + message DoubleArray { + repeated double array = 1; + } + message BoolArray { + repeated bool array = 1; + } + message StringArray { + repeated string array = 1; + } + message ProtoArray { + repeated google.protobuf.Any array = 1; + } + + // The flattened tensor data. Data is laid out in row-major order. + oneof payload { + // LINT.IfChange(Tensor) + FloatArray floats = 1; + DoubleArray doubles = 2; + Int8Array int8s = 3; + Int32Array int32s = 4; + Int64Array int64s = 5; + Uint8Array uint8s = 6; + Uint32Array uint32s = 7; + Uint64Array uint64s = 8; + BoolArray bools = 9; + StringArray strings = 10; + ProtoArray protos = 11; + // LINT.ThenChange(:DataType) + } + + // The dimensions of the repeated data fields. If empty, the data channel + // will be treated as a scalar and expected to have exactly one element. + // + // If the payload has exactly one element, it will be repeated to fill the + // shape. + // + // A negative element in a dimension indicates its size should be determined + // based on the number of elements in the payload and the rest of the shape. + // For instance, a shape of [-1, 5] means the shape is a matrix with 5 columns + // and a variable number of rows. Only one element in the shape may be set to + // a negative value. + repeated int32 shape = 15; +} + +// The data type of elements of a tensor. This must match the types in the +// Tensor payload oneof. +enum DataType { + // This is the default value indicating no value was set. It should never be + // sent or received. + INVALID_DATA_TYPE = 0; + + // LINT.IfChange(DataType) + FLOAT = 1; + DOUBLE = 2; + INT8 = 3; + INT32 = 4; + INT64 = 5; + UINT8 = 6; + UINT32 = 7; + UINT64 = 8; + BOOL = 9; + STRING = 10; + PROTO = 11; + // LINT.ThenChange(:Tensor) +} + +message TensorSpec { + message Value { + oneof value { + float float = 1; + double double = 2; + int32 int32 = 3; + int64 int64 = 4; + uint32 uint32 = 5; + uint64 uint64 = 6; + } + } + + // A human-readable name describing this tensor. + string name = 1; + + // The dimensionality of the tensor. See Tensor.shape for more information. + repeated int32 shape = 2; + + // The data type of the elements in the tensor. + DataType dtype = 3; + + // The minimum value that elements in the tensor can obtain. Inclusive. + Value min = 4; // Optional + + // The maximum value that elements in the tensor can obtain. Inclusive. + Value max = 5; // Optional +} + +message CreateWorldRequest { + // Settings to create the world with. This can define the level layout, the + // number of agents, the goal or game mode, or other universal settings. + // Agent-specific settings, such as anything which would change the action or + // observation spec, should go in the JoinWorldRequest. + map settings = 1; +} +message CreateWorldResponse { + // The unique name for the world just created. + string world_name = 1; +} + +message ActionObservationSpecs { + map actions = 1; + + map observations = 2; +} + +message JoinWorldRequest { + // The name of the world to join. + string world_name = 1; + + // Agent-specific settings which define how to join the world, such as agent + // name and class in an RPG. + map settings = 2; +} +message JoinWorldResponse { + ActionObservationSpecs specs = 1; +} + +enum EnvironmentStateType { + // This is the default value indicating no value was set. It should never be + // sent or received. + INVALID_ENVIRONMENT_STATE = 0; + + // The environment is currently in the middle of a sequence. + RUNNING = 1; + + // The previously running sequence reached its natural conclusion. + TERMINATED = 2; + + // The sequence was interrupted by a reset. + INTERRUPTED = 3; +} + +message StepRequest { + // The actions to perform on the environment. If the environment is currently + // in a non-RUNNING state, whether because the agent has just called + // JoinWorld, the state from the last is StepResponse was TERMINATED or + // INTERRUPTED, or a ResetRequest had previously been sent, the actions will + // be ignored. + map actions = 1; + + // Array of observations UIDs to return. If not set, no observations are + // returned. + repeated uint64 requested_observations = 2; +} + +message StepResponse { + // If state is not RUNNING, the action on the next StepRequest will be + // ignored and the environment will transition to a RUNNING state. + EnvironmentStateType state = 1; + + // The observations requested in `StepRequest`. Observations returned should + // match the dimensionality and type specified in `specs.observations`. + map observations = 2; +} + +// The current sequence will be interrupted. The actions on the next call to +// StepRequest will be ignored and a new sequence will begin. +message ResetRequest { + // Agents-specific settings to apply for the next sequence, such as changing + // class in an RPG. + map settings = 1; +} +message ResetResponse { + ActionObservationSpecs specs = 1; +} + +// All connected agents will have their next StepResponse return INTERRUPTED. +message ResetWorldRequest { + string world_name = 1; + + // World settings to apply for the next sequence, such as changing the map or + // seed. + map settings = 2; +} +message ResetWorldResponse {} + +message LeaveWorldRequest {} +message LeaveWorldResponse {} + +message DestroyWorldRequest { + string world_name = 1; +} +message DestroyWorldResponse {} + +message ReadPropertyRequest { + repeated string keys = 1; +} + +message ReadPropertyResponse { + map properties = 1; +} + +message WritePropertyRequest { + map properties = 1; +} +message WritePropertyResponse {} + +message ListPropertyRequest { + // Keys to list properties for. Empty string is the root level. + repeated string keys = 1; +} +message ListPropertyResponse { + message Property { + bool is_readable = 1; + bool is_writeable = 2; + + // Are there listable sub attributes? + bool is_listable = 3; + + TensorSpec spec = 4; + } + map properties = 1; +} + +message EnvironmentRequest { + oneof payload { + CreateWorldRequest create_world = 1; + JoinWorldRequest join_world = 2; + StepRequest step = 3; + ResetRequest reset = 4; + ResetWorldRequest reset_world = 5; + LeaveWorldRequest leave_world = 6; + DestroyWorldRequest destroy_world = 7; + ReadPropertyRequest read_property = 8; + WritePropertyRequest write_property = 9; + ListPropertyRequest list_property = 10; + + // If the environment supports a specialized request not covered above it + // can be sent this way. + // + // Slot 15 is the last slot which can be encoded with one byte. See + // https://developers.google.com/protocol-buffers/docs/proto3#assigning-field-numbers + google.protobuf.Any extension = 15; + } + + // Slot corresponds to `error` in the EnvironmentResponse. + reserved 16; +} + +message EnvironmentResponse { + oneof payload { + CreateWorldResponse create_world = 1; + JoinWorldResponse join_world = 2; + StepResponse step = 3; + ResetResponse reset = 4; + ResetWorldResponse reset_world = 5; + LeaveWorldResponse leave_world = 6; + DestroyWorldResponse destroy_world = 7; + ReadPropertyResponse read_property = 8; + WritePropertyResponse write_property = 9; + ListPropertyResponse list_property = 10; + + // If the environment supports a specialized response not covered above it + // can be sent this way. + // + // Slot 15 is the last slot which can be encoded with one byte. See + // https://developers.google.com/protocol-buffers/docs/proto3#assigning-field-numbers + google.protobuf.Any extension = 15; + + google.rpc.Status error = 16; + } +} + +service Environment { + // Process incoming environment requests. + rpc Process(stream EnvironmentRequest) returns (stream EnvironmentResponse) {} +} diff --git a/dm_env_rpc/v1/dm_env_rpc_test.py b/dm_env_rpc/v1/dm_env_rpc_test.py new file mode 100644 index 0000000..d6fbe75 --- /dev/null +++ b/dm_env_rpc/v1/dm_env_rpc_test.py @@ -0,0 +1,74 @@ +# Copyright 2019 DeepMind Technologies Limited. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +"""Tests for environment_stream.proto. + +These aren't for testing functionality (it's assumed protobufs work) but for +testing/demonstrating how the protobufs would have to be used in code. +""" + +from absl.testing import absltest +from absl.testing import parameterized +from dm_env_rpc.v1 import dm_env_rpc_pb2 + + +class TensorTests(parameterized.TestCase): + + def test_setting_tensor_data(self): + tensor = dm_env_rpc_pb2.Tensor() + tensor.floats.array[:] = [1, 2] + + def test_setting_tensor_data_with_wrong_type(self): + tensor = dm_env_rpc_pb2.Tensor() + with self.assertRaises(TypeError): + tensor.floats.array[:] = ['hello!'] + + def test_which_is_set(self): + tensor = dm_env_rpc_pb2.Tensor() + tensor.floats.array[:] = [1, 2] + self.assertEqual('floats', tensor.WhichOneof('payload')) + + +class TensorSpec(parameterized.TestCase): + + def test_setting_spec(self): + tensor_spec = dm_env_rpc_pb2.TensorSpec() + tensor_spec.name = 'Foo' + tensor_spec.min.float = 0.0 + tensor_spec.max.float = 0.0 + tensor_spec.shape[:] = [2, 2] + tensor_spec.dtype = dm_env_rpc_pb2.DataType.FLOAT + + +class JoinWorldResponse(parameterized.TestCase): + + def test_setting_spec(self): + response = dm_env_rpc_pb2.JoinWorldResponse() + tensor_spec = response.specs.actions[1] + tensor_spec.shape[:] = [1] + tensor_spec.dtype = dm_env_rpc_pb2.DataType.FLOAT + + +class ListPropertyResponse(parameterized.TestCase): + + def test_setting_response(self): + response = dm_env_rpc_pb2.ListPropertyResponse() + prop = response.properties['foo'] + prop.is_readable = True + prop.is_writeable = True + prop.is_listable = False + + +if __name__ == '__main__': + absltest.main() diff --git a/dm_env_rpc/v1/dm_env_utils.py b/dm_env_rpc/v1/dm_env_utils.py new file mode 100644 index 0000000..ae608b1 --- /dev/null +++ b/dm_env_rpc/v1/dm_env_utils.py @@ -0,0 +1,81 @@ +# Copyright 2019 DeepMind Technologies Limited. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +"""Utilities for interfacing dm_env and dm_env_rpc.""" + +from dm_env import specs +import numpy as np + +from dm_env_rpc.v1 import tensor_utils + + +def _np_range_info(np_type): + """Returns type info for `np_type`, which includes min and max attributes.""" + if issubclass(np_type, np.floating): + return np.finfo(np_type) + elif issubclass(np_type, np.integer): + return np.iinfo(np_type) + else: + raise ValueError('{} does not have range info.'.format(np_type)) + + +def _find_extreme(tensor_spec, tensor_type, extremal_name): + """Finds the min or max value the tensor spec sets.""" + explicit_extreme = None + if tensor_spec.HasField(extremal_name): + explicit_extreme = getattr(tensor_spec, extremal_name) + if explicit_extreme is not None: + value_field = explicit_extreme.WhichOneof('value') + if value_field is None: + raise ValueError( + 'Tensor spec had {} present but no value was given.'.format( + extremal_name)) + if getattr(np, value_field) != tensor_type: + raise ValueError( + 'Tensor spec had {}.{} set, but tensor has type {}.'.format( + extremal_name, value_field, tensor_type)) + return getattr(explicit_extreme, value_field) + else: + return getattr(_np_range_info(tensor_type), extremal_name) + + +def tensor_spec_to_dm_env_spec(tensor_spec): + """Returns the dm_env Array or BoundedArray given a dm_env_rpc TensorSpec.""" + tensor_type = tensor_utils.data_type_to_np_type(tensor_spec.dtype) + if tensor_spec.HasField('min') or tensor_spec.HasField('max'): + return specs.BoundedArray( + shape=tensor_spec.shape, + dtype=tensor_type, + name=tensor_spec.name, + minimum=_find_extreme(tensor_spec, tensor_type, 'min'), + maximum=_find_extreme(tensor_spec, tensor_type, 'max')) + else: + return specs.Array( + shape=tensor_spec.shape, dtype=tensor_type, name=tensor_spec.name) + + +def dm_env_spec(spec_manager): + """Returns a dm_env spec for the given `spec_manager`. + + Args: + spec_manager: An instance of SpecManager. + + Returns: + A dict mapping names to either dm_env ArraySpecs or BoundedArraySpecs for + each named TensorSpec in `spec_manager`. + """ + return { + name: tensor_spec_to_dm_env_spec(spec_manager.name_to_spec(name)) + for name in spec_manager.names() + } diff --git a/dm_env_rpc/v1/dm_env_utils_test.py b/dm_env_rpc/v1/dm_env_utils_test.py new file mode 100644 index 0000000..16d653b --- /dev/null +++ b/dm_env_rpc/v1/dm_env_utils_test.py @@ -0,0 +1,170 @@ +# Copyright 2019 DeepMind Technologies Limited. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +"""Tests for dm_env_rpc/dm_env utilities.""" + +from absl.testing import absltest +from absl.testing import parameterized +from dm_env import specs +import numpy as np + +from dm_env_rpc.v1 import dm_env_rpc_pb2 +from dm_env_rpc.v1 import dm_env_utils +from dm_env_rpc.v1 import spec_manager + + +class NpRangeInfoTests(parameterized.TestCase): + + def test_floating(self): + expected_min = np.finfo(np.float32).min + actual_min = dm_env_utils._np_range_info(np.float32).min + self.assertEqual(expected_min, actual_min) + + def test_integer(self): + actual_min = dm_env_utils._np_range_info(np.uint32).min + self.assertEqual(0, actual_min) + + def test_string_gives_error(self): + with self.assertRaisesRegex(ValueError, 'numpy.str_'): + _ = dm_env_utils._np_range_info(np.str_).min + + +class FindExtremeTests(parameterized.TestCase): + + def test_min_from_type(self): + tensor_spec = dm_env_rpc_pb2.TensorSpec() + tensor_spec.dtype = dm_env_rpc_pb2.DataType.UINT32 + tensor_spec.shape[:] = [3] + tensor_type = np.uint32 + self.assertEqual( + 0, dm_env_utils._find_extreme(tensor_spec, tensor_type, 'min')) + + def test_explicit_min(self): + tensor_spec = dm_env_rpc_pb2.TensorSpec() + tensor_spec.dtype = dm_env_rpc_pb2.DataType.UINT32 + tensor_spec.shape[:] = [3] + tensor_spec.min.uint32 = 1 + tensor_type = np.uint32 + self.assertEqual( + 1, dm_env_utils._find_extreme(tensor_spec, tensor_type, 'min')) + + +class TensorSpecToDmEnvSpecTests(parameterized.TestCase): + + def test_no_bounds_gives_arrayspec(self): + tensor_spec = dm_env_rpc_pb2.TensorSpec() + tensor_spec.dtype = dm_env_rpc_pb2.DataType.UINT32 + tensor_spec.shape[:] = [3] + tensor_spec.name = 'foo' + actual = dm_env_utils.tensor_spec_to_dm_env_spec(tensor_spec) + self.assertEqual(specs.Array(shape=[3], dtype=np.uint32), actual) + self.assertEqual('foo', actual.name) + + def test_only_min_bounds(self): + tensor_spec = dm_env_rpc_pb2.TensorSpec() + tensor_spec.dtype = dm_env_rpc_pb2.DataType.UINT32 + tensor_spec.shape[:] = [3] + tensor_spec.name = 'foo' + tensor_spec.min.uint32 = 1 + actual = dm_env_utils.tensor_spec_to_dm_env_spec(tensor_spec) + expected = specs.BoundedArray( + shape=[3], dtype=np.uint32, minimum=1, maximum=2**32 - 1) + self.assertEqual(expected, actual) + self.assertEqual('foo', actual.name) + + def test_only_max_bounds(self): + tensor_spec = dm_env_rpc_pb2.TensorSpec() + tensor_spec.dtype = dm_env_rpc_pb2.DataType.UINT32 + tensor_spec.shape[:] = [3] + tensor_spec.name = 'foo' + tensor_spec.max.uint32 = 10 + actual = dm_env_utils.tensor_spec_to_dm_env_spec(tensor_spec) + expected = specs.BoundedArray( + shape=[3], dtype=np.uint32, minimum=0, maximum=10) + self.assertEqual(expected, actual) + self.assertEqual('foo', actual.name) + + def test_both_bounds(self): + tensor_spec = dm_env_rpc_pb2.TensorSpec() + tensor_spec.dtype = dm_env_rpc_pb2.DataType.UINT32 + tensor_spec.shape[:] = [3] + tensor_spec.name = 'foo' + tensor_spec.min.uint32 = 1 + tensor_spec.max.uint32 = 10 + actual = dm_env_utils.tensor_spec_to_dm_env_spec(tensor_spec) + expected = specs.BoundedArray( + shape=[3], dtype=np.uint32, minimum=1, maximum=10) + self.assertEqual(expected, actual) + self.assertEqual('foo', actual.name) + + def test_bounds_oneof_not_set_gives_error(self): + tensor_spec = dm_env_rpc_pb2.TensorSpec() + tensor_spec.dtype = dm_env_rpc_pb2.DataType.UINT32 + tensor_spec.shape[:] = [3] + tensor_spec.name = 'foo' + + # Just to force the message to get created. + tensor_spec.min.float = 3 + tensor_spec.min.ClearField('float') + + with self.assertRaisesRegex(ValueError, 'min'): + dm_env_utils.tensor_spec_to_dm_env_spec(tensor_spec) + + def test_bounds_wrong_type_gives_error(self): + tensor_spec = dm_env_rpc_pb2.TensorSpec() + tensor_spec.dtype = dm_env_rpc_pb2.DataType.UINT32 + tensor_spec.shape[:] = [3] + tensor_spec.name = 'foo' + tensor_spec.min.float = 1.9 + with self.assertRaisesRegex(ValueError, 'numpy.uint32'): + dm_env_utils.tensor_spec_to_dm_env_spec(tensor_spec) + + def test_bounds_on_string_gives_error(self): + tensor_spec = dm_env_rpc_pb2.TensorSpec() + tensor_spec.dtype = dm_env_rpc_pb2.DataType.STRING + tensor_spec.shape[:] = [2] + tensor_spec.name = 'named' + tensor_spec.min.float = 1.9 + tensor_spec.max.float = 10.0 + with self.assertRaisesRegex(ValueError, 'numpy.str_'): + dm_env_utils.tensor_spec_to_dm_env_spec(tensor_spec) + + +class DmEnvSpecTests(parameterized.TestCase): + + def test_spec(self): + dm_env_rpc_specs = { + 54: + dm_env_rpc_pb2.TensorSpec( + name='fuzz', shape=[3], dtype=dm_env_rpc_pb2.DataType.FLOAT), + 55: + dm_env_rpc_pb2.TensorSpec( + name='foo', shape=[2], dtype=dm_env_rpc_pb2.DataType.INT32), + } + manager = spec_manager.SpecManager(dm_env_rpc_specs) + + expected = { + 'foo': specs.Array(shape=[2], dtype=np.int32), + 'fuzz': specs.Array(shape=[3], dtype=np.float32) + } + + self.assertDictEqual(expected, dm_env_utils.dm_env_spec(manager)) + + def test_empty_spec(self): + self.assertDictEqual({}, + dm_env_utils.dm_env_spec(spec_manager.SpecManager({}))) + + +if __name__ == '__main__': + absltest.main() diff --git a/dm_env_rpc/v1/spec_manager.py b/dm_env_rpc/v1/spec_manager.py new file mode 100644 index 0000000..1bc41a8 --- /dev/null +++ b/dm_env_rpc/v1/spec_manager.py @@ -0,0 +1,122 @@ +# Copyright 2019 DeepMind Technologies Limited. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +"""Manager class to manage the dm_env_rpc UID system.""" + +import numpy as np + +from dm_env_rpc.v1 import tensor_utils + + +def _assert_shapes_match(tensor, dm_env_rpc_spec): + """Raises ValueError if shape of tensor and spec don't match.""" + if not np.array_equal(tensor.shape, dm_env_rpc_spec.shape): + raise ValueError( + 'Received dm_env_rpc tensor {} with shape {} but spec has shape {}.' + .format(dm_env_rpc_spec.name, tensor.shape, dm_env_rpc_spec.shape)) + + +class SpecManager(object): + """Manages transitions between Python dicts and dm_env_rpc UIDs. + + To make sending and receiving actions and observations easier for dm_env_rpc, + this helps manage the transition between UID-keyed dicts mapping to dm_env_rpc + tensors and string-keyed dicts mapping to scalars, lists, or NumPy arrays. + """ + + def __init__(self, specs): + """Builds the SpecManager from the given dm_env_rpc specs. + + Args: + specs: A dict mapping UIDs to dm_env_rpc TensorSpecs, similar to what is + returned by the ActionSpecResponse and ObservationSpecResponse. + """ + self._name_to_uid = { + spec.name: uid for uid, spec in specs.items() + } + self._uid_to_name = { + uid: spec.name for uid, spec in specs.items() + } + if len(self._name_to_uid) != len(self._uid_to_name): + raise ValueError('There are duplicate names in the tensor specs.') + + self._specs_by_uid = specs + self._specs_by_name = { + spec.name: spec for spec in specs.values() + } + + def name_to_uid(self, name): + """Returns the UID for the given name.""" + return self._name_to_uid[name] + + def uid_to_name(self, uid): + """Returns the name for the given UID.""" + return self._uid_to_name[uid] + + def name_to_spec(self, name): + """Returns the dm_env_rpc TensorSpec named `name`.""" + return self._specs_by_name[name] + + def uid_to_spec(self, uid): + """Returns the dm_env_rpc TensorSpec for the given UID.""" + return self._specs_by_uid[uid] + + def names(self): + """Returns the spec names in no particular order.""" + return self._name_to_uid.keys() + + def uids(self): + """Returns the spec UIDs in no particular order.""" + return self._uid_to_name.keys() + + def unpack(self, dm_env_rpc_tensors): + """Unpacks a dm_env_rpc uid-to-tensor map to a name-keyed Python dict. + + Args: + dm_env_rpc_tensors: A dict mapping UIDs to dm_env_rpc tensor protos. + + Returns: + A dict mapping names to scalars and arrays. + """ + unpacked = {} + for uid, tensor in dm_env_rpc_tensors.items(): + name = self._uid_to_name[uid] + dm_env_rpc_spec = self.name_to_spec(name) + _assert_shapes_match(tensor, dm_env_rpc_spec) + tensor_dtype = tensor_utils.get_tensor_type(tensor) + spec_dtype = tensor_utils.data_type_to_np_type(dm_env_rpc_spec.dtype) + if tensor_dtype != spec_dtype: + raise ValueError( + 'Received dm_env_rpc tensor {} with dtype {} but spec has dtype {}.' + .format(name, tensor_dtype, spec_dtype)) + tensor_unpacked = tensor_utils.unpack_tensor(tensor) + unpacked[name] = tensor_unpacked + return unpacked + + def pack(self, tensors): + """Packs a name-keyed Python dict to a dm_env_rpc uid-to-tensor map. + + Args: + tensors: A dict mapping string names to scalars and arrays. + + Returns: + A dict mapping UIDs to dm_env_rpc tensor protos. + """ + packed = {} + for name, value in tensors.items(): + dm_env_rpc_spec = self.name_to_spec(name) + tensor = tensor_utils.pack_tensor(value, dtype=dm_env_rpc_spec.dtype) + _assert_shapes_match(tensor, dm_env_rpc_spec) + packed[self.name_to_uid(name)] = tensor + return packed diff --git a/dm_env_rpc/v1/spec_manager_test.py b/dm_env_rpc/v1/spec_manager_test.py new file mode 100644 index 0000000..98b0e86 --- /dev/null +++ b/dm_env_rpc/v1/spec_manager_test.py @@ -0,0 +1,155 @@ +# Copyright 2019 DeepMind Technologies Limited. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +"""Tests for SpecManager class.""" + +from absl.testing import absltest +from absl.testing import parameterized +import numpy as np + +from dm_env_rpc.v1 import dm_env_rpc_pb2 +from dm_env_rpc.v1 import spec_manager +from dm_env_rpc.v1 import tensor_utils + + +class SpecManagerTests(parameterized.TestCase): + + def setUp(self): + super(SpecManagerTests, self).setUp() + specs = { + 54: + dm_env_rpc_pb2.TensorSpec( + name='fuzz', shape=[2], dtype=dm_env_rpc_pb2.DataType.FLOAT), + 55: + dm_env_rpc_pb2.TensorSpec( + name='foo', shape=[3], dtype=dm_env_rpc_pb2.DataType.INT32), + } + self._spec_manager = spec_manager.SpecManager(specs) + + def test_name_to_uid(self): + self.assertEqual(55, self._spec_manager.name_to_uid('foo')) + + def test_name_to_uid_no_such_name(self): + with self.assertRaisesRegex(KeyError, 'bar'): + self._spec_manager.name_to_uid('bar') + + def test_name_to_spec(self): + spec = self._spec_manager.name_to_spec('foo') + self.assertEqual([3], spec.shape) + + def test_name_to_spec_no_such_name(self): + with self.assertRaisesRegex(KeyError, 'bar'): + self._spec_manager.name_to_spec('bar') + + def test_uid_to_name(self): + self.assertEqual('foo', self._spec_manager.uid_to_name(55)) + + def test_uid_to_name_no_such_uid(self): + with self.assertRaisesRegex(KeyError, '56'): + self._spec_manager.uid_to_name(56) + + def test_names(self): + self.assertEqual(set(['foo', 'fuzz']), self._spec_manager.names()) + + def test_uids(self): + self.assertEqual(set([54, 55]), self._spec_manager.uids()) + + def test_uid_to_spec(self): + spec = self._spec_manager.uid_to_spec(54) + self.assertEqual([2], spec.shape) + + def test_pack(self): + packed = self._spec_manager.pack({'fuzz': [1.0, 2.0], 'foo': [3, 4, 5]}) + expected = { + 54: tensor_utils.pack_tensor([1.0, 2.0], dtype=np.float32), + 55: tensor_utils.pack_tensor([3, 4, 5], dtype=np.int32), + } + self.assertDictEqual(expected, packed) + + def test_partial_pack(self): + packed = self._spec_manager.pack({ + 'fuzz': [1.0, 2.0], + }) + expected = { + 54: tensor_utils.pack_tensor([1.0, 2.0], dtype=np.float32), + } + self.assertDictEqual(expected, packed) + + def test_pack_unknown_key_raises_error(self): + with self.assertRaisesRegex(KeyError, 'buzz'): + self._spec_manager.pack({'buzz': 'hello'}) + + def test_pack_wrong_shape_raises_error(self): + with self.assertRaisesRegex(ValueError, 'shape'): + self._spec_manager.pack({'foo': [1, 2]}) + + def test_pack_wrong_dtype_raises_error(self): + with self.assertRaisesRegex(TypeError, 'int32'): + self._spec_manager.pack({'foo': 'hello'}) + + def test_pack_cast_float_to_int_raises_error(self): + with self.assertRaisesRegex(TypeError, 'int32'): + self._spec_manager.pack({'foo': [0.5, 1.0, 1]}) + + def test_pack_cast_int_to_float_is_ok(self): + packed = self._spec_manager.pack({'fuzz': [1, 2]}) + self.assertEqual([1.0, 2.0], packed[54].floats.array) + + def test_unpack(self): + unpacked = self._spec_manager.unpack({ + 54: tensor_utils.pack_tensor([1.0, 2.0], dtype=np.float32), + 55: tensor_utils.pack_tensor([3, 4, 5], dtype=np.int32), + }) + self.assertLen(unpacked, 2) + np.testing.assert_array_equal(np.asarray([1.0, 2.0]), unpacked['fuzz']) + np.testing.assert_array_equal(np.asarray([3, 4, 5]), unpacked['foo']) + + def test_partial_unpack(self): + unpacked = self._spec_manager.unpack({ + 54: tensor_utils.pack_tensor([1.0, 2.0], dtype=np.float32), + }) + self.assertLen(unpacked, 1) + np.testing.assert_array_equal(np.asarray([1.0, 2.0]), unpacked['fuzz']) + + def test_unpack_unknown_uid_raises_error(self): + with self.assertRaisesRegex(KeyError, '53'): + self._spec_manager.unpack({53: tensor_utils.pack_tensor('foo')}) + + def test_unpack_wrong_shape_raises_error(self): + with self.assertRaisesRegex(ValueError, 'shape'): + self._spec_manager.unpack({55: tensor_utils.pack_tensor([1, 2])}) + + def test_unpack_wrong_type_raises_error(self): + with self.assertRaisesRegex(ValueError, 'dtype'): + self._spec_manager.unpack( + {55: tensor_utils.pack_tensor([1, 2, 3], dtype=np.float32)}) + + +class SpecManagerConstructorTests(parameterized.TestCase): + + def test_duplicate_names_raise_error(self): + specs = { + 54: + dm_env_rpc_pb2.TensorSpec( + name='fuzz', shape=[3], dtype=dm_env_rpc_pb2.DataType.FLOAT), + 55: + dm_env_rpc_pb2.TensorSpec( + name='fuzz', shape=[2], dtype=dm_env_rpc_pb2.DataType.FLOAT), + } + with self.assertRaisesRegex(ValueError, 'duplicate name'): + spec_manager.SpecManager(specs) + + +if __name__ == '__main__': + absltest.main() diff --git a/dm_env_rpc/v1/tensor_utils.py b/dm_env_rpc/v1/tensor_utils.py new file mode 100644 index 0000000..921f410 --- /dev/null +++ b/dm_env_rpc/v1/tensor_utils.py @@ -0,0 +1,187 @@ +# Copyright 2019 DeepMind Technologies Limited. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +"""Helper Python utilities for bridging dm_env_rpc and NumPy. + +Note that the dm_env_rpc proto payload is not supported, as it doesn't play well +with +NumPy. +""" + +import struct + +import numpy as np + +from dm_env_rpc.v1 import dm_env_rpc_pb2 + + +class _BytesWrapper(object): + """Allows protobuf bytes field to be set using the [:] = ... syntax.""" + + def __init__(self, array, signed): + self._array = array + self._signed = signed + + def __getitem__(self, index): + return self._array.array[index] + + def __setitem__(self, index, value): + if index == slice(None, None, None): + self._array.array = struct.pack( + str(len(value)) + ('b' if self._signed else 'B'), *value) + else: + raise ValueError('Unsupported index {}'.format(index)) + + def __len__(self): + return len(self._array.array) + + def as_np_array(self): + return np.frombuffer( + self._array.array, dtype=np.int8 if self._signed else np.uint8) + + +# Payload channel name, NumPy representation, and the payload array. +_RAW_ASSOCIATIONS = [ + ('floats', np.float32, lambda tensor: tensor.floats.array), + ('doubles', np.float64, lambda tensor: tensor.doubles.array), + ('int8s', np.int8, lambda tensor: _BytesWrapper(tensor.int8s, signed=True)), + ('int32s', np.int32, lambda tensor: tensor.int32s.array), + ('int64s', np.int64, lambda tensor: tensor.int64s.array), + ('uint8s', np.uint8, + lambda tensor: _BytesWrapper(tensor.uint8s, signed=False)), + ('uint32s', np.uint32, lambda tensor: tensor.uint32s.array), + ('uint64s', np.uint64, lambda tensor: tensor.uint64s.array), + ('bools', np.bool_, lambda tensor: tensor.bools.array), + ('strings', np.str_, lambda tensor: tensor.strings.array), +] + +_NAME_TO_TYPE_AND_PAYLOAD = { + name: (np_type, payload) for name, np_type, payload in _RAW_ASSOCIATIONS +} + +_TYPE_TO_PAYLOAD = { + np_type: payload for name, np_type, payload in _RAW_ASSOCIATIONS +} + +_DM_ENV_RPC_DTYPE_TO_NUMPY_DTYPE = { + dm_env_rpc_pb2.DataType.FLOAT: np.float32, + dm_env_rpc_pb2.DataType.DOUBLE: np.float64, + dm_env_rpc_pb2.DataType.INT8: np.int8, + dm_env_rpc_pb2.DataType.INT32: np.int32, + dm_env_rpc_pb2.DataType.INT64: np.int64, + dm_env_rpc_pb2.DataType.UINT8: np.uint8, + dm_env_rpc_pb2.DataType.UINT32: np.uint32, + dm_env_rpc_pb2.DataType.UINT64: np.uint64, + dm_env_rpc_pb2.DataType.BOOL: np.bool_, + dm_env_rpc_pb2.DataType.STRING: np.str_, +} + + +def get_tensor_type(tensor_proto): + """Returns the NumPy type for the given tensor.""" + payload = tensor_proto.WhichOneof('payload') + lookup = _NAME_TO_TYPE_AND_PAYLOAD.get(payload) + if not lookup: + raise TypeError('Unknown type {}'.format(payload)) + return lookup[0] + + +def data_type_to_np_type(dm_env_rpc_dtype): + """Returns the NumPy type for the given dm_env_rpc DataType.""" + np_type = _DM_ENV_RPC_DTYPE_TO_NUMPY_DTYPE.get(dm_env_rpc_dtype) + if not np_type: + raise TypeError('Unknown type {}'.format(dm_env_rpc_dtype)) + return np_type + + +def unpack_tensor(tensor_proto): + """Converts a Tensor proto to a scalar or NumPy array. + + Args: + tensor_proto: A dm_env_rpc Tensor protobuf. + + Returns: + If the provided tensor_proto has a non-empty `shape` attribute, returns + a NumPy array of the payload with the correct type and shape. If the + `shape` attribute is empty, returns a scalar (float, int, string, etc.) + of the correct type and value. + """ + payload = tensor_proto.WhichOneof('payload') + + def _unknown_type(*_): + raise TypeError('Unknown type {}'.format(payload)) + + np_type, payload_fetcher = _NAME_TO_TYPE_AND_PAYLOAD.get( + payload, (None, _unknown_type)) + payload_stream = payload_fetcher(tensor_proto) + if tensor_proto.shape: + if len(payload_stream) == 1: + array = np.full(np.maximum(tensor_proto.shape, 1), payload_stream[0]) + else: + if isinstance(payload_stream, _BytesWrapper): + array = payload_stream.as_np_array() + else: + array = np.array(payload_stream, np_type) + array.shape = tensor_proto.shape + return array + else: + length = len(payload_stream) + if length != 1: + raise ValueError( + 'Scalar tensors must have exactly 1 element but had {} elements.' + .format(length)) + return np_type(payload_stream[0]) + + +def pack_tensor(value, dtype=None, try_compress=False): + """Encodes the given value as a tensor. + + Args: + value: A scalar (float, int, string, etc.), NumPy array, or nested lists. + dtype: The type to pack the data to. If set to None, will attempt to detect + the correct type automatically. Either a dm_env_rpc DataType enum or + NumPy type is acceptable. + try_compress: A bool, whether to try and encode the tensor in less space or + not. This will increase the computational cost of the packing, but may + reduce the on-the-wire size of the tensor. There are no guarantees that + any compression will actually happen. + + Raises: + ValueError: If `value` is a jagged array, not a primitive type or nested + iterable of primitive types, or all elements can't be cast to the same + type or the requested type. + + Returns: + A dm_env_rpc Tensor proto containing the data. + """ + packed = dm_env_rpc_pb2.Tensor() + value = np.asarray(value) + if value.dtype == np.object: + raise ValueError('Could not convert to a tensor of primitive types. Are ' + 'the iterables jagged? Or are the data types not ' + 'primitive scalar types like strings, floats, or ' + 'integers?') + if dtype is not None: + value = value.astype( + dtype=_DM_ENV_RPC_DTYPE_TO_NUMPY_DTYPE.get(dtype, dtype), + copy=False, + casting='same_kind') + packed.shape[:] = value.shape + pack_target = _TYPE_TO_PAYLOAD[value.dtype.type](packed) + if (try_compress and np.all(value == next(value.flat))): + # All elements are the same. Pack in to a single value. + pack_target[:] = [next(value.flat)] + else: + pack_target[:] = np.ravel(value).tolist() + return packed diff --git a/dm_env_rpc/v1/tensor_utils_test.py b/dm_env_rpc/v1/tensor_utils_test.py new file mode 100644 index 0000000..e658057 --- /dev/null +++ b/dm_env_rpc/v1/tensor_utils_test.py @@ -0,0 +1,338 @@ +# Copyright 2019 DeepMind Technologies Limited. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +"""Tests for dm_env_rpc helper functions.""" + +import struct + +from absl.testing import absltest +from absl.testing import parameterized +import mock +import numpy as np + +from dm_env_rpc.v1 import dm_env_rpc_pb2 +from dm_env_rpc.v1 import tensor_utils + + +class PackTensorTests(parameterized.TestCase): + + @parameterized.parameters( + (np.float32(2.5), 'floats'), + (2.5, 'doubles'), + (np.int32(-25), 'int32s'), + (np.int64(-25), 'int64s'), + (np.frombuffer(b'\xF0\xF1\xF2\xF3', np.uint32)[0], 'uint32s'), + (np.frombuffer(b'\xF0\xF1\xF2\xF3\xF4\xF5\xF6\xF7', + np.uint64)[0], 'uint64s'), + (True, 'bools'), + (False, 'bools'), + ('foo', 'strings'), + ) + def test_pack_scalars(self, scalar, expected_payload): + tensor = tensor_utils.pack_tensor(scalar) + self.assertEqual([], tensor.shape) + self.assertEqual([scalar], getattr(tensor, expected_payload).array) + + @parameterized.parameters( + (np.int8(-25), 'b', 'int8s'), + (np.uint8(250), 'B', 'uint8s'), + ) + def test_pack_scalar_bytes(self, scalar, fmt, expected_payload): + tensor = tensor_utils.pack_tensor(scalar) + self.assertEqual([], tensor.shape) + actual = struct.unpack(fmt, getattr(tensor, expected_payload).array) + self.assertEqual(scalar, actual) + + @parameterized.parameters( + (25, np.float32, 'floats'), + (25, np.float64, 'doubles'), + (25, np.int32, 'int32s'), + (25, np.int64, 'int64s'), + (25, np.uint32, 'uint32s'), + (25, np.uint64, 'uint64s'), + (True, np.bool, 'bools'), + (False, np.bool, 'bools'), + ('foo', np.str, 'strings'), + ) + def test_pack_scalars_specific_dtype(self, scalar, dtype, expected_payload): + tensor = tensor_utils.pack_tensor(scalar, dtype) + self.assertEqual([], tensor.shape) + self.assertEqual([scalar], getattr(tensor, expected_payload).array) + + def test_pack_with_dm_env_rpc_data_type(self): + tensor = tensor_utils.pack_tensor([5], dm_env_rpc_pb2.DataType.FLOAT) + self.assertEqual([5], tensor.floats.array) + + @parameterized.parameters( + ([np.int8(-25), np.int8(-23)], '2b', 'int8s'), + ([np.uint8(249), np.uint8(250)], '2B', 'uint8s'), + ) + def test_pack_bytes_array(self, scalar, fmt, expected_payload): + tensor = tensor_utils.pack_tensor(scalar) + self.assertEqual([2], tensor.shape) + actual = struct.unpack(fmt, getattr(tensor, expected_payload).array) + np.testing.assert_array_equal(scalar, actual) + + @parameterized.parameters( + (np.array([1.0, 2.0], dtype=np.float32), 'floats'), + (np.array([1.0, 2.0], dtype=np.float64), 'doubles'), + ([1.0, 2.0], 'doubles'), + (np.array([1, 2], dtype=np.int32), 'int32s'), + (np.array([1, 2], dtype=np.int64), 'int64s'), + (np.array([1, 2], dtype=np.uint32), 'uint32s'), + (np.array([1, 2], dtype=np.uint64), 'uint64s'), + ([True, False], 'bools'), + (np.array([True, False]), 'bools'), + (['foo', 'bar'], 'strings'), + ) + def test_pack_arrays(self, array, expected_payload): + tensor = tensor_utils.pack_tensor(array) + self.assertEqual([2], tensor.shape) + packed_array = getattr(tensor, expected_payload).array + np.testing.assert_array_equal(array, packed_array) + + def test_packed_rowmajor(self): + array2d = np.array([[1, 2], [3, 4], [5, 6]], dtype=np.int32) + tensor = tensor_utils.pack_tensor(array2d) + self.assertEqual([3, 2], tensor.shape) + np.testing.assert_array_equal([1, 2, 3, 4, 5, 6], tensor.int32s.array) + + def test_mixed_scalar_types_raises_exception(self): + with self.assertRaises(TypeError): + tensor_utils.pack_tensor(['hello!', 75], dtype=np.float32) + + def test_jagged_arrays_throw_exceptions(self): + with self.assertRaises(ValueError): + tensor_utils.pack_tensor([[1, 2], [3, 4, 5]]) + + def test_class_instance_throw_exception(self): + + class Foo(object): + pass + + with self.assertRaises(ValueError): + tensor_utils.pack_tensor(Foo()) + + def test_compress_integers_to_1_element_when_all_same(self): + array = np.array([1, 1, 1, 1, 1, 1], dtype=np.uint32) + packed = tensor_utils.pack_tensor(array, try_compress=True) + self.assertEqual([6], packed.shape) + self.assertEqual([1], packed.uint32s.array) + + def test_compress_floats_to_1_element_when_all_same(self): + array = np.array([1.5, 1.5, 1.5, 1.5, 1.5, 1.5], dtype=np.float32) + packed = tensor_utils.pack_tensor(array, try_compress=True) + self.assertEqual([6], packed.shape) + self.assertEqual([1.5], packed.floats.array) + + def test_compress_strings_to_1_element_when_all_same(self): + array = np.array(['foo', 'foo', 'foo', 'foo'], dtype=np.str_) + packed = tensor_utils.pack_tensor(array, try_compress=True) + self.assertEqual([4], packed.shape) + self.assertEqual(['foo'], packed.strings.array) + + def test_compress_multidimensional_arrays_to_1_element_when_all_same(self): + array = np.array([[4, 4], [4, 4]], dtype=np.int32) + packed = tensor_utils.pack_tensor(array, try_compress=True) + self.assertEqual([2, 2], packed.shape) + self.assertEqual([4], packed.int32s.array) + + def test_doesnt_compress_if_not_asked_to(self): + array = np.array([1, 1, 1, 1, 1, 1], dtype=np.uint32) + packed = tensor_utils.pack_tensor(array) + self.assertEqual([6], packed.shape) + self.assertEqual([1, 1, 1, 1, 1, 1], packed.uint32s.array) + + def test_ask_to_compress_but_cant(self): + array = np.array([1, 1, 2, 1, 1, 1], dtype=np.uint32) + packed = tensor_utils.pack_tensor(array, try_compress=True) + self.assertEqual([6], packed.shape) + self.assertEqual([1, 1, 2, 1, 1, 1], packed.uint32s.array) + + +class UnpackTensorTests(parameterized.TestCase): + + @parameterized.parameters( + np.float32(2.5), + np.float64(2.5), + np.int8(-25), + np.int32(-25), + np.int64(-25), + np.uint8(250), + np.frombuffer(b'\xF0\xF1\xF2\xF3', np.uint32)[0], + np.frombuffer(b'\xF0\xF1\xF2\xF3\xF4\xF5\xF6\xF7', np.uint64)[0], + True, + False, + 'foo', + ) + def test_unpack_scalars(self, scalar): + tensor = tensor_utils.pack_tensor(scalar) + round_trip = tensor_utils.unpack_tensor(tensor) + self.assertEqual(scalar, round_trip) + + @parameterized.parameters( + ([np.float32(2.5), np.float32(3.5)],), + ([np.float64(2.5), np.float64(3.5)],), + ([np.int8(-25), np.int8(-23)],), + ([np.int32(-25), np.int32(-23)],), + ([np.int64(-25), np.int64(-23)],), + ([np.uint8(250), np.uint8(249)],), + ([np.uint32(1), np.uint32(2)],), + ([np.uint64(1), np.uint64(2)],), + ([True, False],), + (['foo', 'bar'],), + ) + def test_unpack_arrays(self, array): + tensor = tensor_utils.pack_tensor(array) + round_trip = tensor_utils.unpack_tensor(tensor) + np.testing.assert_array_equal(array, round_trip) + + def test_unpack_multidimensional_arrays(self): + tensor = dm_env_rpc_pb2.Tensor() + tensor.floats.array[:] = [1, 2, 3, 4, 5, 6, 7, 8] + tensor.shape[:] = [2, 4] + round_trip = tensor_utils.unpack_tensor(tensor) + expected = np.array([[1, 2, 3, 4], [5, 6, 7, 8]]) + np.testing.assert_array_equal(expected, round_trip) + + def test_too_few_elements(self): + tensor = dm_env_rpc_pb2.Tensor() + tensor.floats.array[:] = [1, 2, 3, 4] + tensor.shape[:] = [2, 4] + with self.assertRaisesRegexp(ValueError, 'cannot reshape array'): + tensor_utils.unpack_tensor(tensor) + + def test_too_many_elements(self): + tensor = dm_env_rpc_pb2.Tensor() + tensor.floats.array[:] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + tensor.shape[:] = [2, 4] + with self.assertRaisesRegexp(ValueError, 'cannot reshape array'): + tensor_utils.unpack_tensor(tensor) + + def test_integer_broadcasts_1_element_to_all_elements(self): + tensor = dm_env_rpc_pb2.Tensor() + tensor.floats.array[:] = [1] + tensor.shape[:] = [4] + unpacked = tensor_utils.unpack_tensor(tensor) + expected = np.array([1, 1, 1, 1], dtype=np.float32) + np.testing.assert_array_equal(expected, unpacked) + + def test_float_broadcasts_1_element_to_all_elements(self): + tensor = dm_env_rpc_pb2.Tensor() + tensor.int32s.array[:] = [1] + tensor.shape[:] = [4] + unpacked = tensor_utils.unpack_tensor(tensor) + expected = np.array([1, 1, 1, 1], dtype=np.int32) + np.testing.assert_array_equal(expected, unpacked) + + def test_string_broadcasts_1_element_to_all_elements(self): + tensor = dm_env_rpc_pb2.Tensor() + tensor.strings.array[:] = ['foo'] + tensor.shape[:] = [4] + unpacked = tensor_utils.unpack_tensor(tensor) + expected = np.array(['foo', 'foo', 'foo', 'foo'], dtype=np.str_) + np.testing.assert_array_equal(expected, unpacked) + + def test_broadcasts_to_multidimensional_arrays(self): + tensor = dm_env_rpc_pb2.Tensor() + tensor.int32s.array[:] = [4] + tensor.shape[:] = [2, 2] + unpacked = tensor_utils.unpack_tensor(tensor) + expected = np.array([[4, 4], [4, 4]], dtype=np.int32) + np.testing.assert_array_equal(expected, unpacked) + + def test_negative_dimension(self): + tensor = dm_env_rpc_pb2.Tensor() + tensor.int32s.array[:] = [1, 2, 3, 4] + tensor.shape[:] = [-1] + unpacked = tensor_utils.unpack_tensor(tensor) + expected = np.array([1, 2, 3, 4], dtype=np.int32) + np.testing.assert_array_equal(expected, unpacked) + + def test_negative_dimension_in_matrix(self): + tensor = dm_env_rpc_pb2.Tensor() + tensor.int32s.array[:] = [1, 2, 3, 4, 5, 6] + tensor.shape[:] = [2, -1] + unpacked = tensor_utils.unpack_tensor(tensor) + expected = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.int32) + np.testing.assert_array_equal(expected, unpacked) + + def test_two_negative_dimensions_in_matrix(self): + tensor = dm_env_rpc_pb2.Tensor() + tensor.int32s.array[:] = [1, 2, 3, 4, 5, 6] + tensor.shape[:] = [-1, -2] + with self.assertRaisesRegexp(ValueError, 'one unknown dimension'): + tensor_utils.unpack_tensor(tensor) + + def test_negative_dimension_single_element(self): + tensor = dm_env_rpc_pb2.Tensor() + tensor.int32s.array[:] = [1] + tensor.shape[:] = [-1] + unpacked = tensor_utils.unpack_tensor(tensor) + expected = np.array([1], dtype=np.int32) + np.testing.assert_array_equal(expected, unpacked) + + def test_unknown_type_raises_error(self): + tensor = mock.MagicMock() + tensor.WhichOneof.return_value = 'foo' + with self.assertRaisesRegexp(TypeError, 'type foo'): + tensor_utils.unpack_tensor(tensor) + + def test_scalar_with_too_many_elements_raises_error(self): + tensor = dm_env_rpc_pb2.Tensor() + tensor.int32s.array[:] = [1, 2, 3] + with self.assertRaisesRegexp(ValueError, '3 element'): + tensor_utils.unpack_tensor(tensor) + + +class GetTensorTypeTests(parameterized.TestCase): + + def test_float(self): + tensor = tensor_utils.pack_tensor(1.25) + self.assertEqual(np.float64, tensor_utils.get_tensor_type(tensor)) + + def test_unknown_tensor_type(self): + mock_tensor = mock.MagicMock() + mock_tensor.WhichOneof.return_value = 'foo' + with self.assertRaisesRegexp(TypeError, 'foo'): + tensor_utils.get_tensor_type(mock_tensor) + + +class GetTensorSpecTypeTests(parameterized.TestCase): + + def test_float(self): + self.assertEqual( + np.float32, + tensor_utils.data_type_to_np_type(dm_env_rpc_pb2.DataType.FLOAT)) + + def test_proto_type(self): + with self.assertRaises(TypeError): + tensor_utils.data_type_to_np_type(dm_env_rpc_pb2.DataType.PROTO) + + def test_unknown_type(self): + with self.assertRaises(TypeError): + tensor_utils.data_type_to_np_type(30) + + +class BytesWrapperTests(parameterized.TestCase): + + def test_unsupported_indexing_on_write_raises_error(self): + tensor = dm_env_rpc_pb2.Tensor() + wrapper = tensor_utils._BytesWrapper(tensor.uint8s, signed=False) + with self.assertRaisesRegexp(ValueError, 'index'): + wrapper[0] = 0 + + +if __name__ == '__main__': + absltest.main() diff --git a/docs/v1/2x2.png b/docs/v1/2x2.png new file mode 100644 index 0000000000000000000000000000000000000000..2dab64e421d705a2664028b58a3b23944eef72af GIT binary patch literal 236 zcmeAS@N?(olHy`uVBq!ia0vp^x**KL3?xsO{9g&At<74`kv3Z9NuifpVNB zL4LsuMxAE`cDpF0-4dU&2qakG5n0T@z;^_M8K-LVNdpDrJzX3_ECiD$%(3ibPnv;&vES3hF~maf z>DB$-haE)NKjdd!%BXLSo*uR^z-5Jn#r+Elqpf%DC=oiI)7R_wc0q``=;~MznIji2 zx~l)@s(+xurN^bzaisg%-Czr+*qA+vJNJCwIemAGN5F^q0vC1ei%YEU;$RASeaVGu z<{7^HGnIPREHhShmz`f~Rrb@h)xGtvo4Dci8dc^Eo=*!r)h-@bd@dy>yugitog~t3(~7ke>ckenl_uWV6CaA-sE!&AFX(PCi}S1u@wQQ5BIL?E8J1x zrKWbK+9>U1Wz!?c`}NE1%&O%iMK71tZG3<0qsSlsQWjk+o6EO)l~i-?zYmr_xt8}{ z_mr;N+Lo>bE!t+;IZyP0y3Z#>&a8;bcI#^7dmik)L6d)$qguwzZ(94MW6hRzcl#^w zueB3f;ddudIat+*Rs3~I){n$*C$HGf=`65oKKU{-s_@uhxtVKDvu*e#vVG2jnKfHW z1b3ILfA3Zo{qDq#=QDzjSyUEiT$~vc`=orFVdQ&MBb@02R{WNdN!< literal 0 HcmV?d00001 diff --git a/docs/v1/appendix.md b/docs/v1/appendix.md new file mode 100644 index 0000000..7ada90d --- /dev/null +++ b/docs/v1/appendix.md @@ -0,0 +1,212 @@ +## Advice for implementers + +A server implementation does not need to implement the full protocol to be +usable. When first starting, just supporting `JoinWorld`, `LeaveWorld` and +`Step` is sufficient to provide a minimum `dm_env_rpc` environment (though +clients using the provided `DmEnvAdaptor` will need access to `Reset` as well). +The world can be considered already created just by virtue of the server being +available and listening on a port. Unsupported requests should just return an +error. After this base, the following can be added: + +* `CreateWorld` and `DestroyWorld` can provide a way for clients to give + settings and manage the lifetime of worlds. +* Supporting sequences, where one sequence reaches its natural conclusion and + the next begins on the next step. +* `Reset` and `ResetWorld` can provide mechanisms to manage transitions + between sequences and to update settings. +* Read/Write/List properties for client code to query or set state outside of + the normal observe/act loop. + +A client implementation likely has to support the full range of features and +data types that the server it wants to interact with supports. However, it's +unnecessary to support data types or features that the server does not +implement. If the target server does not provide tensors with more than one +dimension it probably isn't worth the effort to support higher dimensional +tensors, for instance. + +For Python clients, some python utility code is provided. Specifically: + +* connection.py - A utility class for managing the client-side gRPC connection + and the error handling from server responses. +* spec_manager.py - A utility class for managing the UID-to-name mapping, so + the rest of the client code can use the more human readable tensor names. +* tensor_utils.py - A few utility functions for packing and unpacking NumPy + arrays to `dm_env_rpc` tensors. + +For other languages similar utility functions will likely be needed. It is +especially recommended that client code have something similar to `SpecManager` +that turns UIDs into human readable text as soon as possible. Strings are both +less likely to be used wrong and more easily debugged. + +## Reward functions + +[Reward function design is difficult](https://www.alexirpan.com/2018/02/14/rl-hard.html#reward-function-design-is-difficult), +and may be the first thing a client will tweak. + +For instance, a simple arcade game could expose its score function as `reward`. +However, the score function might not actually be a good measure of playing +ability if there's a series of actions which increases score without advancing +towards the actual desired goal. Alternatively, games might have an obvious +reward signal only at the end (whether the agent won or not), which might be too +sparse for a reinforcement learning agent. Constructing a robust reward function +is an area of active research and it's perfectly reasonable for an environment +to abdicate the responsibility of forming one. + +For instance, constructing a reward function for the game of chess is actually +rather tricky. A simple reward at the end if an agent wins is going to make +learning difficult, as agents won't get feedback during a game if they make +mistakes or play well. Beginner chess players often use +[material values](https://en.wikipedia.org/wiki/Chess_piece_relative_value) to +evaluate a given position to decide who is winning, with queens being worth more +than rooks, etc. This might seem like a prime candidate for a reward function, +however there are +[well known shortcomings](https://en.wikipedia.org/wiki/Chess_piece_relative_value#Shortcomings_of_piece_valuation_systems) +to this simple system. For instance, using this as a reward function can blind +an agent to a move which loses material but produces a checkmate. + +If a client does construct a custom reward function it may want access to data +which normally would be considered hidden and unavailable. Exposing this +information to clients may feel like cheating, however getting an AI agent to +start learning at all is often half the battle. To this end servers should +expose as much relevant information through the environment as they can to give +clients room to experiment. Weaning an agent off this information down the line +may be possible; just be sure to document which observables are considered +hidden information so a client can strip them in the future. + +For client implementations, if a given server does not provide a reward or +discount observable or they aren't suitable you can build your own from other +observables. For instance, the provided `DmEnvAdaptor` has `reward` and +`discount` functions which can be overridden in derived classes. + +## Nested observations example + +Retrofitting an existing object-oriented codebase to be a `dm_env_rpc` server +can be difficult, as the data is likely arranged in a tree-like structure of +heterogeneous nodes. For instance, a game might have pickup and character +classes which inherit from the same base: + +``` +Entity { + Vector3 position; +} + +HealthPickup : Entity { + float HealthRecoveryAmount; + Vector4 IconColor; +} + +Character : Entity { + string Name; +} +``` + +It's not immediately clear how to turn this data into tensors, which can be a +difficult issue for a server implementation. + +First, though, not all data needs to be sent to a joined connection. For a +health pickup, the health recovery amount might be hidden information that +agents don't generally have access to. Likewise, the IconColor might only matter +for a user interface, which an agent might not have. + +After filtering unnecessary data the remainder may fit in a +"[structure of arrays](https://en.wikipedia.org/wiki/AoS_and_SoA)" format. + +In our example case, we can structure the remaining data as a few different +tensors. The specs for them might look like: + +``` +TensorSpec { + name = "HealthPickups.Position", + shape = [3, -1] + dtype = float +}, + +TensorSpec { + name = "Characters.Position" + shape = [3, -1] + dtype = float +}, + +TensorSpec { + name = "Characters.Name", + shape = [-1] + dtype = string +} +``` + +If a structure of arrays format is infeasible, custom protocol messages can be +implemented as a final option. This allows a more natural data representation, +but requires the client to also compile the protocol buffers and makes discovery +of metadata, such as range for numeric types, more difficult. For our toy +example, we could form the data in to this intermediate protocol buffer format: + +```protobuf +message Vector3 { + float x = 1; + float y = 2; + float z = 3; +} + +message HealthPickup { + Vector3 position = 1; +} + +message Character { + Vector3 position = 1; + string name = 2; +} + +message Data { + repeated HealthPickup pickups = 1; + repeated Character characters = 2; +} +``` + +The `TensorSpec` would then look like: + +``` +TensorSpec { + name = "Data", + shape = [], + dtype = proto +}, +``` + +### Rendering + +Often renders are desirable observations for agents, whether human or machine. +In past frameworks, such as +[Atari](https://deepmind.com/research/publications/playing-atari-deep-reinforcement-learning) +and +[DMLab](https://deepmind.com/blog/article/impala-scalable-distributed-deeprl-dmlab-30), +environments have been responsible for rendering. Servers implementing this +protocol will likely (but not necessarily) continue this tradition. + +For performance reasons rendering should not be done unless requested by an +agent, and then only the individual renders requested. Servers can batch similar +renders if desirable. + +The exact image format for a render is at the discretion of the server +implementation, but should be documented. Reasonable choices are to return an +interleaved RGB buffer, similar to a GPU render texture, or a standard image +format such as PNG or JPEG. + +Human agents generally prefer high resolution images, such as the high +definition 1920x1080 format. Reinforcement agents, however, often use much +smaller resolutions, such as 96x72, for performance of both environment and +agent. It is up to the server implementation how or if it wants to expose +resolution as a setting and what resolutions it wants to support. It could be +hard coded or specified in world settings or join settings. Server implementers +are advised to consider performance impacts of whatever choice they make, as +rendering time often dominates the runtime of many environments. + +### Documentation + +Actions and observations can be self-documenting, in the sense that their +`TensorSpec`s provide information about their type, shape and bounds. However, +the exact meaning of actions and observations are not discoverable through the +protocol. In addition, `CreateWorldRequest` and `JoinWorldRequest` settings lack +even a spec. Therefore server implementers should be sure to document allowed +settings for create and join world, along with their types and shapes, the +consequences of any actions and the meaning of any observations, as well as any +properties. diff --git a/docs/v1/glossary.md b/docs/v1/glossary.md new file mode 100644 index 0000000..3b4ba18 --- /dev/null +++ b/docs/v1/glossary.md @@ -0,0 +1,38 @@ +## Glossary + +### Server + +A server runs a gRPC server which implements this protocol. It can host one or +more worlds. + +### Client + +A client connects to a server. It can request worlds to be created and join them +to become agents. It can also query properties and reset worlds without being an +agent. + +### Agent + +A client which has joined a world. In a game sense this is a "player". It has +the ability to send actions and receive observations. This could be a human or a +reinforcement learning agent. + +### World + +A system or simulation in which one or more agents interact. + +### Environment + +An agent's view of a world. In limited information situations, the environment +may expose only part of the world state to an agent that corresponds to +information that agent is allowed by the simulation to have. Agents communicate +directly with an environment, and the environment communicates with the world to +synchronize with it. + +### Sequence + +A series of discrete states, where one state is correlated to previous and +subsequent states, possibly ending in a terminal state, and usually modified by +agent actions. In the simplest case, playing an entire game until one player is +declared the winner is one sequence. Also sometimes called an "episode" in +reinforcement learning contexts. diff --git a/docs/v1/index.md b/docs/v1/index.md new file mode 100644 index 0000000..d1aa8e7 --- /dev/null +++ b/docs/v1/index.md @@ -0,0 +1,6 @@ +# `dm_env_rpc` documentation + +* [Protocol overview](overview.md) +* [Protocol reference](reference.md) +* [Appendix](appendix.md) +* [Glossary](glossary.md) diff --git a/docs/v1/overview.md b/docs/v1/overview.md new file mode 100644 index 0000000..33b4391 --- /dev/null +++ b/docs/v1/overview.md @@ -0,0 +1,221 @@ +## Protocol overview + +`dm_env_rpc` is a protocol for [agents](glossary.md#agent) +([clients](glossary.md#client)) to communicate with +[environments](glossary.md#environment) ([servers](glossary.md#server)). A +server has a single remote procedural call (RPC) named `Process` for handling +messages from clients. + +```protobuf +service Environment { + // Process incoming environment requests. + rpc Process(stream EnvironmentRequest) returns (stream EnvironmentResponse) {} +} +``` + +`Process` is a bidirectional streaming RPC, meaning the connection stays active +over the entire life of an agent-environment session and ensures the ordering of +messages is preserved. + +This RPC can support multiple streams simultaneously, so it is up to each server +implementation to determine if it can support multiple simultaneous clients, if +each client can instantiate its own [world](glossary.md#world), or if each +stream is expected to connect to the same underlying world. + +Each stream accepts a [sequence](glossary.md#sequence) of `EnvironmentRequest` +messages from the client, and the server always sends exactly one +`EnvironmentResponse` for each request. The server endpoint does not send any +other messages. The payload of the response always either corresponds to that of +the request (e.g. a `StepResponse` in response to a `StepRequest`) or is an +error `Status` message. + +### Streaming + +Clients may speculatively send multiple requests without waiting for responses, +though each request is still required to be valid at the time it's processed. +Likewise, after processing requests the server will send back responses in the +same order as the requests were sent. + +Because the connection is streamed, message order is guaranteed. + +### States + +An Environment connection can be in one of two states: joined to a world or not. +When not joined to a world, `StepRequest` and `ResetRequest` calls are +unavailable (the server will send an error upon receiving them). + +A joined connection may be in a variety of sub-states (ie: RUNNING, TERMINATED, +and INTERRUPTED). Agents transition between these states using `StepRequest`, +`ResetRequest` and `ResetWorldRequest` calls, though the environment controls +which state is transitioned to. + +![State transitions](state_transitions.png) + +### Tensors + +For the purposes of this protocol tensors are loosely based on NumPy arrays: +n-dimensional arrays of data with the same data type. A tensor with "n" +dimensions can be referred to as an n-tensor. A 0-tensor is just a scalar value, +such as a single float. A 1-tensor can be thought of either as a single +dimensional array or vector. A 2-tensor is a two dimensional array or a matrix. +In principle there's no limit to the number of dimensions a tensor can have in +the protocol, but in practice we rarely have more than 3 or 4 dimensions. +Tensors are not allowed to be +[ragged](https://en.wikipedia.org/wiki/Jagged_array) (have rows with different +numbers of elements), though they may have a +[variable length](#variable-lengths) along one dimension. + +A tensor's shape represents the number of elements along each dimension. A +2-tensor with a shape of `[3, 4]` would be a 2 dimensional array with 3 rows and +4 columns. + +In order to pack these tensors in a way that can be sent over the network they +have to be flattened to a one dimensional array. For multidimensional tensors +it's expected that they will be packed in a row-major format. That is, the +values at indices `[2, 3, 4]` and `[2, 3, 5]` are located next to each other in +the flattened array. This is the default memory layout in C based languages, +such as C/C++, Java, and C#, and NumPy in Python and TensorFlow, but is opposite +to how column-major languages work, such as Fortran. + +Consult the +[Row- and column-major order](https://en.wikipedia.org/wiki/Row-_and_column-major_order) +article for more information. + +#### Variable lengths: + +Normally a tensor has a well defined shape. However, if one of the elements in a +Tensor's shape is negative it represents a variable dimension. Either the client +or the server, upon receiving a Tensor message with a Shape with a negative +element, will attempt to infer the correct value for the shape based on the +number of elements in the Tensor's array part and the rest of the shape. + +Note: even though this dimension has variable length, the tensor itself is still +not ragged. The variable dimension has a definite length that can be inferred. + +For instance, a Tensor with shape `[2, -1]` represents a variable length +2-tensor with two rows and a variable number of columns. If this Tensor's array +part contains 6 elements `[1, 2, 3, 4, 5, 6]` then the final produced 2-tensor +will look like: + +![2x3 matrix with first row 1, 2, 3 and second row 4, 5, 6](2x3.png) + +Variable length tensors are useful for situations where a given observation or +action's length is unknowable from frame to frame. For instance, the number of +words in a sentence or the number of stochastic events in a given time frame. + +Note: At most one dimension is allowed to be variable on a given tensor. + +Note: servers should provide actions and observations with non-variable length +if possible, as it can reduce the complexity of agent implementations. + +#### Broadcastable + +If a tensor contains all elements of the same value, it is "broadcastable" and +can be represented with a single value in the array part of the Tensor, even if +the shape requires more elements. Either the client or server, upon receiving a +broadcastable tensor, will unpack it to an appropriately sized multidimensional +array with each element being set to the value from the lone element in the +array part of the Tensor. + +For instance, a Tensor with Shape `[2, 2]` and a single element `1` in its array +part will produce a 2-tensor that looks like: + +![2x2 matrix of all 1s](2x2.png) + +#### TensorSpec + +A `TensorSpec` provides metadata about a tensor, such as its name, type, and +expected shape. Tensor names must be unique within a given domain (action or +observation) so clients can use them as keys. + +#### Ranges + +Numerical tensors can have min and max ranges on the TensorSpec. These ranges +are inclusive. + +Note: Range is not enforced by the protocol. Servers and clients should be +careful to validate any incoming or outgoing tensors to make sure they are in +range. Servers should return an error for any out of range tensors from the +client. + +### UIDs + +Unique Identifications (UIDs) are 64 bit numbers used as keys for data that is +sent over the wire frequently, specifically observations and actions. This +reduces the amount of data compared to string keys, since a key is needed for +each action and observation every step. For data that is not intended to be +referenced frequently, such as create and join settings and properties, string +keys are used for clarity. + +For more information on UIDs see [JoinWorld specs](reference.md#specs) + +### Errors + +If an `EnvironmentRequest` fails for any reason, the payload will contain an +error `Status` instead of the normal response message. It’s up to the server +implementation to decide what error codes and messages to use. For fatal errors, +the server can close the stream after sending the error. For recoverable errors +the server can treat the failed request as a no-op and clients can retry. + +The client cannot send errors to the server. If the client has an error that it +can’t recover from, it should just close the connection (gracefully, if +possible). + +Since a server can _only_ send messages in response to a given +`EnvironmentRequest`, the errors should ideally be focused on problems from a +specific client request. More general issues or warnings from a given server +implementation should be logged through a separate mechanism. + +If a server implementation needs to report an error, it should send as much +detail about the nature of the problem as possible and any likely remedies. A +client may have difficulties debugging a server, perhaps because the server is +running on a different machine, so the server should send enough information to +properly diagnose the problem. Any additional relevant information about the +error that would normally be logged by the server should also be included in the +error sent to the client. + +### Nested actions/observations + +Nested actions or observations (lists of lists, dicts of dicts, lists of +objects, etc.) are not directly supported by the protocol, however there are two +ways they can be handled: + +1. Flattening the hierarchy. A separation character such as a period "." in the + name of a spec can indicate a level of nesting. With this a server can + flatten the nested structure to push through the wire and the client can + reconstruct a nested structure on its side. A tensor name that’s a level of + nesting plus a number can indicate an array index. Eg: “wheel.0”, “wheel.1”, + “wheel.2” could represent an array of 3 wheels, each element of which is a + tensor. An exact scheme is up to each server and should be documented. + +2. Defining a custom proto message type, or using the proto common type Struct, + and setting it as the payload in a Tensor message’s array field. + +Flattening the hierarchy is easier for clients to consume, but can involve a +great deal of work on the server. A custom proto message is more flexible but +means every client needs to compile the custom protobuf for their desired +language. + +Nested data structures occur commonly with object-oriented codebases, such as +from an existing game, and flattening them can be difficult. For an in-depth +discussion see the +[nested observations example](appendix.md#nested-observations-example). + +### Reward and discount + +`dm_env_rpc` does not provide explicit channels for reward or discount (common +reinforcement learning signals). For servers where there's a sensible reward or +discount already available they can be provided through a `reward` or `discount` +observation respectively. For `dm_env`, the provided `DmEnvAdaptor` will +properly route the reward and discount for client code if available. + +A server may choose not to provide reward and discount observations, however. +See [reward functions](appendix.md#reward-functions) for a discussion on the +pitfalls of reward design. + +### Multiagent support + +Some servers may support multiple joined connections on the same world. These +multiagent servers are responsible for coordinating how agents interact through +the world, and ensuring each connection has a separate environment for each +agent. diff --git a/docs/v1/reference.md b/docs/v1/reference.md new file mode 100644 index 0000000..6f0ef10 --- /dev/null +++ b/docs/v1/reference.md @@ -0,0 +1,397 @@ +## Protocol reference + +### CreateWorldRequest/Response + +```protobuf +message CreateWorldRequest { + // Settings to create the world with. This can define the level layout, the + // number of agents, the goal or game mode, or other universal settings. + // Agent-specific settings, such as anything which would change the action or + // observation spec, should go in the JoinWorldRequest. + map settings = 1; +} +``` + +```protobuf +message CreateWorldResponse { + // The unique name for the world just created. + string world_name = 1; +} +``` + +`CreateWorldRequest` is responsible for creating a world with the provided +settings. In multi-agent Environments, only one call to `CreateWorldRequest` is +necessary per world, after which clients can send a `JoinWorldRequest` to join +the world. + +It is not required that the same connection which creates a world also sends a +`JoinWorldRequest`. For example, a matchmaking service could call +`CreateWorldRequest` and send the `world_name` to agents over some outside +connection so they can join the new world. + +The `CreateWorldResponse` returns a unique `world_name` identifier. It is up to +the server what this `world_name` should be, and in simple setups where only one +world can exist at a time it’s entirely reasonable to just leave this as empty +(the default) and throw errors on any subsequent `CreateWorldRequest` before a +`DestroyWorldRequest` is sent. + +For even simpler setups a `CreateWorldRequest` may not even be needed; the +server may have hard-coded settings and just by virtue of accepting incoming +connections indicate it is waiting for agents to join. + +### Multiple world support + +Supporting multiple simultaneous worlds on a server is not required, though the +API does support it. For an initial server implementation it is sufficient to +only support a single world, with a `world_name` just being the empty string +(the default) and returning errors on attempts to create or access other worlds. + +If multiple worlds are desired from the same server instance, they should each +be independent in the sense that an agent in one world shouldn't be able to +affect an agent in a different world. + +### JoinWorldRequest/Response + +```protobuf +message JoinWorldRequest { + // The name of the world to join. + string world_name = 1; + + // Agent-specific settings which define how to join the world, such as agent + // name and class in an RPG. + map settings = 2; +} +``` + +```protobuf +message JoinWorldResponse { + ActionObservationSpecs specs = 1; +} +``` + +Requests joining an already created world with the settings provided. If +successful the client connection will be in a "joined" state, becoming an agent +and allowing future calls to `ResetRequest` and `StepRequest`. Player-specific +settings, such as race in an RTS or class in an RPG brawler, should go in these +settings. Each `JoinWorldRequest` corresponds to a different agent, and each +world will allow one or more `JoinWorldRequest`s, depending on the number of +agents it expects. + +From an agent’s perspective, it may send a `StepRequest` immediately after a +`JoinWorldRequest`, however the server is free to not process the `StepRequest` +immediately and block until some condition is met, such as the correct number of +other agents joining the world or synchronizing with some external event. + +The `world_name` specifies which world to join. Only one world may be joined at +a time per client connection. If an agent wants to join multiple worlds at the +same time, or join the same world as multiple agents, it will have to manage +each over a different connection. + +#### Specs + +The `JoinWorldResponse` has a `specs` field which provides a complete list of +available actions and observations and their shape and data type, which won’t +change until the client either leaves or resets the environment. They are keyed +by [UID](overview.md#uids), which can be used for requesting a sparse set of +observations or performing a sparse set of actions. + +It’s the responsibility of the Environment to define these UIDs. There are no +specific requirements for the UIDs to be incremental or even consistent across +worlds, agents, or runs, though they should be consistent between sequences +until either `LeaveWorldRequest` or `ResetRequest` is called. + +The spec names in a group, ie: actions or observations, are unique. In +principle, these are the actual keys, and the UIDs are “compiled” string keys, +for performance. It’s expected that the first thing each side of the connection +will do is map these UIDs back to their human readable strings. + +### StepRequest/Response + +```protobuf +message StepRequest { + // The actions to perform on the environment. If the environment is currently + // in a non-RUNNING state, whether because the agent has just called + // JoinWorld, the state from the last is StepResponse was TERMINATED or + // INTERRUPTED, or a ResetRequest had previously been sent, the actions will + // be ignored. + map actions = 1; + + // Array of observations UIDs to return. If not set, no observations are + // returned. + repeated uint64 requested_observations = 2; +} +``` + +```protobuf +message StepResponse { + // If state is not RUNNING, the action on the next StepRequest will be + // ignored and the environment will transition to a RUNNING state. + EnvironmentStateType state = 1; + + // The observations requested in `StepRequest`. Observations returned should + // match the dimensionality and type specified in `specs.observations`. + map observations = 2; +} +``` + +In a `StepRequest` the client sends the map of actions and the list of +`requested_observations` UIDs. The environment performs the actions provided, +possibly steps the world forward in time, and returns the observations and +`StepType`, which defines the state of the environment. + +The first step of a sequence starts in a non-running state. This might be +because the client has just joined, or because a previous sequence has finished. +When performing a step from a non-running state, the actions are ignored, the +environment is put in a running state (that is, moving on to the next sequence), +and the requested observations are returned. This allows agents to get the first +set of observations for a new sequence without providing blind actions. + +Importantly, an agent may send as many `StepRequest`s as it wishes without +waiting for `StepResponse`s, and these batched requests can and will transition +from one sequence to the next. An example of this use-case would be to step with +random actions to get a large amount of action-observation pairs to use in +offline learning. Note that an agent does not need to call `ResetRequest` to +transition from one sequence to the next, and in the majority of common use +cases an agent might never call `ResetRequest` at all, instead relying on the +natural transition from sequence to sequence that happens during a +`StepRequest`. + +From a protocol standpoint all actions are optional, and the `StepRequest` +provides a sparse set of actions to apply. This set can even be empty. In cases +where an action isn’t optional (a move in chess, for instance, where you’re not +allowed to pass) the environment should return an error if a `StepRequest` +doesn’t provide an expected action. + +#### Time + +Each environment can define how it handles time during a `StepRequest`. For +discrete simulations, such as chess, the `StepRequest` may correspond to the +agent’s move, with the `StepResponse` sending back the opponent’s move. However +continuous simulations or simulations tied to real time in some way may not have +an exact mapping from time to steps or it may be ambiguous. It is up to the +environment to decide how this mapping is meant to work, and any scheme is +considered valid so long as it can be discretized to the +`StepRequest`/`StepResponse` framework with actions and observations. + +It is even reasonable to define the environment so that the amount of time to +advance the simulation is an action the agent provides. This essentially allows +the agent to sleep for a set period of time, wake up, and get back requested +observations about the new state of the world. This would work even in +multi-agent environments, where the simulation could step forward in time until +the next requested wake up time for an agent, send that agent a `StepResponse` +with the current simulation state, block until the agent sends back a +`StepRequest`, and repeat. + +### ResetRequest/Response + +```protobuf +message ResetRequest { + // Agents-specific settings to apply for the next sequence, such as changing + // class in an RPG. + map settings = 1; +} +``` + +```protobuf +message ResetResponse { + ActionObservationSpecs specs = 1; +} +``` + +Requests the next sequence for the agent. Whereas `ResetWorldRequest` would +affect all connected agents, `ResetRequest` only affects the current agent. For +instance, in a multi-agent scenario this might cause the connection to block +until the resolution of the current sequence (until a winner is declared). + +Upon successful completion of `ResetRequest` the environment will be in an +`INTERRUPTED` state. The actions on the next step request after the +`ResetRequest` will be ignored and the environment will be in a `RUNNING` state. + +If the environment is already in a non-`RUNNING` state, an agent might still +send a `ResetRequest` if it wants to change settings (for instance, race in an +RTS). A `ResetRequest` without any settings when the environment is in a +non-`RUNNING` state already is a no-op (because no settings are being changed). + +To allow the reset settings to change the spec, they are sent back in the +`ResetResponse`. The server will always resend the specs even if no change has +occurred. + +### ResetWorldRequest/Response + +```protobuf +message ResetWorldRequest { + string world_name = 1; + + // World settings to apply for the next sequence, such as changing the map or + // seed. + map settings = 2; +} +``` + +```protobuf +message ResetWorldResponse {} +``` + +Requests the world identified by `world_name` start a new sequence for each +joined connection, potentially with some settings changed, such as seed. Unlike +`ResetRequest`, `ResetWorldRequest` affects all joined connections. The exact +meaning and consequences are up to the world, however the results should be +global and apply to all connected clients. + +The calling connection will block until all other joined connections have sent a +`StepRequest` and a `StepResponse` was generated with an `INTERRUPTED` state. +This allows other joined connections to receive their requested observations one +last time and be told the current sequence is ending. Once all other joined +connections have stepped once the world will be considered reset and a +`ResetWorldResponse` will be sent back to the connection which issued the +`ResetWorldRequest`. + +After a reset world all joined connections, including the calling connection if +it’s joined, will be in a non-`RUNNING` state. For all agents the next +StepRequest’s actions will be ignored and the next sequence will begin. + +Note that unlike the other joined connections the joined connection issuing a +`ResetWorldRequest` will not receive an `INTERRUPTED` state on its next step +request and will just begin the next sequence immediately. In this way, calling +ResetWorld from a joined connection behaves similarly to Reset. + +Note that a `ResetWorldRequest` does not have to come from a joined connection. +It's reasonable to have a matchmaking service observing the world and resetting +it periodically. It’s also reasonable to have a “host” joined connection which +decides to reset the world for all connected agents, or even to have each agent +decide independently when to reset the world. + +### LeaveWorldRequest/Response + +```protobuf +message LeaveWorldRequest {} +``` + +```protobuf +message LeaveWorldResponse {} +``` + +Requests leaving a previously joined world. The exact consequences are up to the +implementing environment, except that the connection is no longer in a joined +state. In single agent environments this may put the world back in a newly +created state, waiting for an agent to connect. In a multi-agent environment it +may block the entire simulation until another agent joins, or allow the +simulation to continue with a missing player, or terminate the simulation +entirely. + +If a `LeaveWorldRequest` is received from a connection that is not currently +joined it should be a no-op. + +### DestroyWorldRequest/Response + +```protobuf +message DestroyWorldRequest { + string world_name = 1; +} +``` + +```protobuf +message DestroyWorldResponse {} +``` + +Destroys the underlying world specified by `world_name`. As with the +`CreateWorldRequest`, in multi-agent environments only one `DestroyWorldRequest` +is expected, and it does not need to come from a connected agent (it could come +from some matchmaking service). + +It is up to the world how to handle a `DestroyWorldRequest` if there are still +connected agents. It could block the `DestroyWorldRequest` until all agents have +sent a `LeaveWorldRequest`, immediately destroy the world and send errors back +to any further `StepRequests` from connected agents, or refuse the +`DestroyWorldRequest` and send back an error. + +Sending a `DestroyWorldRequest` on a connection that is joined to the world +being destroyed should always cause the server to return an error. + +### Read/Write/List Property + +```protobuf +message ReadPropertyRequest { + repeated string keys = 1; +} +``` + +```protobuf +message ReadPropertyResponse { + map properties = 1; +} +``` + +```protobuf +message WritePropertyRequest { + map properties = 1; +} +``` + +```protobuf +message WritePropertyResponse {} +``` + +```protobuf +message ListPropertyRequest { + // Keys to list properties for. Empty string is the root level. + repeated string keys = 1; +} +``` + +```protobuf +message ListPropertyResponse { + message Property { + bool is_readable = 1; + bool is_writeable = 2; + + // Are there listable sub attributes? + bool is_listable = 3; + + TensorSpec spec = 4; + } + map properties = 1; +} +``` + +Often side-channel data is useful for debugging or manipulating the simulation +in some way which isn’t appropriate for an agent’s interface. The property +system provides this capability, and allows both reading properties and writing +data to properties. + +Properties queried before a `JoinWorldRequest` will correspond to universal +properties that apply for all worlds. Properties queried after a +`JoinWorldRequest` can add a layer of world-specific properties. + +For writing properties, it’s up to the World to determine if/when any +modification should take place (e.g. changing the world seed might not take +place until the next sequence). + +For reading properties, the exact timing of the observation is up to the world. +It may occur at the previous step's time, or it may occur at some intermediate +time. + +Properties can be laid out in a tree-like structure, as long as each node in the +tree has a unique key, by having parent nodes be listable (the `is_listable` +field in the Property message). + +Although properties can be powerful, if you expect a property to be read or +written to every step by a normally functioning agent, it may be preferable to +make it a proper action or observation, even if it’s intended to be metadata. +For instance, a score which provides hidden information could be done as a +property, but it might be preferable to do it as an observation. This ensures +observations and actions all occur at well-ordered times. + +### Example Sequence Diagrams: Single Agent + +#### Connect and step + +![Single agent connect and step](single_agent_connect_and_step.png) + +#### Sequence transitions + +![Single agent sequence transitions](single_agent_sequence_transitions.png) + +#### World destruction + +![Single agent world destruction](single_agent_world_destruction.png) diff --git a/docs/v1/single_agent_connect_and_step.png b/docs/v1/single_agent_connect_and_step.png new file mode 100644 index 0000000000000000000000000000000000000000..aff7a8b994d13e62a0664d7b75d959aa37dcd9e8 GIT binary patch literal 33227 zcmdSBcT`hf*Djhu=)HF$CKTyiiVzeb0jUavCRHFJNUs7x5JW^m=p8j+p-2ZoqzQtE zK$L3epdb>IB7y>U^SK|JVbfiqb-wTmDS9E35@zY>1+dl76h_~L%wO&qDs#MpR^oyOq2 zzOgVL4l#cuno(=cFmT2+{=qxVG$g(g|2b(S?zp099WQt^Rkvq|2T7OHVR_?EclT6EE$8 zVx3HgYWerw1hcpX{9i4nkPp{Rx?6n-`D~)q@U`8a?%*mITDSRR1PVN&)C_pI$s-xY zV8;H2{Wh|ut=qruM#aS=!A8(J=xAZ%O9(EGl(?GSWgA!-ybO%w$y=l*%GWtdc{D$~Cb0;{XJQjhwj`8AE)zrouB!lfP{{drSvxDvRr`e2E zDrlVI%=0dD;Dw#Z+C)#n&aF5%egf>?IeOC&Cc+E!K|5*N_)I={zav=*p5&P>NI3pD zh8NCP`(n>zQ|f?!WlQcMj_2%Hrzp)wiYUB_b>-4v6Z)-5EGO7vE(lyyYe#0 z9~HA#nTWI5swpIq3;Q0wLh-^5oKMUtW~VW&W@H=UY`>z|PEMT+tu1fJ$nay8UnTKx zvxyQ}!6hVL#KW*l0V?)(LQW3n zh!HM7!g_U?$*d`22GN2S$yLOrtex;j>Xoz7h#7fYRgHWxVMhjY6S-C9q<6nSE1>OI zGF;^saVKJip1dO_gKKRK^h%ORGE=)$AA?t#pj$USxbT~KUC&JDeGjL&+o77%{_`X!#s}T1bE7=UB;TANdPG)rrJ5Q zV}g-H=WDcRJd^P~*5v*U$BAH$8HK1vM9-WZtJNzsh>>Mh{-=K($#&r9Vu`m_9~|wW zwXDbj3%d$h0**-$0UC9#&T`2y$J+1zymhUzn0ZPRWiF7LdpKaohKdzbUt~L9a3j0r zM;M#p3Vb`Q#_-ous7KrG_QuGpbC1(L4H0Bd6I3a0dOVO)Eo_1Rd+DJf{CWuXhQzh} zT_{}BrC0%1@BekU@Pi!gr$KLL2K2N`n@r`!;8$m$a6XppmdM}2%ATz5x2|R+N9dcg z%6WG!@SK&@Gs5kr1-(uqx!)el9{#pVR9A;<|5eqBddnEhL{NR71nG2H0KHRlz)Dn6 z^?!F3$*w6RkJju5DW|@0#nou~KSI==f*3*)#Lp7l8XySxa>Biktw<@=NDxxr;1>UO zxe+Au0UJ-*#&Llw)Z_BqHCjv-jBXVI*PTHWovq! zeIm)QtWWGIDN0aYE6ROOlNJP+IeQY)<#y>;8yZztTO#y|aj2oE0KbyPq@HKSmMvhT zlsv>aSP*Y}DO{!|e?&{7Z^G};-=b^dE>eiA1olPPqy(j6m4EbhYla4h5-z98RSvF$ z0G_T8aPsg%6!C1K3+`>E8agQ?%&O+RW~mdG?$;L*-yR&_pzCiq(8v#BwZfK#Tl)W$ z$N*2dDF3wzg(X4=I}(1>phVO1*GUmzOmvG^t#}+g)yO4rY*eZdf)GNAy$RRIcN)5B ze1zq!ZtK9b+K^Kgj?$yX=zpsoNl@DzM)zI`D<@Hwk#0 z0{iv7Tn_w=iQ}`?eqiOH0}QlZsRxYLK2P;^f5+I56{a2aOS1wKKA$QJ@NasJ=Gpa~svx!G9lh)QB8XN_)ojT!J3Sl-BX-us)pYw(;3bM_ zT6$OSwD%=!It90BzbBDo3fans;mgKKG@TV`AQu%F_kOOv-!+a#Wr6d&R$hLmKY6EG zq?4E3FGKIi-q3{?stT38!oP`JmWT2AQ0D2?AF}-vm6?*qBUa--Mnrdl_9s>SEjef) z{!^&eB^!I_lC|8nXX48sj)V8WYv zDr3Uba}m@GhPz!CC%#9Fhl)v4(wR`ykI;*M~B-sQXprLZ^jt?Th)@UhU3yq}JjG&saF%p6G;O zj#)lP^V6i*?Z2ueYX=^v&^mHqa}@-bp1J$+$WBia7f?#BcJqNZdGg^aJc4i=t)sr_ z@#q}&@gyN5nT;zN!Eq>fn#Em|`n^BCjmIRBO+%>bIST|loopQ8lK3aN>9@;ANr>KJ z^Fc5<_V($!25+2LbnaSR%SaiGFM<$L3t4Dwd8p2y_YyWGHFy2*fUU&u)Js&VveF8h z@oTKdzl8TABvjp15aA4i`%Ux8=Cev6A8(VK@Mt}Hkn8r>(mUE&=xGn*kH@)UDSS+H zcy_gSw;l&bN&|7tgcdg)O?@kmjSWA2_OI(oC?k4YY+bN|5DlH@)cpLNNzyyx9A5LQ zS(q1wCbu`!@c6eko2US*GCoUl@kjQp2E;h-|MiH+=fu`Rup+0`aFU{~jM3`ZVFk_+ zsKvX&gNMg79i>XyVULHNzCUQaIc8Bh`ta#kKXd=}_fa^*(HHpTBku*5g@BvA&vL)A z?4y2W1s=<$cBg8S*$&?pC7Lv(54J9EwMOw+cH%4h4SuaF9`F!o!1Lv>#Fq3L|L}gH zl%%8&gT)8!$JZt%4l5ENv>s|-YHu0=U4M7znh5}qUE(Eh8#d|EbXVmhUif{)-qR`+ z4-Wvw&Q_cg29}l!KW7>M;OS)DiU0n*u=+iINALtdo&nIHb>0B+{^QH2Y|>|H;t4n{ zeFo7Pe8)GXZ5QVVTz|P7_X+l_ zfmquBgV_2WdE-Aw_J8s!Tn^q*T}Q9^wy6vegPFzs;GT~Qem(nMx&ZRGaI9(SUTY=~ zeXMPU76*7B zEo^fnLv+XM@n^J_BN04RvaO-mZpQR#)y=dZmYqfwPUo8KkT!~c-$cvYf1~x~l`cjS ztQf4h_y)j}3gevWUMSB3Zm4pBpS*PqFgc-JEdAf7t;kAi<+?PP5%NSN{@qzB8GhSe)L_LJB)@pL`jGLw(5} z;o*LW;=sSULhdHcW~v%-#2Zi1b|4gAlXk7XycqUGd2&~u@2vv`(qh<*wXTq*iMkm( zPMBsJbWZ36CHQCQhP;F|hj4&jY;NM~o3G zoqW>sVaE4gpC_gCutY(E4@T=enVU!@2G^3J*J;%sliZO*GHXywnsGjc%tBN*SuWE2 z8$%%ykGSw{Iiz8(2vI`t1JX0ZA&Q*&aA=4tqC98E5gMRD47Q?s^!fI8ROLO-AxJ+D zvPc1^k6~*bxpo6XXJT(kMiZ+u8nQ?l-`V6fH7uQ{)fIByc%^QA#t52_Es3GVn!~gn#t#dtni-kZ;0l>L)g%_g#u;1)CTN0X zUl%iBGHnSg_($?MQ%o}rJMr){Q8Z)6786TUjU&~^J6!*)g@Rn9l-nknE=XhaGJ@U3 z=cKxN@4zr~Xy-&0q|0xs*h-)mX?vdY^coq267)J7I9rxUx5@Lws7u#8Tk3?o&+ zu0m;)P8T|CJbi_{O~iX7C~W)`IIH~E((gRxg|U74hsJ^?Q;=qq)Zo`_RZnm2p&LR3 z+?BxMVj0CuJk52AHl%H^o9iwI{XN>Gl{b<3ID(d}cNh%c_^{N@67GnLp>jO8y) z659Jn$ho{RK*ZZ#+B4RwqN>f-r!;;Ye6gubfDr;l^oR{X^dGvo0$t9tjPGCQ+TMx` zkKO^j%j%9(X6P5`{>n6L{J5b2@y@l82$J@@`bt?Tge-wyu5WWpN_sb$4W8OFiB+Tp ztXfWZpUmC7W~=|<=WU@cr3tUXk}{;kwk{%_cq-pN;fi>W)9{*<#`R(}Or6ECzU3MQ zwAJ#1uihF_ij=2MGBH!A3q`u{L zs!VC*U;Wh83cYQPK1f2`{z|llMyHw~qEu4b@h5MOT!_mUDnwB~)YH3;M&Q4dN1S@>TfUI{x zSA3Hmjy9xlAnC9H6A%-sa-7g;Z5ZO)CJn)0&R33Mf;%jihOP1QeIoEy9}c{O^y3hR zSh8uCPq#HqoY$++sPk6T`5e~0J2bfgtIO4|V^Yguh_FN&=tB95MK~sY&E#`Ac&$;7 zospRbu37d#sC#(*&YKW}8c>=mA)Gnk$F9fXhrk^F#nczjH&6tJL|PjUezDKA)n6No z2ytl-+}sbH4EI+lGo|&gFMD2~zy$A*T;s37SQwYK`)wdrZno7`2xP6=Kt#FJTcbq0WEgk`X{5$$J)>T_iOJj?(hk(2DQ)hR2fjis4d8QgU zXRvWsp>^{Mp^ci*x{APG*RBB@4Nm3%`i*8p8qP$$>a6-?AIX0Ar>V078x%`2T-){RX`7q93U`CIVxNZnYvaJ~PpL%)_q(i&0n``O#MK z_t&!w0JeynaY2Pj<<>jI=>jP1dq#4g#$W}2cxGP)3|FE=0ZgIu?PVno!2=a3fQ`Ej z+-(zd_1tp^fU%gkKbzXc{c~h}86fELx4&5ez=!P9$+!&gh3}2Y652|)1lSk@5M_1HN0G;Lw z(JbJ%owgEX23S=HX>H+=dImJYNcPQq69Y~i<;m=QVhnzS2Tvv2fAE0`0?b{c-Tx+| z%V0NO6Fl^_$=Hs}XD{~kE zbDWg`i#}$5(BXhDG$HpqESbyRi6dDfowO37H!qISdMI28E3hfO1Bs{auZ0N5o?0WS zSGdPu!st?9gMs>(7Z`ghGE;J$3SCOt#PR91_b!^qs%Gi4l+-HN=0?5%=S+UNY!5T|Q*>WF~4rtny=*hSe zM;EIU?MxOSs;39%ArAIq!Dl%R%+b@DEtbxis@0?cM6@|Mq^rm%dS^Y|Uo539Pe&3M zxtwp$!$?~Co5}qiMhW2SH>76) zA5Y6pIo&Bj00?x|gOANVJ#ooBv|6LO418hAshrgKfLcHH+26{-qW1D5yf+2^JzjQh zegmehB=A)%4*%Ht9-j1muE2xhNcLP{Rq;G^oJy<-*YzC*xoFjOwR>svt;4`B4I&?@ zT~>Coul!RMEbnMB@vIb~#?zBN+q#m8>0VDych~I)FWmLraMXfDu1|~v zYz+;EtNz8;P1*+V;Z<|69BnY9!Nfq?eKGjGZSxLP^k@gcc4pzE9! z_nT@ZYNWtsoZO#ASqa#rkBCaCcP|iP3qWc6=*%~U0jj1DMiC*IS8HNw5#*HzFB0@*3P_* z(=$`28`%v;%Tm=Em9|u|mwey337zYA=M#O$WvOGEiF%H=S`2RzgKa=C`Lh7qMb@JZ z`ZtGi5!@4dXtL$go0Qa`9>KM4yPOf)Ui>DqIfk*U;*^X~CIW6J?eMe|;~-D-eqvWD z^XfXVUoU_7+Ej^RJh7wyHp~Fe&?OoHPENV0hpDG&_NqU?i;w1VT*8*I!I-K<2{+0+ zRqW$;Llzf%ATZ_BN{+3dj^+vkBrq6em)6~#`T1E0bjRD&(T4tk{Bg) z(jNWog+Z7u>|AC>z4drqqw2_@ zU0FfL>#lab0%l&oWalp4fDPgu&gNpu(|~Pa1!m@n{I{=ztSv#v-^mK5^J-t(bsr9l zFN{ES1Z0w)R}SFcKQA1#iv!akdcLNo{x;S6tEVsAvW_=+b@LJw{jTn(C_BFU+-Z)| zTQbAHlC%0$l4vVK;bYf*S91Q1Gsjk>issQSTep-i2DQt{H{m3utNYHys|hW1ok9( zgJe)vqBD0{C5WoJqUk`0$v#Om3dZx=FVvape$hW{aUU&?`Pt91ZEO&D=q|be3{dpi zLM^}O&w1V4C^ReWu4U!&wHcQ@Wus--1B$N~fKC6I&avA&kQ#`Tg&65F(we#d%uxAT zG8eGd7xz;QO@N*L-)b9506+$VXJx3DEg5%Y;I*v#<6@Beze9@+2Y5X!EBZIC*Z`oS z?(%w^H8zf3ub2%+Ohw^PHsRoK?_K~U@IJ10sD;d2fp;s6Ptj-6dT8*3IY9w+Pm}PG zs=xOVvWve#V=P`8;RstN8lg`_(p8JN7??*=PjIM=q6MRi<!(jqH8TM8 z`Cgu1J;<1O0eG(P_WaeImykn7^lKjE`modPe&)+RHl@Ep?zEV%U*3`@%iAb$%Mbwk zr2GUg?+1%4#m5In!dnZ3b%2p}6$GWV9P|8e7d_NV64e(2%>C7cc$spdg2r0na}s!@ z04=r-ls=Qdvl<79VXdQk)HZM(&{b`Gbk7OOu%Cb?-v2W})Bh~eyJ8m?fNZy20Y~1D z3 zc;QZr*rQWIgbRqu3b{MLbEL~3PMYx(dcQfXi~}({T>aJ)#X2vj2&eNWCwceNF0(VN zUpILOMDIPER1+7iM{T6-OEqZSyZZH70RprIAltx&JEsKez?uf#t_qfK7n3&w#bW7Pg4hgy1p~kCpEzGWU#eRTQfRI&a!*)h$gpx}tUAKi{G^rg*#=(4`^Yo;?{?{~gMJqBeXq zoAzy<5%h#51P!U#4w3V3)D2vZsNn^#ocIIyYsGP)_BNX5#$SFd)^;ool^=hcrfdIJ zIOq~fx6(}LXJ0k{=pS8l5G@>6SWTMVQ8)|!Yf)ISXyocCpjC{Tl&k9;U{2$6Ae?xs z+NiD5ArnqX)-1BkK;votMSVCxP0BV+y7{(^0${MeA!2VPjH&thB_QCUSU$0maWYjZ z%ND>=bgDQd`)XVh?=qN9Sn;+)?dVewqVqbf)_me_pF$FXi$J4@k=X@a%NH;bCHDAG zO=CqQjAB_Yq0sSn>j5ZvowGCc&*wQ&8|wOtj_NppLGHUG(;czNS^pjuH9o0Q?XQJF z!GAWI*SqM%D)qTictrew=>%kFO3?5aqzErS#ahuI+(oWsT26;P7JzFy{@u#y_#|0! zOSzC~B8=dR3`vv+)|*g$16xabm^~!03&zJC+hXv!EEG?3g#v5(V^@{q9P{!E1a{Y_ z`lTm}EPgf1_Ualx0UOw29fzK!8Sx)md`;48vA+50XJ5WhUYqIFo3#C2CDBdlqK;jE zn+x#EKZP7eN@5P4f%C$MPOsg8UG4!gC{EX^6bT0$=e$tv;V$M=S!>T)Ap#Ew`k?u> zu=Pv6Ps8`RLG>yhAf{fxe!495?tGp*vY~nO$$>!8UyNCKU2pklvqdYp+mse^El;e| z48-i-In(-v)8tbz!^+LJjiw6NF~L8K^LvSMWmuUWJzRx%dm0 zHb5BN&Va++iHS#7Uedk!)^#qAMq=}4FO5OBTV7H4VzSzVT$g%_Mn-a!p16MP@~Wi# z)c%(O^QHSLW^$j6#G+NdAIBlt`a6J=jZqelg0xLR#M0BMaDMMWx#mi{iOysfAj~X_6)<#fIa=K9N&fXSBDa2YMJ^^w8jkRRK(y4K_)kCIis=crfEPb# zZ&nVm|MkiRwR<6s%*b^DVA`XYjs3ENIW+W_uZfWT8Jc`mIQqqug!P{aiZB z%2d(q)i<}7shPomYsXoIlsLLtj}F#TZcgWU=HnWGb!kF0<)_A4uuiZ1$U|T1NN6%kgQP#Z72*eP#xTeV*z%~8a_;hWv1<;YSbA*AcF|y2~cPDaR z@r;DK%Kz*ZOuG!K215fulF@e)Z~TK4K7s7+6}ND;Tez@d(tbU1ZMI_pjJy8+vC$|N zxVgqQ5v)16T@tlJ>*2@X6vrb~2Dv5j;USSVW2a*iFLTXyMq0+=gyIVPkP>wbwgrsn zi}lC~9Wa!@`5Zj#=~KWB7bVAg&H!m}b!V$)h&)mN3s{G4oUuUyU{6M~N;H#)0A9Q3 z0t-B8%stXug~9+=Oth4!HpeFba@g_Z6PwZ#AZ_+3@alhM^IiFe!{a#-JxeG3-Z66j z=jeGp;KQ*(+q4s3UWMAgp8!^vft@IfnOA^^lem`rcGG}U$M3VEuEVtc6LXCa7647O z%T}WP;qa{EfcCI^8|9U#e~z95K-+F8V)yBP@(BM&jb>(qKOr=9kV=dN&B-Z5zwCx0 z65F3!*9AVy5Ir+?!sBcIO22AeWWS&B%adjI$dY_j`t$ER(no}8VgGRhNucLAR=BUb z&?&dh%uDEB(bNr4n=b4i%_hslh#yh-Uvgu zOM@=NYkeT@G?A*5$4km5$B5ML5O`Wn@FP+HvPzzzMFF{%ty<(?yl~AFs)a}3l~QBExejR1NsY34fHk%Z^R1PFM9!Q zEm~Wl*th^6!C(Sb{YKsA^4wTTRYx{WLye1WdP6NKh4Y#hEAQNi(pQ?`^K;vvcodpB zK~EhzHa>8uSp-nfFrAP3RAW7|%4uRE)c5LX26j;?T(OIYnE09u#@u*XNhuMB6ye>v zFTOTjYT6$4D-gqmj?`;;^p^3*Vy9)vG&r!`rOI6JAmZZh<48WAbMRvU$Awt5>RqFG z7fc045JL{ppV0WG)o8P-A@4H2?@r`35hnKK>^wkx@#ezl(N}-lSpQORosVt$!-+`A zm()5cA<>{^M85K{TKdB4q4k41v~(DIGcrKUG1?be_~F#FpwEDXfS_kFZ$Orw!Bfv}`o`|SGo(8vGSlwG}wK5z0(KM^!=4UlW! z^t~%@Z6=-OJcQ4^17lV0OvDMsDwW~5CJ^IFepf2d^PS~KY5s2+`6oM#vUXhfp>WW* zmb{k*FvN``fC*Cf>Z5hRxEn*h?LOmy^D!Py8gf!+Um}adafP&D1P;Pu%(N9*7yKLB zujB-0Xz}F$(aC-#7-%~uUP>{3WG)=&mznV4J{ybMJ^U*@z-FCjk4j-cz!G@L7me6~ z?QMDx&9Z3-0X}aHW>D!pomt>a^E)~_r^Fiq~YTogE^MO zMPqVQM>Kx)CJE1hMK;gs2)bjj;|4MwBxAU{%z zn|R>0k@WYCyJ;#|&V7?ZyTsi|%?d9-$i8jkasziKAvkh(BEv;|ij=AmYX?6BU6$e9 zT{QG+#sKN{b=Dx!=zmU@Tl5K6#rDyN$O z)+q0(}tg%D;5`_Nxt0Ub(5PVdYy9#De{yQ_T%Oe@UMV!Vn0eBpp&w@s>K9Kth zN`yZ=671bg!|U6rTT1(*O$&s^xjh(x0b8@0+2B-I`G@4hJ!AVr66w zy8&m{n<%UVjrS)g(UddHU1bn%TF@dqbp^bYzfjDPlGn*~_0PpsVZxUX$I4O0p6SKv z`>Ug*vX62oW$FO*=_5lFc<*KfTtrpBKoi+6#JK1deYQogR#6*+P2WRLH?k5a9d2D0 z{TKZD&X8gej+K=nq8iD`e=gmQ!8-@N#1|$Y)FX<(pPxU1zm=^rB>(_w(l}d`@8(g-)&@2;xBsaDd zE<$&-*!Y(^)7!z5J0DxIwPc3#&)Jk0AF#>X|8;k{_slXOj%SRKK)Db&7arwwMIlbN zTBjlqhCBxk%P?; zO&)$4lsLze0QD7~?hC6o?zJ5HYxboQZUMMf6d#V76rQ?UiwN*Fq`%1S8u3F4vS(q)weDH!hHKKnlh-QPIb;0=5;%_SiYRXiRbQ8 zq|AXgJ-&Uqjld@)406mPxQ^4bIVYqzim^f7b5tcZRu&3fP)`3jiR4moRxSMf+ERt= zh35;k!dosOi|P`&iE0n5!#(J8lMnCV-?YwZVdD+z7VnuG77@iJWbyaYB7RJ=ZHqiE z7|7ry7|d)*aioR+fU#08EC!$rYpulSkmIs82s)v0Xjri@I~f60PBs4E4zrh1l)~?b z*Fbg|Romh#?^)4$SbLI}R`Vd%w1{!bt%&a8-3$(^;IS%o1`qTs-ZGb}{$wM4bXQ?G ze`5;1l-B*?VnbAkn61R84&9Zi4yS$n*pj)pWrvX8OB2RgV;xVoh7KmG4?{QK?ho7R zMgH{Fxuw+lkIrk{IgGz?3%{yX`Vm5!`paXjE7M8-rQmZ2z-pmKfCNkeNXq{34sX7S ziFzLIO2GQ70w+lCkhTBpEjpm4gI@lsCIA}WnLeYt(g3jrdz`k=y4Scmw*2cI3GmNJ z5ygR3+;gxpgK?}S%2^&0)@lL(6DLmc7;`nm8ir8$_99=160(sVyM0ICFEQmYwq39i zWe9uD+<%|glFRcn0q_8_d^CQw3^-623;S&2vI=0MBr}Hz9XQYxRC!eNODDi?vQjdm zI%j~SyYEeps)zp@(^?BO6f-Yw^8;oU5P&1FafLuTO;~V%jD;0oJkjia+n643>QKd& z`iy7Q0D!MtjsIE)fSIhTfs6t4ZRM9&kyl^j->mMqNtV;nS z(n%6`1>;Oo7+3tNhd4APpAz^Mah|+DeBfgd8UcvNf|3{7 zH|~whN}T#AN6fxq3xB28L}Eh>A=eguOk;@Z^v8ySUy(%;fKKXMUO6#k52V^f-ubr| z)?nWy;2POG3Yf6+n=#lAK8VB1WGc%$KPj9HW^T~p=Cn9aP=etDX(p3Gkg4)GSIiUI zADbCXr=y8Cd4LiV)#$E6H8>M5Wysnb;G_-<0kLV#K}6~6Pog0P>oOo5Wt?wIeitF= zeYwfuqyXi5GN5TRIIbSEhj#pU&eGqF42!oZ)dt+Z7IFjD-y0vres;m^6_y-IoHf6q z*G&4ub=*-#di*V)PH>Z^^U#o7NepNC;p&q&1 zzmNVZgP%7WPuq}}LN@#ql2dPWxb(ViP2+ z0BP2LSX3CLW#u2>eyYQA;>j-J(2`6d`VoTx=$g#6@4x9burQm-z;yKLN8}UVYMw8- znu|d@xujl1boNuRiJ!qC(dQh<(`z|SUkaPxDkfiV+sq3A^SdJhY@A-1J9iO|@sh^* zm&gOIT*r1!yVw|nx+j&A58j%sNru#wm}tq|I1)2T9kJJ95F^F2LVi|I@-}+ z=P?dwt<$5EumybQy09>QMVQEIOtFT1(nf%3-UYI2A}KwdV2^xtzO3Lz(in0^3b$Ok z1`XLBTE}iDVe+M4?PVd3614;f7piL3U%UcB^g!B=7G2pSfN(AARSl{sZgFU9)92c2 z03&`xNoAg%r!`T1-E9(Fa&Ef7ii`47R&A0(F=6GCK0ytNv(YsUwEG zyP(F(#$_yyk}g++;-ox)^|Cn+vFetXdZ}?~q^|_S#2G*1zP&jEP1$Rdbc}}DO(>>vhQBvOf)BRm zFZ*ES!JS!Y_{XN?kPokpl(8>p%v{@FPos2z#I$=`fY3ua33f>d2&W5D?4ma4LOD7u{`FjO6(_P621Igi9Elc;GIp9gARM(90U=>uKHX8oR z8}vawS+`2ev3IF4Ph32*mQ?Gw4N>+js1*?psCCr5G5>4D?`*{IC0)Q9Uhg%lP|tt` z=%YEE{9p&ogl8SMybU!c+xXSpiv}aK1)jWoJ~B*1&l-LPTJYrNKx`}GOv}}bVHZFyZZVB840@iB!sF{*f27&(L12|13_>0%Vm)@m!X(2=4Vtcg zcFV818&F%F+uyS!uX8!C0se9VIU*ggIp`xS-%`Ip8Zw;ey0<)E?=w?C(GT>`(>k=9uTDiE!PwGizCP=X9i?@6x7KGN7>F@&J zQedf%Mt2x3&i|!q-o8jr6@Wg)*$t-t&Wp6=!k`yyXC)F~wHm@&SSd#W=D zE|ek!Kj8U?%1{n*KHdF;_WG;}H%%00fKLj(m`9Y36mXYPM! zF&4s_3A{YpYhMc7SXl~mgw!ol@Lx7)FZx%3`+w)sg}Gs`ZK^%fRe-;YT^i-Qeceqw zGV2%d1S{3;FKyBAH{bcAjVifRWKYnkM?*d+X{n4Gv2)m{#ToBmrcKE#(xBuvPFtBd z8k!b#Oa063H@gE^ez9q=rl?nPvcgq(e^NZe{|hLtO@jf=s;t9$L7TF``Si_W_1&}x z5-uak#&PU^`Y?N(xlv-&u;JwgWA{Q>E(Hv1ry8`(Cn*E(|Fi0KQF>r4i_85IC*<)M zUZl^}g%@rR*b8r~2DY~ljFT$^5L+D)^4x6n>AkmbcmBZ`C%I&McOceuh#x)C%zs;= z{4$yQUt^zBJ@Np|GpM6YE#UEH(G$;$1F(Z2;p(~n#w6{a;^;djgHO(%%jMUwlRMch zhn^Uz@H2jb)^SKB=kJOvyHwJp1>WUewFUS#;p%b5E9b$o?>dsatVKwdlcjgzk$|o? z#WXfk&ckmHw0}4@(a3i9M0^#cb#_I4>F;R(fZk;9H#JunBf!YZqC7NQVXc*bWo9Jx2kSLwZ9m+m!xu^k1L^Ks+Zk44Qy9gBVvA|Gm5*i}=GTVgbwlcvVv_%Zwxg z;U{@r^97H0DNv$%%@y`v92+A5Ze%TPXk3H+b7cJwD7kiGG*I38UqbZ%g3*0$Y2s@^ zK$T{z?$=wwa2zW}=GxP*;g}_ieZBsa5;9sA7lYaJeA;HsF|DnUeriKYz=2Fh{6gIE zm+ELDsq}PoWbZsDRUkHG*M{WxK93y&FoRst1#oWgjlP`vylNijt7INxb!Nl)iR&V@ zkNhB8M#OS6)T0bPC`aGl9@;ye#GOo1GNOG3Qzo+Pgas0+$kiF1c%Ad)`$WTR)q2un zBv6L?R%EBwP8%pj{(ec}YZK(O6d`m;CMEcgzF%j$3W=KoNkM>k8att@W-UTuI@i_Ubhp6azxRQ;5b~LPciYjC97p`ZLVc5h^p#vYY<*rs z@J}PkXExZ+{OMc}!N`~#q>}8QiYL{s{cU$iLdXR(q_6Z^3??=Qs9)7KK8Ojw$&C49 z0(s759BDunCCb0>;mi3x15lL-Okw||)P+QR1{3SvV)C8HOTY{bGs%cJl^w9n*Ua8 zVNyjG{(D_%wcigQ_Ozi0zw@2-X(TSBsl?iH&CA2e>NAw&)j>~PCni*2Z2vhC{QgT) zJp>MV%T2t>00%uar*)B+!O>w-jNPD;qhd!Tseh+xEXB^?lrYDacH|&i)u8MGs4UnD zNNYo@M^1Omq{{G5E@<`oPBRjOY2*pPX{0#`L5j}(f0e`LgKo|Lu@awa1Q-Q@|@I~66SM~wioKpM}O>sd{)A1VgC;~4R-!4V$wK2 zjBvKwG*Z~CrQ@3XX#8$hM@Dk3N{&MUl(S~}LF6fnb7%mQGQ3Tyyh892@S65*&%VF? z#@MwU87?V7{WM7v$B=Cotz|&okpu0wyR2FG6}l|EnaCBB1qi9mp7jdad8%A;DW7aH z4slLQ{rhJwk+Y{e)gqjQzys|P+1?c=>2L0!`%QW*SspM1%E z43l6K2vIK7EwbvDJaM)ZB-1x13q~`i{iyjT7l2hoMnbvrRB{aHiFr48Q3{|*M(>ry z3ZH+#Vk6oMMkKMx+U`hLZsPPmPtqjCZzlXLQCs7Ozj?kgza81Ha#A_#7y<1j4ha%` zd7I~&AREBs3dwu|luf6WZK>E%P<}&(_0; zKsIM2NO+vtW*ABIuaC)Eh5Dfm?hGoF)1zgdB1#3NM;Qq%5??bD)PyeA!$l76ng}up z)GYAENLb3-zr*?t9DOwEyYpFDf|c<}!g1HR#P8$hDMA%y>2pW1 zmbF$#!A~4+uCiyZK$jySUz_V+yA|GRl|0dMxkuc!=fk3#ua`b2`a%sWFCW#KlB?r> z*CE4&?*RCJ2Rc^gOEH(eCbZQoA9@24fz!eQvD~-?yYyJHiV6qwMAK3z9qAy^uqtdK zWhI0-LTmdI3q;B(3YpSjNdM89Qc9!9;Sq2B?huiEtzoqW8xc%9Awwr*^#p%E`t2FS zkHmRdStm|;n=cPQr>R!|AGC0@JP$7@OH19P&x8x6gP1ATZj>wa!bKioryOfnCqG5} zJ{K{U<){-OhlaSctoMi~SNCc|2@EvJT?k5sYnBqLEJD_FTEhQ{CBaNm-e!wPl((z2 zrk-OUceSpRf)wLWZBjOnKzcZ2{3jEvhGsN)!r!+4oG8_TN|Ssgb&ZaAB*LmF%=?S@ z#-~Am3%~pz6AFQQ&i>YL#@1Z!2XNxZJ@2Vw+6hg0_dX&sxodaoL+?9#SCLU?#~Bd6 zhU$xtbGh{0-75O*931bKdsisFE`kJT?VTt965+x7sjZZ_fW0?~sZm+EzDg&CJ z^8;^n>xXQ{38J zt(ThKs2MA=$gcqm;|!S2F%>)elfAqBOj@;nGeb>F0rJ~U+#67hR&3Gl+ccFbl^+hI zY<3{8JNehQUa@QDGnaNn(v-I(*Sg(gYF*^GD_sRkrc(CYEQ2=D!OB0(XiY68*O|~u zer3_)EbA$z!b7+f#W8ae@nRrdrph) z>>tRp`1A1S{I`5B)peZ&J0c|vhy%gT!{B${m{0K1ik6915M7_YDpFct?Xkb^)>>qn;(!1(V3 zdw?ssRPi zql|_WQX|sz^csLWh{4`B+sZN!zU&7-?vVZ=v3|AO^wR|f?x9swILN4aQp&rrc-ELq zB&uiaJS8PV@x^{4ReC0Z&g4i~umH+T9Os02Y+{ckKO?f{G?;@o_@>^RA&!I|hzrS; z%Ka->(xCCgecBx_RtvP0*a86#LaR?Z#reA0%Eltvi5^mMnf)Xm@{NStGK%Qg%@2;*iBo*qj6vTFB2nb%q@UxZt``W z2lImboiRDa2d(-!h75Z|l*}wkAYbnLwwELkMAhjvXV;9mllqFQj3I`oSuxQ54gBf@ zqG$Hb`TiUlgpUUf!)H8wo-B}jul-6!0T}GsGIle1` zD4Q0f7`NJ!zY+a1RRcjQ(2(h^gLgm%gX-n*w0PtC9^;#~CSFxS|D{MX_s=TaV}OL$ z`~t1y6YD7YucdJ+m=hcWDGaTEF8>Y5M(&k>{P$?!;ElJ$KElo;Qahq3&(P`X|;=DD?DqmBjq*(y=ez~m09 z$Su*XGv~nJPn+HBXY?V`j%8w>vV^S3NMejNUH=r~Trs~E?+mtU_uU|taG7>~(Z0+a z5ZiA)VwS%|N#C5n&?B0&f*r#Ti|1d@D5U>C<-G?`R87||Ix}Ptkc^UsjFKehBym7c zM28%NK}2#65(W@Z0UgO16cCjm=Zp$6WI@SM$r*`~PxFSa{!^!J-8xk_{B1$n1OI6qlF!GF5_b zX(4`B%Y4`b3i!M=I*VjJuX;4-%&~iDd4(EwzX>jw=#&afMBo+ANq+6fi|!%8>fDF@AdRa3omzI zzKRvaE2EO1n$NhO=%FuJwNc-?i4F3k!YGmZcsATN9>Yh=FB(y5ov|g&*Tx1h3W!>K z>EtenzzsQ=;)nzOGS`$pc*Jk7LVc?>hfDs5nx;78it+>^Ev7CEpu+3_GS>Xs$fz^Z zVfS$fAo?q!(GqBAqdBzZ((~Ma>&jG7(ZXa}Q^Ren)URv3p0%Ec?NPG7pywA~aG-7LoLo3it)I}1t+C`nM=nZ}MEq=umG20; z&=^chxvns6AY0P$xxA>})IEMr6csna2748&kF_70v+yZ`s5LD?O7MJ-I?NqJA9zg* z+JCSz@1@zWb2B{f5QQK6?N1zB0B85TuliPDjz4v9H8O`;~FuearlzO+YZB7HT)NpRv$qCpfSMsrv<3X;E! zEMhMQweL{hXyjM>L!JDiuLHfdIO;L|F9;Y*)3X65TUCDazMlovANbz?2_b6GZe*6p z9Y_WCq?&u=e@!j+^=PO}?sna_3b0%-KQ%s^W+Vs5BB`y}vqWL(X9r*RH~}(QWSCX` z!ol=}uPLnxc7^WQ7UyL$ElF4&G)3Qxp&X`vLD2g|x|;=~3MyT^0K)H2H`KLrgTt`E z=+hcGBs~0Y!24VGLvwws4l9*=8E|i@UWjHEitnR^Ey~x>>W4lEB}q8Z<0*v^A>d)~ zHojwMN1zSN4aT7eSB4`?0FURQOe_9&zWoFr36Fz$mp+W`3CAk`2dI*LiBwuE|5Evk z;=PO5UfTw1P~;t`(t`dc#2%(IAH1kZ#_bhWS&raD9lB0?VM!$e6VLo|KLGJlU!Pfo z>quYQxEl;a^Zsx^Z8Z}94_L2{R9qhzNe2Gk^yFNSB(GmMUdaD17sq=GUdD*wdoD(- zuK|BkhIT}G;F?S)B}yN!%>TQyGSWp)frB4o2u$K(p@b;Her=0fLY!@qIda&{Mzw6t zV}8Rpg#Er%7`f!PYxzF_(p@xc;P2;J(E^9~wNH@Qd|=iEXoFA^yZkoxcxBP$zZ8u_ zSix#qpqWH>=UZOUVZzv^(;ak>?gH+CXx0)TO7XPQXbdedi> z2JtJ3qw`E*+Y4zfL$88%bEu9QJ9yHT0AQc#=boWu;C(WI5;OoF$_oPQtu3=l-ZAR* z*v4PDeofS=c0SP`fY&7eq&ex`cS2Ot2k_2|`h0*KjyV+D1GqWcQ1F?;LbA=v8<_y- z+&l*Gwgk%g$<{B}**518$)|{gN ze(Z24gAlIxcJp%r)9nGvfZyQ&5zm$Zz_R`T1-`rP5^Q>bZTA9LO$x*5-S4-H87zDs z)C>omu37?kd+&I`$?~rGWM25~l;2{8;gZ;Nzz%s=7=i33e43>TL_NTP76+fqvZcCrUJF+&D5`th zetNs$ymHv00AQ$_-mQ9m*Ubk2&^rLbnoA+O1V3FdZ2nj&2aw6bXQu%9J$$s+$zS{^ z;Xbk-42^0jFvK4Ix9n^oBKOXH1LKb)c{OaJFNY;<9 z6pyq_9ZSP0RImW06RcFJ?#>P8327{0YG`TaWk-(&{47hz?40W0^P#Wz;u5MIBRgC_nH1HAFejY zBJ6kDbrr$0L)qOAyX4nO+ga-lrk+R)-ZQYrmVUiTsP**~ncv}jQgMj;1`z`fLW z;Gui#GY|nbzd0&EK{1yIw7IfCsO;|ga|VB+|N4F-O^@`YMo@UA$Q!oHCmm=*x~*Xt>r?vB;XKw47q z9+L^xSQT2jow_br2mT)Y1Hk6*z+|rX=8HN={h>N7Tr(3oCcVtN^CcutUlcB9tirh< z@$U{JGvzx=K726lT(1i#3B$y*pQx=o90{~$K1GmVuIBYfO`D~Um5Rq5WX5cJcNMl> z3yhH%dJoMpf4pd&0mvVo-6HMJbd}z*o&Nz5HeRb&Y&IQJRDXKV#wFM_YL{i+{HIO$ zF}-Kv5A9$9g|E-<--Lyte_xO_T#2BPwIevvvy zpAq4L!G7~aF_JA{OQlEqb`v2ZST+umU@xrQ{uG}OC`CXR#_$FBu_d8HCJu!WDk>R{+J&vnU|V|3>rcI*0B`H4?|(N! zigDb2^o5<>zQfwBuwlDX!U10UNdavCUyQ%*Liw5bg>@e1DWN~D$N(ObL*U`}th3xW zJ=qTr!@1i@;K2v=(}QQ$92{2;;2Xx&8;=BTPrU4?*r;0_!p7&O$<-On{>%+PlQzl;|cbbI4PPdqZtIhqNdCEQ$HT||-pPpB0-q5Z8(P839 zs3qXKKq%$lewq%}j6~O!)vD2>pXku}v^Awwii7j>)9Ld;iVL)VI@+lmA`4u{0zmxI zt~3L2l_IH?&xDSqE_0)yc*Re{z&M0#c6uW4E@c;inAM~L)>B|2;{>6Ra)W1-88+Y1t_?+pW&L2xm1TD<1mB2T1B8m z!4WVOi=N|s@A|;QcMTs?+Go^W<0Vs5^3|nwp8n@l@ohp>y|E;G=h~0pJRLF|A9o^t zk+7s1HUb5ESc97Fs59N3c57#;T$Cdm!_7WR1wkJYb`9i$-0E?K#b{1l!FmvqRFs~iE= zKoAgcJefH4mr$m=F(hS849P{m9dx{I2f%`l?#@+4NrY_>Vg5Lf14Gn> zCnd5KFcU%)F+NhN7(&E!nVzo5e#7hvWq)Z0`AhPY6MW;D?bg)Kq%nsBy9z1!Ali$S ztJ1S53602?qMdw|a>a9BR_r0bW$o$(Gn-hiVSgU$M4PLc(PM_)&6=T}sKj@Mx-6Um z9uPA$Svmf2L64adkBCb0$yyBuPZpBj!$) zql$Oki-IeeLjo}oxTG`J)vgjC3s!!+(g1_8oC z(ueg}S#yN|?YP2V?%7{bZjkp57u%$@q3UO5Nkl>--}>uOTg1QvkO_+`RJ0rN#S5unyYr`3R( z{kMuvR6anaOwHYCPJd(WWm3I9Or_-dj{Nn0yu*pP%ix5xKr{4zqvGyKjJcBllqVH^GnwBF^E$!BzYKrt$F z?U=M01c!wJ$sE>KbbmB1*ghA&;{RWTo&o*vzsAdn${4oP8Pfykxh9NfOs*yUcsP}b zsp&w<&%t8u#_P-15pp{IjdVfyImvj>^4xxI-RsL! zgyu%-16kasm71TwOAF>&l3ERg)Fmc?ah+*D|bbg{6-Lz9D+SGqXbb$(?Z2=*J zA=?#!qlNkffJyAoB{acV+~B9gwP=?0Dq^BbRx**f;aq{n6Yd?8AUxYekn#(YURx1F zG7#p4{(&DmL{`*&WKrQ!TK3$gA5xfH^0H*Cwor}@ zt^@KAwbCG%H{`NYyQeB-4>+b-NlM1*!z&=Ma(#3(ekGpEM5kw5M*f&EBkZIHQ%#B#vx3d&=NX|M9vSa8Bi zYZelAJrHPeGKo=MbjTC>0zvx@SbzX>B2k2DM3o7+AB}t_>@<9T%c9fejXX-3fjhj- zHhS<`nztfCXV~5Yc$r@aEiPH-H6`BtF6}UC=V}kBN zGpvl_ueJnkW?`}Upu~&76V(P^%*QNm{&-ZzdqLhP*{;cQNKA#6vED#1@UKFp79YhX zYPDQu;1e=Iw@upAw@$JMAmB#MSOA8u3ujXOA(R8{Oo_-J^1>^bp^hH1 z3KTAVO6>kABekMC_K*W!TmB`Kv!6$(GmeXe(W0O^4F6uumD z9Lt-Z9-oTS;@>2TIMa-Zuh;#0tA)bZ0(*CZw^SfUJ+kd6p3AH_@@btc0cuSS$?p6( z)UCu5QDekLQS^A@?y<}j%&Gj7jVIT@h8>kg2-EmVau9No^h9e$!megkdmmpXhPXSA ziwY%nn~wp{powAQ3_$v*xfvtucn-Hi7=MC5Mo&wB`DXCl#&W^6Jgy{T#ZLUNcTrz}8st#Dp4S;UQZsjOc z7d_)Uk+i8lI6T#8GJf|j|KXe-p%c-ZCc0#K&+lmDeen92%Rx~3Id+dT1v>ik-O?Ho z4qVwmJF{(ss98LA5P>J^+#f9*R0X>`Wax zXkN)`d=>QhKNK$I`_K*y(P_a#!xj(;-Aqa&BXvUMO|DZA-Qo8ej$0P5W4?7X~16%p1-1?-uI#^iz zqOVya=-vZc{%-7lTa@7c3T<3lZ+OXL&wy~yN!1vrk3HjB{x;>U!;2V$@BSo1^yz>| zZ6UEuYi`pM0kWV^JY{8)=pF$~<>+{uTYN4zx6}1yeFO~=L}4=96tEOPTwT&7QFOZ6 zKwYdxQbh@f9>gMKp9LEeE_;5T>&^PEx}=G+DIp5VQUClg(7pG)gDhXSYIs|`aHb{K zvT87SYe}R9uzn5Kj>c{qU%~~Y3LXsp%Y`OSt^4;28F58cqw>QujWM~Y{NgF-_5Xa` zqF>{d@+J%Tz{8l@03{c6d7axl9;ey3hi=`As;sx-m*475VW@CPx+?YT3!enY z-b<@%2Hwtye66dzHSqwA|38rlAe}@j74v%u+$=oy@3vTp5*|h-Ocf>engFQ9_paK) z7Gj`fG+NTq;BQ&2Lpvq7ZEM2<3eG|$FI__48t1W+=coy@bP#Lv zF5N#9n_)EyxeHc)qMgZC@}Vn2D#~|U+A8Y8@bA`WE4}BJS~;)wVO1Uw^kPvUAm)?N zi5HL|QgO%JEJLY<$}5X%^=eUbec|Lw(WFI$&cTWA&ZUQC!(!v+P^Z3ud!oJ7a^K6x zzaMNYe=PQ0^PWCCI6PQOp(rP7fYp2oh4+yLxQp*%mtNklve=o&!f)$8O)DI1^;DV3 z&ygJc-0eMgKz~^y)(4h(x9M!EEbthk-^ib2TDt07S~O<&HABQKzd^;<{K0Vo_c9W5 z+CUhs1~aF+gE7yW9WE$+w!z%ePJeS8fyu`!GxtAp-}im5Pi~5h3Jvfcjp%OIB~+oX z!@M>^CR6Dt#)`(Lr}4a-T@T%wZ0QPdJfO%!VhZ+UcH`1&jB6j7jqW#cVbzlexc!L*|&bzPBtVnRRz)3T)#AV@9O6%`nf6%Bh)k9Nj#_;KfG0OWUUv zx>;0Kii2E=o* z1zyo^@>ZkzEgL9A##?~HKHO~F>Pc65SbuOYP&Yg#=l3Odo2QC1*)(@B;~CIV(Qe(& zKH6&!)e|l0ZzvWsadgLY%f+W9D+wi;f3<$$nRyQ%({-b^FojI^^|Vx{Dxu1PsNRZQ z+Me?~y%X|{YNKtnkw=x^#(+yNkAZtrGMW4#vz^eFxdu_MuWsZ?p6>M${2kGP>=P@xEnvOO%2t9XT~o^HLC4V zU#AL=r88)tt058NX`nBSQE5)?jy)P`R1kW3iO02TqJf6BKhvTR212Cnougv=Ld1MHp@DA#&r~y1B+0TpE7mTRd0q0@ysgr! z@a3h;GzlLrP;=rw5ouY0=bFO}Qg_SWM_Xaa$||f+1E`6OSq|q0s2S&yqKsb8iRxd+Y1c@`wScU$3|u!9U|}Z_xTNQIRrG|5 zH72e=2}X@?Uebi|UpArz4=*5{&x#%)lMbI@)M3;{aV_@H>glwdT@mQ?m8}D>`zA5h9rYH$q3e#4u`T}iv~&Iq zSiulhVqZjlqyXcWHIR2Se+1f#L9^{7Ke)?sHyEE56)YpdL9(U+@ckuB?9IX<3h-?B z0p#r~jB+IY2{S4ZA(CqkR^a!}q<(Fy`(T{!-yMo;VJGI(%8$TbKq#y9iWR^7$PQ|A zxyl@o{ZFDN4jr0qXTg$yPn+wZ3}d3cgJFVI)Z)u>XT#-mqaWx-g5ULuqVK_0!Ma=h zR#t?c2oic^B;Ehyysru)En|1Ak5&FBk;)4tkm+lXy-vcXwTQ--q@zhpU z@lIv@l1c%7&oI040e^@+8$SHT)|^BDBr@o!lmE&2F#@z#b;O%H;e=2i44RKPENGed|488h3T4btBxc}k!cn$sEk zTR|3Yk|(rVnk{YKuX`B)xP*AsPZ{YyOZ{AdR+?$+aC`cLR6K5dyu~ZT6vjZ%bZLUX zcl5nQW<4KHiO_<8nc>f-{^ZHGMswrCCbpW{SUOi^8zm9-^mDWeB$(i2a!F=+leR@u z+mRRFf@Gqh;W^{SOhR9ASHGD@E#qi8Xcn^qzY9+$Y=W_d$@Th-U5jqWLG()AXYyf+$`NjsNmwaZSFf(~^|I>r5pX+gYD&ZL-& z+_mnITpWDC*lW~ur~^NbaCe@`P#2==B0x6_-}P1hAWpoEmuJCs_043XV@^=%4^vuH zucqat28KU7`dQV`4blnXKdw~!@TGI+!6yn^J?=wCmg-Lr_iOa<{rG28TH?^(6BNHJ z($5<=69f?dQG4y5%g}F8n*KAW`9~Do64pRm<28CUNZyrohB~KagTRcf#)8UmF1UJJF0z8gUEo?AVxdQqOGe1 zhfXf{z2F(Rs*Th%uCxtL6(ttkX3p{mY9Qv`GvL52#wVB}jj8l|x!BBvsLA{K!ZV2Z z0x9Qp)qfmZ30${PM^a8eo$yn{`U;{~PfloDdb+{e{}bpH}aVvNB775xfVm6jC49X?x4YK!u<8<51YCwL*?ap z_(8g0X4`R~5|T~d8V{z^{F%7PSWmKT(DET9>*d&dii~hKy-g<^S1yE&b=O}qvR(DM zj6#|%;+^3?8!5ZV(oXvT^+d9u>bJJ!=e`#bXzxdn@rp$vq3V`}(RtDSl6iT@(gi`ulj}^lw&^_W^o54 z|D%IcZ2X4|KWmbfsA>IZXNI;u#-JnK&07c5SxQ1i=gG}l3E7t@DHRPao|_TDLdt;8f@!h?JBYU1tb z8bxULbul-JKGXXc)dM>r{_eDdMO64{*mh64DNohxx?~GUfTfBa91$@h1tq9$JW=DI znmk3@2|cAMMd`R2kqc?0B-|0IMag+Jh%yZ`fw8K`&fFcatK!Qdl*a}*{k|mnwo}rk z_t^bRvZ9Mrl>ZCXfA|^$bjku*s3b(r`GzdvPb8gvucX+5*+_?^3o=h8tGms^k|7esF_Uz(m0K_8&h%k8!2w@FgpRDS?t;61Rh)-VYFbOJ7C3^$74}V5 z8I@P2IxV$6>E@Whw(RV+%Ed1xI!&5!_#A*Edt&6?$dCz)>S@iRC zcVzN?9ii%zWA>Z4>(>fCYHga&^Q_c3=N@9pNTY6TH%^fDQ2vQm!3^VX+^Lc;*G(Ny zIsBv)ZyVa(pq1e$@vuOgzb@W^p;U8)3YnW`n$O0pAY9OErq)?F>5|~~aIh9knZ?xC zE~s>|eul)6uMCgM{G)H}h`hoEo#&+!EJVBW6%@feniv)H1r}e{*3kMj+k^xQ!PE!* zIA5Q|6-+EOe*NrcTc2Vu92bh6eaKI8A`E2CbcgGEF&wK#15hO*YZhq3LemW-S-OzK z-NqPWw`dnLXM!zyx%(K?r_(CKq-9}TjGrq=y6O$jK0 zaZGOnwRCLD4}#iT(WC>Tf+3tfcenL30^+%Y!+vl;Nw5g3kD=Y|c-!L^WeIf#9d>K( z2}*rXQ13(e$b zK)>J+d92HKV;a{euc2ixS3%-i<`_Iq;pK{C!F>^KAc3_h+@^31JOBZG(4n5%T2?>_aaTo4CMoBG_vJqq!7hUy z_}-E5THA^R{KR51l+Z#ABOS135Ag$U+vzUIc?{GE$)`?+)Q|p2?0|w7Qw%k^p#;m! zm=>Uf%tb()ghKSd(CX3H7<~uoN`4$aXF`iC_(`?`%OAoG^r!DPVgKeF!~iKuJYK~0 z?VrTI3Ya!1S_U3M4&Fo#9W#SvfI4LGd@XsWs;r{NYp8KO#0#{@>yfb`Oy@#%3;+M5 z>h^7cdMO?qx+X3XA#pJ(vbCajNwA;`id4JmsEsZrkEEawi9C(;CEpziT?e*T_VLUo= zXE7}<7wRVQKkDZHd7Whixo-Tcs9p?kM9I1~?4QKrOi-Zw&I;g{U6elprp3hN>>j{C z2b!h5^(BXkndIqin@ep3G;XuVL@(}=r(i~#Xj%Q6^D3wf`GfHU{C^UI@}LaAg+YCw z+shJhl>m*tmR&hbC))t1qTgE?hde=pmghRPxJ!V>T}vUjs%=FAW^@In<=>nVpf*gO ztN2O8|4tPAyCh;k(G>rU7n9X}m9xj0U#8>qCn=-@ZfDM^zSEcyYTl7-?sr{z*-ZEwUbIpBrOw)BQ1NGR(KUm*}WDzltO)N z#XXEy3b-a52b0mESfV2M>S#}1UMRLJ_d;S7dpRuBRRHlf1CAXAk&SKiDvnH?MPIKjA?1o9VKevRT z$sq0cibQ6xKl|bljc)Oz7e@E@a7vXq za5=vt7*`vZN(X5GRv-m3G2zD-LwFRL?|<1p#4ndyt|(F zO;*Xbul>$mm+fp1ZtvO9>RVJcykREWE&^uTj0D?xMddNu!Bpzk57VUTMOBUS`-nx0 zC;735@7$MkRP=&Y*rK6p`S2YInN6*7r=zaW9ba3fg`b=ao7Xngb~w&eXC!PB4oCGf znhBgqPh58^TQk|Nj;8ihIcnn{#&5g4Po=Z3@@A{W+$He4vG6D8r8|uZg`0WlS~Mz3 zsU7c{Oe&57QFWu8dq<(5t^4kwhUUDVYTv7iQBS)6sA}z7hQdf z#1F^s=`BH|+u_PpqB}6w&h<{Y1;;!JZad7TpxyIP);hMWF#_t**v_iQQv{p$XhC#w z&uqB~G8ICrDagZdG0n@@{KS%_T>VsQO^wwtei{}AeqyPU@n-RIx}y(SGMn|XNZqT! z_r;7aj|i(Ql$(!Q^cY+9vPZ@aT2#qZ%3_rxSxs%*XG`Eu6hY) z!%N4nd0@+<5QfE<%6a&N8;7M2R;;@bmtfI*xjg*v$lSN^I&Kv*TI)yQI>qfGIlOG3 z());7E?$)e6lFo~t3vgSlD@9*x(EXyD|vd&v)#m&dY#6JhAx88-!b(Qu~A0l=1j&5 z0%Go6>mxodGn-$+J&rdhl&jvkN2GMA)}P+EN~x}L)cKKW?n6iy%;j1=kIV~PTY11b z3umrOShE>8>=KkWEO#59{$e)2kC74Uw#|0@zM%V)KQR|9DY{&bbLGF9ZNB2xwYQ^= z8I|p{4GFl!$<2n>vA5^ekuu-dDL3_VmFkxzUM)}7`Vg`Bc+ITfgcp8_9cnMtqt7LQ zO@8#DN#%bQOdo+fiL_nqOzdptZ9n!^#b1yq%4v*}BYBIwDZr=Is!D2gjr zVKQ;uFT6^zWBH-!o9W&tPS`Ow?ml}KEiOv1*_&U=+~B2&5apbq%e?lJVs(}M)D&;G zLE^lZjcdIK z#ak*vdBD2xzq!%>KO~cAETCf$2-F&(rgHE9SU#*QzC;QZ@0188~~T%T`n!NF~|Fc8_r0gN&D7q^~32lb96{(?b++mI%pNi}Uh5U0Ml z{RWh%Q3S^&RoKPt5jX)IcSiPKyd9&=3&b|>^Bb4`yA^hhN0tL`#J4-S3ju!!bv?DB In^vL!15vh<@Bjb+ literal 0 HcmV?d00001 diff --git a/docs/v1/single_agent_sequence_transitions.png b/docs/v1/single_agent_sequence_transitions.png new file mode 100644 index 0000000000000000000000000000000000000000..b9affd068f73e315939c0dc7ae64dcf0870b182d GIT binary patch literal 47793 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 E0txu{EdT%j literal 0 HcmV?d00001 diff --git a/docs/v1/state_transitions.graphviz b/docs/v1/state_transitions.graphviz new file mode 100644 index 0000000..52251bd --- /dev/null +++ b/docs/v1/state_transitions.graphviz @@ -0,0 +1,19 @@ +# Source to generate the state_transitions.png diagram using Graphviz. + +digraph { + node [shape=record]; + compound=true; + "" [shape=diamond] + "" -> TERMINATED [label = "JoinWorld "] + subgraph cluster_world_states { + rank="same"; + TERMINATED -> RUNNING [label = "Step", color=grey, dir=both]; + RUNNING -> INTERRUPTED [label = "Step ", color=grey, dir=both]; + RUNNING -> INTERRUPTED [label = "Reset", color=grey]; + RUNNING -> RUNNING [label = " Step", color=grey]; + TERMINATED -> TERMINATED [label = "Reset", color=grey]; + INTERRUPTED -> INTERRUPTED [label = "Reset", color=grey]; + } + + TERMINATED -> "" [label = "LeaveWorld", ltail=cluster_world_states]; +} diff --git a/docs/v1/state_transitions.png b/docs/v1/state_transitions.png new file mode 100644 index 0000000000000000000000000000000000000000..0dfe25e4ff6952aacc327cbbe4a43228b0b4f61b GIT binary patch literal 26090 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 literal 0 HcmV?d00001 diff --git a/examples/catch_environment.py b/examples/catch_environment.py new file mode 100644 index 0000000..0b8d5c4 --- /dev/null +++ b/examples/catch_environment.py @@ -0,0 +1,257 @@ +# Copyright 2019 DeepMind Technologies Limited. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +"""Catch example implemented as a gRPC EnvironmentServicer.""" + +import numpy as np + +from google.rpc import status_pb2 +from dm_env_rpc.v1 import dm_env_rpc_pb2 +from dm_env_rpc.v1 import dm_env_rpc_pb2_grpc +from dm_env_rpc.v1 import spec_manager + +_ACTION_PADDLE = 'paddle' +_DEFAULT_ACTION = 0 +_INITIAL_SEED = 1 +_NUM_ROWS = 10 +_NUM_COLUMNS = 10 +_OBSERVATION_REWARD = 'reward' +_OBSERVATION_BOARD = 'board' +_WORLD_NAME = 'catch' + + +class CatchGame(object): + """Simple Catch game environment. + + The agent must move a paddle to intercept falling balls. Falling balls only + move downwards on the column they are in. + + The observation is an array shape (rows, columns), with binary values: + zero if a space is empty; 1 if it contains the paddle or a ball. + + The actions are discrete, and there are three available: stay, move left + and move right. + + The rewards adjusted when the ball reaches the bottom of the screen. + """ + + def __init__(self, rows, columns, seed): + """Initializes a new Catch environment. + + Args: + rows: number of rows. + columns: number of columns. + seed: random seed for the RNG. + """ + self._rows = rows + self._columns = columns + self._ball_x = np.random.RandomState(seed).randint(self._columns) + self._ball_y = 0 + self._paddle_x = self._columns // 2 + self._paddle_y = self._rows - 1 + + def draw_board(self): + """Draw the board into a numpy array and return it.""" + board = np.zeros((self._rows, self._columns), dtype=np.float32) + board[self._ball_y, self._ball_x] = 1. + board[self._paddle_y, self._paddle_x] = 1. + return board + + def update(self, action): + """Updates the environment according to the action.""" + if self.has_terminated(): + raise RuntimeError('Trying to update terminated environment') + + # Move the paddle. + self._paddle_x = np.clip(self._paddle_x + action, 0, self._columns - 1) + + # Drop the ball. + self._ball_y += 1 + + def has_terminated(self): + return self._ball_y == self._paddle_y + + def reward(self): + """Provides the incremental reward for the current frame.""" + if self.has_terminated(): + return 1. if self._paddle_x == self._ball_x else -1. + else: + return 0 + + +def _check_message_type(env, is_joined, message_type): + """Checks the message type is valid given the environment's world state.""" + + if not env: + if message_type not in ['create_world', 'leave_world']: + raise RuntimeError('Cannot {} when no world exists.'.format(message_type)) + else: + if message_type == 'create_world': + raise RuntimeError( + 'This example does not support creating multiple worlds.') + if is_joined: + if message_type == 'destroy_world': + raise RuntimeError('Cannot destroy world when still joined.') + else: + if message_type == 'reset_world': + raise RuntimeError( + 'This example does not support reset_world when not joined.') + elif message_type in ['step', 'reset']: + raise RuntimeError( + 'Cannot {} when world not joined.'.format(message_type)) + + +def _observation_spec(): + """Returns the observation spec.""" + return { + 1: + dm_env_rpc_pb2.TensorSpec( + name=_OBSERVATION_BOARD, + shape=[_NUM_ROWS, _NUM_COLUMNS], + dtype=dm_env_rpc_pb2.FLOAT), + 2: + dm_env_rpc_pb2.TensorSpec( + name=_OBSERVATION_REWARD, dtype=dm_env_rpc_pb2.FLOAT) + } + + +def _action_spec(): + """Returns the action spec.""" + return { + 1: + dm_env_rpc_pb2.TensorSpec( + dtype=dm_env_rpc_pb2.INT8, name=_ACTION_PADDLE) + } + + +class CatchGameFactory(object): + """Factory for creating new CatchGame instances.""" + + def __init__(self, initial_seed): + self._seed = initial_seed + + def new_game(self): + env = CatchGame(rows=_NUM_ROWS, columns=_NUM_COLUMNS, seed=self._seed) + self._seed += 1 + return env + + +class CatchEnvironmentService(dm_env_rpc_pb2_grpc.EnvironmentServicer): + """Runs the Catch game as a gRPC EnvironmentServicer.""" + + def Process(self, request_iterator, context): + """Processes incoming EnvironmentRequests. + + For each EnvironmentRequest the internal message is extracted and handled. + The response for that message is then placed in a EnvironmentResponse which + is returned to the client. + + An error status will be returned if an unknown message type is received or + if the message is invalid for the current world state. + + + Args: + request_iterator: Message iterator provided by gRPC. + context: Context provided by gRPC. + + Yields: + EnvironmentResponse: Response for each incoming EnvironmentRequest. + """ + + env_factory = CatchGameFactory(_INITIAL_SEED) + env = None + is_joined = False + skip_next_frame = False + action_manager = spec_manager.SpecManager(_action_spec()) + observation_manager = spec_manager.SpecManager(_observation_spec()) + + for request in request_iterator: + environment_response = dm_env_rpc_pb2.EnvironmentResponse() + try: + message_type = request.WhichOneof('payload') + internal_request = getattr(request, message_type) + _check_message_type(env, is_joined, message_type) + + if message_type == 'create_world': + env = env_factory.new_game() + skip_next_frame = True + response = dm_env_rpc_pb2.CreateWorldResponse(world_name=_WORLD_NAME) + elif message_type == 'join_world': + if internal_request.world_name != _WORLD_NAME: + raise RuntimeError( + 'Tried to join world "{}" but only support world "{}"'.format( + internal_request.world_name, _WORLD_NAME)) + response = dm_env_rpc_pb2.JoinWorldResponse() + for uid, action in _action_spec().items(): + response.specs.actions[uid].CopyFrom(action) + for uid, observation in _observation_spec().items(): + response.specs.observations[uid].CopyFrom(observation) + is_joined = True + elif message_type == 'step': + # We need to skip all actions after creating or resetting the + # environment. + if skip_next_frame: + skip_next_frame = False + else: + unpacked_actions = action_manager.unpack(internal_request.actions) + paddle_action = unpacked_actions.get(_ACTION_PADDLE, + _DEFAULT_ACTION) + env.update(paddle_action) + + response = dm_env_rpc_pb2.StepResponse() + packed_observations = observation_manager.pack({ + _OBSERVATION_BOARD: env.draw_board(), + _OBSERVATION_REWARD: env.reward() + }) + + for requested_observation in internal_request.requested_observations: + response.observations[requested_observation].CopyFrom( + packed_observations[requested_observation]) + if env.has_terminated(): + response.state = dm_env_rpc_pb2.EnvironmentStateType.TERMINATED + else: + response.state = dm_env_rpc_pb2.EnvironmentStateType.RUNNING + + if env.has_terminated(): + env = env_factory.new_game() + skip_next_frame = True + elif message_type == 'reset': + env = env_factory.new_game() + skip_next_frame = True + response = dm_env_rpc_pb2.ResetResponse() + for uid, action in _action_spec().items(): + response.specs.actions[uid].CopyFrom(action) + for uid, observation in _observation_spec().items(): + response.specs.observations[uid].CopyFrom(observation) + elif message_type == 'reset_world': + env = env_factory.new_game() + skip_next_frame = True + response = dm_env_rpc_pb2.ResetWorldResponse() + elif message_type == 'leave_world': + is_joined = False + response = dm_env_rpc_pb2.LeaveWorldResponse() + elif message_type == 'destroy_world': + if internal_request.world_name != _WORLD_NAME: + raise RuntimeError( + 'Tried to destroy world "{}" but we only support world "{}"' + .format(internal_request.world_name, _WORLD_NAME)) + env = None + response = dm_env_rpc_pb2.DestroyWorldResponse() + else: + raise RuntimeError('Unhandled message: {}'.format(message_type)) + getattr(environment_response, message_type).CopyFrom(response) + except RuntimeError as e: + environment_response.error.CopyFrom(status_pb2.Status(message=str(e))) + + yield environment_response diff --git a/examples/catch_human_agent.py b/examples/catch_human_agent.py new file mode 100644 index 0000000..224719e --- /dev/null +++ b/examples/catch_human_agent.py @@ -0,0 +1,150 @@ +# Copyright 2019 DeepMind Technologies Limited. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +"""Example Catch human agent.""" + +from absl import app +from concurrent import futures +import grpc +import portpicker +import pygame + +import catch_environment +from dm_env_rpc.v1 import connection as dm_env_rpc_connection +from dm_env_rpc.v1 import dm_env_adaptor +from dm_env_rpc.v1 import dm_env_rpc_pb2 +from dm_env_rpc.v1 import dm_env_rpc_pb2_grpc + +_FRAMES_PER_SEC = 3 +_FRAME_DELAY_MS = int(1000.0 // _FRAMES_PER_SEC) + +_BLACK = (0, 0, 0) +_WHITE = (255, 255, 255) + +_ACTION_LEFT = -1 +_ACTION_NOTHING = 0 +_ACTION_RIGHT = 1 + +_ACTION_PADDLE = 'paddle' +_OBSERVATION_REWARD = 'reward' +_OBSERVATION_BOARD = 'board' + + +def _draw_row(row_str, row_index, standard_font, window_surface): + text = standard_font.render(row_str, True, _WHITE) + text_rect = text.get_rect() + text_rect.left = 50 + text_rect.top = 30 + (row_index * 30) + window_surface.blit(text, text_rect) + + +def _render_window(board, window_surface, reward): + """Render the game onto the window surface.""" + + standard_font = pygame.font.SysFont('Courier', 24) + instructions_font = pygame.font.SysFont('Courier', 16) + + num_rows = board.shape[0] + num_cols = board.shape[1] + + window_surface.fill(_BLACK) + + # Draw board. + header = '* ' * (num_cols + 2) + _draw_row(header, 0, standard_font, window_surface) + for board_index in range(num_rows): + row = board[board_index] + row_str = '* ' + for c in row: + row_str += 'x ' if c == 1. else ' ' + row_str += '* ' + _draw_row(row_str, board_index + 1, standard_font, window_surface) + _draw_row(header, num_rows + 1, standard_font, window_surface) + + # Draw footer. + reward_str = 'Reward: {}'.format(reward) + _draw_row(reward_str, num_rows + 3, standard_font, window_surface) + instructions = ('Instructions: Left/Right arrow keys to move paddle, Escape ' + 'to exit.') + _draw_row(instructions, num_rows + 5, instructions_font, window_surface) + + +def _start_server(port): + """Starts the Catch gRPC server.""" + server = grpc.server(futures.ThreadPoolExecutor(max_workers=1)) + servicer = catch_environment.CatchEnvironmentService() + dm_env_rpc_pb2_grpc.add_EnvironmentServicer_to_server(servicer, server) + + server.add_insecure_port('localhost:{}'.format(port)) + server.start() + return server + + +def main(_): + pygame.init() + + port = portpicker.pick_unused_port() + server = _start_server(port) + + with grpc.secure_channel('localhost:{}'.format(port), + grpc.local_channel_credentials()) as channel: + grpc.channel_ready_future(channel).result() + with dm_env_rpc_connection.Connection(channel) as connection: + response = connection.send(dm_env_rpc_pb2.CreateWorldRequest()) + world_name = response.world_name + response = connection.send( + dm_env_rpc_pb2.JoinWorldRequest(world_name=world_name)) + specs = response.specs + + with dm_env_adaptor.DmEnvAdaptor(connection, specs) as dm_env: + window_surface = pygame.display.set_mode((800, 600), 0, 32) + pygame.display.set_caption('Catch Human Agent') + + keep_running = True + while keep_running: + requested_action = _ACTION_NOTHING + + for event in pygame.event.get(): + if event.type == pygame.QUIT: + keep_running = False + break + elif event.type == pygame.KEYDOWN: + if event.key == pygame.K_LEFT: + requested_action = _ACTION_LEFT + elif event.key == pygame.K_RIGHT: + requested_action = _ACTION_RIGHT + elif event.key == pygame.K_ESCAPE: + keep_running = False + break + + actions = {_ACTION_PADDLE: requested_action} + step_result = dm_env.step(actions) + + board = step_result.observation[_OBSERVATION_BOARD] + reward = step_result.observation[_OBSERVATION_REWARD] + + _render_window(board, window_surface, reward) + + pygame.display.update() + + pygame.time.wait(_FRAME_DELAY_MS) + + connection.send(dm_env_rpc_pb2.LeaveWorldRequest()) + connection.send(dm_env_rpc_pb2.DestroyWorldRequest(world_name=world_name)) + + server.stop(None) + + +if __name__ == '__main__': + app.run(main) diff --git a/examples/catch_test.py b/examples/catch_test.py new file mode 100644 index 0000000..a3d555d --- /dev/null +++ b/examples/catch_test.py @@ -0,0 +1,193 @@ +# Copyright 2019 DeepMind Technologies Limited. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +"""Tests for CatchEnvironment.""" + +from absl.testing import absltest +from concurrent import futures +from dm_env import test_utils +import grpc +import numpy as np +import portpicker + +import catch_environment +from dm_env_rpc.v1 import connection as dm_env_rpc_connection +from dm_env_rpc.v1 import dm_env_adaptor +from dm_env_rpc.v1 import dm_env_rpc_pb2 +from dm_env_rpc.v1 import dm_env_rpc_pb2_grpc + + +def _local_address(port): + return '[::1]:{}'.format(port) + + +class CatchTestBase(absltest.TestCase): + + def setUp(self): + super(CatchTestBase, self).setUp() + + port = portpicker.pick_unused_port() + self._server = grpc.server( + futures.ThreadPoolExecutor(max_workers=1)) + servicer = catch_environment.CatchEnvironmentService() + dm_env_rpc_pb2_grpc.add_EnvironmentServicer_to_server( + servicer, self._server) + self._server.add_insecure_port(_local_address(port)) + self._server.start() + + self._channel = grpc.secure_channel( + _local_address(port), grpc.local_channel_credentials()) + grpc.channel_ready_future(self._channel).result() + + self._connection = dm_env_rpc_connection.Connection(self._channel) + response = self._connection.send(dm_env_rpc_pb2.CreateWorldRequest()) + self._world_name = response.world_name + + def tearDown(self): + self._connection.send(dm_env_rpc_pb2.LeaveWorldRequest()) + self._connection.send( + dm_env_rpc_pb2.DestroyWorldRequest(world_name=self._world_name)) + self._server.stop(None) + self._connection.close() + self._channel.close() + super(CatchTestBase, self).tearDown() + + +class CatchDmEnvTest(CatchTestBase, test_utils.EnvironmentTestMixin): + + def setUp(self): + super(CatchDmEnvTest, self).setUp() + response = dm_env_rpc_pb2.JoinWorldRequest(world_name=self._world_name) + specs = self._connection.send(response).specs + self._dm_env = dm_env_adaptor.DmEnvAdaptor(self._connection, specs) + self.object_under_test = self._dm_env + + def make_object_under_test(self): + return self._dm_env + + +class CatchTest(CatchTestBase): + + def test_can_reset_world_when_joined(self): + self._connection.send( + dm_env_rpc_pb2.JoinWorldRequest(world_name=self._world_name)) + self._connection.send(dm_env_rpc_pb2.ResetWorldRequest()) + + def test_cannot_reset_world_when_not_joined(self): + with self.assertRaises(ValueError): + self._connection.send(dm_env_rpc_pb2.ResetWorldRequest()) + + def test_cannot_step_when_not_joined(self): + with self.assertRaises(ValueError): + self._connection.send(dm_env_rpc_pb2.StepRequest()) + + def test_cannot_reset_when_not_joined(self): + with self.assertRaises(ValueError): + self._connection.send(dm_env_rpc_pb2.ResetRequest()) + + def test_cannot_join_world_with_wrong_name(self): + with self.assertRaises(ValueError): + self._connection.send( + dm_env_rpc_pb2.JoinWorldRequest(world_name='wrong_name')) + + def test_cannot_create_world_when_world_exists(self): + with self.assertRaises(ValueError): + self._connection.send(dm_env_rpc_pb2.CreateWorldRequest()) + + def test_cannot_join_when_no_world_exists(self): + self._connection.send( + dm_env_rpc_pb2.DestroyWorldRequest(world_name=self._world_name)) + with self.assertRaises(ValueError): + self._connection.send( + dm_env_rpc_pb2.JoinWorldRequest(world_name=self._world_name)) + self._connection.send(dm_env_rpc_pb2.CreateWorldRequest()) + + def test_cannot_destroy_world_when_still_joined(self): + self._connection.send( + dm_env_rpc_pb2.JoinWorldRequest(world_name=self._world_name)) + with self.assertRaises(ValueError): + self._connection.send( + dm_env_rpc_pb2.DestroyWorldRequest(world_name=self._world_name)) + + def test_cannot_destroy_world_with_wrong_name(self): + with self.assertRaises(ValueError): + self._connection.send( + dm_env_rpc_pb2.DestroyWorldRequest(world_name='wrong_name')) + + def test_read_property_request_is_not_supported(self): + with self.assertRaises(ValueError): + self._connection.send(dm_env_rpc_pb2.ReadPropertyRequest()) + + def test_write_property_request_is_not_supported(self): + with self.assertRaises(ValueError): + self._connection.send(dm_env_rpc_pb2.WritePropertyRequest()) + + +class CatchGameTest(absltest.TestCase): + + def setUp(self): + super(CatchGameTest, self).setUp() + self._rows = 3 + self._cols = 3 + self._game = catch_environment.CatchGame(self._rows, self._cols, 1) + + def test_draw_board_correct_initial_state(self): + board = self._game.draw_board() + self.assertEqual(board.shape, (3, 3)) + + def test_draw_board_ball_in_top_row(self): + board = self._game.draw_board() + self.assertIn(1, board[0]) + + def test_draw_board_bat_in_center_bottom_row(self): + board = self._game.draw_board() + self.assertTrue(np.array_equal([0, 1, 0], board[2])) + + def test_update_drops_ball(self): + self._game.update(action=0) + board = self._game.draw_board() + self.assertNotIn(1, board[0]) + self.assertIn(1, board[1]) + + def test_has_terminated_when_ball_hits_bottom(self): + self.assertFalse(self._game.has_terminated()) + self._game.update(action=0) + self.assertFalse(self._game.has_terminated()) + self._game.update(action=0) + self.assertTrue(self._game.has_terminated()) + + def test_update_moves_paddle(self): + self._game.update(action=1) + board = self._game.draw_board() + self.assertTrue(np.array_equal([0, 0, 1], board[2])) + + def test_cannot_update_game_when_has_terminated(self): + self._game.update(action=0) + self._game.update(action=0) + with self.assertRaises(RuntimeError): + self._game.update(action=0) + + def test_no_reward_when_not_terminated(self): + self.assertEqual(0, self._game.reward()) + self._game.update(action=0) + self.assertEqual(0, self._game.reward()) + self._game.update(action=0) + + def test_has_reward_when_terminated(self): + self._game.update(action=0) + self._game.update(action=0) + self.assertNotEqual(0, self._game.reward()) + +if __name__ == '__main__': + absltest.main() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..886331b --- /dev/null +++ b/setup.py @@ -0,0 +1,125 @@ +# Copyright 2019 DeepMind Technologies Limited. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ============================================================================ +"""Install script for setuptools.""" + +import importlib.machinery +import os + +from distutils.cmd import Command +import pkg_resources +from setuptools import find_packages +from setuptools import setup +from setuptools.command.build_py import build_py + + +ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) +GOOGLE_COMMON_PROTOS_ROOT_DIR = os.path.join(ROOT_DIR, + 'third_party/api-common-protos') + + +class _GenerateProtoFiles(Command): + """Command to generate protobuf bindings for dm_env_rpc.proto.""" + + descriptions = 'Generates Python protobuf bindings for dm_env_rpc.proto.' + user_options = [] + + def initialize_options(self): + pass + + def finalize_options(self): + pass + + def run(self): + # Import grpc_tools here, after setuptools has installed setup_requires + # dependencies. + from grpc_tools import protoc # pylint: disable=g-import-not-at-top + + if not os.path.exists( + os.path.join(GOOGLE_COMMON_PROTOS_ROOT_DIR, 'google/rpc/status.proto')): + raise RuntimeError( + 'Cannot find third_party/api-common-protos. ' + 'Please run `git submodule init && git submodule update` to install ' + 'the api-common-protos submodule.' + ) + dm_env_rpc_proto = os.path.join(ROOT_DIR, 'dm_env_rpc/v1/dm_env_rpc.proto') + grpc_protos_include = pkg_resources.resource_filename( + 'grpc_tools', '_proto') + proto_args = [ + 'grpc_tools.protoc', + '--proto_path={}'.format(GOOGLE_COMMON_PROTOS_ROOT_DIR), + '--proto_path={}'.format(grpc_protos_include), + '--proto_path={}'.format(ROOT_DIR), + '--python_out={}'.format(ROOT_DIR), + '--grpc_python_out={}'.format(ROOT_DIR), + dm_env_rpc_proto, + ] + if protoc.main(proto_args) != 0: + raise RuntimeError('ERROR: {}'.format(proto_args)) + + +class _BuildPy(build_py): + """Generate protobuf bindings in build_py stage.""" + + def run(self): + self.run_command('generate_protos') + build_py.run(self) + + +setup( + name='dm-env-rpc', + version=importlib.machinery.SourceFileLoader( + '_version', 'dm_env_rpc/_version.py').load_module().__version__, + description='A networking protocol for agent-environment communication.', + author='DeepMind', + license='Apache License, Version 2.0', + keywords='reinforcement-learning python machine learning', + packages=find_packages(exclude=['examples']), + install_requires=[ + 'dm-env>=1.2', + 'googleapis-common-protos', + 'grpcio', + 'numpy', + 'protobuf', + ], + tests_require=[ + 'absl-py', + 'nose', + 'mock', + 'portpicker', + ], + setup_requires=['grpcio-tools'], + extras_require={ + 'examples': ['pygame', 'portpicker'], + }, + cmdclass={ + 'build_py': _BuildPy, + 'generate_protos': _GenerateProtoFiles, + }, + test_suite='nose.collector', + classifiers=[ + 'Development Status :: 4 - Beta', + 'Environment :: Console', + 'Intended Audience :: Science/Research', + 'License :: OSI Approved :: Apache Software License', + 'Operating System :: POSIX :: Linux', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: MacOS :: MacOS X', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Topic :: Scientific/Engineering :: Artificial Intelligence', + ], +) diff --git a/third_party/api-common-protos b/third_party/api-common-protos new file mode 160000 index 0000000..a104965 --- /dev/null +++ b/third_party/api-common-protos @@ -0,0 +1 @@ +Subproject commit a1049653796e24778de3073bd04760588494aecd