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()