Refactor CLI/interface: init command, factory field, remove extras_size

- Rename `retro-gamer create` to `retro-gamer init` with positional
  args (GAME OUTPUT) instead of --game/--output flags
- Add [tool.retro-gamer].factory = "module:attr" to declare the game
  factory function from pyproject.toml instead of relying on a
  hard-coded create_game attribute
- Remove extras_size from user-facing config; it is now measured
  automatically from a sample observation and never declared
- Update docs throughout: create→init, runs/→training/, add factory
  field documentation, clarify [model] vs [training] hyperparameter
  sections, remove stale version-history explanations
- Bump version to 0.3.0; require retro-games>=2.5.0
This commit is contained in:
Chris Proctor
2026-06-26 06:55:30 -04:00
parent 0cd3c3b488
commit c89609fe77
12 changed files with 217 additions and 136 deletions

View File

@@ -22,15 +22,12 @@ def cli():
# ---------------------------------------------------------------------------
# retro-gamer create
# retro-gamer init
# ---------------------------------------------------------------------------
@cli.command()
@click.option('--game', required=True,
help='Game to train: a .py file path (e.g. my_game.py) or a Python module '
'(e.g. retro.examples.snake)')
@click.option('--output', required=True,
help='Directory to create for this training run')
@click.argument('game')
@click.argument('output')
@click.option('--learning-rate', default=DEFAULTS['learning_rate'], type=float,
help=f"Adam optimizer learning rate (default {DEFAULTS['learning_rate']})")
@click.option('--learning-rate-decay', default=DEFAULTS['learning_rate_decay'], type=float,
@@ -60,11 +57,17 @@ def cli():
@click.option('--prioritize-experiences/--no-prioritize-experiences',
default=DEFAULTS['prioritize_experiences'],
help='Use prioritized experience replay')
def create(game, output, **hyperparams):
def init(game, output, **hyperparams):
"""Create a new training run directory.
Game metadata (actions, reward signal, etc.) is read from the
[tool.retro-gamer] section of the game's pyproject.toml.
GAME is a path to a game directory or a Python module name
(e.g. games/snake or retro.examples.snake).
OUTPUT is the directory to create for this training run
(e.g. training/snake).
Game metadata (actions, reward signal, factory function, etc.) is
read from the [tool.retro-gamer] section of the game's pyproject.toml.
Board size is read directly from the game. Hyperparameter options
control how the trainer learns, not what it learns about.
"""
@@ -84,6 +87,9 @@ def create(game, output, **hyperparams):
except (FileNotFoundError, ValueError) as e:
raise click.ClickException(str(e))
if metadata.factory:
game_config['factory'] = metadata.factory
game_factory = _load_factory(game_config)
if metadata.board_size is None:
g = game_factory()
@@ -105,7 +111,7 @@ def create(game, output, **hyperparams):
with open(run_dir / 'config.toml', 'wb') as f:
tomli_w.dump(config, f)
click.echo(f"Created training run at {output}/config.toml")
click.echo(f"Initialized training run at {output}/config.toml")
click.echo(f" game : {game}")
click.echo(f" board_size : {metadata.board_size[0]}×{metadata.board_size[1]}")
click.echo(f" actions : {metadata.actions}")
@@ -370,6 +376,21 @@ def _load_factory(game_config: dict):
path = game_config.get('path')
if path and path not in sys.path:
sys.path.insert(0, path)
factory_str = game_config.get('factory')
if factory_str:
module_name, attr_name = factory_str.split(':', 1)
try:
module = importlib.import_module(module_name)
except ImportError as e:
raise click.ClickException(f"Cannot import factory module '{module_name}': {e}")
if not hasattr(module, attr_name):
raise click.ClickException(
f"Module '{module_name}' has no '{attr_name}' function "
f"(from factory = {factory_str!r})"
)
return getattr(module, attr_name)
module_name = game_config['module']
try:
module = importlib.import_module(module_name)
@@ -377,7 +398,8 @@ def _load_factory(game_config: dict):
raise click.ClickException(f"Cannot import game module '{module_name}': {e}")
if not hasattr(module, 'create_game'):
raise click.ClickException(
f"Module '{module_name}' has no create_game() function"
f"Module '{module_name}' has no create_game() function. "
"Add factory = \"module:create_game\" to [tool.retro-gamer] in pyproject.toml."
)
return module.create_game

View File

@@ -12,15 +12,18 @@ class GameMetadata:
"""Describes a retro game for training purposes.
Required fields: actions, reward.
Optional fields: character_set, spatial, observation_function.
Optional fields: character_set, spatial, observation_function, factory.
Discovered fields: board_size (from game.board_size), extras_size
(computed by DQNTrainer from one sampled observation — never set in a
game's own pyproject.toml).
(measured by DQNTrainer from one sampled observation — never declared).
observation_function, if set, is a "module:attr" string naming a function
``f(game) -> Any`` that fully replaces the built-in board/observe_state
encoding. It is mutually exclusive with the [preprocessing] observe_state
option. See GameEnvironment for how the two paths are selected.
factory, if set, is a "module:attr" string naming the create_game function.
Read from [tool.retro-gamer].factory in pyproject.toml. When absent,
the loader falls back to looking for create_game on the game module.
"""
actions: list[str]
reward: str
@@ -29,6 +32,7 @@ class GameMetadata:
board: bool = True
board_size: tuple[int, int] | None = None
observation_function: str | None = None
factory: str | None = None
extras_size: int = 0
def validate(self):
@@ -76,6 +80,41 @@ class GameMetadata:
"This should name a function f(game) -> observation that fully\n"
"describes what your agent observes each turn."
)
if self.factory is not None:
if not isinstance(self.factory, str) or ':' not in self.factory:
raise ValueError(
f"'factory' must be a string of the form 'module:attr', "
f"but got: {self.factory!r}\n"
"Example: factory = \"my_game:create_game\"\n"
"This should name a function that takes no arguments and returns a Game."
)
def resolve_factory(self) -> Callable | None:
"""Import and return the factory function named by the factory field, or None if unset."""
if self.factory is None:
return None
if ':' not in self.factory:
raise ValueError(
f"'factory' must be of the form 'module:attr', but got "
f"{self.factory!r} (no ':' found).\n"
"Example: factory = \"my_game:create_game\""
)
module_name, attr_name = self.factory.split(':', 1)
try:
module = importlib.import_module(module_name)
except ImportError as e:
raise ValueError(
f"Could not import module {module_name!r} for factory "
f"{self.factory!r}: {e}"
) from e
try:
return getattr(module, attr_name)
except AttributeError:
raise ValueError(
f"Module {module_name!r} has no attribute {attr_name!r} "
f"(from factory = {self.factory!r}).\n"
f"Define a function named '{attr_name}' in {module_name}."
) from None
def resolve_observation_function(self) -> Callable | None:
"""Import and return the function named by observation_function, or None if unset.
@@ -153,14 +192,13 @@ class GameMetadata:
spatial=d.get('spatial', False),
board_size=board_size,
observation_function=d.get('observation_function'),
extras_size=d.get('extras_size', 0),
factory=d.get('factory'),
)
def to_dict(self) -> dict:
d = {
'actions': self.actions,
'reward': self.reward,
'extras_size': self.extras_size,
}
if self.board_size is not None:
d['board_size'] = list(self.board_size)

View File

@@ -55,7 +55,6 @@ _INCOMPATIBLE_METADATA = {
'character_set': 'the set of board characters (changes input layer size)',
'board_size': 'the board dimensions (changes input layer size)',
'observation_function': 'how the observation is computed (changes input representation)',
'extras_size': 'the size of the non-board portion of the observation (changes input layer size)',
}
_INCOMPATIBLE_PREPROCESSING = {
'spatial': 'spatial vs non-spatial network type (changes network architecture)',