Lars Talian commited on
Commit
7106e5f
·
1 Parent(s): b439619

fix(runner): ground manifest axis in reset inputs

Browse files
src/open_range/training/runner.py CHANGED
@@ -13,6 +13,7 @@ Usage::
13
 
14
  from __future__ import annotations
15
 
 
16
  import argparse
17
  import json
18
  import logging
@@ -22,6 +23,8 @@ from dataclasses import dataclass, field
22
  from pathlib import Path
23
  from typing import Any, Protocol, runtime_checkable
24
 
 
 
25
  logger = logging.getLogger(__name__)
26
 
27
 
@@ -159,6 +162,8 @@ class CurriculumRunner:
159
  self.blue = blue
160
  self.config = config
161
  self._results: list[EpisodeRecord] = []
 
 
162
 
163
  @property
164
  def results(self) -> list[EpisodeRecord]:
@@ -204,8 +209,13 @@ class CurriculumRunner:
204
 
205
  start = time.time()
206
 
 
 
 
 
 
207
  try:
208
- obs = self.env.reset(seed=seed)
209
  except Exception as exc:
210
  logger.error("Reset failed: %s", exc)
211
  return EpisodeRecord(
@@ -213,7 +223,7 @@ class CurriculumRunner:
213
  seed=seed,
214
  episode=episode_num,
215
  outcome="error",
216
- metadata={"error": str(exc)},
217
  )
218
 
219
  briefing = getattr(obs, "stdout", str(obs))
@@ -277,6 +287,50 @@ class CurriculumRunner:
277
  duration_s=duration,
278
  )
279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  def save_results(self, path: str | Path) -> int:
281
  """Save results to a JSONL file.
282
 
 
13
 
14
  from __future__ import annotations
15
 
16
+ import asyncio
17
  import argparse
18
  import json
19
  import logging
 
23
  from pathlib import Path
24
  from typing import Any, Protocol, runtime_checkable
25
 
26
+ import yaml
27
+
28
  logger = logging.getLogger(__name__)
29
 
30
 
 
162
  self.blue = blue
163
  self.config = config
164
  self._results: list[EpisodeRecord] = []
165
+ self._manifest_cache: dict[str, dict[str, Any]] = {}
166
+ self._snapshot_builder: Any = None
167
 
168
  @property
169
  def results(self) -> list[EpisodeRecord]:
 
209
 
210
  start = time.time()
211
 
212
+ reset_kwargs: dict[str, Any] = {"seed": seed, "manifest_path": manifest_path}
213
+ snapshot = self._build_snapshot_for_manifest(manifest_path, seed)
214
+ if snapshot is not None:
215
+ reset_kwargs["snapshot"] = snapshot
216
+
217
  try:
218
+ obs = self.env.reset(**reset_kwargs)
219
  except Exception as exc:
220
  logger.error("Reset failed: %s", exc)
221
  return EpisodeRecord(
 
223
  seed=seed,
224
  episode=episode_num,
225
  outcome="error",
226
+ metadata={"error": str(exc), "reset_kwargs": sorted(reset_kwargs.keys())},
227
  )
228
 
229
  briefing = getattr(obs, "stdout", str(obs))
 
287
  duration_s=duration,
288
  )
289
 
290
+ def _build_snapshot_for_manifest(self, manifest_path: str, seed: int) -> Any | None:
291
+ """Build a deterministic snapshot from the manifest for this episode.
292
+
293
+ This grounds the manifest axis in real environment input rather than
294
+ using manifest names only for reporting metadata.
295
+ """
296
+ from open_range.builder.builder import TemplateOnlyBuilder
297
+ from open_range.protocols import BuildContext
298
+
299
+ manifest = self._manifest_cache.get(manifest_path)
300
+ if manifest is None:
301
+ manifest_file = Path(manifest_path)
302
+ if not manifest_file.exists():
303
+ logger.debug(
304
+ "Manifest path %s not found; skipping snapshot build and relying on env.reset kwargs only",
305
+ manifest_path,
306
+ )
307
+ return None
308
+ with open(manifest_file) as f:
309
+ manifest = yaml.safe_load(f)
310
+ if not isinstance(manifest, dict):
311
+ raise ValueError(f"Manifest {manifest_path!r} did not parse to a mapping")
312
+ self._manifest_cache[manifest_path] = manifest
313
+
314
+ if self._snapshot_builder is None:
315
+ self._snapshot_builder = TemplateOnlyBuilder()
316
+
317
+ tier = int(manifest.get("tier", 1) or 1)
318
+ context = BuildContext(seed=seed, tier=tier)
319
+ return self._run_coro_sync(self._snapshot_builder.build(manifest, context))
320
+
321
+ @staticmethod
322
+ def _run_coro_sync(coro: Any) -> Any:
323
+ """Run an async coroutine in sync code, including notebook event loops."""
324
+ try:
325
+ asyncio.get_running_loop()
326
+ except RuntimeError:
327
+ return asyncio.run(coro)
328
+
329
+ from concurrent.futures import ThreadPoolExecutor
330
+
331
+ with ThreadPoolExecutor(max_workers=1) as pool:
332
+ return pool.submit(lambda: asyncio.run(coro)).result()
333
+
334
  def save_results(self, path: str | Path) -> int:
335
  """Save results to a JSONL file.
