Files
coorl-lost-cities/tests/lost_cities_jax/test_ppo_stack.py
T
coolguyandClaude Opus 4.8 d1c2cf628e Reset envs inside the rollout scan and fix the metrics it breaks
rollout_steps was pinned to MAX_STEPS (400) while a round actually runs
~50 plies, and finished envs were only reset between updates. 83% of every
rollout was spent stepping already-done envs to produce masked-out zeros.
Measured at rollout_steps=400: active steps go 17.3% -> 100%, i.e. 5.8x the
learner actions per update for the same compute.

Resetting in-scan exposes three things that were previously benign:

- compute_gae bootstrapped truncated episodes from zero. That was safe only
  because every episode used to terminate inside the scan; now episodes cross
  the boundary, so thread V(s_T) through.
- rollout_metrics read final_env and summed rewards along the scan axis, both
  of which assume one episode per slot. With several episodes per slot that
  silently produces garbage, so aggregate at done boundaries instead.
- league assignments were redrawn only between updates, which would pin a slot
  to one seat/opponent across every episode in a scan. Redraw them on reset.

Also anneal potential shaping against learner actions rather than padded scan
steps: the old accounting counted the dead steps, so a 5M-step anneal expired
within two updates of 250. Any earlier evidence that shaping does not help was
gathered with it effectively off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
2026-07-15 01:27:02 +09:00

412 lines
14 KiB
Python

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)