Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions envs/chess_env/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,18 @@ def _parse_result(self, payload: Dict[str, Any]) -> StepResult[ChessObservation]
StepResult with ChessObservation.
"""
obs_data = payload.get("observation", {})
# `serialize_observation` keeps reward and done on the envelope, not in
# the observation dict. Reading them from obs_data left every step
# reporting reward 0.0 and done False.
reward = payload.get("reward", 0.0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor consistency nit (non-blocking): this defaults to 0.0, whereas sumo_rl_env and sophistry use payload.get("reward") (default None). Harmless here — serialize_observation always emits the reward key so the default is never hit, and ChessObservation.reward accepts None — but None would be more consistent with the base Observation.reward default and the sibling clients.

done = payload.get("done", False)

observation = ChessObservation(
fen=obs_data.get("fen", ""),
legal_moves=obs_data.get("legal_moves", []),
is_check=obs_data.get("is_check", False),
done=obs_data.get("done", False),
reward=obs_data.get("reward", 0.0),
done=bool(done),
reward=reward,
result=obs_data.get("result"),
metadata=payload.get("metadata", obs_data.get("metadata", {})),
)
Expand Down
14 changes: 9 additions & 5 deletions envs/sophistry_bench_sprint_env/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,17 @@ def _parse_result(self, data: dict) -> StepResult[AdvocacyObservation]:
error = obs_data.get("error") or ""
if error and "error" not in metadata:
metadata["error"] = error
# ``serialize_observation`` keeps reward and done on the envelope, not in
# the observation dict, so set them on the observation too. Without this
# ``result.observation.reward`` stayed None while ``result.reward`` was
# correct, which traps callers that read the observation.
reward = data.get("reward")
done = bool(data.get("done", False))
# Construct once with metadata set, rather than mutating the model after.
observation = AdvocacyObservation(**obs_data, metadata=metadata)
return StepResult(
observation=observation,
reward=data.get("reward"),
done=data.get("done", False),
observation = AdvocacyObservation(
**obs_data, metadata=metadata, reward=reward, done=done

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kwargs collide on observation parse

Medium Severity

Building AdvocacyObservation with **obs_data plus explicit reward and done kwargs raises TypeError when those keys are already present in the observation dict. The previous constructor accepted that shape, and test_client_parses_step_result still uses it. That failure is easy to miss because the module is gated by importorskip("sophistry_bench_sprint").

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 77ead67. Configure here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — populating reward/done here makes observation.reward agree with StepResult.reward, matching the majority of env clients. **obs_data is safe against a kwarg collision because serialize_observation excludes reward/done from the observation dict.

Follow-up (Tier 2, out-of-diff): the AdvocacyObservation docstring at envs/sophistry_bench_sprint_env/models.py:29-34 still instructs callers to read reward from StepResult.reward not observation.reward because "only StepResult.reward carries the weighted aggregate." That now contradicts this change — please update it to note the typed client mirrors reward/done onto the observation. (author: Anusha Acharya)

)
return StepResult(observation=observation, reward=reward, done=done)

def _parse_state(self, data: dict) -> State:
return State(**data)
6 changes: 4 additions & 2 deletions envs/sumo_rl_env/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,10 @@ def _parse_result(self, payload: Dict[str, Any]) -> StepResult[SumoObservation]:
observation_shape=obs_data.get("observation_shape", []),
action_mask=obs_data.get("action_mask", []),
sim_time=obs_data.get("sim_time", 0.0),
done=obs_data.get("done", False),
reward=obs_data.get("reward"),
# Read from the envelope, where `serialize_observation` puts these,
# so `result.observation.reward` agrees with `result.reward`.
done=bool(payload.get("done", False)),
reward=payload.get("reward"),
metadata=payload.get("metadata", obs_data.get("metadata", {})),
)

Expand Down
102 changes: 102 additions & 0 deletions tests/envs/test_client_step_result_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# SPDX-License-Identifier: BSD-3-Clause

"""Typed clients must read reward and done from where the server puts them.

`serialize_observation` deliberately excludes `reward` and `done` from the
nested observation dict and surfaces them on the envelope. A client that reads
them from the observation dict instead sees reward 0.0 and done False on every
step, which silently breaks any training loop or episode-termination check
built on it.

