Skip to content

Commit 743e4ad

Browse files
authored
Merge pull request #81 from CurtisTechSolutions/feat/local-trajectory-recording
feat(training): record local model trajectories as JSONL
2 parents 9887bbd + e8b4e69 commit 743e4ad

3 files changed

Lines changed: 351 additions & 0 deletions

File tree

‎examples/record-trajectories.md‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Recording your models' games
2+
3+
```python
4+
import numpy as np
5+
from gamesforai import ChatPolicy
6+
from gamesforai.trajectories import EpisodeRecorder, select_records, write_jsonl
7+
8+
policy = ChatPolicy("http://localhost:8000/v1", "your-model")
9+
episode = EpisodeRecorder("connect4", ["your-model-v1", "random-v1"], seed=42)
10+
rng = np.random.default_rng(42)
11+
while episode.current_players:
12+
seat = episode.current_players[0]
13+
obs, info = episode.observation(seat)
14+
action = policy(obs, info) if seat == 0 else int(rng.choice(np.flatnonzero(info["action_mask"])))
15+
episode.step(seat, action)
16+
write_jsonl("training.jsonl", select_records(episode.records(), agent="your-model-v1"))
17+
```
18+
19+
Python callbacks can replace the HTTP policy directly. Each row records one
20+
game turn: the acting seat's observation, legal mask, canonical action and index,
21+
per-seat immediate reward vector, same-seat next observation, and next actors.
22+
Final episode returns and each acting seat's eventual outcome are added only
23+
after the game ends. Truncation remains distinct from a draw. Multi-agent
24+
trainers must account for intervening opponent turns when forming decision
25+
transitions; this format does not collapse them into a single Gym step.
26+
27+
The schema pins the engine version, normalized configuration, seed, episode id,
28+
timestamp, agent identity and optional supplied rating. Ratings are metadata,
29+
not an estimate produced by the recorder. Optional reasoning and caller-supplied
30+
full transcripts are accepted by `step`; HTTP policy transcripts are not
31+
automatically captured. Only provide transcript content you intend to export.
32+
33+
Records contain seat-visible observations rather than private engine snapshots
34+
or position-set answers. A recorder buffers one episode with configurable
35+
step/byte limits; a rejected action or limit leaves the episode unchanged.
36+
Exports support game, agent, rating, date and outcome filters, and replace files
37+
atomically. Server-owned match export and hidden-benchmark access controls are
38+
separate service features; this API records local training games.

