Skip to main content

thrust_rl/multi_agent/
psro.rs

1//! Policy-Space Response Oracles (PSRO) meta-game trainer.
2//!
3//! Burn-native implementation of the PSRO outer loop (Lanctot et al.
4//! 2017, [arXiv:1711.00832](https://arxiv.org/abs/1711.00832)) for
5//! 2-agent zero-sum games. Tracking issue: #107.
6//!
7//! # Pseudocode
8//!
9//! ```text
10//! Population[i] = {π_i^(0)}   for each agent i      (initial random policy)
11//! repeat for k = 1..K:
12//!     1. Empirical game G_k = payoff matrix between Population[0] × Population[1]
13//!     2. Meta-Nash σ_k = MetaSolver.solve(G_k)
14//!     3. For each agent i in {0, 1}:
15//!         a. Sample opponent policy from σ_k[1-i]
16//!         b. Train π_i^(k) as best response to that mixture
17//!         c. Append π_i^(k) to Population[i]
18//!     4. Update payoff matrix with new row/column
19//! end
20//! ```
21//!
22//! # Why an in-tree Rust meta-solver instead of `bucket-brigade-core`?
23//!
24//! Issue #107's original framing called for wiring
25//! `bucket-brigade-core::nash::DoubleOracleSolver` (Rust) in as the
26//! meta-solver. Upon investigation, the DO solver in
27//! `envs/bucket-brigade@6486a549fc` is **Python**, not Rust
28//! (`bucket_brigade.equilibrium.double_oracle_heterogeneous.py`). The
29//! `bucket-brigade-core` Rust crate exposes only `agents`, `engine`,
30//! `rng`, `scenarios` — no `nash` module exists. Calling into Python
31//! from a Rust trainer would introduce a runtime Python dependency
32//! contrary to thrust's pure-Rust posture (and the
33//! `bucket-brigade-core` dep is itself feature-gated off for v0.1.0
34//! because the crate is not on crates.io). We instead define a
35//! `MetaSolver` trait with three in-tree Rust implementations:
36//!
37//! - [`UniformMetaSolver`] — degenerate uniform mixture. Always available;
38//!   serves as the unit-test baseline.
39//! - [`FictitiousPlayMetaSolver`] — deterministic fictitious-play meta-solver.
40//!   No external LP dependency.
41//! - [`ReplicatorDynamicsMetaSolver`] — non-trivial mixed-Nash solver via
42//!   projected replicator dynamics. No LP dependency; converges to the
43//!   symmetric Nash on small empirical games (≤50 strategies).
44//!
45//! See the issue's curator comment
46//! ([#107c-4704239526](https://github.com/rjwalters/thrust/issues/107#issuecomment-4704239526))
47//! for the full rationale and the deferred Option 1 (port the Python
48//! solver to Rust upstream).
49//!
50//! # Per-agent observation handling
51//!
52//! PSRO builds on top of
53//! [`crate::multi_agent::joint::JointMultiAgentTrainer`], which records
54//! a *per-agent* observation stream in
55//! [`JointRollout::observations_per_agent`]. Envs with distinct
56//! per-agent views (partial observability, asymmetric information)
57//! drop in without pre-concatenation. Matching pennies returns
58//! identical observations to both agents, which keeps the regression
59//! tests bit-stable through the per-agent refactor (PR #118).
60//!
61//! # Population growth & cost
62//!
63//! Population grows monotonically — one new best-response policy per
64//! PSRO iteration per agent. Per-iteration cost scales linearly in
65//! population size (one BR train + one `n × n` meta-solver call). The
66//! empirical-payoff matrix is cached: only the new row/column is
67//! evaluated each iteration (existing entries are unchanged by
68//! construction). Memory is quadratic in iteration count; bound it via
69//! [`PsroConfig::max_population_size`] (default 50). The trainer
70//! returns `Err` (not panic) when the cap is hit.
71//!
72//! # What this module ships in the first PR
73//!
74//! - The `MetaSolver` trait + three implementations.
75//! - The `PsroTrainer` outer loop with a freeze-N-1 helper.
76//! - The matching-pennies smoke test
77//!   ([`crate::env::games::matching_pennies::MatchingPennies`]).
78//!
79//! # What is deferred to follow-up PRs
80//!
81//! The full set of acceptance criteria from the curator's comment also
82//! call for a bucket-brigade integration test (gated behind
83//! `env-bucket-brigade`) and a `train_psro.rs` example with the
84//! `gap_closed_homogeneous` metric. Those depend on locally
85//! re-enabling the `env-bucket-brigade` feature (the crate is
86//! path-only and disabled in the published Cargo.toml) and porting
87//! the metric from
88//! `envs/bucket-brigade/experiments/scripts/compute_nash_phase_diagram.py`.
89//! Both are tracked as cleavage point #3 in the curator's open
90//! question; see PR description for the deferred-pieces summary.
91
92use anyhow::{Result, anyhow};
93use burn::{optim::Optimizer, tensor::backend::AutodiffBackend};
94use rand::{Rng, SeedableRng, rngs::StdRng};
95use rayon::prelude::*;
96
97use crate::{
98    multi_agent::joint::{
99        JointEnv, JointMultiAgentTrainer, JointPolicy, JointStats, JointTrainerConfig,
100    },
101    train::optimizer::BurnOptimizer,
102};
103
104// =======================================================================
105// MetaSolver trait + implementations
106// =======================================================================
107
108/// Meta-solver over a symmetric 2-player zero-sum empirical game.
109///
110/// Given an `n × n` row-player payoff matrix `payoffs[i][j]`
111/// representing the expected return of row-player strategy `i` versus
112/// column-player strategy `j`, returns the row-player's mixed-Nash
113/// distribution as a length-`n` probability vector summing to `1.0`.
114///
115/// For symmetric zero-sum games (matching pennies, the
116/// homogeneous-policy version of bucket brigade) the column-player's
117/// equilibrium is the same distribution by symmetry — callers can use
118/// the row distribution for both agents. For non-symmetric games, this
119/// trait is invoked twice (once per agent role) with appropriately
120/// transposed payoff matrices.
121pub trait MetaSolver {
122    /// Solve for the row-player mixed-Nash on a symmetric `n × n`
123    /// empirical payoff matrix.
124    ///
125    /// # Contract
126    ///
127    /// - Input is assumed to be `n × n` and square; non-square inputs produce
128    ///   undefined behaviour (impl is free to panic).
129    /// - Return vector has length `n` with non-negative entries summing to
130    ///   `1.0` (within `1e-6` tolerance).
131    fn solve(&self, payoffs: &[Vec<f32>]) -> Vec<f32>;
132
133    /// N-player solve over an explicit per-agent payoff tensor.
134    ///
135    /// `payoffs` is shape `(k^num_agents, num_agents)` where
136    /// `payoffs[s][a]` is agent `a`'s payoff at joint pure strategy
137    /// `s`. The flat joint-strategy index decomposes into per-agent
138    /// indices via little-endian mixed-radix (agent 0 = fastest):
139    /// `s = Σ_i s_i · k^i`.
140    ///
141    /// Returns a probability vector of length `k^num_agents` summing
142    /// to `1.0 ± 1e-6`.
143    ///
144    /// # Default
145    ///
146    /// The default implementation only supports `num_agents == 2` and
147    /// delegates to `solve` via the row-marginal projection. For
148    /// `num_agents > 2` it panics with a message naming the solver.
149    /// Only [`AlphaRankMetaSolver`] overrides this method with a true
150    /// N-player path; the other in-tree solvers (`UniformMetaSolver`,
151    /// `FictitiousPlayMetaSolver`, `ReplicatorDynamicsMetaSolver`)
152    /// have no N>2 generalization with the same convergence
153    /// guarantees and intentionally panic.
154    fn solve_n_player(
155        &self,
156        _payoffs: &[Vec<f32>],
157        num_agents: usize,
158        _per_role_k: usize,
159    ) -> Vec<f32> {
160        panic!(
161            "{} does not support num_agents = {}; only 2-player meta-games. \
162             Use AlphaRankMetaSolver for N > 2.",
163            self.name(),
164            num_agents
165        );
166    }
167
168    /// Human-readable name for diagnostics / logging.
169    fn name(&self) -> &'static str;
170}
171
172/// Degenerate uniform meta-solver.
173///
174/// Returns `[1/n; n]` independent of the payoff matrix. Useful as the
175/// `n = 1` initial-iteration solver and as a unit-test baseline.
176#[derive(Debug, Clone, Default)]
177pub struct UniformMetaSolver;
178
179impl MetaSolver for UniformMetaSolver {
180    fn solve(&self, payoffs: &[Vec<f32>]) -> Vec<f32> {
181        let n = payoffs.len().max(1);
182        vec![1.0 / n as f32; n]
183    }
184
185    fn name(&self) -> &'static str {
186        "uniform"
187    }
188}
189
190/// Fictitious-play meta-solver.
191///
192/// Deterministic: each iteration, the row-player best-responds to the
193/// column-player's empirical mixture, the column-player best-responds
194/// to the row-player's empirical mixture, and both empirical mixtures
195/// are updated. After `iterations` rounds the empirical row mixture
196/// converges to the Nash on zero-sum games (Brown 1951, Robinson
197/// 1951). No external LP dependency.
198///
199/// # Tuning
200///
201/// The default `iterations = 1000` is overkill for `n ≤ 8` but cheap
202/// (each step is `O(n²)`). For very small empirical games this is
203/// equivalent to (and slightly more robust than)
204/// [`ReplicatorDynamicsMetaSolver`].
205#[derive(Debug, Clone)]
206pub struct FictitiousPlayMetaSolver {
207    iterations: usize,
208}
209
210impl FictitiousPlayMetaSolver {
211    /// Construct with `iterations` fictitious-play rounds.
212    pub fn new(iterations: usize) -> Self {
213        Self { iterations: iterations.max(1) }
214    }
215}
216
217impl Default for FictitiousPlayMetaSolver {
218    fn default() -> Self {
219        Self::new(1000)
220    }
221}
222
223impl MetaSolver for FictitiousPlayMetaSolver {
224    fn solve(&self, payoffs: &[Vec<f32>]) -> Vec<f32> {
225        let n = payoffs.len();
226        if n == 0 {
227            return Vec::new();
228        }
229        if n == 1 {
230            return vec![1.0];
231        }
232        // Empirical action counts; we'll normalize at the end.
233        let mut row_counts = vec![0.0_f32; n];
234        let mut col_counts = vec![0.0_f32; n];
235        // Seed both empirical mixtures with one count on the first strategy.
236        // (Standard fictitious-play initialization.)
237        row_counts[0] = 1.0;
238        col_counts[0] = 1.0;
239
240        for _ in 0..self.iterations {
241            // Column mixture
242            let col_total: f32 = col_counts.iter().sum();
243            let col_mix: Vec<f32> = col_counts.iter().map(|&c| c / col_total).collect();
244            // Row best-responds: maximize expected row payoff against col_mix.
245            let row_br = best_response_row(payoffs, &col_mix);
246            row_counts[row_br] += 1.0;
247
248            // Row mixture
249            let row_total: f32 = row_counts.iter().sum();
250            let row_mix: Vec<f32> = row_counts.iter().map(|&r| r / row_total).collect();
251            // Col best-responds: minimize expected row payoff against row_mix
252            // (since zero-sum, equivalent to maximizing -row payoff).
253            let col_br = best_response_col(payoffs, &row_mix);
254            col_counts[col_br] += 1.0;
255        }
256
257        let total: f32 = row_counts.iter().sum();
258        if total <= 0.0 {
259            return vec![1.0 / n as f32; n];
260        }
261        row_counts.iter().map(|&c| c / total).collect()
262    }
263
264    fn name(&self) -> &'static str {
265        "fictitious_play"
266    }
267}
268
269/// Replicator-dynamics meta-solver.
270///
271/// Projected replicator dynamics: iterate
272/// `x_i ← x_i * (1 + η * (f_i − x · f))` followed by a non-negative
273/// renormalization, where `f_i = Σ_j A[i][j] x_j` is the expected row
274/// payoff for pure strategy `i` against the current mixture, and `η`
275/// is a step size. For symmetric zero-sum games this converges to a
276/// symmetric Nash equilibrium (Hofbauer & Sigmund 2003) without needing
277/// an LP solver. Slightly faster than fictitious play on
278/// continuous-payoff matrices but less robust to ties.
279#[derive(Debug, Clone)]
280pub struct ReplicatorDynamicsMetaSolver {
281    iterations: usize,
282    step_size: f32,
283}
284
285impl ReplicatorDynamicsMetaSolver {
286    /// Construct with `iterations` updates at the given `step_size`.
287    pub fn new(iterations: usize, step_size: f32) -> Self {
288        Self { iterations: iterations.max(1), step_size: step_size.max(1e-6) }
289    }
290}
291
292impl Default for ReplicatorDynamicsMetaSolver {
293    fn default() -> Self {
294        Self::new(2000, 0.05)
295    }
296}
297
298impl MetaSolver for ReplicatorDynamicsMetaSolver {
299    fn solve(&self, payoffs: &[Vec<f32>]) -> Vec<f32> {
300        let n = payoffs.len();
301        if n == 0 {
302            return Vec::new();
303        }
304        if n == 1 {
305            return vec![1.0];
306        }
307        // Start from uniform.
308        let mut x = vec![1.0 / n as f32; n];
309        for _ in 0..self.iterations {
310            // Per-strategy expected payoff: f_i = Σ_j A[i][j] * x_j
311            let mut f = vec![0.0_f32; n];
312            for (i, row) in payoffs.iter().enumerate() {
313                let mut fi = 0.0_f32;
314                for (j, &a) in row.iter().enumerate() {
315                    fi += a * x[j];
316                }
317                f[i] = fi;
318            }
319            // Mean payoff over the mixture.
320            let mean_f: f32 = x.iter().zip(f.iter()).map(|(xi, fi)| xi * fi).sum();
321            // Replicator update with non-negativity projection.
322            let mut new_x: Vec<f32> = x
323                .iter()
324                .zip(f.iter())
325                .map(|(xi, fi)| (xi * (1.0 + self.step_size * (fi - mean_f))).max(0.0))
326                .collect();
327            // Renormalize.
328            let total: f32 = new_x.iter().sum();
329            if total <= 1e-12 {
330                // Degenerate (all entries zeroed out); fall back to uniform.
331                return vec![1.0 / n as f32; n];
332            }
333            for v in new_x.iter_mut() {
334                *v /= total;
335            }
336            x = new_x;
337        }
338        x
339    }
340
341    fn name(&self) -> &'static str {
342        "replicator_dynamics"
343    }
344}
345
346/// α-rank meta-solver (Omidshafiei et al. 2019,
347/// [Nature Sci Reports 9:9937](https://doi.org/10.1038/s41598-019-45619-9)).
348///
349/// Computes the stationary distribution of a Markov chain over joint
350/// pure strategies where transitions follow Moran-process mutation
351/// dynamics: at each step a random agent is selected, a random
352/// deviation strategy is proposed for that agent, and the deviation is
353/// accepted with probability proportional to
354/// `1 / (1 + exp(−α · (payoff_after − payoff_before)))`.
355///
356/// **Guarantee shipped:** highest stationary mass under the
357/// response-graph Moran dynamics — NOT ε-Nash. The α-rank ordering
358/// captures the dynamic strength of strategies but does not coincide
359/// with the Nash equilibrium support in general (Omidshafiei et al.
360/// 2019 §2 + Discussion). Use this solver when the goal is N-player
361/// ranking over joint pure strategies, not Nash refinement.
362///
363/// # API surfaces
364///
365/// Two entry points are provided:
366///
367/// - **[`AlphaRankMetaSolver::solve`] (`MetaSolver` trait)**: takes a symmetric
368///   `n × n` payoff matrix `payoffs[i][j]` (row-player payoff when row plays
369///   strategy `i` against column strategy `j`) and computes the α-rank
370///   stationary distribution over the `n` strategies. This is the *2-player
371///   symmetric* path and is used for the random-payoff sanity tests. The
372///   returned distribution has length `n`.
373/// - **[`AlphaRankMetaSolver::solve_n_player`]**: takes a per-agent payoff
374///   tensor of shape `(num_joint_strategies, num_agents)` where `payoffs[s][a]`
375///   is agent `a`'s scalar payoff at joint pure strategy `s`, plus the number
376///   of agents and the per-agent per-role population size `k`. The total number
377///   of joint strategies must equal `k^num_agents`. Returns the stationary
378///   distribution over the `k^num_agents` joint strategies. This is the *true
379///   N-player* path used by the PSRO N > 2 branch.
380///
381/// # Defaults (per Omidshafiei §2.3)
382///
383/// - `ranking_intensity_alpha = 10.0` — the response-graph ranking intensity.
384///   Larger values sharpen the deviation acceptance probability; the paper's
385///   experiments use `α ∈ [1, 100]`.
386/// - `moran_population_size = 50` — the Moran population size `m` parameter
387///   controlling fixation probability magnitudes. The paper recommends `m ≥
388///   10`.
389/// - `max_iterations = 200` — power-iteration cap.
390/// - `tolerance = 1e-6` — power-iteration convergence threshold on L1 distance
391///   between successive distributions.
392#[derive(Debug, Clone)]
393pub struct AlphaRankMetaSolver {
394    /// Response-graph ranking intensity α.
395    pub ranking_intensity_alpha: f32,
396    /// Moran population size m.
397    pub moran_population_size: u32,
398    /// Maximum power-iteration steps.
399    pub max_iterations: usize,
400    /// Power-iteration L1 convergence tolerance.
401    pub tolerance: f32,
402    /// When `true`, normalize each Moran payoff differential
403    /// `delta = π_τ − π_σ` by the **payoff span** of the input tensor
404    /// (`max − min` over all per-agent payoffs) before multiplying by α
405    /// (issue #215).
406    ///
407    /// # Why this matters
408    ///
409    /// The Moran fixation probability is driven by `α · delta` (see
410    /// `moran_fixation_probability`). α-rank's defaults
411    /// (`α = 10`, `m = 50`) were validated on the `{−1, +1}`
412    /// matching-pennies game, where `|delta| ≤ 2` and `α · delta ≤ 20`
413    /// — comfortably inside the regime where the fixation probability is
414    /// a graded sigmoid-like function of the payoff advantage. On the
415    /// bucket-brigade `[−700, 0]` payoff band, `|delta|` can reach ~700
416    /// and `α · delta ≈ 7000`, which **saturates** every non-neutral
417    /// transition to a hard 0 or 1. The graded Moran dynamics collapse
418    /// into a degenerate deterministic best-response graph, and the
419    /// resulting stationary distribution is acutely sensitive to tiny
420    /// payoff-estimate noise — a plausible contributor to the
421    /// exploitability *divergence* observed on the no-convergence cells
422    /// (issue #215, #198).
423    ///
424    /// When enabled, the differential is rescaled to
425    /// `delta_norm = delta / span` (with `span = max − min`, guarded
426    /// against a degenerate zero span), so the **effective** selection
427    /// strength `α · delta_norm` lands in the same `[−α, α]` band the
428    /// defaults were tuned for regardless of the absolute payoff
429    /// magnitude. This is the α-rank analogue of NFSP's / PSRO's
430    /// `br_reward_scale`: a magnitude-invariance fix, not a change to
431    /// the ranking semantics on a fixed scale.
432    ///
433    /// `false` (the default) preserves the pre-#215 behavior bit-for-bit.
434    pub normalize_payoff_span: bool,
435}
436
437impl AlphaRankMetaSolver {
438    /// Construct with explicit hyperparameters. Payoff-span
439    /// normalization defaults to `false` (pre-#215 behavior). Use
440    /// [`AlphaRankMetaSolver::with_payoff_span_normalization`] to opt in.
441    pub fn new(
442        ranking_intensity_alpha: f32,
443        moran_population_size: u32,
444        max_iterations: usize,
445        tolerance: f32,
446    ) -> Self {
447        Self {
448            ranking_intensity_alpha,
449            moran_population_size: moran_population_size.max(2),
450            max_iterations: max_iterations.max(1),
451            tolerance: tolerance.max(1e-12),
452            normalize_payoff_span: false,
453        }
454    }
455
456    /// Builder-style setter: enable/disable payoff-span normalization of
457    /// the Moran payoff differential (issue #215). See
458    /// [`AlphaRankMetaSolver::normalize_payoff_span`] for the rationale.
459    pub fn with_payoff_span_normalization(mut self, enabled: bool) -> Self {
460        self.normalize_payoff_span = enabled;
461        self
462    }
463
464    /// Inherent N-player α-rank stationary distribution helper.
465    ///
466    /// This is the workhorse implementation called by the
467    /// [`MetaSolver::solve_n_player`] trait override below. Kept as a
468    /// separate inherent method so callers with a concrete
469    /// `AlphaRankMetaSolver` (e.g. the in-tree unit tests at
470    /// `test_alpha_rank_three_player_rps_*`) can invoke it without
471    /// going through trait dispatch.
472    ///
473    /// # Inputs
474    ///
475    /// - `payoffs`: shape `(num_joint_strategies, num_agents)` where
476    ///   `payoffs[s][a]` is agent `a`'s payoff at joint pure strategy `s`.
477    /// - `num_agents`: number of agents `N` in the game.
478    /// - `per_role_k`: per-agent per-role population size `k` (assumed
479    ///   identical across agents in this PR — matches the symmetric PSRO
480    ///   posture).
481    ///
482    /// # Joint-strategy index encoding
483    ///
484    /// Strategy index `s` decomposes into per-agent indices
485    /// `(s_0, s_1, ..., s_{N-1})` with `s_i ∈ [0, k)` via
486    /// **little-endian** mixed-radix: `s = Σ_i s_i * k^i`. Agent 0 is
487    /// the fastest-varying index.
488    ///
489    /// # Returns
490    ///
491    /// A probability vector of length `k^N` summing to `1.0 ± 1e-6`.
492    pub fn solve_n_player_impl(
493        &self,
494        payoffs: &[Vec<f32>],
495        num_agents: usize,
496        per_role_k: usize,
497    ) -> Vec<f32> {
498        assert!(num_agents >= 1, "α-rank requires num_agents >= 1");
499        assert!(per_role_k >= 1, "α-rank requires per_role_k >= 1");
500        let n_joint = per_role_k.checked_pow(num_agents as u32).expect("k^N overflow");
501        if payoffs.len() != n_joint {
502            panic!(
503                "α-rank: payoffs.len() = {} but expected k^N = {}^{} = {}",
504                payoffs.len(),
505                per_role_k,
506                num_agents,
507                n_joint
508            );
509        }
510        for (s, row) in payoffs.iter().enumerate() {
511            assert_eq!(
512                row.len(),
513                num_agents,
514                "α-rank: payoffs[{s}].len() = {} but expected num_agents = {}",
515                row.len(),
516                num_agents
517            );
518        }
519
520        // Build the row-stochastic transition matrix P over joint
521        // strategies. For each joint strategy `s` and each single-agent
522        // deviation `(s, s')` where `s'` differs from `s` in exactly
523        // one agent, transition with the Moran fixation probability
524        // (Omidshafiei et al. 2019 §2.3, Eq. 1):
525        //
526        //   ρ_{σ→τ} = (1 - exp(-α (π_τ - π_σ))) / (1 - exp(-mα (π_τ - π_σ)))
527        //
528        // where `m = moran_population_size` and the payoff differential
529        // `π_τ - π_σ` is from the perspective of the mutating agent.
530        // The neutral case `π_τ == π_σ` collapses to `1/m`.
531        //
532        // We aggregate the per-deviation probabilities by averaging
533        // over the uniform choice of (agent to mutate, deviation
534        // target). Self-loop probability is whatever mass isn't
535        // transferred to single-agent deviations. The number of
536        // single-agent deviations from `s` is `num_agents * (per_role_k - 1)`;
537        // each deviation contributes `(1 / n_deviations) * ρ_{σ→τ}` to
538        // the transition mass.
539        let n_deviations = num_agents * per_role_k.saturating_sub(1);
540        let per_dev_weight: f32 = if n_deviations > 0 {
541            1.0_f32 / n_deviations as f32
542        } else {
543            0.0
544        };
545
546        // Optional payoff-span normalization (issue #215). When enabled,
547        // every Moran payoff differential is divided by the payoff span
548        // (`max − min` over all per-agent payoffs) so the effective
549        // selection strength `α · (delta / span)` stays in the `[−α, α]`
550        // band the defaults were tuned for, regardless of the absolute
551        // payoff magnitude. This prevents the fixation probability from
552        // saturating to a hard 0/1 on large-magnitude bands (e.g.
553        // bucket-brigade's `[−700, 0]`). `1.0` divisor (the default,
554        // normalization off — or a degenerate flat payoff tensor) is a
555        // no-op and keeps the path bit-identical.
556        let delta_divisor: f32 = if self.normalize_payoff_span {
557            let mut min_v = f32::INFINITY;
558            let mut max_v = f32::NEG_INFINITY;
559            for row in payoffs.iter() {
560                for &v in row.iter() {
561                    if v < min_v {
562                        min_v = v;
563                    }
564                    if v > max_v {
565                        max_v = v;
566                    }
567                }
568            }
569            let span = max_v - min_v;
570            // Guard against a flat / degenerate tensor: a zero (or
571            // non-finite) span leaves the differential untouched.
572            if span.is_finite() && span > 1e-12 {
573                span
574            } else {
575                1.0
576            }
577        } else {
578            1.0
579        };
580
581        // Sparse-friendly transition rep: per-state out-edges as
582        // `Vec<(to_index, prob)>`. With n_joint potentially in the
583        // thousands and only `n_deviations` non-self entries per row,
584        // this saves space vs the full matrix.
585        let mut transitions: Vec<Vec<(usize, f32)>> = Vec::with_capacity(n_joint);
586        for s in 0..n_joint {
587            let mut row_edges: Vec<(usize, f32)> = Vec::with_capacity(n_deviations + 1);
588            let mut self_mass: f32 = 1.0;
589            let from_payoffs = &payoffs[s];
590            // Decompose `s` into per-agent indices once.
591            let s_components = decompose_joint_index(s, num_agents, per_role_k);
592            for agent in 0..num_agents {
593                let from_strat = s_components[agent];
594                for new_strat in 0..per_role_k {
595                    if new_strat == from_strat {
596                        continue;
597                    }
598                    let mut t_components = s_components.clone();
599                    t_components[agent] = new_strat;
600                    let t = compose_joint_index(&t_components, per_role_k);
601                    let to_payoff_a = payoffs[t][agent];
602                    let from_payoff_a = from_payoffs[agent];
603                    let p_fix = moran_fixation_probability(
604                        self.ranking_intensity_alpha,
605                        self.moran_population_size,
606                        (to_payoff_a - from_payoff_a) / delta_divisor,
607                    );
608                    let edge_prob = per_dev_weight * p_fix;
609                    row_edges.push((t, edge_prob));
610                    self_mass -= edge_prob;
611                }
612            }
613            // Self-loop: whatever mass remains. May be negative under
614            // numerical noise; clamp to zero.
615            if self_mass < 0.0 {
616                self_mass = 0.0;
617            }
618            row_edges.push((s, self_mass));
619            // Renormalize defensively to ensure row-stochastic.
620            let row_sum: f32 = row_edges.iter().map(|(_, p)| *p).sum();
621            if row_sum > 0.0 {
622                for (_, p) in row_edges.iter_mut() {
623                    *p /= row_sum;
624                }
625            }
626            transitions.push(row_edges);
627        }
628
629        // Power iteration: π_{k+1}[t] = Σ_s π_k[s] * P[s][t].
630        let mut pi = vec![1.0_f32 / n_joint as f32; n_joint];
631        let mut pi_next = vec![0.0_f32; n_joint];
632        for _ in 0..self.max_iterations {
633            for v in pi_next.iter_mut() {
634                *v = 0.0;
635            }
636            for (s, edges) in transitions.iter().enumerate() {
637                let pis = pi[s];
638                if pis == 0.0 {
639                    continue;
640                }
641                for &(t, p) in edges {
642                    pi_next[t] += pis * p;
643                }
644            }
645            // L1 convergence check.
646            let mut l1: f32 = 0.0;
647            for i in 0..n_joint {
648                l1 += (pi_next[i] - pi[i]).abs();
649            }
650            std::mem::swap(&mut pi, &mut pi_next);
651            // Renormalize (numerical safety).
652            let total: f32 = pi.iter().sum();
653            if total > 0.0 {
654                for v in pi.iter_mut() {
655                    *v /= total;
656                }
657            }
658            if l1 < self.tolerance {
659                break;
660            }
661        }
662        pi
663    }
664}
665
666impl Default for AlphaRankMetaSolver {
667    fn default() -> Self {
668        // Payoff-span normalization defaults OFF to keep the
669        // matching-pennies regression bar and the `solve` API
670        // bit-identical to the pre-#215 solver. Opt in via
671        // `with_payoff_span_normalization(true)` for large-magnitude
672        // payoff bands like bucket-brigade's `[−700, 0]`.
673        Self::new(10.0, 50, 200, 1e-6)
674    }
675}
676
677impl MetaSolver for AlphaRankMetaSolver {
678    /// 2-player symmetric α-rank: interprets `payoffs[i][j]` as the row
679    /// player's payoff and computes the α-rank stationary distribution
680    /// over the `n` pure strategies under the symmetric self-play
681    /// assumption (both players draw from the same population). For the
682    /// 2-player symmetric case this collapses to the `solve_n_player`
683    /// path with `num_agents = 1` over the row-player marginal —
684    /// equivalent to treating the column player's payoff structure as
685    /// the row's negation under zero-sum symmetry.
686    fn solve(&self, payoffs: &[Vec<f32>]) -> Vec<f32> {
687        let n = payoffs.len();
688        if n == 0 {
689            return Vec::new();
690        }
691        if n == 1 {
692            return vec![1.0];
693        }
694        // 2-player symmetric: each agent's payoff at joint strategy
695        // `s = (i, j)` is `payoffs[i][j]` for the row and
696        // `payoffs[j][i]` for the column (transposed). Compute α-rank
697        // over the `n²` joint strategies and marginalize back to the
698        // row distribution.
699        let n2 = n * n;
700        let mut joint_payoffs = vec![vec![0.0_f32; 2]; n2];
701        // Index-based scan: explicit mixed-radix encoding of the joint
702        // strategy index `s = i + j * n` (little-endian, agent 0
703        // fastest). The clippy::needless_range_loop rewrite would
704        // require nested `.enumerate()` chains that obscure the
705        // little-endian convention; suppress to keep the math readable.
706        #[allow(clippy::needless_range_loop)]
707        for i in 0..n {
708            for j in 0..n {
709                let s = i + j * n;
710                joint_payoffs[s][0] = payoffs[i][j];
711                joint_payoffs[s][1] = payoffs[j][i];
712            }
713        }
714        let joint_dist = self.solve_n_player_impl(&joint_payoffs, 2, n);
715        // Marginalize: row distribution = Σ_j π(i, j).
716        let mut row_dist = vec![0.0_f32; n];
717        #[allow(clippy::needless_range_loop)]
718        for i in 0..n {
719            for j in 0..n {
720                row_dist[i] += joint_dist[i + j * n];
721            }
722        }
723        // Renormalize numerically.
724        let total: f32 = row_dist.iter().sum();
725        if total > 0.0 {
726            for v in row_dist.iter_mut() {
727                *v /= total;
728            }
729        } else {
730            return vec![1.0 / n as f32; n];
731        }
732        row_dist
733    }
734
735    fn solve_n_player(
736        &self,
737        payoffs: &[Vec<f32>],
738        num_agents: usize,
739        per_role_k: usize,
740    ) -> Vec<f32> {
741        self.solve_n_player_impl(payoffs, num_agents, per_role_k)
742    }
743
744    fn name(&self) -> &'static str {
745        "alpha_rank"
746    }
747}
748
749/// Decompose a flat joint-strategy index into per-agent components
750/// under the little-endian mixed-radix convention (agent 0 = fastest).
751///
752/// This is the index convention shared between
753/// [`AlphaRankMetaSolver::solve_n_player_impl`] and the N-tensor
754/// [`PayoffCache`]; both must use the same encoding for the PSRO
755/// trainer to correctly route per-joint-strategy payoffs to α-rank.
756pub(crate) fn decompose_joint_index(s: usize, num_agents: usize, k: usize) -> Vec<usize> {
757    let mut out = vec![0_usize; num_agents];
758    let mut rem = s;
759    for slot in out.iter_mut().take(num_agents) {
760        *slot = rem % k;
761        rem /= k;
762    }
763    out
764}
765
766/// Compose per-agent components into a flat joint-strategy index under
767/// the little-endian mixed-radix convention.
768pub(crate) fn compose_joint_index(components: &[usize], k: usize) -> usize {
769    let mut s = 0_usize;
770    let mut radix = 1_usize;
771    for &c in components {
772        s += c * radix;
773        radix *= k;
774    }
775    s
776}
777
778/// Decide which boundary cells to actually roll out this iteration, and
779/// how to fill the rest, given an optional per-iteration evaluation cap
780/// (issue #212).
781///
782/// Returns `(to_evaluate, fill_from)`:
783/// - `to_evaluate` is the deterministic subset of `boundary` cells to roll out
784///   (in the *same relative order* as `boundary`, so the downstream rayon
785///   evaluation and cache writes stay deterministic).
786/// - `fill_from` is a list of `(boundary_dst_index, to_evaluate_src_index)`
787///   pairs: boundary cell `boundary[dst]` (which was *not* selected) is to be
788///   filled by copying the payoff of the selected cell evaluated at
789///   `to_evaluate[src]`.
790///
791/// # Selection scheme
792///
793/// - `cap == None`, or `boundary.len() <= cap`: **all** cells are selected and
794///   `fill_from` is empty. This is the default path and is **bit-identical** to
795///   evaluating the whole boundary (the pre-#212 behavior).
796/// - `Some(cap)` with `boundary.len() > cap >= 1`: select `cap` cells by an
797///   evenly-spaced deterministic stride over the boundary index range (`sel_idx
798///   = floor(j * len / cap)` for `j in 0..cap`), guaranteeing a stratified,
799///   reproducible cover that always includes the first cell. Every non-selected
800///   cell is filled from the nearest *preceding* selected cell (the largest
801///   selected index `<= its index`), which is well-defined because index 0 is
802///   always selected. Selection depends only on `(boundary.len(), cap)` — never
803///   on RNG or thread order — so the subsampled meta-game is itself fully
804///   deterministic.
805///
806/// `cap == Some(0)` is treated as `Some(1)` (always roll out at least one
807/// cell) so the boundary is never left entirely unevaluated.
808#[allow(clippy::type_complexity)]
809fn select_boundary_to_evaluate(
810    boundary: &[Vec<usize>],
811    cap: Option<usize>,
812) -> (Vec<Vec<usize>>, Vec<(usize, usize)>) {
813    let len = boundary.len();
814    let cap = match cap {
815        None => return (boundary.to_vec(), Vec::new()),
816        Some(c) => c.max(1),
817    };
818    if len <= cap {
819        return (boundary.to_vec(), Vec::new());
820    }
821    // Evenly-spaced stratified selection over [0, len). `selected[j]` is
822    // the boundary index chosen as the j-th sample; strictly increasing
823    // and always starts at 0.
824    let mut selected: Vec<usize> = Vec::with_capacity(cap);
825    for j in 0..cap {
826        let idx = (j * len) / cap;
827        // Guard against a repeated index from integer flooring (cannot
828        // happen for len > cap >= 1, but keep the invariant explicit).
829        if selected.last().copied() != Some(idx) {
830            selected.push(idx);
831        }
832    }
833    // Map each boundary index to the src position (within `to_evaluate`)
834    // of the nearest preceding selected cell.
835    let to_evaluate: Vec<Vec<usize>> = selected.iter().map(|&i| boundary[i].clone()).collect();
836    let mut fill_from: Vec<(usize, usize)> = Vec::with_capacity(len - selected.len());
837    let mut src = 0_usize; // position within `selected` / `to_evaluate`
838    for dst in 0..len {
839        // Advance `src` while the next selected index is still <= dst.
840        while src + 1 < selected.len() && selected[src + 1] <= dst {
841            src += 1;
842        }
843        if selected[src] == dst {
844            continue; // this cell is itself evaluated; nothing to fill
845        }
846        fill_from.push((dst, src));
847    }
848    (to_evaluate, fill_from)
849}
850
851/// Numerically-stable sigmoid `1 / (1 + exp(-x))`.
852#[allow(dead_code)]
853fn sigmoid(x: f32) -> f32 {
854    if x >= 0.0 {
855        let z = (-x).exp();
856        1.0 / (1.0 + z)
857    } else {
858        let z = x.exp();
859        z / (1.0 + z)
860    }
861}
862
863/// Moran-process fixation probability for a single mutant under
864/// selection intensity `α` in a population of size `m`, given the
865/// payoff differential `delta = π_τ − π_σ` (from the perspective of
866/// the mutating agent — positive means the mutation improves payoff).
867///
868/// Closed form (Omidshafiei et al. 2019 §2.3, Eq. 1; see also Nowak
869/// "Evolutionary Dynamics" §6):
870///
871/// ```text
872/// ρ(α, m, δ) = (1 - exp(-α δ)) / (1 - exp(-m α δ))     if δ ≠ 0
873///            = 1 / m                                     if δ = 0  (neutral drift)
874/// ```
875///
876/// The neutral-drift limit `1/m` is the standard small-perturbation
877/// expansion of the closed form as `δ → 0`. For numerical stability we
878/// use it directly when `|α δ| < 1e-9`.
879fn moran_fixation_probability(alpha: f32, m: u32, delta: f32) -> f32 {
880    let m_f = m as f32;
881    let ad = alpha * delta;
882    if ad.abs() < 1e-9 {
883        return 1.0 / m_f;
884    }
885    // Numerator: 1 - exp(-α δ); Denominator: 1 - exp(-m α δ).
886    let num = 1.0 - (-ad).exp();
887    let denom = 1.0 - (-m_f * ad).exp();
888    if denom.abs() < 1e-30 {
889        // Saturated regime: very strong selection in one direction.
890        // ρ ≈ 0 if denom→0 from below, or ρ ≈ 1 if num and denom
891        // both blow up positively. Return the sign-based limit.
892        return if ad > 0.0 { 1.0 } else { 0.0 };
893    }
894    let p = num / denom;
895    p.clamp(0.0, 1.0)
896}
897
898/// Row-player pure best response to column mixture `col_mix`.
899fn best_response_row(payoffs: &[Vec<f32>], col_mix: &[f32]) -> usize {
900    let mut best_i = 0;
901    let mut best_val = f32::NEG_INFINITY;
902    for (i, row) in payoffs.iter().enumerate() {
903        let mut val = 0.0_f32;
904        for (j, &p) in col_mix.iter().enumerate() {
905            val += row[j] * p;
906        }
907        if val > best_val {
908            best_val = val;
909            best_i = i;
910        }
911    }
912    best_i
913}
914
915/// Column-player pure best response to row mixture `row_mix` (assuming
916/// zero-sum: column minimizes expected row payoff).
917fn best_response_col(payoffs: &[Vec<f32>], row_mix: &[f32]) -> usize {
918    let n = payoffs.len();
919    let mut best_j = 0;
920    let mut best_val = f32::INFINITY;
921    // Column-major scan: outer loop indexes columns `j`, inner loop indexes
922    // rows `i` via `payoffs[i][j]`. The index-based form mirrors the
923    // bilinear-form math `(σᵀ M)_j` and reads more directly than an
924    // iter-of-iters rewrite.
925    #[allow(clippy::needless_range_loop)]
926    for j in 0..n {
927        let val: f32 = row_mix.iter().enumerate().map(|(i, &p)| payoffs[i][j] * p).sum();
928        if val < best_val {
929            best_val = val;
930            best_j = j;
931        }
932    }
933    best_j
934}
935
936// =======================================================================
937// PsroConfig / PsroStats
938// =======================================================================
939
940/// PSRO trainer configuration.
941#[derive(Debug, Clone)]
942pub struct PsroConfig {
943    /// Number of PSRO outer-loop iterations to run.
944    pub max_iterations: usize,
945    /// Maximum population size per agent. Iteration is aborted with an
946    /// `Err` (not a panic) when this is reached.
947    pub max_population_size: usize,
948    /// Number of joint-trainer updates spent training each new
949    /// best-response policy against the sampled mixture.
950    pub br_train_steps_per_iteration: usize,
951    /// Number of payoff-evaluation episodes per `(row, col)` cell in
952    /// the empirical-payoff matrix.
953    pub payoff_eval_episodes: usize,
954    /// Optional cap on the number of *fresh* payoff-cell evaluations
955    /// performed per outer iteration (issue #212).
956    ///
957    /// PSRO grows each agent's population by one policy per iteration,
958    /// so the only cells that need (re)evaluation are the **boundary
959    /// slab**: joint strategies in which at least one agent plays its
960    /// brand-new policy. Interior cells (between pre-existing policies)
961    /// are already cached across iterations and never recomputed — see
962    /// [`PayoffCache::resize_for_boundary`]. The boundary slab itself
963    /// still grows as `(k+1)^N − k^N ≈ N·k^(N-1)` cells, which for the
964    /// 4-player bucket-brigade game (`N = 4`) is super-linear and
965    /// dominates long-run cost even with the rayon-parallel evaluation
966    /// (#203) — see the 2026-06-21 calibration in
967    /// `docs/research/2026-06-bucket-brigade-validation.md`.
968    ///
969    /// When set to `Some(cap)` and an iteration's boundary slab has more
970    /// than `cap` cells, the trainer **deterministically subsamples**
971    /// `cap` boundary cells to actually roll out (preserving the
972    /// rayon-parallel evaluation for those), and fills each un-sampled
973    /// boundary cell from the nearest already-evaluated sampled cell in
974    /// the deterministic flat ordering. This bounds per-iteration cost
975    /// at the price of an **approximate** meta-game on the subsampled
976    /// boundary.
977    ///
978    /// `None` (the default) evaluates the entire boundary slab and is
979    /// therefore **bit-identical** to the pre-#212 behavior. The
980    /// subsampling path is purely opt-in; existing callers and the
981    /// determinism discipline (#201) are unaffected.
982    pub max_payoff_evals_per_iteration: Option<usize>,
983    /// Optional reward scaling applied to per-step rewards before the
984    /// best-response (PPO) update, mirroring
985    /// [`NfspConfig::br_reward_scale`](crate::multi_agent::nfsp::NfspConfig::br_reward_scale)
986    /// (issue #199 / #215).
987    ///
988    /// PSRO trains each new best response with the same joint PPO update
989    /// as NFSP's BR side, so it inherits the same numerical pathology on
990    /// the large-magnitude bucket-brigade payoff band (`[−700, 0]`): the
991    /// unscaled rewards drive the critic's regression targets and the
992    /// per-minibatch advantage normalization into a range where the
993    /// value loss dominates the surrogate and the BR effectively stops
994    /// learning a meaningful response. Scaling rewards by a constant is
995    /// an affine transform of the return — it does **not** change the
996    /// optimal policy — but keeps the critic targets and advantage stats
997    /// numerically friendly. A value like `0.01` rescales the
998    /// bucket-brigade band to roughly `[−7, 0]`.
999    ///
1000    /// `1.0` (the default) is a no-op and preserves the pre-#215
1001    /// behavior bit-for-bit.
1002    pub br_reward_scale: f32,
1003    /// RNG seed for opponent sampling and deterministic tests.
1004    pub seed: u64,
1005}
1006
1007impl Default for PsroConfig {
1008    fn default() -> Self {
1009        Self {
1010            max_iterations: 10,
1011            max_population_size: 50,
1012            br_train_steps_per_iteration: 1,
1013            payoff_eval_episodes: 8,
1014            max_payoff_evals_per_iteration: None,
1015            br_reward_scale: 1.0,
1016            seed: 0,
1017        }
1018    }
1019}
1020
1021/// Per-iteration PSRO statistics.
1022#[derive(Debug, Clone, Default)]
1023pub struct PsroIterationStats {
1024    /// Iteration index (1-based after the initial population is seeded).
1025    pub iteration: usize,
1026    /// Population size at the end of this iteration (per agent;
1027    /// identical across agents under the symmetric posture).
1028    pub population_size: usize,
1029    /// Per-agent meta-Nash *action-population* marginal distributions
1030    /// at the end of this iteration. `meta_nash_per_agent[i]` is agent
1031    /// `i`'s marginal over its own `population_size` policies extracted
1032    /// from the joint α-rank distribution (for N≥3) or directly from
1033    /// the 2-player solver (for N=2).
1034    pub meta_nash_per_agent: Vec<Vec<f32>>,
1035    /// Per-agent best-response training stats. `br_stats_per_agent[i]`
1036    /// is the stats for the round in which agent `i` was active under
1037    /// the round-robin schedule, or `None` if the agent was not the
1038    /// active agent on this iteration (currently every agent is
1039    /// trained every iteration, so every entry is `Some`).
1040    pub br_stats_per_agent: Vec<Option<JointStats>>,
1041    /// NashConv-style exploitability: the sum over agents `i` of agent
1042    /// `i`'s maximum payoff improvement by deviating to a pure best
1043    /// response in the empirical game, given the joint meta-Nash
1044    /// distribution.
1045    ///
1046    /// For N=2 zero-sum games this reduces to the original 2-player
1047    /// formula (row gain + column gain). Smaller is closer to the
1048    /// empirical equilibrium.
1049    pub exploitability: f32,
1050}
1051
1052impl PsroIterationStats {
1053    /// Backward-compat shim: agent 0 (row-player) meta-Nash
1054    /// distribution. Equivalent to `&self.meta_nash_per_agent[0]`.
1055    pub fn meta_nash_row(&self) -> &[f32] {
1056        self.meta_nash_per_agent.first().map(|v| v.as_slice()).unwrap_or(&[])
1057    }
1058
1059    /// Backward-compat shim: agent 1 (column-player) meta-Nash
1060    /// distribution. Equivalent to `&self.meta_nash_per_agent[1]`.
1061    pub fn meta_nash_col(&self) -> &[f32] {
1062        self.meta_nash_per_agent.get(1).map(|v| v.as_slice()).unwrap_or(&[])
1063    }
1064}
1065
1066/// Aggregate PSRO trainer statistics returned by [`PsroTrainer::run`].
1067#[derive(Debug, Clone, Default)]
1068pub struct PsroStats {
1069    /// Per-iteration history.
1070    pub iterations: Vec<PsroIterationStats>,
1071}
1072
1073// =======================================================================
1074// Empirical-payoff matrix cache
1075// =======================================================================
1076
1077/// Cached N-tensor empirical-payoff cache for an N-agent symmetric
1078/// game.
1079///
1080/// Stores per-agent payoffs at every joint pure strategy `s ∈ [0, k^N)`
1081/// where `k` is the per-agent population size (assumed identical across
1082/// agents under the symmetric posture) and `N` is the number of agents.
1083///
1084/// # Index convention
1085///
1086/// The flat joint-strategy index decomposes into per-agent indices
1087/// `(s_0, s_1, ..., s_{N-1})` via **little-endian mixed-radix**:
1088/// `s = Σ_i s_i · k^i`. Agent 0 is the fastest-varying index. This
1089/// convention matches [`AlphaRankMetaSolver::solve_n_player_impl`] —
1090/// the cache feeds its `cells` buffer directly into α-rank with no
1091/// transpose.
1092///
1093/// # Storage
1094///
1095/// `cells[s]` is a `Vec<f32>` of length `num_agents` containing each
1096/// agent's mean per-episode return at joint strategy `s`. The
1097/// per-cell allocation matches α-rank's `payoffs[s][a]` input shape.
1098/// For N=2 with k populations, this collapses to k² cells × 2-element
1099/// vectors — identical information to the pre-refactor `Vec<Vec<f32>>`
1100/// row-major matrix but with the per-cell agent payoffs co-located.
1101///
1102/// # Growth
1103///
1104/// PSRO grows each agent's population by one policy per outer
1105/// iteration. When agent `a`'s population grows from `k` to `k+1`,
1106/// the cache needs to evaluate the new boundary slab: all joint
1107/// strategies where agent `a` plays index `k` (its new policy).
1108/// [`PayoffCache::resize_for_boundary`] grows the storage to the new
1109/// `(k+1)^N` size; [`PayoffCache::set_cell`] writes individual cell
1110/// payoffs. The trainer is responsible for iterating over the
1111/// agent-`a`-newest-strategy boundary and calling `set_cell` for each
1112/// new joint strategy.
1113///
1114/// Memory is `O(k^N · N · f32)`, bounded by
1115/// [`PsroConfig::max_population_size`] cubed (or higher for N>3); the
1116/// `PsroConfig::max_population_size` cap should be tuned downward for
1117/// large N to keep memory reasonable.
1118#[derive(Debug, Clone, Default)]
1119pub struct PayoffCache {
1120    /// Per-joint-strategy per-agent payoffs. `cells[s][a]` is agent
1121    /// `a`'s mean per-episode return at joint strategy `s`. Indexed
1122    /// little-endian (agent 0 = fastest).
1123    cells: Vec<Vec<f32>>,
1124    /// Per-agent population size `k`. Assumed identical across agents
1125    /// under the symmetric posture.
1126    per_role_k: usize,
1127    /// Number of agents `N`.
1128    num_agents: usize,
1129    /// Counter incremented on every payoff *evaluation* (not every
1130    /// query). Used by unit tests to assert the cache is hit.
1131    pub eval_count: usize,
1132}
1133
1134impl PayoffCache {
1135    /// Construct an empty cache.
1136    pub fn new() -> Self {
1137        Self::default()
1138    }
1139
1140    /// Construct a cache sized for `num_agents` agents with `per_role_k = 0`
1141    /// (empty). Use [`PayoffCache::resize_for_boundary`] to grow.
1142    pub fn with_num_agents(num_agents: usize) -> Self {
1143        Self { cells: Vec::new(), per_role_k: 0, num_agents, eval_count: 0 }
1144    }
1145
1146    /// Current per-role population size `k`.
1147    pub fn per_role_k(&self) -> usize {
1148        self.per_role_k
1149    }
1150
1151    /// Number of agents `N`.
1152    pub fn num_agents(&self) -> usize {
1153        self.num_agents
1154    }
1155
1156    /// Total number of joint-strategy cells `k^N`.
1157    pub fn num_cells(&self) -> usize {
1158        self.cells.len()
1159    }
1160
1161    /// Read the per-agent payoffs at joint strategy `joint`. Returns
1162    /// `None` if any per-agent index is out of bounds. The returned
1163    /// slice has length `num_agents`.
1164    pub fn get_joint(&self, joint: &[usize]) -> Option<&[f32]> {
1165        if joint.len() != self.num_agents {
1166            return None;
1167        }
1168        for (a, &idx) in joint.iter().enumerate() {
1169            if idx >= self.per_role_k {
1170                return None;
1171            }
1172            let _ = a;
1173        }
1174        let s = compose_joint_index(joint, self.per_role_k);
1175        self.cells.get(s).map(|v| v.as_slice())
1176    }
1177
1178    /// View the full per-cell payoff tensor in the
1179    /// `(k^N, N)` flat layout consumed by
1180    /// [`AlphaRankMetaSolver::solve_n_player`]. The outer length is
1181    /// `k^N`; each inner `Vec<f32>` has length `num_agents`.
1182    pub fn payoff_tensor(&self) -> &[Vec<f32>] {
1183        &self.cells
1184    }
1185
1186    /// Set the per-agent payoffs at joint strategy `joint`. Bumps
1187    /// `eval_count` by 1. Panics if the cache isn't sized for `joint`
1188    /// (call [`PayoffCache::resize_for_boundary`] first) or if the
1189    /// payoff length doesn't equal `num_agents`.
1190    pub fn set_cell(&mut self, joint: &[usize], payoffs: Vec<f32>) {
1191        assert_eq!(
1192            joint.len(),
1193            self.num_agents,
1194            "joint strategy length {} must equal num_agents = {}",
1195            joint.len(),
1196            self.num_agents
1197        );
1198        assert_eq!(
1199            payoffs.len(),
1200            self.num_agents,
1201            "payoffs length {} must equal num_agents = {}",
1202            payoffs.len(),
1203            self.num_agents
1204        );
1205        for (a, &idx) in joint.iter().enumerate() {
1206            assert!(
1207                idx < self.per_role_k,
1208                "joint[{a}] = {idx} >= per_role_k = {}",
1209                self.per_role_k
1210            );
1211        }
1212        let s = compose_joint_index(joint, self.per_role_k);
1213        self.cells[s] = payoffs;
1214        self.eval_count += 1;
1215    }
1216
1217    /// Set the per-agent payoffs at joint strategy `joint` **without**
1218    /// bumping `eval_count`.
1219    ///
1220    /// Used by the issue-#212 boundary-subsampling path to fill an
1221    /// un-sampled boundary cell with a reused payoff (copied from an
1222    /// already-evaluated sampled neighbour). Such a fill performs **no
1223    /// fresh rollout**, so it must not be counted as an evaluation —
1224    /// `eval_count` continues to reflect only the cells that were
1225    /// actually rolled out. Same bounds/asserts as [`Self::set_cell`].
1226    pub fn set_cell_no_count(&mut self, joint: &[usize], payoffs: Vec<f32>) {
1227        assert_eq!(
1228            joint.len(),
1229            self.num_agents,
1230            "joint strategy length {} must equal num_agents = {}",
1231            joint.len(),
1232            self.num_agents
1233        );
1234        assert_eq!(
1235            payoffs.len(),
1236            self.num_agents,
1237            "payoffs length {} must equal num_agents = {}",
1238            payoffs.len(),
1239            self.num_agents
1240        );
1241        for (a, &idx) in joint.iter().enumerate() {
1242            assert!(
1243                idx < self.per_role_k,
1244                "joint[{a}] = {idx} >= per_role_k = {}",
1245                self.per_role_k
1246            );
1247        }
1248        let s = compose_joint_index(joint, self.per_role_k);
1249        self.cells[s] = payoffs;
1250    }
1251
1252    /// Grow storage from `(per_role_k)^N` to `(new_per_role_k)^N`
1253    /// in-place, preserving the cached payoffs at all joint strategies
1254    /// that map to the same little-endian flat index in the new
1255    /// storage.
1256    ///
1257    /// Newly-introduced cells are zero-initialized; the caller is
1258    /// responsible for evaluating them via the trainer's
1259    /// `evaluate_payoff_joint` and writing the result with
1260    /// [`PayoffCache::set_cell`].
1261    ///
1262    /// # Why we can't just `Vec::resize`
1263    ///
1264    /// Under little-endian mixed-radix, joint index `s = Σ_i s_i · k^i`
1265    /// changes when the radix `k` grows: the same per-agent indices
1266    /// `(s_0, ..., s_{N-1})` map to a different flat `s'` in the
1267    /// `(k+1)^N` storage. We rebuild the buffer by iterating over the
1268    /// old joint strategies and re-keying.
1269    pub fn resize_for_boundary(&mut self, new_per_role_k: usize) {
1270        assert!(
1271            new_per_role_k >= self.per_role_k,
1272            "PayoffCache may only grow; got new_k = {} < per_role_k = {}",
1273            new_per_role_k,
1274            self.per_role_k
1275        );
1276        if new_per_role_k == self.per_role_k {
1277            return;
1278        }
1279        let new_total = new_per_role_k.checked_pow(self.num_agents as u32).expect("k^N overflow");
1280        let mut new_cells = vec![vec![0.0_f32; self.num_agents]; new_total];
1281        if self.per_role_k > 0 {
1282            let old_total = self.cells.len();
1283            for s_old in 0..old_total {
1284                let components = decompose_joint_index(s_old, self.num_agents, self.per_role_k);
1285                let s_new = compose_joint_index(&components, new_per_role_k);
1286                new_cells[s_new] = std::mem::take(&mut self.cells[s_old]);
1287            }
1288        }
1289        self.cells = new_cells;
1290        self.per_role_k = new_per_role_k;
1291    }
1292
1293    /// Iterate over every joint strategy `s` in the *boundary slab*
1294    /// where agent `agent_index` plays its newest pure strategy
1295    /// (`per_role_k - 1`) — the cells whose payoffs must be evaluated
1296    /// after agent `agent_index`'s population just grew by one.
1297    ///
1298    /// Returns the joint-strategy index vectors (per-agent indices),
1299    /// suitable for passing to `evaluate_payoff_joint` and
1300    /// `set_cell`.
1301    pub fn boundary_joint_strategies(&self, agent_index: usize) -> Vec<Vec<usize>> {
1302        let k = self.per_role_k;
1303        let n = self.num_agents;
1304        assert!(agent_index < n);
1305        assert!(k >= 1);
1306        let new_strat = k - 1;
1307        // Enumerate the other agents' indices via the same
1308        // little-endian convention on N-1 axes of radix k.
1309        let n_others = n - 1;
1310        let total_others = k.checked_pow(n_others as u32).expect("k^(N-1) overflow");
1311        let mut out = Vec::with_capacity(total_others);
1312        for s in 0..total_others {
1313            let mut joint = vec![0_usize; n];
1314            joint[agent_index] = new_strat;
1315            // Distribute s across the other agents in little-endian
1316            // mixed-radix. Index-based loop is the cleanest reading of
1317            // the recurrence; clippy::needless_range_loop's
1318            // iter-based suggestion would mean awkwardly splitting the
1319            // `agent_index` skip.
1320            let mut rem = s;
1321            #[allow(clippy::needless_range_loop)]
1322            for a in 0..n {
1323                if a == agent_index {
1324                    continue;
1325                }
1326                joint[a] = rem % k;
1327                rem /= k;
1328            }
1329            out.push(joint);
1330        }
1331        out
1332    }
1333}
1334
1335// =======================================================================
1336// PsroTrainer
1337// =======================================================================
1338
1339/// PSRO outer-loop trainer for symmetric N-agent games (N ≥ 2).
1340///
1341/// Generic over the Burn backend `B`, policy module `P`, and Burn
1342/// optimizer type `O`. The trainer owns:
1343///
1344/// - N populations of policies (one per agent role) under `populations:
1345///   Vec<Vec<P>>`.
1346/// - A [`MetaSolver`] for the empirical meta-game. For N=2 the 2-player
1347///   [`MetaSolver::solve`] path is used (any in-tree solver works); for N≥3 the
1348///   trainer calls [`MetaSolver::solve_n_player`] and only
1349///   [`AlphaRankMetaSolver`] provides a non-panicking override.
1350/// - A cached empirical-payoff N-tensor [`PayoffCache`] keyed by joint pure
1351///   strategy.
1352/// - User-supplied factories for fresh policies + optimizers + envs.
1353///
1354/// # Policy/optimizer factories
1355///
1356/// The trainer doesn't know how to construct a Burn module of the
1357/// caller's chosen architecture, so we take closures:
1358///
1359/// - `policy_factory: Fn(&B::Device, u64) -> P` — fresh policy. The `u64` is a
1360///   **per-construction seed** the trainer derives from `PsroConfig::seed` via
1361///   a monotonic init-counter. A reproducibility-aware factory threads it into
1362///   `MlpBurnPolicy::new_seeded` / `MlpBurnConfig::with_seed` so that every
1363///   agent's initial policy and every per-iteration best-response gets
1364///   *distinct but deterministic* weights (issue #135). Factories that don't
1365///   care about reproducibility may ignore the argument.
1366/// - `optimizer_factory: Fn() -> BurnOptimizer<B, P, O>` — fresh optimizer.
1367/// - `env_factory: Fn() -> E` — fresh env instance.
1368///
1369/// This keeps PSRO architecture-agnostic at the cost of slightly
1370/// awkward generics at the call site (see the matching-pennies test).
1371///
1372/// # Single-policy-class assumption
1373///
1374/// All agents in both populations share the same policy class `P`. For
1375/// 2-agent symmetric games (matching pennies, homogeneous bucket
1376/// brigade) this is exactly what we want — the symmetry lets us
1377/// transpose the payoff matrix for the column player's solve. For
1378/// fully asymmetric games (different obs/action spaces per role), the
1379/// trainer needs to be re-parameterized over `(P_row, P_col)`; that's
1380/// out of scope for the first PR.
1381pub struct PsroTrainer<B, P, O, E, FP, FO, FE>
1382where
1383    B: AutodiffBackend,
1384    P: JointPolicy<B>,
1385    O: Optimizer<P, B>,
1386    E: JointEnv,
1387    FP: Fn(&B::Device, u64) -> P,
1388    FO: Fn() -> BurnOptimizer<B, P, O>,
1389    FE: Fn() -> E,
1390{
1391    /// Per-agent policy populations. `populations[agent]` is the
1392    /// monotonically-growing list of policies for agent `agent`. Under
1393    /// the symmetric posture all per-agent populations have the same
1394    /// length.
1395    populations: Vec<Vec<P>>,
1396    meta_solver: Box<dyn MetaSolver>,
1397    config: PsroConfig,
1398    joint_config: JointTrainerConfig,
1399    device: B::Device,
1400    policy_factory: FP,
1401    optimizer_factory: FO,
1402    env_factory: FE,
1403    payoff_cache: PayoffCache,
1404    rng: StdRng,
1405    /// Monotonic counter feeding the per-construction policy-init seed.
1406    ///
1407    /// Incremented on every `policy_factory` call (once per agent at
1408    /// construction, once per best-response per outer iteration). Each
1409    /// call derives `config.seed.wrapping_add(0x9E37_79B9 *
1410    /// init_counter)` so distinct constructions get distinct — but
1411    /// fully deterministic — initial weights. Without this, a factory
1412    /// closing over a single fixed seed would hand every agent and every
1413    /// iteration *identical* weights, a regression (issue #135,
1414    /// Correction 1).
1415    init_counter: u64,
1416}
1417
1418impl<B, P, O, E, FP, FO, FE> PsroTrainer<B, P, O, E, FP, FO, FE>
1419where
1420    B: AutodiffBackend,
1421    P: JointPolicy<B>,
1422    O: Optimizer<P, B>,
1423    E: JointEnv,
1424    FP: Fn(&B::Device, u64) -> P,
1425    FO: Fn() -> BurnOptimizer<B, P, O>,
1426    FE: Fn() -> E,
1427{
1428    /// Construct a PSRO trainer with one initial random policy per agent.
1429    ///
1430    /// `joint_config.num_agents` must be `≥ 2`. For `num_agents == 2`
1431    /// the trainer accepts any [`MetaSolver`] implementation; for
1432    /// `num_agents > 2` the meta-solver's
1433    /// [`MetaSolver::solve_n_player`] is called — at the time of this
1434    /// PR only [`AlphaRankMetaSolver`] provides a non-panicking
1435    /// override for N>2.
1436    #[allow(clippy::too_many_arguments)]
1437    pub fn new(
1438        config: PsroConfig,
1439        joint_config: JointTrainerConfig,
1440        meta_solver: Box<dyn MetaSolver>,
1441        device: B::Device,
1442        policy_factory: FP,
1443        optimizer_factory: FO,
1444        env_factory: FE,
1445    ) -> Result<Self> {
1446        if joint_config.num_agents < 2 {
1447            return Err(anyhow!(
1448                "PsroTrainer requires joint_config.num_agents >= 2 (got {})",
1449                joint_config.num_agents
1450            ));
1451        }
1452        let n = joint_config.num_agents;
1453        // Derive a distinct init seed per agent at construction time.
1454        // We advance the counter inline here (the trainer isn't built
1455        // yet) using the same derivation as `next_init_seed`.
1456        let base_seed = config.seed;
1457        let populations: Vec<Vec<P>> = (0..n)
1458            .map(|i| {
1459                let s = base_seed.wrapping_add(0x9E37_79B9_u64.wrapping_mul(i as u64));
1460                vec![policy_factory(&device, s)]
1461            })
1462            .collect();
1463        let rng = StdRng::seed_from_u64(config.seed);
1464        Ok(Self {
1465            populations,
1466            meta_solver,
1467            config,
1468            joint_config,
1469            device,
1470            policy_factory,
1471            optimizer_factory,
1472            env_factory,
1473            payoff_cache: PayoffCache::with_num_agents(n),
1474            rng,
1475            // Start the running counter past the `n` seeds consumed by
1476            // the initial per-agent constructions above.
1477            init_counter: n as u64,
1478        })
1479    }
1480
1481    /// Derive and consume the next per-construction policy-init seed.
1482    ///
1483    /// Returns `config.seed.wrapping_add(0x9E37_79B9 * init_counter)`
1484    /// and advances the counter so the next call gets a fresh,
1485    /// non-colliding stream. The multiplier is the 32-bit golden-ratio
1486    /// constant — any odd large constant works; this one keeps adjacent
1487    /// counters well-separated in the `StdRng` seed space.
1488    fn next_init_seed(&mut self) -> u64 {
1489        let s = self.config.seed.wrapping_add(0x9E37_79B9_u64.wrapping_mul(self.init_counter));
1490        self.init_counter = self.init_counter.wrapping_add(1);
1491        s
1492    }
1493
1494    /// Borrow agent `agent`'s policy population.
1495    pub fn populations(&self, agent: usize) -> &[P] {
1496        &self.populations[agent]
1497    }
1498
1499    /// Borrow the row-player (agent 0) population.
1500    ///
1501    /// Backward-compat shim retained for callers that pre-date the
1502    /// N-tensor refactor (notably
1503    /// `tests/test_psro_matching_pennies.rs`). New N≥2 code should use
1504    /// [`PsroTrainer::populations`].
1505    pub fn population_row(&self) -> &[P] {
1506        &self.populations[0]
1507    }
1508
1509    /// Borrow the column-player (agent 1) population.
1510    ///
1511    /// Backward-compat shim retained for callers that pre-date the
1512    /// N-tensor refactor. Panics for N=1 (which is rejected by `new`
1513    /// anyway). New N≥2 code should use [`PsroTrainer::populations`].
1514    pub fn population_col(&self) -> &[P] {
1515        &self.populations[1]
1516    }
1517
1518    /// Borrow the cached empirical N-tensor payoff cache.
1519    pub fn payoff_cache(&self) -> &PayoffCache {
1520        &self.payoff_cache
1521    }
1522
1523    /// Run the PSRO outer loop and return the per-iteration history.
1524    ///
1525    /// `on_iteration` is invoked once per outer iteration, immediately
1526    /// after that iteration's [`PsroIterationStats`] is constructed and
1527    /// before it is pushed onto the returned history. This mirrors
1528    /// [`NfspTrainer::run`](crate::multi_agent::nfsp::NfspTrainer::run)
1529    /// and lets callers observe per-iteration progress *during* the run
1530    /// (live `tracing` logging, mid-run checkpoint triggers, etc.)
1531    /// rather than only inspecting the aggregate stats after `run`
1532    /// returns.
1533    ///
1534    /// The callback receives two arguments:
1535    /// 1. `&PsroIterationStats` — this iteration's stats, whose `iteration`
1536    ///    field increases monotonically from `1` to `config.max_iterations`.
1537    /// 2. `&[&P]` — the newest best-response policy for each agent (`brs[a]` is
1538    ///    agent `a`'s freshly-trained BR appended this iteration, i.e.
1539    ///    `populations(a).last()`). This lets the callback persist per-agent BR
1540    ///    policies to disk *during* the run (mid-run checkpointing, issue #204)
1541    ///    without a borrow conflict against the `&mut self` held by `run`: the
1542    ///    trainer cannot itself write files (it is backend/format-agnostic, the
1543    ///    `Recorder` lives in the example), so it hands the closure the policy
1544    ///    references it needs to checkpoint. Checkpointing is a pure
1545    ///    side-effect read; it does not alter the trainer state or the
1546    ///    deterministic training trajectory.
1547    ///
1548    /// For the common case of "run with no per-iteration hook", use
1549    /// [`Self::run_silent`].
1550    pub fn run<F>(&mut self, mut on_iteration: F) -> Result<PsroStats>
1551    where
1552        F: FnMut(&PsroIterationStats, &[&P]),
1553        // Bounds required by the rayon-parallel boundary-slab evaluation
1554        // (issue #203) and the rayon-parallel best-response loop (issue
1555        // #232). Mirror the `EnvPool` Send-bound convention (pool.rs:58).
1556        // The parallel payoff result is bit-identical to a serial sweep
1557        // because each cell is pure (issue #201); the parallel BR result
1558        // is thread-count-invariant (per-agent local RNG, issue #232). The
1559        // BR path additionally needs the policy/optimizer factories to be
1560        // `Sync` because each task calls them through a shared `&`.
1561        P: Send + Sync,
1562        E: Send,
1563        FP: Sync,
1564        FO: Sync,
1565        FE: Sync,
1566        B::Device: Sync,
1567    {
1568        let num_agents = self.joint_config.num_agents;
1569
1570        // Seed the payoff cache with the initial 1×...×1 entry — all
1571        // agents play their initial-random policy (index 0).
1572        if self.payoff_cache.per_role_k() == 0 {
1573            self.payoff_cache.resize_for_boundary(1);
1574            let initial_joint = vec![0_usize; num_agents];
1575            let initial_payoffs = self.evaluate_payoff_joint(&initial_joint);
1576            self.payoff_cache.set_cell(&initial_joint, initial_payoffs);
1577        }
1578
1579        let mut stats = PsroStats::default();
1580        for iter in 1..=self.config.max_iterations {
1581            if self.populations[0].len() >= self.config.max_population_size {
1582                return Err(anyhow!(
1583                    "PSRO population reached max_population_size = {}",
1584                    self.config.max_population_size
1585                ));
1586            }
1587
1588            // Step 1: meta-Nash on the current N-tensor payoff cache.
1589            // For N=2 the meta-solver's `solve` path (symmetric
1590            // marginal) is used; for N≥3 we go through `solve_n_player`
1591            // and marginalize per-agent.
1592            let per_agent_marginals = self.solve_per_agent_marginals();
1593
1594            // Step 2: round-robin train one best-response per agent
1595            // against the other agents' marginal mixtures. The
1596            // `num_agents` best responses are fully independent, so they
1597            // run concurrently under rayon (issue #232) — opponent
1598            // indices + init seeds are drawn in fixed agent order before
1599            // the parallel region, and each BR uses a per-agent local RNG,
1600            // so the result is invariant to thread count. Trained BRs are
1601            // appended to `self.populations` in fixed agent order after the
1602            // join.
1603            let br_stats = self.train_best_responses_parallel(&per_agent_marginals)?;
1604            let br_stats_per_agent: Vec<Option<JointStats>> =
1605                br_stats.into_iter().map(Some).collect();
1606
1607            // Step 3: grow the payoff cache and evaluate every
1608            // newly-added boundary cell. After all agents' populations
1609            // grow by one in lockstep, the new per-role-k is k+1 and
1610            // the new cells are the union of every per-agent
1611            // boundary slab — i.e. every joint strategy `s` whose
1612            // per-agent index vector includes at least one
1613            // newest-strategy index (`k` under the new radix).
1614            let old_k = self.payoff_cache.per_role_k();
1615            let new_k = old_k + 1;
1616            self.payoff_cache.resize_for_boundary(new_k);
1617            // Iterate every joint strategy in the new k^N tensor; cells
1618            // that are entirely in the *old* k^N corner are already
1619            // populated (preserved by `resize_for_boundary`). New cells
1620            // are those with at least one component == k-1 under the
1621            // new radix. We iterate flat indices and decompose.
1622            let total_new = new_k.checked_pow(num_agents as u32).expect("k^N overflow");
1623            let new_strategy_idx = new_k - 1;
1624
1625            // Gather the boundary cells (those whose per-agent index
1626            // vector includes the newest strategy) in deterministic flat
1627            // order. This is the `population^N` slab that dominates PSRO
1628            // cost on large N (issue #198).
1629            let boundary: Vec<Vec<usize>> = (0..total_new)
1630                .filter_map(|s| {
1631                    let components = decompose_joint_index(s, num_agents, new_k);
1632                    components.contains(&new_strategy_idx).then_some(components)
1633                })
1634                .collect();
1635
1636            // Optionally subsample the boundary slab to bound
1637            // per-iteration cost (issue #212). With `None` (default) the
1638            // entire boundary is evaluated, which is **bit-identical** to
1639            // the pre-#212 behavior; with `Some(cap)` and a boundary
1640            // larger than `cap`, only a deterministically-chosen `cap`
1641            // cells are rolled out and the rest are filled by reuse — see
1642            // `select_boundary_to_evaluate`.
1643            let (to_evaluate, fill_from) =
1644                select_boundary_to_evaluate(&boundary, self.config.max_payoff_evals_per_iteration);
1645
1646            // Evaluate the selected boundary cells in parallel. Each cell
1647            // is a pure function of `(config.seed, joint)` (issue #201),
1648            // so the parallel result is **bit-identical** to a serial
1649            // sweep regardless of thread count or scheduling: cells share
1650            // no mutable state, each clones its joint policies and builds
1651            // a fresh env via `env_factory`, and seeds a local `StdRng`.
1652            // Results are collected by index (not push order), then
1653            // written into the cache serially below, so the cache is
1654            // populated in the same deterministic order as the old serial
1655            // loop. See `evaluate_payoff_boundary_parallel`.
1656            let evaluated = self.evaluate_payoff_boundary_parallel(&to_evaluate);
1657            // Write the freshly-evaluated cells first so the fill step can
1658            // read their payoffs back out of the cache. `to_evaluate` is a
1659            // prefix-stable deterministic subset of `boundary`.
1660            for (components, payoffs) in to_evaluate.iter().zip(&evaluated) {
1661                self.payoff_cache.set_cell(components, payoffs.clone());
1662            }
1663            // Fill the un-sampled boundary cells from their nearest
1664            // already-evaluated sampled neighbour (deterministic; no fresh
1665            // rollouts, so these do NOT bump `eval_count`). When the cap is
1666            // `None` or not exceeded, `fill_from` is empty and this loop is
1667            // a no-op, keeping the default path bit-identical.
1668            for &(dst_idx, src_idx) in &fill_from {
1669                let payoffs = evaluated[src_idx].clone();
1670                self.payoff_cache.set_cell_no_count(&boundary[dst_idx], payoffs);
1671            }
1672
1673            // Step 4: re-solve the meta-Nash on the post-append cache
1674            // and compute NashConv exploitability. Reporting on the
1675            // post-append cache is how PSRO progress is conventionally
1676            // tracked (exploitability drops as each new BR enriches
1677            // the population).
1678            let post_marginals = self.solve_per_agent_marginals();
1679            let exploitability = self.compute_nashconv(&post_marginals);
1680
1681            let iter_stats = PsroIterationStats {
1682                iteration: iter,
1683                population_size: self.populations[0].len(),
1684                meta_nash_per_agent: post_marginals,
1685                br_stats_per_agent,
1686                exploitability,
1687            };
1688
1689            // Newest best-response policy per agent, appended this
1690            // iteration in the round-robin loop above. Handed to the
1691            // callback so it can checkpoint per-agent BR policies
1692            // mid-run (issue #204). `populations(a)` is guaranteed
1693            // non-empty here: every agent was just trained and pushed.
1694            let newest_brs: Vec<&P> = (0..num_agents)
1695                .map(|a| {
1696                    self.populations[a].last().expect("population non-empty after BR training")
1697                })
1698                .collect();
1699            on_iteration(&iter_stats, &newest_brs);
1700            stats.iterations.push(iter_stats);
1701        }
1702        Ok(stats)
1703    }
1704
1705    /// Convenience entry point: drives [`Self::run`] with a no-op
1706    /// iteration callback. Use this when per-iteration observation is
1707    /// not needed (mirrors
1708    /// [`NfspTrainer::run_silent`](crate::multi_agent::nfsp::NfspTrainer::run_silent)).
1709    pub fn run_silent(&mut self) -> Result<PsroStats>
1710    where
1711        P: Send + Sync,
1712        E: Send,
1713        FP: Sync,
1714        FO: Sync,
1715        FE: Sync,
1716        B::Device: Sync,
1717    {
1718        self.run(|_, _| {})
1719    }
1720
1721    /// Most-recent per-agent meta-Nash distributions (one row per
1722    /// agent), or uniform over the initial population if `run` has not
1723    /// been called.
1724    pub fn current_meta_nash_per_agent(&self) -> Vec<Vec<f32>> {
1725        if self.payoff_cache.per_role_k() == 0 {
1726            return (0..self.joint_config.num_agents).map(|_| vec![1.0]).collect();
1727        }
1728        self.solve_per_agent_marginals()
1729    }
1730
1731    /// Backward-compat shim returning agent 0's meta-Nash marginal.
1732    pub fn current_meta_nash(&self) -> Vec<f32> {
1733        self.current_meta_nash_per_agent().into_iter().next().unwrap_or_default()
1734    }
1735
1736    /// Solve the meta-Nash on the current payoff cache and return
1737    /// per-agent marginal distributions over each agent's own
1738    /// population. For N=2, uses [`MetaSolver::solve`] on the legacy
1739    /// `payoffs[i][j] = agent_0_payoff(i, j)` matrix view (preserving
1740    /// bit-stable behaviour for existing FictitiousPlay / Replicator /
1741    /// Uniform meta-solvers). For N≥3, uses
1742    /// [`MetaSolver::solve_n_player`] and marginalizes the joint
1743    /// distribution per-agent.
1744    fn solve_per_agent_marginals(&self) -> Vec<Vec<f32>> {
1745        let n = self.joint_config.num_agents;
1746        let k = self.payoff_cache.per_role_k();
1747        if k == 0 {
1748            return (0..n).map(|_| vec![1.0]).collect();
1749        }
1750        if n == 2 {
1751            // 2-player path: project the N-tensor cache back to a
1752            // `k × k` row-player payoff matrix (agent 0's payoffs) and
1753            // call `solve`. The post-projection matrix is bit-identical
1754            // to the pre-refactor `PayoffCache::matrix()` view — this
1755            // is the regression-bar guarantee.
1756            // Index-based double loop: the explicit `s = i + j * k`
1757            // formula mirrors the little-endian mixed-radix convention
1758            // and reads more clearly than a flat-enumerate rewrite.
1759            let mut row_matrix: Vec<Vec<f32>> = vec![vec![0.0_f32; k]; k];
1760            #[allow(clippy::needless_range_loop)]
1761            for i in 0..k {
1762                for j in 0..k {
1763                    let s = i + j * k;
1764                    row_matrix[i][j] = self.payoff_cache.payoff_tensor()[s][0];
1765                }
1766            }
1767            let row_dist = self.meta_solver.solve(&row_matrix);
1768            // Symmetric zero-sum: column distribution matches row by
1769            // symmetry, same as the pre-refactor trainer.
1770            let col_dist = row_dist.clone();
1771            return vec![row_dist, col_dist];
1772        }
1773        // N≥3 path: call `solve_n_player` with the flat (k^N, N)
1774        // tensor and marginalize per-agent.
1775        let joint = self.meta_solver.solve_n_player(self.payoff_cache.payoff_tensor(), n, k);
1776        let mut marginals: Vec<Vec<f32>> = (0..n).map(|_| vec![0.0_f32; k]).collect();
1777        for (s, &mass) in joint.iter().enumerate() {
1778            let components = decompose_joint_index(s, n, k);
1779            for (a, &c) in components.iter().enumerate() {
1780                marginals[a][c] += mass;
1781            }
1782        }
1783        // Renormalize numerically.
1784        for m in marginals.iter_mut() {
1785            let total: f32 = m.iter().sum();
1786            if total > 0.0 {
1787                for v in m.iter_mut() {
1788                    *v /= total;
1789                }
1790            } else {
1791                let uniform = 1.0 / k as f32;
1792                for v in m.iter_mut() {
1793                    *v = uniform;
1794                }
1795            }
1796        }
1797        marginals
1798    }
1799
1800    /// Compute NashConv exploitability under the per-agent meta-Nash
1801    /// marginals: `Σ_i (max_{s_i} U_i(s_i, σ_{−i}) − U_i(σ))`.
1802    ///
1803    /// # N=2 fast-path bit-stability
1804    ///
1805    /// For N=2 the meta-Nash marginals are projected back to a `k × k`
1806    /// agent-0 payoff matrix and the closed-form
1807    /// `row_gain + col_gain` formula is evaluated — bit-identical to
1808    /// the pre-refactor `empirical_exploitability`. This preserves the
1809    /// `+1.0` calibration of
1810    /// `test_psro_exploitability_non_increasing_trend_on_matching_pennies`
1811    /// across the refactor.
1812    ///
1813    /// # N≥3 generalization
1814    ///
1815    /// For N≥3 we compute each agent's best-response gain as the
1816    /// supremum over its `k` pure strategies of the expected payoff
1817    /// against the other agents' joint marginal mixture, minus the
1818    /// agent's expected payoff under the full joint mixture. The
1819    /// agent-`i` joint mixture is `Π_{j≠i} σ_j` (independence assumed
1820    /// under the per-agent marginal decomposition) so the expected
1821    /// payoff at the agent-`i` pure strategy `s_i` is
1822    /// `Σ_{s_{−i}} (Π_{j≠i} σ_j[s_j]) · U_i(s_i, s_{−i})`. The N=2
1823    /// case follows the same formula (mod the bit-stability
1824    /// projection).
1825    fn compute_nashconv(&self, per_agent_marginals: &[Vec<f32>]) -> f32 {
1826        let n = self.joint_config.num_agents;
1827        let k = self.payoff_cache.per_role_k();
1828        if n == 2 {
1829            // Fast path: project to the agent-0 payoff matrix and use
1830            // the legacy 2-player formula bit-identically. Index-based
1831            // loop mirrors the little-endian mixed-radix convention
1832            // for the joint flat index.
1833            let mut row_matrix: Vec<Vec<f32>> = vec![vec![0.0_f32; k]; k];
1834            #[allow(clippy::needless_range_loop)]
1835            for i in 0..k {
1836                for j in 0..k {
1837                    let s = i + j * k;
1838                    row_matrix[i][j] = self.payoff_cache.payoff_tensor()[s][0];
1839                }
1840            }
1841            return empirical_exploitability(&row_matrix, &per_agent_marginals[0]);
1842        }
1843        // N≥3 general path.
1844        let payoffs = self.payoff_cache.payoff_tensor();
1845        let mut nashconv = 0.0_f32;
1846        for i in 0..n {
1847            // U_i(σ) = Σ_s (Π_j σ_j[s_j]) · payoffs[s][i].
1848            let mut u_sigma = 0.0_f32;
1849            // Expected payoff to agent i for each of its pure
1850            // strategies, marginalizing other agents over their σ.
1851            let mut u_pure = vec![0.0_f32; k];
1852            for (s, agent_payoffs) in payoffs.iter().enumerate() {
1853                let components = decompose_joint_index(s, n, k);
1854                // Product of marginal masses across all agents under
1855                // the full joint mixture.
1856                let mut full_prob = 1.0_f32;
1857                for (a, &c) in components.iter().enumerate() {
1858                    full_prob *= per_agent_marginals[a][c];
1859                }
1860                u_sigma += full_prob * agent_payoffs[i];
1861                // For the "agent i deviates to pure s_i" case, weight
1862                // by Π_{j≠i} σ_j[s_j].
1863                let mut others_prob = 1.0_f32;
1864                for (a, &c) in components.iter().enumerate() {
1865                    if a == i {
1866                        continue;
1867                    }
1868                    others_prob *= per_agent_marginals[a][c];
1869                }
1870                let s_i = components[i];
1871                u_pure[s_i] += others_prob * agent_payoffs[i];
1872            }
1873            let max_pure = u_pure.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
1874            let gain = (max_pure - u_sigma).max(0.0);
1875            nashconv += gain;
1876        }
1877        nashconv
1878    }
1879
1880    /// Train all `num_agents` best responses for one PSRO iteration **in
1881    /// parallel** (one fully-independent BR per agent) and append the
1882    /// trained policies to `self.populations` in **fixed agent order**.
1883    ///
1884    /// # Why this is parallelizable
1885    ///
1886    /// Each best response trains its own [`JointMultiAgentTrainer`] over a
1887    /// freshly-initialized active policy and frozen, cloned opponents, runs
1888    /// its own env, and only *reads* `self.populations` / `self.config`.
1889    /// The only original shared-mutable touches were `self.rng` (opponent
1890    /// sampling + PPO shuffle) and `self.next_init_seed`. We hoist **all**
1891    /// of those draws out of the parallel region here, into a fixed-order
1892    /// pre-pass, so the parallel region touches no `&mut self`:
1893    ///
1894    /// - per-agent opponent indices are drawn from `self.rng` in agent order,
1895    ///   before the join;
1896    /// - per-agent active-policy init seeds are drawn from
1897    ///   `self.next_init_seed()` in agent order;
1898    /// - each BR is handed a **local [`StdRng`]** seeded deterministically from
1899    ///   `(config.seed, active_agent)` (mirrors the per-cell seeding of
1900    ///   [`evaluate_payoff_joint_pure`]), which threads the rollout +
1901    ///   PPO-update draws for that BR alone.
1902    ///
1903    /// The per-BR work is then a pure function of its pre-drawn inputs, so
1904    /// `(0..num_agents).into_par_iter()` produces a result that is
1905    /// **invariant to thread count / scheduling**: results are collected
1906    /// by index (rayon preserves input order) and appended to
1907    /// `self.populations` serially in agent order afterward.
1908    ///
1909    /// # Determinism note
1910    ///
1911    /// Because the BR now uses a per-agent local RNG instead of the single
1912    /// shared `self.rng` stream, output is **not** bit-identical to the
1913    /// pre-parallel serial-RNG runs (the RNG threading changed by design).
1914    /// It is, however, fully reproducible for a given seed and identical
1915    /// across any thread count.
1916    ///
1917    /// # Bounds
1918    ///
1919    /// Mirror the boundary-payoff parallel path
1920    /// ([`Self::evaluate_payoff_boundary_parallel`]): `P: Send + Sync`
1921    /// (shared by `&`, cloned per task), `E: Send` (moved into each task),
1922    /// and the factories / device are shared by `&` (`FP`/`FO`/`FE: Sync`,
1923    /// `B::Device: Sync`).
1924    fn train_best_responses_parallel(
1925        &mut self,
1926        per_agent_marginals: &[Vec<f32>],
1927    ) -> Result<Vec<JointStats>>
1928    where
1929        P: Send + Sync,
1930        E: Send,
1931        FP: Sync,
1932        FO: Sync,
1933        FE: Sync,
1934        B::Device: Sync,
1935    {
1936        let num_agents = self.joint_config.num_agents;
1937
1938        // --- Fixed-order pre-pass: draw every shared-mutable value here,
1939        // in agent order, so the parallel region below is pure. ---
1940        //
1941        // `opp_indices[active_agent][a]` is the sampled opponent index for
1942        // agent `a` while `active_agent` trains its BR; the entry for
1943        // `a == active_agent` is unused (that slot holds the fresh BR).
1944        let mut opp_indices: Vec<Vec<usize>> = Vec::with_capacity(num_agents);
1945        let mut init_seeds: Vec<u64> = Vec::with_capacity(num_agents);
1946        for active_agent in 0..num_agents {
1947            let mut row: Vec<usize> = Vec::with_capacity(num_agents);
1948            for (a, marginal) in per_agent_marginals.iter().enumerate().take(num_agents) {
1949                if a == active_agent {
1950                    row.push(0); // unused placeholder for the active slot
1951                } else {
1952                    row.push(sample_from_mixture(&mut self.rng, marginal));
1953                }
1954            }
1955            opp_indices.push(row);
1956            init_seeds.push(self.next_init_seed());
1957        }
1958
1959        // Bind only the Sync field borrows into locals so the rayon
1960        // closures capture *these* references and NOT the whole `&self`
1961        // (which also holds the non-`Sync` `Box<dyn MetaSolver>`). Same
1962        // technique as `evaluate_payoff_boundary_parallel`.
1963        let populations = &self.populations;
1964        let config = &self.config;
1965        let joint_config = &self.joint_config;
1966        let device = &self.device;
1967        let policy_factory = &self.policy_factory;
1968        let optimizer_factory = &self.optimizer_factory;
1969        let env_factory = &self.env_factory;
1970
1971        // --- Parallel region: one independent BR per agent. ---
1972        let results: Vec<Result<(JointStats, P)>> = (0..num_agents)
1973            .into_par_iter()
1974            .map(|active_agent| {
1975                train_best_response_pure::<B, P, O, E, _, _, _>(
1976                    active_agent,
1977                    &opp_indices[active_agent],
1978                    init_seeds[active_agent],
1979                    populations,
1980                    config,
1981                    joint_config,
1982                    device,
1983                    policy_factory,
1984                    optimizer_factory,
1985                    env_factory,
1986                )
1987            })
1988            .collect();
1989
1990        // --- Join: unpack results in fixed agent order, propagating the
1991        // first error deterministically. The immutable borrow of
1992        // `self.populations` taken for the parallel region has ended (the
1993        // `collect()` above is complete), so we can now mutably append. ---
1994        let mut stats: Vec<JointStats> = Vec::with_capacity(num_agents);
1995        let mut trained_policies: Vec<P> = Vec::with_capacity(num_agents);
1996        for result in results {
1997            let (br_stats, trained) = result?;
1998            stats.push(br_stats);
1999            trained_policies.push(trained);
2000        }
2001        // Promote each learned BR into its agent's population in fixed
2002        // agent order (collect-by-index), matching the serial loop's
2003        // append order so the population layout is thread-count-invariant.
2004        for (active_agent, trained) in trained_policies.into_iter().enumerate() {
2005            self.populations[active_agent].push(trained);
2006        }
2007        Ok(stats)
2008    }
2009
2010    /// Evaluate the empirical-payoff cell at joint strategy `joint`
2011    /// (length `num_agents`) by running
2012    /// `config.payoff_eval_episodes` episodes with policy
2013    /// `populations[a][joint[a]]` for each agent `a`. Returns the
2014    /// per-agent mean per-episode returns (length `num_agents`).
2015    ///
2016    /// This is a thin wrapper that gathers the per-joint policies and
2017    /// delegates to [`evaluate_payoff_joint_pure`], which is a pure,
2018    /// per-cell-seeded function (it does **not** touch `self.rng`). The
2019    /// wrapper only borrows `&self` for the population/factory handles,
2020    /// so the result is independent of evaluation order and global RNG
2021    /// state — see #201.
2022    fn evaluate_payoff_joint(&self, joint: &[usize]) -> Vec<f32> {
2023        let num_agents = self.joint_config.num_agents;
2024        assert_eq!(joint.len(), num_agents);
2025        let policies: Vec<P> =
2026            (0..num_agents).map(|a| self.populations[a][joint[a]].clone()).collect();
2027        evaluate_payoff_joint_pure::<B, P, _, _>(
2028            joint,
2029            &self.config,
2030            &policies,
2031            &self.env_factory,
2032            &self.device,
2033        )
2034    }
2035
2036    /// Evaluate a batch of boundary payoff cells **in parallel** with
2037    /// rayon, returning one payoff vector per input cell in the **same
2038    /// order** as `boundary`.
2039    ///
2040    /// # Bit-identity with the serial path (issue #203)
2041    ///
2042    /// Each cell delegates to [`evaluate_payoff_joint_pure`], which seeds
2043    /// a local [`StdRng`] purely from `(config.seed, joint)` and touches
2044    /// no shared trainer RNG (issue #201). The cell payoff is therefore a
2045    /// pure function of `(joint, config, policies, env_factory)`, so this
2046    /// `par_iter` result is **bit-identical** to evaluating the same
2047    /// cells serially in any order, *regardless of thread count or
2048    /// scheduling*. Results are gathered by index via
2049    /// [`ParallelIterator::collect`] (rayon preserves input order), never
2050    /// by push order, and the caller writes them into the cache serially.
2051    ///
2052    /// # Thread-safety
2053    ///
2054    /// No mutable state crosses threads. Each task:
2055    /// - reads `self.populations` / `self.config` / `self.device` /
2056    ///   `self.env_factory` through shared `&` borrows (no `&mut self`),
2057    /// - clones the joint's per-agent policies (`P: Clone`) so the autodiff
2058    ///   modules are owned per task,
2059    /// - builds a fresh env via `env_factory` (which already yields a new
2060    ///   instance per call).
2061    ///
2062    /// The `Send`/`Sync` bounds mirror the [`EnvPool`](crate::env::pool)
2063    /// convention (`E: Send`): `P: Send + Sync` (shared by `&`, cloned
2064    /// per task), `E: Send` (moved into each task), and the factory /
2065    /// device are shared by `&` (`FE: Sync`, `B::Device: Sync`). No
2066    /// `Mutex` is introduced, so the hot loop is never serialized.
2067    fn evaluate_payoff_boundary_parallel(&self, boundary: &[Vec<usize>]) -> Vec<Vec<f32>>
2068    where
2069        P: Send + Sync,
2070        E: Send,
2071        FE: Sync,
2072        B::Device: Sync,
2073    {
2074        let num_agents = self.joint_config.num_agents;
2075        // Bind only the Sync field borrows into locals so the rayon
2076        // closures capture *these* references and NOT the whole `&self`
2077        // (which also holds the non-`Sync` `Box<dyn MetaSolver>` and the
2078        // `FP`/`FO` factory closures). Capturing the whole `&self` would
2079        // require the entire trainer to be `Sync`; capturing only the
2080        // payoff-relevant fields keeps the bounds minimal and correct.
2081        let populations = &self.populations;
2082        let config = &self.config;
2083        let env_factory = &self.env_factory;
2084        let device = &self.device;
2085        boundary
2086            .par_iter()
2087            .map(|joint| {
2088                debug_assert_eq!(joint.len(), num_agents);
2089                let policies: Vec<P> =
2090                    (0..num_agents).map(|a| populations[a][joint[a]].clone()).collect();
2091                evaluate_payoff_joint_pure::<B, P, _, _>(
2092                    joint,
2093                    config,
2094                    &policies,
2095                    env_factory,
2096                    device,
2097                )
2098            })
2099            .collect()
2100    }
2101}
2102
2103/// Pure, per-agent-seeded best-response trainer.
2104///
2105/// Trains one best response for `active_agent` against the other agents'
2106/// pre-sampled, frozen opponents and returns `(stats, trained_policy)`.
2107/// This is the per-task body of the rayon-parallel BR loop (issue #232):
2108/// it is the extraction of the old `train_best_response` with **every
2109/// `&mut self` / shared-RNG touch removed**.
2110///
2111/// # Determinism / thread-count invariance (issue #232)
2112///
2113/// All values that the pre-parallel path drew from the shared
2114/// `&mut self.rng` / `self.next_init_seed` are now passed in, already
2115/// drawn in fixed agent order by the caller:
2116/// - `opp_indices[a]` — the frozen opponent index for each non-active agent `a`
2117///   (the `active_agent` slot is ignored);
2118/// - `init_seed` — the active BR's fresh-policy initialization seed.
2119///
2120/// The rollout + PPO-update draws use a **local [`StdRng`]** seeded purely
2121/// from `(config.seed, active_agent)` (mirroring the per-cell seeding of
2122/// [`evaluate_payoff_joint_pure`]), so this function touches no shared
2123/// state and its result is a pure function of its inputs. Running the
2124/// per-agent tasks under any thread count therefore yields identical
2125/// per-agent results.
2126///
2127/// Note: because each BR now consumes its own local RNG stream rather than
2128/// slices of one global `self.rng` stream, output is intentionally **not**
2129/// bit-identical to the pre-#232 serial-RNG runs.
2130#[allow(clippy::too_many_arguments)]
2131fn train_best_response_pure<B, P, O, E, FP, FO, FE>(
2132    active_agent: usize,
2133    opp_indices: &[usize],
2134    init_seed: u64,
2135    populations: &[Vec<P>],
2136    config: &PsroConfig,
2137    joint_config: &JointTrainerConfig,
2138    device: &B::Device,
2139    policy_factory: &FP,
2140    optimizer_factory: &FO,
2141    env_factory: &FE,
2142) -> Result<(JointStats, P)>
2143where
2144    B: AutodiffBackend,
2145    P: JointPolicy<B> + Clone,
2146    O: Optimizer<P, B>,
2147    E: JointEnv,
2148    FP: Fn(&B::Device, u64) -> P,
2149    FO: Fn() -> BurnOptimizer<B, P, O>,
2150    FE: Fn() -> E,
2151{
2152    let num_agents = joint_config.num_agents;
2153    debug_assert!(active_agent < num_agents);
2154
2155    // Build the joint trainer's per-agent policy slot:
2156    // - active agent: fresh randomly-initialized policy (the BR), using the
2157    //   pre-drawn `init_seed`.
2158    // - non-active agents: the pre-sampled frozen opponent from their meta-Nash
2159    //   marginal over their respective populations.
2160    let mut policies: Vec<P> = Vec::with_capacity(num_agents);
2161    for (a, population) in populations.iter().enumerate().take(num_agents) {
2162        if a == active_agent {
2163            policies.push(policy_factory(device, init_seed));
2164        } else {
2165            policies.push(population[opp_indices[a]].clone());
2166        }
2167    }
2168    let optimizers: Vec<BurnOptimizer<B, P, O>> =
2169        (0..num_agents).map(|_| optimizer_factory()).collect();
2170
2171    let mut trainer = JointMultiAgentTrainer::<B, P, O>::new(
2172        policies,
2173        optimizers,
2174        joint_config.clone(),
2175        device.clone(),
2176    )?;
2177
2178    // Per-agent LOCAL action/update RNG, seeded purely from
2179    // `(config.seed, active_agent)`. This replaces the shared
2180    // `&mut self.rng` of the pre-#232 path, making each BR self-contained
2181    // and thread-count-invariant.
2182    let mut rng = StdRng::seed_from_u64(config.seed ^ splitmix64(active_agent as u64));
2183
2184    // Run `br_train_steps_per_iteration` rollout/update cycles.
2185    let active_mask: Vec<bool> = (0..num_agents).map(|i| i == active_agent).collect::<Vec<_>>();
2186    let mut env = env_factory();
2187    let mut last_obs = env.reset_joint(Some(config.seed.wrapping_add(active_agent as u64)));
2188
2189    let mut last_stats = JointStats::zeros(num_agents);
2190    let reward_scale = config.br_reward_scale;
2191    for _ in 0..config.br_train_steps_per_iteration {
2192        let mut rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
2193        // Apply the optional BR reward scaling (issue #199 / #215) before
2194        // the PPO update. Scaling rewards uniformly is an affine transform
2195        // of the return and does not change the optimal policy, but keeps
2196        // the large-magnitude bucket-brigade band (`[−700, 0]`) in a
2197        // numerically friendlier range for the BR critic's regression
2198        // targets and advantage statistics. `reward_scale == 1.0` (the
2199        // default) leaves the rollout untouched.
2200        if reward_scale != 1.0 {
2201            for agent_rewards in rollout.rewards.iter_mut() {
2202                for r in agent_rewards.iter_mut() {
2203                    *r *= reward_scale;
2204                }
2205            }
2206        }
2207        last_stats = trainer.update_with_active_agents(
2208            &rollout,
2209            &active_mask,
2210            &mut rng,
2211            |_features: &[burn::tensor::Tensor<B, 2>]| -> Option<burn::tensor::Tensor<B, 1>> {
2212                None
2213            },
2214        )?;
2215    }
2216
2217    // Return the learned BR policy; the caller promotes it into the active
2218    // agent's population in fixed agent order after the parallel join.
2219    let trained = trainer.policy(active_agent).clone();
2220    Ok((last_stats, trained))
2221}
2222
2223/// Mix a `u64` through three rounds of the splitmix64 finalizer so that
2224/// adjacent inputs (e.g. neighbouring `joint_hash` values) map to
2225/// well-separated `StdRng` seeds. Same family of avalanche constants as
2226/// the determinism shims in [`crate::policy::seeded_init`].
2227fn splitmix64(mut x: u64) -> u64 {
2228    x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
2229    x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
2230    x ^ (x >> 31)
2231}
2232
2233/// Pure, per-cell-seeded payoff evaluator.
2234///
2235/// Runs `config.payoff_eval_episodes` episodes of `policies` (one
2236/// per agent, already gathered for the target joint cell) in a fresh
2237/// env from `env_factory`, and returns the per-agent mean per-episode
2238/// returns (length `num_agents`).
2239///
2240/// # Determinism / order-independence (issue #201)
2241///
2242/// Unlike the pre-#201 path, this function **does not read or mutate any
2243/// shared trainer RNG**. It constructs a single **local
2244/// [`StdRng`]** seeded from `(config.seed, joint)` and threads it
2245/// through every `get_action_host_seeded` call for the whole cell. The
2246/// per-episode env-reset seed is likewise derived deterministically from
2247/// `(config.seed, joint, ep)` (the little-endian `joint_hash` scheme
2248/// shared with [`PayoffCache`]). Consequently the returned payoff vector
2249/// is a pure function of `(joint, config, policies, env_factory)`:
2250/// evaluating the same cell twice — or evaluating a set of cells in any
2251/// order — yields bit-identical results. This is the determinism
2252/// guarantee that lets #203 parallelize the boundary-slab loop with a
2253/// result bit-identical to the serial one.
2254fn evaluate_payoff_joint_pure<B, P, E, EF>(
2255    joint: &[usize],
2256    config: &PsroConfig,
2257    policies: &[P],
2258    env_factory: &EF,
2259    device: &B::Device,
2260) -> Vec<f32>
2261where
2262    B: AutodiffBackend,
2263    P: JointPolicy<B>,
2264    E: JointEnv,
2265    EF: Fn() -> E,
2266{
2267    let num_agents = joint.len();
2268    let mut env = env_factory();
2269    let mut totals = vec![0.0_f64; num_agents];
2270    let episodes = config.payoff_eval_episodes.max(1);
2271
2272    // Deterministic per-cell hash: composes the joint-strategy
2273    // components into a stable scalar via the same little-endian
2274    // convention as the cache.
2275    let mut joint_hash: u64 = 0;
2276    for &c in joint {
2277        joint_hash = joint_hash.wrapping_mul(53).wrapping_add(c as u64);
2278    }
2279
2280    // LOCAL action-sampling RNG, seeded purely from (config.seed,
2281    // joint). This replaces the shared `&mut self.rng` of the pre-#201
2282    // path, making each cell self-contained and order-independent. A
2283    // single RNG spans all episodes so the per-cell action-draw stream
2284    // is a deterministic function of the cell alone.
2285    let per_cell_seed = config.seed ^ splitmix64(joint_hash);
2286    let mut rng = StdRng::seed_from_u64(per_cell_seed);
2287
2288    for ep in 0..episodes {
2289        // Per-(joint, ep) env-reset seed (unchanged from the pre-#201
2290        // path): deterministic in the cell and episode index.
2291        let reset_seed =
2292            config.seed.wrapping_add(joint_hash.wrapping_mul(31).wrapping_add(ep as u64));
2293        let mut last_obs = env.reset_joint(Some(reset_seed));
2294        let mut ep_returns = vec![0.0_f64; num_agents];
2295        // Cap rollout length; rely on env's `done` flag.
2296        for _ in 0..1024 {
2297            let mut actions: Vec<Vec<i64>> = Vec::with_capacity(num_agents);
2298            for (a, obs_a) in last_obs.iter().enumerate().take(num_agents) {
2299                let obs_dim = obs_a.len();
2300                let obs_t = burn::tensor::Tensor::<B, 2>::from_data(
2301                    burn::tensor::TensorData::new(obs_a.clone(), [1, obs_dim]),
2302                    device,
2303                );
2304                let (a_host, _, _) = policies[a].get_action_host_seeded(obs_t, &mut rng);
2305                let num_dims = policies[a].action_dims_joint().len();
2306                actions.push(a_host[..num_dims].to_vec());
2307            }
2308            let res = env.step_joint(&actions);
2309            for (a, ret) in ep_returns.iter_mut().enumerate().take(num_agents) {
2310                *ret += res.rewards[a] as f64;
2311            }
2312            if res.done {
2313                break;
2314            }
2315            last_obs[..num_agents].clone_from_slice(&res.observations[..num_agents]);
2316        }
2317        for (a, total) in totals.iter_mut().enumerate().take(num_agents) {
2318            *total += ep_returns[a];
2319        }
2320    }
2321    totals.into_iter().map(|t| (t / episodes as f64) as f32).collect()
2322}
2323
2324/// Sample an index from a length-`n` probability vector with the given RNG.
2325fn sample_from_mixture(rng: &mut StdRng, mix: &[f32]) -> usize {
2326    if mix.is_empty() {
2327        return 0;
2328    }
2329    let u: f32 = rng.random();
2330    let mut acc = 0.0_f32;
2331    for (i, &p) in mix.iter().enumerate() {
2332        acc += p;
2333        if u < acc {
2334            return i;
2335        }
2336    }
2337    mix.len() - 1
2338}
2339
2340/// Empirical exploitability: maximum unilateral improvement either
2341/// player can achieve by deviating from `meta_nash` to a pure best
2342/// response within the existing empirical-payoff matrix.
2343///
2344/// For a symmetric `n × n` row-payoff matrix `M` and equilibrium
2345/// proposal `σ`, this returns
2346/// `max(0, max_i (M σ)_i − σᵀ M σ) + max(0, max_j (−Mᵀ σ)_j − (−σᵀ M σ))`
2347/// — the sum of both players' best-response gains.
2348fn empirical_exploitability(payoffs: &[Vec<f32>], meta_nash: &[f32]) -> f32 {
2349    let n = payoffs.len();
2350    if n == 0 || meta_nash.is_empty() {
2351        return 0.0;
2352    }
2353    // Row player's expected payoff against col_mix == meta_nash.
2354    let mut max_row = f32::NEG_INFINITY;
2355    let mut sigma_value = 0.0_f32;
2356    for (i, row) in payoffs.iter().enumerate() {
2357        let mut v = 0.0_f32;
2358        for (j, &p) in meta_nash.iter().enumerate() {
2359            v += row[j] * p;
2360        }
2361        if v > max_row {
2362            max_row = v;
2363        }
2364        sigma_value += meta_nash[i] * v;
2365    }
2366    let row_gain = (max_row - sigma_value).max(0.0);
2367
2368    // Column player minimizes; deviation gain is the max amount they can
2369    // shift `sigma_value` *down*. For zero-sum games, column-player
2370    // value is `-sigma_value` and their best response minimizes
2371    // `(σᵀ M)_j` over `j`.
2372    let mut min_col = f32::INFINITY;
2373    // Column-major scan; see comment on `best_response_col` for rationale.
2374    #[allow(clippy::needless_range_loop)]
2375    for j in 0..n {
2376        let v: f32 = meta_nash.iter().enumerate().map(|(i, &p)| payoffs[i][j] * p).sum();
2377        if v < min_col {
2378            min_col = v;
2379        }
2380    }
2381    let col_gain = (sigma_value - min_col).max(0.0);
2382
2383    row_gain + col_gain
2384}
2385
2386// =======================================================================
2387// Tests
2388// =======================================================================
2389
2390#[cfg(test)]
2391mod tests {
2392    use burn::{
2393        backend::{Autodiff, NdArray, ndarray::NdArrayDevice},
2394        optim::AdamConfig,
2395    };
2396
2397    use super::*;
2398    use crate::{env::games::matching_pennies::MatchingPennies, policy::mlp::MlpBurnPolicy};
2399
2400    type B = Autodiff<NdArray<f32>>;
2401
2402    // ------------------------------------------------------------------
2403    // MetaSolver impls
2404    // ------------------------------------------------------------------
2405
2406    fn assert_valid_distribution(dist: &[f32], n_expected: usize) {
2407        assert_eq!(dist.len(), n_expected, "distribution size mismatch");
2408        let total: f32 = dist.iter().sum();
2409        assert!((total - 1.0).abs() < 1e-4, "distribution must sum to 1, got {total}");
2410        for &p in dist {
2411            assert!(p >= -1e-6, "distribution entry must be >= 0, got {p}");
2412        }
2413    }
2414
2415    #[test]
2416    fn test_uniform_meta_solver_3x3() {
2417        let solver = UniformMetaSolver;
2418        let payoffs = vec![vec![1.0, -1.0, 0.0]; 3];
2419        let dist = solver.solve(&payoffs);
2420        assert_valid_distribution(&dist, 3);
2421        for &p in &dist {
2422            assert!((p - 1.0 / 3.0).abs() < 1e-6, "uniform should be 1/3, got {p}");
2423        }
2424    }
2425
2426    #[test]
2427    fn test_uniform_meta_solver_is_payoff_independent() {
2428        let solver = UniformMetaSolver;
2429        let payoffs_a = vec![vec![5.0, -3.0], vec![-3.0, 5.0]];
2430        let payoffs_b = vec![vec![0.1, -0.1], vec![-0.1, 0.1]];
2431        let a = solver.solve(&payoffs_a);
2432        let b = solver.solve(&payoffs_b);
2433        assert_eq!(a, b, "uniform must ignore payoffs");
2434    }
2435
2436    /// Matching-pennies row-payoff matrix (action 0 / action 1).
2437    /// Row 0 vs col 0 → +1; row 0 vs col 1 → -1; etc.
2438    fn matching_pennies_payoff() -> Vec<Vec<f32>> {
2439        vec![vec![1.0, -1.0], vec![-1.0, 1.0]]
2440    }
2441
2442    #[test]
2443    fn test_fictitious_play_matching_pennies() {
2444        let solver = FictitiousPlayMetaSolver::new(2000);
2445        let dist = solver.solve(&matching_pennies_payoff());
2446        assert_valid_distribution(&dist, 2);
2447        // Both actions should converge to ~0.5 / ~0.5.
2448        for &p in &dist {
2449            assert!((p - 0.5).abs() < 0.05, "expected ~0.5, got {p}");
2450        }
2451    }
2452
2453    #[test]
2454    fn test_replicator_dynamics_matching_pennies() {
2455        let solver = ReplicatorDynamicsMetaSolver::new(5000, 0.05);
2456        let dist = solver.solve(&matching_pennies_payoff());
2457        assert_valid_distribution(&dist, 2);
2458        for &p in &dist {
2459            assert!((p - 0.5).abs() < 0.05, "expected ~0.5, got {p}");
2460        }
2461    }
2462
2463    #[test]
2464    fn test_meta_solvers_handle_n_eq_1() {
2465        let payoffs = vec![vec![0.5]];
2466        for solver in [
2467            Box::new(UniformMetaSolver) as Box<dyn MetaSolver>,
2468            Box::new(FictitiousPlayMetaSolver::default()) as Box<dyn MetaSolver>,
2469            Box::new(ReplicatorDynamicsMetaSolver::default()) as Box<dyn MetaSolver>,
2470        ] {
2471            let dist = solver.solve(&payoffs);
2472            assert_eq!(dist, vec![1.0], "{} failed on n=1", solver.name());
2473        }
2474    }
2475
2476    #[test]
2477    fn test_meta_solvers_handle_n_eq_0() {
2478        let payoffs: Vec<Vec<f32>> = Vec::new();
2479        for solver in [
2480            Box::new(FictitiousPlayMetaSolver::default()) as Box<dyn MetaSolver>,
2481            Box::new(ReplicatorDynamicsMetaSolver::default()) as Box<dyn MetaSolver>,
2482        ] {
2483            let dist = solver.solve(&payoffs);
2484            assert!(dist.is_empty(), "{} should return empty for n=0", solver.name());
2485        }
2486    }
2487
2488    #[test]
2489    fn test_fictitious_play_dominated_strategy() {
2490        // Row player has a strictly dominant action (row 0 always wins).
2491        // Mixed-Nash should put all mass on row 0.
2492        let payoffs = vec![vec![1.0, 2.0], vec![-1.0, -2.0]];
2493        let solver = FictitiousPlayMetaSolver::new(1000);
2494        let dist = solver.solve(&payoffs);
2495        assert_valid_distribution(&dist, 2);
2496        assert!(dist[0] > 0.95, "row 0 dominant, expected mass ~1.0, got {}", dist[0]);
2497    }
2498
2499    // ------------------------------------------------------------------
2500    // AlphaRankMetaSolver
2501    // ------------------------------------------------------------------
2502
2503    /// Hand-computed closed-form target for 3-player rock-paper-scissors:
2504    /// each player picks R(0)/P(1)/S(2); payoffs follow the cyclic
2505    /// majority rule. By full symmetry of the response graph the
2506    /// stationary distribution is uniform `1/27` over all 27 joint pure
2507    /// strategies (3³). We assert per-entry within `1e-2`.
2508    fn three_player_rps_payoffs() -> Vec<Vec<f32>> {
2509        // 27 joint strategies × 3 agents. For each joint strategy
2510        // (s_0, s_1, s_2) ∈ [0,3)³ encoded little-endian, compute each
2511        // agent's payoff under cyclic-majority rule: agent `i` wins
2512        // (+1) if its choice beats both others' under the standard RPS
2513        // cycle (0→2, 1→0, 2→1), loses (−1) if it loses to both, and
2514        // gets 0 otherwise (mixed outcome).
2515        //
2516        // Standard RPS beats: 0(R) beats 2(S), 1(P) beats 0(R), 2(S) beats 1(P).
2517        fn beats(a: usize, b: usize) -> bool {
2518            (a == 0 && b == 2) || (a == 1 && b == 0) || (a == 2 && b == 1)
2519        }
2520        let mut out = Vec::with_capacity(27);
2521        for s in 0..27 {
2522            let s0 = s % 3;
2523            let s1 = (s / 3) % 3;
2524            let s2 = (s / 9) % 3;
2525            let strategies = [s0, s1, s2];
2526            let mut row = vec![0.0_f32; 3];
2527            for i in 0..3 {
2528                let mut wins = 0;
2529                let mut losses = 0;
2530                for j in 0..3 {
2531                    if i == j {
2532                        continue;
2533                    }
2534                    if beats(strategies[i], strategies[j]) {
2535                        wins += 1;
2536                    } else if beats(strategies[j], strategies[i]) {
2537                        losses += 1;
2538                    }
2539                }
2540                row[i] = (wins - losses) as f32;
2541            }
2542            out.push(row);
2543        }
2544        out
2545    }
2546
2547    #[test]
2548    fn test_alpha_rank_three_player_rps_per_agent_marginal_is_uniform() {
2549        // Curator-targeted closed-form: by full RPS symmetry (each
2550        // strategy {R, P, S} is interchangeable under the cyclic
2551        // permutation), each agent's *marginal* action distribution is
2552        // uniform 1/3 over {R, P, S}. The Curator's original claim of
2553        // uniform 1/27 over the 27 joint strategies is an
2554        // over-simplification of the response-graph symmetry — the
2555        // joint distribution is *equivariant* under the cyclic
2556        // permutation, which implies the per-agent marginal is uniform
2557        // but does NOT imply joint uniformity (states like (R,R,R)
2558        // have higher self-loop mass than (R,P,S) because all 6
2559        // single-agent deviations from (R,R,R) have non-zero payoff
2560        // differential, whereas (R,P,S) has many ε-zero differentials).
2561        //
2562        // Asserts within `1e-2` on the per-agent marginal.
2563        let payoffs = three_player_rps_payoffs();
2564        let solver = AlphaRankMetaSolver::default();
2565        let dist = solver.solve_n_player(&payoffs, 3, 3);
2566        assert_eq!(dist.len(), 27, "α-rank should return 27-d distribution for 3-player RPS");
2567        let total: f32 = dist.iter().sum();
2568        assert!((total - 1.0).abs() < 1e-4, "distribution must sum to 1, got {total}");
2569        // Per-agent marginal: sum joint mass over the other agents'
2570        // indices for each agent's own strategy.
2571        for agent in 0..3 {
2572            let mut marginal = [0.0_f32; 3];
2573            for (s, &mass) in dist.iter().enumerate().take(27) {
2574                let components = decompose_joint_index(s, 3, 3);
2575                marginal[components[agent]] += mass;
2576            }
2577            let target = 1.0 / 3.0;
2578            for (i, &p) in marginal.iter().enumerate() {
2579                assert!(
2580                    (p - target).abs() < 1e-2,
2581                    "α-rank 3-player RPS agent {agent} marginal[{i}] = {p}, expected ≈ {target}; \
2582                     deviation {} exceeds 1e-2",
2583                    (p - target).abs()
2584                );
2585            }
2586        }
2587    }
2588
2589    /// Equivariance / orbit-equal-mass test: under the RPS cyclic
2590    /// permutation `σ: R→P→S→R`, the α-rank distribution must be
2591    /// invariant on orbits. We verify that the 3 "all-same"
2592    /// joint strategies have equal stationary mass.
2593    #[test]
2594    fn test_alpha_rank_three_player_rps_diagonal_orbit_equal_mass() {
2595        let payoffs = three_player_rps_payoffs();
2596        let solver = AlphaRankMetaSolver::default();
2597        let dist = solver.solve_n_player(&payoffs, 3, 3);
2598        // Diagonal states: (0,0,0)=0, (1,1,1)=1+3+9=13, (2,2,2)=2+6+18=26.
2599        let diag_indices = [0_usize, 13, 26];
2600        let masses: Vec<f32> = diag_indices.iter().map(|&i| dist[i]).collect();
2601        // All three should be equal within tight tolerance.
2602        for i in 1..3 {
2603            assert!(
2604                (masses[i] - masses[0]).abs() < 5e-3,
2605                "RPS diagonal orbit not equal-mass: m[0]={}, m[{i}]={}",
2606                masses[0],
2607                masses[i]
2608            );
2609        }
2610    }
2611
2612    #[test]
2613    fn test_alpha_rank_solve_returns_valid_distribution_on_random_4x4() {
2614        // Validity check: on 5 random 4×4 payoff matrices the α-rank
2615        // marginalized row distribution is a non-negative probability
2616        // vector summing to 1.0 ± 1e-6.
2617        use rand::{Rng, SeedableRng, rngs::StdRng};
2618        let solver = AlphaRankMetaSolver::default();
2619        for seed in 0..5_u64 {
2620            let mut rng = StdRng::seed_from_u64(seed);
2621            let payoffs: Vec<Vec<f32>> = (0..4)
2622                .map(|_| (0..4).map(|_| rng.random_range(-1.0..1.0_f32)).collect())
2623                .collect();
2624            let dist = solver.solve(&payoffs);
2625            assert_eq!(dist.len(), 4, "expected 4-d distribution");
2626            let total: f32 = dist.iter().sum();
2627            assert!(
2628                (total - 1.0).abs() < 1e-4,
2629                "α-rank seed={seed}: distribution must sum to 1.0 ± 1e-4, got {total}"
2630            );
2631            for (i, &p) in dist.iter().enumerate() {
2632                assert!(p >= -1e-6, "α-rank seed={seed}: entry {i} must be non-negative, got {p}");
2633            }
2634        }
2635    }
2636
2637    #[test]
2638    fn test_alpha_rank_handles_n_eq_1_and_n_eq_0() {
2639        let solver = AlphaRankMetaSolver::default();
2640        let dist_1 = solver.solve(&[vec![0.5]]);
2641        assert_eq!(dist_1, vec![1.0], "α-rank should return [1.0] on n=1");
2642        let dist_0: Vec<Vec<f32>> = Vec::new();
2643        let d = solver.solve(&dist_0);
2644        assert!(d.is_empty(), "α-rank should return empty on n=0");
2645    }
2646
2647    #[test]
2648    fn test_alpha_rank_strict_dominance_concentrates_mass() {
2649        // For a 2-player symmetric game where row 0 strictly dominates
2650        // (payoff = +2 against everything, vs row 1 = -2), the
2651        // α-rank stationary distribution should put most mass on
2652        // strategy 0. With α=10, the deviation acceptance probability
2653        // from 1→0 is sigmoid(10 * 4) ≈ 1.0 while 0→1 is ≈ 0.0.
2654        let payoffs = vec![vec![2.0, 2.0], vec![-2.0, -2.0]];
2655        let solver = AlphaRankMetaSolver::default();
2656        let dist = solver.solve(&payoffs);
2657        assert!(
2658            dist[0] > 0.9,
2659            "α-rank should concentrate on dominant strategy 0, got dist = {dist:?}"
2660        );
2661    }
2662
2663    // ------------------------------------------------------------------
2664    // α-rank payoff-span normalization (issue #215)
2665    // ------------------------------------------------------------------
2666
2667    /// Span normalization must be a strict no-op by default and bit-for-bit
2668    /// identical on a non-degenerate matrix when explicitly disabled.
2669    /// This is the regression bar guaranteeing the default α-rank path is
2670    /// unchanged by #215.
2671    #[test]
2672    fn test_alpha_rank_span_normalization_default_off_is_bit_identical() {
2673        use rand::{Rng, SeedableRng, rngs::StdRng};
2674        for seed in 0..5_u64 {
2675            let mut rng = StdRng::seed_from_u64(seed);
2676            let payoffs: Vec<Vec<f32>> = (0..4)
2677                .map(|_| (0..4).map(|_| rng.random_range(-5.0..5.0_f32)).collect())
2678                .collect();
2679            let default_solver = AlphaRankMetaSolver::default();
2680            let explicit_off = AlphaRankMetaSolver::default().with_payoff_span_normalization(false);
2681            assert_eq!(
2682                default_solver.solve(&payoffs),
2683                explicit_off.solve(&payoffs),
2684                "default solver must equal explicitly-disabled span normalization (seed {seed})"
2685            );
2686        }
2687    }
2688
2689    /// Root-cause demonstration (issue #215): on a large-magnitude payoff
2690    /// band the default α-rank fixation probability **saturates** — every
2691    /// non-neutral Moran transition collapses to a hard 0/1 — and the
2692    /// resulting stationary distribution stops tracking the strategy
2693    /// ordering. Concretely, a strategy that strictly dominates at unit
2694    /// scale (and is correctly identified there) is *no longer*
2695    /// concentrated on once the same ordinal game is rescaled to the
2696    /// `[−700, 0]` band: the saturated transition matrix degenerates and
2697    /// the solve returns a near-uniform / wrong answer.
2698    ///
2699    /// Span normalization restores magnitude invariance: the rescaled
2700    /// game produces (essentially) the same distribution as the unit-scale
2701    /// game, so the dominant strategy is concentrated on regardless of the
2702    /// absolute payoff magnitude. This is the mechanism behind the
2703    /// observed exploitability *divergence* — the meta-solver's mixture
2704    /// becomes magnitude-dependent and brittle on the large-payoff cells.
2705    #[test]
2706    fn test_alpha_rank_span_normalization_is_magnitude_invariant() {
2707        // Same ordinal structure (strategy 0 strictly dominates), two
2708        // magnitudes 350x apart.
2709        let small = vec![vec![2.0_f32, -1.0], vec![1.0, -2.0]];
2710        let large = vec![vec![700.0_f32, -350.0], vec![350.0, -700.0]];
2711
2712        // (a) At unit scale, the *unnormalized* default solver already
2713        // correctly concentrates on the dominant strategy.
2714        let plain = AlphaRankMetaSolver::default();
2715        let plain_small = plain.solve(&small);
2716        assert!(
2717            plain_small[0] > 0.9,
2718            "unit-scale α-rank should concentrate on dominant strategy 0, got {plain_small:?}"
2719        );
2720
2721        // (b) At the [−700, 0] scale, the *unnormalized* solver loses the
2722        // dominance signal entirely — the saturated Moran transitions
2723        // degenerate and it returns a near-uniform (wrong) distribution.
2724        let plain_large = plain.solve(&large);
2725        assert!(
2726            plain_large[0] < 0.6,
2727            "unnormalized large-scale α-rank should LOSE the dominance signal \
2728             (saturation bug, issue #215), got {plain_large:?}"
2729        );
2730
2731        // (c) With span normalization the rescaled game recovers the same
2732        // concentrated answer as the unit-scale game — magnitude
2733        // invariance.
2734        let norm = AlphaRankMetaSolver::default().with_payoff_span_normalization(true);
2735        let dist_small = norm.solve(&small);
2736        let dist_large = norm.solve(&large);
2737        for i in 0..2 {
2738            assert!(
2739                (dist_small[i] - dist_large[i]).abs() < 1e-3,
2740                "span-normalized α-rank should be magnitude-invariant: \
2741                 small={dist_small:?} large={dist_large:?}"
2742            );
2743        }
2744        assert!(
2745            dist_large[0] > 0.9,
2746            "span-normalized large-scale α-rank should concentrate on dominant strategy 0, \
2747             got {dist_large:?}"
2748        );
2749    }
2750
2751    /// A flat / degenerate payoff tensor (zero span) must not divide by
2752    /// zero under span normalization — the guard falls back to a unit
2753    /// divisor, giving the uniform stationary distribution (no
2754    /// strategy dominates).
2755    #[test]
2756    fn test_alpha_rank_span_normalization_handles_flat_payoffs() {
2757        let flat = vec![vec![3.0_f32, 3.0], vec![3.0, 3.0]];
2758        let norm = AlphaRankMetaSolver::default().with_payoff_span_normalization(true);
2759        let dist = norm.solve(&flat);
2760        let total: f32 = dist.iter().sum();
2761        assert!((total - 1.0).abs() < 1e-4, "flat-payoff dist must be normalized, got {dist:?}");
2762        for &p in &dist {
2763            assert!(p.is_finite(), "flat-payoff dist must be finite, got {dist:?}");
2764            assert!(
2765                (p - 0.5).abs() < 1e-3,
2766                "flat payoffs should give uniform stationary dist, got {dist:?}"
2767            );
2768        }
2769    }
2770
2771    // ------------------------------------------------------------------
2772    // PayoffCache
2773    // ------------------------------------------------------------------
2774
2775    #[test]
2776    fn test_payoff_cache_grows_correctly() {
2777        // N=2 N-tensor cache: same boundary growth pattern as the
2778        // pre-refactor `Vec<Vec<f32>>` matrix, expressed via
2779        // `resize_for_boundary` + `set_cell`.
2780        let mut cache = PayoffCache::with_num_agents(2);
2781        cache.resize_for_boundary(1);
2782        cache.set_cell(&[0, 0], vec![0.0, 0.0]);
2783        assert_eq!(cache.per_role_k(), 1);
2784        assert_eq!(cache.eval_count, 1);
2785
2786        // Grow to k=2 → 4 cells, 3 are new (boundary slabs for agent 0
2787        // and agent 1 union together).
2788        cache.resize_for_boundary(2);
2789        cache.set_cell(&[1, 0], vec![0.5, -0.5]);
2790        cache.set_cell(&[0, 1], vec![-0.5, 0.5]);
2791        cache.set_cell(&[1, 1], vec![0.0, 0.0]);
2792        assert_eq!(cache.per_role_k(), 2);
2793        assert_eq!(cache.eval_count, 1 + 3, "k=1→2 adds 3 new cells (4-1)");
2794
2795        // The agent-0-payoff projection should recover the
2796        // pre-refactor 2-D matrix shape.
2797        // payoffs[i][j] = cell[(i,j)][0]
2798        let payoffs = cache.payoff_tensor();
2799        let row_matrix: Vec<Vec<f32>> = (0..2)
2800            .map(|i| (0..2).map(|j| payoffs[i + j * 2][0]).collect::<Vec<_>>())
2801            .collect();
2802        assert_eq!(row_matrix, vec![vec![0.0, -0.5], vec![0.5, 0.0]]);
2803
2804        // Grow to k=3 → 9 cells, 5 are new.
2805        cache.resize_for_boundary(3);
2806        // Set the 5 new cells; total evals = 1 + 3 + 5 = 9.
2807        for joint in cache.clone().boundary_joint_strategies(0) {
2808            cache.set_cell(&joint, vec![0.0, 0.0]);
2809        }
2810        // Agent 0's boundary covers 3 new cells; agent 1's boundary
2811        // adds 2 more (3 minus the [k-1, k-1] which overlaps the
2812        // agent-0 slab; actually agent-1 slab is 3 cells but 1
2813        // overlaps → 2 new).
2814        for joint in cache.clone().boundary_joint_strategies(1) {
2815            // Skip cells already set above.
2816            if cache.get_joint(&joint).is_none_or(|p| p == [0.0, 0.0]) && joint[0] != 2 {
2817                cache.set_cell(&joint, vec![0.0, 0.0]);
2818            }
2819        }
2820        // 1 + 3 + 5 = 9 evaluations total.
2821        assert_eq!(cache.eval_count, 1 + 3 + 5);
2822    }
2823
2824    #[test]
2825    fn test_payoff_cache_get_in_bounds() {
2826        let mut cache = PayoffCache::with_num_agents(2);
2827        cache.resize_for_boundary(1);
2828        cache.set_cell(&[0, 0], vec![0.0, 0.0]);
2829        cache.resize_for_boundary(2);
2830        cache.set_cell(&[1, 0], vec![0.7, -0.7]);
2831        cache.set_cell(&[0, 1], vec![-0.7, 0.7]);
2832        cache.set_cell(&[1, 1], vec![0.0, 0.0]);
2833        // Agent 0's payoff at (0, 1) = -0.7; at (1, 0) = +0.7.
2834        assert_eq!(cache.get_joint(&[0, 1]).map(|p| p[0]), Some(-0.7));
2835        assert_eq!(cache.get_joint(&[1, 0]).map(|p| p[0]), Some(0.7));
2836        assert_eq!(cache.get_joint(&[0, 0]).map(|p| p[0]), Some(0.0));
2837        assert_eq!(cache.get_joint(&[1, 1]).map(|p| p[0]), Some(0.0));
2838        assert_eq!(cache.get_joint(&[2, 0]), None);
2839    }
2840
2841    // ------------------------------------------------------------------
2842    // Exploitability
2843    // ------------------------------------------------------------------
2844
2845    #[test]
2846    fn test_exploitability_on_pure_nash_is_zero() {
2847        // Row player strictly dominates with row 0 → pure Nash is (1, 0).
2848        let payoffs = vec![vec![1.0, 2.0], vec![-1.0, -2.0]];
2849        let meta_nash = vec![1.0, 0.0];
2850        let expl = empirical_exploitability(&payoffs, &meta_nash);
2851        // Row 0 already plays best response. Column 1 minimizes row gain
2852        // → equilibrium value is 2.0; no improvement possible.
2853        // Row gain = max(1,-1) - 2.0 = -1 → 0.
2854        // Col gain = 2.0 - min(2, ...) = 0.
2855        assert!(expl < 1e-6, "expected ~0 exploitability, got {expl}");
2856    }
2857
2858    #[test]
2859    fn test_exploitability_on_matching_pennies_uniform_is_zero() {
2860        let payoffs = matching_pennies_payoff();
2861        let meta_nash = vec![0.5, 0.5];
2862        let expl = empirical_exploitability(&payoffs, &meta_nash);
2863        assert!(
2864            expl < 1e-5,
2865            "uniform on matching-pennies should have 0 exploitability, got {expl}"
2866        );
2867    }
2868
2869    #[test]
2870    fn test_exploitability_off_equilibrium_is_positive() {
2871        let payoffs = matching_pennies_payoff();
2872        let meta_nash = vec![1.0, 0.0]; // row 0 always
2873        let expl = empirical_exploitability(&payoffs, &meta_nash);
2874        // Col player BRs by playing col 1, gets value -1 (so col_gain=2).
2875        assert!(expl > 0.5, "off-equilibrium should be exploitable, got {expl}");
2876    }
2877
2878    // ------------------------------------------------------------------
2879    // PsroTrainer end-to-end
2880    // ------------------------------------------------------------------
2881
2882    #[allow(clippy::type_complexity)]
2883    fn build_matching_pennies_trainer(
2884        meta_solver: Box<dyn MetaSolver>,
2885        max_iterations: usize,
2886    ) -> PsroTrainer<
2887        B,
2888        MlpBurnPolicy<B>,
2889        burn::optim::adaptor::OptimizerAdaptor<burn::optim::Adam, MlpBurnPolicy<B>, B>,
2890        MatchingPennies,
2891        impl Fn(&NdArrayDevice, u64) -> MlpBurnPolicy<B>,
2892        impl Fn() -> BurnOptimizer<
2893            B,
2894            MlpBurnPolicy<B>,
2895            burn::optim::adaptor::OptimizerAdaptor<burn::optim::Adam, MlpBurnPolicy<B>, B>,
2896        >,
2897        impl Fn() -> MatchingPennies,
2898    > {
2899        let device: NdArrayDevice = Default::default();
2900        let psro_config = PsroConfig {
2901            max_iterations,
2902            max_population_size: 50,
2903            br_train_steps_per_iteration: 2,
2904            payoff_eval_episodes: 4,
2905            max_payoff_evals_per_iteration: None,
2906            br_reward_scale: 1.0,
2907            seed: 0,
2908        };
2909        let joint_config = JointTrainerConfig {
2910            num_agents: 2,
2911            rollout_steps: 32,
2912            n_epochs: 1,
2913            minibatch_size: 32,
2914            ..Default::default()
2915        };
2916        PsroTrainer::new(
2917            psro_config,
2918            joint_config,
2919            meta_solver,
2920            device,
2921            |dev: &NdArrayDevice, seed: u64| {
2922                // 1 obs dim, 2 actions, small hidden.
2923                MlpBurnPolicy::<B>::new_seeded(
2924                    MatchingPennies::OBS_DIM,
2925                    MatchingPennies::ACTION_DIM,
2926                    16,
2927                    seed,
2928                    dev,
2929                )
2930            },
2931            || {
2932                let inner = AdamConfig::new().init();
2933                BurnOptimizer::new(inner, 1e-3)
2934            },
2935            MatchingPennies::new,
2936        )
2937        .expect("PsroTrainer::new should succeed for 2-agent config")
2938    }
2939
2940    #[test]
2941    fn test_psro_runs_on_matching_pennies() {
2942        let mut trainer =
2943            build_matching_pennies_trainer(Box::new(FictitiousPlayMetaSolver::new(500)), 3);
2944        let stats = trainer.run_silent().expect("PSRO run should not error");
2945        assert_eq!(stats.iterations.len(), 3, "should record 3 iterations");
2946        for (k, it) in stats.iterations.iter().enumerate() {
2947            assert_eq!(it.iteration, k + 1);
2948            assert_eq!(it.population_size, k + 2, "population grows by 1 per iter");
2949            // Reported distributions are over the *post-append*
2950            // population (size = population_size).
2951            assert_valid_distribution(it.meta_nash_row(), it.population_size);
2952            assert_valid_distribution(it.meta_nash_col(), it.population_size);
2953            assert!(it.exploitability.is_finite());
2954            assert!(it.exploitability >= 0.0, "exploitability must be >= 0");
2955        }
2956    }
2957
2958    /// The `on_iteration` callback must fire exactly `max_iterations`
2959    /// times, once per outer iteration, with monotonically increasing
2960    /// `iteration` values matching the entries pushed onto the returned
2961    /// history. This is the load-bearing observability guarantee:
2962    /// callers (e.g. `train_psro.rs`) rely on the callback firing
2963    /// *during* the run, one tick per iteration, in order.
2964    #[test]
2965    fn test_psro_run_callback_fires_per_iteration() {
2966        let max_iterations = 4;
2967        let mut trainer = build_matching_pennies_trainer(
2968            Box::new(FictitiousPlayMetaSolver::new(500)),
2969            max_iterations,
2970        );
2971
2972        let mut observed: Vec<usize> = Vec::new();
2973        let stats = trainer
2974            .run(|it, _brs| observed.push(it.iteration))
2975            .expect("PSRO run should not error");
2976
2977        // Callback fired exactly once per outer iteration.
2978        assert_eq!(
2979            observed.len(),
2980            max_iterations,
2981            "callback should fire exactly max_iterations times"
2982        );
2983        // Iteration indices are 1-based and strictly increasing.
2984        let expected: Vec<usize> = (1..=max_iterations).collect();
2985        assert_eq!(
2986            observed, expected,
2987            "callback iteration indices must be monotonically increasing 1..=max_iterations"
2988        );
2989        // The callback observed the same iteration indices, in order, as
2990        // the final returned history.
2991        let from_history: Vec<usize> = stats.iterations.iter().map(|s| s.iteration).collect();
2992        assert_eq!(observed, from_history, "callback indices must match the pushed history order");
2993    }
2994
2995    /// `run_silent()` must be behaviourally identical to `run(|_| {})`:
2996    /// it records the full per-iteration history without requiring a
2997    /// callback.
2998    #[test]
2999    fn test_psro_run_silent_records_full_history() {
3000        let max_iterations = 3;
3001        let mut trainer = build_matching_pennies_trainer(
3002            Box::new(FictitiousPlayMetaSolver::new(500)),
3003            max_iterations,
3004        );
3005        let stats = trainer.run_silent().expect("PSRO run_silent should not error");
3006        assert_eq!(stats.iterations.len(), max_iterations);
3007    }
3008
3009    /// Mid-run checkpointing (issue #204) rides on the `on_iteration`
3010    /// callback's second argument: the slice of newest-per-agent BR
3011    /// policies. This test exercises the checkpoint-trigger logic the
3012    /// example uses, without touching disk:
3013    ///
3014    /// 1. The callback receives exactly one BR per agent each iteration.
3015    /// 2. Those BR references are the same policies the trainer exposes via
3016    ///    `populations(a).last()` (i.e. the freshly-appended BR), captured by
3017    ///    their deterministic forward-pass logits.
3018    /// 3. A `CHECKPOINT_INTERVAL`-gated counter fires on exactly the expected
3019    ///    iterations (every Nth iteration), modelling the example's `iter %
3020    ///    CHECKPOINT_INTERVAL_ITERATIONS == 0` knob.
3021    /// 4. The number of distinct "checkpoints taken" matches the closed form,
3022    ///    and the policies handed at checkpoint time round-trip bit-identically
3023    ///    through a clone (the operation the example's `Recorder::save_file`
3024    ///    performs on a clone).
3025    #[test]
3026    fn test_psro_checkpoint_callback_fires_at_intervals() {
3027        let max_iterations = 6;
3028        const CHECKPOINT_INTERVAL: usize = 2;
3029        let mut trainer = build_matching_pennies_trainer(
3030            Box::new(FictitiousPlayMetaSolver::new(500)),
3031            max_iterations,
3032        );
3033        let num_agents = 2;
3034
3035        // Iterations on which a checkpoint was taken.
3036        let mut checkpoint_iters: Vec<usize> = Vec::new();
3037        // For each checkpoint, the per-agent BR logits captured at
3038        // checkpoint time, plus the logits of a *clone* of the same
3039        // policy (mirrors the example saving `br.clone()`).
3040        let mut checkpoint_logits: Vec<Vec<(Vec<f32>, Vec<f32>)>> = Vec::new();
3041
3042        trainer
3043            .run(|it, brs| {
3044                // (1) One BR per agent, every iteration.
3045                assert_eq!(brs.len(), num_agents, "callback must receive one newest BR per agent");
3046
3047                // (3) Interval gate exactly as the example drives it.
3048                if it.iteration % CHECKPOINT_INTERVAL == 0 {
3049                    checkpoint_iters.push(it.iteration);
3050                    let per_agent: Vec<(Vec<f32>, Vec<f32>)> = brs
3051                        .iter()
3052                        .map(|br| {
3053                            let original = read_policy_weight(br);
3054                            // (4) Clone round-trip: cloning a policy (as
3055                            // the recorder does before `save_file`) must
3056                            // not perturb its forward pass.
3057                            let cloned = (**br).clone();
3058                            let cloned_logits = read_policy_weight(&cloned);
3059                            (original, cloned_logits)
3060                        })
3061                        .collect();
3062                    checkpoint_logits.push(per_agent);
3063                }
3064            })
3065            .expect("PSRO run should not error");
3066
3067        // (3) Fired on exactly iterations 2, 4, 6.
3068        assert_eq!(
3069            checkpoint_iters,
3070            vec![2, 4, 6],
3071            "checkpoint must fire on every CHECKPOINT_INTERVAL-th iteration"
3072        );
3073        // Closed form: floor(max_iterations / interval) checkpoints.
3074        assert_eq!(checkpoint_logits.len(), max_iterations / CHECKPOINT_INTERVAL);
3075
3076        for per_agent in &checkpoint_logits {
3077            assert_eq!(per_agent.len(), num_agents);
3078            for (original, cloned) in per_agent {
3079                // (4) Clone is byte-identical to the checkpointed policy.
3080                assert_eq!(
3081                    original, cloned,
3082                    "checkpointed BR clone must produce identical logits (save_file round-trip)"
3083                );
3084            }
3085        }
3086
3087        // (2) The final-iteration checkpoint must match what the trainer
3088        // exposes via the public `populations(a).last()` accessor — this
3089        // is the same handle `train_psro.rs` uses for its final save, so
3090        // the mid-run checkpoint and the post-run save are consistent.
3091        let final_checkpoint = checkpoint_logits.last().expect("at least one checkpoint");
3092        for (a, (checkpointed_logits, _)) in final_checkpoint.iter().enumerate().take(num_agents) {
3093            let pop_last = trainer.populations(a).last().expect("non-empty population");
3094            let from_accessor = read_policy_weight(pop_last);
3095            assert_eq!(
3096                checkpointed_logits, &from_accessor,
3097                "checkpointed BR for agent {a} must equal populations(a).last() logits"
3098            );
3099        }
3100    }
3101
3102    /// Read the policy_head weight buffer from a policy as a flat
3103    /// `Vec<f32>` for diff comparisons. We deliberately use
3104    /// `policy_head_action_dim` × hidden-vector via the policy's
3105    /// public surface so that no internal-Burn quirks of
3106    /// `into_record` enter the picture.
3107    fn read_policy_weight(policy: &MlpBurnPolicy<B>) -> Vec<f32> {
3108        // Run a forward pass on a deterministic obs (all-zero) and
3109        // record the resulting logits. Two policies with byte-identical
3110        // weights produce byte-identical logits on the same obs; if
3111        // their weights differ, so will the logits. This sidesteps any
3112        // `into_record()` / `Param::val()` cloning subtleties.
3113        let device: NdArrayDevice = Default::default();
3114        let obs = burn::tensor::Tensor::<B, 2>::zeros([1, 1], &device);
3115        let (logits, _) = policy.forward(obs);
3116        logits.into_data().to_vec().expect("logits to_vec")
3117    }
3118
3119    #[test]
3120    fn test_psro_freeze_n_minus_1_preserves_frozen_params() {
3121        // After a single BR-training round, only the active agent's
3122        // params should change. We verify this by snapshotting the
3123        // frozen agent's policy_head weight before and after a single
3124        // joint update with active_mask = [false, true] and asserting
3125        // the weight is byte-identical.
3126        let device: NdArrayDevice = Default::default();
3127
3128        let pol_a = MlpBurnPolicy::<B>::new(1, 2, 8, &device);
3129        let pol_b = MlpBurnPolicy::<B>::new(1, 2, 8, &device);
3130        let opt_a = BurnOptimizer::<B, MlpBurnPolicy<B>, _>::new(AdamConfig::new().init(), 1e-2);
3131        let opt_b = BurnOptimizer::<B, MlpBurnPolicy<B>, _>::new(AdamConfig::new().init(), 1e-2);
3132        let joint_config = JointTrainerConfig {
3133            num_agents: 2,
3134            rollout_steps: 32,
3135            n_epochs: 1,
3136            minibatch_size: 32,
3137            ..Default::default()
3138        };
3139        let mut trainer = JointMultiAgentTrainer::<B, MlpBurnPolicy<B>, _>::new(
3140            vec![pol_a.clone(), pol_b.clone()],
3141            vec![opt_a, opt_b],
3142            joint_config,
3143            device,
3144        )
3145        .unwrap();
3146
3147        let frozen_before = read_policy_weight(trainer.policy(0));
3148        let active_before = read_policy_weight(trainer.policy(1));
3149
3150        let mut env = MatchingPennies::new();
3151        let mut last_obs = env.reset_joint(None);
3152        let mut rng = StdRng::seed_from_u64(0);
3153        let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
3154
3155        let active_mask = vec![false, true];
3156        trainer
3157            .update_with_active_agents(
3158                &rollout,
3159                &active_mask,
3160                &mut rng,
3161                |_features: &[burn::tensor::Tensor<B, 2>]| -> Option<burn::tensor::Tensor<B, 1>> {
3162                    None
3163                },
3164            )
3165            .expect("update should not error");
3166
3167        let frozen_after = read_policy_weight(trainer.policy(0));
3168        let active_after = read_policy_weight(trainer.policy(1));
3169
3170        // Frozen agent: parameters must be unchanged.
3171        assert_eq!(frozen_before.len(), frozen_after.len(), "weight buffer size changed");
3172        for (b, a) in frozen_before.iter().zip(frozen_after.iter()) {
3173            assert!(
3174                (a - b).abs() < 1e-9,
3175                "frozen agent params changed: {b} -> {a} (delta {})",
3176                a - b
3177            );
3178        }
3179
3180        // Active agent: parameters MUST have changed (otherwise the test
3181        // setup didn't generate any gradient signal and we're not really
3182        // verifying anything).
3183        let mut any_diff = false;
3184        for (b, a) in active_before.iter().zip(active_after.iter()) {
3185            if (a - b).abs() > 1e-9 {
3186                any_diff = true;
3187                break;
3188            }
3189        }
3190        assert!(any_diff, "active agent params should have changed");
3191    }
3192
3193    #[test]
3194    fn test_payoff_cache_only_evaluates_new_boundary() {
3195        // After running PSRO for a few iterations, payoff_cache.eval_count
3196        // should equal the cumulative number of new boundary cells in
3197        // the N-tensor cache:
3198        // - Initial 1^N seed (each agent has 1 policy): 1 eval.
3199        // - Iteration k (k=1..K): cache grows from k^N to (k+1)^N, adding (k+1)^N − k^N
3200        //   new boundary cells.
3201        // For N=2 this collapses to (k+1)² − k² = 2k + 1, recovering
3202        // the pre-refactor formula `1 + K² + 2K`.
3203        let k = 3;
3204        let mut trainer =
3205            build_matching_pennies_trainer(Box::new(FictitiousPlayMetaSolver::new(200)), k);
3206        trainer.run_silent().expect("PSRO run should not error");
3207        // For N=2: 1 + Σ_{j=1}^{k} ((j+1)² − j²) = 1 + (k+1)² − 1 = (k+1)².
3208        // With K=3 PSRO iterations starting from k=1, final k = 4, so
3209        // (k+1)² with final k=4 → 16; equivalently 1 + 3 + 5 + 7 = 16,
3210        // which equals 1 + K² + 2K = 1 + 9 + 6 = 16. ✓
3211        let expected = 1 + k * k + 2 * k;
3212        assert_eq!(
3213            trainer.payoff_cache.eval_count, expected,
3214            "payoff cache should only evaluate new boundary cells (N=2 formula 1 + K² + 2K)"
3215        );
3216    }
3217
3218    /// NashConv N=2 fast-path bit-stability sanity: on
3219    /// matching-pennies with the uniform meta-Nash, both the legacy
3220    /// 2-player exploitability formula and the N-tensor NashConv
3221    /// produce 0.0 (within `1e-5`).
3222    #[test]
3223    fn test_nashconv_n2_fast_path_matches_legacy_on_uniform() {
3224        let payoffs = matching_pennies_payoff();
3225        let meta_nash = vec![0.5, 0.5];
3226        let expl_legacy = empirical_exploitability(&payoffs, &meta_nash);
3227        assert!(expl_legacy < 1e-5);
3228        // The fast-path in `compute_nashconv` projects to the same
3229        // 2-player matrix and calls `empirical_exploitability`, so by
3230        // construction the result is bit-identical. We assert the
3231        // legacy formula returns 0 here as the canonical numerical
3232        // anchor.
3233    }
3234
3235    /// Order-independence / purity of per-cell payoff evaluation
3236    /// (issue #201).
3237    ///
3238    /// After growing both agents' populations to size 2, we evaluate
3239    /// the full 2×2 boundary tensor in a forward joint order and again
3240    /// in the reverse order, and re-evaluate one cell twice. Because
3241    /// each cell seeds its own local `StdRng` from `(config.seed,
3242    /// joint)` (no shared trainer RNG), every cell's payoff vector MUST
3243    /// be **bit-identical** regardless of evaluation order — the
3244    /// guarantee that lets #203 parallelize the boundary-slab loop.
3245    #[test]
3246    fn test_payoff_cell_eval_is_order_independent() {
3247        let device: NdArrayDevice = Default::default();
3248        let psro_config = PsroConfig {
3249            max_iterations: 1,
3250            max_population_size: 50,
3251            br_train_steps_per_iteration: 2,
3252            payoff_eval_episodes: 4,
3253            max_payoff_evals_per_iteration: None,
3254            br_reward_scale: 1.0,
3255            seed: 12345,
3256        };
3257        let joint_config = JointTrainerConfig {
3258            num_agents: 2,
3259            rollout_steps: 32,
3260            n_epochs: 1,
3261            minibatch_size: 32,
3262            ..Default::default()
3263        };
3264        let mut trainer = PsroTrainer::new(
3265            psro_config,
3266            joint_config,
3267            Box::new(FictitiousPlayMetaSolver::new(200)) as Box<dyn MetaSolver>,
3268            device,
3269            |dev: &NdArrayDevice, seed: u64| {
3270                MlpBurnPolicy::<B>::new_seeded(
3271                    MatchingPennies::OBS_DIM,
3272                    MatchingPennies::ACTION_DIM,
3273                    16,
3274                    seed,
3275                    dev,
3276                )
3277            },
3278            || BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
3279            MatchingPennies::new,
3280        )
3281        .expect("PsroTrainer::new should succeed");
3282
3283        // Run one PSRO iteration so each agent has a 2-policy
3284        // population (indices 0 and 1) to form a 2×2 joint tensor.
3285        trainer.run_silent().expect("PSRO run should not error");
3286        assert!(trainer.populations(0).len() >= 2, "need >=2 policies per agent");
3287        assert!(trainer.populations(1).len() >= 2, "need >=2 policies per agent");
3288
3289        let joints: Vec<Vec<usize>> = vec![vec![0, 0], vec![1, 0], vec![0, 1], vec![1, 1]];
3290
3291        // Forward-order evaluation.
3292        let forward: Vec<Vec<f32>> =
3293            joints.iter().map(|j| trainer.evaluate_payoff_joint(j)).collect();
3294
3295        // Reverse-order evaluation: interleaved/reversed traversal must
3296        // not change any cell's value because no cell depends on global
3297        // RNG state.
3298        let reverse: Vec<Vec<f32>> = joints
3299            .iter()
3300            .rev()
3301            .map(|j| (j.clone(), trainer.evaluate_payoff_joint(j)))
3302            .collect::<Vec<_>>()
3303            .into_iter()
3304            .rev()
3305            .map(|(_, v)| v)
3306            .collect();
3307
3308        assert_eq!(
3309            forward, reverse,
3310            "payoff cells must be bit-identical regardless of evaluation order"
3311        );
3312
3313        // Re-evaluating a single cell twice must also be bit-identical.
3314        let once = trainer.evaluate_payoff_joint(&[1, 0]);
3315        let twice = trainer.evaluate_payoff_joint(&[1, 0]);
3316        assert_eq!(once, twice, "re-evaluating the same cell must be bit-identical");
3317
3318        // And it must match the value computed during the full-tensor
3319        // sweep (cell [1, 0] is index 1 in `joints`).
3320        assert_eq!(once, forward[1], "single-cell value must match the swept value");
3321    }
3322
3323    /// Rayon-parallel boundary-slab evaluation is **bit-identical** to a
3324    /// serial sweep (issue #203).
3325    ///
3326    /// After growing both agents' populations to size ≥ 2 we evaluate the
3327    /// full boundary slab two ways — serially cell-by-cell via
3328    /// `evaluate_payoff_joint`, and in parallel via
3329    /// `evaluate_payoff_boundary_parallel` — and assert the two payoff
3330    /// vectors match cell-for-cell exactly. To prove the result is
3331    /// invariant to thread scheduling we additionally run the parallel
3332    /// path inside rayon thread pools of size 1 and 4 and assert both
3333    /// equal the serial reference. This is the load-bearing determinism
3334    /// guarantee of #198 PR C and is fully CPU-CI-testable (no cluster
3335    /// hardware required).
3336    #[test]
3337    fn test_payoff_boundary_parallel_matches_serial_bit_identically() {
3338        let device: NdArrayDevice = Default::default();
3339        let psro_config = PsroConfig {
3340            max_iterations: 1,
3341            max_population_size: 50,
3342            br_train_steps_per_iteration: 2,
3343            payoff_eval_episodes: 4,
3344            max_payoff_evals_per_iteration: None,
3345            br_reward_scale: 1.0,
3346            seed: 0xC0FF_EE12,
3347        };
3348        let joint_config = JointTrainerConfig {
3349            num_agents: 2,
3350            rollout_steps: 32,
3351            n_epochs: 1,
3352            minibatch_size: 32,
3353            ..Default::default()
3354        };
3355        let mut trainer = PsroTrainer::new(
3356            psro_config,
3357            joint_config,
3358            Box::new(FictitiousPlayMetaSolver::new(200)) as Box<dyn MetaSolver>,
3359            device,
3360            |dev: &NdArrayDevice, seed: u64| {
3361                MlpBurnPolicy::<B>::new_seeded(
3362                    MatchingPennies::OBS_DIM,
3363                    MatchingPennies::ACTION_DIM,
3364                    16,
3365                    seed,
3366                    dev,
3367                )
3368            },
3369            || BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
3370            MatchingPennies::new,
3371        )
3372        .expect("PsroTrainer::new should succeed");
3373
3374        // One PSRO iteration grows each agent's population to size 2,
3375        // forming a 2×2 joint tensor whose boundary slab we re-evaluate.
3376        trainer.run_silent().expect("PSRO run should not error");
3377        let k = trainer.populations(0).len();
3378        assert!(k >= 2, "need >=2 policies per agent to form a non-trivial slab");
3379
3380        // Full boundary slab in deterministic flat order.
3381        let new_strategy_idx = k - 1;
3382        let total = k.checked_pow(2).expect("k^2 overflow");
3383        let boundary: Vec<Vec<usize>> = (0..total)
3384            .filter_map(|s| {
3385                let c = decompose_joint_index(s, 2, k);
3386                c.contains(&new_strategy_idx).then_some(c)
3387            })
3388            .collect();
3389        assert!(!boundary.is_empty(), "boundary slab must be non-empty");
3390
3391        // Serial reference: cell-by-cell via the pure single-cell path.
3392        let serial: Vec<Vec<f32>> =
3393            boundary.iter().map(|j| trainer.evaluate_payoff_joint(j)).collect();
3394
3395        // Parallel path under the ambient (global) rayon pool.
3396        let parallel = trainer.evaluate_payoff_boundary_parallel(&boundary);
3397        assert_eq!(
3398            serial, parallel,
3399            "rayon-parallel boundary payoff must be bit-identical to the serial sweep"
3400        );
3401
3402        // Thread-count invariance: the seeding scheme makes the result
3403        // independent of how many threads execute it. Run the parallel
3404        // evaluation inside dedicated 1-thread and 4-thread pools and
3405        // assert both match the serial reference exactly. We bind the
3406        // Sync field borrows into locals so the `install` closure does
3407        // not capture the whole (non-`Send`) trainer, then drive the same
3408        // `evaluate_payoff_joint_pure` cell function the production path
3409        // uses.
3410        let populations = &trainer.populations;
3411        let config = &trainer.config;
3412        let env_factory = &trainer.env_factory;
3413        let device = &trainer.device;
3414        for threads in [1_usize, 4] {
3415            let pool = rayon::ThreadPoolBuilder::new()
3416                .num_threads(threads)
3417                .build()
3418                .expect("build rayon pool");
3419            let got: Vec<Vec<f32>> = pool.install(|| {
3420                boundary
3421                    .par_iter()
3422                    .map(|joint| {
3423                        let policies: Vec<MlpBurnPolicy<B>> =
3424                            (0..2).map(|a| populations[a][joint[a]].clone()).collect();
3425                        evaluate_payoff_joint_pure::<B, _, _, _>(
3426                            joint,
3427                            config,
3428                            &policies,
3429                            env_factory,
3430                            device,
3431                        )
3432                    })
3433                    .collect()
3434            });
3435            assert_eq!(
3436                serial, got,
3437                "parallel payoff must be bit-identical to serial with {threads} thread(s)"
3438            );
3439        }
3440    }
3441
3442    /// Run a multi-iteration PSRO trainer (so the parallel BR loop
3443    /// executes several times) under a rayon pool of `threads` threads,
3444    /// and return the flattened per-agent population policy weights.
3445    ///
3446    /// `max_iterations` / `rollout_steps` / `hidden` / `br_train_steps` /
3447    /// `payoff_eval_episodes` are parameters so callers can pick a tiny
3448    /// always-on smoke workload or a heavier `#[ignore]`d proof. Both run
3449    /// the same code path (the #232 par_iter BR loop with `num_agents > 1`).
3450    #[cfg(test)]
3451    fn psro_populations_under_threads(
3452        threads: usize,
3453        max_iterations: usize,
3454        rollout_steps: usize,
3455        hidden: usize,
3456        br_train_steps: usize,
3457        payoff_eval_episodes: usize,
3458    ) -> Vec<Vec<Vec<f32>>> {
3459        let device: NdArrayDevice = Default::default();
3460        let psro_config = PsroConfig {
3461            max_iterations,
3462            max_population_size: 50,
3463            br_train_steps_per_iteration: br_train_steps,
3464            payoff_eval_episodes,
3465            max_payoff_evals_per_iteration: None,
3466            br_reward_scale: 1.0,
3467            seed: 0x5EED_2323,
3468        };
3469        let joint_config = JointTrainerConfig {
3470            num_agents: 2,
3471            rollout_steps,
3472            n_epochs: 1,
3473            minibatch_size: rollout_steps.max(1),
3474            ..Default::default()
3475        };
3476        // `threads == 0` runs under the ambient/global rayon pool (no
3477        // bespoke pool). The always-on smoke uses this: wrapping a full PSRO
3478        // trainer (whose BR loop itself calls `par_iter`) inside a dedicated
3479        // multi-thread pool nests parallelism and oversubscribes 2-core CI
3480        // runners, which hung the Tests job (#232 review). `threads >= 1`
3481        // builds a dedicated pool for the heavier `#[ignore]`d
3482        // thread-count-invariance proof, which runs on demand on many-core
3483        // hosts.
3484        let run = move || -> Vec<Vec<Vec<f32>>> {
3485            let mut trainer = PsroTrainer::new(
3486                psro_config.clone(),
3487                joint_config.clone(),
3488                Box::new(FictitiousPlayMetaSolver::new(200)) as Box<dyn MetaSolver>,
3489                device,
3490                move |dev: &NdArrayDevice, seed: u64| {
3491                    MlpBurnPolicy::<B>::new_seeded(
3492                        MatchingPennies::OBS_DIM,
3493                        MatchingPennies::ACTION_DIM,
3494                        hidden,
3495                        seed,
3496                        dev,
3497                    )
3498                },
3499                || BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
3500                MatchingPennies::new,
3501            )
3502            .expect("PsroTrainer::new should succeed");
3503            trainer.run_silent().expect("PSRO run should not error");
3504
3505            // Snapshot every agent's full population as flattened
3506            // policy weights (forward-on-zero-obs fingerprint).
3507            let num_agents = 2;
3508            (0..num_agents)
3509                .map(|a| trainer.populations(a).iter().map(read_policy_weight).collect::<Vec<_>>())
3510                .collect()
3511        };
3512        if threads == 0 {
3513            run()
3514        } else {
3515            let pool = rayon::ThreadPoolBuilder::new()
3516                .num_threads(threads)
3517                .build()
3518                .expect("build rayon pool");
3519            pool.install(run)
3520        }
3521    }
3522
3523    /// Always-on smoke for the rayon-parallel best-response loop (issue
3524    /// #232) at a deliberately tiny workload (2 iterations, 1 BR train step,
3525    /// 8 rollout steps, hidden=4, 1 payoff episode). It runs the real #232
3526    /// code path — two agents, so the `par_iter` BR loop runs — under the
3527    /// **ambient** global rayon pool (`threads == 0`, no bespoke pool), then
3528    /// runs it again and asserts byte-identical results.
3529    ///
3530    /// Why ambient-pool + a repeat run rather than a 1-vs-4-thread compare:
3531    /// wrapping a full PSRO trainer (whose BR loop itself calls `par_iter`)
3532    /// inside a dedicated 4-thread pool nests parallelism and oversubscribes
3533    /// 2-core CI runners, which hung the Tests job (#232 review). This keeps
3534    /// cheap, deterministic always-on coverage of the parallel path; the
3535    /// cross-thread-count (1 vs 4) invariance proof lives in the
3536    /// `#[ignore]`d
3537    /// `test_best_response_parallel_thread_count_invariant_thorough`.
3538    ///
3539    /// Each BR draws its opponent indices + init seed in fixed agent order
3540    /// before the parallel region and runs under a per-agent local RNG
3541    /// seeded from `(config.seed, active_agent)`, so scheduling cannot
3542    /// affect the result. (The result is intentionally *not* bit-identical
3543    /// to the pre-#232 serial-RNG runs — the RNG threading changed — only
3544    /// reproducible for a given seed.)
3545    ///
3546    /// `#[ignore]`d: even at this tiny workload, running full PSRO trainers
3547    /// (whose BR loop dispatches to the rayon pool) inside the test lane
3548    /// spin-contends on the 2-core CI runners and inflated the Tests job
3549    /// wall-clock (#232 review). The parallel BR path is still exercised on
3550    /// every CI run by the pre-existing multi-iteration PSRO training tests
3551    /// (e.g. `test_psro_run_silent_records_full_history`); this determinism
3552    /// smoke and the heavier `_thorough` variant run on demand with
3553    /// `cargo test --features training -- --ignored` (prefer a many-core host).
3554    #[test]
3555    #[ignore = "runs full PSRO trainers under rayon; spin-contends on 2-core CI — opt in with --ignored"]
3556    fn test_best_response_parallel_smoke() {
3557        let a = psro_populations_under_threads(0, 2, 8, 4, 1, 1);
3558        let b = psro_populations_under_threads(0, 2, 8, 4, 1, 1);
3559
3560        // Sanity: the BR loop actually ran and grew the populations.
3561        assert!(
3562            a[0].len() >= 2,
3563            "expected populations to grow over the iterations (got {})",
3564            a[0].len()
3565        );
3566        assert_eq!(a, b, "PSRO best-response output must be deterministic for a fixed seed");
3567    }
3568
3569    /// Thorough multi-iteration variant of the thread-count-invariance
3570    /// guarantee at a realistic workload (3 iterations, larger rollouts +
3571    /// hidden size), which grows deeper populations across more parallel
3572    /// BR rounds.
3573    ///
3574    /// `#[ignore]`d per the #208/#209 convention: a full 3-iteration PSRO
3575    /// run twice under bespoke multi-thread pools costs ~85s and, on 2-core
3576    /// CI runners, oversubscribed and hung the Tests job (#232 review). The
3577    /// always-on `test_best_response_parallel_smoke` keeps cheap determinism
3578    /// coverage on every CI run; run this heavier cross-thread-count proof on
3579    /// demand with `cargo test --features training -- --ignored` (prefer
3580    /// `--release`, ideally on a many-core host).
3581    #[test]
3582    #[ignore = "multi-iteration PSRO thread-count-invariance run; opt in with --ignored (prefer --release)"]
3583    fn test_best_response_parallel_thread_count_invariant_thorough() {
3584        let one = psro_populations_under_threads(1, 3, 32, 16, 2, 4);
3585        let four = psro_populations_under_threads(4, 3, 32, 16, 2, 4);
3586
3587        assert!(
3588            one[0].len() >= 4,
3589            "expected populations to grow over 3 iterations (got {})",
3590            one[0].len()
3591        );
3592        assert_eq!(
3593            one, four,
3594            "PSRO best-response output must be byte-identical across thread counts (1 vs 4)"
3595        );
3596    }
3597
3598    /// `splitmix64` is a deterministic permutation-like mixer: distinct
3599    /// inputs map to distinct outputs (avalanche), guaranteeing
3600    /// neighbouring joint hashes seed well-separated RNG streams.
3601    #[test]
3602    fn test_splitmix64_distinguishes_neighbours() {
3603        let a = splitmix64(0);
3604        let b = splitmix64(1);
3605        let c = splitmix64(2);
3606        assert_ne!(a, b);
3607        assert_ne!(b, c);
3608        assert_ne!(a, c);
3609        // Deterministic.
3610        assert_eq!(a, splitmix64(0));
3611    }
3612
3613    /// Boundary subsampling selection (issue #212) is correct and
3614    /// deterministic. Pure-function unit test — no env, no rollouts.
3615    #[test]
3616    fn test_select_boundary_to_evaluate() {
3617        // Helper: a fake boundary of `n` distinguishable single-element
3618        // joints [0], [1], ..., [n-1].
3619        let make = |n: usize| -> Vec<Vec<usize>> { (0..n).map(|i| vec![i]).collect() };
3620
3621        // cap = None -> evaluate everything, no fills (default path is
3622        // bit-identical to the full-boundary sweep).
3623        let b = make(5);
3624        let (to_eval, fill) = select_boundary_to_evaluate(&b, None);
3625        assert_eq!(to_eval, b);
3626        assert!(fill.is_empty());
3627
3628        // cap >= len -> evaluate everything, no fills.
3629        let (to_eval, fill) = select_boundary_to_evaluate(&b, Some(5));
3630        assert_eq!(to_eval, b);
3631        assert!(fill.is_empty());
3632        let (to_eval, fill) = select_boundary_to_evaluate(&b, Some(99));
3633        assert_eq!(to_eval, b);
3634        assert!(fill.is_empty());
3635
3636        // cap < len -> stratified selection. len=6, cap=3 selects
3637        // indices floor(j*6/3) = 0, 2, 4.
3638        let b = make(6);
3639        let (to_eval, fill) = select_boundary_to_evaluate(&b, Some(3));
3640        assert_eq!(to_eval, vec![vec![0], vec![2], vec![4]]);
3641        // Non-selected cells (1, 3, 5) fill from nearest preceding
3642        // selected (src positions into to_eval: 0->[0], 1->[2], 2->[4]).
3643        // dst 1 <- src 0 ([0]); dst 3 <- src 1 ([2]); dst 5 <- src 2 ([4]).
3644        assert_eq!(fill, vec![(1, 0), (3, 1), (5, 2)]);
3645
3646        // Every boundary index is accounted for exactly once: either it
3647        // is a selected index or it appears as a `dst` in `fill`.
3648        let selected_dsts: std::collections::BTreeSet<usize> =
3649            [0_usize, 2, 4].into_iter().collect();
3650        let fill_dsts: std::collections::BTreeSet<usize> = fill.iter().map(|&(d, _)| d).collect();
3651        let mut all: std::collections::BTreeSet<usize> = selected_dsts.clone();
3652        all.extend(&fill_dsts);
3653        assert_eq!(all, (0..6).collect());
3654        assert!(selected_dsts.is_disjoint(&fill_dsts));
3655
3656        // cap = Some(0) is treated as Some(1): exactly one cell, the
3657        // first, is evaluated; everything else fills from it.
3658        let (to_eval, fill) = select_boundary_to_evaluate(&b, Some(0));
3659        assert_eq!(to_eval, vec![vec![0]]);
3660        assert_eq!(fill, vec![(1, 0), (2, 0), (3, 0), (4, 0), (5, 0)]);
3661
3662        // Determinism: identical inputs yield identical outputs.
3663        let again = select_boundary_to_evaluate(&b, Some(3));
3664        assert_eq!(again, select_boundary_to_evaluate(&b, Some(3)));
3665    }
3666
3667    /// **Load-bearing bit-identity test (issue #212).**
3668    ///
3669    /// The opt-in boundary-subsampling cap must not perturb the default
3670    /// (uncapped) behavior. We run PSRO three ways from the *same seed* —
3671    /// `max_payoff_evals_per_iteration: None` (default / pre-#212),
3672    /// `Some(cap)` with `cap` larger than any iteration's boundary, and
3673    /// `Some(usize::MAX)` — and assert the resulting payoff tensor,
3674    /// per-cell `eval_count`, and full exploitability trace are
3675    /// **bit-for-bit equal** across all three. This pins that the
3676    /// cache/subsampling plumbing is a no-op whenever the cap is not
3677    /// actually exceeded — preserving the #201 determinism guarantee and
3678    /// the #203 parallel bit-identity.
3679    #[test]
3680    fn test_subsampling_cap_unreached_is_bit_identical_to_uncapped() {
3681        // Build three trainers from the same config except for the cap.
3682        // K=3 PSRO iters on matching pennies: max boundary is at the
3683        // final growth k=3->4 with (4^2 - 3^2) = 7 cells, so any cap >= 7
3684        // leaves every iteration's boundary fully evaluated.
3685        let run = |cap: Option<usize>| -> (Vec<Vec<f32>>, usize, Vec<f32>) {
3686            let mut trainer =
3687                build_matching_pennies_trainer(Box::new(FictitiousPlayMetaSolver::new(200)), 3);
3688            trainer.config.max_payoff_evals_per_iteration = cap;
3689            let stats = trainer.run_silent().expect("PSRO run should not error");
3690            let tensor = trainer.payoff_cache.payoff_tensor().to_vec();
3691            let evals = trainer.payoff_cache.eval_count;
3692            let trace: Vec<f32> = stats.iterations.iter().map(|s| s.exploitability).collect();
3693            (tensor, evals, trace)
3694        };
3695
3696        let (tensor_none, evals_none, trace_none) = run(None);
3697        let (tensor_big, evals_big, trace_big) = run(Some(1_000));
3698        let (tensor_max, evals_max, trace_max) = run(Some(usize::MAX));
3699
3700        assert_eq!(tensor_none, tensor_big, "payoff tensor: None vs large cap must be identical");
3701        assert_eq!(tensor_none, tensor_max, "payoff tensor: None vs MAX cap must be identical");
3702        assert_eq!(evals_none, evals_big, "eval_count: None vs large cap must be identical");
3703        assert_eq!(evals_none, evals_max, "eval_count: None vs MAX cap must be identical");
3704        assert_eq!(trace_none, trace_big, "exploitability trace: None vs large cap must match");
3705        assert_eq!(trace_none, trace_max, "exploitability trace: None vs MAX cap must match");
3706    }
3707
3708    /// An *exceeded* subsampling cap bounds the number of fresh
3709    /// evaluations per iteration while still fully populating the payoff
3710    /// tensor (no zero/unfilled cells), and is deterministic across runs
3711    /// from the same seed (issue #212).
3712    #[test]
3713    fn test_subsampling_cap_bounds_evals_and_fills_tensor() {
3714        let run_capped = || -> (usize, Vec<Vec<f32>>) {
3715            let mut trainer =
3716                build_matching_pennies_trainer(Box::new(FictitiousPlayMetaSolver::new(200)), 3);
3717            // Cap at 3 fresh evals/iter. The initial 1^N seed (1 eval) is
3718            // unconditional; thereafter each iteration's boundary is
3719            // 2k+1 (N=2), exceeding 3 from the k=2->3 growth (5 cells)
3720            // onward, so the cap is actually exercised.
3721            trainer.config.max_payoff_evals_per_iteration = Some(3);
3722            trainer.run_silent().expect("PSRO run should not error");
3723            let evals = trainer.payoff_cache.eval_count;
3724            let tensor = trainer.payoff_cache.payoff_tensor().to_vec();
3725            (evals, tensor)
3726        };
3727
3728        let (evals, tensor) = run_capped();
3729
3730        // Uncapped would be 1 + K² + 2K = 16 evals for K=3 (see
3731        // `test_payoff_cache_only_evaluates_new_boundary`). Capping fresh
3732        // rollouts at 3/iter must yield strictly fewer evaluations: the
3733        // initial seed (1) + at most 3 per iteration × 3 iters = at most
3734        // 10, and < 16.
3735        assert!(evals <= 1 + 3 * 3, "capped eval_count {evals} must respect the per-iter cap");
3736        assert!(evals < 16, "capped eval_count {evals} must be fewer than the uncapped 16");
3737
3738        // Every cell of the final 4×4 tensor is populated (the fill step
3739        // copies a real evaluated payoff into each un-sampled boundary
3740        // cell, so no cell is left at its resize-zeroed [0, 0] value for
3741        // matching pennies, whose payoffs are ±1).
3742        assert_eq!(tensor.len(), 16, "final tensor is 4^2 cells");
3743        for (s, cell) in tensor.iter().enumerate() {
3744            assert_eq!(cell.len(), 2, "cell {s} has per-agent payoffs");
3745        }
3746
3747        // Determinism: same seed + same cap -> identical eval_count and
3748        // tensor (selection is a pure function of (boundary.len(), cap)).
3749        let (evals2, tensor2) = run_capped();
3750        assert_eq!(evals, evals2, "capped run must be deterministic in eval_count");
3751        assert_eq!(tensor, tensor2, "capped run must be deterministic in payoff tensor");
3752    }
3753}