coolguyandClaude Opus 4.8 8860f62030 Pay the match total densely and drop the tanh terminal reward
The user asked why we were not simply maximising the three-round total, and they
were right. Each ply now pays the points by which it moved the running match
difference; at gamma=1 that telescopes to the final total, so the objective is
exactly the rulebook's, handed out every ply instead of once 150 plies later.

Three measurements killed the tanh design:

- Rounds are independent (corr(m1,m2)=0.004, corr(m1+m2,m3)=0.05), so a reward
  linear in the total decomposes the match into three independent rounds and
  carry enters the objective nowhere. The only coupling, the start-player rule,
  is worth +0.73 +/- 0.84 points -- indistinguishable from zero.
- Risk attitude, the one thing tanh buys, is worthless here. A policy made to
  gamble when it trails by 20 entering round three *loses* to a greedy clone over
  6144 duplicate matches (0.482); gambling only at -40 breaks even (0.498). A
  marginal wager buys about +1.7 sigma for -2 to -3 expected points. Ceiling on
  the whole carry-conditioning idea: under one win-rate point.
- Head to head over 10,000 duplicate matches at equal compute, the linear reward
  *beats* tanh(total/12): 0.5859 (CI 0.576-0.596), +20.3 points. Dropping it is
  not merely free, it is better -- not because of risk, but because tanh hands a
  ~150-ply match one saturated +/-1 and leaves all credit assignment to the critic.

The flat carry probe was not exploration collapse: sampled play still opens 5.00
expeditions, entropy settles at 1.36 nats (3.9 effective actions), and the critic
reads carry cleanly (round-three values run -0.87 to +0.86, monotone). The signal
was there; there was nothing to buy with it.

Criterion 1 (a monotone carry response) comes off the gate accordingly -- the
optimal response barely exists in this game. carry stays in the observation: it
costs nothing and the start-player rule keys off it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBQKgvBbxbheiTF1AVy1Sh
2026-07-15 03:07:18 +09:00
2026-07-14 20:09:03 +09:00
2026-07-14 20:09:03 +09:00
2026-07-14 20:09:03 +09:00
2026-07-14 20:09:03 +09:00
2026-07-14 20:09:03 +09:00
2026-07-14 20:09:03 +09:00
2026-07-14 20:09:03 +09:00
2026-07-12 04:14:02 +09:00

coolrl-lost-cities

JAX PPO training and on-device browser play for the two-player card game Lost Cities. The project provides a pure JAX rules engine, PPO training and evaluation tools, and a static web client that runs the shipped final policy with WebGPU when available and WebAssembly otherwise.

The active path is JAX + PPO. Deep CFR and ISMCTS are retained only as historical research implementations; see legacy notes.

Quick start

Install the project, then run a complete CPU-sized sanity check:

uv sync
uv run lost-cities-jax-ppo rollout-smoke --config configs/jax_ppo/smoke.yaml
uv run lost-cities-jax-ppo train --config configs/jax_ppo/smoke.yaml

The final command prints a run directory containing latest, config.json, and metrics.jsonl under runs/tmp/jax-ppo-artifacts/.

Train and evaluate a PPO policy

The committed configurations describe the opponent and training budget. For a GPU run, use CUDA JAX and keep generated artifacts outside git:

flock -n .compute.lock uv run --with 'jax[cuda12]' lost-cities-jax-ppo train \
  --config configs/jax_ppo/balanced.yaml \
  --set run.artifact_root=runs/jax-ppo

Evaluate a saved checkpoint against a fixed opponent. Duplicate evaluation swaps seats over the same shuffled games:

uv run lost-cities-jax-ppo eval \
  --config configs/jax_ppo/balanced.yaml \
  --checkpoint runs/jax-ppo/<run>/latest \
  --opponent heuristic_balanced \
  --games 10000 \
  --duplicate

Run uv run lost-cities-jax-ppo --help for training against saved opponents, league runs, gates, human-play logs, and evaluation variants.

Browser client

The final verified JAX PPO policy is shipped as a 3.1 MB static ONNX asset. No server or local checkpoint is required to play it:

cd web
npm ci
npm run dev

npm run build produces a fully static site. The model path is deployment-base aware, so the build can be served from GitHub Pages, GitLab Pages, or a normal web root. Pushes to main deploy that build to both configured Pages hosts. See web/README.md for model replacement, tests, and the cross-runtime parity fixture.

JAX Rules Engine

lost_cities_jax is a standalone pure rules simulator for one two-player Lost Cities round. It does not contain neural networks, PPO, CFR, MCTS, match wrappers, bots, or rule variants.

Public API:

from lost_cities_jax import (
    OBS_DIM,
    N_ACTIONS,
    State,
    batched_legal_mask,
    batched_obs,
    batched_reset,
    batched_step,
    board_score,
    legal_action_mask,
    observation,
    reset,
    reset_from_order,
    score,
    step,
)

Core functions are pure JAX functions:

  • reset(rng) -> State
  • reset_from_order(deck_order) -> State
  • legal_action_mask(state) -> bool[96]
  • step(state, action) -> (State, float32[2], bool)
  • score(state) -> float32[2]
  • board_score(state) -> float32[2]
  • observation(state, player) -> float32[454]

The batched exports are jax.jit(jax.vmap(...)) wrappers. Illegal actions and done-state actions are defined as no-op transitions with zero reward; training code should still sample only from legal_action_mask.

Rule Summary

One round uses 60 cards: five colors, each with three handshakes and ranks 2 through 10. Each player starts with eight cards, then each ply must place one hand card to the matching expedition or discard pile and draw one card from the deck or a discard pile. A player may not draw the card they just discarded.

