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}