These tests drive each client's `_parse_result` with the output of the real
serializer, so they fail if a client and the wire format ever drift apart
again.
"""

from __future__ import annotations

from openenv.core.env_server.serialization import serialize_observation

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tier 1 — test is not self-contained (fails standalone, passes only in the full suite).

All from envs.<env> import statements are deferred into the test bodies, and there's no module-level envs.* import here. Run on its own via the repo's documented single-file command, every test errors:

PYTHONPATH=src:envs uv run pytest tests/envs/test_client_step_result_contract.py
# ModuleNotFoundError: No module named 'envs'  (6 failed)

envs is a namespace package (no envs/__init__.py), resolvable only via cwd (sys.path[0]=='') under bare python. The console-script pytest (CI test.yml + .claude/hooks/test.sh) doesn't put cwd on sys.path, so this only passes in the full tests/ run, where a sibling pure-Python env test (e.g. test_connect4_env.py) imports envs.* at module scope during collection and primes sys.modules['envs']. Pairing it with test_chess_environment.py still fails, since that module is importorskip-gated on chess/moonfish and skips before priming.

Fix — import at module scope like every other file in tests/envs/ (also lets you drop the in-body imports):

from envs.chess_env.client import ChessEnv
from envs.chess_env.models import ChessObservation
from envs.sumo_rl_env.client import SumoRLEnv
from envs.sumo_rl_env.models import SumoObservation
from envs.sophistry_bench_sprint_env.client import SophistryBenchSprintEnv
from envs.sophistry_bench_sprint_env.models import AdvocacyObservation

These modules need only openenv+pydantic (no engine), so a top-level import is safe and self-primes envs — consistent with this file's own "no importorskip" rationale.


# No `importorskip` here on purpose. These clients and their models depend only
# on openenv and pydantic, never on an engine package, so skipping when the
# engine is absent would quietly skip the very regression being guarded.


def test_serializer_keeps_reward_and_done_on_the_envelope():
"""Pin the wire contract the clients are being checked against."""
from envs.chess_env.models import ChessObservation

payload = serialize_observation(ChessObservation(done=True, reward=1.0))

assert payload["reward"] == 1.0
assert payload["done"] is True
assert "reward" not in payload["observation"]
assert "done" not in payload["observation"]


class TestChessClient:
def parse(self, observation):
from envs.chess_env.client import ChessEnv

return ChessEnv._parse_result(None, serialize_observation(observation))

def test_step_result_carries_reward_and_done(self):
from envs.chess_env.models import ChessObservation

result = self.parse(
ChessObservation(fen="8/8/8/8/8/8/8/K6k w - - 0 1", done=True, reward=1.0)
)

assert result.reward == 1.0
assert result.done is True

def test_observation_agrees_with_the_step_result(self):
from envs.chess_env.models import ChessObservation

result = self.parse(ChessObservation(done=True, reward=-1.0))

assert result.observation.reward == result.reward
assert result.observation.done == result.done

def test_other_observation_fields_still_arrive(self):
from envs.chess_env.models import ChessObservation

result = self.parse(
ChessObservation(
fen="rn6/8/8/8/8/8/8/K6k b - - 0 1", is_check=True, result="1-0"
)
)

assert result.observation.fen.startswith("rn6")
assert result.observation.is_check is True
assert result.observation.result == "1-0"


class TestSumoClient:
def test_observation_agrees_with_the_step_result(self):
from envs.sumo_rl_env.client import SumoRLEnv
from envs.sumo_rl_env.models import SumoObservation

result = SumoRLEnv._parse_result(
None, serialize_observation(SumoObservation(done=True, reward=2.5))
)

assert result.reward == 2.5
assert result.done is True
assert result.observation.reward == 2.5
assert result.observation.done is True


class TestSophistryClient:
def test_observation_agrees_with_the_step_result(self):
from envs.sophistry_bench_sprint_env.client import SophistryBenchSprintEnv
from envs.sophistry_bench_sprint_env.models import AdvocacyObservation

result = SophistryBenchSprintEnv._parse_result(
None, serialize_observation(AdvocacyObservation(done=True, reward=0.75))
)

assert result.reward == 0.75
assert result.done is True
assert result.observation.reward == 0.75
assert result.observation.done is True
Loading