"""Alternating best-response training orchestrator. Usage: python -m training.alternating_loop --config configs/colab_demo.yaml [--dry-run] Orchestrates N generations of alternating defender/attacker training. Imports train_defender and train_attacker as regular functions (Option A: in-process). GPU code is deferred inside each training function — this module is importable and testable on CPU. --dry-run logs what each generation would do without loading models. """ from __future__ import annotations import argparse import logging from pathlib import Path from typing import Optional import yaml logger = logging.getLogger(__name__) try: from .train_defender import train_defender from .train_attacker import train_attacker except ImportError: from training.train_defender import train_defender from training.train_attacker import train_attacker def load_config(config_path: str) -> dict: """Load and return a YAML training config. Args: config_path: Path to the YAML config file. Returns: Parsed config dict. Raises: FileNotFoundError: If config_path does not exist. """ path = Path(config_path) if not path.exists(): raise FileNotFoundError(f"Config not found: {config_path}") with open(path) as f: return yaml.safe_load(f) def get_opponent_checkpoint( gen_idx: int, role: str, output_dir: str, ) -> Optional[str]: """Return the checkpoint path from the previous generation, or None for gen 0. Args: gen_idx: Current generation index (0-based). role: The role being trained this generation ("defender" or "attacker"). output_dir: Base output directory from config. Returns: Path string of the opponent's checkpoint, or None if gen_idx == 0. """ if gen_idx == 0: return None prev_gen = gen_idx - 1 opponent_role = "attacker" if role == "defender" else "defender" return f"{output_dir}_{opponent_role}_gen{prev_gen}" def _role_for_gen(gen_idx: int, per_gen: list[dict]) -> str: """Return the role to train for generation gen_idx. Uses the per_generation config list if available; falls back to even=defender / odd=attacker alternation. Args: gen_idx: Generation index (0-based). per_gen: List of per-generation dicts from config (may be empty). Returns: "defender" or "attacker". """ if gen_idx < len(per_gen): return per_gen[gen_idx]["role"] return "defender" if gen_idx % 2 == 0 else "attacker" def run_alternating_loop(cfg: dict, dry_run: bool = False) -> None: """Execute the alternating best-response training loop. Args: cfg: Parsed YAML config dict. Must contain "num_generations" (int), "per_generation" (list[dict]), and "training.output_dir" (str). dry_run: If True, log what each generation would do without calling any training function or loading any model. """ num_gens: int = cfg.get("num_generations", 3) per_gen: list[dict] = cfg.get("per_generation", []) output_dir: str = cfg["training"]["output_dir"] logger.info("Starting alternating loop: %d generations (dry_run=%s)", num_gens, dry_run) for gen_idx in range(num_gens): role = _role_for_gen(gen_idx, per_gen) opponent_ckpt = get_opponent_checkpoint(gen_idx, role, output_dir) logger.info( "Gen %d: training %s against %s", gen_idx, role, opponent_ckpt or "scripted baseline", ) if dry_run: continue if role == "defender": ckpt = train_defender(cfg, opponent_checkpoint=opponent_ckpt, gen_idx=gen_idx) else: ckpt = train_attacker(cfg, opponent_checkpoint=opponent_ckpt, gen_idx=gen_idx) logger.info("Gen %d complete: checkpoint -> %s", gen_idx, ckpt) logger.info("Alternating loop complete.") def main() -> None: logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s", ) parser = argparse.ArgumentParser( description="Alternating best-response training loop" ) parser.add_argument("--config", required=True, help="Path to YAML config file") parser.add_argument( "--dry-run", action="store_true", help="Log generation plan without running training", ) args = parser.parse_args() cfg = load_config(args.config) run_alternating_loop(cfg, dry_run=args.dry_run) if __name__ == "__main__": main()