diff --git a/Makefile b/Makefile
index 9045eb9..8b2ba76 100644
--- a/Makefile
+++ b/Makefile
@@ -2,7 +2,7 @@ SHELL := /bin/bash
COMPOSE := docker compose --env-file deployment/env/versions.env \
-f deployment/compose/compose.dev.yaml
-BASE_PATHS := packages agents tasks evaluators tests/fixtures
+BASE_PATHS := packages agents tasks evaluators tests
COLCON_ENV := COLCON_DEFAULTS_FILE=$(CURDIR)/colcon.defaults.yaml
CURRENT_USER := $(shell id -un)
@@ -57,6 +57,6 @@ test-local: build-local
lint-local:
python3 tools/validation/check_repository.py
- ruff check tools
+ ruff check tools tests
check-local: lint-local list-local test-local
diff --git a/packages/README.md b/packages/README.md
index 60e27d2..3aeaf3d 100644
--- a/packages/README.md
+++ b/packages/README.md
@@ -4,5 +4,6 @@ This domain contains shared RoboHarness platform and communication packages.
Packages must depend toward stable layers and must not statically depend on a
specific simulator, robot, agent, task, or evaluator implementation.
-PR 01 intentionally contains no placeholder ROS packages. `rh_interfaces`,
-`rh_core`, `rh_ros`, and `rh_experiment` are introduced by their roadmap PRs.
+The first platform package is [`rh_interfaces`](rh_interfaces/README.md), which
+owns the implementation-independent ROS wire contract. `rh_core`, `rh_ros`,
+and `rh_experiment` are introduced by their roadmap PRs.
diff --git a/packages/rh_interfaces/CMakeLists.txt b/packages/rh_interfaces/CMakeLists.txt
new file mode 100644
index 0000000..99d6546
--- /dev/null
+++ b/packages/rh_interfaces/CMakeLists.txt
@@ -0,0 +1,33 @@
+cmake_minimum_required(VERSION 3.8)
+project(rh_interfaces)
+
+find_package(ament_cmake REQUIRED)
+find_package(builtin_interfaces REQUIRED)
+find_package(geometry_msgs REQUIRED)
+find_package(rosidl_default_generators REQUIRED)
+
+rosidl_generate_interfaces(${PROJECT_NAME}
+ "msg/ComponentStatus.msg"
+ "msg/EpisodeResult.msg"
+ "msg/EpisodeState.msg"
+ "msg/PointNavTask.msg"
+ "srv/AbortEpisode.srv"
+ "srv/ResetAgent.srv"
+ "srv/ResetEnv.srv"
+ "srv/StartEpisode.srv"
+ DEPENDENCIES
+ builtin_interfaces
+ geometry_msgs
+)
+
+ament_export_dependencies(rosidl_default_runtime)
+
+if(BUILD_TESTING)
+ find_package(ament_cmake_lint_cmake REQUIRED)
+ find_package(ament_cmake_xmllint REQUIRED)
+
+ ament_lint_cmake()
+ ament_xmllint()
+endif()
+
+ament_package()
diff --git a/packages/rh_interfaces/README.md b/packages/rh_interfaces/README.md
new file mode 100644
index 0000000..4385aef
--- /dev/null
+++ b/packages/rh_interfaces/README.md
@@ -0,0 +1,48 @@
+# `rh_interfaces`
+
+`rh_interfaces` is the implementation-independent ROS 2 wire contract shared
+by the RoboHarness Environment, Agent, and Experiment containers. It contains
+messages and services only: no nodes, transport helpers, state machines, or
+simulator-specific code.
+
+## Control-plane interfaces
+
+| ROS name | Interface | Producer/server | Consumer/client |
+|---|---|---|---|
+| `/roboharness/env/status` | `ComponentStatus` | Environment | Experiment |
+| `/roboharness/agent/status` | `ComponentStatus` | Agent | Experiment |
+| `/roboharness/env/reset_episode` | `ResetEnv` | Environment | Experiment |
+| `/roboharness/agent/reset_episode` | `ResetAgent` | Agent | Experiment |
+| `/roboharness/episode/state` | `EpisodeState` | Experiment | Environment, Agent, Evaluator |
+| `/roboharness/task/pointnav` | `PointNavTask` | Experiment | Environment, Agent, Evaluator |
+| `/roboharness/episode/start` | `StartEpisode` | Experiment | Operator or automation |
+| `/roboharness/episode/abort` | `AbortEpisode` | Experiment | Operator |
+| `/roboharness/episode/result` | `EpisodeResult` | Experiment | Reporting tools |
+
+The standard robot data plane continues to use ROS types such as
+`geometry_msgs/Twist`, `nav_msgs/Odometry`, and `sensor_msgs/Imu`; this package
+does not wrap those observations in a generic message.
+
+## Contract rules
+
+- Identifiers and request IDs are opaque non-empty strings. Validation belongs
+ to the core/runtime layers rather than the generated interface classes.
+- `PointNavTask.start`, `PointNavTask.goal`, and `ResetEnv.start` use the `map`
+ frame in the MVP. Task start and goal frames must match.
+- Durations use seconds, distances use metres, and Episode metrics use
+ simulation time and the `map` frame.
+- `ComponentStatus.stamp` records the last transition time. Repeated status
+ heartbeats retain it instead of replacing it with publication time.
+- `EpisodeState.sequence` increases on every authoritative transition within an
+ Episode. Consumers reject stale Episode IDs or sequence values.
+- `EpisodeResult` is deliberately compact. `result_uri` is opaque and points to
+ the durable JSON result; its storage scheme is not part of this ROS contract.
+- A reset `request_id` is an idempotency key. Runtime behavior for duplicate
+ requests is implemented in a later protocol-helper PR.
+- Numeric state and termination values are stable wire values. New values may
+ be appended, but existing values must never be reordered or reused.
+- `detail`, abort `reason`, and `result_uri` are opaque strings. Correctness must
+ never depend on parsing human-readable text.
+
+QoS, deadlines, heartbeat behavior, validation, and node implementations are
+intentionally outside this package.
diff --git a/packages/rh_interfaces/msg/ComponentStatus.msg b/packages/rh_interfaces/msg/ComponentStatus.msg
new file mode 100644
index 0000000..09462c2
--- /dev/null
+++ b/packages/rh_interfaces/msg/ComponentStatus.msg
@@ -0,0 +1,23 @@
+# Lifecycle state reported by an Environment or Agent component.
+# Existing numeric values are part of the wire contract and must not change.
+uint8 STARTING=0
+uint8 RESETTING=1
+uint8 READY=2
+uint8 ERROR=3
+
+# Time of the latest state transition, not the heartbeat publication time.
+builtin_interfaces/Time stamp
+
+# Stable identifier of the component instance within an experiment.
+string component_id
+
+uint8 state
+
+# Zero means no error. Non-zero code catalogs are owned by each component.
+uint32 error_code
+
+# Short human-readable context; consumers must not parse this field.
+string detail
+
+# True when an operator must restart or redeploy the component before reuse.
+bool restart_required
diff --git a/packages/rh_interfaces/msg/EpisodeResult.msg b/packages/rh_interfaces/msg/EpisodeResult.msg
new file mode 100644
index 0000000..4105244
--- /dev/null
+++ b/packages/rh_interfaces/msg/EpisodeResult.msg
@@ -0,0 +1,17 @@
+# Compact completion event. Detailed results remain in the referenced artifact.
+string experiment_id
+string episode_id
+
+# Uses the termination reason constants defined by EpisodeState.
+uint8 termination_reason
+
+# Convenience summary; true exactly when termination_reason is SUCCESS.
+bool success
+
+# Metrics are measured in simulation time and the map frame.
+float64 elapsed_time_s
+float64 path_length_m
+float64 final_distance_to_goal_m
+
+# Opaque URI for the durable episode result document.
+string result_uri
diff --git a/packages/rh_interfaces/msg/EpisodeState.msg b/packages/rh_interfaces/msg/EpisodeState.msg
new file mode 100644
index 0000000..77c7bab
--- /dev/null
+++ b/packages/rh_interfaces/msg/EpisodeState.msg
@@ -0,0 +1,32 @@
+# Authoritative lifecycle state published by the Experiment orchestrator.
+# Existing numeric values are part of the wire contract and must not change.
+uint8 PREPARING=0
+uint8 READY=1
+uint8 RUNNING=2
+uint8 TERMINATING=3
+uint8 FINISHED=4
+
+# A reason is final only when state is FINISHED. NONE is used before then.
+uint8 NONE=0
+uint8 SUCCESS=1
+uint8 TIMEOUT=2
+uint8 ABORTED=3
+uint8 FAILURE=4
+uint8 ENV_ERROR=5
+uint8 AGENT_ERROR=6
+uint8 INVALID_TASK=7
+
+# Simulation time of this state transition.
+builtin_interfaces/Time stamp
+
+string experiment_id
+string episode_id
+
+# Strictly increases for every transition within an episode.
+uint64 sequence
+
+uint8 state
+uint8 termination_reason
+
+# Short human-readable context; consumers must not parse this field.
+string detail
diff --git a/packages/rh_interfaces/msg/PointNavTask.msg b/packages/rh_interfaces/msg/PointNavTask.msg
new file mode 100644
index 0000000..a1ebf54
--- /dev/null
+++ b/packages/rh_interfaces/msg/PointNavTask.msg
@@ -0,0 +1,16 @@
+# Immutable PointNav task snapshot. start and goal must use the same frame.
+# RoboHarness MVP requires that frame to be "map".
+string experiment_id
+string episode_id
+
+geometry_msgs/PoseStamped start
+geometry_msgs/PoseStamped goal
+
+# Goal is reached when planar distance is less than or equal to this value.
+float64 success_radius_m
+
+# Episode timeout measured in simulation seconds.
+float64 timeout_s
+
+# Reproducibility seed; interpretation is owned by the selected backend/task.
+int64 seed
diff --git a/packages/rh_interfaces/package.xml b/packages/rh_interfaces/package.xml
new file mode 100644
index 0000000..07f122b
--- /dev/null
+++ b/packages/rh_interfaces/package.xml
@@ -0,0 +1,27 @@
+
+
+
+ rh_interfaces
+ 0.0.0
+ Implementation-independent ROS 2 interfaces for RoboHarness.
+ Staaaaaaaaar
+
+ NOASSERTION
+
+ ament_cmake
+ rosidl_default_generators
+
+ builtin_interfaces
+ geometry_msgs
+
+ rosidl_default_runtime
+
+ ament_cmake_lint_cmake
+ ament_cmake_xmllint
+
+ rosidl_interface_packages
+
+
+ ament_cmake
+
+
diff --git a/packages/rh_interfaces/srv/AbortEpisode.srv b/packages/rh_interfaces/srv/AbortEpisode.srv
new file mode 100644
index 0000000..1a695a3
--- /dev/null
+++ b/packages/rh_interfaces/srv/AbortEpisode.srv
@@ -0,0 +1,12 @@
+string experiment_id
+string episode_id
+
+# Human-readable operator intent. The committed result uses EpisodeState.ABORTED.
+string reason
+---
+
+# Acceptance only acknowledges the transition request; finalization is asynchronous.
+bool accepted
+
+# Short human-readable context; clients must not parse this field.
+string detail
diff --git a/packages/rh_interfaces/srv/ResetAgent.srv b/packages/rh_interfaces/srv/ResetAgent.srv
new file mode 100644
index 0000000..da7d9f1
--- /dev/null
+++ b/packages/rh_interfaces/srv/ResetAgent.srv
@@ -0,0 +1,12 @@
+# Opaque idempotency key. Repeating a completed request_id must not reset twice.
+string request_id
+string experiment_id
+string episode_id
+---
+bool success
+
+# Zero means no error. Non-zero values are component-specific.
+uint32 error_code
+
+# Short human-readable context; clients must not parse this field.
+string detail
diff --git a/packages/rh_interfaces/srv/ResetEnv.srv b/packages/rh_interfaces/srv/ResetEnv.srv
new file mode 100644
index 0000000..b839c98
--- /dev/null
+++ b/packages/rh_interfaces/srv/ResetEnv.srv
@@ -0,0 +1,18 @@
+# Opaque idempotency key. Repeating a completed request_id must not reset twice.
+string request_id
+string experiment_id
+string episode_id
+
+# Requested robot root pose. RoboHarness MVP requires the "map" frame.
+geometry_msgs/PoseStamped start
+
+# Reproducibility seed supplied to the environment backend.
+int64 seed
+---
+bool success
+
+# Zero means no error. Non-zero values are component-specific.
+uint32 error_code
+
+# Short human-readable context; clients must not parse this field.
+string detail
diff --git a/packages/rh_interfaces/srv/StartEpisode.srv b/packages/rh_interfaces/srv/StartEpisode.srv
new file mode 100644
index 0000000..6b3d65f
--- /dev/null
+++ b/packages/rh_interfaces/srv/StartEpisode.srv
@@ -0,0 +1,9 @@
+string experiment_id
+string episode_id
+---
+
+# Acceptance only acknowledges the transition request; it is not an episode result.
+bool accepted
+
+# Short human-readable context; clients must not parse this field.
+string detail
diff --git a/tests/README.md b/tests/README.md
index 92d2028..bbe80dc 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -3,5 +3,7 @@
This directory is reserved for contract, cross-process, cross-container, and
end-to-end tests. Unit tests stay with their owning package.
-`fixtures/` will contain deterministic CPU-only mock components; it is a Colcon
-base path but contains no placeholder package in PR 01.
+- [`contracts/`](contracts/README.md) verifies public interfaces across
+ language bindings and package boundaries.
+- `fixtures/` will contain deterministic CPU-only mock components in later
+ roadmap PRs.
diff --git a/tests/contracts/README.md b/tests/contracts/README.md
new file mode 100644
index 0000000..8aed671
--- /dev/null
+++ b/tests/contracts/README.md
@@ -0,0 +1,6 @@
+# Contract tests
+
+This domain contains black-box tests for public RoboHarness contracts. The
+`rh_interfaces_contract_tests` package verifies generated C++ and Python ROS 2
+types, stable field/constant definitions, serialization round trips, and the
+interface package dependency boundary.
diff --git a/tests/contracts/rh_interfaces_contract_tests/CMakeLists.txt b/tests/contracts/rh_interfaces_contract_tests/CMakeLists.txt
new file mode 100644
index 0000000..4e4e800
--- /dev/null
+++ b/tests/contracts/rh_interfaces_contract_tests/CMakeLists.txt
@@ -0,0 +1,20 @@
+cmake_minimum_required(VERSION 3.8)
+project(rh_interfaces_contract_tests)
+
+find_package(ament_cmake REQUIRED)
+find_package(ament_cmake_gtest REQUIRED)
+find_package(ament_cmake_pytest REQUIRED)
+find_package(rh_interfaces REQUIRED)
+find_package(rosidl_typesupport_cpp REQUIRED)
+
+ament_add_gtest(test_cpp_typesupport test/test_cpp_typesupport.cpp)
+ament_target_dependencies(test_cpp_typesupport
+ rh_interfaces
+ rosidl_typesupport_cpp
+)
+
+ament_add_pytest_test(test_python_contract test/test_python_contract.py
+ TIMEOUT 30
+)
+
+ament_package()
diff --git a/tests/contracts/rh_interfaces_contract_tests/package.xml b/tests/contracts/rh_interfaces_contract_tests/package.xml
new file mode 100644
index 0000000..6462b88
--- /dev/null
+++ b/tests/contracts/rh_interfaces_contract_tests/package.xml
@@ -0,0 +1,23 @@
+
+
+
+ rh_interfaces_contract_tests
+ 0.0.0
+ Cross-language contract tests for the RoboHarness ROS interfaces.
+ Staaaaaaaaar
+
+ NOASSERTION
+
+ ament_cmake
+
+ ament_cmake_gtest
+ ament_cmake_pytest
+ ament_index_python
+ rclpy
+ rh_interfaces
+ rosidl_typesupport_cpp
+
+
+ ament_cmake
+
+
diff --git a/tests/contracts/rh_interfaces_contract_tests/test/test_cpp_typesupport.cpp b/tests/contracts/rh_interfaces_contract_tests/test/test_cpp_typesupport.cpp
new file mode 100644
index 0000000..baec43c
--- /dev/null
+++ b/tests/contracts/rh_interfaces_contract_tests/test/test_cpp_typesupport.cpp
@@ -0,0 +1,114 @@
+#include
+
+#include "rh_interfaces/msg/component_status.hpp"
+#include "rh_interfaces/msg/episode_result.hpp"
+#include "rh_interfaces/msg/episode_state.hpp"
+#include "rh_interfaces/msg/point_nav_task.hpp"
+#include "rh_interfaces/srv/abort_episode.hpp"
+#include "rh_interfaces/srv/reset_agent.hpp"
+#include "rh_interfaces/srv/reset_env.hpp"
+#include "rh_interfaces/srv/start_episode.hpp"
+#include "rosidl_typesupport_cpp/message_type_support.hpp"
+#include "rosidl_typesupport_cpp/service_type_support.hpp"
+
+TEST(RhInterfacesContract, StableNumericConstants)
+{
+ using ComponentStatus = rh_interfaces::msg::ComponentStatus;
+ EXPECT_EQ(ComponentStatus::STARTING, 0u);
+ EXPECT_EQ(ComponentStatus::RESETTING, 1u);
+ EXPECT_EQ(ComponentStatus::READY, 2u);
+ EXPECT_EQ(ComponentStatus::ERROR, 3u);
+
+ using EpisodeState = rh_interfaces::msg::EpisodeState;
+ EXPECT_EQ(EpisodeState::PREPARING, 0u);
+ EXPECT_EQ(EpisodeState::READY, 1u);
+ EXPECT_EQ(EpisodeState::RUNNING, 2u);
+ EXPECT_EQ(EpisodeState::TERMINATING, 3u);
+ EXPECT_EQ(EpisodeState::FINISHED, 4u);
+
+ EXPECT_EQ(EpisodeState::NONE, 0u);
+ EXPECT_EQ(EpisodeState::SUCCESS, 1u);
+ EXPECT_EQ(EpisodeState::TIMEOUT, 2u);
+ EXPECT_EQ(EpisodeState::ABORTED, 3u);
+ EXPECT_EQ(EpisodeState::FAILURE, 4u);
+ EXPECT_EQ(EpisodeState::ENV_ERROR, 5u);
+ EXPECT_EQ(EpisodeState::AGENT_ERROR, 6u);
+ EXPECT_EQ(EpisodeState::INVALID_TASK, 7u);
+}
+
+TEST(RhInterfacesContract, CppTypesupportIsLinked)
+{
+ EXPECT_NE(
+ rosidl_typesupport_cpp::get_message_type_support_handle<
+ rh_interfaces::msg::ComponentStatus>(),
+ nullptr);
+ EXPECT_NE(
+ rosidl_typesupport_cpp::get_message_type_support_handle<
+ rh_interfaces::msg::EpisodeResult>(),
+ nullptr);
+ EXPECT_NE(
+ rosidl_typesupport_cpp::get_message_type_support_handle<
+ rh_interfaces::msg::EpisodeState>(),
+ nullptr);
+ EXPECT_NE(
+ rosidl_typesupport_cpp::get_message_type_support_handle<
+ rh_interfaces::msg::PointNavTask>(),
+ nullptr);
+
+ EXPECT_NE(
+ rosidl_typesupport_cpp::get_service_type_support_handle<
+ rh_interfaces::srv::AbortEpisode>(),
+ nullptr);
+ EXPECT_NE(
+ rosidl_typesupport_cpp::get_service_type_support_handle<
+ rh_interfaces::srv::ResetAgent>(),
+ nullptr);
+ EXPECT_NE(
+ rosidl_typesupport_cpp::get_service_type_support_handle<
+ rh_interfaces::srv::ResetEnv>(),
+ nullptr);
+ EXPECT_NE(
+ rosidl_typesupport_cpp::get_service_type_support_handle<
+ rh_interfaces::srv::StartEpisode>(),
+ nullptr);
+}
+
+TEST(RhInterfacesContract, GeneratedCppTypesAreUsable)
+{
+ rh_interfaces::msg::ComponentStatus status;
+ status.component_id = "env";
+ status.state = status.READY;
+ EXPECT_EQ(status.component_id, "env");
+
+ rh_interfaces::msg::EpisodeState state;
+ state.experiment_id = "experiment-1";
+ state.episode_id = "episode-1";
+ state.sequence = 4;
+ state.state = state.FINISHED;
+ state.termination_reason = state.SUCCESS;
+ EXPECT_EQ(state.sequence, 4u);
+
+ rh_interfaces::msg::PointNavTask task;
+ task.start.header.frame_id = "map";
+ task.goal.header.frame_id = "map";
+ task.success_radius_m = 0.5;
+ EXPECT_DOUBLE_EQ(task.success_radius_m, 0.5);
+
+ rh_interfaces::srv::ResetEnv::Request reset_request;
+ reset_request.request_id = "request-1";
+ reset_request.start.header.frame_id = "map";
+ EXPECT_EQ(reset_request.start.header.frame_id, "map");
+
+ rh_interfaces::srv::ResetAgent::Response reset_response;
+ reset_response.success = true;
+ reset_response.error_code = 0;
+ EXPECT_TRUE(reset_response.success);
+
+ rh_interfaces::srv::StartEpisode::Response start_response;
+ start_response.accepted = true;
+ EXPECT_TRUE(start_response.accepted);
+
+ rh_interfaces::srv::AbortEpisode::Request abort_request;
+ abort_request.reason = "operator request";
+ EXPECT_EQ(abort_request.reason, "operator request");
+}
diff --git a/tests/contracts/rh_interfaces_contract_tests/test/test_python_contract.py b/tests/contracts/rh_interfaces_contract_tests/test/test_python_contract.py
new file mode 100644
index 0000000..183269c
--- /dev/null
+++ b/tests/contracts/rh_interfaces_contract_tests/test/test_python_contract.py
@@ -0,0 +1,212 @@
+from __future__ import annotations
+
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+import pytest
+from ament_index_python.packages import get_package_share_directory
+from rclpy.serialization import deserialize_message, serialize_message
+from rh_interfaces.msg import ComponentStatus, EpisodeResult, EpisodeState, PointNavTask
+from rh_interfaces.srv import AbortEpisode, ResetAgent, ResetEnv, StartEpisode
+
+
+def test_stable_numeric_constants() -> None:
+ assert {
+ "STARTING": ComponentStatus.STARTING,
+ "RESETTING": ComponentStatus.RESETTING,
+ "READY": ComponentStatus.READY,
+ "ERROR": ComponentStatus.ERROR,
+ } == {"STARTING": 0, "RESETTING": 1, "READY": 2, "ERROR": 3}
+
+ assert {
+ "PREPARING": EpisodeState.PREPARING,
+ "READY": EpisodeState.READY,
+ "RUNNING": EpisodeState.RUNNING,
+ "TERMINATING": EpisodeState.TERMINATING,
+ "FINISHED": EpisodeState.FINISHED,
+ } == {"PREPARING": 0, "READY": 1, "RUNNING": 2, "TERMINATING": 3, "FINISHED": 4}
+
+ assert {
+ "NONE": EpisodeState.NONE,
+ "SUCCESS": EpisodeState.SUCCESS,
+ "TIMEOUT": EpisodeState.TIMEOUT,
+ "ABORTED": EpisodeState.ABORTED,
+ "FAILURE": EpisodeState.FAILURE,
+ "ENV_ERROR": EpisodeState.ENV_ERROR,
+ "AGENT_ERROR": EpisodeState.AGENT_ERROR,
+ "INVALID_TASK": EpisodeState.INVALID_TASK,
+ } == {
+ "NONE": 0,
+ "SUCCESS": 1,
+ "TIMEOUT": 2,
+ "ABORTED": 3,
+ "FAILURE": 4,
+ "ENV_ERROR": 5,
+ "AGENT_ERROR": 6,
+ "INVALID_TASK": 7,
+ }
+
+
+@pytest.mark.parametrize(
+ ("interface_type", "expected_fields"),
+ [
+ (
+ ComponentStatus,
+ {
+ "stamp": "builtin_interfaces/Time",
+ "component_id": "string",
+ "state": "uint8",
+ "error_code": "uint32",
+ "detail": "string",
+ "restart_required": "boolean",
+ },
+ ),
+ (
+ EpisodeState,
+ {
+ "stamp": "builtin_interfaces/Time",
+ "experiment_id": "string",
+ "episode_id": "string",
+ "sequence": "uint64",
+ "state": "uint8",
+ "termination_reason": "uint8",
+ "detail": "string",
+ },
+ ),
+ (
+ PointNavTask,
+ {
+ "experiment_id": "string",
+ "episode_id": "string",
+ "start": "geometry_msgs/PoseStamped",
+ "goal": "geometry_msgs/PoseStamped",
+ "success_radius_m": "double",
+ "timeout_s": "double",
+ "seed": "int64",
+ },
+ ),
+ (
+ EpisodeResult,
+ {
+ "experiment_id": "string",
+ "episode_id": "string",
+ "termination_reason": "uint8",
+ "success": "boolean",
+ "elapsed_time_s": "double",
+ "path_length_m": "double",
+ "final_distance_to_goal_m": "double",
+ "result_uri": "string",
+ },
+ ),
+ (
+ ResetEnv.Request,
+ {
+ "request_id": "string",
+ "experiment_id": "string",
+ "episode_id": "string",
+ "start": "geometry_msgs/PoseStamped",
+ "seed": "int64",
+ },
+ ),
+ (
+ ResetEnv.Response,
+ {"success": "boolean", "error_code": "uint32", "detail": "string"},
+ ),
+ (
+ ResetAgent.Request,
+ {"request_id": "string", "experiment_id": "string", "episode_id": "string"},
+ ),
+ (
+ ResetAgent.Response,
+ {"success": "boolean", "error_code": "uint32", "detail": "string"},
+ ),
+ (
+ StartEpisode.Request,
+ {"experiment_id": "string", "episode_id": "string"},
+ ),
+ (
+ StartEpisode.Response,
+ {"accepted": "boolean", "detail": "string"},
+ ),
+ (
+ AbortEpisode.Request,
+ {"experiment_id": "string", "episode_id": "string", "reason": "string"},
+ ),
+ (
+ AbortEpisode.Response,
+ {"accepted": "boolean", "detail": "string"},
+ ),
+ ],
+)
+def test_field_names_order_and_types_are_stable(
+ interface_type: type, expected_fields: dict[str, str]
+) -> None:
+ assert interface_type.get_fields_and_field_types() == expected_fields
+
+
+@pytest.mark.parametrize(
+ "instance",
+ [
+ ComponentStatus(component_id="env", state=ComponentStatus.READY),
+ EpisodeState(
+ experiment_id="experiment-1",
+ episode_id="episode-1",
+ sequence=4,
+ state=EpisodeState.FINISHED,
+ termination_reason=EpisodeState.SUCCESS,
+ ),
+ PointNavTask(
+ experiment_id="experiment-1",
+ episode_id="episode-1",
+ success_radius_m=0.5,
+ timeout_s=60.0,
+ seed=42,
+ ),
+ EpisodeResult(
+ experiment_id="experiment-1",
+ episode_id="episode-1",
+ termination_reason=EpisodeState.SUCCESS,
+ success=True,
+ elapsed_time_s=12.5,
+ path_length_m=4.25,
+ final_distance_to_goal_m=0.3,
+ result_uri="results/experiment-1/episode-1/result.json",
+ ),
+ ResetEnv.Request(request_id="request-1", episode_id="episode-1", seed=42),
+ ResetEnv.Response(success=True),
+ ResetAgent.Request(request_id="request-2", episode_id="episode-1"),
+ ResetAgent.Response(success=True),
+ StartEpisode.Request(experiment_id="experiment-1", episode_id="episode-1"),
+ StartEpisode.Response(accepted=True),
+ AbortEpisode.Request(episode_id="episode-1", reason="operator request"),
+ AbortEpisode.Response(accepted=True),
+ ],
+)
+def test_python_serialization_round_trip(instance: object) -> None:
+ restored = deserialize_message(serialize_message(instance), type(instance))
+ assert restored == instance
+
+
+def test_interface_package_has_only_allowed_dependencies() -> None:
+ manifest = Path(get_package_share_directory("rh_interfaces")) / "package.xml"
+ root = ET.parse(manifest).getroot()
+ dependency_tags = {
+ "buildtool_depend",
+ "build_depend",
+ "build_export_depend",
+ "depend",
+ "exec_depend",
+ }
+ dependencies = {
+ element.text.strip()
+ for element in root
+ if element.tag in dependency_tags and element.text is not None
+ }
+
+ assert dependencies == {
+ "ament_cmake",
+ "builtin_interfaces",
+ "geometry_msgs",
+ "rosidl_default_generators",
+ "rosidl_default_runtime",
+ }