from __future__ import annotations from pathlib import Path import jax import jax.numpy as jnp import numpy as np import pytest from lost_cities_jax import legal_action_mask, reset from lost_cities_jax.engine import current_hand_sorted, decode_action, reset_from_order from lost_cities_jax.opponents import ( HeuristicExpertConfig, discard_only_action, heuristic_balanced_action, heuristic_cautious_action, heuristic_expert_action, make_heuristic_expert_policy, ) from lost_cities_jax.ppo import ( JaxPPOConfig, NetworkConfig, OpponentConfig, PPOHyperConfig, RunConfig, create_train_state, evaluate, evaluate_checkpoint_match, evaluate_static_mirror, load_config, make_train_iteration, policy_by_name, random_rollout, train, ) from lost_cities_jax.types import ( CARDS_PER_COLOR, HAND_SIZE, LOC_DECK, LOC_P0_BOARD, LOC_P0_HAND, N_CARDS, N_COLORS, PLAY, ) GATE3_CHECKPOINT = ( "/mnt/2tbhdd/coolrl-lost-cities-artifacts/jax-ppo-static-opponents/" "2026-07-04_230150_jax-ppo-cautious/latest" ) def tiny_config(tmp_path) -> JaxPPOConfig: return JaxPPOConfig( run=RunConfig( experiment_name="pytest-jax-ppo", 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), # A round runs at least 44 plies (one per deck draw), so a shorter # rollout would finish no episodes and leave the episode metrics empty. ppo=PPOHyperConfig(batch_games=8, rollout_steps=80, epochs=1, minibatches=2), ) def test_static_opponents_return_legal_actions(): state = reset(jax.random.PRNGKey(0)) mask = np.asarray(legal_action_mask(state), dtype=bool) for fn in [ discard_only_action, heuristic_balanced_action, heuristic_cautious_action, heuristic_expert_action, ]: action = int(fn(state, jnp.int32(0), jax.random.PRNGKey(1))) assert mask[action] def test_cautious_discards_weak_unopened_hand(): low_cards = [_rank_card(0, rank) for rank in range(2, 7)] state = _manual_state(p0_hand=low_cards, to_move=0) action = int(heuristic_cautious_action(state, jnp.int32(0), jax.random.PRNGKey(1))) _, place_type, _ = [int(x) for x in decode_action(jnp.asarray(action))] assert place_type != PLAY def test_balanced_opens_strong_unopened_rank(): strong = _rank_card(0, 10) state = _manual_state(p0_hand=[strong, _rank_card(1, 2), _rank_card(2, 3)], to_move=0) action = int(heuristic_balanced_action(state, jnp.int32(0), jax.random.PRNGKey(1))) hand_slot, place_type, _ = [int(x) for x in decode_action(jnp.asarray(action))] hand = [int(x) for x in current_hand_sorted(state, 0)] assert place_type == PLAY assert hand[hand_slot] == strong def test_cautious_uses_own_board_when_playing_as_p1(): existing = _rank_card(0, 5) playable = _rank_card(0, 6) state = _manual_state( p1_hand=[ playable, _rank_card(1, 2), _rank_card(1, 3), _rank_card(2, 2), _rank_card(2, 3), _rank_card(3, 2), _rank_card(3, 3), _rank_card(4, 2), ], p1_board=[existing], to_move=1, ) action = int(heuristic_cautious_action(state, jnp.int32(1), jax.random.PRNGKey(1))) hand_slot, place_type, _ = [int(x) for x in decode_action(jnp.asarray(action))] hand = [int(x) for x in current_hand_sorted(state, 1)] assert place_type == PLAY assert hand[hand_slot] == playable def test_expert_opens_strong_ev_hand(): strong_cards = [ _hs_card(0, 0), _rank_card(0, 7), _rank_card(0, 8), _rank_card(0, 9), _rank_card(0, 10), _rank_card(1, 2), _rank_card(2, 2), _rank_card(3, 2), ] state = _manual_state(p0_hand=strong_cards, to_move=0) action = int(heuristic_expert_action(state, jnp.int32(0), jax.random.PRNGKey(1))) hand_slot, place_type, _ = [int(x) for x in decode_action(jnp.asarray(action))] hand = [int(x) for x in current_hand_sorted(state, 0)] assert place_type == PLAY assert hand[hand_slot] // CARDS_PER_COLOR == 0 def test_expert_rejects_weak_new_open(): weak_cards = [ _rank_card(0, 2), _rank_card(0, 3), _rank_card(0, 4), _rank_card(1, 2), _rank_card(2, 2), _rank_card(3, 2), _rank_card(4, 2), _rank_card(4, 3), ] state = _manual_state(p0_hand=weak_cards, to_move=0) action = int(heuristic_expert_action(state, jnp.int32(0), jax.random.PRNGKey(1))) _, place_type, _ = [int(x) for x in decode_action(jnp.asarray(action))] assert place_type != PLAY def test_expert_avoids_discarding_immediately_useful_opponent_card(): dangerous = _rank_card(0, 8) safe = _rank_card(4, 2) state = _manual_state( p0_hand=[ dangerous, safe, _rank_card(1, 2), _rank_card(1, 3), _rank_card(2, 2), _rank_card(2, 3), _rank_card(3, 2), _rank_card(3, 3), ], p1_board=[_rank_card(0, 7)], to_move=0, ) action = int(heuristic_expert_action(state, jnp.int32(0), jax.random.PRNGKey(1))) hand_slot, place_type, _ = [int(x) for x in decode_action(jnp.asarray(action))] hand = [int(x) for x in current_hand_sorted(state, 0)] if place_type != PLAY: assert hand[hand_slot] != dangerous def test_expert_cap2_blocks_third_open_color(): state = _manual_state( p0_hand=[ _hs_card(2), _rank_card(2, 7), _rank_card(2, 8), _rank_card(2, 9), _rank_card(2, 10), _rank_card(3, 2), _rank_card(3, 3), _rank_card(4, 2), ], p0_board=[_rank_card(0, 2), _rank_card(1, 2)], to_move=0, ) policy = make_heuristic_expert_policy(HeuristicExpertConfig(max_open_colors=2)) action = int(policy(state, jnp.int32(0), jax.random.PRNGKey(1))) hand_slot, place_type, _ = [int(x) for x in decode_action(jnp.asarray(action))] hand = [int(x) for x in current_hand_sorted(state, 0)] opened_new_color = ( place_type == PLAY and state.col_len[0, hand[hand_slot] // CARDS_PER_COLOR] == 0 ) assert not bool(opened_new_color) def test_expert_cap3_allows_third_but_blocks_fourth_open_color(): cap3 = make_heuristic_expert_policy(HeuristicExpertConfig(max_open_colors=3)) third_state = _manual_state( p0_hand=[ _hs_card(2), _rank_card(2, 7), _rank_card(2, 8), _rank_card(2, 9), _rank_card(2, 10), _rank_card(3, 2), _rank_card(3, 3), _rank_card(4, 2), ], p0_board=[_rank_card(0, 2), _rank_card(1, 2)], to_move=0, ) action = int(cap3(third_state, jnp.int32(0), jax.random.PRNGKey(1))) hand_slot, place_type, _ = [int(x) for x in decode_action(jnp.asarray(action))] hand = [int(x) for x in current_hand_sorted(third_state, 0)] assert place_type == PLAY assert third_state.col_len[0, hand[hand_slot] // CARDS_PER_COLOR] == 0 fourth_state = _manual_state( p0_hand=[ _hs_card(3), _rank_card(3, 7), _rank_card(3, 8), _rank_card(3, 9), _rank_card(3, 10), _rank_card(4, 2), _rank_card(4, 3), _rank_card(4, 4), ], p0_board=[_rank_card(0, 2), _rank_card(1, 2), _rank_card(2, 2)], to_move=0, ) action = int(cap3(fourth_state, jnp.int32(0), jax.random.PRNGKey(1))) hand_slot, place_type, _ = [int(x) for x in decode_action(jnp.asarray(action))] hand = [int(x) for x in current_hand_sorted(fourth_state, 0)] opened_new_color = ( place_type == PLAY and fourth_state.col_len[0, hand[hand_slot] // CARDS_PER_COLOR] == 0 ) assert not bool(opened_new_color) @pytest.mark.parametrize( "policy_name", ["discard_only", "heuristic_balanced", "heuristic_cautious"] ) def test_static_policy_duplicate_mirror_score_diff_is_zero(policy_name): result = evaluate_static_mirror( policy_name, games=16, duplicate=True, shuffle_bank_seed=20260704, batch_games=16, ) assert result["games"] == 32 assert result["mean_score_diff"] == 0.0 assert result["wins"] == result["losses"] def test_expert_duplicate_mirror_regression_stats(): result = evaluate_static_mirror( "heuristic_expert", games=1000, duplicate=True, shuffle_bank_seed=20260704, batch_games=1000, ) assert result["games"] == 2000 assert abs(result["mean_score_diff"]) <= 1.0e-6 assert result["wins"] == result["losses"] assert result["max_steps_rate"] < 0.05 assert 2.0 <= result["opened_colors_per_game"] <= 3.5 assert 0.30 <= result["play_action_rate"] <= 0.60 assert result["mean_game_length"] <= 70.0 def test_gate3_checkpoint_duplicate_self_mirror_score_diff_is_zero(): checkpoint = Path(GATE3_CHECKPOINT) if not checkpoint.exists(): pytest.skip(f"gate-3 checkpoint not available: {checkpoint}") if not any(device.platform == "gpu" for device in jax.local_devices()): pytest.skip("gate-3 checkpoint was saved with CUDA sharding; run under CUDA JAX") cfg = load_config("configs/jax_ppo/cautious.yaml") result = evaluate_checkpoint_match( cfg, checkpoint, cfg, checkpoint, games=4, duplicate=True, ) assert result["games"] == 8 assert result["mean_score_diff"] == 0.0 assert result["wins"] == result["losses"] def test_random_rollout_smoke(tmp_path): row = random_rollout(tiny_config(tmp_path)) assert row["env_steps"] == 8 * 80 assert 0.0 <= row["play_action_rate"] <= 1.0 # Averaged over episodes that actually finished, so it must clear the # 44-ply floor rather than report a mid-episode step count. assert row["episodes_completed"] > 0 assert row["game_length_mean"] >= 44.0 def test_train_checkpoint_and_eval_smoke(tmp_path): cfg = tiny_config(tmp_path) run_dir = train(cfg) assert (run_dir / "latest").exists() result = evaluate(cfg, run_dir / "latest", games=8, duplicate=True) assert result["games"] == 16 assert 0.0 <= result["win_rate"] <= 1.0 def test_one_jitted_train_iteration_shapes(tmp_path): cfg = tiny_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)) train_iteration = make_train_iteration(cfg, policy_by_name("discard_only")) train_state, env_state, rng, metrics = train_iteration( train_state, env_state, jax.random.PRNGKey(2), jnp.asarray(1.0) ) assert env_state.to_move.shape == (8,) assert "loss" in metrics assert jnp.isfinite(metrics["loss"]) def test_load_config_file(): cfg = load_config("configs/jax_ppo/smoke.yaml") assert cfg.opponent.name == "discard_only" assert cfg.ppo.batch_games == 64 def _rank_card(color: int, rank: int) -> int: return color * CARDS_PER_COLOR + rank + 1 def _hs_card(color: int, slot: int = 0) -> int: return color * CARDS_PER_COLOR + slot def _manual_state( *, p0_hand: list[int] | None = None, p1_hand: list[int] | None = None, p0_board: list[int] | None = None, p1_board: list[int] | None = None, to_move: int = 0, ): p0_hand = _fill_hand(p0_hand or [], set((p0_board or []) + (p1_board or []))) p1_hand = _fill_hand(p1_hand or [], set(p0_hand + (p0_board or []) + (p1_board or []))) rest = [card for card in range(N_CARDS) if card not in set(p0_hand + p1_hand)] deck_order = jnp.asarray(p0_hand + p1_hand + rest, dtype=jnp.int8) state = reset_from_order(deck_order) card_loc = jnp.full((N_CARDS,), LOC_DECK, dtype=jnp.int8) card_loc = card_loc.at[jnp.asarray(p0_hand)].set(LOC_P0_HAND) card_loc = card_loc.at[jnp.asarray(p1_hand)].set(LOC_P0_HAND + 1) if p0_board: card_loc = card_loc.at[jnp.asarray(p0_board)].set(LOC_P0_BOARD) if p1_board: card_loc = card_loc.at[jnp.asarray(p1_board)].set(LOC_P0_BOARD + 1) col_top, col_hs, col_len = _columns(p0_board or [], p1_board or []) return state._replace( card_loc=card_loc, col_top=col_top, col_hs=col_hs, col_len=col_len, to_move=jnp.asarray(to_move, dtype=jnp.int8), ) def _fill_hand(cards: list[int], reserved: set[int]) -> list[int]: result = list(cards) blocked = set(result) | reserved for card in range(N_CARDS): if len(result) == HAND_SIZE: return sorted(result) if card not in blocked: result.append(card) blocked.add(card) raise AssertionError("could not fill hand") def _columns(p0_board: list[int], p1_board: list[int]): col_top = np.zeros((2, N_COLORS), dtype=np.int8) col_hs = np.zeros((2, N_COLORS), dtype=np.int8) col_len = np.zeros((2, N_COLORS), dtype=np.int8) for player, cards in enumerate([p0_board, p1_board]): for card in cards: color = card // CARDS_PER_COLOR slot = card % CARDS_PER_COLOR col_len[player, color] += 1 if slot < 3: col_hs[player, color] += 1 else: col_top[player, color] = max(col_top[player, color], slot - 1) return jnp.asarray(col_top), jnp.asarray(col_hs), jnp.asarray(col_len)