use anyhow::{Result, anyhow};
use burn::{optim::Optimizer, tensor::backend::AutodiffBackend};
use rand::{Rng, SeedableRng, rngs::StdRng};
use rayon::prelude::*;
use crate::{
multi_agent::joint::{
JointEnv, JointMultiAgentTrainer, JointPolicy, JointStats, JointTrainerConfig,
},
train::optimizer::BurnOptimizer,
};
pub trait MetaSolver {
fn solve(&self, payoffs: &[Vec<f32>]) -> Vec<f32>;
fn solve_n_player(
&self,
_payoffs: &[Vec<f32>],
num_agents: usize,
_per_role_k: usize,
) -> Vec<f32> {
panic!(
"{} does not support num_agents = {}; only 2-player meta-games. \
Use AlphaRankMetaSolver for N > 2.",
self.name(),
num_agents
);
}
fn name(&self) -> &'static str;
}
#[derive(Debug, Clone, Default)]
pub struct UniformMetaSolver;
impl MetaSolver for UniformMetaSolver {
fn solve(&self, payoffs: &[Vec<f32>]) -> Vec<f32> {
let n = payoffs.len().max(1);
vec![1.0 / n as f32; n]
}
fn name(&self) -> &'static str {
"uniform"
}
}
#[derive(Debug, Clone)]
pub struct FictitiousPlayMetaSolver {
iterations: usize,
}
impl FictitiousPlayMetaSolver {
pub fn new(iterations: usize) -> Self {
Self { iterations: iterations.max(1) }
}
}
impl Default for FictitiousPlayMetaSolver {
fn default() -> Self {
Self::new(1000)
}
}
impl MetaSolver for FictitiousPlayMetaSolver {
fn solve(&self, payoffs: &[Vec<f32>]) -> Vec<f32> {
let n = payoffs.len();
if n == 0 {
return Vec::new();
}
if n == 1 {
return vec![1.0];
}
let mut row_counts = vec![0.0_f32; n];
let mut col_counts = vec![0.0_f32; n];
row_counts[0] = 1.0;
col_counts[0] = 1.0;
for _ in 0..self.iterations {
let col_total: f32 = col_counts.iter().sum();
let col_mix: Vec<f32> = col_counts.iter().map(|&c| c / col_total).collect();
let row_br = best_response_row(payoffs, &col_mix);
row_counts[row_br] += 1.0;
let row_total: f32 = row_counts.iter().sum();
let row_mix: Vec<f32> = row_counts.iter().map(|&r| r / row_total).collect();
let col_br = best_response_col(payoffs, &row_mix);
col_counts[col_br] += 1.0;
}
let total: f32 = row_counts.iter().sum();
if total <= 0.0 {
return vec![1.0 / n as f32; n];
}
row_counts.iter().map(|&c| c / total).collect()
}
fn name(&self) -> &'static str {
"fictitious_play"
}
}
#[derive(Debug, Clone)]
pub struct ReplicatorDynamicsMetaSolver {
iterations: usize,
step_size: f32,
}
impl ReplicatorDynamicsMetaSolver {
pub fn new(iterations: usize, step_size: f32) -> Self {
Self { iterations: iterations.max(1), step_size: step_size.max(1e-6) }
}
}
impl Default for ReplicatorDynamicsMetaSolver {
fn default() -> Self {
Self::new(2000, 0.05)
}
}
impl MetaSolver for ReplicatorDynamicsMetaSolver {
fn solve(&self, payoffs: &[Vec<f32>]) -> Vec<f32> {
let n = payoffs.len();
if n == 0 {
return Vec::new();
}
if n == 1 {
return vec![1.0];
}
let mut x = vec![1.0 / n as f32; n];
for _ in 0..self.iterations {
let mut f = vec![0.0_f32; n];
for (i, row) in payoffs.iter().enumerate() {
let mut fi = 0.0_f32;
for (j, &a) in row.iter().enumerate() {
fi += a * x[j];
}
f[i] = fi;
}
let mean_f: f32 = x.iter().zip(f.iter()).map(|(xi, fi)| xi * fi).sum();
let mut new_x: Vec<f32> = x
.iter()
.zip(f.iter())
.map(|(xi, fi)| (xi * (1.0 + self.step_size * (fi - mean_f))).max(0.0))
.collect();
let total: f32 = new_x.iter().sum();
if total <= 1e-12 {
return vec![1.0 / n as f32; n];
}
for v in new_x.iter_mut() {
*v /= total;
}
x = new_x;
}
x
}
fn name(&self) -> &'static str {
"replicator_dynamics"
}
}
#[derive(Debug, Clone)]
pub struct AlphaRankMetaSolver {
pub ranking_intensity_alpha: f32,
pub moran_population_size: u32,
pub max_iterations: usize,
pub tolerance: f32,
pub normalize_payoff_span: bool,
}
impl AlphaRankMetaSolver {
pub fn new(
ranking_intensity_alpha: f32,
moran_population_size: u32,
max_iterations: usize,
tolerance: f32,
) -> Self {
Self {
ranking_intensity_alpha,
moran_population_size: moran_population_size.max(2),
max_iterations: max_iterations.max(1),
tolerance: tolerance.max(1e-12),
normalize_payoff_span: false,
}
}
pub fn with_payoff_span_normalization(mut self, enabled: bool) -> Self {
self.normalize_payoff_span = enabled;
self
}
pub fn solve_n_player_impl(
&self,
payoffs: &[Vec<f32>],
num_agents: usize,
per_role_k: usize,
) -> Vec<f32> {
assert!(num_agents >= 1, "α-rank requires num_agents >= 1");
assert!(per_role_k >= 1, "α-rank requires per_role_k >= 1");
let n_joint = per_role_k.checked_pow(num_agents as u32).expect("k^N overflow");
if payoffs.len() != n_joint {
panic!(
"α-rank: payoffs.len() = {} but expected k^N = {}^{} = {}",
payoffs.len(),
per_role_k,
num_agents,
n_joint
);
}
for (s, row) in payoffs.iter().enumerate() {
assert_eq!(
row.len(),
num_agents,
"α-rank: payoffs[{s}].len() = {} but expected num_agents = {}",
row.len(),
num_agents
);
}
let n_deviations = num_agents * per_role_k.saturating_sub(1);
let per_dev_weight: f32 = if n_deviations > 0 {
1.0_f32 / n_deviations as f32
} else {
0.0
};
let delta_divisor: f32 = if self.normalize_payoff_span {
let mut min_v = f32::INFINITY;
let mut max_v = f32::NEG_INFINITY;
for row in payoffs.iter() {
for &v in row.iter() {
if v < min_v {
min_v = v;
}
if v > max_v {
max_v = v;
}
}
}
let span = max_v - min_v;
if span.is_finite() && span > 1e-12 {
span
} else {
1.0
}
} else {
1.0
};
let mut transitions: Vec<Vec<(usize, f32)>> = Vec::with_capacity(n_joint);
for s in 0..n_joint {
let mut row_edges: Vec<(usize, f32)> = Vec::with_capacity(n_deviations + 1);
let mut self_mass: f32 = 1.0;
let from_payoffs = &payoffs[s];
let s_components = decompose_joint_index(s, num_agents, per_role_k);
for agent in 0..num_agents {
let from_strat = s_components[agent];
for new_strat in 0..per_role_k {
if new_strat == from_strat {
continue;
}
let mut t_components = s_components.clone();
t_components[agent] = new_strat;
let t = compose_joint_index(&t_components, per_role_k);
let to_payoff_a = payoffs[t][agent];
let from_payoff_a = from_payoffs[agent];
let p_fix = moran_fixation_probability(
self.ranking_intensity_alpha,
self.moran_population_size,
(to_payoff_a - from_payoff_a) / delta_divisor,
);
let edge_prob = per_dev_weight * p_fix;
row_edges.push((t, edge_prob));
self_mass -= edge_prob;
}
}
if self_mass < 0.0 {
self_mass = 0.0;
}
row_edges.push((s, self_mass));
let row_sum: f32 = row_edges.iter().map(|(_, p)| *p).sum();
if row_sum > 0.0 {
for (_, p) in row_edges.iter_mut() {
*p /= row_sum;
}
}
transitions.push(row_edges);
}
let mut pi = vec![1.0_f32 / n_joint as f32; n_joint];
let mut pi_next = vec![0.0_f32; n_joint];
for _ in 0..self.max_iterations {
for v in pi_next.iter_mut() {
*v = 0.0;
}
for (s, edges) in transitions.iter().enumerate() {
let pis = pi[s];
if pis == 0.0 {
continue;
}
for &(t, p) in edges {
pi_next[t] += pis * p;
}
}
let mut l1: f32 = 0.0;
for i in 0..n_joint {
l1 += (pi_next[i] - pi[i]).abs();
}
std::mem::swap(&mut pi, &mut pi_next);
let total: f32 = pi.iter().sum();
if total > 0.0 {
for v in pi.iter_mut() {
*v /= total;
}
}
if l1 < self.tolerance {
break;
}
}
pi
}
}
impl Default for AlphaRankMetaSolver {
fn default() -> Self {
Self::new(10.0, 50, 200, 1e-6)
}
}
impl MetaSolver for AlphaRankMetaSolver {
fn solve(&self, payoffs: &[Vec<f32>]) -> Vec<f32> {
let n = payoffs.len();
if n == 0 {
return Vec::new();
}
if n == 1 {
return vec![1.0];
}
let n2 = n * n;
let mut joint_payoffs = vec![vec![0.0_f32; 2]; n2];
#[allow(clippy::needless_range_loop)]
for i in 0..n {
for j in 0..n {
let s = i + j * n;
joint_payoffs[s][0] = payoffs[i][j];
joint_payoffs[s][1] = payoffs[j][i];
}
}
let joint_dist = self.solve_n_player_impl(&joint_payoffs, 2, n);
let mut row_dist = vec![0.0_f32; n];
#[allow(clippy::needless_range_loop)]
for i in 0..n {
for j in 0..n {
row_dist[i] += joint_dist[i + j * n];
}
}
let total: f32 = row_dist.iter().sum();
if total > 0.0 {
for v in row_dist.iter_mut() {
*v /= total;
}
} else {
return vec![1.0 / n as f32; n];
}
row_dist
}
fn solve_n_player(
&self,
payoffs: &[Vec<f32>],
num_agents: usize,
per_role_k: usize,
) -> Vec<f32> {
self.solve_n_player_impl(payoffs, num_agents, per_role_k)
}
fn name(&self) -> &'static str {
"alpha_rank"
}
}
pub(crate) fn decompose_joint_index(s: usize, num_agents: usize, k: usize) -> Vec<usize> {
let mut out = vec![0_usize; num_agents];
let mut rem = s;
for slot in out.iter_mut().take(num_agents) {
*slot = rem % k;
rem /= k;
}
out
}
pub(crate) fn compose_joint_index(components: &[usize], k: usize) -> usize {
let mut s = 0_usize;
let mut radix = 1_usize;
for &c in components {
s += c * radix;
radix *= k;
}
s
}
#[allow(clippy::type_complexity)]
fn select_boundary_to_evaluate(
boundary: &[Vec<usize>],
cap: Option<usize>,
) -> (Vec<Vec<usize>>, Vec<(usize, usize)>) {
let len = boundary.len();
let cap = match cap {
None => return (boundary.to_vec(), Vec::new()),
Some(c) => c.max(1),
};
if len <= cap {
return (boundary.to_vec(), Vec::new());
}
let mut selected: Vec<usize> = Vec::with_capacity(cap);
for j in 0..cap {
let idx = (j * len) / cap;
if selected.last().copied() != Some(idx) {
selected.push(idx);
}
}
let to_evaluate: Vec<Vec<usize>> = selected.iter().map(|&i| boundary[i].clone()).collect();
let mut fill_from: Vec<(usize, usize)> = Vec::with_capacity(len - selected.len());
let mut src = 0_usize; for dst in 0..len {
while src + 1 < selected.len() && selected[src + 1] <= dst {
src += 1;
}
if selected[src] == dst {
continue; }
fill_from.push((dst, src));
}
(to_evaluate, fill_from)
}
#[allow(dead_code)]
fn sigmoid(x: f32) -> f32 {
if x >= 0.0 {
let z = (-x).exp();
1.0 / (1.0 + z)
} else {
let z = x.exp();
z / (1.0 + z)
}
}
fn moran_fixation_probability(alpha: f32, m: u32, delta: f32) -> f32 {
let m_f = m as f32;
let ad = alpha * delta;
if ad.abs() < 1e-9 {
return 1.0 / m_f;
}
let num = 1.0 - (-ad).exp();
let denom = 1.0 - (-m_f * ad).exp();
if denom.abs() < 1e-30 {
return if ad > 0.0 { 1.0 } else { 0.0 };
}
let p = num / denom;
p.clamp(0.0, 1.0)
}
fn best_response_row(payoffs: &[Vec<f32>], col_mix: &[f32]) -> usize {
let mut best_i = 0;
let mut best_val = f32::NEG_INFINITY;
for (i, row) in payoffs.iter().enumerate() {
let mut val = 0.0_f32;
for (j, &p) in col_mix.iter().enumerate() {
val += row[j] * p;
}
if val > best_val {
best_val = val;
best_i = i;
}
}
best_i
}
fn best_response_col(payoffs: &[Vec<f32>], row_mix: &[f32]) -> usize {
let n = payoffs.len();
let mut best_j = 0;
let mut best_val = f32::INFINITY;
#[allow(clippy::needless_range_loop)]
for j in 0..n {
let val: f32 = row_mix.iter().enumerate().map(|(i, &p)| payoffs[i][j] * p).sum();
if val < best_val {
best_val = val;
best_j = j;
}
}
best_j
}
#[derive(Debug, Clone)]
pub struct PsroConfig {
pub max_iterations: usize,
pub max_population_size: usize,
pub br_train_steps_per_iteration: usize,
pub payoff_eval_episodes: usize,
pub max_payoff_evals_per_iteration: Option<usize>,
pub br_reward_scale: f32,
pub seed: u64,
pub serialize_br_updates: bool,
}
impl Default for PsroConfig {
fn default() -> Self {
Self {
max_iterations: 10,
max_population_size: 50,
br_train_steps_per_iteration: 1,
payoff_eval_episodes: 8,
max_payoff_evals_per_iteration: None,
br_reward_scale: 1.0,
seed: 0,
serialize_br_updates: true,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct PsroIterationStats {
pub iteration: usize,
pub population_size: usize,
pub meta_nash_per_agent: Vec<Vec<f32>>,
pub br_stats_per_agent: Vec<Option<JointStats>>,
pub exploitability: f32,
}
impl PsroIterationStats {
pub fn meta_nash_row(&self) -> &[f32] {
self.meta_nash_per_agent.first().map(|v| v.as_slice()).unwrap_or(&[])
}
pub fn meta_nash_col(&self) -> &[f32] {
self.meta_nash_per_agent.get(1).map(|v| v.as_slice()).unwrap_or(&[])
}
}
#[derive(Debug, Clone, Default)]
pub struct PsroStats {
pub iterations: Vec<PsroIterationStats>,
}
#[derive(Debug, Clone, Default)]
pub struct PayoffCache {
cells: Vec<Vec<f32>>,
per_role_k: usize,
num_agents: usize,
pub eval_count: usize,
}
impl PayoffCache {
pub fn new() -> Self {
Self::default()
}
pub fn with_num_agents(num_agents: usize) -> Self {
Self { cells: Vec::new(), per_role_k: 0, num_agents, eval_count: 0 }
}
pub fn per_role_k(&self) -> usize {
self.per_role_k
}
pub fn num_agents(&self) -> usize {
self.num_agents
}
pub fn num_cells(&self) -> usize {
self.cells.len()
}
pub fn get_joint(&self, joint: &[usize]) -> Option<&[f32]> {
if joint.len() != self.num_agents {
return None;
}
for (a, &idx) in joint.iter().enumerate() {
if idx >= self.per_role_k {
return None;
}
let _ = a;
}
let s = compose_joint_index(joint, self.per_role_k);
self.cells.get(s).map(|v| v.as_slice())
}
pub fn payoff_tensor(&self) -> &[Vec<f32>] {
&self.cells
}
pub fn set_cell(&mut self, joint: &[usize], payoffs: Vec<f32>) {
assert_eq!(
joint.len(),
self.num_agents,
"joint strategy length {} must equal num_agents = {}",
joint.len(),
self.num_agents
);
assert_eq!(
payoffs.len(),
self.num_agents,
"payoffs length {} must equal num_agents = {}",
payoffs.len(),
self.num_agents
);
for (a, &idx) in joint.iter().enumerate() {
assert!(
idx < self.per_role_k,
"joint[{a}] = {idx} >= per_role_k = {}",
self.per_role_k
);
}
let s = compose_joint_index(joint, self.per_role_k);
self.cells[s] = payoffs;
self.eval_count += 1;
}
pub fn set_cell_no_count(&mut self, joint: &[usize], payoffs: Vec<f32>) {
assert_eq!(
joint.len(),
self.num_agents,
"joint strategy length {} must equal num_agents = {}",
joint.len(),
self.num_agents
);
assert_eq!(
payoffs.len(),
self.num_agents,
"payoffs length {} must equal num_agents = {}",
payoffs.len(),
self.num_agents
);
for (a, &idx) in joint.iter().enumerate() {
assert!(
idx < self.per_role_k,
"joint[{a}] = {idx} >= per_role_k = {}",
self.per_role_k
);
}
let s = compose_joint_index(joint, self.per_role_k);
self.cells[s] = payoffs;
}
pub fn resize_for_boundary(&mut self, new_per_role_k: usize) {
assert!(
new_per_role_k >= self.per_role_k,
"PayoffCache may only grow; got new_k = {} < per_role_k = {}",
new_per_role_k,
self.per_role_k
);
if new_per_role_k == self.per_role_k {
return;
}
let new_total = new_per_role_k.checked_pow(self.num_agents as u32).expect("k^N overflow");
let mut new_cells = vec![vec![0.0_f32; self.num_agents]; new_total];
if self.per_role_k > 0 {
let old_total = self.cells.len();
for s_old in 0..old_total {
let components = decompose_joint_index(s_old, self.num_agents, self.per_role_k);
let s_new = compose_joint_index(&components, new_per_role_k);
new_cells[s_new] = std::mem::take(&mut self.cells[s_old]);
}
}
self.cells = new_cells;
self.per_role_k = new_per_role_k;
}
pub fn boundary_joint_strategies(&self, agent_index: usize) -> Vec<Vec<usize>> {
let k = self.per_role_k;
let n = self.num_agents;
assert!(agent_index < n);
assert!(k >= 1);
let new_strat = k - 1;
let n_others = n - 1;
let total_others = k.checked_pow(n_others as u32).expect("k^(N-1) overflow");
let mut out = Vec::with_capacity(total_others);
for s in 0..total_others {
let mut joint = vec![0_usize; n];
joint[agent_index] = new_strat;
let mut rem = s;
#[allow(clippy::needless_range_loop)]
for a in 0..n {
if a == agent_index {
continue;
}
joint[a] = rem % k;
rem /= k;
}
out.push(joint);
}
out
}
}
pub struct PsroTrainer<B, P, O, E, FP, FO, FE>
where
B: AutodiffBackend,
P: JointPolicy<B>,
O: Optimizer<P, B>,
E: JointEnv,
FP: Fn(&B::Device, u64) -> P,
FO: Fn() -> BurnOptimizer<B, P, O>,
FE: Fn() -> E,
{
populations: Vec<Vec<P>>,
meta_solver: Box<dyn MetaSolver>,
config: PsroConfig,
joint_config: JointTrainerConfig,
device: B::Device,
policy_factory: FP,
optimizer_factory: FO,
env_factory: FE,
payoff_cache: PayoffCache,
rng: StdRng,
init_counter: u64,
}
impl<B, P, O, E, FP, FO, FE> PsroTrainer<B, P, O, E, FP, FO, FE>
where
B: AutodiffBackend,
P: JointPolicy<B>,
O: Optimizer<P, B>,
E: JointEnv,
FP: Fn(&B::Device, u64) -> P,
FO: Fn() -> BurnOptimizer<B, P, O>,
FE: Fn() -> E,
{
#[allow(clippy::too_many_arguments)]
pub fn new(
config: PsroConfig,
joint_config: JointTrainerConfig,
meta_solver: Box<dyn MetaSolver>,
device: B::Device,
policy_factory: FP,
optimizer_factory: FO,
env_factory: FE,
) -> Result<Self> {
if joint_config.num_agents < 2 {
return Err(anyhow!(
"PsroTrainer requires joint_config.num_agents >= 2 (got {})",
joint_config.num_agents
));
}
let n = joint_config.num_agents;
let base_seed = config.seed;
let populations: Vec<Vec<P>> = (0..n)
.map(|i| {
let s = base_seed.wrapping_add(0x9E37_79B9_u64.wrapping_mul(i as u64));
vec![policy_factory(&device, s)]
})
.collect();
let rng = StdRng::seed_from_u64(config.seed);
Ok(Self {
populations,
meta_solver,
config,
joint_config,
device,
policy_factory,
optimizer_factory,
env_factory,
payoff_cache: PayoffCache::with_num_agents(n),
rng,
init_counter: n as u64,
})
}
fn next_init_seed(&mut self) -> u64 {
let s = self.config.seed.wrapping_add(0x9E37_79B9_u64.wrapping_mul(self.init_counter));
self.init_counter = self.init_counter.wrapping_add(1);
s
}
pub fn populations(&self, agent: usize) -> &[P] {
&self.populations[agent]
}
pub fn population_row(&self) -> &[P] {
&self.populations[0]
}
pub fn population_col(&self) -> &[P] {
&self.populations[1]
}
pub fn payoff_cache(&self) -> &PayoffCache {
&self.payoff_cache
}
pub fn run<F>(&mut self, mut on_iteration: F) -> Result<PsroStats>
where
F: FnMut(&PsroIterationStats, &[&P]),
P: Send + Sync,
E: Send,
FP: Sync,
FO: Sync,
FE: Sync,
B::Device: Sync,
{
let num_agents = self.joint_config.num_agents;
if self.payoff_cache.per_role_k() == 0 {
self.payoff_cache.resize_for_boundary(1);
let initial_joint = vec![0_usize; num_agents];
let initial_payoffs = self.evaluate_payoff_joint(&initial_joint);
self.payoff_cache.set_cell(&initial_joint, initial_payoffs);
}
let mut stats = PsroStats::default();
for iter in 1..=self.config.max_iterations {
if self.populations[0].len() >= self.config.max_population_size {
return Err(anyhow!(
"PSRO population reached max_population_size = {}",
self.config.max_population_size
));
}
let per_agent_marginals = self.solve_per_agent_marginals();
let br_stats = self.train_best_responses_parallel(&per_agent_marginals)?;
let br_stats_per_agent: Vec<Option<JointStats>> =
br_stats.into_iter().map(Some).collect();
let old_k = self.payoff_cache.per_role_k();
let new_k = old_k + 1;
self.payoff_cache.resize_for_boundary(new_k);
let total_new = new_k.checked_pow(num_agents as u32).expect("k^N overflow");
let new_strategy_idx = new_k - 1;
let boundary: Vec<Vec<usize>> = (0..total_new)
.filter_map(|s| {
let components = decompose_joint_index(s, num_agents, new_k);
components.contains(&new_strategy_idx).then_some(components)
})
.collect();
let (to_evaluate, fill_from) =
select_boundary_to_evaluate(&boundary, self.config.max_payoff_evals_per_iteration);
let evaluated = self.evaluate_payoff_boundary_parallel(&to_evaluate);
for (components, payoffs) in to_evaluate.iter().zip(&evaluated) {
self.payoff_cache.set_cell(components, payoffs.clone());
}
for &(dst_idx, src_idx) in &fill_from {
let payoffs = evaluated[src_idx].clone();
self.payoff_cache.set_cell_no_count(&boundary[dst_idx], payoffs);
}
let post_marginals = self.solve_per_agent_marginals();
let exploitability = self.compute_nashconv(&post_marginals);
let iter_stats = PsroIterationStats {
iteration: iter,
population_size: self.populations[0].len(),
meta_nash_per_agent: post_marginals,
br_stats_per_agent,
exploitability,
};
let newest_brs: Vec<&P> = (0..num_agents)
.map(|a| {
self.populations[a].last().expect("population non-empty after BR training")
})
.collect();
on_iteration(&iter_stats, &newest_brs);
stats.iterations.push(iter_stats);
}
Ok(stats)
}
pub fn run_silent(&mut self) -> Result<PsroStats>
where
P: Send + Sync,
E: Send,
FP: Sync,
FO: Sync,
FE: Sync,
B::Device: Sync,
{
self.run(|_, _| {})
}
pub fn current_meta_nash_per_agent(&self) -> Vec<Vec<f32>> {
if self.payoff_cache.per_role_k() == 0 {
return (0..self.joint_config.num_agents).map(|_| vec![1.0]).collect();
}
self.solve_per_agent_marginals()
}
pub fn current_meta_nash(&self) -> Vec<f32> {
self.current_meta_nash_per_agent().into_iter().next().unwrap_or_default()
}
fn solve_per_agent_marginals(&self) -> Vec<Vec<f32>> {
let n = self.joint_config.num_agents;
let k = self.payoff_cache.per_role_k();
if k == 0 {
return (0..n).map(|_| vec![1.0]).collect();
}
if n == 2 {
let mut row_matrix: Vec<Vec<f32>> = vec![vec![0.0_f32; k]; k];
#[allow(clippy::needless_range_loop)]
for i in 0..k {
for j in 0..k {
let s = i + j * k;
row_matrix[i][j] = self.payoff_cache.payoff_tensor()[s][0];
}
}
let row_dist = self.meta_solver.solve(&row_matrix);
let col_dist = row_dist.clone();
return vec![row_dist, col_dist];
}
let joint = self.meta_solver.solve_n_player(self.payoff_cache.payoff_tensor(), n, k);
let mut marginals: Vec<Vec<f32>> = (0..n).map(|_| vec![0.0_f32; k]).collect();
for (s, &mass) in joint.iter().enumerate() {
let components = decompose_joint_index(s, n, k);
for (a, &c) in components.iter().enumerate() {
marginals[a][c] += mass;
}
}
for m in marginals.iter_mut() {
let total: f32 = m.iter().sum();
if total > 0.0 {
for v in m.iter_mut() {
*v /= total;
}
} else {
let uniform = 1.0 / k as f32;
for v in m.iter_mut() {
*v = uniform;
}
}
}
marginals
}
fn compute_nashconv(&self, per_agent_marginals: &[Vec<f32>]) -> f32 {
let n = self.joint_config.num_agents;
let k = self.payoff_cache.per_role_k();
if n == 2 {
let mut row_matrix: Vec<Vec<f32>> = vec![vec![0.0_f32; k]; k];
#[allow(clippy::needless_range_loop)]
for i in 0..k {
for j in 0..k {
let s = i + j * k;
row_matrix[i][j] = self.payoff_cache.payoff_tensor()[s][0];
}
}
return empirical_exploitability(&row_matrix, &per_agent_marginals[0]);
}
let payoffs = self.payoff_cache.payoff_tensor();
let mut nashconv = 0.0_f32;
for i in 0..n {
let mut u_sigma = 0.0_f32;
let mut u_pure = vec![0.0_f32; k];
for (s, agent_payoffs) in payoffs.iter().enumerate() {
let components = decompose_joint_index(s, n, k);
let mut full_prob = 1.0_f32;
for (a, &c) in components.iter().enumerate() {
full_prob *= per_agent_marginals[a][c];
}
u_sigma += full_prob * agent_payoffs[i];
let mut others_prob = 1.0_f32;
for (a, &c) in components.iter().enumerate() {
if a == i {
continue;
}
others_prob *= per_agent_marginals[a][c];
}
let s_i = components[i];
u_pure[s_i] += others_prob * agent_payoffs[i];
}
let max_pure = u_pure.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let gain = (max_pure - u_sigma).max(0.0);
nashconv += gain;
}
nashconv
}
fn train_best_responses_parallel(
&mut self,
per_agent_marginals: &[Vec<f32>],
) -> Result<Vec<JointStats>>
where
P: Send + Sync,
E: Send,
FP: Sync,
FO: Sync,
FE: Sync,
B::Device: Sync,
{
let num_agents = self.joint_config.num_agents;
let mut opp_indices: Vec<Vec<usize>> = Vec::with_capacity(num_agents);
let mut init_seeds: Vec<u64> = Vec::with_capacity(num_agents);
for active_agent in 0..num_agents {
let mut row: Vec<usize> = Vec::with_capacity(num_agents);
for (a, marginal) in per_agent_marginals.iter().enumerate().take(num_agents) {
if a == active_agent {
row.push(0); } else {
row.push(sample_from_mixture(&mut self.rng, marginal));
}
}
opp_indices.push(row);
init_seeds.push(self.next_init_seed());
}
let populations = &self.populations;
let config = &self.config;
let joint_config = &self.joint_config;
let device = &self.device;
let policy_factory = &self.policy_factory;
let optimizer_factory = &self.optimizer_factory;
let env_factory = &self.env_factory;
let run_br = |active_agent: usize| {
train_best_response_pure::<B, P, O, E, _, _, _>(
active_agent,
&opp_indices[active_agent],
init_seeds[active_agent],
populations,
config,
joint_config,
device,
policy_factory,
optimizer_factory,
env_factory,
)
};
let results: Vec<Result<(JointStats, P)>> = if config.serialize_br_updates {
(0..num_agents).map(run_br).collect()
} else {
(0..num_agents).into_par_iter().map(run_br).collect()
};
let mut stats: Vec<JointStats> = Vec::with_capacity(num_agents);
let mut trained_policies: Vec<P> = Vec::with_capacity(num_agents);
for result in results {
let (br_stats, trained) = result?;
stats.push(br_stats);
trained_policies.push(trained);
}
for (active_agent, trained) in trained_policies.into_iter().enumerate() {
self.populations[active_agent].push(trained);
}
Ok(stats)
}
fn evaluate_payoff_joint(&self, joint: &[usize]) -> Vec<f32> {
let num_agents = self.joint_config.num_agents;
assert_eq!(joint.len(), num_agents);
let policies: Vec<P> =
(0..num_agents).map(|a| self.populations[a][joint[a]].clone()).collect();
evaluate_payoff_joint_pure::<B, P, _, _>(
joint,
&self.config,
&policies,
&self.env_factory,
&self.device,
)
}
fn evaluate_payoff_boundary_parallel(&self, boundary: &[Vec<usize>]) -> Vec<Vec<f32>>
where
P: Send + Sync,
E: Send,
FE: Sync,
B::Device: Sync,
{
let num_agents = self.joint_config.num_agents;
let populations = &self.populations;
let config = &self.config;
let env_factory = &self.env_factory;
let device = &self.device;
boundary
.par_iter()
.map(|joint| {
debug_assert_eq!(joint.len(), num_agents);
let policies: Vec<P> =
(0..num_agents).map(|a| populations[a][joint[a]].clone()).collect();
evaluate_payoff_joint_pure::<B, P, _, _>(
joint,
config,
&policies,
env_factory,
device,
)
})
.collect()
}
}
#[allow(clippy::too_many_arguments)]
fn train_best_response_pure<B, P, O, E, FP, FO, FE>(
active_agent: usize,
opp_indices: &[usize],
init_seed: u64,
populations: &[Vec<P>],
config: &PsroConfig,
joint_config: &JointTrainerConfig,
device: &B::Device,
policy_factory: &FP,
optimizer_factory: &FO,
env_factory: &FE,
) -> Result<(JointStats, P)>
where
B: AutodiffBackend,
P: JointPolicy<B> + Clone,
O: Optimizer<P, B>,
E: JointEnv,
FP: Fn(&B::Device, u64) -> P,
FO: Fn() -> BurnOptimizer<B, P, O>,
FE: Fn() -> E,
{
let num_agents = joint_config.num_agents;
debug_assert!(active_agent < num_agents);
let mut policies: Vec<P> = Vec::with_capacity(num_agents);
for (a, population) in populations.iter().enumerate().take(num_agents) {
if a == active_agent {
policies.push(policy_factory(device, init_seed));
} else {
policies.push(population[opp_indices[a]].clone());
}
}
let optimizers: Vec<BurnOptimizer<B, P, O>> =
(0..num_agents).map(|_| optimizer_factory()).collect();
let mut trainer = JointMultiAgentTrainer::<B, P, O>::new(
policies,
optimizers,
joint_config.clone(),
device.clone(),
)?;
let mut rng = StdRng::seed_from_u64(config.seed ^ splitmix64(active_agent as u64));
let active_mask: Vec<bool> = (0..num_agents).map(|i| i == active_agent).collect::<Vec<_>>();
let mut env = env_factory();
let mut last_obs = env.reset_joint(Some(config.seed.wrapping_add(active_agent as u64)));
let mut last_stats = JointStats::zeros(num_agents);
let reward_scale = config.br_reward_scale;
for _ in 0..config.br_train_steps_per_iteration {
let mut rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
if reward_scale != 1.0 {
for agent_rewards in rollout.rewards.iter_mut() {
for r in agent_rewards.iter_mut() {
*r *= reward_scale;
}
}
}
last_stats = trainer.update_with_active_agents(
&rollout,
&active_mask,
&mut rng,
|_features: &[burn::tensor::Tensor<B, 2>]| -> Option<burn::tensor::Tensor<B, 1>> {
None
},
)?;
}
let trained = trainer.policy(active_agent).clone();
Ok((last_stats, trained))
}
fn splitmix64(mut x: u64) -> u64 {
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
x ^ (x >> 31)
}
fn evaluate_payoff_joint_pure<B, P, E, EF>(
joint: &[usize],
config: &PsroConfig,
policies: &[P],
env_factory: &EF,
device: &B::Device,
) -> Vec<f32>
where
B: AutodiffBackend,
P: JointPolicy<B>,
E: JointEnv,
EF: Fn() -> E,
{
let num_agents = joint.len();
let mut env = env_factory();
let mut totals = vec![0.0_f64; num_agents];
let episodes = config.payoff_eval_episodes.max(1);
let mut joint_hash: u64 = 0;
for &c in joint {
joint_hash = joint_hash.wrapping_mul(53).wrapping_add(c as u64);
}
let per_cell_seed = config.seed ^ splitmix64(joint_hash);
let mut rng = StdRng::seed_from_u64(per_cell_seed);
for ep in 0..episodes {
let reset_seed =
config.seed.wrapping_add(joint_hash.wrapping_mul(31).wrapping_add(ep as u64));
let mut last_obs = env.reset_joint(Some(reset_seed));
let mut ep_returns = vec![0.0_f64; num_agents];
for _ in 0..1024 {
let mut actions: Vec<Vec<i64>> = Vec::with_capacity(num_agents);
for (a, obs_a) in last_obs.iter().enumerate().take(num_agents) {
let obs_dim = obs_a.len();
let obs_t = burn::tensor::Tensor::<B, 2>::from_data(
burn::tensor::TensorData::new(obs_a.clone(), [1, obs_dim]),
device,
);
let (a_host, _, _) = policies[a].get_action_host_seeded(obs_t, &mut rng);
let num_dims = policies[a].action_dims_joint().len();
actions.push(a_host[..num_dims].to_vec());
}
let res = env.step_joint(&actions);
for (a, ret) in ep_returns.iter_mut().enumerate().take(num_agents) {
*ret += res.rewards[a] as f64;
}
if res.done {
break;
}
last_obs[..num_agents].clone_from_slice(&res.observations[..num_agents]);
}
for (a, total) in totals.iter_mut().enumerate().take(num_agents) {
*total += ep_returns[a];
}
}
totals.into_iter().map(|t| (t / episodes as f64) as f32).collect()
}
fn sample_from_mixture(rng: &mut StdRng, mix: &[f32]) -> usize {
if mix.is_empty() {
return 0;
}
let u: f32 = rng.random();
let mut acc = 0.0_f32;
for (i, &p) in mix.iter().enumerate() {
acc += p;
if u < acc {
return i;
}
}
mix.len() - 1
}
fn empirical_exploitability(payoffs: &[Vec<f32>], meta_nash: &[f32]) -> f32 {
let n = payoffs.len();
if n == 0 || meta_nash.is_empty() {
return 0.0;
}
let mut max_row = f32::NEG_INFINITY;
let mut sigma_value = 0.0_f32;
for (i, row) in payoffs.iter().enumerate() {
let mut v = 0.0_f32;
for (j, &p) in meta_nash.iter().enumerate() {
v += row[j] * p;
}
if v > max_row {
max_row = v;
}
sigma_value += meta_nash[i] * v;
}
let row_gain = (max_row - sigma_value).max(0.0);
let mut min_col = f32::INFINITY;
#[allow(clippy::needless_range_loop)]
for j in 0..n {
let v: f32 = meta_nash.iter().enumerate().map(|(i, &p)| payoffs[i][j] * p).sum();
if v < min_col {
min_col = v;
}
}
let col_gain = (sigma_value - min_col).max(0.0);
row_gain + col_gain
}
#[cfg(test)]
mod tests {
use burn::{
backend::{Autodiff, NdArray, ndarray::NdArrayDevice},
optim::AdamConfig,
};
use super::*;
use crate::{env::games::matching_pennies::MatchingPennies, policy::mlp::MlpBurnPolicy};
type B = Autodiff<NdArray<f32>>;
fn assert_valid_distribution(dist: &[f32], n_expected: usize) {
assert_eq!(dist.len(), n_expected, "distribution size mismatch");
let total: f32 = dist.iter().sum();
assert!((total - 1.0).abs() < 1e-4, "distribution must sum to 1, got {total}");
for &p in dist {
assert!(p >= -1e-6, "distribution entry must be >= 0, got {p}");
}
}
#[test]
fn test_uniform_meta_solver_3x3() {
let solver = UniformMetaSolver;
let payoffs = vec![vec![1.0, -1.0, 0.0]; 3];
let dist = solver.solve(&payoffs);
assert_valid_distribution(&dist, 3);
for &p in &dist {
assert!((p - 1.0 / 3.0).abs() < 1e-6, "uniform should be 1/3, got {p}");
}
}
#[test]
fn test_uniform_meta_solver_is_payoff_independent() {
let solver = UniformMetaSolver;
let payoffs_a = vec![vec![5.0, -3.0], vec![-3.0, 5.0]];
let payoffs_b = vec![vec![0.1, -0.1], vec![-0.1, 0.1]];
let a = solver.solve(&payoffs_a);
let b = solver.solve(&payoffs_b);
assert_eq!(a, b, "uniform must ignore payoffs");
}
fn matching_pennies_payoff() -> Vec<Vec<f32>> {
vec![vec![1.0, -1.0], vec![-1.0, 1.0]]
}
#[test]
fn test_fictitious_play_matching_pennies() {
let solver = FictitiousPlayMetaSolver::new(2000);
let dist = solver.solve(&matching_pennies_payoff());
assert_valid_distribution(&dist, 2);
for &p in &dist {
assert!((p - 0.5).abs() < 0.05, "expected ~0.5, got {p}");
}
}
#[test]
fn test_replicator_dynamics_matching_pennies() {
let solver = ReplicatorDynamicsMetaSolver::new(5000, 0.05);
let dist = solver.solve(&matching_pennies_payoff());
assert_valid_distribution(&dist, 2);
for &p in &dist {
assert!((p - 0.5).abs() < 0.05, "expected ~0.5, got {p}");
}
}
#[test]
fn test_meta_solvers_handle_n_eq_1() {
let payoffs = vec![vec![0.5]];
for solver in [
Box::new(UniformMetaSolver) as Box<dyn MetaSolver>,
Box::new(FictitiousPlayMetaSolver::default()) as Box<dyn MetaSolver>,
Box::new(ReplicatorDynamicsMetaSolver::default()) as Box<dyn MetaSolver>,
] {
let dist = solver.solve(&payoffs);
assert_eq!(dist, vec![1.0], "{} failed on n=1", solver.name());
}
}
#[test]
fn test_meta_solvers_handle_n_eq_0() {
let payoffs: Vec<Vec<f32>> = Vec::new();
for solver in [
Box::new(FictitiousPlayMetaSolver::default()) as Box<dyn MetaSolver>,
Box::new(ReplicatorDynamicsMetaSolver::default()) as Box<dyn MetaSolver>,
] {
let dist = solver.solve(&payoffs);
assert!(dist.is_empty(), "{} should return empty for n=0", solver.name());
}
}
#[test]
fn test_fictitious_play_dominated_strategy() {
let payoffs = vec![vec![1.0, 2.0], vec![-1.0, -2.0]];
let solver = FictitiousPlayMetaSolver::new(1000);
let dist = solver.solve(&payoffs);
assert_valid_distribution(&dist, 2);
assert!(dist[0] > 0.95, "row 0 dominant, expected mass ~1.0, got {}", dist[0]);
}
fn three_player_rps_payoffs() -> Vec<Vec<f32>> {
fn beats(a: usize, b: usize) -> bool {
(a == 0 && b == 2) || (a == 1 && b == 0) || (a == 2 && b == 1)
}
let mut out = Vec::with_capacity(27);
for s in 0..27 {
let s0 = s % 3;
let s1 = (s / 3) % 3;
let s2 = (s / 9) % 3;
let strategies = [s0, s1, s2];
let mut row = vec![0.0_f32; 3];
for i in 0..3 {
let mut wins = 0;
let mut losses = 0;
for j in 0..3 {
if i == j {
continue;
}
if beats(strategies[i], strategies[j]) {
wins += 1;
} else if beats(strategies[j], strategies[i]) {
losses += 1;
}
}
row[i] = (wins - losses) as f32;
}
out.push(row);
}
out
}
#[test]
fn test_alpha_rank_three_player_rps_per_agent_marginal_is_uniform() {
let payoffs = three_player_rps_payoffs();
let solver = AlphaRankMetaSolver::default();
let dist = solver.solve_n_player(&payoffs, 3, 3);
assert_eq!(dist.len(), 27, "α-rank should return 27-d distribution for 3-player RPS");
let total: f32 = dist.iter().sum();
assert!((total - 1.0).abs() < 1e-4, "distribution must sum to 1, got {total}");
for agent in 0..3 {
let mut marginal = [0.0_f32; 3];
for (s, &mass) in dist.iter().enumerate().take(27) {
let components = decompose_joint_index(s, 3, 3);
marginal[components[agent]] += mass;
}
let target = 1.0 / 3.0;
for (i, &p) in marginal.iter().enumerate() {
assert!(
(p - target).abs() < 1e-2,
"α-rank 3-player RPS agent {agent} marginal[{i}] = {p}, expected ≈ {target}; \
deviation {} exceeds 1e-2",
(p - target).abs()
);
}
}
}
#[test]
fn test_alpha_rank_three_player_rps_diagonal_orbit_equal_mass() {
let payoffs = three_player_rps_payoffs();
let solver = AlphaRankMetaSolver::default();
let dist = solver.solve_n_player(&payoffs, 3, 3);
let diag_indices = [0_usize, 13, 26];
let masses: Vec<f32> = diag_indices.iter().map(|&i| dist[i]).collect();
for i in 1..3 {
assert!(
(masses[i] - masses[0]).abs() < 5e-3,
"RPS diagonal orbit not equal-mass: m[0]={}, m[{i}]={}",
masses[0],
masses[i]
);
}
}
#[test]
fn test_alpha_rank_solve_returns_valid_distribution_on_random_4x4() {
use rand::{Rng, SeedableRng, rngs::StdRng};
let solver = AlphaRankMetaSolver::default();
for seed in 0..5_u64 {
let mut rng = StdRng::seed_from_u64(seed);
let payoffs: Vec<Vec<f32>> = (0..4)
.map(|_| (0..4).map(|_| rng.random_range(-1.0..1.0_f32)).collect())
.collect();
let dist = solver.solve(&payoffs);
assert_eq!(dist.len(), 4, "expected 4-d distribution");
let total: f32 = dist.iter().sum();
assert!(
(total - 1.0).abs() < 1e-4,
"α-rank seed={seed}: distribution must sum to 1.0 ± 1e-4, got {total}"
);
for (i, &p) in dist.iter().enumerate() {
assert!(p >= -1e-6, "α-rank seed={seed}: entry {i} must be non-negative, got {p}");
}
}
}
#[test]
fn test_alpha_rank_handles_n_eq_1_and_n_eq_0() {
let solver = AlphaRankMetaSolver::default();
let dist_1 = solver.solve(&[vec![0.5]]);
assert_eq!(dist_1, vec![1.0], "α-rank should return [1.0] on n=1");
let dist_0: Vec<Vec<f32>> = Vec::new();
let d = solver.solve(&dist_0);
assert!(d.is_empty(), "α-rank should return empty on n=0");
}
#[test]
fn test_alpha_rank_strict_dominance_concentrates_mass() {
let payoffs = vec![vec![2.0, 2.0], vec![-2.0, -2.0]];
let solver = AlphaRankMetaSolver::default();
let dist = solver.solve(&payoffs);
assert!(
dist[0] > 0.9,
"α-rank should concentrate on dominant strategy 0, got dist = {dist:?}"
);
}
#[test]
fn test_alpha_rank_span_normalization_default_off_is_bit_identical() {
use rand::{Rng, SeedableRng, rngs::StdRng};
for seed in 0..5_u64 {
let mut rng = StdRng::seed_from_u64(seed);
let payoffs: Vec<Vec<f32>> = (0..4)
.map(|_| (0..4).map(|_| rng.random_range(-5.0..5.0_f32)).collect())
.collect();
let default_solver = AlphaRankMetaSolver::default();
let explicit_off = AlphaRankMetaSolver::default().with_payoff_span_normalization(false);
assert_eq!(
default_solver.solve(&payoffs),
explicit_off.solve(&payoffs),
"default solver must equal explicitly-disabled span normalization (seed {seed})"
);
}
}
#[test]
fn test_alpha_rank_span_normalization_is_magnitude_invariant() {
let small = vec![vec![2.0_f32, -1.0], vec![1.0, -2.0]];
let large = vec![vec![700.0_f32, -350.0], vec![350.0, -700.0]];
let plain = AlphaRankMetaSolver::default();
let plain_small = plain.solve(&small);
assert!(
plain_small[0] > 0.9,
"unit-scale α-rank should concentrate on dominant strategy 0, got {plain_small:?}"
);
let plain_large = plain.solve(&large);
assert!(
plain_large[0] < 0.6,
"unnormalized large-scale α-rank should LOSE the dominance signal \
(saturation bug, issue #215), got {plain_large:?}"
);
let norm = AlphaRankMetaSolver::default().with_payoff_span_normalization(true);
let dist_small = norm.solve(&small);
let dist_large = norm.solve(&large);
for i in 0..2 {
assert!(
(dist_small[i] - dist_large[i]).abs() < 1e-3,
"span-normalized α-rank should be magnitude-invariant: \
small={dist_small:?} large={dist_large:?}"
);
}
assert!(
dist_large[0] > 0.9,
"span-normalized large-scale α-rank should concentrate on dominant strategy 0, \
got {dist_large:?}"
);
}
#[test]
fn test_alpha_rank_span_normalization_handles_flat_payoffs() {
let flat = vec![vec![3.0_f32, 3.0], vec![3.0, 3.0]];
let norm = AlphaRankMetaSolver::default().with_payoff_span_normalization(true);
let dist = norm.solve(&flat);
let total: f32 = dist.iter().sum();
assert!((total - 1.0).abs() < 1e-4, "flat-payoff dist must be normalized, got {dist:?}");
for &p in &dist {
assert!(p.is_finite(), "flat-payoff dist must be finite, got {dist:?}");
assert!(
(p - 0.5).abs() < 1e-3,
"flat payoffs should give uniform stationary dist, got {dist:?}"
);
}
}
#[test]
fn test_payoff_cache_grows_correctly() {
let mut cache = PayoffCache::with_num_agents(2);
cache.resize_for_boundary(1);
cache.set_cell(&[0, 0], vec![0.0, 0.0]);
assert_eq!(cache.per_role_k(), 1);
assert_eq!(cache.eval_count, 1);
cache.resize_for_boundary(2);
cache.set_cell(&[1, 0], vec![0.5, -0.5]);
cache.set_cell(&[0, 1], vec![-0.5, 0.5]);
cache.set_cell(&[1, 1], vec![0.0, 0.0]);
assert_eq!(cache.per_role_k(), 2);
assert_eq!(cache.eval_count, 1 + 3, "k=1→2 adds 3 new cells (4-1)");
let payoffs = cache.payoff_tensor();
let row_matrix: Vec<Vec<f32>> = (0..2)
.map(|i| (0..2).map(|j| payoffs[i + j * 2][0]).collect::<Vec<_>>())
.collect();
assert_eq!(row_matrix, vec![vec![0.0, -0.5], vec![0.5, 0.0]]);
cache.resize_for_boundary(3);
for joint in cache.clone().boundary_joint_strategies(0) {
cache.set_cell(&joint, vec![0.0, 0.0]);
}
for joint in cache.clone().boundary_joint_strategies(1) {
if cache.get_joint(&joint).is_none_or(|p| p == [0.0, 0.0]) && joint[0] != 2 {
cache.set_cell(&joint, vec![0.0, 0.0]);
}
}
assert_eq!(cache.eval_count, 1 + 3 + 5);
}
#[test]
fn test_payoff_cache_get_in_bounds() {
let mut cache = PayoffCache::with_num_agents(2);
cache.resize_for_boundary(1);
cache.set_cell(&[0, 0], vec![0.0, 0.0]);
cache.resize_for_boundary(2);
cache.set_cell(&[1, 0], vec![0.7, -0.7]);
cache.set_cell(&[0, 1], vec![-0.7, 0.7]);
cache.set_cell(&[1, 1], vec![0.0, 0.0]);
assert_eq!(cache.get_joint(&[0, 1]).map(|p| p[0]), Some(-0.7));
assert_eq!(cache.get_joint(&[1, 0]).map(|p| p[0]), Some(0.7));
assert_eq!(cache.get_joint(&[0, 0]).map(|p| p[0]), Some(0.0));
assert_eq!(cache.get_joint(&[1, 1]).map(|p| p[0]), Some(0.0));
assert_eq!(cache.get_joint(&[2, 0]), None);
}
#[test]
fn test_exploitability_on_pure_nash_is_zero() {
let payoffs = vec![vec![1.0, 2.0], vec![-1.0, -2.0]];
let meta_nash = vec![1.0, 0.0];
let expl = empirical_exploitability(&payoffs, &meta_nash);
assert!(expl < 1e-6, "expected ~0 exploitability, got {expl}");
}
#[test]
fn test_exploitability_on_matching_pennies_uniform_is_zero() {
let payoffs = matching_pennies_payoff();
let meta_nash = vec![0.5, 0.5];
let expl = empirical_exploitability(&payoffs, &meta_nash);
assert!(
expl < 1e-5,
"uniform on matching-pennies should have 0 exploitability, got {expl}"
);
}
#[test]
fn test_exploitability_off_equilibrium_is_positive() {
let payoffs = matching_pennies_payoff();
let meta_nash = vec![1.0, 0.0]; let expl = empirical_exploitability(&payoffs, &meta_nash);
assert!(expl > 0.5, "off-equilibrium should be exploitable, got {expl}");
}
#[allow(clippy::type_complexity)]
fn build_matching_pennies_trainer(
meta_solver: Box<dyn MetaSolver>,
max_iterations: usize,
) -> PsroTrainer<
B,
MlpBurnPolicy<B>,
burn::optim::adaptor::OptimizerAdaptor<burn::optim::Adam, MlpBurnPolicy<B>, B>,
MatchingPennies,
impl Fn(&NdArrayDevice, u64) -> MlpBurnPolicy<B>,
impl Fn() -> BurnOptimizer<
B,
MlpBurnPolicy<B>,
burn::optim::adaptor::OptimizerAdaptor<burn::optim::Adam, MlpBurnPolicy<B>, B>,
>,
impl Fn() -> MatchingPennies,
> {
let device: NdArrayDevice = Default::default();
let psro_config = PsroConfig {
max_iterations,
max_population_size: 50,
br_train_steps_per_iteration: 2,
payoff_eval_episodes: 4,
max_payoff_evals_per_iteration: None,
br_reward_scale: 1.0,
seed: 0,
serialize_br_updates: true,
};
let joint_config = JointTrainerConfig {
num_agents: 2,
rollout_steps: 32,
n_epochs: 1,
minibatch_size: 32,
..Default::default()
};
PsroTrainer::new(
psro_config,
joint_config,
meta_solver,
device,
|dev: &NdArrayDevice, seed: u64| {
MlpBurnPolicy::<B>::new_seeded(
MatchingPennies::OBS_DIM,
MatchingPennies::ACTION_DIM,
16,
seed,
dev,
)
},
|| {
let inner = AdamConfig::new().init();
BurnOptimizer::new(inner, 1e-3)
},
MatchingPennies::new,
)
.expect("PsroTrainer::new should succeed for 2-agent config")
}
#[test]
fn test_psro_runs_on_matching_pennies() {
let mut trainer =
build_matching_pennies_trainer(Box::new(FictitiousPlayMetaSolver::new(500)), 3);
let stats = trainer.run_silent().expect("PSRO run should not error");
assert_eq!(stats.iterations.len(), 3, "should record 3 iterations");
for (k, it) in stats.iterations.iter().enumerate() {
assert_eq!(it.iteration, k + 1);
assert_eq!(it.population_size, k + 2, "population grows by 1 per iter");
assert_valid_distribution(it.meta_nash_row(), it.population_size);
assert_valid_distribution(it.meta_nash_col(), it.population_size);
assert!(it.exploitability.is_finite());
assert!(it.exploitability >= 0.0, "exploitability must be >= 0");
}
}
#[test]
fn test_psro_run_callback_fires_per_iteration() {
let max_iterations = 4;
let mut trainer = build_matching_pennies_trainer(
Box::new(FictitiousPlayMetaSolver::new(500)),
max_iterations,
);
let mut observed: Vec<usize> = Vec::new();
let stats = trainer
.run(|it, _brs| observed.push(it.iteration))
.expect("PSRO run should not error");
assert_eq!(
observed.len(),
max_iterations,
"callback should fire exactly max_iterations times"
);
let expected: Vec<usize> = (1..=max_iterations).collect();
assert_eq!(
observed, expected,
"callback iteration indices must be monotonically increasing 1..=max_iterations"
);
let from_history: Vec<usize> = stats.iterations.iter().map(|s| s.iteration).collect();
assert_eq!(observed, from_history, "callback indices must match the pushed history order");
}
#[test]
fn test_psro_run_silent_records_full_history() {
let max_iterations = 3;
let mut trainer = build_matching_pennies_trainer(
Box::new(FictitiousPlayMetaSolver::new(500)),
max_iterations,
);
let stats = trainer.run_silent().expect("PSRO run_silent should not error");
assert_eq!(stats.iterations.len(), max_iterations);
}
#[test]
fn test_psro_checkpoint_callback_fires_at_intervals() {
let max_iterations = 6;
const CHECKPOINT_INTERVAL: usize = 2;
let mut trainer = build_matching_pennies_trainer(
Box::new(FictitiousPlayMetaSolver::new(500)),
max_iterations,
);
let num_agents = 2;
let mut checkpoint_iters: Vec<usize> = Vec::new();
let mut checkpoint_logits: Vec<Vec<(Vec<f32>, Vec<f32>)>> = Vec::new();
trainer
.run(|it, brs| {
assert_eq!(brs.len(), num_agents, "callback must receive one newest BR per agent");
if it.iteration % CHECKPOINT_INTERVAL == 0 {
checkpoint_iters.push(it.iteration);
let per_agent: Vec<(Vec<f32>, Vec<f32>)> = brs
.iter()
.map(|br| {
let original = read_policy_weight(br);
let cloned = (**br).clone();
let cloned_logits = read_policy_weight(&cloned);
(original, cloned_logits)
})
.collect();
checkpoint_logits.push(per_agent);
}
})
.expect("PSRO run should not error");
assert_eq!(
checkpoint_iters,
vec![2, 4, 6],
"checkpoint must fire on every CHECKPOINT_INTERVAL-th iteration"
);
assert_eq!(checkpoint_logits.len(), max_iterations / CHECKPOINT_INTERVAL);
for per_agent in &checkpoint_logits {
assert_eq!(per_agent.len(), num_agents);
for (original, cloned) in per_agent {
assert_eq!(
original, cloned,
"checkpointed BR clone must produce identical logits (save_file round-trip)"
);
}
}
let final_checkpoint = checkpoint_logits.last().expect("at least one checkpoint");
for (a, (checkpointed_logits, _)) in final_checkpoint.iter().enumerate().take(num_agents) {
let pop_last = trainer.populations(a).last().expect("non-empty population");
let from_accessor = read_policy_weight(pop_last);
assert_eq!(
checkpointed_logits, &from_accessor,
"checkpointed BR for agent {a} must equal populations(a).last() logits"
);
}
}
fn read_policy_weight(policy: &MlpBurnPolicy<B>) -> Vec<f32> {
let device: NdArrayDevice = Default::default();
let obs = burn::tensor::Tensor::<B, 2>::zeros([1, 1], &device);
let (logits, _) = policy.forward(obs);
logits.into_data().to_vec().expect("logits to_vec")
}
#[test]
fn test_psro_freeze_n_minus_1_preserves_frozen_params() {
let device: NdArrayDevice = Default::default();
let pol_a = MlpBurnPolicy::<B>::new(1, 2, 8, &device);
let pol_b = MlpBurnPolicy::<B>::new(1, 2, 8, &device);
let opt_a = BurnOptimizer::<B, MlpBurnPolicy<B>, _>::new(AdamConfig::new().init(), 1e-2);
let opt_b = BurnOptimizer::<B, MlpBurnPolicy<B>, _>::new(AdamConfig::new().init(), 1e-2);
let joint_config = JointTrainerConfig {
num_agents: 2,
rollout_steps: 32,
n_epochs: 1,
minibatch_size: 32,
..Default::default()
};
let mut trainer = JointMultiAgentTrainer::<B, MlpBurnPolicy<B>, _>::new(
vec![pol_a.clone(), pol_b.clone()],
vec![opt_a, opt_b],
joint_config,
device,
)
.unwrap();
let frozen_before = read_policy_weight(trainer.policy(0));
let active_before = read_policy_weight(trainer.policy(1));
let mut env = MatchingPennies::new();
let mut last_obs = env.reset_joint(None);
let mut rng = StdRng::seed_from_u64(0);
let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
let active_mask = vec![false, true];
trainer
.update_with_active_agents(
&rollout,
&active_mask,
&mut rng,
|_features: &[burn::tensor::Tensor<B, 2>]| -> Option<burn::tensor::Tensor<B, 1>> {
None
},
)
.expect("update should not error");
let frozen_after = read_policy_weight(trainer.policy(0));
let active_after = read_policy_weight(trainer.policy(1));
assert_eq!(frozen_before.len(), frozen_after.len(), "weight buffer size changed");
for (b, a) in frozen_before.iter().zip(frozen_after.iter()) {
assert!(
(a - b).abs() < 1e-9,
"frozen agent params changed: {b} -> {a} (delta {})",
a - b
);
}
let mut any_diff = false;
for (b, a) in active_before.iter().zip(active_after.iter()) {
if (a - b).abs() > 1e-9 {
any_diff = true;
break;
}
}
assert!(any_diff, "active agent params should have changed");
}
#[test]
fn test_payoff_cache_only_evaluates_new_boundary() {
let k = 3;
let mut trainer =
build_matching_pennies_trainer(Box::new(FictitiousPlayMetaSolver::new(200)), k);
trainer.run_silent().expect("PSRO run should not error");
let expected = 1 + k * k + 2 * k;
assert_eq!(
trainer.payoff_cache.eval_count, expected,
"payoff cache should only evaluate new boundary cells (N=2 formula 1 + K² + 2K)"
);
}
#[test]
fn test_nashconv_n2_fast_path_matches_legacy_on_uniform() {
let payoffs = matching_pennies_payoff();
let meta_nash = vec![0.5, 0.5];
let expl_legacy = empirical_exploitability(&payoffs, &meta_nash);
assert!(expl_legacy < 1e-5);
}
#[test]
fn test_payoff_cell_eval_is_order_independent() {
let device: NdArrayDevice = Default::default();
let psro_config = PsroConfig {
max_iterations: 1,
max_population_size: 50,
br_train_steps_per_iteration: 2,
payoff_eval_episodes: 4,
max_payoff_evals_per_iteration: None,
br_reward_scale: 1.0,
seed: 12345,
serialize_br_updates: true,
};
let joint_config = JointTrainerConfig {
num_agents: 2,
rollout_steps: 32,
n_epochs: 1,
minibatch_size: 32,
..Default::default()
};
let mut trainer = PsroTrainer::new(
psro_config,
joint_config,
Box::new(FictitiousPlayMetaSolver::new(200)) as Box<dyn MetaSolver>,
device,
|dev: &NdArrayDevice, seed: u64| {
MlpBurnPolicy::<B>::new_seeded(
MatchingPennies::OBS_DIM,
MatchingPennies::ACTION_DIM,
16,
seed,
dev,
)
},
|| BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
MatchingPennies::new,
)
.expect("PsroTrainer::new should succeed");
trainer.run_silent().expect("PSRO run should not error");
assert!(trainer.populations(0).len() >= 2, "need >=2 policies per agent");
assert!(trainer.populations(1).len() >= 2, "need >=2 policies per agent");
let joints: Vec<Vec<usize>> = vec![vec![0, 0], vec![1, 0], vec![0, 1], vec![1, 1]];
let forward: Vec<Vec<f32>> =
joints.iter().map(|j| trainer.evaluate_payoff_joint(j)).collect();
let reverse: Vec<Vec<f32>> = joints
.iter()
.rev()
.map(|j| (j.clone(), trainer.evaluate_payoff_joint(j)))
.collect::<Vec<_>>()
.into_iter()
.rev()
.map(|(_, v)| v)
.collect();
assert_eq!(
forward, reverse,
"payoff cells must be bit-identical regardless of evaluation order"
);
let once = trainer.evaluate_payoff_joint(&[1, 0]);
let twice = trainer.evaluate_payoff_joint(&[1, 0]);
assert_eq!(once, twice, "re-evaluating the same cell must be bit-identical");
assert_eq!(once, forward[1], "single-cell value must match the swept value");
}
#[test]
fn test_payoff_boundary_parallel_matches_serial_bit_identically() {
let device: NdArrayDevice = Default::default();
let psro_config = PsroConfig {
max_iterations: 1,
max_population_size: 50,
br_train_steps_per_iteration: 2,
payoff_eval_episodes: 4,
max_payoff_evals_per_iteration: None,
br_reward_scale: 1.0,
seed: 0xC0FF_EE12,
serialize_br_updates: true,
};
let joint_config = JointTrainerConfig {
num_agents: 2,
rollout_steps: 32,
n_epochs: 1,
minibatch_size: 32,
..Default::default()
};
let mut trainer = PsroTrainer::new(
psro_config,
joint_config,
Box::new(FictitiousPlayMetaSolver::new(200)) as Box<dyn MetaSolver>,
device,
|dev: &NdArrayDevice, seed: u64| {
MlpBurnPolicy::<B>::new_seeded(
MatchingPennies::OBS_DIM,
MatchingPennies::ACTION_DIM,
16,
seed,
dev,
)
},
|| BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
MatchingPennies::new,
)
.expect("PsroTrainer::new should succeed");
trainer.run_silent().expect("PSRO run should not error");
let k = trainer.populations(0).len();
assert!(k >= 2, "need >=2 policies per agent to form a non-trivial slab");
let new_strategy_idx = k - 1;
let total = k.checked_pow(2).expect("k^2 overflow");
let boundary: Vec<Vec<usize>> = (0..total)
.filter_map(|s| {
let c = decompose_joint_index(s, 2, k);
c.contains(&new_strategy_idx).then_some(c)
})
.collect();
assert!(!boundary.is_empty(), "boundary slab must be non-empty");
let serial: Vec<Vec<f32>> =
boundary.iter().map(|j| trainer.evaluate_payoff_joint(j)).collect();
let parallel = trainer.evaluate_payoff_boundary_parallel(&boundary);
assert_eq!(
serial, parallel,
"rayon-parallel boundary payoff must be bit-identical to the serial sweep"
);
let populations = &trainer.populations;
let config = &trainer.config;
let env_factory = &trainer.env_factory;
let device = &trainer.device;
for threads in [1_usize, 4] {
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.build()
.expect("build rayon pool");
let got: Vec<Vec<f32>> = pool.install(|| {
boundary
.par_iter()
.map(|joint| {
let policies: Vec<MlpBurnPolicy<B>> =
(0..2).map(|a| populations[a][joint[a]].clone()).collect();
evaluate_payoff_joint_pure::<B, _, _, _>(
joint,
config,
&policies,
env_factory,
device,
)
})
.collect()
});
assert_eq!(
serial, got,
"parallel payoff must be bit-identical to serial with {threads} thread(s)"
);
}
}
#[cfg(test)]
fn psro_populations_under_threads(
threads: usize,
max_iterations: usize,
rollout_steps: usize,
hidden: usize,
br_train_steps: usize,
payoff_eval_episodes: usize,
) -> Vec<Vec<Vec<f32>>> {
let device: NdArrayDevice = Default::default();
let psro_config = PsroConfig {
max_iterations,
max_population_size: 50,
br_train_steps_per_iteration: br_train_steps,
payoff_eval_episodes,
max_payoff_evals_per_iteration: None,
br_reward_scale: 1.0,
seed: 0x5EED_2323,
serialize_br_updates: true,
};
let joint_config = JointTrainerConfig {
num_agents: 2,
rollout_steps,
n_epochs: 1,
minibatch_size: rollout_steps.max(1),
..Default::default()
};
let run = move || -> Vec<Vec<Vec<f32>>> {
let mut trainer = PsroTrainer::new(
psro_config.clone(),
joint_config.clone(),
Box::new(FictitiousPlayMetaSolver::new(200)) as Box<dyn MetaSolver>,
device,
move |dev: &NdArrayDevice, seed: u64| {
MlpBurnPolicy::<B>::new_seeded(
MatchingPennies::OBS_DIM,
MatchingPennies::ACTION_DIM,
hidden,
seed,
dev,
)
},
|| BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
MatchingPennies::new,
)
.expect("PsroTrainer::new should succeed");
trainer.run_silent().expect("PSRO run should not error");
let num_agents = 2;
(0..num_agents)
.map(|a| trainer.populations(a).iter().map(read_policy_weight).collect::<Vec<_>>())
.collect()
};
if threads == 0 {
run()
} else {
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.build()
.expect("build rayon pool");
pool.install(run)
}
}
#[test]
#[ignore = "runs full PSRO trainers under rayon; spin-contends on 2-core CI — opt in with --ignored"]
fn test_best_response_parallel_smoke() {
let a = psro_populations_under_threads(0, 2, 8, 4, 1, 1);
let b = psro_populations_under_threads(0, 2, 8, 4, 1, 1);
assert!(
a[0].len() >= 2,
"expected populations to grow over the iterations (got {})",
a[0].len()
);
assert_eq!(a, b, "PSRO best-response output must be deterministic for a fixed seed");
}
#[test]
#[ignore = "multi-iteration PSRO thread-count-invariance run; opt in with --ignored (prefer --release)"]
fn test_best_response_parallel_thread_count_invariant_thorough() {
let one = psro_populations_under_threads(1, 3, 32, 16, 2, 4);
let four = psro_populations_under_threads(4, 3, 32, 16, 2, 4);
assert!(
one[0].len() >= 4,
"expected populations to grow over 3 iterations (got {})",
one[0].len()
);
assert_eq!(
one, four,
"PSRO best-response output must be byte-identical across thread counts (1 vs 4)"
);
}
#[test]
fn test_splitmix64_distinguishes_neighbours() {
let a = splitmix64(0);
let b = splitmix64(1);
let c = splitmix64(2);
assert_ne!(a, b);
assert_ne!(b, c);
assert_ne!(a, c);
assert_eq!(a, splitmix64(0));
}
#[test]
fn test_select_boundary_to_evaluate() {
let make = |n: usize| -> Vec<Vec<usize>> { (0..n).map(|i| vec![i]).collect() };
let b = make(5);
let (to_eval, fill) = select_boundary_to_evaluate(&b, None);
assert_eq!(to_eval, b);
assert!(fill.is_empty());
let (to_eval, fill) = select_boundary_to_evaluate(&b, Some(5));
assert_eq!(to_eval, b);
assert!(fill.is_empty());
let (to_eval, fill) = select_boundary_to_evaluate(&b, Some(99));
assert_eq!(to_eval, b);
assert!(fill.is_empty());
let b = make(6);
let (to_eval, fill) = select_boundary_to_evaluate(&b, Some(3));
assert_eq!(to_eval, vec![vec![0], vec![2], vec![4]]);
assert_eq!(fill, vec![(1, 0), (3, 1), (5, 2)]);
let selected_dsts: std::collections::BTreeSet<usize> =
[0_usize, 2, 4].into_iter().collect();
let fill_dsts: std::collections::BTreeSet<usize> = fill.iter().map(|&(d, _)| d).collect();
let mut all: std::collections::BTreeSet<usize> = selected_dsts.clone();
all.extend(&fill_dsts);
assert_eq!(all, (0..6).collect());
assert!(selected_dsts.is_disjoint(&fill_dsts));
let (to_eval, fill) = select_boundary_to_evaluate(&b, Some(0));
assert_eq!(to_eval, vec![vec![0]]);
assert_eq!(fill, vec![(1, 0), (2, 0), (3, 0), (4, 0), (5, 0)]);
let again = select_boundary_to_evaluate(&b, Some(3));
assert_eq!(again, select_boundary_to_evaluate(&b, Some(3)));
}
#[test]
fn test_subsampling_cap_unreached_is_bit_identical_to_uncapped() {
let run = |cap: Option<usize>| -> (Vec<Vec<f32>>, usize, Vec<f32>) {
let mut trainer =
build_matching_pennies_trainer(Box::new(FictitiousPlayMetaSolver::new(200)), 3);
trainer.config.max_payoff_evals_per_iteration = cap;
let stats = trainer.run_silent().expect("PSRO run should not error");
let tensor = trainer.payoff_cache.payoff_tensor().to_vec();
let evals = trainer.payoff_cache.eval_count;
let trace: Vec<f32> = stats.iterations.iter().map(|s| s.exploitability).collect();
(tensor, evals, trace)
};
let (tensor_none, evals_none, trace_none) = run(None);
let (tensor_big, evals_big, trace_big) = run(Some(1_000));
let (tensor_max, evals_max, trace_max) = run(Some(usize::MAX));
assert_eq!(tensor_none, tensor_big, "payoff tensor: None vs large cap must be identical");
assert_eq!(tensor_none, tensor_max, "payoff tensor: None vs MAX cap must be identical");
assert_eq!(evals_none, evals_big, "eval_count: None vs large cap must be identical");
assert_eq!(evals_none, evals_max, "eval_count: None vs MAX cap must be identical");
assert_eq!(trace_none, trace_big, "exploitability trace: None vs large cap must match");
assert_eq!(trace_none, trace_max, "exploitability trace: None vs MAX cap must match");
}
#[test]
fn test_subsampling_cap_bounds_evals_and_fills_tensor() {
let run_capped = || -> (usize, Vec<Vec<f32>>) {
let mut trainer =
build_matching_pennies_trainer(Box::new(FictitiousPlayMetaSolver::new(200)), 3);
trainer.config.max_payoff_evals_per_iteration = Some(3);
trainer.run_silent().expect("PSRO run should not error");
let evals = trainer.payoff_cache.eval_count;
let tensor = trainer.payoff_cache.payoff_tensor().to_vec();
(evals, tensor)
};
let (evals, tensor) = run_capped();
assert!(evals <= 1 + 3 * 3, "capped eval_count {evals} must respect the per-iter cap");
assert!(evals < 16, "capped eval_count {evals} must be fewer than the uncapped 16");
assert_eq!(tensor.len(), 16, "final tensor is 4^2 cells");
for (s, cell) in tensor.iter().enumerate() {
assert_eq!(cell.len(), 2, "cell {s} has per-agent payoffs");
}
let (evals2, tensor2) = run_capped();
assert_eq!(evals, evals2, "capped run must be deterministic in eval_count");
assert_eq!(tensor, tensor2, "capped run must be deterministic in payoff tensor");
}
}