|
| 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) |
0 commit comments