Expedition numbers must be strictly increasing. Handshakes may be played only before any number in that color. The round ends immediately when the final deck card is drawn, or at MAX_STEPS == 400; forced termination is scored exactly like natural termination.

Scoring per player/color:

empty column: 0
non-empty: (sum(number ranks) - 20) * (1 + handshake_count)
bonus: +20 if total column length >= 8, not multiplied

Encodings

Cards:

Field Encoding
card_id color * 12 + slot
color 0..4
slot 0..2 handshake
slot 3..11 ranks 2..10, with rank = slot - 1

Actions (N_ACTIONS == 96):

action_id = hand_slot * 12 + place_type * 6 + draw_source
hand_slot   = 0..7, current player's hand sorted by card_id
place_type  = 0 play, 1 discard
draw_source = 0 deck, 1..5 discard pile color 0..4

Observation (OBS_DIM == 454):

  • 60 cards x 7 one-hot channels: my hand, my board, opponent board, discard top, discard non-top, opponent public hand, unknown.
  • 34 scalar features: remaining deck /44, opponent unknown hand count /8, step count /400, current-player then opponent col_top /10, col_hs /3, col_len /12, and current board score difference (player - opponent) /780.

Verification

uv run pytest -q tests/lost_cities_jax
uv run pytest -q
uv run ruff check .

Large differential profiles:

# CI profile: 100,000 random legal-policy games
CI=1 uv run pytest -q tests/lost_cities_jax/test_differential.py

# Full profile: 1,000,000 random legal-policy games
uv run pytest -q tests/lost_cities_jax/test_differential.py --full

Observed differential results on 2026-07-04 with CPU JAX backend:

CI=1 ... test_differential.py
1 passed in 247.61s (0:04:07)

... test_differential.py --full
1 passed in 2514.14s (0:41:54)
elapsed=41:54.48

Throughput benchmark:

flock -n .compute.lock uv run python benchmarks/throughput.py

# Optional CUDA check without making CUDA a project dependency:
flock -n .compute.lock uv run --with 'jax[cuda12]' python benchmarks/throughput.py

Measured on 2026-07-04 with CPU JAX backend:

backend=cpu
batch_size=8192
steps=256
elapsed_sec=4.655526
steps_per_sec=450465.14

Measured on 2026-07-04 with CUDA JAX backend on RTX 3090, using the optional uv run --with 'jax[cuda12]' ... command:

backend=gpu
batch_size=8192
steps=256
elapsed_sec=0.530039
steps_per_sec=3956598.38

DECISIONS.md

  • Explicit deck_order dealing uses the first eight cards for player 0 and the next eight for player 1. The remaining cards are drawn from index 16. This is equivalent under a uniform shuffle and is fixed by tests.
  • After a legal terminal transition, to_move is advanced to the next player, but done=True makes all later steps complete no-ops.
  • Terminal reward is emitted only on the transition that reaches done=True. Done-state no-op steps return zero reward.
  • Observation scalar normalization is implementation-defined as documented above and locked by the exported OBS_DIM.

JAX PPO Static-Opponent Ladder

The first training stack above lost_cities_jax is exposed as:

uv run lost-cities-jax-ppo --help

CPU smoke:

uv run lost-cities-jax-ppo rollout-smoke --config configs/jax_ppo/smoke.yaml
uv run lost-cities-jax-ppo train --config configs/jax_ppo/smoke.yaml

GPU training uses optional CUDA JAX, keeping CUDA wheels out of the default project dependency set:

tmux new-session -s coolrl-jax-ppo-discard \
  -c /home/coolguy/dev/coolrl-lost-cities \
  "flock -n .compute.lock uv run --with 'jax[cuda12]' lost-cities-jax-ppo train \
    --config configs/jax_ppo/discard-only.yaml"

Random-policy baseline vs discard_only, measured on 2026-07-04 with 8192 games x 400 plies on GPU:

return_mean=-55.582763671875
game_length_mean=69.6085205078125
max_steps_rate=0.0
play_action_rate=0.28854578733444214
opened_colors_mean=4.942626953125
positive_expeditions_mean=0.41796875

Static-opponent gate results, measured on 2026-07-04 with 10,000 fixed shuffles and duplicate seat-swapped evaluation:

Gate Opponent Result Win rate (Wilson 95%) Mean score diff Mean length Positive expeditions/game
1 discard_only PASS 1.00000 [0.99981, 1.00000] 204.56335 82.45495 3.2783
2 heuristic_balanced PASS 0.98655 [0.98486, 0.98806] 116.83400 167.81450 3.9573
3 heuristic_cautious PASS 0.95955 [0.95673, 0.96219] 142.89930 185.38690 4.0946

Large PPO artifacts are written under /mnt/2tbhdd/coolrl-lost-cities-artifacts/jax-ppo-static-opponents/. Generated checkpoints and evaluation JSON are not committed to git. The full run summary is in docs/reports/jax-ppo-static-opponent-ladder-2026-07-04.md.

Basic Usage

from coolrl_lost_cities.games.classic import GameState, build_bot, classic_config

state = GameState.new_game(classic_config(seed=1))
bot = build_bot("random", seed=1)

while not state.terminal:
    state.apply_action(bot.act(state))

print(state.total_score(0), state.total_score(1))

See classic port notes for the current direction.

S
Description
Lost Cities
Readme
7.7 MiB
Languages
Python 73.3%
Cython 21.7%
Julia 4.8%
Shell 0.2%