Skip to content

Commit 77ead67

Browse files
committed
fix(envs): read reward and done from the step envelope in typed clients
serialize_observation() excludes reward and done from the nested observation dict and surfaces them on the response envelope. Three clients read them from the observation dict instead, so they got the defaults. chess_env was the worst case: StepResult.reward was always 0.0 and StepResult.done always False, so a chess training loop never observed a reward or an episode ending. sumo_rl_env and sophistry_bench_sprint_env built a correct StepResult but left observation.reward as None, trapping callers that read result.observation rather than result. Adds tests/envs/test_client_step_result_contract.py, which drives each client's _parse_result with real serializer output and pins the wire contract. The chess cases deliberately do not importorskip on python-chess: neither the client nor the models import it, so skipping would have hidden this exact regression.
1 parent 7a882b8 commit 77ead67

4 files changed

Lines changed: 122 additions & 9 deletions

File tree

envs/chess_env/client.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,18 @@ def _parse_result(self, payload: Dict[str, Any]) -> StepResult[ChessObservation]
6565
StepResult with ChessObservation.
6666
"""
6767
obs_data = payload.get("observation", {})
68+
# `serialize_observation` keeps reward and done on the envelope, not in
69+
# the observation dict. Reading them from obs_data left every step
70+
# reporting reward 0.0 and done False.
71+
reward = payload.get("reward", 0.0)
72+
done = payload.get("done", False)
6873

6974
observation = ChessObservation(
7075
fen=obs_data.get("fen", ""),
7176
legal_moves=obs_data.get("legal_moves", []),
7277
is_check=obs_data.get("is_check", False),
73-
done=obs_data.get("done", False),
74-
reward=obs_data.get("reward", 0.0),
78+
done=bool(done),
79+
reward=reward,
7580
result=obs_data.get("result"),
7681
metadata=payload.get("metadata", obs_data.get("metadata", {})),
7782
)

envs/sophistry_bench_sprint_env/client.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,17 @@ def _parse_result(self, data: dict) -> StepResult[AdvocacyObservation]:
5353
error = obs_data.get("error") or ""
5454
if error and "error" not in metadata:
5555
metadata["error"] = error
56+
# ``serialize_observation`` keeps reward and done on the envelope, not in
57+
# the observation dict, so set them on the observation too. Without this
58+
# ``result.observation.reward`` stayed None while ``result.reward`` was
59+
# correct, which traps callers that read the observation.
60+
reward = data.get("reward")
61+
done = bool(data.get("done", False))
5662
# Construct once with metadata set, rather than mutating the model after.
57-
observation = AdvocacyObservation(**obs_data, metadata=metadata)
58-
return StepResult(
59-
observation=observation,
60-
reward=data.get("reward"),
61-
done=data.get("done", False),
63+
observation = AdvocacyObservation(
64+
**obs_data, metadata=metadata, reward=reward, done=done
6265
)
66+
return StepResult(observation=observation, reward=reward, done=done)
6367

6468
def _parse_state(self, data: dict) -> State:
6569
return State(**data)

envs/sumo_rl_env/client.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,10 @@ def _parse_result(self, payload: Dict[str, Any]) -> StepResult[SumoObservation]:
104104
observation_shape=obs_data.get("observation_shape", []),
105105
action_mask=obs_data.get("action_mask", []),
106106
sim_time=obs_data.get("sim_time", 0.0),
107-
done=obs_data.get("done", False),
108-
reward=obs_data.get("reward"),
107+
# Read from the envelope, where `serialize_observation` puts these,
108+
# so `result.observation.reward` agrees with `result.reward`.
109+
done=bool(payload.get("done", False)),
110+
reward=payload.get("reward"),
109111
metadata=payload.get("metadata", obs_data.get("metadata", {})),
110112
)
111113

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# SPDX-License-Identifier: BSD-3-Clause
2+
3+
"""Typed clients must read reward and done from where the server puts them.
4+
5+
`serialize_observation` deliberately excludes `reward` and `done` from the
6+
nested observation dict and surfaces them on the envelope. A client that reads
7+
them from the observation dict instead sees reward 0.0 and done False on every
8+
step, which silently breaks any training loop or episode-termination check
9+
built on it.
10+
11+
These tests drive each client's `_parse_result` with the output of the real
12+
serializer, so they fail if a client and the wire format ever drift apart
13+
again.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from openenv.core.env_server.serialization import serialize_observation
19+
20+
# No `importorskip` here on purpose. These clients and their models depend only
21+
# on openenv and pydantic, never on an engine package, so skipping when the
22+
# engine is absent would quietly skip the very regression being guarded.
23+
24+
25+
def test_serializer_keeps_reward_and_done_on_the_envelope():
26+
"""Pin the wire contract the clients are being checked against."""
27+
from envs.chess_env.models import ChessObservation
28+
29+
payload = serialize_observation(ChessObservation(done=True, reward=1.0))
30+
31+
assert payload["reward"] == 1.0
32+
assert payload["done"] is True
33+
assert "reward" not in payload["observation"]
34+
assert "done" not in payload["observation"]
35+
36+
37+
class TestChessClient:
38+
def parse(self, observation):
39+
from envs.chess_env.client import ChessEnv
40+
41+
return ChessEnv._parse_result(None, serialize_observation(observation))
42+
43+
def test_step_result_carries_reward_and_done(self):
44+
from envs.chess_env.models import ChessObservation
45+
46+
result = self.parse(
47+
ChessObservation(fen="8/8/8/8/8/8/8/K6k w - - 0 1", done=True, reward=1.0)
48+
)
49+
50+
assert result.reward == 1.0
51+
assert result.done is True
52+
53+
def test_observation_agrees_with_the_step_result(self):
54+
from envs.chess_env.models import ChessObservation
55+
56+
result = self.parse(ChessObservation(done=True, reward=-1.0))
57+
58+
assert result.observation.reward == result.reward
59+
assert result.observation.done == result.done
60+
61+
def test_other_observation_fields_still_arrive(self):
62+
from envs.chess_env.models import ChessObservation
63+
64+
result = self.parse(
65+
ChessObservation(
66+
fen="rn6/8/8/8/8/8/8/K6k b - - 0 1", is_check=True, result="1-0"
67+
)
68+
)
69+
70+
assert result.observation.fen.startswith("rn6")
71+
assert result.observation.is_check is True
72+
assert result.observation.result == "1-0"
73+
74+
75+
class TestSumoClient:
76+
def test_observation_agrees_with_the_step_result(self):
77+
from envs.sumo_rl_env.client import SumoRLEnv
78+
from envs.sumo_rl_env.models import SumoObservation
79+
80+
result = SumoRLEnv._parse_result(
81+
None, serialize_observation(SumoObservation(done=True, reward=2.5))
82+
)
83+
84+
assert result.reward == 2.5
85+
assert result.done is True
86+
assert result.observation.reward == 2.5
87+
assert result.observation.done is True
88+
89+
90+
class TestSophistryClient:
91+
def test_observation_agrees_with_the_step_result(self):
92+
from envs.sophistry_bench_sprint_env.client import SophistryBenchSprintEnv
93+
from envs.sophistry_bench_sprint_env.models import AdvocacyObservation
94+
95+
result = SophistryBenchSprintEnv._parse_result(
96+
None, serialize_observation(AdvocacyObservation(done=True, reward=0.75))
97+
)
98+
99+
assert result.reward == 0.75
100+
assert result.done is True
101+
assert result.observation.reward == 0.75
102+
assert result.observation.done is True

0 commit comments

Comments
 (0)