#!/usr/bin/env python3 """What is each piece of the match stack worth? Train one variant per switch at identical compute, then play each against the full stack in duplicate matches -- same three deals, same coins, both seats -- so deal luck cancels and only the policy difference is left. The variants do not share an observation shape (dropping the privileged critic or the match features changes the input dims), so this carries its own head-to-head rather than reusing match_eval's, which assumes one architecture. """ from __future__ import annotations import json from pathlib import Path import jax import jax.numpy as jnp import numpy as np from lost_cities_jax.match import ( match_legal_action_mask, match_reset_from, match_score, match_step, ) from lost_cities_jax.match_eval import MATCH_SCAN_STEPS, _wilson, match_bank from lost_cities_jax.match_ppo import ( Ablation, MatchActorCritic, _seat_views, create_match_train_state, match_train, ) from lost_cities_jax.ppo import load_config, mask_logits, restore_checkpoint VARIANTS = [ Ablation(privileged_critic=False), Ablation(both_seats=False), Ablation(match_obs=False), ] MATCHES = 4096 FULL_CHECKPOINT = Path("runs/jax-ppo-match/2026-07-15_030152_match-linear/latest") def _load(cfg, checkpoint: Path, ablation: Ablation): state = create_match_train_state(cfg, jax.random.PRNGKey(0), ablation) return restore_checkpoint(checkpoint, state).params def _existing_run(label: str) -> Path | None: runs = sorted(Path("runs/jax-ppo-match").glob(f"*ablate-{label}")) for run in reversed(runs): if (run / "latest").exists(): return run return None def duel(cfg, params_a, ablation_a, params_b, ablation_b, *, matches: int) -> dict: """A vs B over duplicate matches. Each side sees the world its own way.""" decks, coins = match_bank(20260719, matches) model = MatchActorCritic(cfg.network.hidden_size, cfg.network.num_layers) seats = jnp.arange(2, dtype=jnp.int32) @jax.jit def run(env, a_seat): def body(carry, _): env, _unused = carry to_move = env.round.to_move.astype(jnp.int32) mask = jax.vmap(match_legal_action_mask)(env) def act(params, ablation): obs, critic = _seat_views(env, seats, ablation) idx = to_move[None, :, None] seat_obs = jnp.take_along_axis(obs, idx, axis=0)[0] seat_critic = jnp.take_along_axis(critic, idx, axis=0)[0] logits, _ = model.apply(params, seat_obs, seat_critic) return jnp.argmax(mask_logits(logits, mask), axis=-1).astype(jnp.int32) action = jnp.where( to_move == a_seat, act(params_a, ablation_a), act(params_b, ablation_b) ) env, _, _ = jax.vmap(match_step, in_axes=(0, 0))(env, action) return (env, _unused), 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 { "matches": games, "a_win_rate": wins / games, "wilson_low": low, "wilson_high": high, "a_mean_lead": float(lead.mean()), } def main() -> None: cfg = load_config("configs/jax_ppo/match-selfplay.yaml") full = _load(cfg, FULL_CHECKPOINT, Ablation()) rows = [] for ablation in VARIANTS: label = ablation.label() run_dir = _existing_run(label) if run_dir is None: print(f"\n===== training {label} =====", flush=True) cfg = load_config("configs/jax_ppo/match-selfplay.yaml") cfg.run.experiment_name = f"ablate-{label}" run_dir = match_train(cfg, ablation=ablation) else: print(f"\n===== reusing {run_dir} =====", flush=True) cfg = load_config("configs/jax_ppo/match-selfplay.yaml") params = _load(cfg, run_dir / "latest", ablation) # The ablated net plays seat A, the full stack answers. Below 0.5 means # the piece we removed was carrying weight. result = duel(cfg, params, ablation, full, Ablation(), matches=MATCHES) rows.append({"ablation": label, **result}) print(f" {label}: win rate vs full = {result['a_win_rate']:.4f}", flush=True) print("\n\n==================== ablation ====================") print(f"{'removed':<26}{'win rate vs full':>18}{'95% CI':>22}{'mean lead':>12}") print("-" * 78) for row in rows: ci = f"[{row['wilson_low']:.3f}, {row['wilson_high']:.3f}]" print( f"{row['ablation']:<26}{row['a_win_rate']:>18.4f}{ci:>22}{row['a_mean_lead']:>+12.1f}" ) print("\nbelow 0.5 = the removed piece was carrying weight") Path("runs/jax-ppo-match/ablation.json").write_text(json.dumps(rows, indent=2, sort_keys=True)) if __name__ == "__main__": main()