From 94e9ac1854720982154ac8738a292028efe0ca39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EC=8B=9C=EC=9B=90?= Date: Sun, 5 Jul 2026 06:33:22 +0900 Subject: [PATCH] Add JAX PPO league self-play v1 --- configs/jax_ppo/league-smoke.yaml | 77 ++ configs/jax_ppo/league-v1.yaml | 77 ++ .../league-v1-2026-07-05-summary.jsonl | 18 + docs/reports/league-v1-2026-07-05.md | 53 + docs/reports/league-v1-elo-estimate.png | Bin 0 -> 26793 bytes docs/reports/league-v1-exploiter-win-rate.png | Bin 0 -> 13835 bytes docs/reports/league-v1-opened-colors.png | Bin 0 -> 24871 bytes src/lost_cities_jax/league.py | 905 ++++++++++++++++++ src/lost_cities_jax/ppo.py | 244 ++++- tests/lost_cities_jax/test_league.py | 109 +++ 10 files changed, 1482 insertions(+), 1 deletion(-) create mode 100644 configs/jax_ppo/league-smoke.yaml create mode 100644 configs/jax_ppo/league-v1.yaml create mode 100644 docs/reports/league-v1-2026-07-05-summary.jsonl create mode 100644 docs/reports/league-v1-2026-07-05.md create mode 100644 docs/reports/league-v1-elo-estimate.png create mode 100644 docs/reports/league-v1-exploiter-win-rate.png create mode 100644 docs/reports/league-v1-opened-colors.png create mode 100644 src/lost_cities_jax/league.py create mode 100644 tests/lost_cities_jax/test_league.py diff --git a/configs/jax_ppo/league-smoke.yaml b/configs/jax_ppo/league-smoke.yaml new file mode 100644 index 0000000..1279cd6 --- /dev/null +++ b/configs/jax_ppo/league-smoke.yaml @@ -0,0 +1,77 @@ +base_config: configs/jax_ppo/ladder-v2-expert.yaml +warm_start_checkpoint: /mnt/2tbhdd/coolrl-lost-cities-artifacts/ladder-v2/2026-07-05_013223_jax-ppo-ladder-v2-expert/latest + +run: + experiment_name: jax-ppo-league-smoke + seed: 20260705 + artifact_root: /mnt/2tbhdd/coolrl-lost-cities-artifacts/league-smoke + +league: + cycles: 1 + league_updates_per_cycle: 1 + snapshot_interval_updates: 1 + mirror_probability: 0.5 + uniform_mix: 0.1 + stalling_anchor_floor: 0.02 + stalling_anchor_cap: 0.05 + pool_max_size: 8 + max_active_pool_members: 7 + exploiter_member_fraction_cap: 0.3333333333333333 + wall_clock_hours: 1.0 + success_exploiter_win_rate: 0.60 + stagnation_window: 5 + stagnation_min_delta: 0.02 + +evaluation: + games: 2 + duplicate: true + shuffle_bank_seed: 20260704 + batch_games: 2 + recent_snapshot_evals: 1 + +exploiter: + config: configs/jax_ppo/ladder-v2-exploiter.yaml + updates: 1 + +guards: + expert_ci_low: -1000.0 + max_steps_rate: 1.0 + +tracking: + tracked_summary_path: /mnt/2tbhdd/coolrl-lost-cities-artifacts/league-smoke/summary.jsonl + report_path: /mnt/2tbhdd/coolrl-lost-cities-artifacts/league-smoke/report.md + +anchors: + - name: discard_only + kind: static + policy_name: discard_only + anchor: true + stalling: true + - name: heuristic_balanced + kind: static + policy_name: heuristic_balanced + anchor: true + - name: heuristic_cautious + kind: static + policy_name: heuristic_cautious + anchor: true + stalling: true + - name: heuristic_expert + kind: static + policy_name: heuristic_expert + anchor: true + - name: ladder_v2_gate1_discard + kind: checkpoint + config: configs/jax_ppo/ladder-v2-discard-only.yaml + checkpoint: /mnt/2tbhdd/coolrl-lost-cities-artifacts/ladder-v2/2026-07-05_010429_jax-ppo-ladder-v2-discard-only/latest + anchor: true + - name: ladder_v2_gate2_balanced + kind: checkpoint + config: configs/jax_ppo/ladder-v2-balanced.yaml + checkpoint: /mnt/2tbhdd/coolrl-lost-cities-artifacts/ladder-v2/2026-07-05_011827_jax-ppo-ladder-v2-balanced/latest + anchor: true + - name: ladder_v2_gate3_expert + kind: checkpoint + config: configs/jax_ppo/ladder-v2-expert.yaml + checkpoint: /mnt/2tbhdd/coolrl-lost-cities-artifacts/ladder-v2/2026-07-05_013223_jax-ppo-ladder-v2-expert/latest + anchor: true diff --git a/configs/jax_ppo/league-v1.yaml b/configs/jax_ppo/league-v1.yaml new file mode 100644 index 0000000..0265e0c --- /dev/null +++ b/configs/jax_ppo/league-v1.yaml @@ -0,0 +1,77 @@ +base_config: configs/jax_ppo/ladder-v2-expert.yaml +warm_start_checkpoint: /mnt/2tbhdd/coolrl-lost-cities-artifacts/ladder-v2/2026-07-05_013223_jax-ppo-ladder-v2-expert/latest + +run: + experiment_name: jax-ppo-league-v1 + seed: 20260705 + artifact_root: /mnt/2tbhdd/coolrl-lost-cities-artifacts/league + +league: + cycles: 5 + league_updates_per_cycle: 500 + snapshot_interval_updates: 250 + mirror_probability: 0.5 + uniform_mix: 0.1 + stalling_anchor_floor: 0.02 + stalling_anchor_cap: 0.05 + pool_max_size: 24 + max_active_pool_members: 12 + exploiter_member_fraction_cap: 0.3333333333333333 + wall_clock_hours: 6.0 + success_exploiter_win_rate: 0.60 + stagnation_window: 5 + stagnation_min_delta: 0.02 + +evaluation: + games: 10000 + duplicate: true + shuffle_bank_seed: 20260704 + batch_games: 8192 + recent_snapshot_evals: 3 + +exploiter: + config: configs/jax_ppo/ladder-v2-exploiter.yaml + updates: 250 + +guards: + expert_ci_low: 0.0 + max_steps_rate: 0.02 + +tracking: + tracked_summary_path: docs/reports/league-v1-2026-07-05-summary.jsonl + report_path: docs/reports/league-v1-2026-07-05.md + +anchors: + - name: discard_only + kind: static + policy_name: discard_only + anchor: true + stalling: true + - name: heuristic_balanced + kind: static + policy_name: heuristic_balanced + anchor: true + - name: heuristic_cautious + kind: static + policy_name: heuristic_cautious + anchor: true + stalling: true + - name: heuristic_expert + kind: static + policy_name: heuristic_expert + anchor: true + - name: ladder_v2_gate1_discard + kind: checkpoint + config: configs/jax_ppo/ladder-v2-discard-only.yaml + checkpoint: /mnt/2tbhdd/coolrl-lost-cities-artifacts/ladder-v2/2026-07-05_010429_jax-ppo-ladder-v2-discard-only/latest + anchor: true + - name: ladder_v2_gate2_balanced + kind: checkpoint + config: configs/jax_ppo/ladder-v2-balanced.yaml + checkpoint: /mnt/2tbhdd/coolrl-lost-cities-artifacts/ladder-v2/2026-07-05_011827_jax-ppo-ladder-v2-balanced/latest + anchor: true + - name: ladder_v2_gate3_expert + kind: checkpoint + config: configs/jax_ppo/ladder-v2-expert.yaml + checkpoint: /mnt/2tbhdd/coolrl-lost-cities-artifacts/ladder-v2/2026-07-05_013223_jax-ppo-ladder-v2-expert/latest + anchor: true diff --git a/docs/reports/league-v1-2026-07-05-summary.jsonl b/docs/reports/league-v1-2026-07-05-summary.jsonl new file mode 100644 index 0000000..7e64ff1 --- /dev/null +++ b/docs/reports/league-v1-2026-07-05-summary.jsonl @@ -0,0 +1,18 @@ +{"checkpoint": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/snapshots/cycle_01_update_000250", "cycle": 1, "elo_estimate": 914.3801500956258, "event": "snapshot_eval", "games": 20000, "losses": 96.0, "max_steps_rate": 0.0, "mean_game_length": 71.11445, "mean_score_diff": 125.14285, "opened_colors_per_game": 4.4374, "opponent": "discard_only", "opponent_kind": "static", "play_action_rate": 0.6509964599542043, "positive_expeditions_per_game": 2.9703, "score_diff_ci95_high": 125.74118659681464, "score_diff_ci95_low": 124.54451340318535, "snapshot": "league_c01_u000250", "ties": 7.0, "update": 250, "wilson_high": 0.9957514261405391, "wilson_low": 0.9937585157747342, "win_rate": 0.99485, "wins": 19897.0} +{"checkpoint": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/snapshots/cycle_01_update_000250", "cycle": 1, "elo_estimate": 637.8568047136434, "event": "snapshot_eval", "games": 20000, "losses": 479.0, "max_steps_rate": 0.0231, "mean_game_length": 84.2736, "mean_score_diff": 100.8997, "opened_colors_per_game": 4.8582, "opponent": "heuristic_balanced", "opponent_kind": "static", "play_action_rate": 0.49003797546566286, "positive_expeditions_per_game": 2.6421, "score_diff_ci95_high": 101.6030587557229, "score_diff_ci95_low": 100.1963412442771, "snapshot": "league_c01_u000250", "ties": 17.0, "update": 250, "wilson_high": 0.9772657590483588, "wilson_low": 0.9729517298839193, "win_rate": 0.9752, "wins": 19504.0} +{"checkpoint": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/snapshots/cycle_01_update_000250", "cycle": 1, "elo_estimate": 522.2171364943674, "event": "snapshot_eval", "games": 20000, "losses": 931.0, "max_steps_rate": 0.1264, "mean_game_length": 139.4069, "mean_score_diff": 116.66435, "opened_colors_per_game": 4.9623, "opponent": "heuristic_cautious", "opponent_kind": "static", "play_action_rate": 0.3169406240961486, "positive_expeditions_per_game": 3.0465, "score_diff_ci95_high": 117.46585342388337, "score_diff_ci95_low": 115.86284657611662, "snapshot": "league_c01_u000250", "ties": 12.0, "update": 250, "wilson_high": 0.9557015978586921, "wilson_low": 0.9498244750852939, "win_rate": 0.95285, "wins": 19057.0} +{"checkpoint": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/snapshots/cycle_01_update_000250", "cycle": 1, "elo_estimate": 186.84131209092277, "event": "snapshot_eval", "games": 20000, "losses": 4910.0, "max_steps_rate": 0.0, "mean_game_length": 56.74285, "mean_score_diff": 26.7149, "opened_colors_per_game": 4.5478, "opponent": "heuristic_expert", "opponent_kind": "static", "play_action_rate": 0.6407205946284102, "positive_expeditions_per_game": 2.20505, "score_diff_ci95_high": 27.262035512864674, "score_diff_ci95_low": 26.167764487135326, "snapshot": "league_c01_u000250", "ties": 177.0, "update": 250, "wilson_high": 0.7516379759261593, "wilson_low": 0.7395676767594765, "win_rate": 0.74565, "wins": 14913.0} +{"checkpoint": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/snapshots/cycle_01_update_000250", "cycle": 1, "elo_estimate": 816.73245801838, "event": "snapshot_eval", "games": 20000, "losses": 178.0, "max_steps_rate": 0.03575, "mean_game_length": 124.3454, "mean_score_diff": 163.52095, "opened_colors_per_game": 4.99725, "opponent": "ladder_v2_gate1_discard", "opponent_kind": "checkpoint", "play_action_rate": 0.36905599758881236, "positive_expeditions_per_game": 3.6202, "score_diff_ci95_high": 164.3139031956684, "score_diff_ci95_low": 162.7279968043316, "snapshot": "league_c01_u000250", "ties": 2.0, "update": 250, "wilson_high": 0.9922178307682814, "wilson_low": 0.9895935898246238, "win_rate": 0.991, "wins": 19820.0} +{"checkpoint": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/snapshots/cycle_01_update_000250", "cycle": 1, "elo_estimate": 90.70646999257946, "event": "snapshot_eval", "games": 20000, "losses": 7275.0, "max_steps_rate": 0.0, "mean_game_length": 49.7744, "mean_score_diff": 16.1786, "opened_colors_per_game": 4.7712, "opponent": "ladder_v2_gate2_balanced", "opponent_kind": "checkpoint", "play_action_rate": 0.7223761262392399, "positive_expeditions_per_game": 2.14855, "score_diff_ci95_high": 16.80698121804704, "score_diff_ci95_low": 15.550218781952958, "snapshot": "league_c01_u000250", "ties": 172.0, "update": 250, "wilson_high": 0.6343247760445404, "wilson_low": 0.6209261971503363, "win_rate": 0.62765, "wins": 12553.0} +{"checkpoint": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/snapshots/cycle_01_update_000250", "cycle": 1, "elo_estimate": 246.30450760214026, "event": "snapshot_eval", "games": 20000, "losses": 3778.0, "max_steps_rate": 5e-05, "mean_game_length": 57.6601, "mean_score_diff": 43.7116, "opened_colors_per_game": 4.9119, "opponent": "ladder_v2_gate3_expert", "opponent_kind": "checkpoint", "play_action_rate": 0.6936249212033888, "positive_expeditions_per_game": 2.57955, "score_diff_ci95_high": 44.41070179719779, "score_diff_ci95_low": 43.012498202802206, "snapshot": "league_c01_u000250", "ties": 122.0, "update": 250, "wilson_high": 0.8104321748965465, "wilson_low": 0.7994506831092297, "win_rate": 0.805, "wins": 16100.0} +{"checkpoint": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/snapshots/cycle_01_update_000500", "cycle": 1, "elo_estimate": 630.8113677090081, "event": "snapshot_eval", "games": 20000, "losses": 494.0, "max_steps_rate": 0.0, "mean_game_length": 69.40915, "mean_score_diff": 107.6151, "opened_colors_per_game": 4.88455, "opponent": "discard_only", "opponent_kind": "static", "play_action_rate": 0.6697188054808169, "positive_expeditions_per_game": 2.9962, "score_diff_ci95_high": 108.30476814184044, "score_diff_ci95_low": 106.92543185815956, "snapshot": "league_c01_u000500", "ties": 22.0, "update": 500, "wilson_high": 0.9763077989189339, "wilson_low": 0.9719100740854563, "win_rate": 0.9742, "wins": 19484.0} +{"checkpoint": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/snapshots/cycle_01_update_000500", "cycle": 1, "elo_estimate": 560.1723021956866, "event": "snapshot_eval", "games": 20000, "losses": 740.0, "max_steps_rate": 0.0271, "mean_game_length": 82.88495, "mean_score_diff": 88.5699, "opened_colors_per_game": 4.93005, "opponent": "heuristic_balanced", "opponent_kind": "static", "play_action_rate": 0.5006923105759661, "positive_expeditions_per_game": 2.53425, "score_diff_ci95_high": 89.24524287663027, "score_diff_ci95_low": 87.89455712336974, "snapshot": "league_c01_u000500", "ties": 25.0, "update": 500, "wilson_high": 0.9643207034494167, "wilson_low": 0.9590019512527707, "win_rate": 0.96175, "wins": 19235.0} +{"checkpoint": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/snapshots/cycle_01_update_000500", "cycle": 1, "elo_estimate": 455.44923699476067, "event": "snapshot_eval", "games": 20000, "losses": 1336.0, "max_steps_rate": 0.16445, "mean_game_length": 148.75985, "mean_score_diff": 94.1284, "opened_colors_per_game": 4.9832, "opponent": "heuristic_cautious", "opponent_kind": "static", "play_action_rate": 0.28929498432939504, "positive_expeditions_per_game": 2.8439, "score_diff_ci95_high": 94.9311528056073, "score_diff_ci95_low": 93.3256471943927, "snapshot": "league_c01_u000500", "ties": 19.0, "update": 500, "wilson_high": 0.9356506507735038, "wilson_low": 0.9286833340559937, "win_rate": 0.93225, "wins": 18645.0} +{"checkpoint": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/snapshots/cycle_01_update_000500", "cycle": 1, "elo_estimate": 111.06146709621818, "event": "snapshot_eval", "games": 20000, "losses": 6672.0, "max_steps_rate": 0.0, "mean_game_length": 53.33205, "mean_score_diff": 14.2458, "opened_colors_per_game": 4.8409, "opponent": "heuristic_expert", "opponent_kind": "static", "play_action_rate": 0.6926969797194702, "positive_expeditions_per_game": 2.16835, "score_diff_ci95_high": 14.735340147409243, "score_diff_ci95_low": 13.756259852590755, "snapshot": "league_c01_u000500", "ties": 236.0, "update": 500, "wilson_high": 0.6611596974601753, "wilson_low": 0.647980924991277, "win_rate": 0.6546, "wins": 13092.0} +{"checkpoint": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/snapshots/cycle_01_update_000500", "cycle": 1, "elo_estimate": 683.7735272455216, "event": "snapshot_eval", "games": 20000, "losses": 373.0, "max_steps_rate": 0.0419, "mean_game_length": 123.1991, "mean_score_diff": 141.3215, "opened_colors_per_game": 4.99905, "opponent": "ladder_v2_gate1_discard", "opponent_kind": "checkpoint", "play_action_rate": 0.3599053985607506, "positive_expeditions_per_game": 3.56165, "score_diff_ci95_high": 142.1693389362133, "score_diff_ci95_low": 140.47366106378666, "snapshot": "league_c01_u000500", "ties": 10.0, "update": 500, "wilson_high": 0.9826591308155324, "wilson_low": 0.9788561881093119, "win_rate": 0.98085, "wins": 19617.0} +{"checkpoint": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/snapshots/cycle_01_update_000500", "cycle": 1, "elo_estimate": 118.72098430949596, "event": "snapshot_eval", "games": 20000, "losses": 6562.0, "max_steps_rate": 5e-05, "mean_game_length": 49.765, "mean_score_diff": 20.6296, "opened_colors_per_game": 4.9362, "opponent": "ladder_v2_gate2_balanced", "opponent_kind": "checkpoint", "play_action_rate": 0.771107881039547, "positive_expeditions_per_game": 2.2327, "score_diff_ci95_high": 21.259362634675494, "score_diff_ci95_low": 19.999837365324506, "snapshot": "league_c01_u000500", "ties": 148.0, "update": 500, "wilson_high": 0.6710116102233363, "wilson_low": 0.6579252099142052, "win_rate": 0.6645, "wins": 13290.0} +{"checkpoint": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/snapshots/cycle_01_update_000500", "cycle": 1, "elo_estimate": 229.5355020840115, "event": "snapshot_eval", "games": 20000, "losses": 4093.0, "max_steps_rate": 5e-05, "mean_game_length": 55.7303, "mean_score_diff": 38.96295, "opened_colors_per_game": 4.96905, "opponent": "ladder_v2_gate3_expert", "opponent_kind": "checkpoint", "play_action_rate": 0.7273133036132009, "positive_expeditions_per_game": 2.5661, "score_diff_ci95_high": 39.63449150676593, "score_diff_ci95_low": 38.291408493234066, "snapshot": "league_c01_u000500", "ties": 119.0, "update": 500, "wilson_high": 0.7949949671932197, "wilson_low": 0.7836938823375068, "win_rate": 0.7894, "wins": 15788.0} +{"checkpoint": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/snapshots/cycle_01_update_000500", "cycle": 1, "elo_estimate": 30.163477370688103, "event": "snapshot_eval", "games": 20000, "losses": 8961.0, "max_steps_rate": 0.0001, "mean_game_length": 50.86105, "mean_score_diff": 5.5848, "opened_colors_per_game": 4.94555, "opponent": "league_c01_u000250", "opponent_kind": "checkpoint", "play_action_rate": 0.7468741704926414, "positive_expeditions_per_game": 2.3624, "score_diff_ci95_high": 6.236320508695035, "score_diff_ci95_low": 4.933279491304966, "snapshot": "league_c01_u000500", "ties": 173.0, "update": 500, "wilson_high": 0.5501945130045903, "wilson_low": 0.536388856672951, "win_rate": 0.5433, "wins": 10866.0} +{"cycle": 1, "eval_json": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/exploiters/2026-07-05_060727_jax-ppo-league-v1-cycle-1-exploiter/eval_vs_league_c01_u000250_duplicate.json", "event": "exploiter_eval", "exploiter": "exploiter_c1", "exploiter_checkpoint": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/exploiters/2026-07-05_060727_jax-ppo-league-v1-cycle-1-exploiter/latest", "games": 20000, "losses": 9759.0, "max_steps_rate": 0.0, "mean_game_length": 48.76485, "mean_score_diff": 0.6818, "opened_colors_per_game": 4.99735, "play_action_rate": 0.7938232266689632, "positive_expeditions_per_game": 2.5895, "score_diff_ci95_high": 1.283273913015642, "score_diff_ci95_low": 0.08032608698435773, "target": "league_c01_u000250", "target_checkpoint": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/snapshots/cycle_01_update_000250", "ties": 196.0, "wilson_high": 0.5091783515083876, "wilson_low": 0.4953207843293599, "win_rate": 0.50225, "wins": 10045.0} +{"cycle": 1, "eval_json": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/exploiters/2026-07-05_060727_jax-ppo-league-v1-cycle-1-exploiter/eval_vs_league_c01_u000500_duplicate.json", "event": "exploiter_final_eval", "exploiter": "exploiter_c1", "exploiter_checkpoint": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/exploiters/2026-07-05_060727_jax-ppo-league-v1-cycle-1-exploiter/latest", "games": 20000, "losses": 10376.0, "max_steps_rate": 0.0, "mean_game_length": 48.23185, "mean_score_diff": -2.63775, "opened_colors_per_game": 4.9971, "play_action_rate": 0.797814966505273, "positive_expeditions_per_game": 2.569, "score_diff_ci95_high": -2.039857230939478, "score_diff_ci95_low": -3.2356427690605223, "target": "league_c01_u000500", "target_checkpoint": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/snapshots/cycle_01_update_000500", "ties": 205.0, "wilson_high": 0.4778727301883191, "wilson_low": 0.46403842710654053, "win_rate": 0.47095, "wins": 9419.0} +{"cycles_completed": 1, "event": "league_complete", "final_checkpoint": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/snapshots/cycle_01_update_000500", "run_dir": "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1", "stop_reason": "success", "updates": 500} diff --git a/docs/reports/league-v1-2026-07-05.md b/docs/reports/league-v1-2026-07-05.md new file mode 100644 index 0000000..140a77e --- /dev/null +++ b/docs/reports/league-v1-2026-07-05.md @@ -0,0 +1,53 @@ +# League v1 Report - 2026-07-05 + +**Status:** `success`. +**Run dir:** `/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1`. +**Final checkpoint:** `/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/snapshots/cycle_01_update_000500`. + +## Summary + +- Updates: 500 +- Cycles completed: 1 +- Success threshold: exploiter win rate <= 0.60 and expert CI low > 0.00 + +## Exploiter Series + +| Type | Cycle | Win rate | Mean diff | Opened colors | Max-step | Target | +| --- | ---: | ---: | ---: | ---: | ---: | --- | +| cycle-target | 1 | 0.5022 | +0.6818 | 4.9973 | 0.0000 | `league_c01_u000250` | +| final-checkpoint | 1 | 0.4709 | -2.6378 | 4.9971 | 0.0000 | `league_c01_u000500` | + +## Expert Evaluation Series + +| Cycle | Update | Win rate | Mean diff | CI low | Opened colors | Max-step | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | 250 | 0.7457 | +26.7149 | +26.1678 | 4.5478 | 0.0000 | +| 1 | 500 | 0.6546 | +14.2458 | +13.7563 | 4.8409 | 0.0000 | + +## Final Anchor Table + +| Opponent | Win rate | Mean diff | CI low | Opened colors | Max-step | Elo est. | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| `discard_only` | 0.9742 | +107.6151 | +106.9254 | 4.8845 | 0.0000 | +630.8 | +| `heuristic_balanced` | 0.9617 | +88.5699 | +87.8946 | 4.9300 | 0.0271 | +560.2 | +| `heuristic_cautious` | 0.9323 | +94.1284 | +93.3256 | 4.9832 | 0.1645 | +455.4 | +| `heuristic_expert` | 0.6546 | +14.2458 | +13.7563 | 4.8409 | 0.0000 | +111.1 | +| `ladder_v2_gate1_discard` | 0.9808 | +141.3215 | +140.4737 | 4.9991 | 0.0419 | +683.8 | +| `ladder_v2_gate2_balanced` | 0.6645 | +20.6296 | +19.9998 | 4.9362 | 0.0001 | +118.7 | +| `ladder_v2_gate3_expert` | 0.7894 | +38.9629 | +38.2914 | 4.9691 | 0.0001 | +229.5 | +| `league_c01_u000250` | 0.5433 | +5.5848 | +4.9333 | 4.9455 | 0.0001 | +30.2 | + +## Plots + +- `docs/reports/league-v1-exploiter-win-rate.png` +- `docs/reports/league-v1-opened-colors.png` +- `docs/reports/league-v1-elo-estimate.png` + +## Hypothesis Read + +부분 지지: 피탈률 목표는 한 사이클 만에 달성했다. 다만 expert 상대 오픈 색은 4.55에서 4.84로 증가해, 적대적 압력이 selectivity를 유도한다는 하위 가설은 아직 지지되지 않는다. + +## Decisions + +- Training uses a PFSP active subset when the pool exceeds `max_active_pool_members=12`. The full pool is retained for lifecycle and evaluation; the subset keeps single-GPU cycle time within budget. +- Stalling anchors are capped between the configured floor and cap during PFSP sampling. diff --git a/docs/reports/league-v1-elo-estimate.png b/docs/reports/league-v1-elo-estimate.png new file mode 100644 index 0000000000000000000000000000000000000000..10c600a2106f4b2b49b6d854802f6e2efab56bfd GIT binary patch literal 26793 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 literal 0 HcmV?d00001 diff --git a/docs/reports/league-v1-opened-colors.png b/docs/reports/league-v1-opened-colors.png new file mode 100644 index 0000000000000000000000000000000000000000..1b69c60b4c36daa79473e73bcbb0c51adcc135c8 GIT binary patch literal 24871 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 LeagueConfig: + data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {} + run = data.get("run", {}) + league = data.get("league", {}) + evaluation = data.get("evaluation", {}) + exploiter = data.get("exploiter", {}) + guards = data.get("guards", {}) + tracking = data.get("tracking", {}) + anchors = [_member_from_dict(item) for item in data.get("anchors", [])] + return LeagueConfig( + base_config=data["base_config"], + warm_start_checkpoint=data["warm_start_checkpoint"], + experiment_name=run.get("experiment_name", "jax-ppo-league-v1"), + seed=run.get("seed", 20260705), + artifact_root=run.get("artifact_root", "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league"), + cycles=league.get("cycles", 5), + league_updates_per_cycle=league.get("league_updates_per_cycle", 500), + snapshot_interval_updates=league.get("snapshot_interval_updates", 250), + mirror_probability=league.get("mirror_probability", 0.5), + uniform_mix=league.get("uniform_mix", 0.1), + stalling_anchor_floor=league.get("stalling_anchor_floor", 0.02), + stalling_anchor_cap=league.get("stalling_anchor_cap", 0.05), + pool_max_size=league.get("pool_max_size", 24), + max_active_pool_members=league.get("max_active_pool_members", 12), + exploiter_member_fraction_cap=league.get("exploiter_member_fraction_cap", 1.0 / 3.0), + wall_clock_hours=league.get("wall_clock_hours", 6.0), + success_exploiter_win_rate=league.get("success_exploiter_win_rate", 0.60), + stagnation_window=league.get("stagnation_window", 5), + stagnation_min_delta=league.get("stagnation_min_delta", 0.02), + evaluation_games=evaluation.get("games", 10_000), + evaluation_batch_games=evaluation.get("batch_games", 8192), + evaluation_duplicate=evaluation.get("duplicate", True), + evaluation_shuffle_bank_seed=evaluation.get("shuffle_bank_seed", 20260704), + recent_snapshot_evals=evaluation.get("recent_snapshot_evals", 3), + exploiter_config=exploiter.get("config", "configs/jax_ppo/ladder-v2-exploiter.yaml"), + exploiter_updates=exploiter.get("updates", 250), + expert_guard_ci_low=guards.get("expert_ci_low", 0.0), + max_steps_guard=guards.get("max_steps_rate", 0.02), + tracked_summary_path=tracking.get( + "tracked_summary_path", "docs/reports/league-v1-2026-07-05-summary.jsonl" + ), + report_path=tracking.get("report_path", "docs/reports/league-v1-2026-07-05.md"), + anchors=anchors, + ) + + +def run_league(config_path: str | Path) -> Path: + cfg = load_league_config(config_path) + ppo_cfg = _main_ppo_config(cfg) + run_dir = _create_league_run_dir(cfg) + _write_json(run_dir / "league_config.json", _league_config_to_dict(cfg)) + _write_json(run_dir / "main_ppo_config.json", asdict(ppo_cfg)) + summary_path = Path(cfg.tracked_summary_path) + summary_path.parent.mkdir(parents=True, exist_ok=True) + summary_path.write_text("", encoding="utf-8") + + rng = jax.random.PRNGKey(cfg.seed) + rng, init_key, reset_key, assignment_key = jax.random.split(rng, 4) + state = create_train_state(ppo_cfg, init_key) + state = restore_checkpoint(Path(cfg.warm_start_checkpoint), state) + env_state = jax.jit(jax.vmap(reset))(jax.random.split(reset_key, ppo_cfg.ppo.batch_games)) + pool = list(cfg.anchors) + if not pool: + pool = _default_pool_members() + + initial_probs = pool_sampling_probabilities(pool, cfg) + assignments = sample_league_assignments( + assignment_key, + ppo_cfg.ppo.batch_games, + jnp.asarray(initial_probs, dtype=jnp.float32), + cfg.mirror_probability, + ) + + metrics_path = run_dir / "league_metrics.jsonl" + pool_path = run_dir / "pool.jsonl" + start_time = time.perf_counter() + global_update = 0 + snapshots: list[PoolMember] = [] + best_snapshot: PoolMember | None = None + best_expert_ci = -math.inf + exploiter_rates: list[float] = [] + stop_reason = "cycles_complete" + + for cycle in range(1, cfg.cycles + 1): + segment_count = max(1, cfg.league_updates_per_cycle // cfg.snapshot_interval_updates) + for segment in range(segment_count): + updates = _segment_updates(cfg, segment, segment_count) + active_pool = active_training_pool(pool, cfg) + probs = pool_sampling_probabilities(active_pool, cfg) + policies = [_policy_for_member(member) for member in active_pool] + train_iteration = make_league_train_iteration( + ppo_cfg, policies, jnp.asarray(probs, dtype=jnp.float32), cfg.mirror_probability + ) + rng, reset_key, assignment_key = jax.random.split(rng, 3) + env_state = jax.jit(jax.vmap(reset))( + jax.random.split(reset_key, ppo_cfg.ppo.batch_games) + ) + assignments = sample_league_assignments( + assignment_key, + ppo_cfg.ppo.batch_games, + jnp.asarray(probs, dtype=jnp.float32), + cfg.mirror_probability, + ) + for _ in range(updates): + iter_start = time.perf_counter() + state, env_state, assignments, rng, metrics = train_iteration( + state, env_state, assignments, rng, jnp.asarray(0.0, dtype=jnp.float32) + ) + jax.tree_util.tree_leaves(metrics)[0].block_until_ready() + row = _metrics_to_row(metrics, global_update, cycle, active_pool, probs) + row["iteration_seconds"] = time.perf_counter() - iter_start + row["elapsed_seconds"] = time.perf_counter() - start_time + _append_jsonl(metrics_path, row) + if global_update % ppo_cfg.run.log_every == 0: + print( + json.dumps( + { + "cycle": cycle, + "update": global_update, + "return_mean": row["return_mean"], + "opened_colors_mean": row["opened_colors_mean"], + "play_action_rate": row["play_action_rate"], + "active_pool": row["active_pool_size"], + }, + sort_keys=True, + ), + flush=True, + ) + global_update += 1 + + snapshot = _save_main_snapshot( + run_dir, state, ppo_cfg, cycle, global_update, anchor=False + ) + snapshots.append(snapshot) + pool.append(snapshot) + pool = prune_pool(pool, cfg) + _append_jsonl( + pool_path, {"cycle": cycle, "update": global_update, "pool": _pool_json(pool)} + ) + eval_rows = evaluate_snapshot( + snapshot, ppo_cfg, pool, snapshots, cfg, run_dir, cycle, global_update + ) + for row in eval_rows: + _append_jsonl(run_dir / "league_summary.jsonl", row) + _append_jsonl(summary_path, row) + if row["event"] == "snapshot_eval" and row["opponent"] == "heuristic_expert": + if row["score_diff_ci95_low"] > best_expert_ci: + best_snapshot = snapshot + best_expert_ci = row["score_diff_ci95_low"] + if row["score_diff_ci95_low"] < cfg.expert_guard_ci_low: + _append_jsonl( + run_dir / "alerts.jsonl", + {**row, "alert": "expert_ci_low_below_guard"}, + ) + if row["event"] == "snapshot_eval" and row["max_steps_rate"] > cfg.max_steps_guard: + _append_jsonl( + run_dir / "alerts.jsonl", + {**row, "alert": "max_steps_rate_above_guard"}, + ) + _update_pool_win_rates(pool, eval_rows) + + target = best_snapshot or snapshots[-1] + exploiter_member, exploiter_row = run_exploiter_cycle(cfg, ppo_cfg, target, run_dir, cycle) + final_exploiter_row = None + if snapshots and target.name != snapshots[-1].name: + final_exploiter_row = evaluate_exploiter_member( + cfg, + ppo_cfg, + exploiter_member, + snapshots[-1], + cycle, + event="exploiter_final_eval", + ) + pool.append(exploiter_member) + pool = prune_pool(pool, cfg) + success_row = final_exploiter_row or exploiter_row + exploiter_rates.append(success_row["win_rate"]) + _append_jsonl(run_dir / "league_summary.jsonl", exploiter_row) + _append_jsonl(summary_path, exploiter_row) + if final_exploiter_row is not None: + _append_jsonl(run_dir / "league_summary.jsonl", final_exploiter_row) + _append_jsonl(summary_path, final_exploiter_row) + _append_jsonl( + pool_path, {"cycle": cycle, "update": global_update, "pool": _pool_json(pool)} + ) + + if _success(cfg, success_row, best_expert_ci): + stop_reason = "success" + break + if _stagnated(cfg, exploiter_rates): + stop_reason = "stagnation" + break + if (time.perf_counter() - start_time) / 3600.0 >= cfg.wall_clock_hours: + stop_reason = "wall_clock_budget" + break + + final_snapshot = _save_main_snapshot( + run_dir, state, ppo_cfg, cycle, global_update, anchor=False + ) + final_row = { + "event": "league_complete", + "stop_reason": stop_reason, + "cycles_completed": cycle, + "updates": global_update, + "final_checkpoint": str(final_snapshot.checkpoint), + "run_dir": str(run_dir), + } + _append_jsonl(run_dir / "league_summary.jsonl", final_row) + _append_jsonl(summary_path, final_row) + write_report(cfg, run_dir, summary_path) + return run_dir + + +def active_training_pool(pool: list[PoolMember], cfg: LeagueConfig) -> list[PoolMember]: + if len(pool) <= cfg.max_active_pool_members: + return list(pool) + anchors = [member for member in pool if member.anchor] + nonanchors = [member for member in pool if not member.anchor] + nonanchors.sort(key=lambda member: (member.recent_win_rate, -member.created_update)) + slots = max(cfg.max_active_pool_members - len(anchors), 0) + return anchors + nonanchors[:slots] + + +def pool_sampling_probabilities(pool: list[PoolMember], cfg: LeagueConfig) -> list[float]: + raw = [(1.0 - _clamp(member.recent_win_rate, 0.001, 0.999)) ** 2 for member in pool] + raw_sum = sum(raw) + if raw_sum <= 0.0: + base = [1.0 / len(pool)] * len(pool) + else: + base = [value / raw_sum for value in raw] + probs = [(1.0 - cfg.uniform_mix) * value + cfg.uniform_mix / len(pool) for value in base] + + fixed: dict[int, float] = {} + for idx, member in enumerate(pool): + if member.stalling: + fixed[idx] = _clamp(probs[idx], cfg.stalling_anchor_floor, cfg.stalling_anchor_cap) + fixed_sum = sum(fixed.values()) + if fixed_sum >= 1.0: + total = fixed_sum + return [fixed.get(idx, 0.0) / total for idx in range(len(pool))] + free_indices = [idx for idx in range(len(pool)) if idx not in fixed] + free_sum = sum(probs[idx] for idx in free_indices) + result = [0.0] * len(pool) + for idx, value in fixed.items(): + result[idx] = value + if free_indices and free_sum > 0.0: + scale = (1.0 - fixed_sum) / free_sum + for idx in free_indices: + result[idx] = probs[idx] * scale + elif free_indices: + value = (1.0 - fixed_sum) / len(free_indices) + for idx in free_indices: + result[idx] = value + return result + + +def evaluate_snapshot( + snapshot: PoolMember, + ppo_cfg: JaxPPOConfig, + pool: list[PoolMember], + snapshots: list[PoolMember], + cfg: LeagueConfig, + run_dir: Path, + cycle: int, + update: int, +) -> list[dict[str, Any]]: + eval_members = [member for member in pool if member.anchor] + recent = [member for member in snapshots if member.name != snapshot.name][ + -cfg.recent_snapshot_evals : + ] + for member in recent: + if all(existing.name != member.name for existing in eval_members): + eval_members.append(member) + + rows = [] + for member in eval_members: + output = run_dir / "eval" / snapshot.name / f"vs_{member.name}.json" + result = _evaluate_against_member(snapshot, ppo_cfg, member, cfg) + _write_json(output, result) + elo = _elo_estimate(result["win_rate"]) + row = { + "event": "snapshot_eval", + "cycle": cycle, + "update": update, + "snapshot": snapshot.name, + "checkpoint": snapshot.checkpoint, + "opponent": member.name, + "opponent_kind": member.kind, + "elo_estimate": elo, + **_result_summary(result), + } + rows.append(row) + return rows + + +def run_exploiter_cycle( + cfg: LeagueConfig, + ppo_cfg: JaxPPOConfig, + target: PoolMember, + league_run_dir: Path, + cycle: int, +) -> tuple[PoolMember, dict[str, Any]]: + exploiter_cfg = load_config(cfg.exploiter_config) + exploiter_cfg.run.experiment_name = f"{cfg.experiment_name}-cycle-{cycle}-exploiter" + exploiter_cfg.run.artifact_root = str(league_run_dir / "exploiters") + exploiter_cfg.run.total_updates = cfg.exploiter_updates + from lost_cities_jax.ppo import train_against_checkpoint + + exploiter_run = train_against_checkpoint( + exploiter_cfg, + ppo_cfg, + target.checkpoint or cfg.warm_start_checkpoint, + ) + result = evaluate_checkpoint_match( + exploiter_cfg, + exploiter_run / "latest", + ppo_cfg, + target.checkpoint or cfg.warm_start_checkpoint, + games=cfg.evaluation_games, + duplicate=cfg.evaluation_duplicate, + ) + output = exploiter_run / f"eval_vs_{target.name}_duplicate.json" + _write_json(output, result) + member = PoolMember( + name=f"exploiter_c{cycle}", + kind="checkpoint", + anchor=False, + exploiter=True, + config=str(exploiter_run / "config.json"), + checkpoint=str(exploiter_run / "latest"), + recent_win_rate=1.0 - result["win_rate"], + created_cycle=cycle, + ) + row = { + "event": "exploiter_eval", + "cycle": cycle, + "target": target.name, + "target_checkpoint": target.checkpoint, + "exploiter": member.name, + "exploiter_checkpoint": member.checkpoint, + "eval_json": str(output), + **_result_summary(result), + } + return member, row + + +def evaluate_exploiter_member( + cfg: LeagueConfig, + ppo_cfg: JaxPPOConfig, + exploiter: PoolMember, + target: PoolMember, + cycle: int, + *, + event: str, +) -> dict[str, Any]: + if exploiter.config is None or exploiter.checkpoint is None: + msg = f"exploiter member is missing config/checkpoint: {exploiter.name}" + raise ValueError(msg) + if target.checkpoint is None: + msg = f"target member is missing checkpoint: {target.name}" + raise ValueError(msg) + output = Path(exploiter.checkpoint).parent / f"eval_vs_{target.name}_duplicate.json" + result = evaluate_checkpoint_match( + load_config(exploiter.config), + exploiter.checkpoint, + ppo_cfg, + target.checkpoint, + games=cfg.evaluation_games, + duplicate=cfg.evaluation_duplicate, + ) + _write_json(output, result) + return { + "event": event, + "cycle": cycle, + "target": target.name, + "target_checkpoint": target.checkpoint, + "exploiter": exploiter.name, + "exploiter_checkpoint": exploiter.checkpoint, + "eval_json": str(output), + **_result_summary(result), + } + + +def prune_pool(pool: list[PoolMember], cfg: LeagueConfig) -> list[PoolMember]: + anchors = [member for member in pool if member.anchor] + nonanchors = [member for member in pool if not member.anchor] + exploiter_cap = max(1, int(cfg.pool_max_size * cfg.exploiter_member_fraction_cap)) + exploiters = [member for member in nonanchors if member.exploiter] + if len(exploiters) > exploiter_cap: + exploiters.sort(key=lambda member: member.created_cycle) + remove = {member.name for member in exploiters[: len(exploiters) - exploiter_cap]} + nonanchors = [member for member in nonanchors if member.name not in remove] + if len(anchors) + len(nonanchors) <= cfg.pool_max_size: + return anchors + nonanchors + nonanchors.sort(key=lambda member: (member.recent_win_rate, -member.created_update)) + keep = max(cfg.pool_max_size - len(anchors), 0) + return anchors + nonanchors[:keep] + + +def write_report(cfg: LeagueConfig, run_dir: Path, summary_path: Path) -> None: + rows = [ + json.loads(line) + for line in summary_path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + report_path = Path(cfg.report_path) + report_path.parent.mkdir(parents=True, exist_ok=True) + plot_paths = _write_plots(cfg, report_path.parent, rows) + exploiter_rows = [ + row for row in rows if row.get("event") in {"exploiter_eval", "exploiter_final_eval"} + ] + final_exploiter_rows = [row for row in rows if row.get("event") == "exploiter_final_eval"] + expert_rows = [ + row + for row in rows + if row.get("event") == "snapshot_eval" and row.get("opponent") == "heuristic_expert" + ] + snapshot_rows = [row for row in rows if row.get("event") == "snapshot_eval"] + final_snapshot_rows = _final_snapshot_rows(snapshot_rows) + completion_rows = [row for row in rows if row.get("event") == "league_complete"] + final = completion_rows[-1] if completion_rows else (rows[-1] if rows else {}) + lines = [ + "# League v1 Report - 2026-07-05", + "", + f"**Status:** `{final.get('stop_reason', 'unknown')}`.", + f"**Run dir:** `{run_dir}`.", + f"**Final checkpoint:** `{final.get('final_checkpoint', 'unknown')}`.", + "", + "## Summary", + "", + f"- Updates: {final.get('updates', 'unknown')}", + f"- Cycles completed: {final.get('cycles_completed', 'unknown')}", + f"- Success threshold: exploiter win rate <= {cfg.success_exploiter_win_rate:.2f} " + f"and expert CI low > {cfg.expert_guard_ci_low:.2f}", + "", + "## Exploiter Series", + "", + "| Type | Cycle | Win rate | Mean diff | Opened colors | Max-step | Target |", + "| --- | ---: | ---: | ---: | ---: | ---: | --- |", + ] + for row in exploiter_rows: + kind = "final-checkpoint" if row["event"] == "exploiter_final_eval" else "cycle-target" + lines.append( + "| {kind} | {cycle} | {win_rate:.4f} | {mean_score_diff:+.4f} | " + "{opened_colors_per_game:.4f} | {max_steps_rate:.4f} | `{target}` |".format( + kind=kind, **row + ) + ) + lines.extend( + [ + "", + "## Expert Evaluation Series", + "", + "| Cycle | Update | Win rate | Mean diff | CI low | Opened colors | Max-step |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: |", + ] + ) + for row in expert_rows: + lines.append( + "| {cycle} | {update} | {win_rate:.4f} | {mean_score_diff:+.4f} | " + "{score_diff_ci95_low:+.4f} | {opened_colors_per_game:.4f} | " + "{max_steps_rate:.4f} |".format(**row) + ) + lines.extend( + [ + "", + "## Final Anchor Table", + "", + "| Opponent | Win rate | Mean diff | CI low | Opened colors | Max-step | Elo est. |", + "| --- | ---: | ---: | ---: | ---: | ---: | ---: |", + ] + ) + for row in final_snapshot_rows: + lines.append( + "| `{opponent}` | {win_rate:.4f} | {mean_score_diff:+.4f} | " + "{score_diff_ci95_low:+.4f} | {opened_colors_per_game:.4f} | " + "{max_steps_rate:.4f} | {elo_estimate:+.1f} |".format(**row) + ) + lines.extend( + [ + "", + "## Plots", + "", + *[f"- `{path}`" for path in plot_paths], + "", + "## Hypothesis Read", + "", + _hypothesis_read(final_exploiter_rows or exploiter_rows, expert_rows), + "", + "## Decisions", + "", + "- Training uses a PFSP active subset when the pool exceeds " + f"`max_active_pool_members={cfg.max_active_pool_members}`. The full pool is retained " + "for lifecycle and evaluation; the subset keeps single-GPU cycle time within budget.", + "- Stalling anchors are capped between the configured floor and cap during PFSP sampling.", + ] + ) + report_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _main_ppo_config(cfg: LeagueConfig) -> JaxPPOConfig: + ppo_cfg = load_config(cfg.base_config) + ppo_cfg.run.experiment_name = cfg.experiment_name + ppo_cfg.run.seed = cfg.seed + ppo_cfg.run.artifact_root = cfg.artifact_root + ppo_cfg.run.total_updates = cfg.snapshot_interval_updates + ppo_cfg.reward.potential_shaping_initial = 0.0 + ppo_cfg.reward.potential_shaping_final = 0.0 + ppo_cfg.reward.potential_shaping_anneal_steps = 0 + ppo_cfg.evaluation.games = cfg.evaluation_games + ppo_cfg.evaluation.batch_games = cfg.evaluation_batch_games + ppo_cfg.evaluation.duplicate = cfg.evaluation_duplicate + ppo_cfg.evaluation.shuffle_bank_seed = cfg.evaluation_shuffle_bank_seed + return ppo_cfg + + +def _member_from_dict(data: dict[str, Any]) -> PoolMember: + return PoolMember( + name=data["name"], + kind=data["kind"], + anchor=data.get("anchor", True), + stalling=data.get("stalling", False), + exploiter=data.get("exploiter", False), + policy_name=data.get("policy_name"), + config=data.get("config"), + checkpoint=data.get("checkpoint"), + recent_win_rate=data.get("recent_win_rate", 0.5), + ) + + +def _default_pool_members() -> list[PoolMember]: + return [ + PoolMember("discard_only", "static", stalling=True, policy_name="discard_only"), + PoolMember("heuristic_balanced", "static", policy_name="heuristic_balanced"), + PoolMember("heuristic_cautious", "static", stalling=True, policy_name="heuristic_cautious"), + PoolMember("heuristic_expert", "static", policy_name="heuristic_expert"), + ] + + +def _policy_for_member(member: PoolMember): + if member.kind == "static": + return policy_by_name(member.policy_name or member.name) + if member.kind == "checkpoint": + if member.config is None or member.checkpoint is None: + msg = f"checkpoint pool member is missing config/checkpoint: {member.name}" + raise ValueError(msg) + return checkpoint_policy(load_config(member.config), member.checkpoint) + msg = f"unknown pool member kind: {member.kind}" + raise ValueError(msg) + + +def _evaluate_against_member( + snapshot: PoolMember, ppo_cfg: JaxPPOConfig, member: PoolMember, cfg: LeagueConfig +) -> dict[str, Any]: + if snapshot.checkpoint is None: + msg = f"snapshot has no checkpoint: {snapshot.name}" + raise ValueError(msg) + if member.kind == "static": + return evaluate_checkpoint_vs_static( + ppo_cfg, + snapshot.checkpoint, + member.policy_name or member.name, + games=cfg.evaluation_games, + duplicate=cfg.evaluation_duplicate, + ) + if member.kind == "checkpoint": + if member.config is None or member.checkpoint is None: + msg = f"checkpoint eval member is missing config/checkpoint: {member.name}" + raise ValueError(msg) + return evaluate_checkpoint_match( + ppo_cfg, + snapshot.checkpoint, + load_config(member.config), + member.checkpoint, + games=cfg.evaluation_games, + duplicate=cfg.evaluation_duplicate, + ) + msg = f"unknown pool member kind: {member.kind}" + raise ValueError(msg) + + +def _save_main_snapshot( + run_dir: Path, + state: TrainState, + ppo_cfg: JaxPPOConfig, + cycle: int, + update: int, + *, + anchor: bool, +) -> PoolMember: + checkpoint = run_dir / "snapshots" / f"cycle_{cycle:02d}_update_{update:06d}" + save_checkpoint(checkpoint, state, ppo_cfg) + save_checkpoint(run_dir / "latest", state, ppo_cfg) + return PoolMember( + name=f"league_c{cycle:02d}_u{update:06d}", + kind="checkpoint", + anchor=anchor, + config=str(run_dir / "main_ppo_config.json"), + checkpoint=str(checkpoint), + created_cycle=cycle, + created_update=update, + ) + + +def _segment_updates(cfg: LeagueConfig, segment: int, segment_count: int) -> int: + base = cfg.league_updates_per_cycle // segment_count + remainder = cfg.league_updates_per_cycle - base * segment_count + return base + (1 if segment < remainder else 0) + + +def _metrics_to_row( + metrics: dict[str, jax.Array], + update: int, + cycle: int, + active_pool: list[PoolMember], + probs: list[float], +) -> dict[str, Any]: + row = { + key: float(value) if getattr(value, "shape", ()) == () else value.tolist() + for key, value in metrics.items() + } + row["event"] = "train_update" + row["update"] = update + row["cycle"] = cycle + row["active_pool_size"] = len(active_pool) + row["active_pool"] = [member.name for member in active_pool] + row["active_pool_probs"] = [float(value) for value in probs] + return row + + +def _update_pool_win_rates(pool: list[PoolMember], rows: list[dict[str, Any]]) -> None: + by_name = {member.name: member for member in pool} + for row in rows: + member = by_name.get(row.get("opponent")) + if member is not None: + member.recent_win_rate = row["win_rate"] + + +def _success(cfg: LeagueConfig, exploiter_row: dict[str, Any], best_expert_ci: float) -> bool: + return ( + exploiter_row["win_rate"] <= cfg.success_exploiter_win_rate + and best_expert_ci > cfg.expert_guard_ci_low + ) + + +def _stagnated(cfg: LeagueConfig, exploiter_rates: list[float]) -> bool: + if len(exploiter_rates) < cfg.stagnation_window: + return False + window = exploiter_rates[-cfg.stagnation_window :] + return max(window) - min(window) < cfg.stagnation_min_delta + + +def _result_summary(result: dict[str, Any]) -> dict[str, Any]: + keys = [ + "games", + "wins", + "losses", + "ties", + "win_rate", + "wilson_low", + "wilson_high", + "mean_score_diff", + "score_diff_ci95_low", + "score_diff_ci95_high", + "mean_game_length", + "max_steps_rate", + "opened_colors_per_game", + "play_action_rate", + "positive_expeditions_per_game", + ] + return {key: result[key] for key in keys if key in result} + + +def _elo_estimate(win_rate: float) -> float: + p = _clamp(win_rate, 0.001, 0.999) + return 400.0 * math.log10(p / (1.0 - p)) + + +def _hypothesis_read( + exploiter_rows: list[dict[str, Any]], expert_rows: list[dict[str, Any]] +) -> str: + if not exploiter_rows or not expert_rows: + return "미결: exploiter 또는 expert 시계열이 부족하다." + if len(exploiter_rows) == 1: + exploit_end = exploiter_rows[-1]["win_rate"] + open_start = expert_rows[0]["opened_colors_per_game"] + open_end = expert_rows[-1]["opened_colors_per_game"] + if exploit_end <= 0.60: + return ( + "부분 지지: 피탈률 목표는 한 사이클 만에 달성했다. 다만 expert 상대 " + f"오픈 색은 {open_start:.2f}에서 {open_end:.2f}로 증가해, " + "적대적 압력이 selectivity를 유도한다는 하위 가설은 아직 지지되지 않는다." + ) + return ( + "미결: exploiter 시계열이 한 점뿐이라 추세 판단은 이르다. " + f"expert 상대 오픈 색은 {open_start:.2f}에서 {open_end:.2f}로 이동했다." + ) + exploit_start = exploiter_rows[0]["win_rate"] + exploit_end = exploiter_rows[-1]["win_rate"] + open_start = expert_rows[0]["opened_colors_per_game"] + open_end = expert_rows[-1]["opened_colors_per_game"] + if exploit_end <= 0.60 and open_end < open_start: + return "지지: 피탈률이 성공 기준까지 하락했고 expert 상대 오픈 색도 감소했다." + if exploit_end < exploit_start and open_end < open_start: + return "부분 지지: 피탈률과 오픈 색이 함께 하락했지만 성공 기준에는 아직 못 미쳤다." + if exploit_end >= exploit_start and open_end >= open_start: + return "기각 쪽: exploiter 압력이 누적되어도 피탈률과 오픈 색이 함께 개선되지 않았다." + return "미결: 피탈률과 오픈 색 수가 서로 다른 방향으로 움직였다." + + +def _final_snapshot_rows(snapshot_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + if not snapshot_rows: + return [] + final_update = max(row["update"] for row in snapshot_rows) + return [row for row in snapshot_rows if row["update"] == final_update] + + +def _write_plots(cfg: LeagueConfig, output_dir: Path, rows: list[dict[str, Any]]) -> list[str]: + try: + import matplotlib.pyplot as plt + except Exception: + return [] + output_dir.mkdir(parents=True, exist_ok=True) + plot_paths = [] + + exploiter_rows = [row for row in rows if row.get("event") == "exploiter_eval"] + if exploiter_rows: + path = output_dir / "league-v1-exploiter-win-rate.png" + plt.figure() + plt.plot( + [row["cycle"] for row in exploiter_rows], + [row["win_rate"] for row in exploiter_rows], + marker="o", + ) + plt.axhline(cfg.success_exploiter_win_rate, color="red", linestyle="--") + plt.xlabel("Cycle") + plt.ylabel("Exploiter win rate") + plt.tight_layout() + plt.savefig(path) + plt.close() + plot_paths.append(str(path)) + + expert_rows = [ + row + for row in rows + if row.get("event") == "snapshot_eval" and row.get("opponent") == "heuristic_expert" + ] + if expert_rows: + path = output_dir / "league-v1-opened-colors.png" + plt.figure() + plt.plot( + [row["update"] for row in expert_rows], + [row["opened_colors_per_game"] for row in expert_rows], + marker="o", + ) + plt.xlabel("Update") + plt.ylabel("Opened colors vs expert") + plt.tight_layout() + plt.savefig(path) + plt.close() + plot_paths.append(str(path)) + + path = output_dir / "league-v1-elo-estimate.png" + plt.figure() + plt.plot( + [row["update"] for row in expert_rows], + [row["elo_estimate"] for row in expert_rows], + marker="o", + ) + plt.xlabel("Update") + plt.ylabel("Logistic Elo estimate vs expert") + plt.tight_layout() + plt.savefig(path) + plt.close() + plot_paths.append(str(path)) + return plot_paths + + +def _create_league_run_dir(cfg: LeagueConfig) -> Path: + root = Path(cfg.artifact_root).resolve() + timestamp = time.strftime("%Y-%m-%d_%H%M%S") + run_dir = root / f"{timestamp}_{_slugify(cfg.experiment_name)}" + run_dir.mkdir(parents=True, exist_ok=False) + return run_dir + + +def _league_config_to_dict(cfg: LeagueConfig) -> dict[str, Any]: + data = asdict(cfg) + data["anchors"] = [asdict(member) for member in cfg.anchors] + return data + + +def _pool_json(pool: list[PoolMember]) -> list[dict[str, Any]]: + return [asdict(member) for member in pool] + + +def _write_json(path: Path, data: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _append_jsonl(path: Path, data: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(data, sort_keys=True) + "\n") + + +def _clamp(value: float, lower: float, upper: float) -> float: + return min(max(value, lower), upper) + + +def _slugify(value: str) -> str: + return "".join(ch.lower() if ch.isalnum() else "-" for ch in value).strip("-") + + +__all__ = [ + "LeagueConfig", + "PoolMember", + "active_training_pool", + "load_league_config", + "pool_sampling_probabilities", + "run_league", +] diff --git a/src/lost_cities_jax/ppo.py b/src/lost_cities_jax/ppo.py index dade168..b0845f2 100644 --- a/src/lost_cities_jax/ppo.py +++ b/src/lost_cities_jax/ppo.py @@ -157,6 +157,12 @@ class MatchBatch(NamedTuple): max_steps: jax.Array +class LeagueAssignments(NamedTuple): + learner_seat: jax.Array + opponent_index: jax.Array + use_mirror: jax.Array + + def load_config(path: str | Path | None = None, overrides: dict | None = None) -> JaxPPOConfig: data: dict = {} if path is not None: @@ -272,6 +278,52 @@ def make_train_iteration(cfg: JaxPPOConfig, opponent_policy): return train_iteration +def make_league_train_iteration( + cfg: JaxPPOConfig, + opponent_policies, + opponent_probs: jax.Array, + mirror_probability: float, +): + rollout_fn = make_league_rollout_fn( + cfg, + opponent_policies, + jnp.asarray(opponent_probs, dtype=jnp.float32), + mirror_probability, + ) + + @jax.jit + def train_iteration( + state: TrainState, + env_state: State, + assignments: LeagueAssignments, + rng: jax.Array, + shaping_coef: jax.Array, + ): + rng, rollout_key, update_key, reset_key = jax.random.split(rng, 4) + env_state, transitions, rollout_metrics = rollout_fn( + state, env_state, assignments, rollout_key, shaping_coef + ) + advantages, returns = compute_gae( + transitions.reward, + transitions.value, + transitions.done, + cfg.ppo.gamma, + cfg.ppo.gae_lambda, + ) + state, update_metrics = ppo_update(state, transitions, advantages, returns, update_key, cfg) + env_state, assignments = reset_done_envs_with_assignments( + env_state, + assignments, + reset_key, + cfg.ppo.batch_games, + opponent_probs, + mirror_probability, + ) + return state, env_state, assignments, rng, {**rollout_metrics, **update_metrics} + + return train_iteration + + def make_rollout_fn(cfg: JaxPPOConfig, opponent_policy): learner = jnp.asarray(cfg.run.learner_seat, dtype=jnp.int32) opponent = jnp.asarray(1 - cfg.run.learner_seat, dtype=jnp.int32) @@ -334,6 +386,100 @@ def make_rollout_fn(cfg: JaxPPOConfig, opponent_policy): return rollout_fn +def make_league_rollout_fn( + cfg: JaxPPOConfig, + opponent_policies, + opponent_probs: jax.Array, + mirror_probability: float, +): + if not opponent_policies: + msg = "league rollout requires at least one pool opponent" + raise ValueError(msg) + + @jax.jit + def rollout_fn( + state: TrainState, + env_state: State, + assignments: LeagueAssignments, + rng: jax.Array, + shaping_coef: jax.Array, + ): + def body(carry, _): + env, key = carry + key, learner_key, mirror_key, pool_key = jax.random.split(key, 4) + learner = assignments.learner_seat.astype(jnp.int32) + opponent = 1 - learner + + obs = jax.vmap(observation, in_axes=(0, 0))(env, learner) + legal = jax.vmap(legal_action_mask)(env) + logits, value = state.apply_fn(state.params, obs) + masked_logits = mask_logits(logits, legal) + learner_actions = jax.random.categorical(learner_key, masked_logits, axis=-1).astype( + jnp.int32 + ) + log_prob = action_log_prob(masked_logits, learner_actions) + entropy = categorical_entropy(masked_logits) + + mirror_obs = jax.vmap(observation, in_axes=(0, 0))(env, opponent) + mirror_logits, _ = state.apply_fn(state.params, mirror_obs) + mirror_logits = jax.lax.stop_gradient(mirror_logits) + mirror_actions = jax.random.categorical( + mirror_key, mask_logits(mirror_logits, legal), axis=-1 + ).astype(jnp.int32) + + pool_keys = jax.random.split(pool_key, cfg.ppo.batch_games) + pool_actions = [] + for policy in opponent_policies: + pool_actions.append(jax.vmap(policy, in_axes=(0, 0, 0))(env, opponent, pool_keys)) + stacked_pool_actions = jnp.stack(pool_actions, axis=0).astype(jnp.int32) + selected_pool_actions = jnp.take_along_axis( + stacked_pool_actions, + assignments.opponent_index.astype(jnp.int32)[None, :], + axis=0, + )[0] + opponent_actions = jnp.where( + assignments.use_mirror, mirror_actions, selected_pool_actions + ) + + learner_turn = env.to_move.astype(jnp.int32) == learner + active = ~env.done + actions = jnp.where(learner_turn, learner_actions, opponent_actions) + + before_diff = batch_score_diff_for_players(env, learner) + next_env, _, _ = jax.vmap(step, in_axes=(0, 0))(env, actions) + after_diff = batch_score_diff_for_players(next_env, learner) + terminal_reward = jnp.tanh(after_diff / cfg.reward.terminal_scale) + terminal = active & next_env.done + reward = jnp.where(terminal, terminal_reward, 0.0) + reward = reward + shaping_coef * (after_diff - before_diff) + reward = jnp.where(active, reward, 0.0) + + actor_mask = active & learner_turn + place_type = (actions % 12) // 6 + transition = Transition( + obs=obs, + legal_mask=legal, + action=actions, + log_prob=log_prob, + value=value, + reward=reward, + done=next_env.done, + active=active, + actor_mask=actor_mask, + entropy=entropy, + play_action=(place_type == PLAY) & actor_mask, + ) + return (next_env, key), transition + + (next_env, rng), transitions = jax.lax.scan( + body, (env_state, rng), xs=None, length=cfg.ppo.rollout_steps + ) + metrics = rollout_metrics_for_players(transitions, next_env, assignments.learner_seat) + return next_env, transitions, metrics + + return rollout_fn + + def random_rollout(cfg: JaxPPOConfig) -> dict: opponent_policy = policy_by_name(cfg.opponent.name) rng = jax.random.PRNGKey(cfg.run.seed) @@ -399,6 +545,53 @@ def reset_done_envs(env_state: State, rng: jax.Array, batch_games: int) -> State ) +def sample_league_assignments( + rng: jax.Array, + batch_games: int, + opponent_probs: jax.Array, + mirror_probability: float, +) -> LeagueAssignments: + seat_key, opponent_key, mirror_key = jax.random.split(rng, 3) + learner_seat = jax.random.bernoulli(seat_key, 0.5, shape=(batch_games,)).astype(jnp.int32) + logits = jnp.log(jnp.maximum(jnp.asarray(opponent_probs, dtype=jnp.float32), 1.0e-12)) + opponent_index = jax.random.categorical(opponent_key, logits, shape=(batch_games,)).astype( + jnp.int32 + ) + use_mirror = jax.random.bernoulli(mirror_key, mirror_probability, shape=(batch_games,)) + return LeagueAssignments( + learner_seat=learner_seat, + opponent_index=opponent_index, + use_mirror=use_mirror, + ) + + +def reset_done_envs_with_assignments( + env_state: State, + assignments: LeagueAssignments, + rng: jax.Array, + batch_games: int, + opponent_probs: jax.Array, + mirror_probability: float, +) -> tuple[State, LeagueAssignments]: + env_key, assignment_key = jax.random.split(rng) + fresh_env = jax.vmap(reset)(jax.random.split(env_key, batch_games)) + fresh_assignments = sample_league_assignments( + assignment_key, batch_games, opponent_probs, mirror_probability + ) + done = env_state.done + next_env = jax.tree_util.tree_map( + lambda old, new: jnp.where(_broadcast_done(done, old), new, old), env_state, fresh_env + ) + next_assignments = LeagueAssignments( + learner_seat=jnp.where(done, fresh_assignments.learner_seat, assignments.learner_seat), + opponent_index=jnp.where( + done, fresh_assignments.opponent_index, assignments.opponent_index + ), + use_mirror=jnp.where(done, fresh_assignments.use_mirror, assignments.use_mirror), + ) + return next_env, next_assignments + + def ppo_update( state: TrainState, transitions: Transition, @@ -899,6 +1092,33 @@ def rollout_metrics( } +def rollout_metrics_for_players( + transitions: Transition, final_env: State, learners: jax.Array +) -> dict[str, jax.Array]: + episode_return = jnp.sum(transitions.reward, axis=0) + actor_count = jnp.sum(transitions.actor_mask) + active_count = jnp.sum(transitions.active) + color_scores = jax.vmap(color_scores_for_player, in_axes=(0, 0))(final_env, learners) + batch_idx = jnp.arange(learners.shape[0]) + opened = jnp.sum(final_env.col_len[batch_idx, learners.astype(jnp.int32), :] > 0, axis=-1) + positive = jnp.sum(color_scores > 0, axis=-1) + return { + "return_mean": jnp.mean(episode_return), + "return_std": jnp.std(episode_return), + "game_length_mean": jnp.mean(final_env.step_count.astype(jnp.float32)), + "game_length_max": jnp.max(final_env.step_count), + "max_steps_rate": jnp.mean(final_env.step_count >= MAX_STEPS), + "play_action_rate": jnp.sum(transitions.play_action) / jnp.maximum(actor_count, 1), + "opened_colors_mean": jnp.mean(opened.astype(jnp.float32)), + "positive_expeditions_mean": jnp.mean(positive.astype(jnp.float32)), + "entropy_mean": masked_mean( + transitions.entropy, transitions.actor_mask.astype(jnp.float32) + ), + "active_steps": active_count, + "learner_actions": actor_count, + } + + def color_scores_for_player(state: State, player: jax.Array) -> jax.Array: card_ids = jnp.arange(N_CARDS, dtype=jnp.int32) colors = card_ids // CARDS_PER_COLOR @@ -923,6 +1143,14 @@ def batch_score_diff(state: State, learner: jax.Array) -> jax.Array: return scores[:, learner.astype(jnp.int32)] - scores[:, opponent] +def batch_score_diff_for_players(state: State, learners: jax.Array) -> jax.Array: + scores = jax.vmap(board_score)(state) + learners = learners.astype(jnp.int32) + opponents = 1 - learners + batch_idx = jnp.arange(learners.shape[0]) + return scores[batch_idx, learners] - scores[batch_idx, opponents] + + def mask_logits(logits: jax.Array, legal_mask: jax.Array) -> jax.Array: return jnp.where(legal_mask, logits, NEG_INF) @@ -1057,10 +1285,15 @@ def cli_main(argv: list[str] | None = None) -> None: static_match_parser.add_argument("--duplicate", action="store_true") static_match_parser.add_argument("--output") + league_parser = sub.add_parser("league") + league_sub = league_parser.add_subparsers(dest="league_command", required=True) + league_run_parser = league_sub.add_parser("run") + league_run_parser.add_argument("--config", required=True) + args = parser.parse_args(argv) cfg = ( load_config(args.config, overrides=parse_overrides(args.set)) - if hasattr(args, "config") + if hasattr(args, "config") and args.command != "league" else None ) if args.command == "rollout-smoke": @@ -1139,6 +1372,12 @@ def cli_main(argv: list[str] | None = None) -> None: output=args.output, ) print(json.dumps(result, indent=2, sort_keys=True)) + elif args.command == "league": + if args.league_command == "run": + from lost_cities_jax.league import run_league + + run_dir = run_league(args.config) + print(run_dir) def _create_run_dir(cfg: JaxPPOConfig) -> Path: @@ -1210,6 +1449,7 @@ def _slugify(value: str) -> str: __all__ = [ "ActorCritic", "JaxPPOConfig", + "LeagueAssignments", "TrainState", "checkpoint_policy", "checkpoint_policy_from_params", @@ -1222,7 +1462,9 @@ __all__ = [ "evaluate_static_match", "evaluate_static_mirror", "load_config", + "make_league_train_iteration", "random_rollout", + "sample_league_assignments", "train", "train_against_checkpoint", ] diff --git a/tests/lost_cities_jax/test_league.py b/tests/lost_cities_jax/test_league.py new file mode 100644 index 0000000..bd61cf8 --- /dev/null +++ b/tests/lost_cities_jax/test_league.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import jax +import jax.numpy as jnp + +from lost_cities_jax import reset +from lost_cities_jax.league import ( + LeagueConfig, + PoolMember, + active_training_pool, + load_league_config, + pool_sampling_probabilities, +) +from lost_cities_jax.opponents import policy_by_name +from lost_cities_jax.ppo import ( + JaxPPOConfig, + NetworkConfig, + OpponentConfig, + PPOHyperConfig, + RunConfig, + create_train_state, + make_league_train_iteration, + sample_league_assignments, +) + + +def tiny_league_ppo_config(tmp_path) -> JaxPPOConfig: + return JaxPPOConfig( + run=RunConfig( + experiment_name="pytest-jax-ppo-league", + seed=123, + total_updates=1, + checkpoint_every=1, + artifact_root=str(tmp_path), + ), + opponent=OpponentConfig(name="discard_only"), + network=NetworkConfig(hidden_size=32, num_layers=1), + ppo=PPOHyperConfig(batch_games=8, rollout_steps=16, epochs=1, minibatches=2), + ) + + +def test_league_config_loads(): + cfg = load_league_config("configs/jax_ppo/league-v1.yaml") + assert cfg.experiment_name == "jax-ppo-league-v1" + assert cfg.cycles == 5 + assert len(cfg.anchors) == 7 + assert any(member.name == "heuristic_expert" for member in cfg.anchors) + + +def test_pfsp_probabilities_cap_stalling_anchors(): + cfg = LeagueConfig( + base_config="configs/jax_ppo/ladder-v2-expert.yaml", + warm_start_checkpoint="unused", + stalling_anchor_floor=0.02, + stalling_anchor_cap=0.05, + uniform_mix=0.1, + ) + pool = [ + PoolMember("discard_only", "static", stalling=True, recent_win_rate=0.01), + PoolMember("heuristic_expert", "static", recent_win_rate=0.5), + PoolMember("snapshot", "checkpoint", anchor=False, recent_win_rate=0.2), + ] + probs = pool_sampling_probabilities(pool, cfg) + assert abs(sum(probs) - 1.0) < 1.0e-6 + assert 0.02 <= probs[0] <= 0.05 + + +def test_active_training_pool_keeps_anchors_and_hard_nonanchors(): + cfg = LeagueConfig( + base_config="configs/jax_ppo/ladder-v2-expert.yaml", + warm_start_checkpoint="unused", + max_active_pool_members=4, + ) + pool = [ + PoolMember("anchor_a", "static", anchor=True), + PoolMember("anchor_b", "static", anchor=True), + PoolMember("easy", "checkpoint", anchor=False, recent_win_rate=0.9), + PoolMember("hard", "checkpoint", anchor=False, recent_win_rate=0.1), + PoolMember("medium", "checkpoint", anchor=False, recent_win_rate=0.5), + ] + active = active_training_pool(pool, cfg) + assert [member.name for member in active] == ["anchor_a", "anchor_b", "hard", "medium"] + + +def test_one_jitted_league_train_iteration_shapes(tmp_path): + cfg = tiny_league_ppo_config(tmp_path) + train_state = create_train_state(cfg, jax.random.PRNGKey(0)) + env_state = jax.jit(jax.vmap(reset))(jax.random.split(jax.random.PRNGKey(1), 8)) + probs = jnp.asarray([0.5, 0.5], dtype=jnp.float32) + assignments = sample_league_assignments(jax.random.PRNGKey(2), 8, probs, 0.5) + train_iteration = make_league_train_iteration( + cfg, + [policy_by_name("discard_only"), policy_by_name("heuristic_expert")], + probs, + 0.5, + ) + + train_state, env_state, assignments, rng, metrics = train_iteration( + train_state, + env_state, + assignments, + jax.random.PRNGKey(3), + jnp.asarray(0.0, dtype=jnp.float32), + ) + + assert env_state.to_move.shape == (8,) + assert assignments.learner_seat.shape == (8,) + assert rng.shape == (2,) + assert "opened_colors_mean" in metrics