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