‎python/gamesforai/trajectories.py‎

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"""Seat-visible local trajectories for supervised and reinforcement learning."""
2+
from __future__ import annotations
3+
4+
import copy
5+
import json
6+
import math
7+
import os
8+
import tempfile
9+
import uuid
10+
from datetime import datetime, timezone
11+
from pathlib import Path
12+
13+
import numpy as np
14+
15+
from ._native import NativeEnv
16+
17+
18+
def _time(value):
19+
if not isinstance(value, str):
20+
raise ValueError("timestamp must be an ISO-8601 string with a timezone")
21+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
22+
if parsed.tzinfo is None:
23+
raise ValueError("timestamp must include a timezone")
24+
return parsed
25+
26+
27+
def _text(value, name, limit):
28+
if not isinstance(value, str) or not value or len(value.encode("utf-8")) > limit:
29+
raise ValueError(f"{name} must be nonempty text within {limit} bytes")
30+
return value
31+
32+
33+
def _dump(value):
34+
return json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":"))
35+
36+
37+
class EpisodeRecorder:
38+
"""Record one native episode, one row per game turn.
39+
40+
Policies receive only their own observation. The recorder keeps private
41+
engine state internally to make a failed move or record limit atomic.
42+
"""
43+
44+
def __init__(
45+
self, game, agents, *, config=None, seed=0, position=None, ratings=None,
46+
episode_id=None, created_at=None, max_bytes=64 * 1024 * 1024, max_steps=10000,
47+
):
48+
self._native = NativeEnv(game, _dump(config or {}), seed)
49+
if position is not None:
50+
self._native.reset(seed, position)
51+
self._spec = json.loads(self._native.spec_json())
52+
if isinstance(agents, (str, bytes)):
53+
raise ValueError("agents must be a sequence of identities")
54+
self._agents = tuple(_text(agent, "agent id", 256) for agent in agents)
55+
if len(self._agents) != self._native.num_players:
56+
raise ValueError("one agent identity is required per seat")
57+
self._ratings = tuple([None] * len(self._agents) if ratings is None else ratings)
58+
if len(self._ratings) != len(self._agents) or any(
59+
rating is not None and (type(rating) not in (int, float) or not math.isfinite(rating))
60+
for rating in self._ratings
61+
):
62+
raise ValueError("ratings must be finite values or None, one per seat")
63+
if type(max_bytes) is not int or not 1 <= max_bytes <= 256 * 1024 * 1024:
64+
raise ValueError("max_bytes must be 1..256 MiB")
65+
if type(max_steps) is not int or not 1 <= max_steps <= 100000:
66+
raise ValueError("max_steps must be 1..100000")
67+
snapshot = json.loads(self._native.get_state())
68+
self._metadata = {
69+
"format_version": 1,
70+
"episode_id": _text(episode_id or str(uuid.uuid4()), "episode id", 256),
71+
"game": game,
72+
"engine_version": snapshot["engine_version"],
73+
"config_json": _dump(snapshot["config"]),
74+
"seed": str(seed),
75+
"created_at": created_at or datetime.now(timezone.utc).isoformat(),
76+
}
77+
_time(self._metadata["created_at"])
78+
self._rows = []
79+
self._bytes = 0
80+
self._max_bytes, self._max_steps = max_bytes, max_steps
81+
82+
@property
83+
def current_players(self):
84+
return self._native.current_players()
85+
86+
@property
87+
def flags(self):
88+
return self._native.flags()
89+
90+
@property
91+
def returns(self):
92+
return self._native.returns()
93+
94+
def observation(self, seat):
95+
"""Inputs usable by Python callbacks or ChatPolicy without private state."""
96+
frame = self._native.frame(seat)
97+
terminated, truncated = self._native.flags()
98+
info = {
99+
"game_id": self._spec["id"], "rules": self._spec["rules_markdown"],
100+
"action_notation": self._spec["action_notation"], "seat": seat,
101+
"to_act": self.current_players, "terminated": terminated, "truncated": truncated,
102+
"action_mask": frame["action_mask"], "legal_actions": frame["legal_actions"],
103+
"text": frame["text"], "board": json.loads(frame["board_json"]),
104+
}
105+
return frame["observation"], info
106+
107+
def step(self, seat, action, *, reasoning=None, transcript=None):
108+
if isinstance(seat, (bool, np.bool_)) or not isinstance(seat, (int, np.integer)):
109+
raise ValueError("seat must be an integer")
110+
if isinstance(action, (bool, np.bool_)) or not isinstance(action, (int, np.integer)):
111+
raise ValueError("action must be an integer index")
112+
seat, action = int(seat), int(action)
113+
if reasoning is not None:
114+
_text(reasoning, "reasoning", 64 * 1024)
115+
transcript_json = None if transcript is None else _dump(transcript)
116+
if transcript_json is not None and len(transcript_json.encode("utf-8")) > 1024 * 1024:
117+
raise ValueError("transcript exceeds 1 MiB")
118+
if len(self._rows) >= self._max_steps:
119+
raise ValueError("episode record step limit reached")
120+
before = self._native.frame(seat)
121+
catalog = dict(before["legal_actions"])
122+
if action not in catalog:
123+
raise ValueError("recorded action must be legal for the acting seat")
124+
candidate = self._native.clone()
125+
rewards, terminated, truncated = candidate.step(seat, action)
126+
after = candidate.frame(seat)
127+
row = {
128+
**self._metadata,
129+
"ply": len(self._rows), "seat": seat, "agent": self._agents[seat],
130+
"agent_rating": self._ratings[seat],
131+
"observation": before["observation"].reshape(-1).tolist(),
132+
"observation_shape": list(before["observation"].shape),
133+
"action_mask": before["action_mask"].astype(bool).tolist(),
134+
"observation_text": before["text"],
135+
"action": catalog[action], "action_index": action,
136+
"reasoning": reasoning, "transcript_json": transcript_json,
137+
"rewards": list(rewards), "reward": float(rewards[seat]),
138+
"next_observation": after["observation"].reshape(-1).tolist(),
139+
"next_action_mask": after["action_mask"].astype(bool).tolist(),
140+
"next_to_act": candidate.current_players(),
141+
"terminated": terminated, "truncated": truncated,
142+
}
143+
# Reserve final labels and up to 32 bytes for each finite float64 return.
144+
size = len(_dump(row).encode("utf-8")) + 128 + 32 * len(rewards)
145+
if self._bytes + size > self._max_bytes:
146+
raise ValueError("episode record byte limit reached")
147+
self._native = candidate
148+
self._rows.append(row)
149+
self._bytes += size
150+
return list(rewards), terminated, truncated
151+
152+
def records(self):
153+
"""Finalized rows with the eventual outcome for each acting seat."""
154+
terminated, truncated = self.flags
155+
if not terminated and not truncated:
156+
raise ValueError("finish the episode before exporting its outcomes")
157+
returns = self.returns
158+
result = []
159+
for row in self._rows:
160+
value = returns[row["seat"]]
161+
outcome = "truncated" if truncated else "win" if value > 0 else "loss" if value < 0 else "draw"
162+
result.append({**copy.deepcopy(row), "outcome": outcome, "episode_returns": returns[:]})
163+
return result
164+
165+
166+
def select_records(
167+
records, *, game=None, agent=None, min_rating=None, max_rating=None,
168+
after=None, before=None, outcome=None,
169+
):
170+
"""Filter completed records; timestamps use [after, before)."""
171+
start, end = None if after is None else _time(after), None if before is None else _time(before)
172+
if start is not None and end is not None and start >= end:
173+
raise ValueError("date filter must have after < before")
174+
for rating in (min_rating, max_rating):
175+
if rating is not None and (type(rating) not in (int, float) or not math.isfinite(rating)):
176+
raise ValueError("rating bounds must be finite")
177+
if min_rating is not None and max_rating is not None and min_rating > max_rating:
178+
raise ValueError("minimum rating exceeds maximum")
179+
if outcome is not None and outcome not in {"win", "loss", "draw", "truncated"}:
180+
raise ValueError("unknown trajectory outcome")
181+
for row in records:
182+
if game is not None and row["game"] != game:
183+
continue
184+
if agent is not None and row["agent"] != agent:
185+
continue
186+
if outcome is not None and row["outcome"] != outcome:
187+
continue
188+
rating = row["agent_rating"]
189+
if min_rating is not None and (rating is None or rating < min_rating):
190+
continue
191+
if max_rating is not None and (rating is None or rating > max_rating):
192+
continue
193+
timestamp = _time(row["created_at"])
194+
if (start is not None and timestamp < start) or (end is not None and timestamp >= end):
195+
continue
196+
yield copy.deepcopy(row)
197+
198+
199+
def write_jsonl(path, records):
200+
"""Atomically replace a JSONL file; a failed generator preserves old output."""
201+
path = Path(path)
202+
temporary = None
203+
try:
204+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", newline="\n", dir=path.parent, delete=False) as file:
205+
temporary = Path(file.name)
206+
count = 0
207+
for row in records:
208+
if row.get("format_version") != 1 or row.get("outcome") not in {"win", "loss", "draw", "truncated"}:
209+
raise ValueError("export requires completed version 1 trajectory records")
210+
file.write(_dump(row) + "\n")
211+
count += 1
212+
file.flush()
213+
os.fsync(file.fileno())
214+
os.replace(temporary, path)
215+
return count
216+
finally:
217+
if temporary is not None:
218+
temporary.unlink(missing_ok=True)

