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    /// Serialize the per-agent best-response `update` (backward) calls to
1006    /// avoid a concurrent-`backward()` deadlock in `burn-autodiff 0.21` on
1007    /// some many-core hosts (issue #307).
1008    ///
1009    /// The #232 parallel path dispatches `num_agents` independent
1010    /// best-response tasks via `rayon`, so all agents' `joint_loss.backward()`
1011    /// passes run concurrently on worker threads. `burn-autodiff 0.21`
1012    /// guards its `GraphLocator`/`GraphState` singletons with `parking_lot`
1013    /// mutexes acquired in an inconsistent order across the two backward
1014    /// code paths, producing a lock-order inversion that deterministically
1015    /// wedges on macOS arm64 (8-core M-series) with `N = 4` concurrent
1016    /// backward passes. Linux CI (2-core x86) rarely hits the race window.
1017    ///
1018    /// When `true` (the default until the upstream burn issue is resolved
1019    /// — no `burn 0.22` exists on crates.io yet), the parallel `par_iter`
1020    /// is replaced by a serial `.map()` that trains each agent's BR in
1021    /// fixed agent order, so at most one backward graph is live at a time.
1022    /// When `false`, all agents' backward passes run concurrently under
1023    /// `rayon` (the original #232 path).
1024    ///
1025    /// # Determinism
1026    ///
1027    /// This flag does **not** affect results. All shared-mutable draws are
1028    /// hoisted into the fixed-order pre-pass in
1029    /// `train_best_responses_parallel` before any dispatch, and each
1030    /// BR is a pure function of its pre-drawn inputs, collected by index.
1031    /// Serial vs. parallel dispatch therefore yields bit-identical
1032    /// `PsroStats` for a given seed; only wall-clock concurrency of the
1033    /// backward passes changes. The #232 thread-count-invariance guarantee
1034    /// is preserved in both modes.
1035    ///
1036    /// Set this back to `false` once a burn version that fixes the
1037    /// autodiff graph-lock inversion is adopted (tracked in #307).
1038    pub serialize_br_updates: bool,
1039}
1040
1041impl Default for PsroConfig {
1042    fn default() -> Self {
1043        Self {
1044            max_iterations: 10,
1045            max_population_size: 50,
1046            br_train_steps_per_iteration: 1,
1047            payoff_eval_episodes: 8,
1048            max_payoff_evals_per_iteration: None,
1049            br_reward_scale: 1.0,
1050            seed: 0,
1051            serialize_br_updates: true,
1052        }
1053    }
1054}
1055
1056/// Per-iteration PSRO statistics.
1057#[derive(Debug, Clone, Default)]
1058pub struct PsroIterationStats {
1059    /// Iteration index (1-based after the initial population is seeded).
1060    pub iteration: usize,
1061    /// Population size at the end of this iteration (per agent;
1062    /// identical across agents under the symmetric posture).
1063    pub population_size: usize,
1064    /// Per-agent meta-Nash *action-population* marginal distributions
1065    /// at the end of this iteration. `meta_nash_per_agent[i]` is agent
1066    /// `i`'s marginal over its own `population_size` policies extracted
1067    /// from the joint α-rank distribution (for N≥3) or directly from
1068    /// the 2-player solver (for N=2).
1069    pub meta_nash_per_agent: Vec<Vec<f32>>,
1070    /// Per-agent best-response training stats. `br_stats_per_agent[i]`
1071    /// is the stats for the round in which agent `i` was active under
1072    /// the round-robin schedule, or `None` if the agent was not the
1073    /// active agent on this iteration (currently every agent is
1074    /// trained every iteration, so every entry is `Some`).
1075    pub br_stats_per_agent: Vec<Option<JointStats>>,
1076    /// NashConv-style exploitability: the sum over agents `i` of agent
1077    /// `i`'s maximum payoff improvement by deviating to a pure best
1078    /// response in the empirical game, given the joint meta-Nash
1079    /// distribution.
1080    ///
1081    /// For N=2 zero-sum games this reduces to the original 2-player
1082    /// formula (row gain + column gain). Smaller is closer to the
1083    /// empirical equilibrium.
1084    pub exploitability: f32,
1085}
1086
1087impl PsroIterationStats {
1088    /// Backward-compat shim: agent 0 (row-player) meta-Nash
1089    /// distribution. Equivalent to `&self.meta_nash_per_agent[0]`.
1090    pub fn meta_nash_row(&self) -> &[f32] {
1091        self.meta_nash_per_agent.first().map(|v| v.as_slice()).unwrap_or(&[])
1092    }
1093
1094    /// Backward-compat shim: agent 1 (column-player) meta-Nash
1095    /// distribution. Equivalent to `&self.meta_nash_per_agent[1]`.
1096    pub fn meta_nash_col(&self) -> &[f32] {
1097        self.meta_nash_per_agent.get(1).map(|v| v.as_slice()).unwrap_or(&[])
1098    }
1099}
1100
1101/// Aggregate PSRO trainer statistics returned by [`PsroTrainer::run`].
1102#[derive(Debug, Clone, Default)]
1103pub struct PsroStats {
1104    /// Per-iteration history.
1105    pub iterations: Vec<PsroIterationStats>,
1106}
1107
1108// =======================================================================
1109// Empirical-payoff matrix cache
1110// =======================================================================
1111
1112/// Cached N-tensor empirical-payoff cache for an N-agent symmetric
1113/// game.
1114///
1115/// Stores per-agent payoffs at every joint pure strategy `s ∈ [0, k^N)`
1116/// where `k` is the per-agent population size (assumed identical across
1117/// agents under the symmetric posture) and `N` is the number of agents.
1118///
1119/// # Index convention
1120///
1121/// The flat joint-strategy index decomposes into per-agent indices
1122/// `(s_0, s_1, ..., s_{N-1})` via **little-endian mixed-radix**:
1123/// `s = Σ_i s_i · k^i`. Agent 0 is the fastest-varying index. This
1124/// convention matches [`AlphaRankMetaSolver::solve_n_player_impl`] —
1125/// the cache feeds its `cells` buffer directly into α-rank with no
1126/// transpose.
1127///
1128/// # Storage
1129///
1130/// `cells[s]` is a `Vec<f32>` of length `num_agents` containing each
1131/// agent's mean per-episode return at joint strategy `s`. The
1132/// per-cell allocation matches α-rank's `payoffs[s][a]` input shape.
1133/// For N=2 with k populations, this collapses to k² cells × 2-element
1134/// vectors — identical information to the pre-refactor `Vec<Vec<f32>>`
1135/// row-major matrix but with the per-cell agent payoffs co-located.
1136///
1137/// # Growth
1138///
1139/// PSRO grows each agent's population by one policy per outer
1140/// iteration. When agent `a`'s population grows from `k` to `k+1`,
1141/// the cache needs to evaluate the new boundary slab: all joint
1142/// strategies where agent `a` plays index `k` (its new policy).
1143/// [`PayoffCache::resize_for_boundary`] grows the storage to the new
1144/// `(k+1)^N` size; [`PayoffCache::set_cell`] writes individual cell
1145/// payoffs. The trainer is responsible for iterating over the
1146/// agent-`a`-newest-strategy boundary and calling `set_cell` for each
1147/// new joint strategy.
1148///
1149/// Memory is `O(k^N · N · f32)`, bounded by
1150/// [`PsroConfig::max_population_size`] cubed (or higher for N>3); the
1151/// `PsroConfig::max_population_size` cap should be tuned downward for
1152/// large N to keep memory reasonable.
1153#[derive(Debug, Clone, Default)]
1154pub struct PayoffCache {
1155    /// Per-joint-strategy per-agent payoffs. `cells[s][a]` is agent
1156    /// `a`'s mean per-episode return at joint strategy `s`. Indexed
1157    /// little-endian (agent 0 = fastest).
1158    cells: Vec<Vec<f32>>,
1159    /// Per-agent population size `k`. Assumed identical across agents
1160    /// under the symmetric posture.
1161    per_role_k: usize,
1162    /// Number of agents `N`.
1163    num_agents: usize,
1164    /// Counter incremented on every payoff *evaluation* (not every
1165    /// query). Used by unit tests to assert the cache is hit.
1166    pub eval_count: usize,
1167}
1168
1169impl PayoffCache {
1170    /// Construct an empty cache.
1171    pub fn new() -> Self {
1172        Self::default()
1173    }
1174
1175    /// Construct a cache sized for `num_agents` agents with `per_role_k = 0`
1176    /// (empty). Use [`PayoffCache::resize_for_boundary`] to grow.
1177    pub fn with_num_agents(num_agents: usize) -> Self {
1178        Self { cells: Vec::new(), per_role_k: 0, num_agents, eval_count: 0 }
1179    }
1180
1181    /// Current per-role population size `k`.
1182    pub fn per_role_k(&self) -> usize {
1183        self.per_role_k
1184    }
1185
1186    /// Number of agents `N`.
1187    pub fn num_agents(&self) -> usize {
1188        self.num_agents
1189    }
1190
1191    /// Total number of joint-strategy cells `k^N`.
1192    pub fn num_cells(&self) -> usize {
1193        self.cells.len()
1194    }
1195
1196    /// Read the per-agent payoffs at joint strategy `joint`. Returns
1197    /// `None` if any per-agent index is out of bounds. The returned
1198    /// slice has length `num_agents`.
1199    pub fn get_joint(&self, joint: &[usize]) -> Option<&[f32]> {
1200        if joint.len() != self.num_agents {
1201            return None;
1202        }
1203        for (a, &idx) in joint.iter().enumerate() {
1204            if idx >= self.per_role_k {
1205                return None;
1206            }
1207            let _ = a;
1208        }
1209        let s = compose_joint_index(joint, self.per_role_k);
1210        self.cells.get(s).map(|v| v.as_slice())
1211    }
1212
1213    /// View the full per-cell payoff tensor in the
1214    /// `(k^N, N)` flat layout consumed by
1215    /// [`AlphaRankMetaSolver::solve_n_player`]. The outer length is
1216    /// `k^N`; each inner `Vec<f32>` has length `num_agents`.
1217    pub fn payoff_tensor(&self) -> &[Vec<f32>] {
1218        &self.cells
1219    }
1220
1221    /// Set the per-agent payoffs at joint strategy `joint`. Bumps
1222    /// `eval_count` by 1. Panics if the cache isn't sized for `joint`
1223    /// (call [`PayoffCache::resize_for_boundary`] first) or if the
1224    /// payoff length doesn't equal `num_agents`.
1225    pub fn set_cell(&mut self, joint: &[usize], payoffs: Vec<f32>) {
1226        assert_eq!(
1227            joint.len(),
1228            self.num_agents,
1229            "joint strategy length {} must equal num_agents = {}",
1230            joint.len(),
1231            self.num_agents
1232        );
1233        assert_eq!(
1234            payoffs.len(),
1235            self.num_agents,
1236            "payoffs length {} must equal num_agents = {}",
1237            payoffs.len(),
1238            self.num_agents
1239        );
1240        for (a, &idx) in joint.iter().enumerate() {
1241            assert!(
1242                idx < self.per_role_k,
1243                "joint[{a}] = {idx} >= per_role_k = {}",
1244                self.per_role_k
1245            );
1246        }
1247        let s = compose_joint_index(joint, self.per_role_k);
1248        self.cells[s] = payoffs;
1249        self.eval_count += 1;
1250    }
1251
1252    /// Set the per-agent payoffs at joint strategy `joint` **without**
1253    /// bumping `eval_count`.
1254    ///
1255    /// Used by the issue-#212 boundary-subsampling path to fill an
1256    /// un-sampled boundary cell with a reused payoff (copied from an
1257    /// already-evaluated sampled neighbour). Such a fill performs **no
1258    /// fresh rollout**, so it must not be counted as an evaluation —
1259    /// `eval_count` continues to reflect only the cells that were
1260    /// actually rolled out. Same bounds/asserts as [`Self::set_cell`].
1261    pub fn set_cell_no_count(&mut self, joint: &[usize], payoffs: Vec<f32>) {
1262        assert_eq!(
1263            joint.len(),
1264            self.num_agents,
1265            "joint strategy length {} must equal num_agents = {}",
1266            joint.len(),
1267            self.num_agents
1268        );
1269        assert_eq!(
1270            payoffs.len(),
1271            self.num_agents,
1272            "payoffs length {} must equal num_agents = {}",
1273            payoffs.len(),
1274            self.num_agents
1275        );
1276        for (a, &idx) in joint.iter().enumerate() {
1277            assert!(
1278                idx < self.per_role_k,
1279                "joint[{a}] = {idx} >= per_role_k = {}",
1280                self.per_role_k
1281            );
1282        }
1283        let s = compose_joint_index(joint, self.per_role_k);
1284        self.cells[s] = payoffs;
1285    }
1286
1287    /// Grow storage from `(per_role_k)^N` to `(new_per_role_k)^N`
1288    /// in-place, preserving the cached payoffs at all joint strategies
1289    /// that map to the same little-endian flat index in the new
1290    /// storage.
1291    ///
1292    /// Newly-introduced cells are zero-initialized; the caller is
1293    /// responsible for evaluating them via the trainer's
1294    /// `evaluate_payoff_joint` and writing the result with
1295    /// [`PayoffCache::set_cell`].
1296    ///
1297    /// # Why we can't just `Vec::resize`
1298    ///
1299    /// Under little-endian mixed-radix, joint index `s = Σ_i s_i · k^i`
1300    /// changes when the radix `k` grows: the same per-agent indices
1301    /// `(s_0, ..., s_{N-1})` map to a different flat `s'` in the
1302    /// `(k+1)^N` storage. We rebuild the buffer by iterating over the
1303    /// old joint strategies and re-keying.
1304    pub fn resize_for_boundary(&mut self, new_per_role_k: usize) {
1305        assert!(
1306            new_per_role_k >= self.per_role_k,
1307            "PayoffCache may only grow; got new_k = {} < per_role_k = {}",
1308            new_per_role_k,
1309            self.per_role_k
1310        );
1311        if new_per_role_k == self.per_role_k {
1312            return;
1313        }
1314        let new_total = new_per_role_k.checked_pow(self.num_agents as u32).expect("k^N overflow");
1315        let mut new_cells = vec![vec![0.0_f32; self.num_agents]; new_total];
1316        if self.per_role_k > 0 {
1317            let old_total = self.cells.len();
1318            for s_old in 0..old_total {
1319                let components = decompose_joint_index(s_old, self.num_agents, self.per_role_k);
1320                let s_new = compose_joint_index(&components, new_per_role_k);
1321                new_cells[s_new] = std::mem::take(&mut self.cells[s_old]);
1322            }
1323        }
1324        self.cells = new_cells;
1325        self.per_role_k = new_per_role_k;
1326    }
1327
1328    /// Iterate over every joint strategy `s` in the *boundary slab*
1329    /// where agent `agent_index` plays its newest pure strategy
1330    /// (`per_role_k - 1`) — the cells whose payoffs must be evaluated
1331    /// after agent `agent_index`'s population just grew by one.
1332    ///
1333    /// Returns the joint-strategy index vectors (per-agent indices),
1334    /// suitable for passing to `evaluate_payoff_joint` and
1335    /// `set_cell`.
1336    pub fn boundary_joint_strategies(&self, agent_index: usize) -> Vec<Vec<usize>> {
1337        let k = self.per_role_k;
1338        let n = self.num_agents;
1339        assert!(agent_index < n);
1340        assert!(k >= 1);
1341        let new_strat = k - 1;
1342        // Enumerate the other agents' indices via the same
1343        // little-endian convention on N-1 axes of radix k.
1344        let n_others = n - 1;
1345        let total_others = k.checked_pow(n_others as u32).expect("k^(N-1) overflow");
1346        let mut out = Vec::with_capacity(total_others);
1347        for s in 0..total_others {
1348            let mut joint = vec![0_usize; n];
1349            joint[agent_index] = new_strat;
1350            // Distribute s across the other agents in little-endian
1351            // mixed-radix. Index-based loop is the cleanest reading of
1352            // the recurrence; clippy::needless_range_loop's
1353            // iter-based suggestion would mean awkwardly splitting the
1354            // `agent_index` skip.
1355            let mut rem = s;
1356            #[allow(clippy::needless_range_loop)]
1357            for a in 0..n {
1358                if a == agent_index {
1359                    continue;
1360                }
1361                joint[a] = rem % k;
1362                rem /= k;
1363            }
1364            out.push(joint);
1365        }
1366        out
1367    }
1368}
1369
1370// =======================================================================
1371// PsroTrainer
1372// =======================================================================
1373
1374/// PSRO outer-loop trainer for symmetric N-agent games (N ≥ 2).
1375///
1376/// Generic over the Burn backend `B`, policy module `P`, and Burn
1377/// optimizer type `O`. The trainer owns:
1378///
1379/// - N populations of policies (one per agent role) under `populations:
1380///   Vec<Vec<P>>`.
1381/// - A [`MetaSolver`] for the empirical meta-game. For N=2 the 2-player
1382///   [`MetaSolver::solve`] path is used (any in-tree solver works); for N≥3 the
1383///   trainer calls [`MetaSolver::solve_n_player`] and only
1384///   [`AlphaRankMetaSolver`] provides a non-panicking override.
1385/// - A cached empirical-payoff N-tensor [`PayoffCache`] keyed by joint pure
1386///   strategy.
1387/// - User-supplied factories for fresh policies + optimizers + envs.
1388///
1389/// # Policy/optimizer factories
1390///
1391/// The trainer doesn't know how to construct a Burn module of the
1392/// caller's chosen architecture, so we take closures:
1393///
1394/// - `policy_factory: Fn(&B::Device, u64) -> P` — fresh policy. The `u64` is a
1395///   **per-construction seed** the trainer derives from `PsroConfig::seed` via
1396///   a monotonic init-counter. A reproducibility-aware factory threads it into
1397///   `MlpBurnPolicy::new_seeded` / `MlpBurnConfig::with_seed` so that every
1398///   agent's initial policy and every per-iteration best-response gets
1399///   *distinct but deterministic* weights (issue #135). Factories that don't
1400///   care about reproducibility may ignore the argument.
1401/// - `optimizer_factory: Fn() -> BurnOptimizer<B, P, O>` — fresh optimizer.
1402/// - `env_factory: Fn() -> E` — fresh env instance.
1403///
1404/// This keeps PSRO architecture-agnostic at the cost of slightly
1405/// awkward generics at the call site (see the matching-pennies test).
1406///
1407/// # Single-policy-class assumption
1408///
1409/// All agents in both populations share the same policy class `P`. For
1410/// 2-agent symmetric games (matching pennies, homogeneous bucket
1411/// brigade) this is exactly what we want — the symmetry lets us
1412/// transpose the payoff matrix for the column player's solve. For
1413/// fully asymmetric games (different obs/action spaces per role), the
1414/// trainer needs to be re-parameterized over `(P_row, P_col)`; that's
1415/// out of scope for the first PR.
1416pub struct PsroTrainer<B, P, O, E, FP, FO, FE>
1417where
1418    B: AutodiffBackend,
1419    P: JointPolicy<B>,
1420    O: Optimizer<P, B>,
1421    E: JointEnv,
1422    FP: Fn(&B::Device, u64) -> P,
1423    FO: Fn() -> BurnOptimizer<B, P, O>,
1424    FE: Fn() -> E,
1425{
1426    /// Per-agent policy populations. `populations[agent]` is the
1427    /// monotonically-growing list of policies for agent `agent`. Under
1428    /// the symmetric posture all per-agent populations have the same
1429    /// length.
1430    populations: Vec<Vec<P>>,
1431    meta_solver: Box<dyn MetaSolver>,
1432    config: PsroConfig,
1433    joint_config: JointTrainerConfig,
1434    device: B::Device,
1435    policy_factory: FP,
1436    optimizer_factory: FO,
1437    env_factory: FE,
1438    payoff_cache: PayoffCache,
1439    rng: StdRng,
1440    /// Monotonic counter feeding the per-construction policy-init seed.
1441    ///
1442    /// Incremented on every `policy_factory` call (once per agent at
1443    /// construction, once per best-response per outer iteration). Each
1444    /// call derives `config.seed.wrapping_add(0x9E37_79B9 *
1445    /// init_counter)` so distinct constructions get distinct — but
1446    /// fully deterministic — initial weights. Without this, a factory
1447    /// closing over a single fixed seed would hand every agent and every
1448    /// iteration *identical* weights, a regression (issue #135,
1449    /// Correction 1).
1450    init_counter: u64,
1451}
1452
1453impl<B, P, O, E, FP, FO, FE> PsroTrainer<B, P, O, E, FP, FO, FE>
1454where
1455    B: AutodiffBackend,
1456    P: JointPolicy<B>,
1457    O: Optimizer<P, B>,
1458    E: JointEnv,
1459    FP: Fn(&B::Device, u64) -> P,
1460    FO: Fn() -> BurnOptimizer<B, P, O>,
1461    FE: Fn() -> E,
1462{
1463    /// Construct a PSRO trainer with one initial random policy per agent.
1464    ///
1465    /// `joint_config.num_agents` must be `≥ 2`. For `num_agents == 2`
1466    /// the trainer accepts any [`MetaSolver`] implementation; for
1467    /// `num_agents > 2` the meta-solver's
1468    /// [`MetaSolver::solve_n_player`] is called — at the time of this
1469    /// PR only [`AlphaRankMetaSolver`] provides a non-panicking
1470    /// override for N>2.
1471    #[allow(clippy::too_many_arguments)]
1472    pub fn new(
1473        config: PsroConfig,
1474        joint_config: JointTrainerConfig,
1475        meta_solver: Box<dyn MetaSolver>,
1476        device: B::Device,
1477        policy_factory: FP,
1478        optimizer_factory: FO,
1479        env_factory: FE,
1480    ) -> Result<Self> {
1481        if joint_config.num_agents < 2 {
1482            return Err(anyhow!(
1483                "PsroTrainer requires joint_config.num_agents >= 2 (got {})",
1484                joint_config.num_agents
1485            ));
1486        }
1487        let n = joint_config.num_agents;
1488        // Derive a distinct init seed per agent at construction time.
1489        // We advance the counter inline here (the trainer isn't built
1490        // yet) using the same derivation as `next_init_seed`.
1491        let base_seed = config.seed;
1492        let populations: Vec<Vec<P>> = (0..n)
1493            .map(|i| {
1494                let s = base_seed.wrapping_add(0x9E37_79B9_u64.wrapping_mul(i as u64));
1495                vec![policy_factory(&device, s)]
1496            })
1497            .collect();
1498        let rng = StdRng::seed_from_u64(config.seed);
1499        Ok(Self {
1500            populations,
1501            meta_solver,
1502            config,
1503            joint_config,
1504            device,
1505            policy_factory,
1506            optimizer_factory,
1507            env_factory,
1508            payoff_cache: PayoffCache::with_num_agents(n),
1509            rng,
1510            // Start the running counter past the `n` seeds consumed by
1511            // the initial per-agent constructions above.
1512            init_counter: n as u64,
1513        })
1514    }
1515
1516    /// Derive and consume the next per-construction policy-init seed.
1517    ///
1518    /// Returns `config.seed.wrapping_add(0x9E37_79B9 * init_counter)`
1519    /// and advances the counter so the next call gets a fresh,
1520    /// non-colliding stream. The multiplier is the 32-bit golden-ratio
1521    /// constant — any odd large constant works; this one keeps adjacent
1522    /// counters well-separated in the `StdRng` seed space.
1523    fn next_init_seed(&mut self) -> u64 {
1524        let s = self.config.seed.wrapping_add(0x9E37_79B9_u64.wrapping_mul(self.init_counter));
1525        self.init_counter = self.init_counter.wrapping_add(1);
1526        s
1527    }
1528
1529    /// Borrow agent `agent`'s policy population.
1530    pub fn populations(&self, agent: usize) -> &[P] {
1531        &self.populations[agent]
1532    }
1533
1534    /// Borrow the row-player (agent 0) population.
1535    ///
1536    /// Backward-compat shim retained for callers that pre-date the
1537    /// N-tensor refactor (notably
1538    /// `tests/test_psro_matching_pennies.rs`). New N≥2 code should use
1539    /// [`PsroTrainer::populations`].
1540    pub fn population_row(&self) -> &[P] {
1541        &self.populations[0]
1542    }
1543
1544    /// Borrow the column-player (agent 1) population.
1545    ///
1546    /// Backward-compat shim retained for callers that pre-date the
1547    /// N-tensor refactor. Panics for N=1 (which is rejected by `new`
1548    /// anyway). New N≥2 code should use [`PsroTrainer::populations`].
1549    pub fn population_col(&self) -> &[P] {
1550        &self.populations[1]
1551    }
1552
1553    /// Borrow the cached empirical N-tensor payoff cache.
1554    pub fn payoff_cache(&self) -> &PayoffCache {
1555        &self.payoff_cache
1556    }
1557
1558    /// Run the PSRO outer loop and return the per-iteration history.
1559    ///
1560    /// `on_iteration` is invoked once per outer iteration, immediately
1561    /// after that iteration's [`PsroIterationStats`] is constructed and
1562    /// before it is pushed onto the returned history. This mirrors
1563    /// [`NfspTrainer::run`](crate::multi_agent::nfsp::NfspTrainer::run)
1564    /// and lets callers observe per-iteration progress *during* the run
1565    /// (live `tracing` logging, mid-run checkpoint triggers, etc.)
1566    /// rather than only inspecting the aggregate stats after `run`
1567    /// returns.
1568    ///
1569    /// The callback receives two arguments:
1570    /// 1. `&PsroIterationStats` — this iteration's stats, whose `iteration`
1571    ///    field increases monotonically from `1` to `config.max_iterations`.
1572    /// 2. `&[&P]` — the newest best-response policy for each agent (`brs[a]` is
1573    ///    agent `a`'s freshly-trained BR appended this iteration, i.e.
1574    ///    `populations(a).last()`). This lets the callback persist per-agent BR
1575    ///    policies to disk *during* the run (mid-run checkpointing, issue #204)
1576    ///    without a borrow conflict against the `&mut self` held by `run`: the
1577    ///    trainer cannot itself write files (it is backend/format-agnostic, the
1578    ///    `Recorder` lives in the example), so it hands the closure the policy
1579    ///    references it needs to checkpoint. Checkpointing is a pure
1580    ///    side-effect read; it does not alter the trainer state or the
1581    ///    deterministic training trajectory.
1582    ///
1583    /// For the common case of "run with no per-iteration hook", use
1584    /// [`Self::run_silent`].
1585    pub fn run<F>(&mut self, mut on_iteration: F) -> Result<PsroStats>
1586    where
1587        F: FnMut(&PsroIterationStats, &[&P]),
1588        // Bounds required by the rayon-parallel boundary-slab evaluation
1589        // (issue #203) and the rayon-parallel best-response loop (issue
1590        // #232). Mirror the `EnvPool` Send-bound convention (pool.rs:58).
1591        // The parallel payoff result is bit-identical to a serial sweep
1592        // because each cell is pure (issue #201); the parallel BR result
1593        // is thread-count-invariant (per-agent local RNG, issue #232). The
1594        // BR path additionally needs the policy/optimizer factories to be
1595        // `Sync` because each task calls them through a shared `&`.
1596        P: Send + Sync,
1597        E: Send,
1598        FP: Sync,
1599        FO: Sync,
1600        FE: Sync,
1601        B::Device: Sync,
1602    {
1603        let num_agents = self.joint_config.num_agents;
1604
1605        // Seed the payoff cache with the initial 1×...×1 entry — all
1606        // agents play their initial-random policy (index 0).
1607        if self.payoff_cache.per_role_k() == 0 {
1608            self.payoff_cache.resize_for_boundary(1);
1609            let initial_joint = vec![0_usize; num_agents];
1610            let initial_payoffs = self.evaluate_payoff_joint(&initial_joint);
1611            self.payoff_cache.set_cell(&initial_joint, initial_payoffs);
1612        }
1613
1614        let mut stats = PsroStats::default();
1615        for iter in 1..=self.config.max_iterations {
1616            if self.populations[0].len() >= self.config.max_population_size {
1617                return Err(anyhow!(
1618                    "PSRO population reached max_population_size = {}",
1619                    self.config.max_population_size
1620                ));
1621            }
1622
1623            // Step 1: meta-Nash on the current N-tensor payoff cache.
1624            // For N=2 the meta-solver's `solve` path (symmetric
1625            // marginal) is used; for N≥3 we go through `solve_n_player`
1626            // and marginalize per-agent.
1627            let per_agent_marginals = self.solve_per_agent_marginals();
1628
1629            // Step 2: round-robin train one best-response per agent
1630            // against the other agents' marginal mixtures. The
1631            // `num_agents` best responses are fully independent, so they
1632            // run concurrently under rayon (issue #232) — opponent
1633            // indices + init seeds are drawn in fixed agent order before
1634            // the parallel region, and each BR uses a per-agent local RNG,
1635            // so the result is invariant to thread count. Trained BRs are
1636            // appended to `self.populations` in fixed agent order after the
1637            // join.
1638            let br_stats = self.train_best_responses_parallel(&per_agent_marginals)?;
1639            let br_stats_per_agent: Vec<Option<JointStats>> =
1640                br_stats.into_iter().map(Some).collect();
1641
1642            // Step 3: grow the payoff cache and evaluate every
1643            // newly-added boundary cell. After all agents' populations
1644            // grow by one in lockstep, the new per-role-k is k+1 and
1645            // the new cells are the union of every per-agent
1646            // boundary slab — i.e. every joint strategy `s` whose
1647            // per-agent index vector includes at least one
1648            // newest-strategy index (`k` under the new radix).
1649            let old_k = self.payoff_cache.per_role_k();
1650            let new_k = old_k + 1;
1651            self.payoff_cache.resize_for_boundary(new_k);
1652            // Iterate every joint strategy in the new k^N tensor; cells
1653            // that are entirely in the *old* k^N corner are already
1654            // populated (preserved by `resize_for_boundary`). New cells
1655            // are those with at least one component == k-1 under the
1656            // new radix. We iterate flat indices and decompose.
1657            let total_new = new_k.checked_pow(num_agents as u32).expect("k^N overflow");
1658            let new_strategy_idx = new_k - 1;
1659
1660            // Gather the boundary cells (those whose per-agent index
1661            // vector includes the newest strategy) in deterministic flat
1662            // order. This is the `population^N` slab that dominates PSRO
1663            // cost on large N (issue #198).
1664            let boundary: Vec<Vec<usize>> = (0..total_new)
1665                .filter_map(|s| {
1666                    let components = decompose_joint_index(s, num_agents, new_k);
1667                    components.contains(&new_strategy_idx).then_some(components)
1668                })
1669                .collect();
1670
1671            // Optionally subsample the boundary slab to bound
1672            // per-iteration cost (issue #212). With `None` (default) the
1673            // entire boundary is evaluated, which is **bit-identical** to
1674            // the pre-#212 behavior; with `Some(cap)` and a boundary
1675            // larger than `cap`, only a deterministically-chosen `cap`
1676            // cells are rolled out and the rest are filled by reuse — see
1677            // `select_boundary_to_evaluate`.
1678            let (to_evaluate, fill_from) =
1679                select_boundary_to_evaluate(&boundary, self.config.max_payoff_evals_per_iteration);
1680
1681            // Evaluate the selected boundary cells in parallel. Each cell
1682            // is a pure function of `(config.seed, joint)` (issue #201),
1683            // so the parallel result is **bit-identical** to a serial
1684            // sweep regardless of thread count or scheduling: cells share
1685            // no mutable state, each clones its joint policies and builds
1686            // a fresh env via `env_factory`, and seeds a local `StdRng`.
1687            // Results are collected by index (not push order), then
1688            // written into the cache serially below, so the cache is
1689            // populated in the same deterministic order as the old serial
1690            // loop. See `evaluate_payoff_boundary_parallel`.
1691            let evaluated = self.evaluate_payoff_boundary_parallel(&to_evaluate);
1692            // Write the freshly-evaluated cells first so the fill step can
1693            // read their payoffs back out of the cache. `to_evaluate` is a
1694            // prefix-stable deterministic subset of `boundary`.
1695            for (components, payoffs) in to_evaluate.iter().zip(&evaluated) {
1696                self.payoff_cache.set_cell(components, payoffs.clone());
1697            }
1698            // Fill the un-sampled boundary cells from their nearest
1699            // already-evaluated sampled neighbour (deterministic; no fresh
1700            // rollouts, so these do NOT bump `eval_count`). When the cap is
1701            // `None` or not exceeded, `fill_from` is empty and this loop is
1702            // a no-op, keeping the default path bit-identical.
1703            for &(dst_idx, src_idx) in &fill_from {
1704                let payoffs = evaluated[src_idx].clone();
1705                self.payoff_cache.set_cell_no_count(&boundary[dst_idx], payoffs);
1706            }
1707
1708            // Step 4: re-solve the meta-Nash on the post-append cache
1709            // and compute NashConv exploitability. Reporting on the
1710            // post-append cache is how PSRO progress is conventionally
1711            // tracked (exploitability drops as each new BR enriches
1712            // the population).
1713            let post_marginals = self.solve_per_agent_marginals();
1714            let exploitability = self.compute_nashconv(&post_marginals);
1715
1716            let iter_stats = PsroIterationStats {
1717                iteration: iter,
1718                population_size: self.populations[0].len(),
1719                meta_nash_per_agent: post_marginals,
1720                br_stats_per_agent,
1721                exploitability,
1722            };
1723
1724            // Newest best-response policy per agent, appended this
1725            // iteration in the round-robin loop above. Handed to the
1726            // callback so it can checkpoint per-agent BR policies
1727            // mid-run (issue #204). `populations(a)` is guaranteed
1728            // non-empty here: every agent was just trained and pushed.
1729            let newest_brs: Vec<&P> = (0..num_agents)
1730                .map(|a| {
1731                    self.populations[a].last().expect("population non-empty after BR training")
1732                })
1733                .collect();
1734            on_iteration(&iter_stats, &newest_brs);
1735            stats.iterations.push(iter_stats);
1736        }
1737        Ok(stats)
1738    }
1739
1740    /// Convenience entry point: drives [`Self::run`] with a no-op
1741    /// iteration callback. Use this when per-iteration observation is
1742    /// not needed (mirrors
1743    /// [`NfspTrainer::run_silent`](crate::multi_agent::nfsp::NfspTrainer::run_silent)).
1744    pub fn run_silent(&mut self) -> Result<PsroStats>
1745    where
1746        P: Send + Sync,
1747        E: Send,
1748        FP: Sync,
1749        FO: Sync,
1750        FE: Sync,
1751        B::Device: Sync,
1752    {
1753        self.run(|_, _| {})
1754    }
1755
1756    /// Most-recent per-agent meta-Nash distributions (one row per
1757    /// agent), or uniform over the initial population if `run` has not
1758    /// been called.
1759    pub fn current_meta_nash_per_agent(&self) -> Vec<Vec<f32>> {
1760        if self.payoff_cache.per_role_k() == 0 {
1761            return (0..self.joint_config.num_agents).map(|_| vec![1.0]).collect();
1762        }
1763        self.solve_per_agent_marginals()
1764    }
1765
1766    /// Backward-compat shim returning agent 0's meta-Nash marginal.
1767    pub fn current_meta_nash(&self) -> Vec<f32> {
1768        self.current_meta_nash_per_agent().into_iter().next().unwrap_or_default()
1769    }
1770
1771    /// Solve the meta-Nash on the current payoff cache and return
1772    /// per-agent marginal distributions over each agent's own
1773    /// population. For N=2, uses [`MetaSolver::solve`] on the legacy
1774    /// `payoffs[i][j] = agent_0_payoff(i, j)` matrix view (preserving
1775    /// bit-stable behaviour for existing FictitiousPlay / Replicator /
1776    /// Uniform meta-solvers). For N≥3, uses
1777    /// [`MetaSolver::solve_n_player`] and marginalizes the joint
1778    /// distribution per-agent.
1779    fn solve_per_agent_marginals(&self) -> Vec<Vec<f32>> {
1780        let n = self.joint_config.num_agents;
1781        let k = self.payoff_cache.per_role_k();
1782        if k == 0 {
1783            return (0..n).map(|_| vec![1.0]).collect();
1784        }
1785        if n == 2 {
1786            // 2-player path: project the N-tensor cache back to a
1787            // `k × k` row-player payoff matrix (agent 0's payoffs) and
1788            // call `solve`. The post-projection matrix is bit-identical
1789            // to the pre-refactor `PayoffCache::matrix()` view — this
1790            // is the regression-bar guarantee.
1791            // Index-based double loop: the explicit `s = i + j * k`
1792            // formula mirrors the little-endian mixed-radix convention
1793            // and reads more clearly than a flat-enumerate rewrite.
1794            let mut row_matrix: Vec<Vec<f32>> = vec![vec![0.0_f32; k]; k];
1795            #[allow(clippy::needless_range_loop)]
1796            for i in 0..k {
1797                for j in 0..k {
1798                    let s = i + j * k;
1799                    row_matrix[i][j] = self.payoff_cache.payoff_tensor()[s][0];
1800                }
1801            }
1802            let row_dist = self.meta_solver.solve(&row_matrix);
1803            // Symmetric zero-sum: column distribution matches row by
1804            // symmetry, same as the pre-refactor trainer.
1805            let col_dist = row_dist.clone();
1806            return vec![row_dist, col_dist];
1807        }
1808        // N≥3 path: call `solve_n_player` with the flat (k^N, N)
1809        // tensor and marginalize per-agent.
1810        let joint = self.meta_solver.solve_n_player(self.payoff_cache.payoff_tensor(), n, k);
1811        let mut marginals: Vec<Vec<f32>> = (0..n).map(|_| vec![0.0_f32; k]).collect();
1812        for (s, &mass) in joint.iter().enumerate() {
1813            let components = decompose_joint_index(s, n, k);
1814            for (a, &c) in components.iter().enumerate() {
1815                marginals[a][c] += mass;
1816            }
1817        }
1818        // Renormalize numerically.
1819        for m in marginals.iter_mut() {
1820            let total: f32 = m.iter().sum();
1821            if total > 0.0 {
1822                for v in m.iter_mut() {
1823                    *v /= total;
1824                }
1825            } else {
1826                let uniform = 1.0 / k as f32;
1827                for v in m.iter_mut() {
1828                    *v = uniform;
1829                }
1830            }
1831        }
1832        marginals
1833    }
1834
1835    /// Compute NashConv exploitability under the per-agent meta-Nash
1836    /// marginals: `Σ_i (max_{s_i} U_i(s_i, σ_{−i}) − U_i(σ))`.
1837    ///
1838    /// # N=2 fast-path bit-stability
1839    ///
1840    /// For N=2 the meta-Nash marginals are projected back to a `k × k`
1841    /// agent-0 payoff matrix and the closed-form
1842    /// `row_gain + col_gain` formula is evaluated — bit-identical to
1843    /// the pre-refactor `empirical_exploitability`. This preserves the
1844    /// `+1.0` calibration of
1845    /// `test_psro_exploitability_non_increasing_trend_on_matching_pennies`
1846    /// across the refactor.
1847    ///
1848    /// # N≥3 generalization
1849    ///
1850    /// For N≥3 we compute each agent's best-response gain as the
1851    /// supremum over its `k` pure strategies of the expected payoff
1852    /// against the other agents' joint marginal mixture, minus the
1853    /// agent's expected payoff under the full joint mixture. The
1854    /// agent-`i` joint mixture is `Π_{j≠i} σ_j` (independence assumed
1855    /// under the per-agent marginal decomposition) so the expected
1856    /// payoff at the agent-`i` pure strategy `s_i` is
1857    /// `Σ_{s_{−i}} (Π_{j≠i} σ_j[s_j]) · U_i(s_i, s_{−i})`. The N=2
1858    /// case follows the same formula (mod the bit-stability
1859    /// projection).
1860    fn compute_nashconv(&self, per_agent_marginals: &[Vec<f32>]) -> f32 {
1861        let n = self.joint_config.num_agents;
1862        let k = self.payoff_cache.per_role_k();
1863        if n == 2 {
1864            // Fast path: project to the agent-0 payoff matrix and use
1865            // the legacy 2-player formula bit-identically. Index-based
1866            // loop mirrors the little-endian mixed-radix convention
1867            // for the joint flat index.
1868            let mut row_matrix: Vec<Vec<f32>> = vec![vec![0.0_f32; k]; k];
1869            #[allow(clippy::needless_range_loop)]
1870            for i in 0..k {
1871                for j in 0..k {
1872                    let s = i + j * k;
1873                    row_matrix[i][j] = self.payoff_cache.payoff_tensor()[s][0];
1874                }
1875            }
1876            return empirical_exploitability(&row_matrix, &per_agent_marginals[0]);
1877        }
1878        // N≥3 general path.
1879        let payoffs = self.payoff_cache.payoff_tensor();
1880        let mut nashconv = 0.0_f32;
1881        for i in 0..n {
1882            // U_i(σ) = Σ_s (Π_j σ_j[s_j]) · payoffs[s][i].
1883            let mut u_sigma = 0.0_f32;
1884            // Expected payoff to agent i for each of its pure
1885            // strategies, marginalizing other agents over their σ.
1886            let mut u_pure = vec![0.0_f32; k];
1887            for (s, agent_payoffs) in payoffs.iter().enumerate() {
1888                let components = decompose_joint_index(s, n, k);
1889                // Product of marginal masses across all agents under
1890                // the full joint mixture.
1891                let mut full_prob = 1.0_f32;
1892                for (a, &c) in components.iter().enumerate() {
1893                    full_prob *= per_agent_marginals[a][c];
1894                }
1895                u_sigma += full_prob * agent_payoffs[i];
1896                // For the "agent i deviates to pure s_i" case, weight
1897                // by Π_{j≠i} σ_j[s_j].
1898                let mut others_prob = 1.0_f32;
1899                for (a, &c) in components.iter().enumerate() {
1900                    if a == i {
1901                        continue;
1902                    }
1903                    others_prob *= per_agent_marginals[a][c];
1904                }
1905                let s_i = components[i];
1906                u_pure[s_i] += others_prob * agent_payoffs[i];
1907            }
1908            let max_pure = u_pure.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
1909            let gain = (max_pure - u_sigma).max(0.0);
1910            nashconv += gain;
1911        }
1912        nashconv
1913    }
1914
1915    /// Train all `num_agents` best responses for one PSRO iteration **in
1916    /// parallel** (one fully-independent BR per agent) and append the
1917    /// trained policies to `self.populations` in **fixed agent order**.
1918    ///
1919    /// # Why this is parallelizable
1920    ///
1921    /// Each best response trains its own [`JointMultiAgentTrainer`] over a
1922    /// freshly-initialized active policy and frozen, cloned opponents, runs
1923    /// its own env, and only *reads* `self.populations` / `self.config`.
1924    /// The only original shared-mutable touches were `self.rng` (opponent
1925    /// sampling + PPO shuffle) and `self.next_init_seed`. We hoist **all**
1926    /// of those draws out of the parallel region here, into a fixed-order
1927    /// pre-pass, so the parallel region touches no `&mut self`:
1928    ///
1929    /// - per-agent opponent indices are drawn from `self.rng` in agent order,
1930    ///   before the join;
1931    /// - per-agent active-policy init seeds are drawn from
1932    ///   `self.next_init_seed()` in agent order;
1933    /// - each BR is handed a **local [`StdRng`]** seeded deterministically from
1934    ///   `(config.seed, active_agent)` (mirrors the per-cell seeding of
1935    ///   [`evaluate_payoff_joint_pure`]), which threads the rollout +
1936    ///   PPO-update draws for that BR alone.
1937    ///
1938    /// The per-BR work is then a pure function of its pre-drawn inputs, so
1939    /// `(0..num_agents).into_par_iter()` produces a result that is
1940    /// **invariant to thread count / scheduling**: results are collected
1941    /// by index (rayon preserves input order) and appended to
1942    /// `self.populations` serially in agent order afterward.
1943    ///
1944    /// # Determinism note
1945    ///
1946    /// Because the BR now uses a per-agent local RNG instead of the single
1947    /// shared `self.rng` stream, output is **not** bit-identical to the
1948    /// pre-parallel serial-RNG runs (the RNG threading changed by design).
1949    /// It is, however, fully reproducible for a given seed and identical
1950    /// across any thread count.
1951    ///
1952    /// # Bounds
1953    ///
1954    /// Mirror the boundary-payoff parallel path
1955    /// ([`Self::evaluate_payoff_boundary_parallel`]): `P: Send + Sync`
1956    /// (shared by `&`, cloned per task), `E: Send` (moved into each task),
1957    /// and the factories / device are shared by `&` (`FP`/`FO`/`FE: Sync`,
1958    /// `B::Device: Sync`).
1959    fn train_best_responses_parallel(
1960        &mut self,
1961        per_agent_marginals: &[Vec<f32>],
1962    ) -> Result<Vec<JointStats>>
1963    where
1964        P: Send + Sync,
1965        E: Send,
1966        FP: Sync,
1967        FO: Sync,
1968        FE: Sync,
1969        B::Device: Sync,
1970    {
1971        let num_agents = self.joint_config.num_agents;
1972
1973        // --- Fixed-order pre-pass: draw every shared-mutable value here,
1974        // in agent order, so the parallel region below is pure. ---
1975        //
1976        // `opp_indices[active_agent][a]` is the sampled opponent index for
1977        // agent `a` while `active_agent` trains its BR; the entry for
1978        // `a == active_agent` is unused (that slot holds the fresh BR).
1979        let mut opp_indices: Vec<Vec<usize>> = Vec::with_capacity(num_agents);
1980        let mut init_seeds: Vec<u64> = Vec::with_capacity(num_agents);
1981        for active_agent in 0..num_agents {
1982            let mut row: Vec<usize> = Vec::with_capacity(num_agents);
1983            for (a, marginal) in per_agent_marginals.iter().enumerate().take(num_agents) {
1984                if a == active_agent {
1985                    row.push(0); // unused placeholder for the active slot
1986                } else {
1987                    row.push(sample_from_mixture(&mut self.rng, marginal));
1988                }
1989            }
1990            opp_indices.push(row);
1991            init_seeds.push(self.next_init_seed());
1992        }
1993
1994        // Bind only the Sync field borrows into locals so the rayon
1995        // closures capture *these* references and NOT the whole `&self`
1996        // (which also holds the non-`Sync` `Box<dyn MetaSolver>`). Same
1997        // technique as `evaluate_payoff_boundary_parallel`.
1998        let populations = &self.populations;
1999        let config = &self.config;
2000        let joint_config = &self.joint_config;
2001        let device = &self.device;
2002        let policy_factory = &self.policy_factory;
2003        let optimizer_factory = &self.optimizer_factory;
2004        let env_factory = &self.env_factory;
2005
2006        // --- BR region: one independent BR per agent. ---
2007        //
2008        // The closure body is a pure function of the pre-drawn inputs and
2009        // collected by index, so serial vs. parallel dispatch is
2010        // bit-identical (see `PsroConfig::serialize_br_updates`). When
2011        // `serialize_br_updates` is set (the default), the BRs run in a
2012        // serial `.map()` so at most one `burn-autodiff` backward graph is
2013        // live at a time, side-stepping the 0.21 `GraphLocator`/`GraphState`
2014        // lock-order deadlock (issue #307). Otherwise they run concurrently
2015        // under `rayon` (the #232 parallel path).
2016        let run_br = |active_agent: usize| {
2017            train_best_response_pure::<B, P, O, E, _, _, _>(
2018                active_agent,
2019                &opp_indices[active_agent],
2020                init_seeds[active_agent],
2021                populations,
2022                config,
2023                joint_config,
2024                device,
2025                policy_factory,
2026                optimizer_factory,
2027                env_factory,
2028            )
2029        };
2030        let results: Vec<Result<(JointStats, P)>> = if config.serialize_br_updates {
2031            (0..num_agents).map(run_br).collect()
2032        } else {
2033            (0..num_agents).into_par_iter().map(run_br).collect()
2034        };
2035
2036        // --- Join: unpack results in fixed agent order, propagating the
2037        // first error deterministically. The immutable borrow of
2038        // `self.populations` taken for the parallel region has ended (the
2039        // `collect()` above is complete), so we can now mutably append. ---
2040        let mut stats: Vec<JointStats> = Vec::with_capacity(num_agents);
2041        let mut trained_policies: Vec<P> = Vec::with_capacity(num_agents);
2042        for result in results {
2043            let (br_stats, trained) = result?;
2044            stats.push(br_stats);
2045            trained_policies.push(trained);
2046        }
2047        // Promote each learned BR into its agent's population in fixed
2048        // agent order (collect-by-index), matching the serial loop's
2049        // append order so the population layout is thread-count-invariant.
2050        for (active_agent, trained) in trained_policies.into_iter().enumerate() {
2051            self.populations[active_agent].push(trained);
2052        }
2053        Ok(stats)
2054    }
2055
2056    /// Evaluate the empirical-payoff cell at joint strategy `joint`
2057    /// (length `num_agents`) by running
2058    /// `config.payoff_eval_episodes` episodes with policy
2059    /// `populations[a][joint[a]]` for each agent `a`. Returns the
2060    /// per-agent mean per-episode returns (length `num_agents`).
2061    ///
2062    /// This is a thin wrapper that gathers the per-joint policies and
2063    /// delegates to [`evaluate_payoff_joint_pure`], which is a pure,
2064    /// per-cell-seeded function (it does **not** touch `self.rng`). The
2065    /// wrapper only borrows `&self` for the population/factory handles,
2066    /// so the result is independent of evaluation order and global RNG
2067    /// state — see #201.
2068    fn evaluate_payoff_joint(&self, joint: &[usize]) -> Vec<f32> {
2069        let num_agents = self.joint_config.num_agents;
2070        assert_eq!(joint.len(), num_agents);
2071        let policies: Vec<P> =
2072            (0..num_agents).map(|a| self.populations[a][joint[a]].clone()).collect();
2073        evaluate_payoff_joint_pure::<B, P, _, _>(
2074            joint,
2075            &self.config,
2076            &policies,
2077            &self.env_factory,
2078            &self.device,
2079        )
2080    }
2081
2082    /// Evaluate a batch of boundary payoff cells **in parallel** with
2083    /// rayon, returning one payoff vector per input cell in the **same
2084    /// order** as `boundary`.
2085    ///
2086    /// # Bit-identity with the serial path (issue #203)
2087    ///
2088    /// Each cell delegates to [`evaluate_payoff_joint_pure`], which seeds
2089    /// a local [`StdRng`] purely from `(config.seed, joint)` and touches
2090    /// no shared trainer RNG (issue #201). The cell payoff is therefore a
2091    /// pure function of `(joint, config, policies, env_factory)`, so this
2092    /// `par_iter` result is **bit-identical** to evaluating the same
2093    /// cells serially in any order, *regardless of thread count or
2094    /// scheduling*. Results are gathered by index via
2095    /// [`ParallelIterator::collect`] (rayon preserves input order), never
2096    /// by push order, and the caller writes them into the cache serially.
2097    ///
2098    /// # Thread-safety
2099    ///
2100    /// No mutable state crosses threads. Each task:
2101    /// - reads `self.populations` / `self.config` / `self.device` /
2102    ///   `self.env_factory` through shared `&` borrows (no `&mut self`),
2103    /// - clones the joint's per-agent policies (`P: Clone`) so the autodiff
2104    ///   modules are owned per task,
2105    /// - builds a fresh env via `env_factory` (which already yields a new
2106    ///   instance per call).
2107    ///
2108    /// The `Send`/`Sync` bounds mirror the [`EnvPool`](crate::env::pool)
2109    /// convention (`E: Send`): `P: Send + Sync` (shared by `&`, cloned
2110    /// per task), `E: Send` (moved into each task), and the factory /
2111    /// device are shared by `&` (`FE: Sync`, `B::Device: Sync`). No
2112    /// `Mutex` is introduced, so the hot loop is never serialized.
2113    fn evaluate_payoff_boundary_parallel(&self, boundary: &[Vec<usize>]) -> Vec<Vec<f32>>
2114    where
2115        P: Send + Sync,
2116        E: Send,
2117        FE: Sync,
2118        B::Device: Sync,
2119    {
2120        let num_agents = self.joint_config.num_agents;
2121        // Bind only the Sync field borrows into locals so the rayon
2122        // closures capture *these* references and NOT the whole `&self`
2123        // (which also holds the non-`Sync` `Box<dyn MetaSolver>` and the
2124        // `FP`/`FO` factory closures). Capturing the whole `&self` would
2125        // require the entire trainer to be `Sync`; capturing only the
2126        // payoff-relevant fields keeps the bounds minimal and correct.
2127        let populations = &self.populations;
2128        let config = &self.config;
2129        let env_factory = &self.env_factory;
2130        let device = &self.device;
2131        boundary
2132            .par_iter()
2133            .map(|joint| {
2134                debug_assert_eq!(joint.len(), num_agents);
2135                let policies: Vec<P> =
2136                    (0..num_agents).map(|a| populations[a][joint[a]].clone()).collect();
2137                evaluate_payoff_joint_pure::<B, P, _, _>(
2138                    joint,
2139                    config,
2140                    &policies,
2141                    env_factory,
2142                    device,
2143                )
2144            })
2145            .collect()
2146    }
2147}
2148
2149/// Pure, per-agent-seeded best-response trainer.
2150///
2151/// Trains one best response for `active_agent` against the other agents'
2152/// pre-sampled, frozen opponents and returns `(stats, trained_policy)`.
2153/// This is the per-task body of the rayon-parallel BR loop (issue #232):
2154/// it is the extraction of the old `train_best_response` with **every
2155/// `&mut self` / shared-RNG touch removed**.
2156///
2157/// # Determinism / thread-count invariance (issue #232)
2158///
2159/// All values that the pre-parallel path drew from the shared
2160/// `&mut self.rng` / `self.next_init_seed` are now passed in, already
2161/// drawn in fixed agent order by the caller:
2162/// - `opp_indices[a]` — the frozen opponent index for each non-active agent `a`
2163///   (the `active_agent` slot is ignored);
2164/// - `init_seed` — the active BR's fresh-policy initialization seed.
2165///
2166/// The rollout + PPO-update draws use a **local [`StdRng`]** seeded purely
2167/// from `(config.seed, active_agent)` (mirroring the per-cell seeding of
2168/// [`evaluate_payoff_joint_pure`]), so this function touches no shared
2169/// state and its result is a pure function of its inputs. Running the
2170/// per-agent tasks under any thread count therefore yields identical
2171/// per-agent results.
2172///
2173/// Note: because each BR now consumes its own local RNG stream rather than
2174/// slices of one global `self.rng` stream, output is intentionally **not**
2175/// bit-identical to the pre-#232 serial-RNG runs.
2176#[allow(clippy::too_many_arguments)]
2177fn train_best_response_pure<B, P, O, E, FP, FO, FE>(
2178    active_agent: usize,
2179    opp_indices: &[usize],
2180    init_seed: u64,
2181    populations: &[Vec<P>],
2182    config: &PsroConfig,
2183    joint_config: &JointTrainerConfig,
2184    device: &B::Device,
2185    policy_factory: &FP,
2186    optimizer_factory: &FO,
2187    env_factory: &FE,
2188) -> Result<(JointStats, P)>
2189where
2190    B: AutodiffBackend,
2191    P: JointPolicy<B> + Clone,
2192    O: Optimizer<P, B>,
2193    E: JointEnv,
2194    FP: Fn(&B::Device, u64) -> P,
2195    FO: Fn() -> BurnOptimizer<B, P, O>,
2196    FE: Fn() -> E,
2197{
2198    let num_agents = joint_config.num_agents;
2199    debug_assert!(active_agent < num_agents);
2200
2201    // Build the joint trainer's per-agent policy slot:
2202    // - active agent: fresh randomly-initialized policy (the BR), using the
2203    //   pre-drawn `init_seed`.
2204    // - non-active agents: the pre-sampled frozen opponent from their meta-Nash
2205    //   marginal over their respective populations.
2206    let mut policies: Vec<P> = Vec::with_capacity(num_agents);
2207    for (a, population) in populations.iter().enumerate().take(num_agents) {
2208        if a == active_agent {
2209            policies.push(policy_factory(device, init_seed));
2210        } else {
2211            policies.push(population[opp_indices[a]].clone());
2212        }
2213    }
2214    let optimizers: Vec<BurnOptimizer<B, P, O>> =
2215        (0..num_agents).map(|_| optimizer_factory()).collect();
2216
2217    let mut trainer = JointMultiAgentTrainer::<B, P, O>::new(
2218        policies,
2219        optimizers,
2220        joint_config.clone(),
2221        device.clone(),
2222    )?;
2223
2224    // Per-agent LOCAL action/update RNG, seeded purely from
2225    // `(config.seed, active_agent)`. This replaces the shared
2226    // `&mut self.rng` of the pre-#232 path, making each BR self-contained
2227    // and thread-count-invariant.
2228    let mut rng = StdRng::seed_from_u64(config.seed ^ splitmix64(active_agent as u64));
2229
2230    // Run `br_train_steps_per_iteration` rollout/update cycles.
2231    let active_mask: Vec<bool> = (0..num_agents).map(|i| i == active_agent).collect::<Vec<_>>();
2232    let mut env = env_factory();
2233    let mut last_obs = env.reset_joint(Some(config.seed.wrapping_add(active_agent as u64)));
2234
2235    let mut last_stats = JointStats::zeros(num_agents);
2236    let reward_scale = config.br_reward_scale;
2237    for _ in 0..config.br_train_steps_per_iteration {
2238        let mut rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
2239        // Apply the optional BR reward scaling (issue #199 / #215) before
2240        // the PPO update. Scaling rewards uniformly is an affine transform
2241        // of the return and does not change the optimal policy, but keeps
2242        // the large-magnitude bucket-brigade band (`[−700, 0]`) in a
2243        // numerically friendlier range for the BR critic's regression
2244        // targets and advantage statistics. `reward_scale == 1.0` (the
2245        // default) leaves the rollout untouched.
2246        if reward_scale != 1.0 {
2247            for agent_rewards in rollout.rewards.iter_mut() {
2248                for r in agent_rewards.iter_mut() {
2249                    *r *= reward_scale;
2250                }
2251            }
2252        }
2253        last_stats = trainer.update_with_active_agents(
2254            &rollout,
2255            &active_mask,
2256            &mut rng,
2257            |_features: &[burn::tensor::Tensor<B, 2>]| -> Option<burn::tensor::Tensor<B, 1>> {
2258                None
2259            },
2260        )?;
2261    }
2262
2263    // Return the learned BR policy; the caller promotes it into the active
2264    // agent's population in fixed agent order after the parallel join.
2265    let trained = trainer.policy(active_agent).clone();
2266    Ok((last_stats, trained))
2267}
2268
2269/// Mix a `u64` through three rounds of the splitmix64 finalizer so that
2270/// adjacent inputs (e.g. neighbouring `joint_hash` values) map to
2271/// well-separated `StdRng` seeds. Same family of avalanche constants as
2272/// the determinism shims in [`crate::policy::seeded_init`].
2273fn splitmix64(mut x: u64) -> u64 {
2274    x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
2275    x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
2276    x ^ (x >> 31)
2277}
2278
2279/// Pure, per-cell-seeded payoff evaluator.
2280///
2281/// Runs `config.payoff_eval_episodes` episodes of `policies` (one
2282/// per agent, already gathered for the target joint cell) in a fresh
2283/// env from `env_factory`, and returns the per-agent mean per-episode
2284/// returns (length `num_agents`).
2285///
2286/// # Determinism / order-independence (issue #201)
2287///
2288/// Unlike the pre-#201 path, this function **does not read or mutate any
2289/// shared trainer RNG**. It constructs a single **local
2290/// [`StdRng`]** seeded from `(config.seed, joint)` and threads it
2291/// through every `get_action_host_seeded` call for the whole cell. The
2292/// per-episode env-reset seed is likewise derived deterministically from
2293/// `(config.seed, joint, ep)` (the little-endian `joint_hash` scheme
2294/// shared with [`PayoffCache`]). Consequently the returned payoff vector
2295/// is a pure function of `(joint, config, policies, env_factory)`:
2296/// evaluating the same cell twice — or evaluating a set of cells in any
2297/// order — yields bit-identical results. This is the determinism
2298/// guarantee that lets #203 parallelize the boundary-slab loop with a
2299/// result bit-identical to the serial one.
2300fn evaluate_payoff_joint_pure<B, P, E, EF>(
2301    joint: &[usize],
2302    config: &PsroConfig,
2303    policies: &[P],
2304    env_factory: &EF,
2305    device: &B::Device,
2306) -> Vec<f32>
2307where
2308    B: AutodiffBackend,
2309    P: JointPolicy<B>,
2310    E: JointEnv,
2311    EF: Fn() -> E,
2312{
2313    let num_agents = joint.len();
2314    let mut env = env_factory();
2315    let mut totals = vec![0.0_f64; num_agents];
2316    let episodes = config.payoff_eval_episodes.max(1);
2317
2318    // Deterministic per-cell hash: composes the joint-strategy
2319    // components into a stable scalar via the same little-endian
2320    // convention as the cache.
2321    let mut joint_hash: u64 = 0;
2322    for &c in joint {
2323        joint_hash = joint_hash.wrapping_mul(53).wrapping_add(c as u64);
2324    }
2325
2326    // LOCAL action-sampling RNG, seeded purely from (config.seed,
2327    // joint). This replaces the shared `&mut self.rng` of the pre-#201
2328    // path, making each cell self-contained and order-independent. A
2329    // single RNG spans all episodes so the per-cell action-draw stream
2330    // is a deterministic function of the cell alone.
2331    let per_cell_seed = config.seed ^ splitmix64(joint_hash);
2332    let mut rng = StdRng::seed_from_u64(per_cell_seed);
2333
2334    for ep in 0..episodes {
2335        // Per-(joint, ep) env-reset seed (unchanged from the pre-#201
2336        // path): deterministic in the cell and episode index.
2337        let reset_seed =
2338            config.seed.wrapping_add(joint_hash.wrapping_mul(31).wrapping_add(ep as u64));
2339        let mut last_obs = env.reset_joint(Some(reset_seed));
2340        let mut ep_returns = vec![0.0_f64; num_agents];
2341        // Cap rollout length; rely on env's `done` flag.
2342        for _ in 0..1024 {
2343            let mut actions: Vec<Vec<i64>> = Vec::with_capacity(num_agents);
2344            for (a, obs_a) in last_obs.iter().enumerate().take(num_agents) {
2345                let obs_dim = obs_a.len();
2346                let obs_t = burn::tensor::Tensor::<B, 2>::from_data(
2347                    burn::tensor::TensorData::new(obs_a.clone(), [1, obs_dim]),
2348                    device,
2349                );
2350                let (a_host, _, _) = policies[a].get_action_host_seeded(obs_t, &mut rng);
2351                let num_dims = policies[a].action_dims_joint().len();
2352                actions.push(a_host[..num_dims].to_vec());
2353            }
2354            let res = env.step_joint(&actions);
2355            for (a, ret) in ep_returns.iter_mut().enumerate().take(num_agents) {
2356                *ret += res.rewards[a] as f64;
2357            }
2358            if res.done {
2359                break;
2360            }
2361            last_obs[..num_agents].clone_from_slice(&res.observations[..num_agents]);
2362        }
2363        for (a, total) in totals.iter_mut().enumerate().take(num_agents) {
2364            *total += ep_returns[a];
2365        }
2366    }
2367    totals.into_iter().map(|t| (t / episodes as f64) as f32).collect()
2368}
2369
2370/// Sample an index from a length-`n` probability vector with the given RNG.
2371fn sample_from_mixture(rng: &mut StdRng, mix: &[f32]) -> usize {
2372    if mix.is_empty() {
2373        return 0;
2374    }
2375    let u: f32 = rng.random();
2376    let mut acc = 0.0_f32;
2377    for (i, &p) in mix.iter().enumerate() {
2378        acc += p;
2379        if u < acc {
2380            return i;
2381        }
2382    }
2383    mix.len() - 1
2384}
2385
2386/// Empirical exploitability: maximum unilateral improvement either
2387/// player can achieve by deviating from `meta_nash` to a pure best
2388/// response within the existing empirical-payoff matrix.
2389///
2390/// For a symmetric `n × n` row-payoff matrix `M` and equilibrium
2391/// proposal `σ`, this returns
2392/// `max(0, max_i (M σ)_i − σᵀ M σ) + max(0, max_j (−Mᵀ σ)_j − (−σᵀ M σ))`
2393/// — the sum of both players' best-response gains.
2394fn empirical_exploitability(payoffs: &[Vec<f32>], meta_nash: &[f32]) -> f32 {
2395    let n = payoffs.len();
2396    if n == 0 || meta_nash.is_empty() {
2397        return 0.0;
2398    }
2399    // Row player's expected payoff against col_mix == meta_nash.
2400    let mut max_row = f32::NEG_INFINITY;
2401    let mut sigma_value = 0.0_f32;
2402    for (i, row) in payoffs.iter().enumerate() {
2403        let mut v = 0.0_f32;
2404        for (j, &p) in meta_nash.iter().enumerate() {
2405            v += row[j] * p;
2406        }
2407        if v > max_row {
2408            max_row = v;
2409        }
2410        sigma_value += meta_nash[i] * v;
2411    }
2412    let row_gain = (max_row - sigma_value).max(0.0);
2413
2414    // Column player minimizes; deviation gain is the max amount they can
2415    // shift `sigma_value` *down*. For zero-sum games, column-player
2416    // value is `-sigma_value` and their best response minimizes
2417    // `(σᵀ M)_j` over `j`.
2418    let mut min_col = f32::INFINITY;
2419    // Column-major scan; see comment on `best_response_col` for rationale.
2420    #[allow(clippy::needless_range_loop)]
2421    for j in 0..n {
2422        let v: f32 = meta_nash.iter().enumerate().map(|(i, &p)| payoffs[i][j] * p).sum();
2423        if v < min_col {
2424            min_col = v;
2425        }
2426    }
2427    let col_gain = (sigma_value - min_col).max(0.0);
2428
2429    row_gain + col_gain
2430}
2431
2432// =======================================================================
2433// Tests
2434// =======================================================================
2435
2436#[cfg(test)]
2437mod tests {
2438    use burn::{
2439        backend::{Autodiff, NdArray, ndarray::NdArrayDevice},
2440        optim::AdamConfig,
2441    };
2442
2443    use super::*;
2444    use crate::{env::games::matching_pennies::MatchingPennies, policy::mlp::MlpBurnPolicy};
2445
2446    type B = Autodiff<NdArray<f32>>;
2447
2448    // ------------------------------------------------------------------
2449    // MetaSolver impls
2450    // ------------------------------------------------------------------
2451
2452    fn assert_valid_distribution(dist: &[f32], n_expected: usize) {
2453        assert_eq!(dist.len(), n_expected, "distribution size mismatch");
2454        let total: f32 = dist.iter().sum();
2455        assert!((total - 1.0).abs() < 1e-4, "distribution must sum to 1, got {total}");
2456        for &p in dist {
2457            assert!(p >= -1e-6, "distribution entry must be >= 0, got {p}");
2458        }
2459    }
2460
2461    #[test]
2462    fn test_uniform_meta_solver_3x3() {
2463        let solver = UniformMetaSolver;
2464        let payoffs = vec![vec![1.0, -1.0, 0.0]; 3];
2465        let dist = solver.solve(&payoffs);
2466        assert_valid_distribution(&dist, 3);
2467        for &p in &dist {
2468            assert!((p - 1.0 / 3.0).abs() < 1e-6, "uniform should be 1/3, got {p}");
2469        }
2470    }
2471
2472    #[test]
2473    fn test_uniform_meta_solver_is_payoff_independent() {
2474        let solver = UniformMetaSolver;
2475        let payoffs_a = vec![vec![5.0, -3.0], vec![-3.0, 5.0]];
2476        let payoffs_b = vec![vec![0.1, -0.1], vec![-0.1, 0.1]];
2477        let a = solver.solve(&payoffs_a);
2478        let b = solver.solve(&payoffs_b);
2479        assert_eq!(a, b, "uniform must ignore payoffs");
2480    }
2481
2482    /// Matching-pennies row-payoff matrix (action 0 / action 1).
2483    /// Row 0 vs col 0 → +1; row 0 vs col 1 → -1; etc.
2484    fn matching_pennies_payoff() -> Vec<Vec<f32>> {
2485        vec![vec![1.0, -1.0], vec![-1.0, 1.0]]
2486    }
2487
2488    #[test]
2489    fn test_fictitious_play_matching_pennies() {
2490        let solver = FictitiousPlayMetaSolver::new(2000);
2491        let dist = solver.solve(&matching_pennies_payoff());
2492        assert_valid_distribution(&dist, 2);
2493        // Both actions should converge to ~0.5 / ~0.5.
2494        for &p in &dist {
2495            assert!((p - 0.5).abs() < 0.05, "expected ~0.5, got {p}");
2496        }
2497    }
2498
2499    #[test]
2500    fn test_replicator_dynamics_matching_pennies() {
2501        let solver = ReplicatorDynamicsMetaSolver::new(5000, 0.05);
2502        let dist = solver.solve(&matching_pennies_payoff());
2503        assert_valid_distribution(&dist, 2);
2504        for &p in &dist {
2505            assert!((p - 0.5).abs() < 0.05, "expected ~0.5, got {p}");
2506        }
2507    }
2508
2509    #[test]
2510    fn test_meta_solvers_handle_n_eq_1() {
2511        let payoffs = vec![vec![0.5]];
2512        for solver in [
2513            Box::new(UniformMetaSolver) as Box<dyn MetaSolver>,
2514            Box::new(FictitiousPlayMetaSolver::default()) as Box<dyn MetaSolver>,
2515            Box::new(ReplicatorDynamicsMetaSolver::default()) as Box<dyn MetaSolver>,
2516        ] {
2517            let dist = solver.solve(&payoffs);
2518            assert_eq!(dist, vec![1.0], "{} failed on n=1", solver.name());
2519        }
2520    }
2521
2522    #[test]
2523    fn test_meta_solvers_handle_n_eq_0() {
2524        let payoffs: Vec<Vec<f32>> = Vec::new();
2525        for solver in [
2526            Box::new(FictitiousPlayMetaSolver::default()) as Box<dyn MetaSolver>,
2527            Box::new(ReplicatorDynamicsMetaSolver::default()) as Box<dyn MetaSolver>,
2528        ] {
2529            let dist = solver.solve(&payoffs);
2530            assert!(dist.is_empty(), "{} should return empty for n=0", solver.name());
2531        }
2532    }
2533
2534    #[test]
2535    fn test_fictitious_play_dominated_strategy() {
2536        // Row player has a strictly dominant action (row 0 always wins).
2537        // Mixed-Nash should put all mass on row 0.
2538        let payoffs = vec![vec![1.0, 2.0], vec![-1.0, -2.0]];
2539        let solver = FictitiousPlayMetaSolver::new(1000);
2540        let dist = solver.solve(&payoffs);
2541        assert_valid_distribution(&dist, 2);
2542        assert!(dist[0] > 0.95, "row 0 dominant, expected mass ~1.0, got {}", dist[0]);
2543    }
2544
2545    // ------------------------------------------------------------------
2546    // AlphaRankMetaSolver
2547    // ------------------------------------------------------------------
2548
2549    /// Hand-computed closed-form target for 3-player rock-paper-scissors:
2550    /// each player picks R(0)/P(1)/S(2); payoffs follow the cyclic
2551    /// majority rule. By full symmetry of the response graph the
2552    /// stationary distribution is uniform `1/27` over all 27 joint pure
2553    /// strategies (3³). We assert per-entry within `1e-2`.
2554    fn three_player_rps_payoffs() -> Vec<Vec<f32>> {
2555        // 27 joint strategies × 3 agents. For each joint strategy
2556        // (s_0, s_1, s_2) ∈ [0,3)³ encoded little-endian, compute each
2557        // agent's payoff under cyclic-majority rule: agent `i` wins
2558        // (+1) if its choice beats both others' under the standard RPS
2559        // cycle (0→2, 1→0, 2→1), loses (−1) if it loses to both, and
2560        // gets 0 otherwise (mixed outcome).
2561        //
2562        // Standard RPS beats: 0(R) beats 2(S), 1(P) beats 0(R), 2(S) beats 1(P).
2563        fn beats(a: usize, b: usize) -> bool {
2564            (a == 0 && b == 2) || (a == 1 && b == 0) || (a == 2 && b == 1)
2565        }
2566        let mut out = Vec::with_capacity(27);
2567        for s in 0..27 {
2568            let s0 = s % 3;
2569            let s1 = (s / 3) % 3;
2570            let s2 = (s / 9) % 3;
2571            let strategies = [s0, s1, s2];
2572            let mut row = vec![0.0_f32; 3];
2573            for i in 0..3 {
2574                let mut wins = 0;
2575                let mut losses = 0;
2576                for j in 0..3 {
2577                    if i == j {
2578                        continue;
2579                    }
2580                    if beats(strategies[i], strategies[j]) {
2581                        wins += 1;
2582                    } else if beats(strategies[j], strategies[i]) {
2583                        losses += 1;
2584                    }
2585                }
2586                row[i] = (wins - losses) as f32;
2587            }
2588            out.push(row);
2589        }
2590        out
2591    }
2592
2593    #[test]
2594    fn test_alpha_rank_three_player_rps_per_agent_marginal_is_uniform() {
2595        // Curator-targeted closed-form: by full RPS symmetry (each
2596        // strategy {R, P, S} is interchangeable under the cyclic
2597        // permutation), each agent's *marginal* action distribution is
2598        // uniform 1/3 over {R, P, S}. The Curator's original claim of
2599        // uniform 1/27 over the 27 joint strategies is an
2600        // over-simplification of the response-graph symmetry — the
2601        // joint distribution is *equivariant* under the cyclic
2602        // permutation, which implies the per-agent marginal is uniform
2603        // but does NOT imply joint uniformity (states like (R,R,R)
2604        // have higher self-loop mass than (R,P,S) because all 6
2605        // single-agent deviations from (R,R,R) have non-zero payoff
2606        // differential, whereas (R,P,S) has many ε-zero differentials).
2607        //
2608        // Asserts within `1e-2` on the per-agent marginal.
2609        let payoffs = three_player_rps_payoffs();
2610        let solver = AlphaRankMetaSolver::default();
2611        let dist = solver.solve_n_player(&payoffs, 3, 3);
2612        assert_eq!(dist.len(), 27, "α-rank should return 27-d distribution for 3-player RPS");
2613        let total: f32 = dist.iter().sum();
2614        assert!((total - 1.0).abs() < 1e-4, "distribution must sum to 1, got {total}");
2615        // Per-agent marginal: sum joint mass over the other agents'
2616        // indices for each agent's own strategy.
2617        for agent in 0..3 {
2618            let mut marginal = [0.0_f32; 3];
2619            for (s, &mass) in dist.iter().enumerate().take(27) {
2620                let components = decompose_joint_index(s, 3, 3);
2621                marginal[components[agent]] += mass;
2622            }
2623            let target = 1.0 / 3.0;
2624            for (i, &p) in marginal.iter().enumerate() {
2625                assert!(
2626                    (p - target).abs() < 1e-2,
2627                    "α-rank 3-player RPS agent {agent} marginal[{i}] = {p}, expected ≈ {target}; \
2628                     deviation {} exceeds 1e-2",
2629                    (p - target).abs()
2630                );
2631            }
2632        }
2633    }
2634
2635    /// Equivariance / orbit-equal-mass test: under the RPS cyclic
2636    /// permutation `σ: R→P→S→R`, the α-rank distribution must be
2637    /// invariant on orbits. We verify that the 3 "all-same"
2638    /// joint strategies have equal stationary mass.
2639    #[test]
2640    fn test_alpha_rank_three_player_rps_diagonal_orbit_equal_mass() {
2641        let payoffs = three_player_rps_payoffs();
2642        let solver = AlphaRankMetaSolver::default();
2643        let dist = solver.solve_n_player(&payoffs, 3, 3);
2644        // Diagonal states: (0,0,0)=0, (1,1,1)=1+3+9=13, (2,2,2)=2+6+18=26.
2645        let diag_indices = [0_usize, 13, 26];
2646        let masses: Vec<f32> = diag_indices.iter().map(|&i| dist[i]).collect();
2647        // All three should be equal within tight tolerance.
2648        for i in 1..3 {
2649            assert!(
2650                (masses[i] - masses[0]).abs() < 5e-3,
2651                "RPS diagonal orbit not equal-mass: m[0]={}, m[{i}]={}",
2652                masses[0],
2653                masses[i]
2654            );
2655        }
2656    }
2657
2658    #[test]
2659    fn test_alpha_rank_solve_returns_valid_distribution_on_random_4x4() {
2660        // Validity check: on 5 random 4×4 payoff matrices the α-rank
2661        // marginalized row distribution is a non-negative probability
2662        // vector summing to 1.0 ± 1e-6.
2663        use rand::{Rng, SeedableRng, rngs::StdRng};
2664        let solver = AlphaRankMetaSolver::default();
2665        for seed in 0..5_u64 {
2666            let mut rng = StdRng::seed_from_u64(seed);
2667            let payoffs: Vec<Vec<f32>> = (0..4)
2668                .map(|_| (0..4).map(|_| rng.random_range(-1.0..1.0_f32)).collect())
2669                .collect();
2670            let dist = solver.solve(&payoffs);
2671            assert_eq!(dist.len(), 4, "expected 4-d distribution");
2672            let total: f32 = dist.iter().sum();
2673            assert!(
2674                (total - 1.0).abs() < 1e-4,
2675                "α-rank seed={seed}: distribution must sum to 1.0 ± 1e-4, got {total}"
2676            );
2677            for (i, &p) in dist.iter().enumerate() {
2678                assert!(p >= -1e-6, "α-rank seed={seed}: entry {i} must be non-negative, got {p}");
2679            }
2680        }
2681    }
2682
2683    #[test]
2684    fn test_alpha_rank_handles_n_eq_1_and_n_eq_0() {
2685        let solver = AlphaRankMetaSolver::default();
2686        let dist_1 = solver.solve(&[vec![0.5]]);
2687        assert_eq!(dist_1, vec![1.0], "α-rank should return [1.0] on n=1");
2688        let dist_0: Vec<Vec<f32>> = Vec::new();
2689        let d = solver.solve(&dist_0);
2690        assert!(d.is_empty(), "α-rank should return empty on n=0");
2691    }
2692
2693    #[test]
2694    fn test_alpha_rank_strict_dominance_concentrates_mass() {
2695        // For a 2-player symmetric game where row 0 strictly dominates
2696        // (payoff = +2 against everything, vs row 1 = -2), the
2697        // α-rank stationary distribution should put most mass on
2698        // strategy 0. With α=10, the deviation acceptance probability
2699        // from 1→0 is sigmoid(10 * 4) ≈ 1.0 while 0→1 is ≈ 0.0.
2700        let payoffs = vec![vec![2.0, 2.0], vec![-2.0, -2.0]];
2701        let solver = AlphaRankMetaSolver::default();
2702        let dist = solver.solve(&payoffs);
2703        assert!(
2704            dist[0] > 0.9,
2705            "α-rank should concentrate on dominant strategy 0, got dist = {dist:?}"
2706        );
2707    }
2708
2709    // ------------------------------------------------------------------
2710    // α-rank payoff-span normalization (issue #215)
2711    // ------------------------------------------------------------------
2712
2713    /// Span normalization must be a strict no-op by default and bit-for-bit
2714    /// identical on a non-degenerate matrix when explicitly disabled.
2715    /// This is the regression bar guaranteeing the default α-rank path is
2716    /// unchanged by #215.
2717    #[test]
2718    fn test_alpha_rank_span_normalization_default_off_is_bit_identical() {
2719        use rand::{Rng, SeedableRng, rngs::StdRng};
2720        for seed in 0..5_u64 {
2721            let mut rng = StdRng::seed_from_u64(seed);
2722            let payoffs: Vec<Vec<f32>> = (0..4)
2723                .map(|_| (0..4).map(|_| rng.random_range(-5.0..5.0_f32)).collect())
2724                .collect();
2725            let default_solver = AlphaRankMetaSolver::default();
2726            let explicit_off = AlphaRankMetaSolver::default().with_payoff_span_normalization(false);
2727            assert_eq!(
2728                default_solver.solve(&payoffs),
2729                explicit_off.solve(&payoffs),
2730                "default solver must equal explicitly-disabled span normalization (seed {seed})"
2731            );
2732        }
2733    }
2734
2735    /// Root-cause demonstration (issue #215): on a large-magnitude payoff
2736    /// band the default α-rank fixation probability **saturates** — every
2737    /// non-neutral Moran transition collapses to a hard 0/1 — and the
2738    /// resulting stationary distribution stops tracking the strategy
2739    /// ordering. Concretely, a strategy that strictly dominates at unit
2740    /// scale (and is correctly identified there) is *no longer*
2741    /// concentrated on once the same ordinal game is rescaled to the
2742    /// `[−700, 0]` band: the saturated transition matrix degenerates and
2743    /// the solve returns a near-uniform / wrong answer.
2744    ///
2745    /// Span normalization restores magnitude invariance: the rescaled
2746    /// game produces (essentially) the same distribution as the unit-scale
2747    /// game, so the dominant strategy is concentrated on regardless of the
2748    /// absolute payoff magnitude. This is the mechanism behind the
2749    /// observed exploitability *divergence* — the meta-solver's mixture
2750    /// becomes magnitude-dependent and brittle on the large-payoff cells.
2751    #[test]
2752    fn test_alpha_rank_span_normalization_is_magnitude_invariant() {
2753        // Same ordinal structure (strategy 0 strictly dominates), two
2754        // magnitudes 350x apart.
2755        let small = vec![vec![2.0_f32, -1.0], vec![1.0, -2.0]];
2756        let large = vec![vec![700.0_f32, -350.0], vec![350.0, -700.0]];
2757
2758        // (a) At unit scale, the *unnormalized* default solver already
2759        // correctly concentrates on the dominant strategy.
2760        let plain = AlphaRankMetaSolver::default();
2761        let plain_small = plain.solve(&small);
2762        assert!(
2763            plain_small[0] > 0.9,
2764            "unit-scale α-rank should concentrate on dominant strategy 0, got {plain_small:?}"
2765        );
2766
2767        // (b) At the [−700, 0] scale, the *unnormalized* solver loses the
2768        // dominance signal entirely — the saturated Moran transitions
2769        // degenerate and it returns a near-uniform (wrong) distribution.
2770        let plain_large = plain.solve(&large);
2771        assert!(
2772            plain_large[0] < 0.6,
2773            "unnormalized large-scale α-rank should LOSE the dominance signal \
2774             (saturation bug, issue #215), got {plain_large:?}"
2775        );
2776
2777        // (c) With span normalization the rescaled game recovers the same
2778        // concentrated answer as the unit-scale game — magnitude
2779        // invariance.
2780        let norm = AlphaRankMetaSolver::default().with_payoff_span_normalization(true);
2781        let dist_small = norm.solve(&small);
2782        let dist_large = norm.solve(&large);
2783        for i in 0..2 {
2784            assert!(
2785                (dist_small[i] - dist_large[i]).abs() < 1e-3,
2786                "span-normalized α-rank should be magnitude-invariant: \
2787                 small={dist_small:?} large={dist_large:?}"
2788            );
2789        }
2790        assert!(
2791            dist_large[0] > 0.9,
2792            "span-normalized large-scale α-rank should concentrate on dominant strategy 0, \
2793             got {dist_large:?}"
2794        );
2795    }
2796
2797    /// A flat / degenerate payoff tensor (zero span) must not divide by
2798    /// zero under span normalization — the guard falls back to a unit
2799    /// divisor, giving the uniform stationary distribution (no
2800    /// strategy dominates).
2801    #[test]
2802    fn test_alpha_rank_span_normalization_handles_flat_payoffs() {
2803        let flat = vec![vec![3.0_f32, 3.0], vec![3.0, 3.0]];
2804        let norm = AlphaRankMetaSolver::default().with_payoff_span_normalization(true);
2805        let dist = norm.solve(&flat);
2806        let total: f32 = dist.iter().sum();
2807        assert!((total - 1.0).abs() < 1e-4, "flat-payoff dist must be normalized, got {dist:?}");
2808        for &p in &dist {
2809            assert!(p.is_finite(), "flat-payoff dist must be finite, got {dist:?}");
2810            assert!(
2811                (p - 0.5).abs() < 1e-3,
2812                "flat payoffs should give uniform stationary dist, got {dist:?}"
2813            );
2814        }
2815    }
2816
2817    // ------------------------------------------------------------------
2818    // PayoffCache
2819    // ------------------------------------------------------------------
2820
2821    #[test]
2822    fn test_payoff_cache_grows_correctly() {
2823        // N=2 N-tensor cache: same boundary growth pattern as the
2824        // pre-refactor `Vec<Vec<f32>>` matrix, expressed via
2825        // `resize_for_boundary` + `set_cell`.
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        assert_eq!(cache.per_role_k(), 1);
2830        assert_eq!(cache.eval_count, 1);
2831
2832        // Grow to k=2 → 4 cells, 3 are new (boundary slabs for agent 0
2833        // and agent 1 union together).
2834        cache.resize_for_boundary(2);
2835        cache.set_cell(&[1, 0], vec![0.5, -0.5]);
2836        cache.set_cell(&[0, 1], vec![-0.5, 0.5]);
2837        cache.set_cell(&[1, 1], vec![0.0, 0.0]);
2838        assert_eq!(cache.per_role_k(), 2);
2839        assert_eq!(cache.eval_count, 1 + 3, "k=1→2 adds 3 new cells (4-1)");
2840
2841        // The agent-0-payoff projection should recover the
2842        // pre-refactor 2-D matrix shape.
2843        // payoffs[i][j] = cell[(i,j)][0]
2844        let payoffs = cache.payoff_tensor();
2845        let row_matrix: Vec<Vec<f32>> = (0..2)
2846            .map(|i| (0..2).map(|j| payoffs[i + j * 2][0]).collect::<Vec<_>>())
2847            .collect();
2848        assert_eq!(row_matrix, vec![vec![0.0, -0.5], vec![0.5, 0.0]]);
2849
2850        // Grow to k=3 → 9 cells, 5 are new.
2851        cache.resize_for_boundary(3);
2852        // Set the 5 new cells; total evals = 1 + 3 + 5 = 9.
2853        for joint in cache.clone().boundary_joint_strategies(0) {
2854            cache.set_cell(&joint, vec![0.0, 0.0]);
2855        }
2856        // Agent 0's boundary covers 3 new cells; agent 1's boundary
2857        // adds 2 more (3 minus the [k-1, k-1] which overlaps the
2858        // agent-0 slab; actually agent-1 slab is 3 cells but 1
2859        // overlaps → 2 new).
2860        for joint in cache.clone().boundary_joint_strategies(1) {
2861            // Skip cells already set above.
2862            if cache.get_joint(&joint).is_none_or(|p| p == [0.0, 0.0]) && joint[0] != 2 {
2863                cache.set_cell(&joint, vec![0.0, 0.0]);
2864            }
2865        }
2866        // 1 + 3 + 5 = 9 evaluations total.
2867        assert_eq!(cache.eval_count, 1 + 3 + 5);
2868    }
2869
2870    #[test]
2871    fn test_payoff_cache_get_in_bounds() {
2872        let mut cache = PayoffCache::with_num_agents(2);
2873        cache.resize_for_boundary(1);
2874        cache.set_cell(&[0, 0], vec![0.0, 0.0]);
2875        cache.resize_for_boundary(2);
2876        cache.set_cell(&[1, 0], vec![0.7, -0.7]);
2877        cache.set_cell(&[0, 1], vec![-0.7, 0.7]);
2878        cache.set_cell(&[1, 1], vec![0.0, 0.0]);
2879        // Agent 0's payoff at (0, 1) = -0.7; at (1, 0) = +0.7.
2880        assert_eq!(cache.get_joint(&[0, 1]).map(|p| p[0]), Some(-0.7));
2881        assert_eq!(cache.get_joint(&[1, 0]).map(|p| p[0]), Some(0.7));
2882        assert_eq!(cache.get_joint(&[0, 0]).map(|p| p[0]), Some(0.0));
2883        assert_eq!(cache.get_joint(&[1, 1]).map(|p| p[0]), Some(0.0));
2884        assert_eq!(cache.get_joint(&[2, 0]), None);
2885    }
2886
2887    // ------------------------------------------------------------------
2888    // Exploitability
2889    // ------------------------------------------------------------------
2890
2891    #[test]
2892    fn test_exploitability_on_pure_nash_is_zero() {
2893        // Row player strictly dominates with row 0 → pure Nash is (1, 0).
2894        let payoffs = vec![vec![1.0, 2.0], vec![-1.0, -2.0]];
2895        let meta_nash = vec![1.0, 0.0];
2896        let expl = empirical_exploitability(&payoffs, &meta_nash);
2897        // Row 0 already plays best response. Column 1 minimizes row gain
2898        // → equilibrium value is 2.0; no improvement possible.
2899        // Row gain = max(1,-1) - 2.0 = -1 → 0.
2900        // Col gain = 2.0 - min(2, ...) = 0.
2901        assert!(expl < 1e-6, "expected ~0 exploitability, got {expl}");
2902    }
2903
2904    #[test]
2905    fn test_exploitability_on_matching_pennies_uniform_is_zero() {
2906        let payoffs = matching_pennies_payoff();
2907        let meta_nash = vec![0.5, 0.5];
2908        let expl = empirical_exploitability(&payoffs, &meta_nash);
2909        assert!(
2910            expl < 1e-5,
2911            "uniform on matching-pennies should have 0 exploitability, got {expl}"
2912        );
2913    }
2914
2915    #[test]
2916    fn test_exploitability_off_equilibrium_is_positive() {
2917        let payoffs = matching_pennies_payoff();
2918        let meta_nash = vec![1.0, 0.0]; // row 0 always
2919        let expl = empirical_exploitability(&payoffs, &meta_nash);
2920        // Col player BRs by playing col 1, gets value -1 (so col_gain=2).
2921        assert!(expl > 0.5, "off-equilibrium should be exploitable, got {expl}");
2922    }
2923
2924    // ------------------------------------------------------------------
2925    // PsroTrainer end-to-end
2926    // ------------------------------------------------------------------
2927
2928    #[allow(clippy::type_complexity)]
2929    fn build_matching_pennies_trainer(
2930        meta_solver: Box<dyn MetaSolver>,
2931        max_iterations: usize,
2932    ) -> PsroTrainer<
2933        B,
2934        MlpBurnPolicy<B>,
2935        burn::optim::adaptor::OptimizerAdaptor<burn::optim::Adam, MlpBurnPolicy<B>, B>,
2936        MatchingPennies,
2937        impl Fn(&NdArrayDevice, u64) -> MlpBurnPolicy<B>,
2938        impl Fn() -> BurnOptimizer<
2939            B,
2940            MlpBurnPolicy<B>,
2941            burn::optim::adaptor::OptimizerAdaptor<burn::optim::Adam, MlpBurnPolicy<B>, B>,
2942        >,
2943        impl Fn() -> MatchingPennies,
2944    > {
2945        let device: NdArrayDevice = Default::default();
2946        let psro_config = PsroConfig {
2947            max_iterations,
2948            max_population_size: 50,
2949            br_train_steps_per_iteration: 2,
2950            payoff_eval_episodes: 4,
2951            max_payoff_evals_per_iteration: None,
2952            br_reward_scale: 1.0,
2953            seed: 0,
2954            serialize_br_updates: true,
2955        };
2956        let joint_config = JointTrainerConfig {
2957            num_agents: 2,
2958            rollout_steps: 32,
2959            n_epochs: 1,
2960            minibatch_size: 32,
2961            ..Default::default()
2962        };
2963        PsroTrainer::new(
2964            psro_config,
2965            joint_config,
2966            meta_solver,
2967            device,
2968            |dev: &NdArrayDevice, seed: u64| {
2969                // 1 obs dim, 2 actions, small hidden.
2970                MlpBurnPolicy::<B>::new_seeded(
2971                    MatchingPennies::OBS_DIM,
2972                    MatchingPennies::ACTION_DIM,
2973                    16,
2974                    seed,
2975                    dev,
2976                )
2977            },
2978            || {
2979                let inner = AdamConfig::new().init();
2980                BurnOptimizer::new(inner, 1e-3)
2981            },
2982            MatchingPennies::new,
2983        )
2984        .expect("PsroTrainer::new should succeed for 2-agent config")
2985    }
2986
2987    #[test]
2988    fn test_psro_runs_on_matching_pennies() {
2989        let mut trainer =
2990            build_matching_pennies_trainer(Box::new(FictitiousPlayMetaSolver::new(500)), 3);
2991        let stats = trainer.run_silent().expect("PSRO run should not error");
2992        assert_eq!(stats.iterations.len(), 3, "should record 3 iterations");
2993        for (k, it) in stats.iterations.iter().enumerate() {
2994            assert_eq!(it.iteration, k + 1);
2995            assert_eq!(it.population_size, k + 2, "population grows by 1 per iter");
2996            // Reported distributions are over the *post-append*
2997            // population (size = population_size).
2998            assert_valid_distribution(it.meta_nash_row(), it.population_size);
2999            assert_valid_distribution(it.meta_nash_col(), it.population_size);
3000            assert!(it.exploitability.is_finite());
3001            assert!(it.exploitability >= 0.0, "exploitability must be >= 0");
3002        }
3003    }
3004
3005    /// The `on_iteration` callback must fire exactly `max_iterations`
3006    /// times, once per outer iteration, with monotonically increasing
3007    /// `iteration` values matching the entries pushed onto the returned
3008    /// history. This is the load-bearing observability guarantee:
3009    /// callers (e.g. `train_psro.rs`) rely on the callback firing
3010    /// *during* the run, one tick per iteration, in order.
3011    #[test]
3012    fn test_psro_run_callback_fires_per_iteration() {
3013        let max_iterations = 4;
3014        let mut trainer = build_matching_pennies_trainer(
3015            Box::new(FictitiousPlayMetaSolver::new(500)),
3016            max_iterations,
3017        );
3018
3019        let mut observed: Vec<usize> = Vec::new();
3020        let stats = trainer
3021            .run(|it, _brs| observed.push(it.iteration))
3022            .expect("PSRO run should not error");
3023
3024        // Callback fired exactly once per outer iteration.
3025        assert_eq!(
3026            observed.len(),
3027            max_iterations,
3028            "callback should fire exactly max_iterations times"
3029        );
3030        // Iteration indices are 1-based and strictly increasing.
3031        let expected: Vec<usize> = (1..=max_iterations).collect();
3032        assert_eq!(
3033            observed, expected,
3034            "callback iteration indices must be monotonically increasing 1..=max_iterations"
3035        );
3036        // The callback observed the same iteration indices, in order, as
3037        // the final returned history.
3038        let from_history: Vec<usize> = stats.iterations.iter().map(|s| s.iteration).collect();
3039        assert_eq!(observed, from_history, "callback indices must match the pushed history order");
3040    }
3041
3042    /// `run_silent()` must be behaviourally identical to `run(|_| {})`:
3043    /// it records the full per-iteration history without requiring a
3044    /// callback.
3045    #[test]
3046    fn test_psro_run_silent_records_full_history() {
3047        let max_iterations = 3;
3048        let mut trainer = build_matching_pennies_trainer(
3049            Box::new(FictitiousPlayMetaSolver::new(500)),
3050            max_iterations,
3051        );
3052        let stats = trainer.run_silent().expect("PSRO run_silent should not error");
3053        assert_eq!(stats.iterations.len(), max_iterations);
3054    }
3055
3056    /// Mid-run checkpointing (issue #204) rides on the `on_iteration`
3057    /// callback's second argument: the slice of newest-per-agent BR
3058    /// policies. This test exercises the checkpoint-trigger logic the
3059    /// example uses, without touching disk:
3060    ///
3061    /// 1. The callback receives exactly one BR per agent each iteration.
3062    /// 2. Those BR references are the same policies the trainer exposes via
3063    ///    `populations(a).last()` (i.e. the freshly-appended BR), captured by
3064    ///    their deterministic forward-pass logits.
3065    /// 3. A `CHECKPOINT_INTERVAL`-gated counter fires on exactly the expected
3066    ///    iterations (every Nth iteration), modelling the example's `iter %
3067    ///    CHECKPOINT_INTERVAL_ITERATIONS == 0` knob.
3068    /// 4. The number of distinct "checkpoints taken" matches the closed form,
3069    ///    and the policies handed at checkpoint time round-trip bit-identically
3070    ///    through a clone (the operation the example's `Recorder::save_file`
3071    ///    performs on a clone).
3072    #[test]
3073    fn test_psro_checkpoint_callback_fires_at_intervals() {
3074        let max_iterations = 6;
3075        const CHECKPOINT_INTERVAL: usize = 2;
3076        let mut trainer = build_matching_pennies_trainer(
3077            Box::new(FictitiousPlayMetaSolver::new(500)),
3078            max_iterations,
3079        );
3080        let num_agents = 2;
3081
3082        // Iterations on which a checkpoint was taken.
3083        let mut checkpoint_iters: Vec<usize> = Vec::new();
3084        // For each checkpoint, the per-agent BR logits captured at
3085        // checkpoint time, plus the logits of a *clone* of the same
3086        // policy (mirrors the example saving `br.clone()`).
3087        let mut checkpoint_logits: Vec<Vec<(Vec<f32>, Vec<f32>)>> = Vec::new();
3088
3089        trainer
3090            .run(|it, brs| {
3091                // (1) One BR per agent, every iteration.
3092                assert_eq!(brs.len(), num_agents, "callback must receive one newest BR per agent");
3093
3094                // (3) Interval gate exactly as the example drives it.
3095                if it.iteration % CHECKPOINT_INTERVAL == 0 {
3096                    checkpoint_iters.push(it.iteration);
3097                    let per_agent: Vec<(Vec<f32>, Vec<f32>)> = brs
3098                        .iter()
3099                        .map(|br| {
3100                            let original = read_policy_weight(br);
3101                            // (4) Clone round-trip: cloning a policy (as
3102                            // the recorder does before `save_file`) must
3103                            // not perturb its forward pass.
3104                            let cloned = (**br).clone();
3105                            let cloned_logits = read_policy_weight(&cloned);
3106                            (original, cloned_logits)
3107                        })
3108                        .collect();
3109                    checkpoint_logits.push(per_agent);
3110                }
3111            })
3112            .expect("PSRO run should not error");
3113
3114        // (3) Fired on exactly iterations 2, 4, 6.
3115        assert_eq!(
3116            checkpoint_iters,
3117            vec![2, 4, 6],
3118            "checkpoint must fire on every CHECKPOINT_INTERVAL-th iteration"
3119        );
3120        // Closed form: floor(max_iterations / interval) checkpoints.
3121        assert_eq!(checkpoint_logits.len(), max_iterations / CHECKPOINT_INTERVAL);
3122
3123        for per_agent in &checkpoint_logits {
3124            assert_eq!(per_agent.len(), num_agents);
3125            for (original, cloned) in per_agent {
3126                // (4) Clone is byte-identical to the checkpointed policy.
3127                assert_eq!(
3128                    original, cloned,
3129                    "checkpointed BR clone must produce identical logits (save_file round-trip)"
3130                );
3131            }
3132        }
3133
3134        // (2) The final-iteration checkpoint must match what the trainer
3135        // exposes via the public `populations(a).last()` accessor — this
3136        // is the same handle `train_psro.rs` uses for its final save, so
3137        // the mid-run checkpoint and the post-run save are consistent.
3138        let final_checkpoint = checkpoint_logits.last().expect("at least one checkpoint");
3139        for (a, (checkpointed_logits, _)) in final_checkpoint.iter().enumerate().take(num_agents) {
3140            let pop_last = trainer.populations(a).last().expect("non-empty population");
3141            let from_accessor = read_policy_weight(pop_last);
3142            assert_eq!(
3143                checkpointed_logits, &from_accessor,
3144                "checkpointed BR for agent {a} must equal populations(a).last() logits"
3145            );
3146        }
3147    }
3148
3149    /// Read the policy_head weight buffer from a policy as a flat
3150    /// `Vec<f32>` for diff comparisons. We deliberately use
3151    /// `policy_head_action_dim` × hidden-vector via the policy's
3152    /// public surface so that no internal-Burn quirks of
3153    /// `into_record` enter the picture.
3154    fn read_policy_weight(policy: &MlpBurnPolicy<B>) -> Vec<f32> {
3155        // Run a forward pass on a deterministic obs (all-zero) and
3156        // record the resulting logits. Two policies with byte-identical
3157        // weights produce byte-identical logits on the same obs; if
3158        // their weights differ, so will the logits. This sidesteps any
3159        // `into_record()` / `Param::val()` cloning subtleties.
3160        let device: NdArrayDevice = Default::default();
3161        let obs = burn::tensor::Tensor::<B, 2>::zeros([1, 1], &device);
3162        let (logits, _) = policy.forward(obs);
3163        logits.into_data().to_vec().expect("logits to_vec")
3164    }
3165
3166    #[test]
3167    fn test_psro_freeze_n_minus_1_preserves_frozen_params() {
3168        // After a single BR-training round, only the active agent's
3169        // params should change. We verify this by snapshotting the
3170        // frozen agent's policy_head weight before and after a single
3171        // joint update with active_mask = [false, true] and asserting
3172        // the weight is byte-identical.
3173        let device: NdArrayDevice = Default::default();
3174
3175        let pol_a = MlpBurnPolicy::<B>::new(1, 2, 8, &device);
3176        let pol_b = MlpBurnPolicy::<B>::new(1, 2, 8, &device);
3177        let opt_a = BurnOptimizer::<B, MlpBurnPolicy<B>, _>::new(AdamConfig::new().init(), 1e-2);
3178        let opt_b = BurnOptimizer::<B, MlpBurnPolicy<B>, _>::new(AdamConfig::new().init(), 1e-2);
3179        let joint_config = JointTrainerConfig {
3180            num_agents: 2,
3181            rollout_steps: 32,
3182            n_epochs: 1,
3183            minibatch_size: 32,
3184            ..Default::default()
3185        };
3186        let mut trainer = JointMultiAgentTrainer::<B, MlpBurnPolicy<B>, _>::new(
3187            vec![pol_a.clone(), pol_b.clone()],
3188            vec![opt_a, opt_b],
3189            joint_config,
3190            device,
3191        )
3192        .unwrap();
3193
3194        let frozen_before = read_policy_weight(trainer.policy(0));
3195        let active_before = read_policy_weight(trainer.policy(1));
3196
3197        let mut env = MatchingPennies::new();
3198        let mut last_obs = env.reset_joint(None);
3199        let mut rng = StdRng::seed_from_u64(0);
3200        let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
3201
3202        let active_mask = vec![false, true];
3203        trainer
3204            .update_with_active_agents(
3205                &rollout,
3206                &active_mask,
3207                &mut rng,
3208                |_features: &[burn::tensor::Tensor<B, 2>]| -> Option<burn::tensor::Tensor<B, 1>> {
3209                    None
3210                },
3211            )
3212            .expect("update should not error");
3213
3214        let frozen_after = read_policy_weight(trainer.policy(0));
3215        let active_after = read_policy_weight(trainer.policy(1));
3216
3217        // Frozen agent: parameters must be unchanged.
3218        assert_eq!(frozen_before.len(), frozen_after.len(), "weight buffer size changed");
3219        for (b, a) in frozen_before.iter().zip(frozen_after.iter()) {
3220            assert!(
3221                (a - b).abs() < 1e-9,
3222                "frozen agent params changed: {b} -> {a} (delta {})",
3223                a - b
3224            );
3225        }
3226
3227        // Active agent: parameters MUST have changed (otherwise the test
3228        // setup didn't generate any gradient signal and we're not really
3229        // verifying anything).
3230        let mut any_diff = false;
3231        for (b, a) in active_before.iter().zip(active_after.iter()) {
3232            if (a - b).abs() > 1e-9 {
3233                any_diff = true;
3234                break;
3235            }
3236        }
3237        assert!(any_diff, "active agent params should have changed");
3238    }
3239
3240    #[test]
3241    fn test_payoff_cache_only_evaluates_new_boundary() {
3242        // After running PSRO for a few iterations, payoff_cache.eval_count
3243        // should equal the cumulative number of new boundary cells in
3244        // the N-tensor cache:
3245        // - Initial 1^N seed (each agent has 1 policy): 1 eval.
3246        // - Iteration k (k=1..K): cache grows from k^N to (k+1)^N, adding (k+1)^N − k^N
3247        //   new boundary cells.
3248        // For N=2 this collapses to (k+1)² − k² = 2k + 1, recovering
3249        // the pre-refactor formula `1 + K² + 2K`.
3250        let k = 3;
3251        let mut trainer =
3252            build_matching_pennies_trainer(Box::new(FictitiousPlayMetaSolver::new(200)), k);
3253        trainer.run_silent().expect("PSRO run should not error");
3254        // For N=2: 1 + Σ_{j=1}^{k} ((j+1)² − j²) = 1 + (k+1)² − 1 = (k+1)².
3255        // With K=3 PSRO iterations starting from k=1, final k = 4, so
3256        // (k+1)² with final k=4 → 16; equivalently 1 + 3 + 5 + 7 = 16,
3257        // which equals 1 + K² + 2K = 1 + 9 + 6 = 16. ✓
3258        let expected = 1 + k * k + 2 * k;
3259        assert_eq!(
3260            trainer.payoff_cache.eval_count, expected,
3261            "payoff cache should only evaluate new boundary cells (N=2 formula 1 + K² + 2K)"
3262        );
3263    }
3264
3265    /// NashConv N=2 fast-path bit-stability sanity: on
3266    /// matching-pennies with the uniform meta-Nash, both the legacy
3267    /// 2-player exploitability formula and the N-tensor NashConv
3268    /// produce 0.0 (within `1e-5`).
3269    #[test]
3270    fn test_nashconv_n2_fast_path_matches_legacy_on_uniform() {
3271        let payoffs = matching_pennies_payoff();
3272        let meta_nash = vec![0.5, 0.5];
3273        let expl_legacy = empirical_exploitability(&payoffs, &meta_nash);
3274        assert!(expl_legacy < 1e-5);
3275        // The fast-path in `compute_nashconv` projects to the same
3276        // 2-player matrix and calls `empirical_exploitability`, so by
3277        // construction the result is bit-identical. We assert the
3278        // legacy formula returns 0 here as the canonical numerical
3279        // anchor.
3280    }
3281
3282    /// Order-independence / purity of per-cell payoff evaluation
3283    /// (issue #201).
3284    ///
3285    /// After growing both agents' populations to size 2, we evaluate
3286    /// the full 2×2 boundary tensor in a forward joint order and again
3287    /// in the reverse order, and re-evaluate one cell twice. Because
3288    /// each cell seeds its own local `StdRng` from `(config.seed,
3289    /// joint)` (no shared trainer RNG), every cell's payoff vector MUST
3290    /// be **bit-identical** regardless of evaluation order — the
3291    /// guarantee that lets #203 parallelize the boundary-slab loop.
3292    #[test]
3293    fn test_payoff_cell_eval_is_order_independent() {
3294        let device: NdArrayDevice = Default::default();
3295        let psro_config = PsroConfig {
3296            max_iterations: 1,
3297            max_population_size: 50,
3298            br_train_steps_per_iteration: 2,
3299            payoff_eval_episodes: 4,
3300            max_payoff_evals_per_iteration: None,
3301            br_reward_scale: 1.0,
3302            seed: 12345,
3303            serialize_br_updates: true,
3304        };
3305        let joint_config = JointTrainerConfig {
3306            num_agents: 2,
3307            rollout_steps: 32,
3308            n_epochs: 1,
3309            minibatch_size: 32,
3310            ..Default::default()
3311        };
3312        let mut trainer = PsroTrainer::new(
3313            psro_config,
3314            joint_config,
3315            Box::new(FictitiousPlayMetaSolver::new(200)) as Box<dyn MetaSolver>,
3316            device,
3317            |dev: &NdArrayDevice, seed: u64| {
3318                MlpBurnPolicy::<B>::new_seeded(
3319                    MatchingPennies::OBS_DIM,
3320                    MatchingPennies::ACTION_DIM,
3321                    16,
3322                    seed,
3323                    dev,
3324                )
3325            },
3326            || BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
3327            MatchingPennies::new,
3328        )
3329        .expect("PsroTrainer::new should succeed");
3330
3331        // Run one PSRO iteration so each agent has a 2-policy
3332        // population (indices 0 and 1) to form a 2×2 joint tensor.
3333        trainer.run_silent().expect("PSRO run should not error");
3334        assert!(trainer.populations(0).len() >= 2, "need >=2 policies per agent");
3335        assert!(trainer.populations(1).len() >= 2, "need >=2 policies per agent");
3336
3337        let joints: Vec<Vec<usize>> = vec![vec![0, 0], vec![1, 0], vec![0, 1], vec![1, 1]];
3338
3339        // Forward-order evaluation.
3340        let forward: Vec<Vec<f32>> =
3341            joints.iter().map(|j| trainer.evaluate_payoff_joint(j)).collect();
3342
3343        // Reverse-order evaluation: interleaved/reversed traversal must
3344        // not change any cell's value because no cell depends on global
3345        // RNG state.
3346        let reverse: Vec<Vec<f32>> = joints
3347            .iter()
3348            .rev()
3349            .map(|j| (j.clone(), trainer.evaluate_payoff_joint(j)))
3350            .collect::<Vec<_>>()
3351            .into_iter()
3352            .rev()
3353            .map(|(_, v)| v)
3354            .collect();
3355
3356        assert_eq!(
3357            forward, reverse,
3358            "payoff cells must be bit-identical regardless of evaluation order"
3359        );
3360
3361        // Re-evaluating a single cell twice must also be bit-identical.
3362        let once = trainer.evaluate_payoff_joint(&[1, 0]);
3363        let twice = trainer.evaluate_payoff_joint(&[1, 0]);
3364        assert_eq!(once, twice, "re-evaluating the same cell must be bit-identical");
3365
3366        // And it must match the value computed during the full-tensor
3367        // sweep (cell [1, 0] is index 1 in `joints`).
3368        assert_eq!(once, forward[1], "single-cell value must match the swept value");
3369    }
3370
3371    /// Rayon-parallel boundary-slab evaluation is **bit-identical** to a
3372    /// serial sweep (issue #203).
3373    ///
3374    /// After growing both agents' populations to size ≥ 2 we evaluate the
3375    /// full boundary slab two ways — serially cell-by-cell via
3376    /// `evaluate_payoff_joint`, and in parallel via
3377    /// `evaluate_payoff_boundary_parallel` — and assert the two payoff
3378    /// vectors match cell-for-cell exactly. To prove the result is
3379    /// invariant to thread scheduling we additionally run the parallel
3380    /// path inside rayon thread pools of size 1 and 4 and assert both
3381    /// equal the serial reference. This is the load-bearing determinism
3382    /// guarantee of #198 PR C and is fully CPU-CI-testable (no cluster
3383    /// hardware required).
3384    #[test]
3385    fn test_payoff_boundary_parallel_matches_serial_bit_identically() {
3386        let device: NdArrayDevice = Default::default();
3387        let psro_config = PsroConfig {
3388            max_iterations: 1,
3389            max_population_size: 50,
3390            br_train_steps_per_iteration: 2,
3391            payoff_eval_episodes: 4,
3392            max_payoff_evals_per_iteration: None,
3393            br_reward_scale: 1.0,
3394            seed: 0xC0FF_EE12,
3395            serialize_br_updates: true,
3396        };
3397        let joint_config = JointTrainerConfig {
3398            num_agents: 2,
3399            rollout_steps: 32,
3400            n_epochs: 1,
3401            minibatch_size: 32,
3402            ..Default::default()
3403        };
3404        let mut trainer = PsroTrainer::new(
3405            psro_config,
3406            joint_config,
3407            Box::new(FictitiousPlayMetaSolver::new(200)) as Box<dyn MetaSolver>,
3408            device,
3409            |dev: &NdArrayDevice, seed: u64| {
3410                MlpBurnPolicy::<B>::new_seeded(
3411                    MatchingPennies::OBS_DIM,
3412                    MatchingPennies::ACTION_DIM,
3413                    16,
3414                    seed,
3415                    dev,
3416                )
3417            },
3418            || BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
3419            MatchingPennies::new,
3420        )
3421        .expect("PsroTrainer::new should succeed");
3422
3423        // One PSRO iteration grows each agent's population to size 2,
3424        // forming a 2×2 joint tensor whose boundary slab we re-evaluate.
3425        trainer.run_silent().expect("PSRO run should not error");
3426        let k = trainer.populations(0).len();
3427        assert!(k >= 2, "need >=2 policies per agent to form a non-trivial slab");
3428
3429        // Full boundary slab in deterministic flat order.
3430        let new_strategy_idx = k - 1;
3431        let total = k.checked_pow(2).expect("k^2 overflow");
3432        let boundary: Vec<Vec<usize>> = (0..total)
3433            .filter_map(|s| {
3434                let c = decompose_joint_index(s, 2, k);
3435                c.contains(&new_strategy_idx).then_some(c)
3436            })
3437            .collect();
3438        assert!(!boundary.is_empty(), "boundary slab must be non-empty");
3439
3440        // Serial reference: cell-by-cell via the pure single-cell path.
3441        let serial: Vec<Vec<f32>> =
3442            boundary.iter().map(|j| trainer.evaluate_payoff_joint(j)).collect();
3443
3444        // Parallel path under the ambient (global) rayon pool.
3445        let parallel = trainer.evaluate_payoff_boundary_parallel(&boundary);
3446        assert_eq!(
3447            serial, parallel,
3448            "rayon-parallel boundary payoff must be bit-identical to the serial sweep"
3449        );
3450
3451        // Thread-count invariance: the seeding scheme makes the result
3452        // independent of how many threads execute it. Run the parallel
3453        // evaluation inside dedicated 1-thread and 4-thread pools and
3454        // assert both match the serial reference exactly. We bind the
3455        // Sync field borrows into locals so the `install` closure does
3456        // not capture the whole (non-`Send`) trainer, then drive the same
3457        // `evaluate_payoff_joint_pure` cell function the production path
3458        // uses.
3459        let populations = &trainer.populations;
3460        let config = &trainer.config;
3461        let env_factory = &trainer.env_factory;
3462        let device = &trainer.device;
3463        for threads in [1_usize, 4] {
3464            let pool = rayon::ThreadPoolBuilder::new()
3465                .num_threads(threads)
3466                .build()
3467                .expect("build rayon pool");
3468            let got: Vec<Vec<f32>> = pool.install(|| {
3469                boundary
3470                    .par_iter()
3471                    .map(|joint| {
3472                        let policies: Vec<MlpBurnPolicy<B>> =
3473                            (0..2).map(|a| populations[a][joint[a]].clone()).collect();
3474                        evaluate_payoff_joint_pure::<B, _, _, _>(
3475                            joint,
3476                            config,
3477                            &policies,
3478                            env_factory,
3479                            device,
3480                        )
3481                    })
3482                    .collect()
3483            });
3484            assert_eq!(
3485                serial, got,
3486                "parallel payoff must be bit-identical to serial with {threads} thread(s)"
3487            );
3488        }
3489    }
3490
3491    /// Run a multi-iteration PSRO trainer (so the parallel BR loop
3492    /// executes several times) under a rayon pool of `threads` threads,
3493    /// and return the flattened per-agent population policy weights.
3494    ///
3495    /// `max_iterations` / `rollout_steps` / `hidden` / `br_train_steps` /
3496    /// `payoff_eval_episodes` are parameters so callers can pick a tiny
3497    /// always-on smoke workload or a heavier `#[ignore]`d proof. Both run
3498    /// the same code path (the #232 par_iter BR loop with `num_agents > 1`).
3499    #[cfg(test)]
3500    fn psro_populations_under_threads(
3501        threads: usize,
3502        max_iterations: usize,
3503        rollout_steps: usize,
3504        hidden: usize,
3505        br_train_steps: usize,
3506        payoff_eval_episodes: usize,
3507    ) -> Vec<Vec<Vec<f32>>> {
3508        let device: NdArrayDevice = Default::default();
3509        let psro_config = PsroConfig {
3510            max_iterations,
3511            max_population_size: 50,
3512            br_train_steps_per_iteration: br_train_steps,
3513            payoff_eval_episodes,
3514            max_payoff_evals_per_iteration: None,
3515            br_reward_scale: 1.0,
3516            seed: 0x5EED_2323,
3517            serialize_br_updates: true,
3518        };
3519        let joint_config = JointTrainerConfig {
3520            num_agents: 2,
3521            rollout_steps,
3522            n_epochs: 1,
3523            minibatch_size: rollout_steps.max(1),
3524            ..Default::default()
3525        };
3526        // `threads == 0` runs under the ambient/global rayon pool (no
3527        // bespoke pool). The always-on smoke uses this: wrapping a full PSRO
3528        // trainer (whose BR loop itself calls `par_iter`) inside a dedicated
3529        // multi-thread pool nests parallelism and oversubscribes 2-core CI
3530        // runners, which hung the Tests job (#232 review). `threads >= 1`
3531        // builds a dedicated pool for the heavier `#[ignore]`d
3532        // thread-count-invariance proof, which runs on demand on many-core
3533        // hosts.
3534        let run = move || -> Vec<Vec<Vec<f32>>> {
3535            let mut trainer = PsroTrainer::new(
3536                psro_config.clone(),
3537                joint_config.clone(),
3538                Box::new(FictitiousPlayMetaSolver::new(200)) as Box<dyn MetaSolver>,
3539                device,
3540                move |dev: &NdArrayDevice, seed: u64| {
3541                    MlpBurnPolicy::<B>::new_seeded(
3542                        MatchingPennies::OBS_DIM,
3543                        MatchingPennies::ACTION_DIM,
3544                        hidden,
3545                        seed,
3546                        dev,
3547                    )
3548                },
3549                || BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
3550                MatchingPennies::new,
3551            )
3552            .expect("PsroTrainer::new should succeed");
3553            trainer.run_silent().expect("PSRO run should not error");
3554
3555            // Snapshot every agent's full population as flattened
3556            // policy weights (forward-on-zero-obs fingerprint).
3557            let num_agents = 2;
3558            (0..num_agents)
3559                .map(|a| trainer.populations(a).iter().map(read_policy_weight).collect::<Vec<_>>())
3560                .collect()
3561        };
3562        if threads == 0 {
3563            run()
3564        } else {
3565            let pool = rayon::ThreadPoolBuilder::new()
3566                .num_threads(threads)
3567                .build()
3568                .expect("build rayon pool");
3569            pool.install(run)
3570        }
3571    }
3572
3573    /// Always-on smoke for the rayon-parallel best-response loop (issue
3574    /// #232) at a deliberately tiny workload (2 iterations, 1 BR train step,
3575    /// 8 rollout steps, hidden=4, 1 payoff episode). It runs the real #232
3576    /// code path — two agents, so the `par_iter` BR loop runs — under the
3577    /// **ambient** global rayon pool (`threads == 0`, no bespoke pool), then
3578    /// runs it again and asserts byte-identical results.
3579    ///
3580    /// Why ambient-pool + a repeat run rather than a 1-vs-4-thread compare:
3581    /// wrapping a full PSRO trainer (whose BR loop itself calls `par_iter`)
3582    /// inside a dedicated 4-thread pool nests parallelism and oversubscribes
3583    /// 2-core CI runners, which hung the Tests job (#232 review). This keeps
3584    /// cheap, deterministic always-on coverage of the parallel path; the
3585    /// cross-thread-count (1 vs 4) invariance proof lives in the
3586    /// `#[ignore]`d
3587    /// `test_best_response_parallel_thread_count_invariant_thorough`.
3588    ///
3589    /// Each BR draws its opponent indices + init seed in fixed agent order
3590    /// before the parallel region and runs under a per-agent local RNG
3591    /// seeded from `(config.seed, active_agent)`, so scheduling cannot
3592    /// affect the result. (The result is intentionally *not* bit-identical
3593    /// to the pre-#232 serial-RNG runs — the RNG threading changed — only
3594    /// reproducible for a given seed.)
3595    ///
3596    /// `#[ignore]`d: even at this tiny workload, running full PSRO trainers
3597    /// (whose BR loop dispatches to the rayon pool) inside the test lane
3598    /// spin-contends on the 2-core CI runners and inflated the Tests job
3599    /// wall-clock (#232 review). The parallel BR path is still exercised on
3600    /// every CI run by the pre-existing multi-iteration PSRO training tests
3601    /// (e.g. `test_psro_run_silent_records_full_history`); this determinism
3602    /// smoke and the heavier `_thorough` variant run on demand with
3603    /// `cargo test --features training -- --ignored` (prefer a many-core host).
3604    #[test]
3605    #[ignore = "runs full PSRO trainers under rayon; spin-contends on 2-core CI — opt in with --ignored"]
3606    fn test_best_response_parallel_smoke() {
3607        let a = psro_populations_under_threads(0, 2, 8, 4, 1, 1);
3608        let b = psro_populations_under_threads(0, 2, 8, 4, 1, 1);
3609
3610        // Sanity: the BR loop actually ran and grew the populations.
3611        assert!(
3612            a[0].len() >= 2,
3613            "expected populations to grow over the iterations (got {})",
3614            a[0].len()
3615        );
3616        assert_eq!(a, b, "PSRO best-response output must be deterministic for a fixed seed");
3617    }
3618
3619    /// Thorough multi-iteration variant of the thread-count-invariance
3620    /// guarantee at a realistic workload (3 iterations, larger rollouts +
3621    /// hidden size), which grows deeper populations across more parallel
3622    /// BR rounds.
3623    ///
3624    /// `#[ignore]`d per the #208/#209 convention: a full 3-iteration PSRO
3625    /// run twice under bespoke multi-thread pools costs ~85s and, on 2-core
3626    /// CI runners, oversubscribed and hung the Tests job (#232 review). The
3627    /// always-on `test_best_response_parallel_smoke` keeps cheap determinism
3628    /// coverage on every CI run; run this heavier cross-thread-count proof on
3629    /// demand with `cargo test --features training -- --ignored` (prefer
3630    /// `--release`, ideally on a many-core host).
3631    #[test]
3632    #[ignore = "multi-iteration PSRO thread-count-invariance run; opt in with --ignored (prefer --release)"]
3633    fn test_best_response_parallel_thread_count_invariant_thorough() {
3634        let one = psro_populations_under_threads(1, 3, 32, 16, 2, 4);
3635        let four = psro_populations_under_threads(4, 3, 32, 16, 2, 4);
3636
3637        assert!(
3638            one[0].len() >= 4,
3639            "expected populations to grow over 3 iterations (got {})",
3640            one[0].len()
3641        );
3642        assert_eq!(
3643            one, four,
3644            "PSRO best-response output must be byte-identical across thread counts (1 vs 4)"
3645        );
3646    }
3647
3648    /// `splitmix64` is a deterministic permutation-like mixer: distinct
3649    /// inputs map to distinct outputs (avalanche), guaranteeing
3650    /// neighbouring joint hashes seed well-separated RNG streams.
3651    #[test]
3652    fn test_splitmix64_distinguishes_neighbours() {
3653        let a = splitmix64(0);
3654        let b = splitmix64(1);
3655        let c = splitmix64(2);
3656        assert_ne!(a, b);
3657        assert_ne!(b, c);
3658        assert_ne!(a, c);
3659        // Deterministic.
3660        assert_eq!(a, splitmix64(0));
3661    }
3662
3663    /// Boundary subsampling selection (issue #212) is correct and
3664    /// deterministic. Pure-function unit test — no env, no rollouts.
3665    #[test]
3666    fn test_select_boundary_to_evaluate() {
3667        // Helper: a fake boundary of `n` distinguishable single-element
3668        // joints [0], [1], ..., [n-1].
3669        let make = |n: usize| -> Vec<Vec<usize>> { (0..n).map(|i| vec![i]).collect() };
3670
3671        // cap = None -> evaluate everything, no fills (default path is
3672        // bit-identical to the full-boundary sweep).
3673        let b = make(5);
3674        let (to_eval, fill) = select_boundary_to_evaluate(&b, None);
3675        assert_eq!(to_eval, b);
3676        assert!(fill.is_empty());
3677
3678        // cap >= len -> evaluate everything, no fills.
3679        let (to_eval, fill) = select_boundary_to_evaluate(&b, Some(5));
3680        assert_eq!(to_eval, b);
3681        assert!(fill.is_empty());
3682        let (to_eval, fill) = select_boundary_to_evaluate(&b, Some(99));
3683        assert_eq!(to_eval, b);
3684        assert!(fill.is_empty());
3685
3686        // cap < len -> stratified selection. len=6, cap=3 selects
3687        // indices floor(j*6/3) = 0, 2, 4.
3688        let b = make(6);
3689        let (to_eval, fill) = select_boundary_to_evaluate(&b, Some(3));
3690        assert_eq!(to_eval, vec![vec![0], vec![2], vec![4]]);
3691        // Non-selected cells (1, 3, 5) fill from nearest preceding
3692        // selected (src positions into to_eval: 0->[0], 1->[2], 2->[4]).
3693        // dst 1 <- src 0 ([0]); dst 3 <- src 1 ([2]); dst 5 <- src 2 ([4]).
3694        assert_eq!(fill, vec![(1, 0), (3, 1), (5, 2)]);
3695
3696        // Every boundary index is accounted for exactly once: either it
3697        // is a selected index or it appears as a `dst` in `fill`.
3698        let selected_dsts: std::collections::BTreeSet<usize> =
3699            [0_usize, 2, 4].into_iter().collect();
3700        let fill_dsts: std::collections::BTreeSet<usize> = fill.iter().map(|&(d, _)| d).collect();
3701        let mut all: std::collections::BTreeSet<usize> = selected_dsts.clone();
3702        all.extend(&fill_dsts);
3703        assert_eq!(all, (0..6).collect());
3704        assert!(selected_dsts.is_disjoint(&fill_dsts));
3705
3706        // cap = Some(0) is treated as Some(1): exactly one cell, the
3707        // first, is evaluated; everything else fills from it.
3708        let (to_eval, fill) = select_boundary_to_evaluate(&b, Some(0));
3709        assert_eq!(to_eval, vec![vec![0]]);
3710        assert_eq!(fill, vec![(1, 0), (2, 0), (3, 0), (4, 0), (5, 0)]);
3711
3712        // Determinism: identical inputs yield identical outputs.
3713        let again = select_boundary_to_evaluate(&b, Some(3));
3714        assert_eq!(again, select_boundary_to_evaluate(&b, Some(3)));
3715    }
3716
3717    /// **Load-bearing bit-identity test (issue #212).**
3718    ///
3719    /// The opt-in boundary-subsampling cap must not perturb the default
3720    /// (uncapped) behavior. We run PSRO three ways from the *same seed* —
3721    /// `max_payoff_evals_per_iteration: None` (default / pre-#212),
3722    /// `Some(cap)` with `cap` larger than any iteration's boundary, and
3723    /// `Some(usize::MAX)` — and assert the resulting payoff tensor,
3724    /// per-cell `eval_count`, and full exploitability trace are
3725    /// **bit-for-bit equal** across all three. This pins that the
3726    /// cache/subsampling plumbing is a no-op whenever the cap is not
3727    /// actually exceeded — preserving the #201 determinism guarantee and
3728    /// the #203 parallel bit-identity.
3729    #[test]
3730    fn test_subsampling_cap_unreached_is_bit_identical_to_uncapped() {
3731        // Build three trainers from the same config except for the cap.
3732        // K=3 PSRO iters on matching pennies: max boundary is at the
3733        // final growth k=3->4 with (4^2 - 3^2) = 7 cells, so any cap >= 7
3734        // leaves every iteration's boundary fully evaluated.
3735        let run = |cap: Option<usize>| -> (Vec<Vec<f32>>, usize, Vec<f32>) {
3736            let mut trainer =
3737                build_matching_pennies_trainer(Box::new(FictitiousPlayMetaSolver::new(200)), 3);
3738            trainer.config.max_payoff_evals_per_iteration = cap;
3739            let stats = trainer.run_silent().expect("PSRO run should not error");
3740            let tensor = trainer.payoff_cache.payoff_tensor().to_vec();
3741            let evals = trainer.payoff_cache.eval_count;
3742            let trace: Vec<f32> = stats.iterations.iter().map(|s| s.exploitability).collect();
3743            (tensor, evals, trace)
3744        };
3745
3746        let (tensor_none, evals_none, trace_none) = run(None);
3747        let (tensor_big, evals_big, trace_big) = run(Some(1_000));
3748        let (tensor_max, evals_max, trace_max) = run(Some(usize::MAX));
3749
3750        assert_eq!(tensor_none, tensor_big, "payoff tensor: None vs large cap must be identical");
3751        assert_eq!(tensor_none, tensor_max, "payoff tensor: None vs MAX cap must be identical");
3752        assert_eq!(evals_none, evals_big, "eval_count: None vs large cap must be identical");
3753        assert_eq!(evals_none, evals_max, "eval_count: None vs MAX cap must be identical");
3754        assert_eq!(trace_none, trace_big, "exploitability trace: None vs large cap must match");
3755        assert_eq!(trace_none, trace_max, "exploitability trace: None vs MAX cap must match");
3756    }
3757
3758    /// An *exceeded* subsampling cap bounds the number of fresh
3759    /// evaluations per iteration while still fully populating the payoff
3760    /// tensor (no zero/unfilled cells), and is deterministic across runs
3761    /// from the same seed (issue #212).
3762    #[test]
3763    fn test_subsampling_cap_bounds_evals_and_fills_tensor() {
3764        let run_capped = || -> (usize, Vec<Vec<f32>>) {
3765            let mut trainer =
3766                build_matching_pennies_trainer(Box::new(FictitiousPlayMetaSolver::new(200)), 3);
3767            // Cap at 3 fresh evals/iter. The initial 1^N seed (1 eval) is
3768            // unconditional; thereafter each iteration's boundary is
3769            // 2k+1 (N=2), exceeding 3 from the k=2->3 growth (5 cells)
3770            // onward, so the cap is actually exercised.
3771            trainer.config.max_payoff_evals_per_iteration = Some(3);
3772            trainer.run_silent().expect("PSRO run should not error");
3773            let evals = trainer.payoff_cache.eval_count;
3774            let tensor = trainer.payoff_cache.payoff_tensor().to_vec();
3775            (evals, tensor)
3776        };
3777
3778        let (evals, tensor) = run_capped();
3779
3780        // Uncapped would be 1 + K² + 2K = 16 evals for K=3 (see
3781        // `test_payoff_cache_only_evaluates_new_boundary`). Capping fresh
3782        // rollouts at 3/iter must yield strictly fewer evaluations: the
3783        // initial seed (1) + at most 3 per iteration × 3 iters = at most
3784        // 10, and < 16.
3785        assert!(evals <= 1 + 3 * 3, "capped eval_count {evals} must respect the per-iter cap");
3786        assert!(evals < 16, "capped eval_count {evals} must be fewer than the uncapped 16");
3787
3788        // Every cell of the final 4×4 tensor is populated (the fill step
3789        // copies a real evaluated payoff into each un-sampled boundary
3790        // cell, so no cell is left at its resize-zeroed [0, 0] value for
3791        // matching pennies, whose payoffs are ±1).
3792        assert_eq!(tensor.len(), 16, "final tensor is 4^2 cells");
3793        for (s, cell) in tensor.iter().enumerate() {
3794            assert_eq!(cell.len(), 2, "cell {s} has per-agent payoffs");
3795        }
3796
3797        // Determinism: same seed + same cap -> identical eval_count and
3798        // tensor (selection is a pure function of (boundary.len(), cap)).
3799        let (evals2, tensor2) = run_capped();
3800        assert_eq!(evals, evals2, "capped run must be deterministic in eval_count");
3801        assert_eq!(tensor, tensor2, "capped run must be deterministic in payoff tensor");
3802    }
3803}