#!/usr/bin/env python3 """Freeze each policy, train an exploiter against it on the same budget, compare. Winning the head-to-head says a policy is strong on average. It does not say the policy is hard to beat. This does: whatever the exploiter reaches is a habit the frozen policy could not defend. """ from __future__ import annotations import argparse import json from pathlib import Path import jax import numpy as np from lost_cities_jax.exploit import ( match_frozen_policy, single_round_frozen_policy, train_exploiter, ) from lost_cities_jax.match import match_reset_from, match_score from lost_cities_jax.match_eval import MATCH_SCAN_STEPS, _wilson, match_bank from lost_cities_jax.match_ppo import Ablation, create_match_train_state from lost_cities_jax.ppo import create_train_state, load_config, restore_checkpoint OURS = Path("runs/jax-ppo-match/2026-07-15_031529_match-scaled/latest") LEAGUE = Path( "/mnt/2tbhdd/coolrl-lost-cities-artifacts/league/2026-07-05_052325_jax-ppo-league-v1/latest" ) MATCHES = 4096 def _final_score(cfg, exploiter_params, frozen, matches: int) -> dict: """Play the trained exploiter against the frozen policy, both seats.""" import jax.numpy as jnp from lost_cities_jax.exploit import match_frozen_policy as _mk from lost_cities_jax.match import match_step attacker = _mk(cfg, exploiter_params, Ablation()) decks, coins = match_bank(20260722, matches) @jax.jit def run(env, a_seat): def body(carry, _): env, _u = carry action = jnp.where( env.round.to_move.astype(jnp.int32) == a_seat, attacker(env, a_seat), frozen(env, 1 - a_seat), ) env, _, _ = jax.vmap(match_step, in_axes=(0, 0))(env, action) return (env, _u), None (env, _), _ = jax.lax.scan(body, (env, jnp.int32(0)), xs=None, length=MATCH_SCAN_STEPS) return env leads = [] for seat in (0, 1): env = jax.vmap(match_reset_from)(decks, coins) final = run(env, jnp.full((matches,), seat, dtype=jnp.int32)) totals = np.asarray(jax.vmap(match_score)(final)) leads.append(totals[:, seat] - totals[:, 1 - seat]) lead = np.concatenate(leads) games = float(lead.size) wins = float((lead > 0).sum()) low, high = _wilson(wins, games) return { "exploiter_win_rate": wins / games, "wilson_low": low, "wilson_high": high, "exploiter_mean_lead": float(lead.mean()), "matches": games, } def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--updates", type=int, default=250, help="exploiter training updates") parser.add_argument("--batch-games", type=int, default=1024) parser.add_argument("--tag", default="", help="suffix for the run dir, to keep runs apart") args = parser.parse_args() match_cfg = load_config("configs/jax_ppo/match-selfplay.yaml") old_cfg = load_config("configs/jax_ppo/balanced.yaml") ours = restore_checkpoint( OURS, create_match_train_state(match_cfg, jax.random.PRNGKey(0), Ablation()) ).params league = restore_checkpoint(LEAGUE, create_train_state(old_cfg, jax.random.PRNGKey(0))).params targets = { "ours (match stack, 131M)": match_frozen_policy(match_cfg, ours, Ablation()), "league (web-deployed)": single_round_frozen_policy(old_cfg, league), } rows = [] for name, frozen in targets.items(): print(f"\n===== training an exploiter against: {name} =====", flush=True) cfg = load_config("configs/jax_ppo/match-selfplay.yaml") cfg.ppo.batch_games = args.batch_games cfg.run.total_updates = args.updates cfg.run.log_every = max(1, args.updates // 10) slug = name.split()[0] + args.tag state = train_exploiter(cfg, frozen, Path(f"runs/jax-ppo-match/exploit-{slug}")) result = _final_score(cfg, state.params, frozen, MATCHES) rows.append({"target": name, **result}) print(f" exploiter reached {result['exploiter_win_rate']:.4f} vs {name}", flush=True) print("\n\n============ exploitability (same exploiter budget) ============") print(f"{'frozen policy':<28}{'exploiter win rate':>20}{'95% CI':>22}") print("-" * 72) for row in rows: ci = f"[{row['wilson_low']:.3f}, {row['wilson_high']:.3f}]" print(f"{row['target']:<28}{row['exploiter_win_rate']:>20.4f}{ci:>22}") print("\nhigher = the frozen policy had more to farm. 0.5 = nothing found.") Path(f"runs/jax-ppo-match/exploitability{args.tag}.json").write_text(json.dumps(rows, indent=2)) if __name__ == "__main__": main()