336
 
tests/test_runner.py CHANGED
@@ -46,10 +46,12 @@ class MockEnvironment:
46
  self._flags = flags or []
47
  self._step_count = 0
48
  self._state = _MockState()
 
49
 
50
  def reset(self, seed: int | None = None, **kwargs: Any) -> _MockObs:
51
  self._step_count = 0
52
  self._state = _MockState()
 
53
  return _MockObs(stdout=f"Range ready. Seed={seed}")
54
 
55
  def step(self, action: Any) -> _MockObs:
@@ -213,6 +215,30 @@ class TestCurriculumRunner:
213
  assert len(results) == 3
214
  assert all(r.episode in (1, 2, 3) for r in results)
215
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
 
217
  # ---------------------------------------------------------------------------
218
  # Tests: results collection
 
46
  self._flags = flags or []
47
  self._step_count = 0
48
  self._state = _MockState()
49
+ self.reset_calls: list[dict[str, Any]] = []
50
 
51
  def reset(self, seed: int | None = None, **kwargs: Any) -> _MockObs:
52
  self._step_count = 0
53
  self._state = _MockState()
54
+ self.reset_calls.append({"seed": seed, **kwargs})
55
  return _MockObs(stdout=f"Range ready. Seed={seed}")
56
 
57
  def step(self, action: Any) -> _MockObs:
 
215
  assert len(results) == 3
216
  assert all(r.episode in (1, 2, 3) for r in results)
217
 
218
+ def test_manifest_axis_is_passed_to_reset_with_snapshot(self):
219
+ root = Path(__file__).resolve().parent.parent
220
+ manifests = [
221
+ str(root / "manifests" / "tier1_basic.yaml"),
222
+ str(root / "manifests" / "tier2_corporate.yaml"),
223
+ ]
224
+ env = MockEnvironment(max_env_steps=1)
225
+ red = MockAgent()
226
+ blue = MockAgent()
227
+ config = RunConfig(
228
+ manifests=manifests,
229
+ seeds=[7],
230
+ episodes_per_seed=1,
231
+ max_steps=1,
232
+ )
233
+ runner = CurriculumRunner(env, red, blue, config)
234
+ runner.run()
235
+
236
+ assert len(env.reset_calls) == 2
237
+ assert env.reset_calls[0]["manifest_path"].endswith("tier1_basic.yaml")
238
+ assert env.reset_calls[1]["manifest_path"].endswith("tier2_corporate.yaml")
239
+ assert env.reset_calls[0]["snapshot"].topology["tier"] == 1
240
+ assert env.reset_calls[1]["snapshot"].topology["tier"] == 2
241
+
242
 
243
  # ---------------------------------------------------------------------------
244
  # Tests: results collection