From 353800cba7faa4cbd4299831d1ef8d94c3e66e90 Mon Sep 17 00:00:00 2001 From: Aryan Pareek Date: Thu, 28 May 2026 04:07:07 +0530 Subject: [PATCH] Add Gymnasium integration spike under roboeval/integrations/gymnasium/ Wraps any gymnasium.Env into the EnvironmentAdapter Protocol so policies can be evaluated against Gymnasium environments through EvalRunner. Zero changes to existing SDK files. The adapter performs six translations between the two worlds (observation, action, outcome, events, seed, options) with sensible defaults and override hooks. Constructs StepOutcome directly using the existing 8 fields; namespaces Gymnasium-specific raw data under info["gymnasium"]. Refuses gym.vector.VectorEnv at construction since the runner is single-episode. Files added: - roboeval/integrations/__init__.py (namespace marker) - roboeval/integrations/README.md (subpackage strategy) - roboeval/integrations/gymnasium/__init__.py (public re-exports) - roboeval/integrations/gymnasium/adapter.py (adapter + 6 default hooks) - roboeval/integrations/gymnasium/demo_rollout.py (CartPole-v1 demo) - roboeval/integrations/gymnasium/README.md (usage and mapping table) - roboeval/integrations/gymnasium/requirements.txt (gymnasium>=0.29) - roboeval/integrations/gymnasium/notes.md (design rationale, gotchas, follow-ups) - roboeval/integrations/gymnasium/tests/__init__.py - roboeval/integrations/gymnasium/tests/test_adapter.py (39 unit + integration tests) Validate: python -m roboeval.integrations.gymnasium.demo_rollout python -m unittest discover -s roboeval/integrations/gymnasium/tests --- roboeval/integrations/README.md | 48 +++ roboeval/integrations/__init__.py | 0 roboeval/integrations/gymnasium/README.md | 164 ++++++++ roboeval/integrations/gymnasium/__init__.py | 25 ++ roboeval/integrations/gymnasium/adapter.py | 290 ++++++++++++++ .../integrations/gymnasium/demo_rollout.py | 66 ++++ roboeval/integrations/gymnasium/notes.md | 114 ++++++ .../integrations/gymnasium/requirements.txt | 12 + .../integrations/gymnasium/tests/__init__.py | 0 .../gymnasium/tests/test_adapter.py | 356 ++++++++++++++++++ 10 files changed, 1075 insertions(+) create mode 100644 roboeval/integrations/README.md create mode 100644 roboeval/integrations/__init__.py create mode 100644 roboeval/integrations/gymnasium/README.md create mode 100644 roboeval/integrations/gymnasium/__init__.py create mode 100644 roboeval/integrations/gymnasium/adapter.py create mode 100644 roboeval/integrations/gymnasium/demo_rollout.py create mode 100644 roboeval/integrations/gymnasium/notes.md create mode 100644 roboeval/integrations/gymnasium/requirements.txt create mode 100644 roboeval/integrations/gymnasium/tests/__init__.py create mode 100644 roboeval/integrations/gymnasium/tests/test_adapter.py diff --git a/roboeval/integrations/README.md b/roboeval/integrations/README.md new file mode 100644 index 0000000..fa5dd4e --- /dev/null +++ b/roboeval/integrations/README.md @@ -0,0 +1,48 @@ +# `roboeval.integrations` + +Optional simulator-side adapters that wrap third-party robotics frameworks into the SDK's `EnvironmentAdapter` Protocol. + +## Why this exists + +The `roboeval` core stays lightweight and dependency-free (see the root `pyproject.toml` — `dependencies = []`). Each integration in this subpackage carries its own framework dependency, kept out of the core install path so a base `pip install roboeval` never pulls in heavy simulators users may not need. + +The longer-term distribution model is optional extras: + +```bash +pip install roboeval # core only +pip install roboeval[gymnasium] # adds Gymnasium adapter +pip install roboeval[mujoco] # adds MuJoCo adapter (planned) +pip install roboeval[pybullet] # adds PyBullet adapter (planned) +``` + +Until those extras are wired into `pyproject.toml`, each integration folder ships its own `requirements.txt` so users can install the underlying dependency directly. + +## Current integrations + +| Folder | Wraps | Status | +|--------|-------|--------| +| `gymnasium/` | Any `gymnasium.Env` | Spike (single-env, no vector support, no render-frame capture) | + +## Template for new integrations + +Every integration folder should provide: + +``` +integrations// + __init__.py # re-exports the adapter class + adapter.py # the adapter implementation + demo_rollout.py # minimal end-to-end smoke test + README.md # usage + mapping table + requirements.txt # third-party deps for this integration + notes.md # design rationale, gotchas, follow-ups +``` + +Each adapter should: + +- Match the style of `CallableEnvironmentAdapter` in `roboeval/environment.py` — `@dataclass`, `name: str` field, no inheritance, two duck-typed methods. +- Construct `StepOutcome` directly using the existing 8 fields (no subclassing). +- Expose override hooks for the framework-specific translations so users can customize the boundary without subclassing. +- Reuse `roboeval.core.to_serializable` for JSON safety rather than reinventing it. +- Namespace framework-specific raw data under `info[""]` (e.g. `info["gymnasium"]`, `info["mujoco"]`) so the SDK's `_metric_summary`, rules, and reports remain framework-agnostic. + +The Gymnasium adapter follows this template — use it as the reference. diff --git a/roboeval/integrations/__init__.py b/roboeval/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/roboeval/integrations/gymnasium/README.md b/roboeval/integrations/gymnasium/README.md new file mode 100644 index 0000000..d132556 --- /dev/null +++ b/roboeval/integrations/gymnasium/README.md @@ -0,0 +1,164 @@ +# Gymnasium Integration + +Wrap any [Gymnasium](https://gymnasium.farama.org/) environment as a roboeval `EnvironmentAdapter` so existing policies can be evaluated against Gymnasium envs through `EvalRunner` — no changes to the core SDK. + +## Install + +This integration is shipped inside the `roboeval` package but Gymnasium itself is an optional dependency. + +```bash +pip install gymnasium>=0.29 +``` + +(See `requirements.txt` in this folder. A future SDK release may expose this as `pip install roboeval[gymnasium]`.) + +## Quick start + +```python +import gymnasium as gym + +from roboeval import EvalRunner, Ruleset, Scenario, require_metric, require_outcome +from roboeval.integrations.gymnasium import GymnasiumEnvironmentAdapter + + +def naive_policy(state): + pole_angle = float(state["observation"][2]) + return {"action": 0 if pole_angle < 0 else 1, "debug_info": {"version": "naive"}} + + +env = gym.make("CartPole-v1") +adapter = GymnasiumEnvironmentAdapter(env=env, name="cartpole_v1") + +ruleset = Ruleset([ + require_outcome("terminated_success"), + require_metric("episode_return", ">=", 50.0), +]) + +report = EvalRunner( + policies=[naive_policy], + scenarios=[Scenario("cartpole_smoke", {"seed": 0}, max_steps=200)], + ruleset=ruleset, + baseline_policy="naive_policy", + environment=adapter, +).run() + +report.save("runs/gym_smoke") +``` + +For a runnable end-to-end demo: + +```bash +python -m roboeval.integrations.gymnasium.demo_rollout +``` + +## How Gymnasium maps to the SDK + +Gymnasium has 5-tuple `step()` returns; roboeval has the 8-field `StepOutcome`. The adapter performs six translations: + +| Gymnasium | roboeval `StepOutcome` | Default behavior | +|-----------|------------------------|------------------| +| `obs` from `env.reset()` / `env.step()` | `next_state: dict` | If `obs` is a dict, pass through; otherwise wrap as `{"observation": obs}`. Values pass through raw (the SDK's `to_serializable` handles JSON coercion at report time). Override via `observation_to_state` hook. | +| `decision.action` (`Action = Any`) | passed straight into `env.step(action)` | Identity — the SDK already accepts any action type. Override via `action_from_decision` hook (e.g. for string action vocabularies). | +| `reward: float` | `metrics["reward"]` + `metrics["episode_return"]` (running sum) | Reward is reported per-step; episode return is reset on each `adapter.reset()`. | +| `terminated: bool` + `truncated: bool` | `terminal = terminated or truncated`, plus `info["gymnasium"]["terminated"]` and `info["gymnasium"]["truncated"]` | Distinguishes the two terminations in `info` so rules / reports can tell timeouts from natural episode ends. | +| `info: dict` | `info["gymnasium"]["raw_info"]` | Pass-through. Use the `info_keys` allowlist to filter heavy keys out. | +| Derived `(outcome, failure_label)` | `outcome` and `failure_label` fields | Default mapping: `terminated and reward > 0` → `terminated_success`; `terminated` → `terminated_failure`; `truncated` → `truncated`; else `progress`. Override via `outcome_from_step` hook for env-specific semantics. | +| `Scenario` | `env.reset(seed=..., options=...)` | `seed` from `scenario.initial_state["seed"]` or `scenario.metadata["seed"]`. `options` from `scenario.metadata["reset_options"]`. Override via `seed_from_scenario` / `options_from_scenario` hooks. | + +Additionally, `events` ride along on each step with default tags: `episode_terminated`, `episode_truncated`, `reward_negative`. Customize via `events_from_step`. + +## The `StepOutcome` shape produced + +```python +StepOutcome( + next_state={"observation": ...}, # or dict obs passthrough + outcome="progress", # or terminated_success / terminated_failure / truncated + failure_label="", # populated on failure + terminal=False, # True when terminated or truncated + metrics={ + "reward": 1.0, # this step's reward + "episode_return": 17.0, # running sum since reset + }, + events=["episode_terminated"], # tags for rule filtering + info={ + "gymnasium": { + "terminated": False, + "truncated": False, + "raw_info": {...}, # whatever the env's info dict carries + } + }, +) +``` + +## Customizing the boundary + +All six translations are public callables — override any subset: + +```python +from roboeval.integrations.gymnasium import ( + GymnasiumEnvironmentAdapter, + default_outcome_from_step, +) + + +def my_outcome(reward, terminated, truncated, info): + # Manipulation envs often expose info["is_success"] + if info.get("is_success"): + return ("goal_reached", "") + return default_outcome_from_step(reward, terminated, truncated, info) + + +adapter = GymnasiumEnvironmentAdapter( + env=gym.make("FetchPickAndPlace-v3"), + name="fetch_pick_place", + outcome_from_step=my_outcome, + info_keys=["is_success", "TimeLimit.truncated"], # allowlist heavy keys +) +``` + +A string-action vocabulary on a Discrete env: + +```python +ACTION_TABLE = {"left": 0, "right": 1} + +adapter = GymnasiumEnvironmentAdapter( + env=gym.make("CartPole-v1"), + action_from_decision=lambda a: ACTION_TABLE[a] if isinstance(a, str) else a, +) +``` + +Continuous-action envs (Box action space) work without any hook override because `Action = Any` already accepts `np.ndarray`: + +```python +import numpy as np + +env = gym.make("Pendulum-v1") +adapter = GymnasiumEnvironmentAdapter(env=env) + +def my_policy(state): + # state["observation"] is the (cos, sin, vel) tuple + return {"action": np.array([0.0], dtype=np.float32)} +``` + +## Configuration reference + +| Field | Type | Default | Purpose | +|-------|------|---------|---------| +| `env` | `gymnasium.Env` | required | The wrapped env. `VectorEnv` is explicitly refused. | +| `name` | `str` | `"gymnasium_env"` | Display name in reports (read by the runner). | +| `observation_to_state` | `Callable` \| `None` | `default_observation_to_state` | Obs → `State` dict. | +| `action_from_decision` | `Callable` \| `None` | `default_action_from_decision` | Decision action → Gymnasium action. | +| `outcome_from_step` | `Callable` \| `None` | `default_outcome_from_step` | Step result → `(outcome, failure_label)`. | +| `events_from_step` | `Callable` \| `None` | `default_events_from_step` | Step result → event tag list. | +| `seed_from_scenario` | `Callable` \| `None` | `default_seed_from_scenario` | Scenario → `env.reset(seed=...)`. | +| `options_from_scenario` | `Callable` \| `None` | `default_options_from_scenario` | Scenario → `env.reset(options=...)`. | +| `info_keys` | `list[str]` \| `None` | `None` (pass everything) | Allowlist for keys passed through from gym's `info`. Useful to drop heavy tensors. | +| `coerce_observations` | `bool` | `False` | When `True`, pre-coerce observations to JSON-safe Python types via `to_serializable`. Off by default because the runner already coerces at report-write time. | + +## What this spike does not cover + +- **`gym.vector.VectorEnv`** — refused at construction. The single-episode `EvalRunner` cannot consume batched step returns. When a batched runner ships, drop the `__post_init__` check. +- **Render frames as artifacts** — `render_mode="rgb_array"` frames could populate `StepOutcome.artifacts`, but this spike does not. A follow-up can add an `artifacts_from_step` hook. +- **Legacy `gym.Env`** — only `gymnasium>=0.29` is supported (5-tuple `step()` return). The pre-`gymnasium` `gym` package returns a 4-tuple (`done`) and is not handled. + +See `notes.md` for the full design rationale and follow-up items. diff --git a/roboeval/integrations/gymnasium/__init__.py b/roboeval/integrations/gymnasium/__init__.py new file mode 100644 index 0000000..fb02cd9 --- /dev/null +++ b/roboeval/integrations/gymnasium/__init__.py @@ -0,0 +1,25 @@ +"""Gymnasium integration for roboeval. + +Wraps any ``gymnasium.Env`` into roboeval's ``EnvironmentAdapter`` so policies +can be evaluated against Gymnasium environments using ``EvalRunner``. +""" + +from .adapter import ( + GymnasiumEnvironmentAdapter, + default_action_from_decision, + default_events_from_step, + default_observation_to_state, + default_options_from_scenario, + default_outcome_from_step, + default_seed_from_scenario, +) + +__all__ = [ + "GymnasiumEnvironmentAdapter", + "default_action_from_decision", + "default_events_from_step", + "default_observation_to_state", + "default_options_from_scenario", + "default_outcome_from_step", + "default_seed_from_scenario", +] diff --git a/roboeval/integrations/gymnasium/adapter.py b/roboeval/integrations/gymnasium/adapter.py new file mode 100644 index 0000000..dfa0844 --- /dev/null +++ b/roboeval/integrations/gymnasium/adapter.py @@ -0,0 +1,290 @@ +"""Gymnasium ↔ roboeval environment adapter. + +Wraps any ``gymnasium.Env`` into roboeval's ``EnvironmentAdapter`` Protocol so +the existing ``EvalRunner`` can drive Gymnasium environments without any change +to the core SDK. + +Translation layer +----------------- +The adapter performs six translations between Gymnasium and the SDK. Each is +exposed as an overridable hook so users can customize the boundary without +subclassing this adapter or modifying the SDK: + +1. ``observation_to_state`` Gymnasium observation -> ``State`` dict +2. ``action_from_decision`` Decision action -> Gymnasium-native action +3. ``outcome_from_step`` (reward, terminated, truncated, info) -> outcome label +4. ``events_from_step`` (reward, terminated, truncated, info) -> event tags +5. ``seed_from_scenario`` ``Scenario`` -> ``env.reset(seed=...)`` +6. ``options_from_scenario`` ``Scenario`` -> ``env.reset(options=...)`` + +Each hook has a sensible default that works for the common cases (Discrete +action spaces, Box observations, sparse-reward end-of-episode envs). + +StepOutcome shape +----------------- +The adapter populates ``StepOutcome`` directly using the SDK's existing 8 +fields. Gymnasium-specific raw data lives under ``info["gymnasium"]`` so it +travels through the eval pipeline as structured data without polluting the +domain-level ``next_state`` dict:: + + StepOutcome( + next_state={"observation": ...}, + outcome="progress", + failure_label="", + terminal=False, + metrics={"reward": float, "episode_return": float}, + events=["episode_terminated", ...], + info={"gymnasium": {"terminated": ..., "truncated": ..., "raw_info": ...}}, + ) + +Numeric values land in ``metrics`` so the SDK's metric summaries and +``require_metric`` rules pick them up automatically. Raw / debug data lands in +``info`` so it round-trips through the eval reports without semantic coupling. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable + +import gymnasium as gym + +from roboeval.core import Action, Scenario, State, to_serializable +from roboeval.environment import StepOutcome + + +# --- Default hook implementations ------------------------------------------- +# +# All defaults are public so user overrides can compose on top of them rather +# than reimplementing the common path. Example:: +# +# def my_outcome(reward, terminated, truncated, info): +# outcome, label = default_outcome_from_step(reward, terminated, truncated, info) +# if info.get("is_success"): +# return ("goal_reached", "") +# return outcome, label +# +# adapter = GymnasiumEnvironmentAdapter(env=..., outcome_from_step=my_outcome) + + +def default_observation_to_state(obs: Any) -> dict[str, Any]: + """Wrap a Gymnasium observation into a ``State`` dict. + + - ``dict`` observations are passed through (shallow-copied). + - Non-dict observations (Box, Discrete, etc.) are wrapped as + ``{"observation": obs}`` so the SDK's dict invariant holds. + + Values are not coerced here — the SDK's ``to_serializable`` handles JSON + safety at report-write time. If a user wants pre-coerced observations + visible to their policies, set ``coerce_observations=True`` on the adapter. + """ + if isinstance(obs, dict): + return dict(obs) + return {"observation": obs} + + +def default_action_from_decision(action: Action) -> Any: + """Pass through the decision action unchanged. + + The SDK's ``Action = Any`` type means Gymnasium-native actions (int for + Discrete, ``np.ndarray`` for Box, dict for Dict spaces) round-trip without + coercion. Override this hook to translate from a user-defined action + vocabulary (e.g. ``"left" -> 0``). + """ + return action + + +def default_outcome_from_step( + reward: float, terminated: bool, truncated: bool, info: dict +) -> tuple[str, str]: + """Default ``(outcome, failure_label)`` mapping for generic Gymnasium envs. + + Override for envs with richer semantics (e.g. ``info["is_success"]`` in + manipulation benchmarks). + """ + if terminated: + if reward > 0: + return ("terminated_success", "") + return ("terminated_failure", "terminated_failure") + if truncated: + return ("truncated", "timeout") + return ("progress", "") + + +def default_events_from_step( + reward: float, terminated: bool, truncated: bool, info: dict +) -> list[str]: + """Default event tags emitted from each step. + + Events surface in reports and feed custom ``Rule`` definitions. Add more + domain-specific tags by overriding this hook. + """ + events: list[str] = [] + if terminated: + events.append("episode_terminated") + if truncated: + events.append("episode_truncated") + if reward < 0: + events.append("reward_negative") + return events + + +def default_seed_from_scenario(scenario: Scenario) -> int | None: + """Pull a Gymnasium ``seed`` from the scenario, preferring ``initial_state``.""" + seed = scenario.initial_state.get("seed") + if seed is None: + seed = scenario.metadata.get("seed") + return int(seed) if seed is not None else None + + +def default_options_from_scenario(scenario: Scenario) -> dict | None: + """Pull Gymnasium reset ``options`` from ``scenario.metadata['reset_options']``. + + Returns ``None`` when no options are provided so the env's own defaults run. + """ + options = scenario.metadata.get("reset_options") + if isinstance(options, dict): + return dict(options) + return None + + +# --- The adapter ------------------------------------------------------------ + + +@dataclass +class GymnasiumEnvironmentAdapter: + """Wraps any ``gymnasium.Env`` into roboeval's ``EnvironmentAdapter``. + + The style mirrors ``CallableEnvironmentAdapter`` in ``roboeval.environment``: + a small dataclass with a ``name`` field, no inheritance, and explicit hook + callables for the per-axis translations. + + Parameters + ---------- + env : + The Gymnasium environment to wrap. Must be a single-env + ``gymnasium.Env`` (vector envs are explicitly refused — see + ``__post_init__``). + name : + Display name for reports (read by the runner's ``_environment_name``). + observation_to_state, action_from_decision, outcome_from_step, + events_from_step, seed_from_scenario, options_from_scenario : + Override hooks. ``None`` selects the corresponding ``default_*`` + implementation in this module. + info_keys : + Optional allowlist for keys passed through from Gymnasium's ``info`` + dict into ``StepOutcome.info["gymnasium"]["raw_info"]``. Useful when the + env emits large tensors in ``info`` that should not balloon the eval + logs. ``None`` passes everything through. + coerce_observations : + When ``True``, observations are pre-coerced to JSON-safe Python types + via ``to_serializable`` before being returned. Off by default because + the runner already coerces at report-write time. + """ + + env: gym.Env + name: str = "gymnasium_env" + + observation_to_state: Callable[[Any], dict[str, Any]] | None = None + action_from_decision: Callable[[Action], Any] | None = None + outcome_from_step: ( + Callable[[float, bool, bool, dict], tuple[str, str]] | None + ) = None + events_from_step: ( + Callable[[float, bool, bool, dict], list[str]] | None + ) = None + seed_from_scenario: Callable[[Scenario], int | None] | None = None + options_from_scenario: Callable[[Scenario], dict | None] | None = None + + info_keys: list[str] | None = None + coerce_observations: bool = False + + def __post_init__(self) -> None: + if isinstance(self.env, gym.vector.VectorEnv): + raise NotImplementedError( + "GymnasiumEnvironmentAdapter does not support gym.vector.VectorEnv. " + "The roboeval runner is single-episode; pass a single env " + "(e.g. env.envs[0], or gym.make(env_id) without vectorization)." + ) + + if self.observation_to_state is None: + self.observation_to_state = default_observation_to_state + if self.action_from_decision is None: + self.action_from_decision = default_action_from_decision + if self.outcome_from_step is None: + self.outcome_from_step = default_outcome_from_step + if self.events_from_step is None: + self.events_from_step = default_events_from_step + if self.seed_from_scenario is None: + self.seed_from_scenario = default_seed_from_scenario + if self.options_from_scenario is None: + self.options_from_scenario = default_options_from_scenario + + self._episode_return: float = 0.0 + + # --- EnvironmentAdapter Protocol ---------------------------------------- + + def reset(self, scenario: Scenario) -> State: + seed = self.seed_from_scenario(scenario) + options = self.options_from_scenario(scenario) + obs, _info = self.env.reset(seed=seed, options=options) + self._episode_return = 0.0 + return self._state_from_obs(obs) + + def step(self, action: Action, scenario: Scenario) -> StepOutcome: + gym_action = self.action_from_decision(action) + obs, reward, terminated, truncated, info = self.env.step(gym_action) + reward_value = float(reward) + self._episode_return += reward_value + return self._build_outcome(obs, reward_value, bool(terminated), bool(truncated), info) + + def close(self) -> None: + """Forward close to the wrapped env. Optional — not part of the Protocol.""" + close = getattr(self.env, "close", None) + if callable(close): + close() + + # --- Internals ---------------------------------------------------------- + + def _state_from_obs(self, obs: Any) -> State: + state = self.observation_to_state(obs) + if self.coerce_observations: + state = to_serializable(state) + return state + + def _filter_info(self, info: dict) -> dict: + if self.info_keys is None: + return dict(info) + return {key: info[key] for key in self.info_keys if key in info} + + def _build_outcome( + self, + obs: Any, + reward: float, + terminated: bool, + truncated: bool, + info: dict, + ) -> StepOutcome: + next_state = self._state_from_obs(obs) + outcome, failure_label = self.outcome_from_step(reward, terminated, truncated, info) + events = self.events_from_step(reward, terminated, truncated, info) + filtered_info = self._filter_info(info) + + return StepOutcome( + next_state=next_state, + outcome=outcome, + failure_label=failure_label, + terminal=bool(terminated or truncated), + metrics={ + "reward": reward, + "episode_return": float(self._episode_return), + }, + events=events, + info={ + "gymnasium": { + "terminated": terminated, + "truncated": truncated, + "raw_info": to_serializable(filtered_info), + } + }, + ) diff --git a/roboeval/integrations/gymnasium/demo_rollout.py b/roboeval/integrations/gymnasium/demo_rollout.py new file mode 100644 index 0000000..845ef63 --- /dev/null +++ b/roboeval/integrations/gymnasium/demo_rollout.py @@ -0,0 +1,66 @@ +"""Manual rollout demo for the Gymnasium integration spike. + +Runs CartPole-v1 through ``GymnasiumEnvironmentAdapter`` step-by-step and +prints the resulting ``StepOutcome`` for each step. Demonstrates: + +- Wrapping a vanilla ``gymnasium.Env`` with zero modifications. +- ``Action = Any`` means a plain ``int`` action travels straight through. +- ``next_state`` exposes the raw observation; ``metrics`` carries ``reward`` + and ``episode_return``; ``info["gymnasium"]`` carries the raw terminated / + truncated / info structure. +- ``terminal`` becomes ``True`` either when the pole falls (``terminated``) + or the time limit is hit (``truncated``). + +Run:: + + python -m roboeval.integrations.gymnasium.demo_rollout +""" + +from __future__ import annotations + +import gymnasium as gym + +from roboeval.core import Scenario +from roboeval.integrations.gymnasium import GymnasiumEnvironmentAdapter + + +def naive_balance_policy(state: dict) -> int: + """If the pole is leaning left (negative angle), push left (action 0). + Otherwise push right (action 1). Trivial heuristic — not great, but enough + to drive the loop past the first step.""" + pole_angle = float(state["observation"][2]) + return 0 if pole_angle < 0 else 1 + + +def main() -> None: + env = gym.make("CartPole-v1") + adapter = GymnasiumEnvironmentAdapter(env=env, name="cartpole_v1") + + scenario = Scenario( + name="cartpole_smoke", + initial_state={"seed": 0}, + max_steps=200, + ) + + state = adapter.reset(scenario) + print(f"[reset] state={state}") + + for step in range(scenario.max_steps): + action = naive_balance_policy(state) + outcome = adapter.step(action, scenario) + print( + f"[step {step:2d}] action={action} " + f"outcome={outcome.outcome:<22} terminal={outcome.terminal} " + f"reward={outcome.metrics['reward']:.1f} " + f"return={outcome.metrics['episode_return']:.1f} " + f"events={outcome.events}" + ) + state = outcome.next_state + if outcome.terminal: + break + + adapter.close() + + +if __name__ == "__main__": + main() diff --git a/roboeval/integrations/gymnasium/notes.md b/roboeval/integrations/gymnasium/notes.md new file mode 100644 index 0000000..e08ef8c --- /dev/null +++ b/roboeval/integrations/gymnasium/notes.md @@ -0,0 +1,114 @@ +# Design Notes — Gymnasium Integration Spike + +Engineering notes captured while implementing this spike. Audience: anyone extending this adapter, or templating the next integration (MuJoCo, PyBullet, Isaac). + +--- + +## 1. Why this folder layout + +The integrations subpackage exists so the SDK core can stay zero-dependency. Gymnasium is heavy; not every roboeval user wants it pulled into their base install. By isolating each framework adapter to its own folder under `roboeval/integrations//` with its own `requirements.txt`, we keep the core install path clean and make the future extras-based distribution (`pip install roboeval[gymnasium]`) a straight folder→extras mapping. + +Each integration folder owns six files: `__init__.py`, `adapter.py`, `demo_rollout.py`, `README.md`, `requirements.txt`, `notes.md`. The shape is meant to be copy-pasteable when starting the next adapter. + +## 2. The Gymnasium ↔ `StepOutcome` mapping + +The SDK's `StepOutcome` already has the fields needed to absorb Gymnasium's 5-tuple step return cleanly. No subclassing, no namespace gymnastics in `next_state`. The mapping baked into `adapter.py`: + +| From Gymnasium | To `StepOutcome` | +|----------------|------------------| +| `obs` | `next_state` (wrapped as dict if not already) | +| `reward` | `metrics["reward"]` and `metrics["episode_return"]` (running sum) | +| `terminated`, `truncated` | `terminal = terminated or truncated`, plus structured under `info["gymnasium"]["terminated" / "truncated"]` so rules can distinguish | +| `info` | `info["gymnasium"]["raw_info"]` (allowlistable via `info_keys`) | + +This puts numeric values in `metrics` (where `_metric_summary` and `require_metric` rules find them) and raw / debug data in `info` (where it round-trips through reports without polluting the domain-level state). + +## 3. Why not subclass `StepOutcome` + +Because the existing 8 fields cover everything Gymnasium produces. A subclass would add a type the runner does not understand and create migration risk if `StepOutcome` evolves in core. The duck-typed Protocol means whatever we return passes validation as long as the field shapes are right, so direct construction is both safer and more readable. + +If patterns emerge across multiple integrations (e.g. every framework wanting `raw_info` and `terminated/truncated`), that's the trigger to standardize those fields in core. For now, the namespace under `info["gymnasium"]` is the right granularity. + +## 4. Why hooks instead of inheritance + +Override hooks (six of them) let users customize the SDK ↔ framework boundary without subclassing. They mirror the composition style already established by `CallableEnvironmentAdapter`. The defaults handle the common path (Discrete action, Box observation, sparse-reward end-of-episode envs); overrides cover the long tail. Every default is a public function so user overrides can compose on top rather than reimplementing the whole path. + +## 5. `Action = Any` does the heavy lifting for free + +The SDK's typing decision to make `Action = Any` means: + +- Discrete envs work with `int` actions (CartPole, FrozenLake, Atari-discrete). +- Box envs work with `np.ndarray` actions (Pendulum, MuJoCo continuous control). +- Dict envs work with dict actions. + +No coercion required in the adapter. `default_action_from_decision` is literally identity. Users who want a string action vocabulary (e.g. `"left" -> 0`) override that one hook in three lines. + +This is the single biggest reason the spike stays small. + +## 6. JSON-safety is handled by the SDK + +The runner serializes via `roboeval.core.to_serializable`, which already handles `np.ndarray` (`.tolist()`), `np` scalars (`.item()`), dataclasses (`asdict()`), and nested containers. Reusing it means the adapter does not need its own coercion helper. + +What we do need: when we ourselves call `json` on values (e.g. when nesting Gymnasium's raw `info` dict under `info["gymnasium"]["raw_info"]`), we still pass through `to_serializable` to make sure heavy tensors do not break the report writer. That is the one explicit `to_serializable` call inside the adapter. + +`coerce_observations` is exposed as an opt-in for users who want JSON-safe state passed to their policies too — handy for debug prints, but off by default to keep raw observations available for ML policies that expect arrays. + +## 7. Vector envs are explicitly refused + +`gym.vector.VectorEnv` returns batched arrays from a single `step()` call. The SDK's runner is single-episode; passing a vector env would cause confusing slicing bugs. `__post_init__` raises `NotImplementedError` with a clear next-step message ("pass `env.envs[0]`, or use `gym.make_vec(..., num_envs=1)` and unwrap"). + +When the SDK gains a batched runner, the only change needed here is dropping that check. The rest of the adapter is single-env by construction and would need no other modification. + +## 8. `TimeLimit` wrapper vs `scenario.max_steps` + +Two truncation sources coexist when wrapping a Gymnasium env: + +- The env's own `TimeLimit` wrapper (default for most registered envs) flips `truncated=True` at its configured limit. +- The roboeval `Scenario.max_steps` ends the outer eval loop in the runner. + +Whichever comes first wins. Both produce sane behavior: + +- If `TimeLimit` fires first, the adapter reports `terminal=True` with `events=["episode_truncated"]`. +- If `scenario.max_steps` fires first, the runner just stops the loop; the last step is non-terminal. + +Recommendation: treat `scenario.max_steps` as authoritative for the eval budget. If the env's `TimeLimit` is lower than `scenario.max_steps`, the eval will simply end early via the env. If you want longer episodes than the env's default, wrap with `gym.wrappers.TimeLimit(env, max_episode_steps=...)` or use `env.spec.max_episode_steps = ...`. + +## 9. The default outcome mapping has a known sharp edge + +`default_outcome_from_step` classifies a terminal step as `terminated_success` when `reward > 0`. For CartPole-v1 this misfires: every step (including the terminal one where the pole falls) gives `reward = 1.0`, so falling registers as `terminated_success`. The total `episode_return` is what tells you whether the policy actually did well. + +Users with envs where the terminal reward does not signal success should override `outcome_from_step`. The default is meant as a starting point, not a universal classifier. The docstring on the default function says as much. + +The alternative — using the running episode return as the success signal — couples the default to a threshold that varies per env, which is worse. The right way to score CartPole is via `require_metric("episode_return", ">=", 195.0)` in a `Ruleset`, which is exactly what the rule API is for. + +## 10. `gym` vs `gymnasium` + +The adapter targets `gymnasium>=0.29` (the version that stabilized the 5-tuple `step()` return: `obs, reward, terminated, truncated, info`). The legacy `gym` package returns a 4-tuple (`obs, reward, done, info`) and is not supported. If a user passes a legacy `gym.Env`, the call will fail at the first `step()` unpack — there is no explicit refusal because checking the version string is brittle and `gym` has been unmaintained for years. + +If we ever need to support `gym` envs, the right path is a separate adapter (`LegacyGymEnvironmentAdapter`) that explicitly handles the 4-tuple and translates `done` into `(terminated, truncated)`. Cleaner than dispatching inside the same class. + +## 11. Future integrations follow this template + +`integrations/mujoco/`, `integrations/pybullet/`, `integrations/isaac/` should mirror this folder's shape. The big translations to think about per framework: + +- MuJoCo (raw, not through Gymnasium): step is `mj_step(model, data)` with no reward/done abstractions. The adapter has to define what success means per task. +- PyBullet: similar to MuJoCo — manual stepping, manual success criteria. Wraps cleanly via the `CallableEnvironmentAdapter` pattern if there's no central API. +- Isaac Sim / Isaac Lab: provides `gym.Env`-compatible envs via Isaac Lab. The Gymnasium adapter may work directly with minor hook overrides; needs validation. + +In all cases, namespace framework-specific extras under `info[""]` to keep downstream consumers framework-agnostic. + +## 12. Follow-up items for the team + +1. **`pyproject.toml` extras line.** Add to `[project.optional-dependencies]`: + ```toml + gymnasium = ["gymnasium>=0.29"] + ``` + So `pip install roboeval[gymnasium]` installs both the core and the underlying dependency. The integration folder is already in the SDK's package-find glob (`include = ["roboeval*"]`), so the import path works without further changes. Held out of this spike per the "don't modify pyproject.toml" constraint. + +2. **`artifacts` field.** The adapter does not currently populate `StepOutcome.artifacts`. A natural extension is capturing rendered frames when `env.render_mode == "rgb_array"`, stored either as numpy arrays or PNG paths. Best surfaced via an `artifacts_from_step` hook. + +3. **`VectorEnv` support.** Refused at construction today. When the SDK runner gains batched execution, drop the `__post_init__` check and add per-env state tracking for the running `episode_return`. + +4. **Multiple integrations sharing a base.** When the next adapter ships (MuJoCo or PyBullet), evaluate whether common patterns warrant a shared `_BaseIntegrationAdapter` or a shared `info_keys` / `events_from_step` helper module. Premature today. + +5. **Seed re-seeding semantics.** Gymnasium re-seeds the underlying RNG only when `seed` is non-None on `reset()`. The adapter forwards whatever `seed_from_scenario` returns, so a scenario with no `seed` lets the env continue its current RNG state across resets — which can produce non-reproducible episodes. Document explicitly that scenarios should always specify a seed for reproducibility. diff --git a/roboeval/integrations/gymnasium/requirements.txt b/roboeval/integrations/gymnasium/requirements.txt new file mode 100644 index 0000000..c5112d0 --- /dev/null +++ b/roboeval/integrations/gymnasium/requirements.txt @@ -0,0 +1,12 @@ +# Gymnasium integration dependency. +# +# The roboeval core stays dependency-free. Installing this integration's +# requirement separately keeps the base `pip install roboeval` lightweight. +# +# Once the SDK exposes `pip install roboeval[gymnasium]`, this file is for +# reference only — the extras line in pyproject.toml becomes authoritative. +# +# Why >=0.29: that release stabilized the 5-tuple step return +# (observation, reward, terminated, truncated, info) this adapter relies on. + +gymnasium>=0.29 diff --git a/roboeval/integrations/gymnasium/tests/__init__.py b/roboeval/integrations/gymnasium/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/roboeval/integrations/gymnasium/tests/test_adapter.py b/roboeval/integrations/gymnasium/tests/test_adapter.py new file mode 100644 index 0000000..773c58a --- /dev/null +++ b/roboeval/integrations/gymnasium/tests/test_adapter.py @@ -0,0 +1,356 @@ +"""Unit tests for the Gymnasium integration spike. + +These tests live inside the spike folder, not in the SDK's root ``tests/``. +Run with:: + + python -m unittest discover -s roboeval/integrations/gymnasium/tests +""" + +from __future__ import annotations + +import json +import unittest +from typing import Any + +import gymnasium as gym +import numpy as np + +from roboeval import EvalRunner, Ruleset, Scenario, require_metric, require_outcome +from roboeval.core import to_serializable +from roboeval.environment import EnvironmentAdapter, StepOutcome +from roboeval.integrations.gymnasium import ( + GymnasiumEnvironmentAdapter, + default_action_from_decision, + default_events_from_step, + default_observation_to_state, + default_options_from_scenario, + default_outcome_from_step, + default_seed_from_scenario, +) + + +# --- Default hook tests (no env, no adapter, just the pure functions) ------- + + +class DefaultObservationToStateTest(unittest.TestCase): + def test_dict_observation_is_passed_through(self) -> None: + obs = {"image": np.zeros((2, 2)), "joints": np.array([1.0, 2.0])} + state = default_observation_to_state(obs) + self.assertEqual(set(state.keys()), {"image", "joints"}) + self.assertIs(state["image"], obs["image"]) + + def test_non_dict_observation_is_wrapped(self) -> None: + obs = np.array([0.1, -0.5, 0.0, 0.3]) + state = default_observation_to_state(obs) + self.assertEqual(list(state.keys()), ["observation"]) + self.assertIs(state["observation"], obs) + + def test_scalar_observation_is_wrapped(self) -> None: + state = default_observation_to_state(7) + self.assertEqual(state, {"observation": 7}) + + +class DefaultActionFromDecisionTest(unittest.TestCase): + def test_int_action_passes_through(self) -> None: + self.assertEqual(default_action_from_decision(1), 1) + + def test_ndarray_action_passes_through(self) -> None: + action = np.array([0.1, -0.3], dtype=np.float32) + result = default_action_from_decision(action) + self.assertIs(result, action) + + def test_string_action_passes_through(self) -> None: + self.assertEqual(default_action_from_decision("move_forward"), "move_forward") + + +class DefaultOutcomeFromStepTest(unittest.TestCase): + def test_terminated_with_positive_reward_is_success(self) -> None: + outcome, label = default_outcome_from_step(1.0, terminated=True, truncated=False, info={}) + self.assertEqual(outcome, "terminated_success") + self.assertEqual(label, "") + + def test_terminated_with_zero_reward_is_failure(self) -> None: + outcome, label = default_outcome_from_step(0.0, terminated=True, truncated=False, info={}) + self.assertEqual(outcome, "terminated_failure") + self.assertEqual(label, "terminated_failure") + + def test_truncated_yields_timeout(self) -> None: + outcome, label = default_outcome_from_step(0.5, terminated=False, truncated=True, info={}) + self.assertEqual(outcome, "truncated") + self.assertEqual(label, "timeout") + + def test_non_terminal_yields_progress(self) -> None: + outcome, label = default_outcome_from_step(0.1, terminated=False, truncated=False, info={}) + self.assertEqual(outcome, "progress") + self.assertEqual(label, "") + + +class DefaultEventsFromStepTest(unittest.TestCase): + def test_no_events_on_plain_progress(self) -> None: + events = default_events_from_step(1.0, terminated=False, truncated=False, info={}) + self.assertEqual(events, []) + + def test_terminated_event(self) -> None: + events = default_events_from_step(1.0, terminated=True, truncated=False, info={}) + self.assertEqual(events, ["episode_terminated"]) + + def test_truncated_event(self) -> None: + events = default_events_from_step(1.0, terminated=False, truncated=True, info={}) + self.assertEqual(events, ["episode_truncated"]) + + def test_reward_negative_event(self) -> None: + events = default_events_from_step(-1.0, terminated=False, truncated=False, info={}) + self.assertEqual(events, ["reward_negative"]) + + def test_terminated_with_negative_reward_emits_two_events(self) -> None: + events = default_events_from_step(-1.0, terminated=True, truncated=False, info={}) + self.assertEqual(events, ["episode_terminated", "reward_negative"]) + + +class DefaultSeedFromScenarioTest(unittest.TestCase): + def test_seed_from_initial_state(self) -> None: + scenario = Scenario("s", {"seed": 42}, max_steps=10) + self.assertEqual(default_seed_from_scenario(scenario), 42) + + def test_seed_from_metadata_when_initial_state_lacks_it(self) -> None: + scenario = Scenario("s", {"foo": 1}, max_steps=10, metadata={"seed": 7}) + self.assertEqual(default_seed_from_scenario(scenario), 7) + + def test_no_seed_returns_none(self) -> None: + scenario = Scenario("s", {"foo": 1}, max_steps=10) + self.assertIsNone(default_seed_from_scenario(scenario)) + + +class DefaultOptionsFromScenarioTest(unittest.TestCase): + def test_options_from_metadata(self) -> None: + scenario = Scenario("s", {"foo": 1}, max_steps=10, metadata={"reset_options": {"a": 1}}) + self.assertEqual(default_options_from_scenario(scenario), {"a": 1}) + + def test_no_options_returns_none(self) -> None: + scenario = Scenario("s", {"foo": 1}, max_steps=10) + self.assertIsNone(default_options_from_scenario(scenario)) + + def test_non_dict_options_returns_none(self) -> None: + scenario = Scenario("s", {"foo": 1}, max_steps=10, metadata={"reset_options": "not_a_dict"}) + self.assertIsNone(default_options_from_scenario(scenario)) + + +# --- Adapter construction tests (no env step yet) --------------------------- + + +class GymnasiumEnvironmentAdapterConstructionTest(unittest.TestCase): + def test_satisfies_environment_adapter_protocol(self) -> None: + """Duck-check: adapter has the methods the EnvironmentAdapter Protocol requires.""" + adapter = GymnasiumEnvironmentAdapter(env=gym.make("CartPole-v1")) + self.assertTrue(hasattr(adapter, "reset")) + self.assertTrue(hasattr(adapter, "step")) + self.assertTrue(callable(adapter.reset)) + self.assertTrue(callable(adapter.step)) + # Treated as EnvironmentAdapter by the runner (duck typing) + env: EnvironmentAdapter = adapter # noqa: F841 - type-check is the point + + def test_refuses_vector_env(self) -> None: + vec_env = gym.make_vec("CartPole-v1", num_envs=2) + with self.assertRaises(NotImplementedError) as ctx: + GymnasiumEnvironmentAdapter(env=vec_env) + self.assertIn("VectorEnv", str(ctx.exception)) + vec_env.close() + + def test_defaults_wired_when_hooks_are_none(self) -> None: + adapter = GymnasiumEnvironmentAdapter(env=gym.make("CartPole-v1")) + self.assertIs(adapter.observation_to_state, default_observation_to_state) + self.assertIs(adapter.action_from_decision, default_action_from_decision) + self.assertIs(adapter.outcome_from_step, default_outcome_from_step) + self.assertIs(adapter.events_from_step, default_events_from_step) + self.assertIs(adapter.seed_from_scenario, default_seed_from_scenario) + self.assertIs(adapter.options_from_scenario, default_options_from_scenario) + + def test_name_propagates_for_report_metadata(self) -> None: + adapter = GymnasiumEnvironmentAdapter(env=gym.make("CartPole-v1"), name="my_env") + self.assertEqual(adapter.name, "my_env") + + +# --- Adapter behavior tests (real env, full reset/step cycle) --------------- + + +class GymnasiumEnvironmentAdapterBehaviorTest(unittest.TestCase): + def setUp(self) -> None: + self.env = gym.make("CartPole-v1") + self.adapter = GymnasiumEnvironmentAdapter(env=self.env, name="cartpole_v1") + self.scenario = Scenario("test", {"seed": 0}, max_steps=200) + + def tearDown(self) -> None: + self.adapter.close() + + def test_reset_returns_dict_with_observation_key(self) -> None: + state = self.adapter.reset(self.scenario) + self.assertIsInstance(state, dict) + self.assertIn("observation", state) + self.assertEqual(state["observation"].shape, (4,)) # CartPole obs is 4-dim + + def test_step_returns_step_outcome_with_required_fields(self) -> None: + self.adapter.reset(self.scenario) + outcome = self.adapter.step(0, self.scenario) + self.assertIsInstance(outcome, StepOutcome) + # Four mandatory fields + self.assertIsInstance(outcome.next_state, dict) + self.assertIsInstance(outcome.outcome, str) + self.assertIsInstance(outcome.failure_label, str) + self.assertIsInstance(outcome.terminal, bool) + + def test_step_populates_metrics_per_spec(self) -> None: + self.adapter.reset(self.scenario) + outcome = self.adapter.step(0, self.scenario) + self.assertIsInstance(outcome.metrics, dict) + self.assertIn("reward", outcome.metrics) + self.assertIn("episode_return", outcome.metrics) + self.assertEqual(outcome.metrics["reward"], outcome.metrics["episode_return"]) + + def test_episode_return_accumulates_across_steps(self) -> None: + self.adapter.reset(self.scenario) + # Take 3 steps; in CartPole each non-terminal step returns reward=1.0 + out1 = self.adapter.step(0, self.scenario) + out2 = self.adapter.step(1, self.scenario) + out3 = self.adapter.step(0, self.scenario) + self.assertEqual(out1.metrics["episode_return"], 1.0) + self.assertEqual(out2.metrics["episode_return"], 2.0) + self.assertEqual(out3.metrics["episode_return"], 3.0) + + def test_episode_return_resets_on_reset(self) -> None: + self.adapter.reset(self.scenario) + self.adapter.step(0, self.scenario) + self.adapter.step(1, self.scenario) + self.adapter.reset(self.scenario) + outcome = self.adapter.step(0, self.scenario) + self.assertEqual(outcome.metrics["episode_return"], 1.0) + + def test_step_populates_gymnasium_info_namespace(self) -> None: + self.adapter.reset(self.scenario) + outcome = self.adapter.step(0, self.scenario) + self.assertIsInstance(outcome.info, dict) + self.assertIn("gymnasium", outcome.info) + gym_info = outcome.info["gymnasium"] + self.assertIn("terminated", gym_info) + self.assertIn("truncated", gym_info) + self.assertIn("raw_info", gym_info) + self.assertIsInstance(gym_info["terminated"], bool) + self.assertIsInstance(gym_info["truncated"], bool) + + def test_seed_makes_episodes_reproducible(self) -> None: + """Same seed + same actions should produce same observations across resets.""" + state_a = self.adapter.reset(self.scenario) + out_a = self.adapter.step(0, self.scenario) + state_b = self.adapter.reset(self.scenario) + out_b = self.adapter.step(0, self.scenario) + np.testing.assert_allclose(state_a["observation"], state_b["observation"]) + np.testing.assert_allclose(out_a.next_state["observation"], out_b.next_state["observation"]) + + def test_full_episode_eventually_terminates(self) -> None: + """Running long enough with a naive policy should hit terminal=True.""" + self.adapter.reset(self.scenario) + terminal_seen = False + for _ in range(200): + outcome = self.adapter.step(0, self.scenario) # always push left + if outcome.terminal: + terminal_seen = True + self.assertIn("episode_terminated", outcome.events) + break + self.assertTrue(terminal_seen, "Expected naive policy to hit terminal within 200 steps") + + +# --- Hook override tests ---------------------------------------------------- + + +class GymnasiumEnvironmentAdapterHookOverrideTest(unittest.TestCase): + def test_outcome_hook_override(self) -> None: + def always_goal(reward, terminated, truncated, info): + return ("goal_reached", "") + + env = gym.make("CartPole-v1") + adapter = GymnasiumEnvironmentAdapter(env=env, outcome_from_step=always_goal) + adapter.reset(Scenario("s", {"seed": 0}, max_steps=10)) + outcome = adapter.step(0, Scenario("s", {"seed": 0}, max_steps=10)) + self.assertEqual(outcome.outcome, "goal_reached") + adapter.close() + + def test_action_hook_can_translate_string_vocabulary(self) -> None: + env = gym.make("CartPole-v1") + adapter = GymnasiumEnvironmentAdapter( + env=env, + action_from_decision=lambda a: {"left": 0, "right": 1}[a], + ) + adapter.reset(Scenario("s", {"seed": 0}, max_steps=10)) + # Passes "left" through hook; env step expects int 0 + outcome = adapter.step("left", Scenario("s", {"seed": 0}, max_steps=10)) + self.assertFalse(outcome.terminal) + adapter.close() + + def test_info_keys_allowlist_filters_raw_info(self) -> None: + # CartPole's info dict is usually empty; inject a custom outcome hook + # that puts a fake value into info before allowlist filtering. + env = gym.make("CartPole-v1") + adapter = GymnasiumEnvironmentAdapter(env=env, info_keys=["only_this_key"]) + adapter.reset(Scenario("s", {"seed": 0}, max_steps=10)) + outcome = adapter.step(0, Scenario("s", {"seed": 0}, max_steps=10)) + # CartPole's info is {} so allowlist returns {}; no extra keys leak + self.assertEqual(outcome.info["gymnasium"]["raw_info"], {}) + adapter.close() + + def test_coerce_observations_flag_converts_ndarray_to_list(self) -> None: + env = gym.make("CartPole-v1") + adapter = GymnasiumEnvironmentAdapter(env=env, coerce_observations=True) + state = adapter.reset(Scenario("s", {"seed": 0}, max_steps=10)) + # With coercion, observation should be a Python list, not ndarray + self.assertIsInstance(state["observation"], list) + adapter.close() + + +# --- Serialization / runner integration tests ------------------------------- + + +class GymnasiumEnvironmentAdapterSerializationTest(unittest.TestCase): + def test_step_outcome_is_json_safe_via_to_serializable(self) -> None: + env = gym.make("CartPole-v1") + adapter = GymnasiumEnvironmentAdapter(env=env) + adapter.reset(Scenario("s", {"seed": 0}, max_steps=10)) + outcome = adapter.step(0, Scenario("s", {"seed": 0}, max_steps=10)) + # to_serializable should produce a JSON-encodable structure + for field in (outcome.next_state, outcome.metrics, outcome.events, outcome.info): + payload = to_serializable(field) + json.dumps(payload) + adapter.close() + + +class GymnasiumEnvironmentAdapterRunnerIntegrationTest(unittest.TestCase): + """End-to-end through EvalRunner + Ruleset (the canonical integration).""" + + def test_eval_runner_produces_report_with_metrics_and_rules(self) -> None: + def naive_policy(state: dict) -> dict[str, Any]: + angle = float(state["observation"][2]) + return {"action": 0 if angle < 0 else 1} + + adapter = GymnasiumEnvironmentAdapter(env=gym.make("CartPole-v1"), name="cartpole_v1") + ruleset = Ruleset([ + require_outcome("terminated_success"), + require_metric("episode_return", ">=", 5.0), # easy threshold for the test + ]) + + report = EvalRunner( + policies=[naive_policy], + scenarios=[Scenario("eval_smoke", {"seed": 0}, max_steps=200)], + ruleset=ruleset, + baseline_policy="naive_policy", + environment=adapter, + ).run() + + self.assertEqual(len(report.episodes), 1) + # Metric summary picked up our `metrics` dict automatically + self.assertIn("naive_policy", report.metric_summary) + self.assertIn("episode_return", report.metric_summary["naive_policy"]) + self.assertIn("reward", report.metric_summary["naive_policy"]) + # At least one rule result was recorded + self.assertGreaterEqual(len(report.episodes[0].rule_results), 2) + + +if __name__ == "__main__": + unittest.main()