‎python/tests/test_trajectories.py‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import json
2+
3+
import numpy as np
4+
import pytest
5+
6+
from gamesforai.trajectories import EpisodeRecorder, select_records, write_jsonl
7+
8+
9+
def episode(**kwargs):
10+
env = EpisodeRecorder("tictactoe", ["my-model", "other-model"], seed=42,
11+
ratings=[1600, None], episode_id="example",
12+
created_at="2026-01-01T12:00:00+00:00", **kwargs)
13+
for action in [0, 3, 1, 4, 2]:
14+
seat = env.current_players[0]
15+
obs, info = env.observation(seat)
16+
assert obs.dtype == np.float32
17+
assert info["action_mask"][action]
18+
env.step(seat, action, reasoning="legal move", transcript={"messages": ["test fixture"]})
19+
return env
20+
21+
22+
def test_recorded_transitions_masks_outcomes_and_independent_arrays(tmp_path):
23+
env = episode()
24+
rows = env.records()
25+
assert len(rows) == 5
26+
assert [row["outcome"] for row in rows] == ["win", "loss", "win", "loss", "win"]
27+
assert rows[-1]["rewards"] == [1.0, -1.0]
28+
assert all(row["episode_returns"] == [1.0, -1.0] for row in rows)
29+
assert rows[-1]["terminated"] and not rows[-1]["truncated"]
30+
for row in rows:
31+
assert row["action_mask"][row["action_index"]]
32+
assert row["observation_text"] and row["action"]
33+
assert len(row["observation"]) == np.prod(row["observation_shape"])
34+
assert set(row).isdisjoint({"state", "native", "expected", "solution", "rng"})
35+
assert json.loads(row["transcript_json"]) == {"messages": ["test fixture"]}
36+
path = tmp_path / "episode.jsonl"
37+
assert write_jsonl(path, rows) == 5
38+
loaded = [json.loads(line) for line in path.read_text().splitlines()]
39+
assert loaded == rows
40+
rows[0]["observation"][0] = 999
41+
assert env.records()[0]["observation"][0] != 999
42+
assert episode().records() == env.records()
43+
44+
45+
def test_filters_game_agent_rating_date_and_outcome():
46+
rows = episode().records()
47+
filtered = list(select_records(rows, game="tictactoe", agent="my-model",
48+
min_rating=1500, max_rating=1700, outcome="win",
49+
after="2026-01-01T00:00:00Z", before="2026-01-02T00:00:00Z"))
50+
assert len(filtered) == 3
51+
assert not list(select_records(rows, max_rating=1500))
52+
assert not list(select_records(rows, game="chess"))
53+
assert not list(select_records(rows, before="2026-01-01T12:00:00Z"))
54+
with pytest.raises(ValueError, match="timezone"):
55+
list(select_records(rows, after="2026-01-01"))
56+
with pytest.raises(ValueError):
57+
list(select_records(rows, min_rating=1700, max_rating=1500))
58+
59+
60+
def test_failed_moves_and_record_limits_preserve_native_state():
61+
env = EpisodeRecorder("connect4", ["one", "two"], max_bytes=1)
62+
before, info = env.observation(0)
63+
with pytest.raises(ValueError, match="byte limit"):
64+
env.step(0, 3)
65+
after, after_info = env.observation(0)
66+
np.testing.assert_array_equal(before, after)
67+
np.testing.assert_array_equal(info["action_mask"], after_info["action_mask"])
68+
assert env.current_players == [0]
69+
with pytest.raises(ValueError, match="legal"):
70+
env.step(0, 100)
71+
with pytest.raises(ValueError, match="finish"):
72+
env.records()
73+
74+
75+
def test_truncated_labels_never_become_draws():
76+
env = EpisodeRecorder("chess", ["one", "two"], config={"max_plies": 2})
77+
for _ in range(2):
78+
seat = env.current_players[0]
79+
env.step(seat, int(np.flatnonzero(env.observation(seat)[1]["action_mask"])[0]))
80+
assert all(row["outcome"] == "truncated" for row in env.records())
81+
assert env.records()[-1]["truncated"]
82+
83+
84+
def test_failed_export_keeps_previous_file_and_cleans_temporary(tmp_path):
85+
path = tmp_path / "train.jsonl"
86+
path.write_text("previous", encoding="utf-8")
87+
88+
def broken():
89+
yield episode().records()[0]
90+
raise RuntimeError("failed collection")
91+
92+
with pytest.raises(RuntimeError):
93+
write_jsonl(path, broken())
94+
assert path.read_text() == "previous"
95+
assert list(tmp_path.iterdir()) == [path]

0 commit comments

Comments
 (0)