From f593a6d91013dded810a5fa3de346659be883e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=8B=9C=EC=9B=90?= Date: Thu, 7 May 2026 00:30:42 +0900 Subject: [PATCH] =?UTF-8?q?Deep=20CFR=20YAML=20config=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- configs/deep_cfr/smoke.yaml | 35 +++ docs/deep-cfr-v0-gap-vs-coolrl.md | 4 +- pyproject.toml | 2 + .../games/classic/deep_cfr/benchmark.py | 20 +- .../games/classic/deep_cfr/cli.py | 74 +++-- .../games/classic/deep_cfr/config.py | 158 +++++++--- .../games/classic/deep_cfr/evaluate.py | 2 +- .../games/classic/deep_cfr/trainer.py | 121 ++++---- .../games/classic/deep_cfr/workers.py | 45 +-- tests/games/classic/test_deep_cfr_trainer.py | 274 +++++++++++------- uv.lock | 142 +++++++++ 11 files changed, 610 insertions(+), 267 deletions(-) create mode 100644 configs/deep_cfr/smoke.yaml diff --git a/configs/deep_cfr/smoke.yaml b/configs/deep_cfr/smoke.yaml new file mode 100644 index 0000000..ea1d77c --- /dev/null +++ b/configs/deep_cfr/smoke.yaml @@ -0,0 +1,35 @@ +run: + iterations: 1 + seed: 1 + device: cpu + +network: + hidden_size: 16 + +traversal: + traversals_per_iteration: 1 + max_depth: 2 + max_nodes: 64 + num_workers: 0 + worker_chunk_size: 1 + +optimization: + advantage_train_steps: 1 + strategy_train_steps: 1 + batch_size: 2 + learning_rate: 0.001 + +memory: + advantage_capacity: 1000 + strategy_capacity: 1000 + +checkpoint: + directory: runs/deep_cfr/smoke + save_every_iteration: false + +evaluation: + eval_every: 0 + games: 2 + opponents: + - random + max_steps: 10000 diff --git a/docs/deep-cfr-v0-gap-vs-coolrl.md b/docs/deep-cfr-v0-gap-vs-coolrl.md index 89ac530..7daa74a 100644 --- a/docs/deep-cfr-v0-gap-vs-coolrl.md +++ b/docs/deep-cfr-v0-gap-vs-coolrl.md @@ -97,8 +97,8 @@ extras. Still smaller than legacy: -1. Config is a single `DeepCFRConfig` dataclass rather than a deeply nested - YAML-first config tree. +1. Config is YAML-first and nested through Pydantic, but only one smoke preset + exists under `configs/deep_cfr/`. 2. Multiprocessing exists, but it is intentionally simple: - no progress callback per worker batch - no hotspot timing profile diff --git a/pyproject.toml b/pyproject.toml index ff338e4..0ca2190 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,8 @@ authors = [ requires-python = ">=3.11" dependencies = [ "numpy>=1.26.0", + "pydantic>=2.7", + "PyYAML>=6.0", "torch>=2.3.0", ] diff --git a/src/coolrl_lost_cities/games/classic/deep_cfr/benchmark.py b/src/coolrl_lost_cities/games/classic/deep_cfr/benchmark.py index eafe3b1..d989391 100644 --- a/src/coolrl_lost_cities/games/classic/deep_cfr/benchmark.py +++ b/src/coolrl_lost_cities/games/classic/deep_cfr/benchmark.py @@ -1,7 +1,6 @@ from __future__ import annotations import time -from dataclasses import replace from coolrl_lost_cities.games.classic.deep_cfr.config import DeepCFRConfig from coolrl_lost_cities.games.classic.deep_cfr.trainer import DeepCFRTrainer @@ -13,14 +12,19 @@ def benchmark_traversal( *, num_workers: int = 0, ) -> dict[str, float | int]: - base = config or DeepCFRConfig( - iterations=1, - traversals_per_iteration=8, - max_traversal_depth=4, - save_every_iteration=False, + base = config or DeepCFRConfig.model_validate( + { + "run": {"iterations": 1}, + "traversal": {"traversals_per_iteration": 8, "max_depth": 4}, + "checkpoint": {"save_every_iteration": False}, + } ) - cfg = replace(base, num_workers=num_workers, save_every_iteration=False, eval_every=0) - trainer = DeepCFRTrainer(cfg, classic_config(seed=cfg.seed)) + data = base.model_dump(mode="python") + data["traversal"]["num_workers"] = num_workers + data["checkpoint"]["save_every_iteration"] = False + data["evaluation"]["eval_every"] = 0 + cfg = DeepCFRConfig.model_validate(data) + trainer = DeepCFRTrainer(cfg, classic_config(seed=cfg.run.seed)) started = time.perf_counter() metrics = trainer.run_iteration(1) elapsed = time.perf_counter() - started diff --git a/src/coolrl_lost_cities/games/classic/deep_cfr/cli.py b/src/coolrl_lost_cities/games/classic/deep_cfr/cli.py index 87963fa..39484cf 100644 --- a/src/coolrl_lost_cities/games/classic/deep_cfr/cli.py +++ b/src/coolrl_lost_cities/games/classic/deep_cfr/cli.py @@ -2,14 +2,13 @@ from __future__ import annotations import argparse import json -from dataclasses import replace -from pathlib import Path +from typing import Any from coolrl_lost_cities.games.classic.deep_cfr.benchmark import ( benchmark_traversal, benchmark_traversal_modes, ) -from coolrl_lost_cities.games.classic.deep_cfr.config import DeepCFRConfig, config_from_dict +from coolrl_lost_cities.games.classic.deep_cfr.config import DeepCFRConfig, load_config from coolrl_lost_cities.games.classic.deep_cfr.evaluate import ( evaluate_strategy_network, load_strategy_policy_from_checkpoint, @@ -27,27 +26,48 @@ from coolrl_lost_cities.games.classic.game import classic_config def _load_config(path: str | None) -> DeepCFRConfig: if path is None: return DeepCFRConfig() - return config_from_dict(json.loads(Path(path).read_text(encoding="utf-8"))) + return load_config(path) + + +def _deep_update(base: dict[str, Any], patch: dict[str, Any]) -> None: + for key, value in patch.items(): + if isinstance(value, dict) and isinstance(base.get(key), dict): + _deep_update(base[key], value) + else: + base[key] = value + + +def _with_overrides(config: DeepCFRConfig, overrides: dict[str, Any]) -> DeepCFRConfig: + data = config.model_dump(mode="python") + _deep_update(data, overrides) + return DeepCFRConfig.model_validate(data) def train_command(args: argparse.Namespace) -> None: config = _load_config(args.config) - overrides = {} - for key in ( - "iterations", - "traversals_per_iteration", - "checkpoint_dir", - "eval_every", - "eval_games", - "seed", - ): - value = getattr(args, key) - if value is not None: - overrides[key] = value + overrides: dict[str, Any] = {} + if args.iterations is not None: + overrides.setdefault("run", {})["iterations"] = args.iterations + if args.seed is not None: + overrides.setdefault("run", {})["seed"] = args.seed + if args.traversals_per_iteration is not None: + overrides.setdefault("traversal", {})["traversals_per_iteration"] = ( + args.traversals_per_iteration + ) + if args.checkpoint_dir is not None: + overrides.setdefault("checkpoint", {})["directory"] = args.checkpoint_dir + if args.eval_every is not None: + overrides.setdefault("evaluation", {})["eval_every"] = args.eval_every + if args.eval_games is not None: + overrides.setdefault("evaluation", {})["games"] = args.eval_games if args.no_save: - overrides["save_every_iteration"] = False - config = replace(config, **overrides) - trainer = DeepCFRTrainer(config, classic_config(seed=config.seed), device=args.device) + overrides.setdefault("checkpoint", {})["save_every_iteration"] = False + config = _with_overrides(config, overrides) + trainer = DeepCFRTrainer( + config, + classic_config(seed=config.run.seed), + device=args.device or config.run.device, + ) if args.resume: trainer.load_checkpoint(args.resume) metrics = trainer.train() @@ -70,11 +90,15 @@ def eval_command(args: argparse.Namespace) -> None: def benchmark_command(args: argparse.Namespace) -> None: - config = DeepCFRConfig( - traversals_per_iteration=args.traversals, - max_traversal_depth=args.depth, - seed=args.seed, - save_every_iteration=False, + config = DeepCFRConfig.model_validate( + { + "run": {"seed": args.seed}, + "traversal": { + "traversals_per_iteration": args.traversals, + "max_depth": args.depth, + }, + "checkpoint": {"save_every_iteration": False}, + } ) if args.compare: print(json.dumps(benchmark_traversal_modes(config), indent=2, sort_keys=True)) @@ -135,7 +159,7 @@ def main(argv: list[str] | None = None) -> None: train.add_argument("--traversals-per-iteration", type=int) train.add_argument("--checkpoint-dir") train.add_argument("--resume") - train.add_argument("--device", default="cpu") + train.add_argument("--device") train.add_argument("--eval-every", type=int) train.add_argument("--eval-games", type=int) train.add_argument("--seed", type=int) diff --git a/src/coolrl_lost_cities/games/classic/deep_cfr/config.py b/src/coolrl_lost_cities/games/classic/deep_cfr/config.py index 4344058..6767827 100644 --- a/src/coolrl_lost_cities/games/classic/deep_cfr/config.py +++ b/src/coolrl_lost_cities/games/classic/deep_cfr/config.py @@ -1,16 +1,33 @@ from __future__ import annotations -from dataclasses import asdict, dataclass +import json +import os +from collections.abc import Mapping from pathlib import Path from typing import Any +import yaml +from pydantic import BaseModel, ConfigDict, Field, field_validator -@dataclass(frozen=True) -class DeepCFRConfig: + +class StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid") + + +class RunConfig(StrictModel): iterations: int = 1 + seed: int = 1 + device: str = "cpu" + + +class NetworkConfig(StrictModel): + hidden_size: int = 64 + + +class TraversalConfig(StrictModel): traversals_per_iteration: int = 2 - max_traversal_depth: int | None = 8 - max_nodes_per_traversal: int | None = 10_000 + max_depth: int | None = 8 + max_nodes: int | None = 10_000 regret_matching_epsilon: float = 1.0e-8 outcome_sampling_epsilon: float = 0.0 outcome_sampling_value_clip: float | None = None @@ -20,59 +37,116 @@ class DeepCFRConfig: cutoff_rollout_policy: str = "random" cutoff_rollout_max_steps: int = 10_000 opponent_policy: str = "network" - self_play_snapshot_every: int = 1 - self_play_max_snapshots: int = 20 - self_play_anchor_probability: float = 0.0 - self_play_current_weight: float = 0.5 - self_play_recent_weight: float = 0.3 - self_play_older_weight: float = 0.2 - self_play_anchor_weight: float = 0.0 - self_play_recent_window: int = 5 strategy_sample_interval: int = 1 store_strategy_on_traverser_nodes: bool = True store_strategy_on_opponent_nodes: bool = True - advantage_memory_capacity: int = 2_000_000 - strategy_memory_capacity: int = 2_000_000 - advantage_train_steps: int = 1 - strategy_train_steps: int = 1 - batch_size: int = 32 - hidden_size: int = 64 - learning_rate: float = 1.0e-3 - seed: int = 1 - checkpoint_dir: str = "runs/deep_cfr/default" - save_every_iteration: bool = True - eval_every: int = 0 - eval_games: int = 10 - eval_opponents: tuple[str, ...] = ("random",) - eval_max_steps: int = 10_000 num_workers: int | str = 0 - traversal_worker_chunk_size: int = 4 + worker_chunk_size: int = 4 - def to_dict(self) -> dict[str, Any]: - return asdict(self) + @field_validator("outcome_unsampled_regret") + @classmethod + def _validate_unsampled_regret(cls, value: str) -> str: + if value not in {"negative_node_value", "zero"}: + raise ValueError("must be 'negative_node_value' or 'zero'") + return value - @property - def checkpoint_path(self) -> Path: - return Path(self.checkpoint_dir) + @field_validator("cutoff_value_mode") + @classmethod + def _validate_cutoff_value_mode(cls, value: str) -> str: + if value not in {"score_diff", "random_rollout"}: + raise ValueError("must be 'score_diff' or 'random_rollout'") + return value + + @field_validator("cutoff_rollout_policy") + @classmethod + def _validate_cutoff_rollout_policy(cls, value: str) -> str: + if value not in {"random", "safe_heuristic"}: + raise ValueError("must be 'random' or 'safe_heuristic'") + return value + + @field_validator("opponent_policy") + @classmethod + def _validate_opponent_policy(cls, value: str) -> str: + if value not in {"network", "safe_heuristic", "self_play_league"}: + raise ValueError("must be 'network', 'safe_heuristic', or 'self_play_league'") + return value def resolved_num_workers(self, batches: int | None = None) -> int: if isinstance(self.num_workers, str): token = self.num_workers.strip().lower() if token == "auto": - guess = max(1, (os_cpu_count() or 2) // 2) + guess = max(1, (os.cpu_count() or 2) // 2) return min(guess, batches) if batches is not None and batches > 0 else guess return max(0, int(token)) return max(0, int(self.num_workers)) -def config_from_dict(data: dict[str, Any]) -> DeepCFRConfig: - values = dict(data) - if "eval_opponents" in values: - values["eval_opponents"] = tuple(values["eval_opponents"]) - return DeepCFRConfig(**values) +class SelfPlayLeagueConfig(StrictModel): + snapshot_every: int = 1 + max_snapshots: int = 20 + anchor_probability: float = 0.0 + current_weight: float = 0.5 + recent_weight: float = 0.3 + older_weight: float = 0.2 + anchor_weight: float = 0.0 + recent_window: int = 5 -def os_cpu_count() -> int | None: - import os +class OptimizationConfig(StrictModel): + advantage_train_steps: int = 1 + strategy_train_steps: int = 1 + batch_size: int = 32 + learning_rate: float = 1.0e-3 - return os.cpu_count() + +class MemoryConfig(StrictModel): + advantage_capacity: int = 2_000_000 + strategy_capacity: int = 2_000_000 + + +class CheckpointConfig(StrictModel): + directory: str = "runs/deep_cfr/default" + save_every_iteration: bool = True + + @property + def path(self) -> Path: + return Path(self.directory) + + +class EvaluationConfig(StrictModel): + eval_every: int = 0 + games: int = 10 + opponents: tuple[str, ...] = ("random",) + max_steps: int = 10_000 + + +class DeepCFRConfig(StrictModel): + run: RunConfig = Field(default_factory=RunConfig) + network: NetworkConfig = Field(default_factory=NetworkConfig) + traversal: TraversalConfig = Field(default_factory=TraversalConfig) + self_play: SelfPlayLeagueConfig = Field(default_factory=SelfPlayLeagueConfig) + optimization: OptimizationConfig = Field(default_factory=OptimizationConfig) + memory: MemoryConfig = Field(default_factory=MemoryConfig) + checkpoint: CheckpointConfig = Field(default_factory=CheckpointConfig) + evaluation: EvaluationConfig = Field(default_factory=EvaluationConfig) + + 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) + + +def load_config(path: str | Path) -> DeepCFRConfig: + config_path = Path(path) + text = config_path.read_text(encoding="utf-8") + if config_path.suffix.lower() in {".yaml", ".yml"}: + data = yaml.safe_load(text) or {} + else: + data = json.loads(text) + return config_from_dict(data) diff --git a/src/coolrl_lost_cities/games/classic/deep_cfr/evaluate.py b/src/coolrl_lost_cities/games/classic/deep_cfr/evaluate.py index fd37329..27b1e28 100644 --- a/src/coolrl_lost_cities/games/classic/deep_cfr/evaluate.py +++ b/src/coolrl_lost_cities/games/classic/deep_cfr/evaluate.py @@ -97,7 +97,7 @@ def load_strategy_policy_from_checkpoint( network = DeepCFRMLP( int(payload["input_dim"]), int(payload["action_size"]), - cfg.hidden_size, + cfg.network.hidden_size, ).to(device) network.load_state_dict(payload["strategy_network"]) network.eval() diff --git a/src/coolrl_lost_cities/games/classic/deep_cfr/trainer.py b/src/coolrl_lost_cities/games/classic/deep_cfr/trainer.py index aa676c5..b17d98d 100644 --- a/src/coolrl_lost_cities/games/classic/deep_cfr/trainer.py +++ b/src/coolrl_lost_cities/games/classic/deep_cfr/trainer.py @@ -68,31 +68,33 @@ class DeepCFRTrainer: device: str = "cpu", ) -> None: self.config = config or DeepCFRConfig() - self.game_config = game_config or LostCitiesConfig(seed=self.config.seed) + self.game_config = game_config or LostCitiesConfig(seed=self.config.run.seed) self.device = torch.device(device) - probe = GameState.new_game(self.game_config, seed=self.config.seed) + probe = GameState.new_game(self.game_config, seed=self.config.run.seed) self.input_dim = input_dim(probe) self.action_size = 2 * probe.config.hand_size + 1 + probe.config.n_colors - torch.manual_seed(self.config.seed) + torch.manual_seed(self.config.run.seed) self.advantage_networks = [ - DeepCFRMLP(self.input_dim, self.action_size, self.config.hidden_size).to(self.device) + DeepCFRMLP(self.input_dim, self.action_size, self.config.network.hidden_size).to( + self.device + ) for _ in range(2) ] self.strategy_network = DeepCFRMLP( - self.input_dim, self.action_size, self.config.hidden_size + self.input_dim, self.action_size, self.config.network.hidden_size ).to(self.device) self.advantage_optimizers = [ - torch.optim.Adam(network.parameters(), lr=self.config.learning_rate) + torch.optim.Adam(network.parameters(), lr=self.config.optimization.learning_rate) for network in self.advantage_networks ] self.strategy_optimizer = torch.optim.Adam( - self.strategy_network.parameters(), lr=self.config.learning_rate + self.strategy_network.parameters(), lr=self.config.optimization.learning_rate ) - self.advantage_memory = ReservoirMemory(self.config.advantage_memory_capacity) - self.strategy_memory = ReservoirMemory(self.config.strategy_memory_capacity) - self.rng = np.random.default_rng(self.config.seed + 101) + self.advantage_memory = ReservoirMemory(self.config.memory.advantage_capacity) + 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.metrics_path = self.run_dir / "metrics.jsonl" @@ -138,7 +140,7 @@ class DeepCFRTrainer: def run_iteration(self, iteration: int) -> IterationMetrics: self.iteration = iteration - if self.config.resolved_num_workers() > 1: + if self.config.traversal.resolved_num_workers() > 1: total_stats = self._run_traversals_parallel(iteration) else: total_stats = self._run_traversals_single_process(iteration) @@ -168,34 +170,34 @@ class DeepCFRTrainer: self.strategy_memory, device=self.device, action_size=self.action_size, - epsilon=self.config.regret_matching_epsilon, - strategy_sample_interval=self.config.strategy_sample_interval, - store_strategy_on_traverser_nodes=self.config.store_strategy_on_traverser_nodes, - store_strategy_on_opponent_nodes=self.config.store_strategy_on_opponent_nodes, - max_depth=self.config.max_traversal_depth, - max_nodes=self.config.max_nodes_per_traversal, - outcome_sampling_epsilon=self.config.outcome_sampling_epsilon, - outcome_sampling_value_clip=self.config.outcome_sampling_value_clip, - outcome_unsampled_regret=self.config.outcome_unsampled_regret, - cutoff_value_mode=self.config.cutoff_value_mode, - cutoff_rollouts=self.config.cutoff_rollouts, - cutoff_rollout_policy=self.config.cutoff_rollout_policy, - cutoff_rollout_max_steps=self.config.cutoff_rollout_max_steps, - opponent_policy=self.config.opponent_policy, + epsilon=self.config.traversal.regret_matching_epsilon, + strategy_sample_interval=self.config.traversal.strategy_sample_interval, + store_strategy_on_traverser_nodes=self.config.traversal.store_strategy_on_traverser_nodes, + store_strategy_on_opponent_nodes=self.config.traversal.store_strategy_on_opponent_nodes, + max_depth=self.config.traversal.max_depth, + max_nodes=self.config.traversal.max_nodes, + outcome_sampling_epsilon=self.config.traversal.outcome_sampling_epsilon, + outcome_sampling_value_clip=self.config.traversal.outcome_sampling_value_clip, + outcome_unsampled_regret=self.config.traversal.outcome_unsampled_regret, + cutoff_value_mode=self.config.traversal.cutoff_value_mode, + cutoff_rollouts=self.config.traversal.cutoff_rollouts, + cutoff_rollout_policy=self.config.traversal.cutoff_rollout_policy, + cutoff_rollout_max_steps=self.config.traversal.cutoff_rollout_max_steps, + opponent_policy=self.config.traversal.opponent_policy, league_advantage_networks=self._materialize_league_networks(), - self_play_anchor_probability=self.config.self_play_anchor_probability, - self_play_current_weight=self.config.self_play_current_weight, - self_play_recent_weight=self.config.self_play_recent_weight, - self_play_older_weight=self.config.self_play_older_weight, - self_play_anchor_weight=self.config.self_play_anchor_weight, - self_play_recent_window=self.config.self_play_recent_window, + self_play_anchor_probability=self.config.self_play.anchor_probability, + self_play_current_weight=self.config.self_play.current_weight, + self_play_recent_weight=self.config.self_play.recent_weight, + self_play_older_weight=self.config.self_play.older_weight, + self_play_anchor_weight=self.config.self_play.anchor_weight, + self_play_recent_window=self.config.self_play.recent_window, rng=self.rng, ) for network in self.advantage_networks: network.eval() - for traversal_index in range(self.config.traversals_per_iteration): + for traversal_index in range(self.config.traversal.traversals_per_iteration): for player in range(2): - seed = self.config.seed + iteration * 10_000 + traversal_index * 10 + player + seed = self.config.run.seed + iteration * 10_000 + traversal_index * 10 + player state = GameState.new_game(self.game_config, seed=seed) _, stats = traverser.traverse(state, player, iteration) total_stats.accumulate(stats) @@ -206,7 +208,7 @@ class DeepCFRTrainer: total_stats = TraversalStats() if not batches: return total_stats - max_workers = self.config.resolved_num_workers(len(batches)) + max_workers = self.config.traversal.resolved_num_workers(len(batches)) with ProcessPoolExecutor( max_workers=max_workers, mp_context=mp.get_context("spawn"), @@ -225,12 +227,12 @@ class DeepCFRTrainer: {name: value.detach().cpu() for name, value in network.state_dict().items()} for network in self.advantage_networks ] - chunk_size = max(1, self.config.traversal_worker_chunk_size) + chunk_size = max(1, self.config.traversal.worker_chunk_size) batch_index = 0 for player in range(2): seeds = [ - self.config.seed + iteration * 10_000 + index * 10 + player - for index in range(self.config.traversals_per_iteration) + self.config.run.seed + iteration * 10_000 + index * 10 + player + for index in range(self.config.traversal.traversals_per_iteration) ] for start in range(0, len(seeds), chunk_size): chunk = seeds[start : start + chunk_size] @@ -245,7 +247,7 @@ class DeepCFRTrainer: action_size=self.action_size, advantage_networks=network_payloads, league_advantage_networks=self._league_payloads(), - worker_seed=self.config.seed + iteration * 1_000_003 + batch_index, + worker_seed=self.config.run.seed + iteration * 1_000_003 + batch_index, ) ) batch_index += 1 @@ -258,14 +260,14 @@ class DeepCFRTrainer: ] def _maybe_record_self_play_snapshot(self, iteration: int) -> None: - if self.config.opponent_policy != "self_play_league": + if self.config.traversal.opponent_policy != "self_play_league": return - if self.config.self_play_max_snapshots <= 0: + if self.config.self_play.max_snapshots <= 0: return - if iteration % max(1, self.config.self_play_snapshot_every) != 0: + if iteration % max(1, self.config.self_play.snapshot_every) != 0: return self.self_play_league_snapshots.append(self._frozen_advantage_state_dicts()) - overflow = len(self.self_play_league_snapshots) - self.config.self_play_max_snapshots + overflow = len(self.self_play_league_snapshots) - self.config.self_play.max_snapshots if overflow > 0: del self.self_play_league_snapshots[:overflow] @@ -273,7 +275,7 @@ class DeepCFRTrainer: league: list[list[nn.Module]] = [] for snapshot in self.self_play_league_snapshots: networks = [ - DeepCFRMLP(self.input_dim, self.action_size, self.config.hidden_size).to( + DeepCFRMLP(self.input_dim, self.action_size, self.config.network.hidden_size).to( self.device ) for _ in range(2) @@ -291,7 +293,7 @@ class DeepCFRTrainer: self._start_run_logging() metrics: list[IterationMetrics] = [] start = self.iteration + 1 - stop = self.iteration + self.config.iterations + stop = self.iteration + self.config.run.iterations for iteration in range(start, stop + 1): started = time.perf_counter() item = self.run_iteration(iteration) @@ -299,7 +301,7 @@ class DeepCFRTrainer: metrics.append(item) self._append_metrics(item, elapsed) self._maybe_record_self_play_snapshot(iteration) - if self.config.save_every_iteration: + if self.config.checkpoint.save_every_iteration: checkpoint_dir = self.run_dir self.save_checkpoint(checkpoint_dir / f"iteration_{iteration:05d}.pt", item) self.save_checkpoint(checkpoint_dir / "latest.pt", item) @@ -316,7 +318,9 @@ class DeepCFRTrainer: if self.iteration == 0 and self.metrics_path.exists(): self.metrics_path.unlink() with self.log_path.open("a", encoding="utf-8") as handle: - handle.write(f"Deep CFR run start iteration={self.iteration} seed={self.config.seed}\n") + handle.write( + f"Deep CFR run start iteration={self.iteration} seed={self.config.run.seed}\n" + ) def _append_metrics(self, metrics: IterationMetrics, iteration_seconds: float) -> None: data = metrics.to_dict() @@ -332,18 +336,21 @@ class DeepCFRTrainer: ) def _evaluate(self, iteration: int) -> dict[str, float | int]: - if self.config.eval_every <= 0 or iteration % self.config.eval_every != 0: + if ( + self.config.evaluation.eval_every <= 0 + or iteration % self.config.evaluation.eval_every != 0 + ): return {} results: dict[str, float | int] = {} - for opponent in self.config.eval_opponents: + for opponent in self.config.evaluation.opponents: result = evaluate_strategy_network( self.strategy_network, self.game_config, - games=self.config.eval_games, - seed=self.config.seed + iteration * 1000, + games=self.config.evaluation.games, + seed=self.config.run.seed + iteration * 1000, opponent=opponent, device=self.device, - max_steps=self.config.eval_max_steps, + max_steps=self.config.evaluation.max_steps, ) for key, value in result.items(): results[f"eval_{opponent}_{key}"] = value @@ -365,7 +372,7 @@ class DeepCFRTrainer: return self._train_strategy(self.strategy_network, self.strategy_optimizer, samples) def _batch(self, samples: list[TrainingSample], step: int) -> list[TrainingSample]: - batch_size = min(self.config.batch_size, len(samples)) + batch_size = min(self.config.optimization.batch_size, len(samples)) offset = (step * batch_size) % len(samples) batch = samples[offset : offset + batch_size] if len(batch) < batch_size: @@ -401,9 +408,11 @@ class DeepCFRTrainer: ) -> float: last_loss = 0.0 network.train() - for _step in range(max(self.config.advantage_train_steps, 0)): + for _step in range(max(self.config.optimization.advantage_train_steps, 0)): x, y, legal = self._batch_tensors( - self.advantage_memory.sample(self.config.batch_size, self.rng, player=player) + self.advantage_memory.sample( + self.config.optimization.batch_size, self.rng, player=player + ) ) pred = network(x) diff = (pred - y).masked_fill(~legal, 0.0) @@ -422,9 +431,9 @@ class DeepCFRTrainer: ) -> float: last_loss = 0.0 network.train() - for _step in range(max(self.config.strategy_train_steps, 0)): + for _step in range(max(self.config.optimization.strategy_train_steps, 0)): x, y, legal = self._batch_tensors( - self.strategy_memory.sample(self.config.batch_size, self.rng) + self.strategy_memory.sample(self.config.optimization.batch_size, self.rng) ) logits = network(x).masked_fill(~legal, torch.finfo(torch.float32).min) log_probs = nn.functional.log_softmax(logits, dim=-1).masked_fill(~legal, 0.0) diff --git a/src/coolrl_lost_cities/games/classic/deep_cfr/workers.py b/src/coolrl_lost_cities/games/classic/deep_cfr/workers.py index ca0ea77..9138fb5 100644 --- a/src/coolrl_lost_cities/games/classic/deep_cfr/workers.py +++ b/src/coolrl_lost_cities/games/classic/deep_cfr/workers.py @@ -40,7 +40,8 @@ def run_traversal_worker_batch(batch: TraversalWorkerBatch) -> TraversalWorkerRe cfg = config_from_dict(batch.config) device = torch.device("cpu") networks = [ - DeepCFRMLP(batch.input_dim, batch.action_size, cfg.hidden_size).to(device) for _ in range(2) + DeepCFRMLP(batch.input_dim, batch.action_size, cfg.network.hidden_size).to(device) + for _ in range(2) ] for network, state_dict in zip(networks, batch.advantage_networks, strict=True): network.load_state_dict(state_dict) @@ -48,7 +49,7 @@ def run_traversal_worker_batch(batch: TraversalWorkerBatch) -> TraversalWorkerRe league_networks: list[list[torch.nn.Module]] = [] for snapshot in batch.league_advantage_networks: snapshot_networks = [ - DeepCFRMLP(batch.input_dim, batch.action_size, cfg.hidden_size).to(device) + DeepCFRMLP(batch.input_dim, batch.action_size, cfg.network.hidden_size).to(device) for _ in range(2) ] for network, state_dict in zip(snapshot_networks, snapshot, strict=True): @@ -63,27 +64,27 @@ def run_traversal_worker_batch(batch: TraversalWorkerBatch) -> TraversalWorkerRe strategy_memory, device=device, action_size=batch.action_size, - epsilon=cfg.regret_matching_epsilon, - strategy_sample_interval=cfg.strategy_sample_interval, - store_strategy_on_traverser_nodes=cfg.store_strategy_on_traverser_nodes, - store_strategy_on_opponent_nodes=cfg.store_strategy_on_opponent_nodes, - max_depth=cfg.max_traversal_depth, - max_nodes=cfg.max_nodes_per_traversal, - outcome_sampling_epsilon=cfg.outcome_sampling_epsilon, - outcome_sampling_value_clip=cfg.outcome_sampling_value_clip, - outcome_unsampled_regret=cfg.outcome_unsampled_regret, - cutoff_value_mode=cfg.cutoff_value_mode, - cutoff_rollouts=cfg.cutoff_rollouts, - cutoff_rollout_policy=cfg.cutoff_rollout_policy, - cutoff_rollout_max_steps=cfg.cutoff_rollout_max_steps, - opponent_policy=cfg.opponent_policy, + epsilon=cfg.traversal.regret_matching_epsilon, + strategy_sample_interval=cfg.traversal.strategy_sample_interval, + store_strategy_on_traverser_nodes=cfg.traversal.store_strategy_on_traverser_nodes, + store_strategy_on_opponent_nodes=cfg.traversal.store_strategy_on_opponent_nodes, + max_depth=cfg.traversal.max_depth, + max_nodes=cfg.traversal.max_nodes, + outcome_sampling_epsilon=cfg.traversal.outcome_sampling_epsilon, + outcome_sampling_value_clip=cfg.traversal.outcome_sampling_value_clip, + outcome_unsampled_regret=cfg.traversal.outcome_unsampled_regret, + cutoff_value_mode=cfg.traversal.cutoff_value_mode, + cutoff_rollouts=cfg.traversal.cutoff_rollouts, + cutoff_rollout_policy=cfg.traversal.cutoff_rollout_policy, + cutoff_rollout_max_steps=cfg.traversal.cutoff_rollout_max_steps, + opponent_policy=cfg.traversal.opponent_policy, league_advantage_networks=league_networks, - self_play_anchor_probability=cfg.self_play_anchor_probability, - self_play_current_weight=cfg.self_play_current_weight, - self_play_recent_weight=cfg.self_play_recent_weight, - self_play_older_weight=cfg.self_play_older_weight, - self_play_anchor_weight=cfg.self_play_anchor_weight, - self_play_recent_window=cfg.self_play_recent_window, + self_play_anchor_probability=cfg.self_play.anchor_probability, + self_play_current_weight=cfg.self_play.current_weight, + self_play_recent_weight=cfg.self_play.recent_weight, + self_play_older_weight=cfg.self_play.older_weight, + self_play_anchor_weight=cfg.self_play.anchor_weight, + self_play_recent_window=cfg.self_play.recent_window, rng=np.random.default_rng(batch.worker_seed), ) game_config = LostCitiesConfig(**batch.game_config) diff --git a/tests/games/classic/test_deep_cfr_trainer.py b/tests/games/classic/test_deep_cfr_trainer.py index eb3473a..90a7540 100644 --- a/tests/games/classic/test_deep_cfr_trainer.py +++ b/tests/games/classic/test_deep_cfr_trainer.py @@ -7,25 +7,43 @@ from coolrl_lost_cities.games.classic.deep_cfr.benchmark import ( benchmark_traversal, benchmark_traversal_modes, ) -from coolrl_lost_cities.games.classic.deep_cfr.config import DeepCFRConfig +from coolrl_lost_cities.games.classic.deep_cfr.config import DeepCFRConfig, load_config from coolrl_lost_cities.games.classic.deep_cfr.memory import ReservoirMemory, TrainingSample from coolrl_lost_cities.games.classic.deep_cfr.trainer import DeepCFRTrainer from coolrl_lost_cities.games.classic.deep_cfr.traverser import DeepCFRTraverser +def _deep_cfr_config(data: dict) -> DeepCFRConfig: + return DeepCFRConfig.model_validate(data) + + +def test_deep_cfr_loads_smoke_yaml_config() -> None: + config = load_config("configs/deep_cfr/smoke.yaml") + + assert config.run.iterations == 1 + assert config.network.hidden_size == 16 + assert config.traversal.traversals_per_iteration == 1 + assert config.checkpoint.directory == "runs/deep_cfr/smoke" + + def test_deep_cfr_trainer_smoke_run() -> None: trainer = DeepCFRTrainer( - DeepCFRConfig( - iterations=1, - traversals_per_iteration=1, - max_traversal_depth=3, - max_nodes_per_traversal=64, - advantage_train_steps=1, - strategy_train_steps=1, - batch_size=2, - hidden_size=16, - seed=23, - save_every_iteration=False, + _deep_cfr_config( + { + "run": {"iterations": 1, "seed": 23}, + "network": {"hidden_size": 16}, + "traversal": { + "traversals_per_iteration": 1, + "max_depth": 3, + "max_nodes": 64, + }, + "optimization": { + "advantage_train_steps": 1, + "strategy_train_steps": 1, + "batch_size": 2, + }, + "checkpoint": {"save_every_iteration": False}, + } ), LostCitiesConfig(seed=23), ) @@ -43,15 +61,18 @@ def test_deep_cfr_trainer_smoke_run() -> None: def test_deep_cfr_recursive_traverser_restores_state_and_collects_samples() -> None: trainer = DeepCFRTrainer( - DeepCFRConfig( - iterations=1, - traversals_per_iteration=1, - max_traversal_depth=2, - max_nodes_per_traversal=32, - batch_size=2, - hidden_size=16, - seed=29, - save_every_iteration=False, + _deep_cfr_config( + { + "run": {"iterations": 1, "seed": 29}, + "network": {"hidden_size": 16}, + "traversal": { + "traversals_per_iteration": 1, + "max_depth": 2, + "max_nodes": 32, + }, + "optimization": {"batch_size": 2}, + "checkpoint": {"save_every_iteration": False}, + } ), LostCitiesConfig(seed=29), ) @@ -85,22 +106,25 @@ def test_deep_cfr_recursive_traverser_restores_state_and_collects_samples() -> N def test_deep_cfr_traverser_supports_outcome_sampling_and_rollout_cutoffs() -> None: trainer = DeepCFRTrainer( - DeepCFRConfig( - iterations=1, - traversals_per_iteration=1, - max_traversal_depth=1, - max_nodes_per_traversal=32, - outcome_sampling_epsilon=0.25, - outcome_sampling_value_clip=100.0, - outcome_unsampled_regret="zero", - cutoff_value_mode="random_rollout", - cutoff_rollouts=2, - cutoff_rollout_policy="random", - cutoff_rollout_max_steps=16, - batch_size=2, - hidden_size=16, - seed=31, - save_every_iteration=False, + _deep_cfr_config( + { + "run": {"iterations": 1, "seed": 31}, + "network": {"hidden_size": 16}, + "traversal": { + "traversals_per_iteration": 1, + "max_depth": 1, + "max_nodes": 32, + "outcome_sampling_epsilon": 0.25, + "outcome_sampling_value_clip": 100.0, + "outcome_unsampled_regret": "zero", + "cutoff_value_mode": "random_rollout", + "cutoff_rollouts": 2, + "cutoff_rollout_policy": "random", + "cutoff_rollout_max_steps": 16, + }, + "optimization": {"batch_size": 2}, + "checkpoint": {"save_every_iteration": False}, + } ), LostCitiesConfig(seed=31), ) @@ -161,19 +185,22 @@ def test_reservoir_memory_caps_samples_and_filters_player_batches() -> None: def test_deep_cfr_trainer_saves_loads_and_evaluates_checkpoint(tmp_path) -> None: checkpoint_dir = tmp_path / "deep_cfr" trainer = DeepCFRTrainer( - DeepCFRConfig( - iterations=1, - traversals_per_iteration=1, - max_traversal_depth=2, - max_nodes_per_traversal=32, - batch_size=2, - hidden_size=16, - seed=41, - checkpoint_dir=str(checkpoint_dir), - save_every_iteration=True, - eval_every=1, - eval_games=2, - eval_opponents=("random",), + _deep_cfr_config( + { + "run": {"iterations": 1, "seed": 41}, + "network": {"hidden_size": 16}, + "traversal": { + "traversals_per_iteration": 1, + "max_depth": 2, + "max_nodes": 32, + }, + "optimization": {"batch_size": 2}, + "checkpoint": { + "directory": str(checkpoint_dir), + "save_every_iteration": True, + }, + "evaluation": {"eval_every": 1, "games": 2, "opponents": ("random",)}, + } ), LostCitiesConfig(seed=41), ) @@ -181,11 +208,15 @@ def test_deep_cfr_trainer_saves_loads_and_evaluates_checkpoint(tmp_path) -> None metrics = trainer.train() latest = checkpoint_dir / "latest.pt" restored = DeepCFRTrainer( - DeepCFRConfig( - hidden_size=16, - seed=41, - checkpoint_dir=str(checkpoint_dir), - save_every_iteration=False, + _deep_cfr_config( + { + "run": {"seed": 41}, + "network": {"hidden_size": 16}, + "checkpoint": { + "directory": str(checkpoint_dir), + "save_every_iteration": False, + }, + } ), LostCitiesConfig(seed=41), ) @@ -202,18 +233,23 @@ def test_deep_cfr_trainer_saves_loads_and_evaluates_checkpoint(tmp_path) -> None def test_deep_cfr_trainer_multiprocessing_smoke_run(tmp_path) -> None: trainer = DeepCFRTrainer( - DeepCFRConfig( - iterations=1, - traversals_per_iteration=2, - max_traversal_depth=2, - max_nodes_per_traversal=32, - batch_size=2, - hidden_size=16, - seed=43, - checkpoint_dir=str(tmp_path / "mp"), - save_every_iteration=False, - num_workers=2, - traversal_worker_chunk_size=1, + _deep_cfr_config( + { + "run": {"iterations": 1, "seed": 43}, + "network": {"hidden_size": 16}, + "traversal": { + "traversals_per_iteration": 2, + "max_depth": 2, + "max_nodes": 32, + "num_workers": 2, + "worker_chunk_size": 1, + }, + "optimization": {"batch_size": 2}, + "checkpoint": { + "directory": str(tmp_path / "mp"), + "save_every_iteration": False, + }, + } ), LostCitiesConfig(seed=43), ) @@ -226,24 +262,26 @@ def test_deep_cfr_trainer_multiprocessing_smoke_run(tmp_path) -> None: def test_deep_cfr_traversal_benchmark_smoke() -> None: result = benchmark_traversal( - DeepCFRConfig( - traversals_per_iteration=1, - max_traversal_depth=2, - hidden_size=16, - save_every_iteration=False, - seed=47, + _deep_cfr_config( + { + "run": {"seed": 47}, + "network": {"hidden_size": 16}, + "traversal": {"traversals_per_iteration": 1, "max_depth": 2}, + "checkpoint": {"save_every_iteration": False}, + } ) ) assert result["traversal_nodes"] > 0 assert result["nodes_per_second"] > 0.0 comparison = benchmark_traversal_modes( - DeepCFRConfig( - traversals_per_iteration=1, - max_traversal_depth=2, - hidden_size=16, - save_every_iteration=False, - seed=48, + _deep_cfr_config( + { + "run": {"seed": 48}, + "network": {"hidden_size": 16}, + "traversal": {"traversals_per_iteration": 1, "max_depth": 2}, + "checkpoint": {"save_every_iteration": False}, + } ) ) assert comparison["summary"]["speedup"] > 0.0 @@ -251,20 +289,27 @@ def test_deep_cfr_traversal_benchmark_smoke() -> None: def test_deep_cfr_self_play_league_records_snapshots(tmp_path) -> None: trainer = DeepCFRTrainer( - DeepCFRConfig( - iterations=2, - traversals_per_iteration=1, - max_traversal_depth=2, - max_nodes_per_traversal=32, - batch_size=2, - hidden_size=16, - seed=53, - checkpoint_dir=str(tmp_path / "league"), - save_every_iteration=False, - opponent_policy="self_play_league", - self_play_snapshot_every=1, - self_play_max_snapshots=1, - self_play_anchor_probability=1.0, + _deep_cfr_config( + { + "run": {"iterations": 2, "seed": 53}, + "network": {"hidden_size": 16}, + "traversal": { + "traversals_per_iteration": 1, + "max_depth": 2, + "max_nodes": 32, + "opponent_policy": "self_play_league", + }, + "self_play": { + "snapshot_every": 1, + "max_snapshots": 1, + "anchor_probability": 1.0, + }, + "optimization": {"batch_size": 2}, + "checkpoint": { + "directory": str(tmp_path / "league"), + "save_every_iteration": False, + }, + } ), LostCitiesConfig(seed=53), ) @@ -277,24 +322,31 @@ def test_deep_cfr_self_play_league_records_snapshots(tmp_path) -> None: def test_deep_cfr_weighted_self_play_league_uses_snapshot_bucket(tmp_path) -> None: trainer = DeepCFRTrainer( - DeepCFRConfig( - iterations=2, - traversals_per_iteration=1, - max_traversal_depth=2, - max_nodes_per_traversal=32, - batch_size=2, - hidden_size=16, - seed=59, - checkpoint_dir=str(tmp_path / "weighted-league"), - save_every_iteration=False, - opponent_policy="self_play_league", - self_play_snapshot_every=1, - self_play_max_snapshots=2, - self_play_current_weight=0.0, - self_play_recent_weight=1.0, - self_play_older_weight=0.0, - self_play_anchor_weight=0.0, - self_play_recent_window=1, + _deep_cfr_config( + { + "run": {"iterations": 2, "seed": 59}, + "network": {"hidden_size": 16}, + "traversal": { + "traversals_per_iteration": 1, + "max_depth": 2, + "max_nodes": 32, + "opponent_policy": "self_play_league", + }, + "self_play": { + "snapshot_every": 1, + "max_snapshots": 2, + "current_weight": 0.0, + "recent_weight": 1.0, + "older_weight": 0.0, + "anchor_weight": 0.0, + "recent_window": 1, + }, + "optimization": {"batch_size": 2}, + "checkpoint": { + "directory": str(tmp_path / "weighted-league"), + "save_every_iteration": False, + }, + } ), LostCitiesConfig(seed=59), ) diff --git a/uv.lock b/uv.lock index 5f148cd..dde3150 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -26,6 +35,8 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "numpy" }, + { name = "pydantic" }, + { name = "pyyaml" }, { name = "torch" }, ] @@ -46,8 +57,10 @@ dev = [ [package.metadata] requires-dist = [ { name = "numpy", specifier = ">=1.26.0" }, + { name = "pydantic", specifier = ">=2.7" }, { name = "pygame", marker = "extra == 'gui'", specifier = ">=2.6.1" }, { name = "pygame-gui", marker = "extra == 'gui'", specifier = ">=0.6.14" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "torch", specifier = ">=2.3.0" }, ] provides-extras = ["gui"] @@ -594,6 +607,123 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + [[package]] name = "pygame" version = "2.6.1" @@ -898,6 +1028,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "virtualenv" version = "21.3.1"