simrig 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,603 @@
1
+ """MuJoCo Playground environment backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import os
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from simrig.core import BackendInfo, EnvInspectionReport, RunConfig, SmokeResult, TrainabilityStatus
13
+ from simrig.custom_env import is_env_module_path, load_custom_env, resolve_env_label
14
+ from simrig.io import default_run_dir, save_json
15
+ from simrig.paths import find_menagerie
16
+ from simrig.presets import hidden_sizes, preset, resolve_small_network
17
+
18
+
19
+ def _import_registry():
20
+ try:
21
+ from mujoco_playground._src import registry # type: ignore
22
+ except ImportError as exc:
23
+ raise RuntimeError(
24
+ "MuJoCo Playground is not installed. Install SimRig with the playground "
25
+ "extra, or install the `playground` package used by MuJoCo Playground."
26
+ ) from exc
27
+ return registry
28
+
29
+
30
+ def _import_training_deps():
31
+ try:
32
+ import jax # type: ignore
33
+ import jax.numpy as jp # type: ignore
34
+ _patch_jax_brax_compat(jax)
35
+ from brax.io import model as brax_model # type: ignore
36
+ from brax.training.acme import running_statistics # type: ignore
37
+ from brax.training.agents.ppo import networks as ppo_networks # type: ignore
38
+ from brax.training.agents.ppo import train as ppo # type: ignore
39
+ from mujoco_playground import wrapper # type: ignore
40
+ except ImportError as exc:
41
+ raise RuntimeError(
42
+ "Training dependencies are not installed. Install JAX, Brax, and "
43
+ "MuJoCo Playground before running train/eval commands."
44
+ ) from exc
45
+ return jax, jp, brax_model, running_statistics, ppo_networks, ppo, wrapper
46
+
47
+
48
+ def _patch_jax_brax_compat(jax: Any) -> None:
49
+ """Restore APIs Brax still calls that newer JAX hid behind AttributeError.
50
+
51
+ JAX 0.10+ makes ``jax.device_put_replicated`` raise, while Brax 0.14 PPO
52
+ still calls it. Re-bind the private implementation when missing.
53
+ """
54
+ if "device_put_replicated" not in jax.__dict__:
55
+ try:
56
+ from jax._src.api import device_put_replicated as _device_put_replicated # type: ignore
57
+ except ImportError:
58
+ pass
59
+ else:
60
+ jax.device_put_replicated = _device_put_replicated # type: ignore[attr-defined]
61
+
62
+ if "device_put_sharded" not in jax.__dict__:
63
+ try:
64
+ from jax._src.api import device_put_sharded as _device_put_sharded # type: ignore
65
+ except ImportError:
66
+ pass
67
+ else:
68
+ jax.device_put_sharded = _device_put_sharded # type: ignore[attr-defined]
69
+
70
+
71
+ def backend_info() -> BackendInfo:
72
+ try:
73
+ registry = _import_registry()
74
+ except RuntimeError as exc:
75
+ return BackendInfo(name="mujoco-playground", available=False, detail=str(exc))
76
+ return BackendInfo(
77
+ name="mujoco-playground",
78
+ available=True,
79
+ detail=f"{len(registry.ALL_ENVS)} registered envs",
80
+ )
81
+
82
+
83
+ def list_envs(backend: str = "mujoco-playground") -> list[dict[str, Any]]:
84
+ """List MuJoCo Playground environments."""
85
+ _validate_backend(backend)
86
+ registry = _import_registry()
87
+ return [{"name": name, "backend": backend} for name in sorted(registry.ALL_ENVS)]
88
+
89
+
90
+ def load_env(env_name: str, config_overrides: dict[str, Any] | None = None):
91
+ """Load a Playground registry env or a custom ``*.py`` env module."""
92
+ if is_env_module_path(env_name):
93
+ return load_custom_env(env_name, config_overrides=config_overrides)
94
+
95
+ _configure_menagerie()
96
+ registry = _import_registry()
97
+ config_overrides = _default_overrides(registry, env_name, config_overrides)
98
+ return registry.load(env_name, config_overrides=config_overrides)
99
+
100
+
101
+ def inspect_env(env_name: str, *, backend: str = "mujoco-playground") -> EnvInspectionReport:
102
+ """Load a Playground or custom env and report trainability metadata."""
103
+ _validate_backend(backend)
104
+ label = resolve_env_label(env_name)
105
+
106
+ if is_env_module_path(env_name):
107
+ return _inspect_custom_env(env_name, label=label, backend=backend)
108
+
109
+ registry = _import_registry()
110
+ if env_name not in registry.ALL_ENVS:
111
+ return EnvInspectionReport(
112
+ name=label,
113
+ backend=backend,
114
+ status=TrainabilityStatus.FAILED,
115
+ available=False,
116
+ loaded=False,
117
+ errors=[f"Unknown environment: {env_name}"],
118
+ notes=[
119
+ f"Available environments: {', '.join(sorted(registry.ALL_ENVS))}",
120
+ "Or pass a custom env module path ending in .py",
121
+ ],
122
+ )
123
+
124
+ try:
125
+ env = load_env(env_name)
126
+ except Exception as exc:
127
+ return EnvInspectionReport(
128
+ name=label,
129
+ backend=backend,
130
+ status=TrainabilityStatus.FAILED,
131
+ available=True,
132
+ loaded=False,
133
+ errors=[str(exc)],
134
+ )
135
+
136
+ mj_model = getattr(env, "mj_model", None)
137
+ xml_path = getattr(env, "xml_path", None)
138
+ try:
139
+ randomizer = registry.get_domain_randomizer(env_name)
140
+ except Exception:
141
+ randomizer = None
142
+ return EnvInspectionReport(
143
+ name=label,
144
+ backend=backend,
145
+ status=TrainabilityStatus.TRAINABLE_EXISTING_ENV,
146
+ available=True,
147
+ loaded=True,
148
+ observation_size=getattr(env, "observation_size", None),
149
+ action_size=int(getattr(env, "action_size", 0) or 0),
150
+ xml_path=str(xml_path) if xml_path else None,
151
+ model_bodies=int(mj_model.nbody) if mj_model is not None else None,
152
+ model_actuators=int(mj_model.nu) if mj_model is not None else None,
153
+ has_domain_randomizer=randomizer is not None,
154
+ notes=[
155
+ "Existing MuJoCo Playground envs are trainable because they define "
156
+ "reset, step, reward, observations, and termination."
157
+ ],
158
+ )
159
+
160
+
161
+ def _inspect_custom_env(env_name: str, *, label: str, backend: str) -> EnvInspectionReport:
162
+ try:
163
+ env = load_env(env_name)
164
+ except Exception as exc:
165
+ return EnvInspectionReport(
166
+ name=label,
167
+ backend=backend,
168
+ status=TrainabilityStatus.NEEDS_CUSTOM_ENV,
169
+ available=True,
170
+ loaded=False,
171
+ errors=[str(exc)],
172
+ notes=[
173
+ "Custom env module failed to load. Finish implementation, then "
174
+ "run `simrig validate-env PATH --runtime` and `simrig smoke PATH`."
175
+ ],
176
+ )
177
+
178
+ mj_model = getattr(env, "mj_model", None)
179
+ xml_path = getattr(env, "xml_path", None)
180
+ warnings = _obs_key_warnings(getattr(env, "observation_size", None))
181
+ return EnvInspectionReport(
182
+ name=label,
183
+ backend=backend,
184
+ status=TrainabilityStatus.TRAINABLE_EXISTING_ENV,
185
+ available=True,
186
+ loaded=True,
187
+ observation_size=getattr(env, "observation_size", None),
188
+ action_size=int(getattr(env, "action_size", 0) or 0),
189
+ xml_path=str(xml_path) if xml_path else None,
190
+ model_bodies=int(mj_model.nbody) if mj_model is not None else None,
191
+ model_actuators=int(mj_model.nu) if mj_model is not None else None,
192
+ has_domain_randomizer=False,
193
+ warnings=warnings,
194
+ notes=[
195
+ f"Custom env module: {env_name}",
196
+ "Run `simrig smoke` before `simrig train`.",
197
+ "SimRig PPO defaults expect observation keys `state` and `privileged_state`.",
198
+ ],
199
+ )
200
+
201
+
202
+ def _obs_key_warnings(observation_size: Any) -> list[str]:
203
+ warnings: list[str] = []
204
+ if observation_size is None:
205
+ warnings.append("observation_size is missing.")
206
+ return warnings
207
+ if isinstance(observation_size, dict):
208
+ if "state" not in observation_size:
209
+ warnings.append("observation_size is missing key `state` (policy obs).")
210
+ if "privileged_state" not in observation_size:
211
+ warnings.append(
212
+ "observation_size is missing key `privileged_state` (value obs). "
213
+ "SimRig PPO defaults expect it."
214
+ )
215
+ else:
216
+ warnings.append(
217
+ "observation_size is flat; SimRig PPO defaults expect dict keys "
218
+ "`state` and `privileged_state`."
219
+ )
220
+ return warnings
221
+
222
+
223
+ def smoke_env(
224
+ env_name: str,
225
+ *,
226
+ steps: int = 10,
227
+ backend: str = "mujoco-playground",
228
+ seed: int = 0,
229
+ ) -> SmokeResult:
230
+ """Run a short reset/zero-action step test."""
231
+ _validate_backend(backend)
232
+ label = resolve_env_label(env_name)
233
+ jax, jp, *_ = _import_training_deps()
234
+ try:
235
+ env = load_env(env_name)
236
+ reset = jax.jit(env.reset)
237
+ step = jax.jit(env.step)
238
+ state = reset(jax.random.PRNGKey(seed))
239
+ completed = 0
240
+ for completed in range(1, steps + 1):
241
+ state = step(state, jp.zeros(env.action_size))
242
+ return SmokeResult(
243
+ env_name=label,
244
+ backend=backend,
245
+ steps_requested=steps,
246
+ steps_completed=completed,
247
+ passed=True,
248
+ action_size=int(env.action_size),
249
+ observation_size=getattr(env, "observation_size", None),
250
+ final_reward=float(state.reward),
251
+ final_done=bool(state.done),
252
+ )
253
+ except Exception as exc:
254
+ return SmokeResult(
255
+ env_name=label,
256
+ backend=backend,
257
+ steps_requested=steps,
258
+ steps_completed=0,
259
+ passed=False,
260
+ errors=[str(exc)],
261
+ )
262
+
263
+
264
+ def train_ppo(
265
+ env_name: str,
266
+ *,
267
+ preset_name: str = "smoke",
268
+ output: Path | str | None = None,
269
+ backend: str = "mujoco-playground",
270
+ overrides: dict[str, Any] | None = None,
271
+ ) -> RunConfig:
272
+ """Train a Playground or custom env module with Brax PPO."""
273
+ _validate_backend(backend)
274
+ jax, jp, brax_model, _, ppo_networks, ppo, wrapper = _import_training_deps()
275
+ del jax, jp
276
+ label = resolve_env_label(env_name)
277
+ config = preset(preset_name)
278
+ config.update(overrides or {})
279
+ output_dir = Path(output) if output is not None else default_run_dir(label, preset_name)
280
+ output_dir.mkdir(parents=True, exist_ok=True)
281
+ # Orbax requires absolute checkpoint paths.
282
+ output_dir = output_dir.resolve()
283
+ checkpoint_dir = output_dir / "checkpoints"
284
+ checkpoint_dir.mkdir(parents=True, exist_ok=True)
285
+ env = load_env(env_name, config_overrides={"impl": config.get("impl", "jax")})
286
+ sizes = hidden_sizes(bool(config["small_network"]))
287
+ network_factory = functools.partial(
288
+ ppo_networks.make_ppo_networks,
289
+ policy_hidden_layer_sizes=sizes,
290
+ value_hidden_layer_sizes=sizes,
291
+ policy_obs_key="state",
292
+ value_obs_key="privileged_state",
293
+ )
294
+
295
+ def progress(num_steps: int, metrics: dict[str, Any]) -> None:
296
+ reward = float(metrics.get("eval/episode_reward", 0.0))
297
+ length = float(metrics.get("eval/avg_episode_length", 0.0))
298
+ print(f"steps={num_steps:,} eval_reward={reward:.3f} eval_length={length:.1f}")
299
+
300
+ run_config = RunConfig(
301
+ env_name=label,
302
+ backend=backend,
303
+ preset=preset_name,
304
+ output_dir=str(output_dir),
305
+ config={**config, "env_ref": str(env_name)},
306
+ command=[
307
+ sys.executable,
308
+ "-m",
309
+ "simrig.cli",
310
+ "train",
311
+ str(env_name),
312
+ "--preset",
313
+ preset_name,
314
+ "--output",
315
+ str(output_dir),
316
+ ],
317
+ )
318
+ save_json(output_dir / "config.json", run_config)
319
+ _, params, metrics = ppo.train(
320
+ environment=env,
321
+ wrap_env_fn=wrapper.wrap_for_brax_training,
322
+ num_timesteps=config["timesteps"],
323
+ num_evals=config["num_evals"],
324
+ num_eval_envs=config["num_eval_envs"],
325
+ episode_length=config["episode_length"],
326
+ normalize_observations=True,
327
+ action_repeat=1,
328
+ unroll_length=config["unroll_length"],
329
+ num_minibatches=config["num_minibatches"],
330
+ num_updates_per_batch=config["num_updates_per_batch"],
331
+ discounting=config["discounting"],
332
+ learning_rate=config["learning_rate"],
333
+ entropy_cost=config["entropy_cost"],
334
+ num_envs=config["num_envs"],
335
+ batch_size=config["batch_size"],
336
+ network_factory=network_factory,
337
+ progress_fn=progress,
338
+ save_checkpoint_path=str(checkpoint_dir),
339
+ )
340
+ brax_model.save_params(str(output_dir / "policy.params"), params)
341
+ save_json(output_dir / "final_metrics.json", metrics)
342
+ return run_config
343
+
344
+
345
+ def eval_policy(
346
+ checkpoint: Path | str,
347
+ *,
348
+ env_name: str,
349
+ steps: int = 500,
350
+ backend: str = "mujoco-playground",
351
+ small_network: bool | None = None,
352
+ seed: int = 0,
353
+ command: tuple[float, ...] | None = None,
354
+ ) -> dict[str, Any]:
355
+ """Headless deterministic policy rollout."""
356
+ _validate_backend(backend)
357
+ jax, jp, brax_model, running_statistics, ppo_networks, *_ = _import_training_deps()
358
+ env = load_env(env_name)
359
+ sizes = hidden_sizes(resolve_small_network(checkpoint, small_network=small_network))
360
+ network_factory = functools.partial(
361
+ ppo_networks.make_ppo_networks,
362
+ policy_hidden_layer_sizes=sizes,
363
+ value_hidden_layer_sizes=sizes,
364
+ policy_obs_key="state",
365
+ value_obs_key="privileged_state",
366
+ )
367
+ networks = network_factory(
368
+ env.observation_size,
369
+ env.action_size,
370
+ preprocess_observations_fn=running_statistics.normalize,
371
+ )
372
+ params = brax_model.load_params(str(checkpoint))
373
+ policy = jax.jit(ppo_networks.make_inference_fn(networks)(params, deterministic=True))
374
+ reset = jax.jit(env.reset)
375
+ step = jax.jit(env.step)
376
+ rng = jax.random.PRNGKey(seed)
377
+ state = reset(rng)
378
+ command_applied = False
379
+ if command is not None:
380
+ state, command_applied = _apply_command(env, state, jp.asarray(command))
381
+ if not command_applied:
382
+ raise ValueError(
383
+ f"Environment {resolve_env_label(env_name)} does not expose command-like state."
384
+ )
385
+ total_reward = 0.0
386
+ completed = 0
387
+ for completed in range(1, steps + 1):
388
+ if command is not None:
389
+ state, command_applied = _apply_command(env, state, jp.asarray(command))
390
+ rng, action_rng = jax.random.split(rng)
391
+ action, _ = policy(state.obs, action_rng)
392
+ state = step(state, action)
393
+ total_reward += float(state.reward)
394
+ if bool(state.done):
395
+ break
396
+ return {
397
+ "env_name": resolve_env_label(env_name),
398
+ "backend": backend,
399
+ "checkpoint": str(checkpoint),
400
+ "seed": seed,
401
+ "command": list(command) if command is not None else None,
402
+ "command_applied": command_applied,
403
+ "steps_requested": steps,
404
+ "steps_completed": completed,
405
+ "total_reward": total_reward,
406
+ "average_reward": total_reward / max(completed, 1),
407
+ "terminated": bool(state.done),
408
+ }
409
+
410
+
411
+ def demo_policy(
412
+ checkpoint: Path | str,
413
+ *,
414
+ env_name: str,
415
+ steps: int = 5000,
416
+ backend: str = "mujoco-playground",
417
+ small_network: bool | None = None,
418
+ seed: int = 0,
419
+ command: tuple[float, ...] | None = None,
420
+ speed: float = 1.0,
421
+ camera_distance: float | None = None,
422
+ ) -> dict[str, Any]:
423
+ """Run a trained policy in a desktop MuJoCo viewer."""
424
+ _validate_backend(backend)
425
+ jax, jp, brax_model, running_statistics, ppo_networks, *_ = _import_training_deps()
426
+ try:
427
+ import mujoco # type: ignore
428
+ from gymnasium.envs.mujoco.mujoco_rendering import WindowViewer # type: ignore
429
+ except ImportError as exc:
430
+ raise RuntimeError(
431
+ "Interactive demo requires MuJoCo and Gymnasium's MuJoCo viewer."
432
+ ) from exc
433
+
434
+ env = load_env(env_name)
435
+ sizes = hidden_sizes(resolve_small_network(checkpoint, small_network=small_network))
436
+ network_factory = functools.partial(
437
+ ppo_networks.make_ppo_networks,
438
+ policy_hidden_layer_sizes=sizes,
439
+ value_hidden_layer_sizes=sizes,
440
+ policy_obs_key="state",
441
+ value_obs_key="privileged_state",
442
+ )
443
+ networks = network_factory(
444
+ env.observation_size,
445
+ env.action_size,
446
+ preprocess_observations_fn=running_statistics.normalize,
447
+ )
448
+ params = brax_model.load_params(str(checkpoint))
449
+ policy = jax.jit(ppo_networks.make_inference_fn(networks)(params, deterministic=True))
450
+ reset = jax.jit(env.reset)
451
+ step = jax.jit(env.step)
452
+ rng = jax.random.PRNGKey(seed)
453
+ state = reset(rng)
454
+ command_applied = False
455
+ if command is not None:
456
+ state, command_applied = _apply_command(env, state, jp.asarray(command))
457
+
458
+ mj_data = mujoco.MjData(env.mj_model)
459
+ viewer = WindowViewer(env.mj_model, mj_data, width=None, height=None, max_geom=1000)
460
+ if camera_distance is not None:
461
+ viewer.cam.distance = camera_distance
462
+
463
+ total_reward = 0.0
464
+ completed = 0
465
+ try:
466
+ for completed in range(1, steps + 1):
467
+ if command is not None:
468
+ state, command_applied = _apply_command(env, state, jp.asarray(command))
469
+ rng, action_rng = jax.random.split(rng)
470
+ action, _ = policy(state.obs, action_rng)
471
+ state = step(state, action)
472
+ total_reward += float(state.reward)
473
+
474
+ mj_data.qpos = state.data.qpos
475
+ mj_data.qvel = state.data.qvel
476
+ _copy_mocap_state(state.data, mj_data)
477
+ mujoco.mj_forward(env.mj_model, mj_data)
478
+ viewer.data = mj_data
479
+ viewer.add_overlay(
480
+ mujoco.mjtGridPos.mjGRID_TOPRIGHT,
481
+ "SimRig demo\nEnv\nReward\nSteps",
482
+ f"\n{resolve_env_label(env_name)}\n{float(state.reward):.4f}\n{completed}",
483
+ )
484
+ viewer.render()
485
+ time.sleep(max(0.0, env.dt / max(speed, 1e-6) - 0.001))
486
+ if viewer.window is None or bool(state.done):
487
+ break
488
+ finally:
489
+ viewer.close()
490
+
491
+ return {
492
+ "env_name": resolve_env_label(env_name),
493
+ "backend": backend,
494
+ "checkpoint": str(checkpoint),
495
+ "steps_requested": steps,
496
+ "steps_completed": completed,
497
+ "total_reward": total_reward,
498
+ "average_reward": total_reward / max(completed, 1),
499
+ "terminated": bool(state.done),
500
+ "command": list(command) if command is not None else None,
501
+ "command_applied": command_applied,
502
+ }
503
+
504
+
505
+ def _validate_backend(backend: str) -> None:
506
+ if backend != "mujoco-playground":
507
+ raise ValueError(
508
+ f"Unsupported backend for v0: {backend}. "
509
+ "SimRig v0 supports only mujoco-playground training envs."
510
+ )
511
+
512
+
513
+ def _copy_mocap_state(source_data: Any, target_data: Any) -> None:
514
+ """Copy optional MJX mocap state into native MuJoCo render data."""
515
+ for name in ("mocap_pos", "mocap_quat"):
516
+ source = getattr(source_data, name, None)
517
+ target = getattr(target_data, name, None)
518
+ if source is not None and target is not None:
519
+ target[:] = source
520
+
521
+
522
+ def _apply_command(env: Any, state: Any, command: Any) -> tuple[Any, bool]:
523
+ if hasattr(env, "set_command"):
524
+ return env.set_command(state, command), True
525
+ info = dict(getattr(state, "info", {}))
526
+ if "command" not in info:
527
+ return state, False
528
+ info["command"] = command
529
+ for key in ("steps_until_next_cmd", "steps_until_next_command"):
530
+ if key in info:
531
+ try:
532
+ import jax.numpy as jp # type: ignore
533
+
534
+ info[key] = jp.iinfo(jp.int32).max
535
+ except Exception:
536
+ pass
537
+ obs = _rebuild_observation(env, state, info)
538
+ if obs is None:
539
+ return state.replace(info=info), True
540
+ return state.replace(info=info, obs=obs), True
541
+
542
+
543
+ def _rebuild_observation(env: Any, state: Any, info: dict[str, Any]) -> Any | None:
544
+ target = getattr(env, "unwrapped", env)
545
+ get_obs = getattr(target, "_get_obs", None)
546
+ if get_obs is None:
547
+ get_obs = getattr(env, "_get_obs", None)
548
+ if get_obs is None:
549
+ return None
550
+ try:
551
+ return get_obs(state.data, info)
552
+ except TypeError:
553
+ # Some locomotion envs require contact flags as a third argument.
554
+ try:
555
+ import jax.numpy as jp # type: ignore
556
+
557
+ foot_sensor_ids = getattr(target, "_feet_floor_found_sensor", None)
558
+ mj_model = getattr(target, "_mj_model", getattr(target, "mj_model", None))
559
+ if foot_sensor_ids is None or mj_model is None:
560
+ return None
561
+ contact = jp.array(
562
+ [
563
+ state.data.sensordata[mj_model.sensor_adr[sensor_id]] > 0
564
+ for sensor_id in foot_sensor_ids
565
+ ]
566
+ )
567
+ return get_obs(state.data, info, contact)
568
+ except Exception:
569
+ return None
570
+
571
+
572
+ def _default_overrides(
573
+ registry: Any,
574
+ env_name: str,
575
+ config_overrides: dict[str, Any] | None,
576
+ ) -> dict[str, Any] | None:
577
+ overrides = dict(config_overrides or {})
578
+ if "impl" in overrides:
579
+ return overrides
580
+ try:
581
+ config = registry.get_default_config(env_name)
582
+ except Exception:
583
+ return overrides or None
584
+ if "impl" in config:
585
+ overrides["impl"] = "jax"
586
+ return overrides or None
587
+
588
+
589
+ def _configure_menagerie() -> None:
590
+ """Point MuJoCo Playground at an existing local Menagerie when possible."""
591
+ try:
592
+ menagerie = find_menagerie()
593
+ except FileNotFoundError:
594
+ return
595
+ os.environ.setdefault("MUJOCO_MENAGERIE_PATH", str(menagerie))
596
+ try:
597
+ from etils import epath # type: ignore
598
+ from mujoco_playground._src import mjx_env # type: ignore
599
+
600
+ mjx_env.MENAGERIE_PATH = epath.Path(menagerie)
601
+ except Exception:
602
+ # Loading may still work if the backend uses the environment variable.
603
+ return
simrig/presets.py ADDED
@@ -0,0 +1,93 @@
1
+ """Training presets shared by CLI and library calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ PRESETS: dict[str, dict[str, Any]] = {
11
+ "smoke": {
12
+ "timesteps": 4096,
13
+ "num_envs": 16,
14
+ "num_eval_envs": 1,
15
+ "num_evals": 2,
16
+ "episode_length": 100,
17
+ "batch_size": 16,
18
+ "unroll_length": 10,
19
+ "num_minibatches": 2,
20
+ "num_updates_per_batch": 1,
21
+ "discounting": 0.97,
22
+ "learning_rate": 3e-4,
23
+ "entropy_cost": 1e-2,
24
+ "small_network": True,
25
+ },
26
+ "local": {
27
+ "timesteps": 1_000_000,
28
+ "num_envs": 128,
29
+ "num_eval_envs": 4,
30
+ "num_evals": 10,
31
+ "episode_length": 1000,
32
+ "batch_size": 128,
33
+ "unroll_length": 20,
34
+ "num_minibatches": 32,
35
+ "num_updates_per_batch": 4,
36
+ "discounting": 0.97,
37
+ "learning_rate": 3e-4,
38
+ "entropy_cost": 1e-2,
39
+ "small_network": False,
40
+ },
41
+ "cloud": {
42
+ "timesteps": 200_000_000,
43
+ "num_envs": 8192,
44
+ "num_eval_envs": 128,
45
+ "num_evals": 20,
46
+ "episode_length": 1000,
47
+ "batch_size": 256,
48
+ "unroll_length": 20,
49
+ "num_minibatches": 32,
50
+ "num_updates_per_batch": 4,
51
+ "discounting": 0.97,
52
+ "learning_rate": 3e-4,
53
+ "entropy_cost": 1e-2,
54
+ "small_network": False,
55
+ },
56
+ }
57
+
58
+
59
+ def preset(name: str) -> dict[str, Any]:
60
+ """Return a mutable copy of a named preset."""
61
+ if name not in PRESETS:
62
+ choices = ", ".join(sorted(PRESETS))
63
+ raise ValueError(f"Unknown preset: {name}. Choose one of: {choices}")
64
+ return dict(PRESETS[name])
65
+
66
+
67
+ def hidden_sizes(small_network: bool) -> tuple[int, ...]:
68
+ return (64, 64) if small_network else (512, 256, 128)
69
+
70
+
71
+ def resolve_small_network(
72
+ checkpoint: Path | str,
73
+ *,
74
+ small_network: bool | None = None,
75
+ ) -> bool:
76
+ """Resolve network size from an explicit flag or a sibling run config.json."""
77
+ if small_network is not None:
78
+ return small_network
79
+
80
+ config_path = Path(checkpoint).resolve().parent / "config.json"
81
+ if not config_path.exists():
82
+ return False
83
+
84
+ try:
85
+ data = json.loads(config_path.read_text(encoding="utf-8"))
86
+ except (OSError, json.JSONDecodeError):
87
+ return False
88
+
89
+ config = data.get("config")
90
+ if isinstance(config, dict) and "small_network" in config:
91
+ return bool(config["small_network"])
92
+ return False
93
+