Plan JAX PPO static-opponent ladder
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
# Plan: JAX PPO Static-Opponent Ladder
|
||||
|
||||
**Status:** Ready to implement.
|
||||
**Owner:** Codex implements; operator reviews training gates.
|
||||
**Scope:** A compact PPO training stack on top of `lost_cities_jax`, using GPU
|
||||
via optional CUDA JAX execution.
|
||||
|
||||
## Goal
|
||||
|
||||
Build the first learning layer above the JAX Lost Cities rules engine:
|
||||
|
||||
1. A batched rollout driver using `batched_reset`, `batched_step`,
|
||||
`batched_legal_mask`, and `batched_obs`.
|
||||
2. Three static pure-JAX opponent policies:
|
||||
`discard_only`, `heuristic_balanced`, and `heuristic_cautious`.
|
||||
3. A 3 x 512 MLP actor-critic with 96-action policy logits and scalar value.
|
||||
4. Standard PPO training against one static opponent at a time.
|
||||
5. Duplicate evaluation on a fixed 10,000-deck shuffle bank.
|
||||
6. Orbax checkpoints, metrics logging, and a CLI:
|
||||
`lost-cities-jax-ppo train --config ...`.
|
||||
|
||||
The near-term objective is not league self-play. It is to pass the
|
||||
static-opponent diagnostic ladder and preserve enough artifacts that a later
|
||||
self-play failure can be localized cleanly.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No league self-play, snapshot pool, Elo, or opponent matchmaking in this
|
||||
phase.
|
||||
- No MCTS, CFR, search, or neural opponent ensemble.
|
||||
- No multi-round match wrapper.
|
||||
- No rule variants, expanded colors, or extra players.
|
||||
- No large binary checkpoints committed to git. Generated artifacts are stored
|
||||
outside the code repo, with small manifests and summaries committed.
|
||||
|
||||
## GPU Execution
|
||||
|
||||
Keep the project dependency portable. Do not make CUDA wheels mandatory in
|
||||
`pyproject.toml`.
|
||||
|
||||
Use optional CUDA JAX for training and GPU benchmarks:
|
||||
|
||||
```bash
|
||||
flock -n .compute.lock uv run --with 'jax[cuda12]' lost-cities-jax-ppo train \
|
||||
--config configs/jax_ppo/discard-only.yaml
|
||||
```
|
||||
|
||||
The CPU path must still work for tests:
|
||||
|
||||
```bash
|
||||
uv run pytest -q tests/lost_cities_jax
|
||||
```
|
||||
|
||||
Current benchmark evidence on RTX 3090:
|
||||
|
||||
```text
|
||||
backend=gpu
|
||||
batch_size=8192
|
||||
steps=256
|
||||
steps_per_sec=3956598.38
|
||||
```
|
||||
|
||||
## Implementation Shape
|
||||
|
||||
Prefer a small number of files while keeping testable boundaries:
|
||||
|
||||
```text
|
||||
src/lost_cities_jax/
|
||||
ppo.py # config, network, rollout, PPO update, train loop
|
||||
opponents.py # pure-JAX static opponent policy functions
|
||||
eval.py # duplicate evaluation and Wilson intervals
|
||||
ppo_cli.py # argparse CLI
|
||||
configs/jax_ppo/
|
||||
discard-only.yaml
|
||||
balanced.yaml
|
||||
cautious.yaml
|
||||
```
|
||||
|
||||
If the PPO implementation remains readable in one main file, keep it there.
|
||||
Split only when a file becomes hard to test or review.
|
||||
|
||||
Dependencies likely needed:
|
||||
|
||||
- `flax` for model modules and train state.
|
||||
- `optax` for Adam and PPO losses.
|
||||
- `orbax-checkpoint` for checkpointing.
|
||||
|
||||
Add them through `uv add`, not pip/conda/poetry.
|
||||
|
||||
## Static Opponents
|
||||
|
||||
All opponent policies are pure JAX functions:
|
||||
|
||||
```python
|
||||
policy_fn(state: State, player: jax.Array) -> jax.Array # int32 action
|
||||
```
|
||||
|
||||
They must sample no Python-side randomness. If tie-breaking needs randomness,
|
||||
pass a JAX key explicitly:
|
||||
|
||||
```python
|
||||
policy_fn(state, player, rng) -> action
|
||||
```
|
||||
|
||||
Policies:
|
||||
|
||||
- `discard_only`: always discard a legal hand slot and draw from deck when
|
||||
legal. This opponent should make free expedition building easy.
|
||||
- `heuristic_balanced`: prefer legal plays that improve expedition prospects,
|
||||
avoid obviously toxic openings, draw useful discard tops when available.
|
||||
- `heuristic_cautious`: stricter opening threshold, fewer negative expedition
|
||||
commitments, more conservative discard/draw behavior.
|
||||
|
||||
Opponent policies are not learning targets. They are diagnostic fixtures.
|
||||
|
||||
## Rollout Driver
|
||||
|
||||
Run 8192 games in parallel by default.
|
||||
|
||||
The learner controls one fixed seat per rollout batch. The opponent occupies
|
||||
the other seat. Because games alternate turns, each environment step chooses:
|
||||
|
||||
- learner action from the actor policy when `state.to_move == learner_seat`;
|
||||
- static opponent action otherwise.
|
||||
|
||||
Done states remain no-op through the engine, so the rollout can keep a fixed
|
||||
time axis. Use `MAX_STEPS == 400` as the scan length unless a shorter config
|
||||
value is explicitly introduced.
|
||||
|
||||
The first milestone is random-policy rollout with the full dashboard. This is
|
||||
not throwaway; it defines the baseline canaries.
|
||||
|
||||
Log canaries:
|
||||
|
||||
- episode return;
|
||||
- score difference;
|
||||
- game length distribution;
|
||||
- `max_steps` termination rate;
|
||||
- `play_action_rate`;
|
||||
- opened color count;
|
||||
- positive expedition count per game;
|
||||
- average entropy under legal-action masking.
|
||||
|
||||
Record the random-policy baseline in `README.md` before PPO training starts.
|
||||
|
||||
## PPO Details
|
||||
|
||||
Network:
|
||||
|
||||
- Input: `OBS_DIM` observation vector.
|
||||
- Body: MLP 512 -> 512 -> 512, ReLU.
|
||||
- Policy head: 96 logits.
|
||||
- Value head: scalar.
|
||||
- Illegal actions are masked to a large negative value before sampling and
|
||||
before log-prob/loss computation.
|
||||
|
||||
Training defaults:
|
||||
|
||||
```yaml
|
||||
ppo:
|
||||
batch_games: 8192
|
||||
rollout_steps: 400
|
||||
gamma: 1.0
|
||||
gae_lambda: 0.95
|
||||
clip_epsilon: 0.2
|
||||
entropy_coef: 0.01
|
||||
value_coef: 0.5
|
||||
max_grad_norm: 0.5
|
||||
learning_rate: 0.0003
|
||||
epochs: 4
|
||||
minibatches: 8
|
||||
reward:
|
||||
terminal_scale: 50.0
|
||||
potential_shaping_initial: 1.0
|
||||
potential_shaping_final: 0.0
|
||||
potential_shaping_anneal_steps: 5_000_000
|
||||
```
|
||||
|
||||
Terminal reward:
|
||||
|
||||
```text
|
||||
tanh((learner_score - opponent_score) / terminal_scale)
|
||||
```
|
||||
|
||||
Potential shaping:
|
||||
|
||||
```text
|
||||
coef(t) * (board_score_diff_after - board_score_diff_before)
|
||||
```
|
||||
|
||||
The shaping coefficient anneals linearly from `initial` to `final`. Expose all
|
||||
four shaping fields in config. This shaping exists to prevent the previous
|
||||
play-action-rate collapse by giving immediate credit for board progress; its
|
||||
schedule is a controlled experiment variable, not a hidden constant.
|
||||
|
||||
## Evaluation Gates
|
||||
|
||||
Evaluation uses a fixed shuffle bank:
|
||||
|
||||
- Generate 10,000 explicit `deck_order` permutations from a fixed seed.
|
||||
- For each deck, play twice:
|
||||
- learner as player 0, opponent as player 1;
|
||||
- opponent as player 0, learner as player 1.
|
||||
- Aggregate duplicate-pair results.
|
||||
|
||||
Report:
|
||||
|
||||
- win rate;
|
||||
- Wilson confidence interval;
|
||||
- mean score difference;
|
||||
- mean game length;
|
||||
- positive expedition count per game;
|
||||
- opened colors;
|
||||
- `play_action_rate`.
|
||||
|
||||
Gates:
|
||||
|
||||
| Gate | Opponent | Pass condition | Diagnosis if failed |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | `discard_only` | win rate >= 90% and >= 2 positive expeditions/game | Audit reward pipeline, observations, and masks. Self-play is irrelevant. |
|
||||
| 2 | `heuristic_balanced` | mean score difference > 0 | Learner works; tune shaping schedule and batch/variance. |
|
||||
| 3 | `heuristic_cautious` | mean score difference > 0 | Same as gate 2; static ladder still not cleared. |
|
||||
|
||||
Only after all three gates pass should league self-play begin.
|
||||
|
||||
## Artifact Policy
|
||||
|
||||
Generated training output stays out of git. Use:
|
||||
|
||||
```text
|
||||
/mnt/2tbhdd/coolrl-lost-cities-artifacts/jax-ppo-static-opponents/
|
||||
```
|
||||
|
||||
Per gate, preserve:
|
||||
|
||||
- checkpoint directory;
|
||||
- resolved config;
|
||||
- duplicate evaluation JSON;
|
||||
- metrics JSONL;
|
||||
- short summary markdown with command, git commit, seed, and pass/fail result.
|
||||
|
||||
Commit only small, durable metadata to the code repo:
|
||||
|
||||
- config files;
|
||||
- evaluator/training code;
|
||||
- README baseline and gate summaries;
|
||||
- optional dated summary under docs/reports.
|
||||
|
||||
If binary artifact tracking is later required, add DVC/Git LFS explicitly
|
||||
instead of committing large checkpoint files directly.
|
||||
|
||||
## Work Order
|
||||
|
||||
### Phase 1: Rollout Dashboard
|
||||
|
||||
1. Add static opponent policies.
|
||||
2. Add random-policy batched rollout.
|
||||
3. Log canary metrics:
|
||||
returns, game length, max-step rate, `play_action_rate`, opened colors,
|
||||
positive expeditions.
|
||||
4. Run CPU tests and one GPU random rollout.
|
||||
5. Record the random baseline in README.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- deterministic smoke rollout passes;
|
||||
- canary metrics look finite and stable;
|
||||
- `play_action_rate` and game length are logged before any PPO code is judged.
|
||||
|
||||
### Phase 2: PPO Against `discard_only`
|
||||
|
||||
1. Add MLP actor-critic and masked action sampling.
|
||||
2. Add GAE and PPO update.
|
||||
3. Add Orbax checkpoints and resume.
|
||||
4. Add CLI and the discard-only config under configs/jax_ppo.
|
||||
5. Train on GPU.
|
||||
|
||||
First-run checks:
|
||||
|
||||
- with shaping enabled, `play_action_rate` rises above random baseline within
|
||||
the first few hundred thousand environment steps;
|
||||
- average game length converges near natural 44-ply games;
|
||||
- `max_steps` termination rate goes to zero.
|
||||
|
||||
Then anneal shaping and run duplicate evaluation for gate 1.
|
||||
|
||||
### Phase 3: Static Ladder
|
||||
|
||||
1. Reuse the same config and checkpoint flow.
|
||||
2. Change only the opponent config for `heuristic_balanced`.
|
||||
3. Train/evaluate until gate 2 passes or diagnostics point to variance.
|
||||
4. Repeat for `heuristic_cautious`.
|
||||
|
||||
No self-play work starts before gate 3 passes.
|
||||
|
||||
## Commands
|
||||
|
||||
Smoke rollout:
|
||||
|
||||
```bash
|
||||
uv run lost-cities-jax-ppo rollout-smoke --config configs/jax_ppo/discard-only.yaml
|
||||
```
|
||||
|
||||
GPU training in tmux:
|
||||
|
||||
```bash
|
||||
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"
|
||||
```
|
||||
|
||||
Duplicate evaluation:
|
||||
|
||||
```bash
|
||||
flock -n .compute.lock uv run --with 'jax[cuda12]' lost-cities-jax-ppo eval \
|
||||
--checkpoint /mnt/2tbhdd/coolrl-lost-cities-artifacts/jax-ppo-static-opponents/<run>/latest \
|
||||
--opponent discard_only \
|
||||
--shuffle-bank-seed 20260704 \
|
||||
--games 10000 \
|
||||
--duplicate \
|
||||
--output /mnt/2tbhdd/coolrl-lost-cities-artifacts/jax-ppo-static-opponents/<run>/eval_duplicate.json
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
- The engine is fast on GPU, but Python logging/evaluation can dominate if
|
||||
metrics are copied every step. Aggregate in JAX and transfer per rollout.
|
||||
- Static heuristics can accidentally become too weak or too strong. Keep them
|
||||
deterministic and versioned by config.
|
||||
- Potential shaping can teach score-chasing artifacts if it never anneals.
|
||||
Treat the coefficient schedule as part of the experiment identity.
|
||||
- Checkpoint volume can grow quickly. Store large artifacts under `/mnt/2tbhdd`
|
||||
and keep only `latest` plus gate checkpoints unless a run is explicitly
|
||||
archived.
|
||||
|
||||
## Current Next Action
|
||||
|
||||
Implement Phase 1: static opponents plus random-policy batched rollout with the
|
||||
full dashboard, then record the random baseline before adding PPO updates.
|
||||
Reference in New Issue
Block a user