Auto-derive run dir from experiment_name + timestamp

Drop checkpoint.directory from config — config defines what an experiment
is, not where its outputs go. The CLI now computes the run directory from
run.experiment_name plus a timestamp, defaulting to runs/tmp/ for
throwaway runs and runs/ when --keep is passed.

- Remove CheckpointConfig.directory and DeepCFRConfig.checkpoint_path
- DeepCFRTrainer takes run_dir: Path explicitly
- CLI: add --keep boolean; --resume requires an explicit path (no shortcut)
- Auto path: runs/[tmp/]<YYYY-MM-DD_HHMMSS>_<experiment_name-kebab>/
- Rename 13 configs to kebab-case; strip directory: lines; kebab their
  experiment_name values
- Rewrite AGENTS.md training/run sections; document
  archive/tmp/<flat> layout, --keep, kebab-case scope
- Update tests for new run_dir flow and dropped --resume shortcut

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 16:32:44 +09:00
co-authored by Claude Opus 4.7
parent a177031963
commit acb664c873
19 changed files with 144 additions and 143 deletions
@@ -2,6 +2,8 @@ from __future__ import annotations
import argparse
import json
import re
from datetime import datetime
from pathlib import Path
from typing import Any
@@ -27,7 +29,19 @@ from coolrl_lost_cities.games.classic.deep_cfr.tracking import RunTracker, Wandb
from coolrl_lost_cities.games.classic.deep_cfr.trainer import DeepCFRTrainer
from coolrl_lost_cities.games.classic.game import classic_config
_RESUME_LATEST = "__latest__"
_SLUG_RE = re.compile(r"[^a-z0-9]+")
def _kebab_slug(value: str) -> str:
slug = _SLUG_RE.sub("-", value.strip().lower()).strip("-")
return slug or "run"
def _resolve_run_dir(config: DeepCFRConfig, *, keep: bool) -> Path:
parent = Path("runs") if keep else Path("runs/tmp")
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
slug = _kebab_slug(config.run.experiment_name)
return parent / f"{timestamp}_{slug}"
def _load_config(path: str | None) -> DeepCFRConfig:
@@ -67,17 +81,6 @@ def _set_path_override(overrides: dict[str, Any], assignment: str) -> None:
cursor[keys[-1]] = value
def _resolve_resume_path(config: DeepCFRConfig, resume: str | None) -> str | None:
if resume != _RESUME_LATEST:
return resume
latest_path = config.checkpoint_path / "latest.pt"
if not latest_path.exists():
raise FileNotFoundError(
f"--resume was used without a path, but latest checkpoint does not exist: {latest_path}"
)
return str(latest_path)
def _train_overrides_from_args(args: argparse.Namespace) -> dict[str, Any]:
overrides: dict[str, Any] = {}
for assignment in getattr(args, "config_overrides", None) or ():
@@ -89,7 +92,10 @@ def train_command(args: argparse.Namespace) -> None:
config = _load_config(args.config)
overrides = _train_overrides_from_args(args)
config = _with_overrides(config, overrides)
resume_path = _resolve_resume_path(config, args.resume)
if args.resume:
run_dir = Path(args.resume).parent
else:
run_dir = _resolve_run_dir(config, keep=args.keep)
extra_trackers: list[RunTracker] = []
if args.wandb:
extra_trackers.append(
@@ -98,18 +104,19 @@ def train_command(args: argparse.Namespace) -> None:
name=args.wandb_name or config.run.experiment_name,
mode=args.wandb_mode,
config=config.to_dict(),
run_dir=str(config.checkpoint_path),
run_dir=str(run_dir),
tags=list(args.wandb_tag) if args.wandb_tag else None,
)
)
trainer = DeepCFRTrainer(
config,
config.rules.to_lost_cities_config(seed=config.run.seed),
run_dir=run_dir,
device=config.run.device,
extra_trackers=extra_trackers or None,
)
if resume_path:
trainer.load_checkpoint(resume_path)
if args.resume:
trainer.load_checkpoint(args.resume)
trainer.train()
@@ -203,7 +210,15 @@ def main(argv: list[str] | None = None) -> None:
train = subparsers.add_parser("train")
train.add_argument("--config")
train.add_argument("--resume", nargs="?", const=_RESUME_LATEST, default=None)
train.add_argument(
"--resume",
help="Path to a checkpoint to resume from (e.g. runs/.../latest.pt).",
)
train.add_argument(
"--keep",
action="store_true",
help="Place run under runs/ instead of runs/tmp/ (use for real experiments).",
)
train.add_argument(
"--set",
action="append",
@@ -217,16 +217,11 @@ class MemoryConfig(StrictModel):
class CheckpointConfig(StrictModel):
directory: str = "runs/deep_cfr/default"
save_every: int = 1
save_latest: bool = True
progress_interval_seconds: float = 20.0
exact_resume: bool = False
@property
def path(self) -> Path:
return Path(self.directory)
class EvaluationConfig(StrictModel):
eval_every: int = 50
@@ -278,10 +273,6 @@ class DeepCFRConfig(StrictModel):
def to_dict(self) -> dict[str, Any]:
return self.model_dump(mode="json")
@property
def checkpoint_path(self) -> Path:
return self.checkpoint.path
def config_from_dict(data: Mapping[str, Any]) -> DeepCFRConfig:
return DeepCFRConfig.model_validate(data)
@@ -157,6 +157,7 @@ class DeepCFRTrainer:
config: DeepCFRConfig | None = None,
game_config: LostCitiesConfig | None = None,
*,
run_dir: str | Path | None = None,
device: str = "cpu",
tracker: RunTracker | None = None,
extra_trackers: list[RunTracker] | None = None,
@@ -200,7 +201,7 @@ class DeepCFRTrainer:
self.strategy_memory = ReservoirMemory(self.config.memory.strategy_capacity)
self.rng = np.random.default_rng(self.config.run.seed + 101)
self.iteration = 0
self.run_dir = self.config.checkpoint_path
self.run_dir = Path(run_dir) if run_dir is not None else Path("runs/tmp/default")
self.metrics_path = self.run_dir / "metrics.jsonl"
self.progress_path = self.run_dir / "runtime_progress.json"
self.log_path = self.run_dir / "train.log"