use anyhow::{Result, anyhow};
use burn::{
optim::{GradientsParams, Optimizer},
tensor::{Int, Tensor, TensorData, backend::AutodiffBackend},
};
use rand::{Rng, SeedableRng, rngs::StdRng};
use rayon::prelude::*;
use crate::{
multi_agent::joint::{
JointEnv, JointMultiAgentTrainer, JointPolicy, JointStats, JointTrainerConfig,
},
train::optimizer::{BackendOptimizer, BurnOptimizer},
};
#[derive(Debug, Clone)]
pub struct ReservoirBuffer<T: Clone> {
items: Vec<T>,
capacity: usize,
stream_index: usize,
rng: StdRng,
}
impl<T: Clone> ReservoirBuffer<T> {
pub fn with_seed(capacity: usize, seed: u64) -> Self {
Self {
items: Vec::with_capacity(capacity.max(1)),
capacity: capacity.max(1),
stream_index: 0,
rng: StdRng::seed_from_u64(seed),
}
}
pub fn new(capacity: usize) -> Self {
let seed: u64 = rand::rng().random();
Self::with_seed(capacity, seed)
}
pub fn items(&self) -> &[T] {
&self.items
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn capacity(&self) -> usize {
self.capacity
}
pub fn stream_length(&self) -> usize {
self.stream_index
}
pub fn push(&mut self, item: T) -> bool {
self.stream_index += 1;
if self.items.len() < self.capacity {
self.items.push(item);
return true;
}
let i = self.stream_index;
let j = self.rng.random_range(0..i);
if j < self.capacity {
self.items[j] = item;
true
} else {
false
}
}
pub fn sample_with_replacement(&mut self, n: usize) -> Vec<T> {
if self.items.is_empty() {
return Vec::new();
}
let mut out = Vec::with_capacity(n);
for _ in 0..n {
let j = self.rng.random_range(0..self.items.len());
out.push(self.items[j].clone());
}
out
}
}
#[derive(Debug, Clone)]
pub struct NfspConfig {
pub max_iterations: usize,
pub anticipatory_param: f32,
pub reservoir_capacity: usize,
pub br_train_steps_per_iteration: usize,
pub avg_policy_train_steps_per_iteration: usize,
pub avg_policy_minibatch_size: usize,
pub avg_policy_lr: f64,
pub avg_policy_min_reservoir_coverage: f32,
pub br_reward_scale: f32,
pub seed: u64,
}
impl Default for NfspConfig {
fn default() -> Self {
Self {
max_iterations: 10,
anticipatory_param: 0.1,
reservoir_capacity: 2_000,
br_train_steps_per_iteration: 1,
avg_policy_train_steps_per_iteration: 1,
avg_policy_minibatch_size: 32,
avg_policy_lr: 1e-3,
avg_policy_min_reservoir_coverage: 0.0,
br_reward_scale: 1.0,
seed: 0,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct NfspIterationStats {
pub iteration: usize,
pub reservoir_sizes: Vec<usize>,
pub br_stats: Option<JointStats>,
pub avg_policy_loss: Vec<Option<f64>>,
pub br_action_marginal: Vec<Option<Vec<f32>>>,
pub avg_action_marginal: Vec<Option<Vec<f32>>>,
pub cumulative_br_pushes: usize,
pub cumulative_rollout_steps: usize,
}
#[derive(Debug, Clone, Default)]
pub struct NfspStats {
pub iterations: Vec<NfspIterationStats>,
}
pub struct NfspTrainer<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,
{
config: NfspConfig,
joint_config: JointTrainerConfig,
device: B::Device,
br_trainer: JointMultiAgentTrainer<B, P, O>,
avg_policies: Vec<Option<P>>,
avg_optimizers: Vec<BurnOptimizer<B, P, O>>,
reservoirs: Vec<ReservoirBuffer<(Vec<f32>, Vec<i64>)>>,
rng: StdRng,
cumulative_br_pushes: usize,
cumulative_rollout_steps: usize,
#[allow(dead_code)]
policy_factory: FP,
#[allow(dead_code)]
optimizer_factory: FO,
env_factory: FE,
}
impl<B, P, O, E, FP, FO, FE> NfspTrainer<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: NfspConfig,
joint_config: JointTrainerConfig,
device: B::Device,
policy_factory: FP,
optimizer_factory: FO,
env_factory: FE,
) -> Result<Self> {
if joint_config.num_agents < 2 {
return Err(anyhow!(
"NfspTrainer requires joint_config.num_agents >= 2 (got {})",
joint_config.num_agents
));
}
if !(0.0..=1.0).contains(&config.anticipatory_param) {
return Err(anyhow!(
"NfspConfig::anticipatory_param must be in [0,1], got {}",
config.anticipatory_param
));
}
let num_agents = joint_config.num_agents;
let init_seed = |idx: u64| config.seed.wrapping_add(0x9E37_79B9_u64.wrapping_mul(idx));
let br_policies: Vec<P> =
(0..num_agents).map(|i| policy_factory(&device, init_seed(i as u64))).collect();
let br_optimizers: Vec<BurnOptimizer<B, P, O>> =
(0..num_agents).map(|_| optimizer_factory()).collect();
let br_trainer = JointMultiAgentTrainer::<B, P, O>::new(
br_policies,
br_optimizers,
joint_config.clone(),
device.clone(),
)?;
let avg_policies: Vec<Option<P>> = (0..num_agents)
.map(|i| Some(policy_factory(&device, init_seed((num_agents + i) as u64))))
.collect();
let avg_optimizers: Vec<BurnOptimizer<B, P, O>> =
(0..num_agents).map(|_| optimizer_factory()).collect();
let reservoirs = (0..num_agents)
.map(|i| {
ReservoirBuffer::with_seed(
config.reservoir_capacity,
config.seed.wrapping_add(0x1ABC + i as u64),
)
})
.collect();
let rng = StdRng::seed_from_u64(config.seed.wrapping_add(0xC0DE));
Ok(Self {
config,
joint_config,
device,
br_trainer,
avg_policies,
avg_optimizers,
reservoirs,
rng,
cumulative_br_pushes: 0,
cumulative_rollout_steps: 0,
policy_factory,
optimizer_factory,
env_factory,
})
}
pub fn br_policy(&self, i: usize) -> &P {
self.br_trainer.policy(i)
}
pub fn avg_policy(&self, i: usize) -> &P {
self.avg_policies[i].as_ref().expect("AP policy is None mid-update")
}
pub fn reservoir(&self, i: usize) -> &ReservoirBuffer<(Vec<f32>, Vec<i64>)> {
&self.reservoirs[i]
}
pub fn cumulative_br_pushes(&self) -> usize {
self.cumulative_br_pushes
}
pub fn cumulative_rollout_steps(&self) -> usize {
self.cumulative_rollout_steps
}
pub fn config(&self) -> &NfspConfig {
&self.config
}
pub fn run<F>(&mut self, mut on_iteration: F) -> Result<NfspStats>
where
F: FnMut(&NfspIterationStats),
P: Send + Sync,
O: Send,
B::Device: Sync,
{
let mut stats = NfspStats::default();
for iter in 1..=self.config.max_iterations {
let mut last_br_stats: Option<JointStats> = None;
let mut br_rollout_time = std::time::Duration::ZERO;
let mut br_update_time = std::time::Duration::ZERO;
for _ in 0..self.config.br_train_steps_per_iteration {
let rollout_start = std::time::Instant::now();
let rollout = self.collect_anticipatory_rollout()?;
br_rollout_time += rollout_start.elapsed();
let active = vec![true; self.joint_config.num_agents];
let update_start = std::time::Instant::now();
let bs = self.br_trainer.update_with_active_agents(
&rollout,
&active,
&mut self.rng,
|_features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> { None },
)?;
br_update_time += update_start.elapsed();
last_br_stats = Some(bs);
}
let ap_start = std::time::Instant::now();
let avg_losses = self.train_average_policies()?;
let ap_time = ap_start.elapsed();
let br_rollout_s = br_rollout_time.as_secs_f64();
let br_update_s = br_update_time.as_secs_f64();
let ap_s = ap_time.as_secs_f64();
let phase_total = br_rollout_s + br_update_s + ap_s;
let pct = |x: f64| {
if phase_total > 0.0 {
100.0 * x / phase_total
} else {
0.0
}
};
tracing::info!(
"iter {iter} phase timing (issue #251): br_rollout={br_rollout_s:.1}s ({:.0}%) \
br_update={br_update_s:.1}s ({:.0}%) ap_train={ap_s:.1}s ({:.0}%) \
[total {phase_total:.1}s]",
pct(br_rollout_s),
pct(br_update_s),
pct(ap_s),
);
let num_agents = self.joint_config.num_agents;
let mut br_marginals: Vec<Option<Vec<f32>>> = Vec::with_capacity(num_agents);
let mut avg_marginals: Vec<Option<Vec<f32>>> = Vec::with_capacity(num_agents);
for i in 0..num_agents {
let br_policy_i = self.br_policy(i).clone();
let avg_policy_i = self.avg_policy(i).clone();
br_marginals.push(self.action_marginal_for(&br_policy_i));
avg_marginals.push(self.action_marginal_for(&avg_policy_i));
}
let iter_stats = NfspIterationStats {
iteration: iter,
reservoir_sizes: self.reservoirs.iter().map(|r| r.len()).collect(),
br_stats: last_br_stats,
avg_policy_loss: avg_losses,
br_action_marginal: br_marginals,
avg_action_marginal: avg_marginals,
cumulative_br_pushes: self.cumulative_br_pushes,
cumulative_rollout_steps: self.cumulative_rollout_steps,
};
on_iteration(&iter_stats);
stats.iterations.push(iter_stats);
}
Ok(stats)
}
pub fn run_silent(&mut self) -> Result<NfspStats>
where
P: Send + Sync,
O: Send,
B::Device: Sync,
{
self.run(|_| {})
}
pub fn action_marginal_for(&mut self, policy: &P) -> Option<Vec<f32>> {
let dims = policy.action_dims_joint();
if dims.len() != 1 {
return None;
}
let action_dim = dims[0] as usize;
let obs_dim = self
.reservoirs
.iter()
.find_map(|r| r.items().first().map(|(obs, _)| obs.len()))
.unwrap_or(1);
let probes = 128usize;
let obs_data: Vec<f32> = vec![0.0_f32; probes * obs_dim];
let obs =
Tensor::<B, 2>::from_data(TensorData::new(obs_data, [probes, obs_dim]), &self.device);
let (acts, _, _) = policy.get_actions_host_seeded_batched(obs, &mut self.rng);
let mut counts = vec![0u32; action_dim];
for &a in acts.iter().take(probes) {
let idx = a as usize;
if idx < action_dim {
counts[idx] += 1;
}
}
Some(counts.iter().map(|&c| c as f32 / probes as f32).collect())
}
fn collect_anticipatory_rollout(&mut self) -> Result<crate::multi_agent::joint::JointRollout> {
use crate::multi_agent::joint::JointRollout;
let num_steps = self.joint_config.rollout_steps;
let num_agents = self.joint_config.num_agents;
let mut env = (self.env_factory)();
let initial_obs = env.reset_joint(Some(self.config.seed));
let obs_dim = initial_obs[0].len();
let mut last_obs: Vec<Vec<f32>> = initial_obs;
let device = self.device.clone();
let num_action_dims: usize = self.br_policy(0).action_dims_joint().len();
let mut obs_buf_per_agent: Vec<Vec<f32>> =
(0..num_agents).map(|_| vec![0.0_f32; num_steps * obs_dim]).collect();
let mut act_buf: Vec<Vec<i64>> =
(0..num_agents).map(|_| vec![0_i64; num_steps * num_action_dims]).collect();
let mut lp_buf: Vec<Vec<f32>> = (0..num_agents).map(|_| vec![0.0_f32; num_steps]).collect();
let mut val_buf: Vec<Vec<f32>> =
(0..num_agents).map(|_| vec![0.0_f32; num_steps]).collect();
let mut rew_buf: Vec<Vec<f32>> =
(0..num_agents).map(|_| vec![0.0_f32; num_steps]).collect();
let mut done_buf = vec![0.0_f32; num_steps];
for t in 0..num_steps {
let start = t * obs_dim;
let mut joint_action: Vec<Vec<i64>> = Vec::with_capacity(num_agents);
for i in 0..num_agents {
let agent_obs = last_obs[i].clone();
obs_buf_per_agent[i][start..start + obs_dim].copy_from_slice(&agent_obs);
let obs_t = Tensor::<B, 2>::from_data(
TensorData::new(agent_obs.clone(), [1, obs_dim]),
&device,
);
let br_policy_i = self.br_trainer.policy(i).clone();
let (br_actions, br_log_probs, br_values) =
br_policy_i.get_action_host_seeded(obs_t.clone(), &mut self.rng);
let u: f32 = self.rng.random();
let take_br = u < self.config.anticipatory_param;
let row: Vec<i64> = if take_br {
let row = br_actions[..num_action_dims].to_vec();
self.reservoirs[i].push((agent_obs.clone(), row.clone()));
self.cumulative_br_pushes += 1;
row
} else {
let ap_policy_i = self.avg_policy(i).clone();
let (ap_actions, _, _) =
ap_policy_i.get_action_host_seeded(obs_t, &mut self.rng);
ap_actions[..num_action_dims].to_vec()
};
let off = t * num_action_dims;
act_buf[i][off..off + num_action_dims].copy_from_slice(&row);
joint_action.push(row);
lp_buf[i][t] = br_log_probs.first().copied().unwrap_or(0.0);
val_buf[i][t] = br_values.first().copied().unwrap_or(0.0);
}
let result = env.step_joint(&joint_action);
let reward_scale = self.config.br_reward_scale;
#[allow(clippy::needless_range_loop)]
for i in 0..num_agents {
rew_buf[i][t] = result.rewards[i] * reward_scale;
}
done_buf[t] = if result.done { 1.0 } else { 0.0 };
if result.done {
let fresh = env.reset_joint(None);
last_obs[..num_agents].clone_from_slice(&fresh[..num_agents]);
} else {
last_obs[..num_agents].clone_from_slice(&result.observations[..num_agents]);
}
self.cumulative_rollout_steps += 1;
}
Ok(JointRollout {
observations_per_agent: obs_buf_per_agent,
obs_dims: vec![obs_dim; num_agents],
actions: act_buf,
num_action_dims,
log_probs: lp_buf,
values: val_buf,
rewards: rew_buf,
dones: done_buf,
})
}
fn train_average_policies(&mut self) -> Result<Vec<Option<f64>>>
where
P: Send + Sync,
O: Send,
B::Device: Sync,
{
let num_agents = self.joint_config.num_agents;
let steps = self.config.avg_policy_train_steps_per_iteration;
if steps == 0 && self.config.avg_policy_min_reservoir_coverage <= 0.0 {
return Ok(vec![None; num_agents]);
}
let num_action_dims: Vec<usize> = (0..num_agents)
.map(|i| self.br_trainer.policy(i).action_dims_joint().len())
.collect();
let mb_size = self.config.avg_policy_minibatch_size.max(1);
let coverage = self.config.avg_policy_min_reservoir_coverage;
let device = &self.device;
let losses: Result<Vec<Option<f64>>> = self
.reservoirs
.par_iter_mut()
.zip(self.avg_policies.par_iter_mut())
.zip(self.avg_optimizers.par_iter_mut())
.zip(num_action_dims.par_iter())
.map(|(((reservoir, avg_policy), avg_optimizer), &n_dims)| {
train_one_average_policy::<B, P, O>(
reservoir,
avg_policy,
avg_optimizer,
n_dims,
steps,
mb_size,
coverage,
device,
)
})
.collect();
losses
}
}
#[allow(clippy::too_many_arguments)]
fn train_one_average_policy<B, P, O>(
reservoir: &mut ReservoirBuffer<(Vec<f32>, Vec<i64>)>,
avg_policy: &mut Option<P>,
avg_optimizer: &mut BurnOptimizer<B, P, O>,
num_action_dims: usize,
steps: usize,
mb_size: usize,
coverage: f32,
device: &B::Device,
) -> Result<Option<f64>>
where
B: AutodiffBackend,
P: JointPolicy<B>,
O: Optimizer<P, B>,
{
if reservoir.is_empty() {
return Ok(None);
}
let agent_steps = if coverage > 0.0 {
let res_len = reservoir.len();
let needed = (coverage as f64 * res_len as f64 / mb_size as f64).ceil() as usize;
steps.max(needed)
} else {
steps
};
let mut sum_loss = 0.0_f64;
let mut n_steps_done = 0usize;
for _ in 0..agent_steps {
let batch = reservoir.sample_with_replacement(mb_size);
if batch.is_empty() {
continue;
}
let obs_dim = batch[0].0.len();
let mb = batch.len();
let mut obs_flat = Vec::with_capacity(mb * obs_dim);
let mut acts_flat = Vec::with_capacity(mb * num_action_dims);
for (o, a) in &batch {
debug_assert_eq!(
a.len(),
num_action_dims,
"reservoir action vec length {} != num_action_dims {}",
a.len(),
num_action_dims,
);
obs_flat.extend_from_slice(o);
acts_flat.extend_from_slice(a);
}
let obs_t = Tensor::<B, 2>::from_data(TensorData::new(obs_flat, [mb, obs_dim]), device);
let acts_t: Tensor<B, 2, Int> = Tensor::<B, 2, Int>::from_data(
TensorData::new(acts_flat, [mb, num_action_dims]),
device,
);
let policy = avg_policy.take().ok_or_else(|| anyhow!("AP policy is None mid-update"))?;
let (log_probs_taken, _, _) = policy.evaluate_actions_joint(obs_t, acts_t);
let loss = log_probs_taken.neg().mean();
let loss_value = scalar_f64_avg_policy(loss.clone());
let mut grads = loss.backward();
let grads_params = GradientsParams::from_module(&mut grads, &policy);
let lr = avg_optimizer.learning_rate();
let updated = avg_optimizer.inner_mut().step(lr, policy, grads_params);
*avg_policy = Some(updated);
sum_loss += loss_value;
n_steps_done += 1;
}
if n_steps_done > 0 {
Ok(Some(sum_loss / n_steps_done as f64))
} else {
Ok(None)
}
}
fn scalar_f64_avg_policy<B: AutodiffBackend>(t: Tensor<B, 1>) -> f64 {
let v: Vec<f32> = t.into_data().to_vec().expect("scalar tensor to_vec");
v.first().copied().unwrap_or(0.0) as f64
}
#[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>>;
#[test]
fn test_reservoir_under_capacity_retains_all() {
let mut r = ReservoirBuffer::<u32>::with_seed(8, 0);
for i in 0..8 {
assert!(r.push(i), "every push under capacity must be retained");
}
assert_eq!(r.len(), 8);
assert_eq!(r.items(), &[0, 1, 2, 3, 4, 5, 6, 7]);
assert_eq!(r.stream_length(), 8);
}
#[test]
fn test_reservoir_at_capacity_stays_at_capacity() {
let mut r = ReservoirBuffer::<u32>::with_seed(4, 0);
for i in 0..100 {
r.push(i);
}
assert_eq!(r.len(), 4, "reservoir size must stay at capacity");
assert_eq!(r.stream_length(), 100);
}
#[test]
fn test_reservoir_uniformity_via_mean_stream_index() {
let capacity = 16usize;
let n = 1000usize; let trials = 50usize;
let expected_mean = (n as f64 + 1.0) / 2.0; let mut grand_mean = 0.0_f64;
for trial in 0..trials {
let mut r = ReservoirBuffer::<usize>::with_seed(capacity, trial as u64);
for i in 1..=n {
r.push(i); }
assert_eq!(r.len(), capacity);
let sum: usize = r.items().iter().sum();
let mean = sum as f64 / capacity as f64;
grand_mean += mean;
}
grand_mean /= trials as f64;
let abs_err = (grand_mean - expected_mean).abs();
let sem = (n as f64) / ((12.0 * capacity as f64 * trials as f64).sqrt());
let tol = 3.0 * sem;
assert!(
abs_err <= tol,
"Vitter Algorithm R uniformity failed: grand_mean={grand_mean:.2}, \
expected_mean={expected_mean:.2}, abs_err={abs_err:.2}, tol={tol:.2} \
(this test fails for FIFO/sliding-window buffers)"
);
}
#[test]
fn test_reservoir_first_overflow_inclusion_probability() {
let capacity = 10usize;
let trials = 5000usize;
let mut kept = 0usize;
for t in 0..trials {
let mut r = ReservoirBuffer::<u32>::with_seed(capacity, (t as u64) ^ 0xdead_beef);
for i in 0..capacity {
r.push(i as u32);
}
let was_retained = r.push(u32::MAX);
if was_retained {
kept += 1;
}
}
let p_emp = kept as f64 / trials as f64;
let p_target = capacity as f64 / (capacity as f64 + 1.0);
let std = (p_target * (1.0 - p_target) / trials as f64).sqrt();
let tol = 3.0 * std;
assert!(
(p_emp - p_target).abs() <= tol,
"first-overflow inclusion probability deviates: p_emp={p_emp:.4}, \
p_target={p_target:.4} (tol={tol:.4})"
);
}
#[test]
fn test_reservoir_sample_with_replacement_empty() {
let mut r = ReservoirBuffer::<u32>::with_seed(4, 0);
let s = r.sample_with_replacement(8);
assert!(s.is_empty());
}
#[test]
fn test_reservoir_sample_with_replacement_uniform_over_items() {
let mut r = ReservoirBuffer::<u32>::with_seed(2, 0);
r.push(7);
r.push(11);
let n = 1000;
let s = r.sample_with_replacement(n);
assert_eq!(s.len(), n);
let c7 = s.iter().filter(|&&x| x == 7).count();
let c11 = s.iter().filter(|&&x| x == 11).count();
assert_eq!(c7 + c11, n, "all samples must be from the reservoir");
let frac7 = c7 as f64 / n as f64;
assert!((frac7 - 0.5).abs() < 0.1, "sample distribution should be ~uniform");
}
fn run_anticipatory_simulation(eta: f32, steps: usize, seed: u64) -> (usize, usize, usize) {
let mut rng = StdRng::seed_from_u64(seed);
let mut br_pushed = 0usize;
let ap_pushed = 0usize;
let mut br_taken = 0usize;
for _ in 0..steps {
let u: f32 = rng.random();
if u < eta {
br_taken += 1;
br_pushed += 1; } else {
let _ = ap_pushed;
}
}
(br_taken, br_pushed, ap_pushed)
}
#[test]
fn test_eta_anticipatory_mixing_rate_concentration() {
let eta = 0.1f32;
let n = 100_000usize;
let (br_taken, _, _) = run_anticipatory_simulation(eta, n, 42);
let p_emp = br_taken as f64 / n as f64;
let p_target = eta as f64;
let std = (p_target * (1.0 - p_target) / n as f64).sqrt();
let tol = 3.0 * std;
assert!(
(p_emp - p_target).abs() <= tol,
"η-mixing rate deviates from 0.1: p_emp={p_emp:.5} (tol={tol:.5})"
);
}
#[test]
fn test_eta_anticipatory_only_br_actions_enter_reservoir() {
let eta = 0.1f32;
for seed in [0u64, 1, 2, 42, 12345] {
let (_, br_pushed, ap_pushed) = run_anticipatory_simulation(eta, 10_000, seed);
assert_eq!(
ap_pushed, 0,
"AP path must NOT push to the reservoir (seed={seed}, ap_pushed={ap_pushed})"
);
assert!(br_pushed > 0, "BR should sample at least once (seed={seed})");
}
}
#[allow(clippy::type_complexity)]
fn build_matching_pennies_nfsp_trainer(
max_iterations: usize,
eta: f32,
) -> NfspTrainer<
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 nfsp_config = NfspConfig {
max_iterations,
anticipatory_param: eta,
reservoir_capacity: 1_024,
br_train_steps_per_iteration: 1,
avg_policy_train_steps_per_iteration: 2,
avg_policy_minibatch_size: 32,
avg_policy_lr: 5e-3,
avg_policy_min_reservoir_coverage: 0.0,
br_reward_scale: 1.0,
seed: 0,
};
let joint_config = JointTrainerConfig {
num_agents: 2,
rollout_steps: 64,
n_epochs: 1,
minibatch_size: 32,
..Default::default()
};
NfspTrainer::new(
nfsp_config,
joint_config,
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, 5e-3)
},
MatchingPennies::new,
)
.expect("NfspTrainer::new should succeed for 2-agent config")
}
#[test]
fn test_nfsp_construction_rejects_num_agents_less_than_two() {
let device: NdArrayDevice = Default::default();
let nfsp_config = NfspConfig::default();
let joint_config = JointTrainerConfig { num_agents: 1, ..Default::default() };
let res = NfspTrainer::<
B,
MlpBurnPolicy<B>,
burn::optim::adaptor::OptimizerAdaptor<burn::optim::Adam, MlpBurnPolicy<B>, B>,
MatchingPennies,
_,
_,
_,
>::new(
nfsp_config,
joint_config,
device,
|dev: &NdArrayDevice, seed: u64| MlpBurnPolicy::<B>::new_seeded(1, 2, 8, seed, dev),
|| BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
MatchingPennies::new,
);
assert!(res.is_err(), "NFSP should reject num_agents < 2");
}
#[test]
fn test_nfsp_construction_accepts_n_player_config() {
use crate::env::games::n_player_matching_pennies::NPlayerMatchingPennies;
let device: NdArrayDevice = Default::default();
let nfsp_config = NfspConfig::default();
let joint_config = JointTrainerConfig { num_agents: 4, ..Default::default() };
let trainer = NfspTrainer::<
B,
MlpBurnPolicy<B>,
burn::optim::adaptor::OptimizerAdaptor<burn::optim::Adam, MlpBurnPolicy<B>, B>,
NPlayerMatchingPennies,
_,
_,
_,
>::new(
nfsp_config,
joint_config,
device,
|dev: &NdArrayDevice, seed: u64| {
MlpBurnPolicy::<B>::new_seeded(
NPlayerMatchingPennies::OBS_DIM,
NPlayerMatchingPennies::ACTION_DIM,
8,
seed,
dev,
)
},
|| BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
|| NPlayerMatchingPennies::new(4),
)
.expect("NFSP should accept num_agents = 4");
for i in 0..4 {
assert_eq!(trainer.reservoir(i).len(), 0, "agent {i} reservoir starts empty");
}
}
#[test]
fn test_nfsp_construction_rejects_out_of_range_eta() {
let device: NdArrayDevice = Default::default();
let nfsp_config = NfspConfig { anticipatory_param: 1.5, ..Default::default() };
let joint_config = JointTrainerConfig { num_agents: 2, ..Default::default() };
let res = NfspTrainer::<
B,
MlpBurnPolicy<B>,
burn::optim::adaptor::OptimizerAdaptor<burn::optim::Adam, MlpBurnPolicy<B>, B>,
MatchingPennies,
_,
_,
_,
>::new(
nfsp_config,
joint_config,
device,
|dev: &NdArrayDevice, seed: u64| MlpBurnPolicy::<B>::new_seeded(1, 2, 8, seed, dev),
|| BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
MatchingPennies::new,
);
assert!(res.is_err(), "NFSP should reject η outside [0,1]");
}
#[test]
fn test_nfsp_runs_end_to_end_on_matching_pennies() {
let mut trainer = build_matching_pennies_nfsp_trainer(3, 0.5);
let stats = trainer.run_silent().expect("NFSP 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.reservoir_sizes.len(), 2);
assert!(it.reservoir_sizes.iter().sum::<usize>() > 0, "reservoirs should accumulate");
}
}
#[test]
fn test_nfsp_eta_zero_pure_avg_policy_never_pushes() {
let mut trainer = build_matching_pennies_nfsp_trainer(2, 0.0);
let _ = trainer.run_silent().expect("NFSP run with η=0 should not error");
for i in 0..2 {
assert_eq!(trainer.reservoir(i).len(), 0, "η=0 must leave reservoir {i} empty");
}
assert_eq!(trainer.cumulative_br_pushes(), 0, "η=0 must result in zero BR pushes");
}
#[test]
fn test_nfsp_eta_one_only_br_path_fills_reservoir() {
let mut trainer = build_matching_pennies_nfsp_trainer(1, 1.0);
let _ = trainer.run_silent().expect("NFSP run with η=1 should not error");
for i in 0..2 {
assert_eq!(
trainer.reservoir(i).len(),
64,
"η=1 should retain all 64 rollout steps per agent in reservoir {i}"
);
}
assert_eq!(trainer.cumulative_br_pushes(), 128);
}
#[test]
fn test_nfsp_eta_mixing_rate_smoke_in_trainer() {
let device: NdArrayDevice = Default::default();
let eta = 0.5f32;
let rollout_steps = 256usize;
let nfsp_config = NfspConfig {
max_iterations: 1,
anticipatory_param: eta,
reservoir_capacity: 100_000,
br_train_steps_per_iteration: 1,
avg_policy_train_steps_per_iteration: 0,
avg_policy_minibatch_size: 32,
avg_policy_lr: 1e-3,
avg_policy_min_reservoir_coverage: 0.0,
br_reward_scale: 1.0,
seed: 7,
};
let joint_config = JointTrainerConfig {
num_agents: 2,
rollout_steps,
n_epochs: 1,
minibatch_size: 32,
..Default::default()
};
let mut trainer = NfspTrainer::new(
nfsp_config,
joint_config,
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("NfspTrainer::new should succeed");
let _ = trainer.run_silent().expect("NFSP run should not error");
let br_pushes = trainer.cumulative_br_pushes() as f64;
let total_steps = (rollout_steps * 2) as f64;
let p_emp = br_pushes / total_steps;
assert!(
br_pushes > 0.0,
"η-mixing BR path must push to the reservoir (br_pushes={br_pushes})"
);
assert!(
(0.25..=0.75).contains(&p_emp),
"η-mixing BR-fraction should be near 0.5: p_emp={p_emp:.4}"
);
}
#[test]
#[ignore = "multi-iteration NFSP η-mixing concentration run; opt in with --ignored (prefer --release)"]
fn test_nfsp_eta_mixing_rate_concentration_in_trainer() {
let device: NdArrayDevice = Default::default();
let eta = 0.1f32;
let rollout_steps = 4096usize;
let nfsp_config = NfspConfig {
max_iterations: 1,
anticipatory_param: eta,
reservoir_capacity: 100_000,
br_train_steps_per_iteration: 1,
avg_policy_train_steps_per_iteration: 0,
avg_policy_minibatch_size: 32,
avg_policy_lr: 1e-3,
avg_policy_min_reservoir_coverage: 0.0,
br_reward_scale: 1.0,
seed: 7,
};
let joint_config = JointTrainerConfig {
num_agents: 2,
rollout_steps,
n_epochs: 1,
minibatch_size: 32,
..Default::default()
};
let mut trainer = NfspTrainer::new(
nfsp_config,
joint_config,
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("NfspTrainer::new should succeed");
let _ = trainer.run_silent().expect("NFSP run should not error");
let br_pushes = trainer.cumulative_br_pushes() as f64;
let total_steps = (rollout_steps * 2) as f64;
let p_emp = br_pushes / total_steps;
let p_target = eta as f64;
let std = (p_target * (1.0 - p_target) / total_steps).sqrt();
let tol = 4.0 * std; assert!(
(p_emp - p_target).abs() <= tol,
"trainer η-mixing rate deviates: p_emp={p_emp:.4}, p_target={p_target:.4}, tol={tol:.4}"
);
}
#[test]
fn test_nfsp_avg_policy_supervised_step_reduces_loss_on_fixed_minibatch() {
let device: NdArrayDevice = Default::default();
let nfsp_config = NfspConfig {
max_iterations: 0,
anticipatory_param: 1.0,
reservoir_capacity: 256,
br_train_steps_per_iteration: 0,
avg_policy_train_steps_per_iteration: 0,
avg_policy_minibatch_size: 32,
avg_policy_lr: 5e-2,
avg_policy_min_reservoir_coverage: 0.0,
br_reward_scale: 1.0,
seed: 13,
};
let joint_config = JointTrainerConfig {
num_agents: 2,
rollout_steps: 32,
n_epochs: 1,
minibatch_size: 32,
..Default::default()
};
let mut trainer = NfspTrainer::new(
nfsp_config,
joint_config,
device,
|dev: &NdArrayDevice, seed: u64| MlpBurnPolicy::<B>::new_seeded(1, 2, 8, seed, dev),
|| BurnOptimizer::new(AdamConfig::new().init(), 5e-2),
MatchingPennies::new,
)
.expect("NfspTrainer::new should succeed");
for _ in 0..64 {
trainer.reservoirs[0].push((vec![0.0_f32], vec![0]));
}
trainer.config.avg_policy_train_steps_per_iteration = 10;
let losses_before = trainer.train_average_policies().unwrap();
let loss_before = losses_before[0].expect("expected supervised loss for agent 0");
for _ in 0..4 {
trainer.train_average_policies().unwrap();
}
let losses_after = trainer.train_average_policies().unwrap();
let loss_after = losses_after[0].expect("expected supervised loss for agent 0");
assert!(
loss_after < loss_before,
"AP supervised CE should decrease: before={loss_before:.4}, after={loss_after:.4}"
);
}
#[test]
fn test_nfsp_multi_discrete_ap_loss_drops_below_uniform_floor() {
use crate::policy::multi_discrete_mlp::MultiDiscreteMlpBurnPolicy;
let device: NdArrayDevice = Default::default();
let action_dims = vec![10usize, 2, 2]; let uniform_floor = (40.0_f64).ln();
let nfsp_config = NfspConfig {
max_iterations: 0,
anticipatory_param: 1.0,
reservoir_capacity: 4_096,
br_train_steps_per_iteration: 0,
avg_policy_train_steps_per_iteration: 8,
avg_policy_minibatch_size: 64,
avg_policy_lr: 5e-3,
avg_policy_min_reservoir_coverage: 4.0,
br_reward_scale: 1.0,
seed: 199,
};
let joint_config = JointTrainerConfig {
num_agents: 2,
rollout_steps: 8,
n_epochs: 1,
minibatch_size: 32,
..Default::default()
};
let obs_dim = 4usize;
let dims_for_factory = action_dims.clone();
let mut trainer = NfspTrainer::new(
nfsp_config,
joint_config,
device,
move |dev: &NdArrayDevice, seed: u64| {
MultiDiscreteMlpBurnPolicy::<B>::new_seeded(
obs_dim,
dims_for_factory.clone(),
16,
seed,
dev,
)
},
|| BurnOptimizer::new(AdamConfig::new().init(), 5e-3),
MatchingPennies::new,
)
.expect("NfspTrainer::new should succeed for multi-discrete config");
let fixed_obs = vec![0.25_f32; obs_dim];
let fixed_action: Vec<i64> = vec![3, 1, 0];
for _ in 0..512 {
trainer.reservoirs[0].push((fixed_obs.clone(), fixed_action.clone()));
}
let losses_first = trainer.train_average_policies().unwrap();
let loss_first = losses_first[0].expect("expected supervised loss for agent 0");
for _ in 0..8 {
trainer.train_average_policies().unwrap();
}
let losses_last = trainer.train_average_policies().unwrap();
let loss_last = losses_last[0].expect("expected supervised loss for agent 0");
eprintln!(
"[#199] multi-discrete AP loss: first={loss_first:.4}, last={loss_last:.4}, \
ln(40) floor={uniform_floor:.4}"
);
assert!(
loss_last < uniform_floor - 0.5,
"AP loss should drop well below the ln(40) uniform floor: \
first={loss_first:.4}, last={loss_last:.4}, floor={uniform_floor:.4}"
);
assert!(
loss_last < loss_first,
"AP loss should decrease across supervised passes: \
first={loss_first:.4}, last={loss_last:.4}"
);
}
#[test]
fn test_nfsp_ap_training_deterministic_across_thread_counts() {
use crate::policy::multi_discrete_mlp::MultiDiscreteMlpBurnPolicy;
let action_dims = vec![10usize, 2, 2]; let obs_dim = 4usize;
let run_trajectory = |threads: usize| -> Vec<Vec<Option<f64>>> {
let device: NdArrayDevice = Default::default();
let nfsp_config = NfspConfig {
max_iterations: 0,
anticipatory_param: 1.0,
reservoir_capacity: 4_096,
br_train_steps_per_iteration: 0,
avg_policy_train_steps_per_iteration: 4,
avg_policy_minibatch_size: 64,
avg_policy_lr: 5e-3,
avg_policy_min_reservoir_coverage: 2.0,
br_reward_scale: 1.0,
seed: 234,
};
let joint_config = JointTrainerConfig {
num_agents: 2,
rollout_steps: 8,
n_epochs: 1,
minibatch_size: 32,
..Default::default()
};
let dims_for_factory = action_dims.clone();
let mut trainer = NfspTrainer::new(
nfsp_config,
joint_config,
device,
move |dev: &NdArrayDevice, seed: u64| {
MultiDiscreteMlpBurnPolicy::<B>::new_seeded(
obs_dim,
dims_for_factory.clone(),
16,
seed,
dev,
)
},
|| BurnOptimizer::new(AdamConfig::new().init(), 5e-3),
MatchingPennies::new,
)
.expect("NfspTrainer::new should succeed");
let fixed_obs = vec![0.25_f32; obs_dim];
for _ in 0..256 {
trainer.reservoirs[0].push((fixed_obs.clone(), vec![3, 1, 0]));
trainer.reservoirs[1].push((fixed_obs.clone(), vec![7, 0, 1]));
}
let pool = rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.build()
.expect("rayon pool should build");
pool.install(|| (0..6).map(|_| trainer.train_average_policies().unwrap()).collect())
};
let serial = run_trajectory(1);
let parallel = run_trajectory(4);
assert_eq!(
serial, parallel,
"AP loss trajectory must be bit-identical across rayon thread counts \
(per-reservoir RNG, no shared self.rng): serial={serial:?}, parallel={parallel:?}"
);
assert!(
serial.iter().all(|pass| pass.iter().all(|l| l.is_some())),
"both agents should produce a loss every pass: {serial:?}"
);
}
#[test]
fn test_nfsp_adaptive_coverage_runs_steps_when_fixed_budget_is_zero() {
use crate::policy::multi_discrete_mlp::MultiDiscreteMlpBurnPolicy;
let device: NdArrayDevice = Default::default();
let obs_dim = 3usize;
let action_dims = vec![4usize, 2];
let make = |coverage: f32, fixed_steps: usize| {
let nfsp_config = NfspConfig {
max_iterations: 0,
anticipatory_param: 1.0,
reservoir_capacity: 1_024,
br_train_steps_per_iteration: 0,
avg_policy_train_steps_per_iteration: fixed_steps,
avg_policy_minibatch_size: 32,
avg_policy_lr: 1e-3,
avg_policy_min_reservoir_coverage: coverage,
br_reward_scale: 1.0,
seed: 7,
};
let joint_config = JointTrainerConfig {
num_agents: 1 + 1,
rollout_steps: 8,
n_epochs: 1,
minibatch_size: 32,
..Default::default()
};
let dims = action_dims.clone();
let mut trainer = NfspTrainer::new(
nfsp_config,
joint_config,
device,
move |dev: &NdArrayDevice, seed: u64| {
MultiDiscreteMlpBurnPolicy::<B>::new_seeded(obs_dim, dims.clone(), 8, seed, dev)
},
|| BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
MatchingPennies::new,
)
.expect("trainer construction");
for _ in 0..256 {
trainer.reservoirs[0].push((vec![0.1_f32; obs_dim], vec![1, 0]));
}
trainer
};
let mut disabled = make(0.0, 0);
assert!(
disabled.train_average_policies().unwrap()[0].is_none(),
"coverage=0 with 0 fixed steps must run no supervised steps"
);
let mut enabled = make(1.0, 0);
assert!(
enabled.train_average_policies().unwrap()[0].is_some(),
"coverage>0 must run supervised steps even when fixed budget is 0"
);
}
#[test]
fn test_nfsp_determinism_within_module_under_seed() {
let mut a = build_matching_pennies_nfsp_trainer(1, 0.5);
let mut b = build_matching_pennies_nfsp_trainer(1, 0.5);
let _ = a.run_silent().unwrap();
let _ = b.run_silent().unwrap();
for i in 0..2 {
assert_eq!(
a.reservoir(i).len(),
b.reservoir(i).len(),
"reservoir lengths must match across same-seed runs (agent {i})"
);
assert_eq!(
a.reservoir(i).stream_length(),
b.reservoir(i).stream_length(),
"reservoir stream length must match across same-seed runs (agent {i})"
);
}
assert_eq!(a.cumulative_br_pushes(), b.cumulative_br_pushes());
assert_eq!(a.cumulative_rollout_steps(), b.cumulative_rollout_steps());
}
}