thrust_rl/multi_agent/nfsp.rs
1//! Neural Fictitious Self-Play (NFSP) trainer.
2//!
3//! Burn-native implementation of NFSP (Heinrich & Silver 2016,
4//! [arXiv:1603.01121](https://arxiv.org/abs/1603.01121)).
5//! The original paper proves ε-Nash convergence in the 2-agent
6//! zero-sum setting (§3 Algorithm 1, Theorem 3). Tracking issue: #106.
7//!
8//! # N-player ("approximate NFSP") caveat
9//!
10//! As of #119, the trainer accepts `joint_config.num_agents > 2`. The
11//! Vitter Algorithm R uniformity invariant on each per-agent reservoir
12//! survives unchanged (the per-agent reservoirs are independent and
13//! Algorithm R's correctness does not depend on the number of agents).
14//! However, the §3 Algorithm 1 **ε-Nash convergence guarantee does
15//! NOT generalize** to N > 2 — Heinrich & Silver §5 explicitly flags
16//! "extending the convergence theory to N-player general-sum games"
17//! as future work. For N > 2 this implementation is therefore best
18//! described as an "approximate NFSP" smoke trainer: the BR/AP
19//! mechanics, η-anticipatory mixing, and reservoir sampling are all
20//! exercised, and empirically the average policy converges on
21//! symmetric mixed-Nash games like
22//! [`crate::env::games::n_player_matching_pennies::NPlayerMatchingPennies`],
23//! but no formal guarantee is shipped beyond the per-agent reservoir
24//! uniformity.
25//!
26//! # Pseudocode (Heinrich & Silver §3 Algorithm 1)
27//!
28//! ```text
29//! for each iteration k:
30//! for each rollout step t:
31//! draw c ~ Bernoulli(η) # η-anticipatory mixing
32//! if c == 1:
33//! a_t ~ best_response_policy # BR (PPO actor)
34//! reservoir.push((s_t, a_t)) # ← reservoir-sampled
35//! else:
36//! a_t ~ average_policy # AP (supervised actor)
37//! # NOTE: do NOT push AP actions into the reservoir
38//! train BR with PPO on the rollout (freeze-N-1 via JointMultiAgentTrainer)
39//! sample minibatch from reservoir; train AP via cross-entropy on (s, a)
40//! end
41//! ```
42//!
43//! The reservoir is Vitter's Algorithm R (paper §3.2 footnote 3, see
44//! also Vitter 1985 "Random Sampling with a Reservoir"). Using a FIFO
45//! or sliding window instead defeats the convergence guarantee: NFSP
46//! depends on the supervised target being a *uniform* sample over the
47//! history of best-response actions so the AP converges to the
48//! time-averaged BR policy.
49//!
50//! # η-anticipatory mixing
51//!
52//! At every rollout step the trainer flips a Bernoulli(η) coin. With
53//! probability `η` it samples from the best-response (BR) policy and
54//! pushes the observation/action pair into the reservoir buffer. With
55//! probability `1 − η` it samples from the average (AP) policy and
56//! does **not** push to the reservoir. Appending AP actions to the
57//! reservoir collapses NFSP to vanilla fictitious play and prevents
58//! convergence to NE — this is the critical invariant the paper's
59//! footnote calls out. Default `η = 0.1` per Heinrich & Silver §3.
60//!
61//! # Reservoir vs FIFO
62//!
63//! Heinrich & Silver §3.2 footnote 3: "Reservoir sampling avoids the
64//! policy distribution drift caused by uniformly drawing from a more
65//! recent window." A FIFO buffer biases the AP towards recent BR
66//! actions, which is roughly equivalent to learning a recency-weighted
67//! mixture — that mixture is *not* the time-average and need not match
68//! any fictitious-play fixed point. Vitter's Algorithm R keeps the
69//! held-items' distribution uniform across the entire history of
70//! pushes regardless of stream length.
71//!
72//! # Per-agent observation handling
73//!
74//! NFSP builds on top of
75//! [`crate::multi_agent::joint::JointMultiAgentTrainer`], which records
76//! a *per-agent* observation stream in
77//! [`JointRollout::observations_per_agent`]. Agent `i`'s reservoir
78//! receives agent `i`'s observation (not agent 0's), so partial-
79//! observability envs supervise the AP module on the correct view.
80//! Matching pennies returns identical observations to both agents,
81//! which keeps the regression tests bit-stable through the per-agent
82//! refactor (PR #118).
83//!
84//! # Determinism dependency on #109
85//!
86//! The reservoir buffer's eviction RNG, the η-anticipatory coin flips,
87//! and the average-policy minibatch sampler are all seeded from
88//! [`NfspConfig::seed`] via an internal `StdRng`. However, the inner
89//! [`crate::multi_agent::joint::JointMultiAgentTrainer::update_with_active_agents`]
90//! still uses `rand::rng()` for its per-epoch shuffle (see
91//! `src/multi_agent/joint.rs:662`), which means same-seed PSRO and
92//! NFSP runs are not yet bit-identical across wall-clock invocations.
93//! Issue #109 tracks the fix. The in-module determinism here is
94//! sufficient for the reservoir-uniformity and η-mixing unit tests in
95//! this PR.
96
97use anyhow::{Result, anyhow};
98use burn::{
99 optim::{GradientsParams, Optimizer},
100 tensor::{Int, Tensor, TensorData, backend::AutodiffBackend},
101};
102use rand::{Rng, SeedableRng, rngs::StdRng};
103use rayon::prelude::*;
104
105use crate::{
106 multi_agent::joint::{
107 JointEnv, JointMultiAgentTrainer, JointPolicy, JointStats, JointTrainerConfig,
108 },
109 train::optimizer::{BackendOptimizer, BurnOptimizer},
110};
111
112// =======================================================================
113// ReservoirBuffer — Vitter's Algorithm R
114// =======================================================================
115
116/// Reservoir-sampled buffer backing the NFSP average-policy supervised
117/// dataset. Implements Vitter (1985) Algorithm R: every distinct item
118/// streamed through `push` is retained with probability
119/// `capacity / stream_index` (1-indexed across the full lifetime of
120/// the buffer). Under capacity, all pushes are kept; at capacity, each
121/// push at stream index `i ≥ capacity + 1` is inserted with probability
122/// `capacity / i` replacing a uniformly-random existing slot.
123///
124/// # Correctness
125///
126/// The key invariant Vitter's algorithm guarantees is that at any
127/// point in time, the held items are a uniform sample of the entire
128/// stream history. This is the property NFSP needs from §3.2 — see the
129/// module doc for why FIFO would be wrong.
130///
131/// # Determinism
132///
133/// The internal RNG is seedable via [`ReservoirBuffer::with_seed`] so
134/// the trainer can produce repeatable buffer trajectories under a
135/// fixed [`NfspConfig::seed`].
136#[derive(Debug, Clone)]
137pub struct ReservoirBuffer<T: Clone> {
138 items: Vec<T>,
139 capacity: usize,
140 /// Total number of items ever pushed (the stream index `i` Vitter's
141 /// algorithm uses). 1-based by convention — the first push is at
142 /// index 1.
143 stream_index: usize,
144 rng: StdRng,
145}
146
147impl<T: Clone> ReservoirBuffer<T> {
148 /// Construct an empty reservoir with the given capacity, seeded
149 /// from a fixed value for reproducible tests.
150 pub fn with_seed(capacity: usize, seed: u64) -> Self {
151 Self {
152 items: Vec::with_capacity(capacity.max(1)),
153 capacity: capacity.max(1),
154 stream_index: 0,
155 rng: StdRng::seed_from_u64(seed),
156 }
157 }
158
159 /// Construct an empty reservoir using a fresh entropy-seeded RNG.
160 pub fn new(capacity: usize) -> Self {
161 let seed: u64 = rand::rng().random();
162 Self::with_seed(capacity, seed)
163 }
164
165 /// Currently-held items.
166 pub fn items(&self) -> &[T] {
167 &self.items
168 }
169
170 /// Number of items currently held (≤ capacity).
171 pub fn len(&self) -> usize {
172 self.items.len()
173 }
174
175 /// True if no items have been retained.
176 pub fn is_empty(&self) -> bool {
177 self.items.is_empty()
178 }
179
180 /// Maximum number of items the reservoir may hold.
181 pub fn capacity(&self) -> usize {
182 self.capacity
183 }
184
185 /// Total stream length (number of `push` calls observed,
186 /// regardless of retention). Vitter's `i` for the next incoming
187 /// item is `stream_length() + 1`.
188 pub fn stream_length(&self) -> usize {
189 self.stream_index
190 }
191
192 /// Push one item through the reservoir.
193 ///
194 /// Under capacity: appended unconditionally. At capacity: kept
195 /// with probability `capacity / i` (where `i` is the new
196 /// 1-indexed stream position), replacing a uniformly-random
197 /// existing slot.
198 ///
199 /// Returns `true` if the item was retained (either appended or
200 /// replaced an existing slot), `false` if it was discarded.
201 pub fn push(&mut self, item: T) -> bool {
202 self.stream_index += 1;
203 if self.items.len() < self.capacity {
204 self.items.push(item);
205 return true;
206 }
207 // Vitter Algorithm R: with probability capacity / i, replace
208 // a uniformly random slot.
209 let i = self.stream_index;
210 let j = self.rng.random_range(0..i);
211 if j < self.capacity {
212 self.items[j] = item;
213 true
214 } else {
215 false
216 }
217 }
218
219 /// Sample `n` items uniformly at random *with replacement* from
220 /// the currently-held reservoir. Returns an empty vec if the
221 /// reservoir is empty.
222 pub fn sample_with_replacement(&mut self, n: usize) -> Vec<T> {
223 if self.items.is_empty() {
224 return Vec::new();
225 }
226 let mut out = Vec::with_capacity(n);
227 for _ in 0..n {
228 let j = self.rng.random_range(0..self.items.len());
229 out.push(self.items[j].clone());
230 }
231 out
232 }
233}
234
235// =======================================================================
236// NfspConfig
237// =======================================================================
238
239/// NFSP trainer configuration.
240#[derive(Debug, Clone)]
241pub struct NfspConfig {
242 /// Number of NFSP outer-loop iterations.
243 pub max_iterations: usize,
244 /// η, the anticipatory-mixing parameter. Probability of sampling
245 /// from the best-response policy (and appending the action to the
246 /// reservoir) at each rollout step. Heinrich & Silver §3
247 /// recommend `η = 0.1` for 2-player zero-sum imperfect-information
248 /// games.
249 pub anticipatory_param: f32,
250 /// Maximum reservoir buffer size (per agent in the multi-agent
251 /// setting). Paper default is 2×10⁶; the matching-pennies smoke
252 /// test runs with ~2000.
253 pub reservoir_capacity: usize,
254 /// Number of BR train steps (one rollout + one PPO update per
255 /// step) per outer iteration.
256 pub br_train_steps_per_iteration: usize,
257 /// Number of supervised cross-entropy steps applied to the AP per
258 /// outer iteration (skipped when the reservoir is empty).
259 ///
260 /// This is the *floor* on AP steps. When
261 /// [`avg_policy_min_reservoir_coverage`](Self::avg_policy_min_reservoir_coverage)
262 /// is `> 0` the trainer may run *more* than this many steps so the
263 /// AP sees enough of the reservoir per iteration (issue #199).
264 pub avg_policy_train_steps_per_iteration: usize,
265 /// Average-policy supervised minibatch size.
266 pub avg_policy_minibatch_size: usize,
267 /// Average-policy learning rate.
268 pub avg_policy_lr: f64,
269 /// Minimum reservoir *coverage* per outer iteration for the AP
270 /// supervised update, expressed as a multiple of the current
271 /// reservoir size (issue #199).
272 ///
273 /// NFSP's average policy is fit by sampling minibatches from a
274 /// reservoir that grows into the thousands of entries, while the
275 /// default budget of `avg_policy_train_steps_per_iteration ×
276 /// avg_policy_minibatch_size` may only touch a few hundred samples
277 /// per iteration — far too few gradient steps to fit a moving
278 /// target, so the AP loss stays pinned at the uniform-entropy floor
279 /// (`ln(num_joint_actions)`). On bucket-brigade this manifested as
280 /// `avg_ap_loss ≈ ln(40)` for an entire 48-iteration run.
281 ///
282 /// When `coverage > 0`, the trainer runs
283 /// `max(avg_policy_train_steps_per_iteration,
284 /// ceil(coverage × reservoir_len / avg_policy_minibatch_size))`
285 /// supervised steps for each agent each iteration, so the AP is
286 /// exposed to (in expectation) `coverage` full passes over the
287 /// reservoir. `coverage = 0.0` disables the adaptive floor and
288 /// preserves the legacy fixed-step behavior. Default `0.0`.
289 pub avg_policy_min_reservoir_coverage: f32,
290 /// Optional reward scaling applied to per-step rewards before the BR
291 /// (PPO) update, to keep the large-magnitude bucket-brigade payoff
292 /// band (`[−700, 0]`) from dominating value targets and advantage
293 /// normalization (issue #199).
294 ///
295 /// Rewards in the anticipatory rollout are multiplied by this factor
296 /// before being handed to the joint PPO update. `1.0` (default) is a
297 /// no-op; a value like `0.01` rescales the bucket-brigade band to
298 /// roughly `[−7, 0]`. Scaling rewards uniformly does not change the
299 /// optimal policy (it is an affine transform of the return), but it
300 /// does keep the critic's regression targets and the advantage
301 /// statistics in a numerically friendlier range.
302 pub br_reward_scale: f32,
303 /// RNG seed (η-flips, reservoir eviction, AP minibatch sampling).
304 pub seed: u64,
305}
306
307impl Default for NfspConfig {
308 fn default() -> Self {
309 Self {
310 max_iterations: 10,
311 anticipatory_param: 0.1,
312 reservoir_capacity: 2_000,
313 br_train_steps_per_iteration: 1,
314 avg_policy_train_steps_per_iteration: 1,
315 avg_policy_minibatch_size: 32,
316 avg_policy_lr: 1e-3,
317 avg_policy_min_reservoir_coverage: 0.0,
318 br_reward_scale: 1.0,
319 seed: 0,
320 }
321 }
322}
323
324// =======================================================================
325// NfspStats
326// =======================================================================
327
328/// Per-iteration NFSP statistics.
329#[derive(Debug, Clone, Default)]
330pub struct NfspIterationStats {
331 /// Iteration index (1-based).
332 pub iteration: usize,
333 /// Per-agent reservoir size at the end of the iteration.
334 pub reservoir_sizes: Vec<usize>,
335 /// Per-agent best-response training stats (the last
336 /// joint-trainer call's `JointStats`).
337 pub br_stats: Option<JointStats>,
338 /// Per-agent average-policy cross-entropy loss (averaged across
339 /// AP supervised steps). `None` if the AP was skipped because the
340 /// reservoir was empty.
341 pub avg_policy_loss: Vec<Option<f64>>,
342 /// Per-agent BR-action marginal under the constant matching-
343 /// pennies observation. `None` when the trainer is not on a
344 /// constant-obs env (callers may ignore on non-matching-pennies
345 /// envs).
346 pub br_action_marginal: Vec<Option<Vec<f32>>>,
347 /// Per-agent AP-action marginal under the constant matching-
348 /// pennies observation. `None` when the trainer is not on a
349 /// constant-obs env.
350 pub avg_action_marginal: Vec<Option<Vec<f32>>>,
351 /// Cumulative count of BR-sampled actions (across the η-mixing
352 /// rollout collector) since the trainer was constructed.
353 pub cumulative_br_pushes: usize,
354 /// Cumulative count of rollout steps observed (across all agents).
355 pub cumulative_rollout_steps: usize,
356}
357
358/// Aggregate NFSP trainer statistics returned by [`NfspTrainer::run`].
359#[derive(Debug, Clone, Default)]
360pub struct NfspStats {
361 /// Per-iteration history.
362 pub iterations: Vec<NfspIterationStats>,
363}
364
365// =======================================================================
366// NfspTrainer
367// =======================================================================
368
369/// NFSP outer-loop trainer.
370///
371/// Generic over the Burn backend `B`, policy module `P`, optimizer
372/// type `O`, env `E`, and the user-supplied policy/optimizer/env
373/// factories. The trainer owns:
374///
375/// - A `JointMultiAgentTrainer` holding the `N` best-response (BR) policies. BR
376/// updates go through [`JointMultiAgentTrainer::update_with_active_agents`] —
377/// the freeze-N-1 primitive PSRO also uses.
378/// - `N` average-policy (AP) Burn modules and their independent optimizers. AP
379/// updates are plain supervised cross-entropy on reservoir samples.
380/// - `N` reservoir buffers (one per agent) carrying `(obs, action)` pairs
381/// harvested from BR-sampled rollout steps.
382/// - An internal `StdRng` (seeded from [`NfspConfig::seed`]) used for
383/// η-anticipatory coin flips, reservoir eviction, and AP minibatch sampling.
384///
385/// As of #119, `joint_config.num_agents` may be any value `≥ 2`. The
386/// formal ε-Nash convergence guarantee from Heinrich & Silver 2016 §3
387/// holds only for N = 2 zero-sum games; for N > 2 this trainer ships
388/// the same per-agent BR/AP/reservoir machinery as an "approximate
389/// NFSP" — see the module docstring for the caveat.
390///
391/// # Single-policy-class assumption
392///
393/// All `2N` policies (`N` BR + `N` AP) share the same module type
394/// `P`. For symmetric matching-pennies-style games this is what we
395/// want.
396///
397/// # See also
398///
399/// - [`crate::multi_agent::psro::PsroTrainer`] — sibling meta-game trainer with
400/// the same JointMultiAgentTrainer-based freeze-N-1 plumbing.
401pub struct NfspTrainer<B, P, O, E, FP, FO, FE>
402where
403 B: AutodiffBackend,
404 P: JointPolicy<B>,
405 O: Optimizer<P, B>,
406 E: JointEnv,
407 FP: Fn(&B::Device, u64) -> P,
408 FO: Fn() -> BurnOptimizer<B, P, O>,
409 FE: Fn() -> E,
410{
411 config: NfspConfig,
412 joint_config: JointTrainerConfig,
413 device: B::Device,
414 /// Joint trainer holding the two BR policies.
415 br_trainer: JointMultiAgentTrainer<B, P, O>,
416 /// Per-agent average policy. Stored in `Option<P>` to match Burn's
417 /// move-through optimizer semantics: we `.take()` before stepping
418 /// and put back after.
419 avg_policies: Vec<Option<P>>,
420 /// Per-agent average-policy optimizer.
421 avg_optimizers: Vec<BurnOptimizer<B, P, O>>,
422 /// Per-agent reservoir buffer of `(obs, action_vec)` pairs.
423 ///
424 /// `action_vec` is the per-step joint action with one entry per
425 /// action dim — `Vec<i64>` of length `num_action_dims`. For
426 /// single-discrete policies (`MlpBurnPolicy`) this is a length-1 vec
427 /// (equivalent to the pre-#127 scalar form); for multi-discrete
428 /// policies (`MultiDiscreteMlpBurnPolicy` — e.g. bucket-brigade
429 /// `[10, 2, 2]`) it carries one entry per factored head, matching
430 /// what `evaluate_actions_joint` expects (`[mb, num_action_dims]`).
431 reservoirs: Vec<ReservoirBuffer<(Vec<f32>, Vec<i64>)>>,
432 /// Internal RNG (η-flips, AP minibatch sampling).
433 rng: StdRng,
434 /// Cumulative BR pushes across all agents (diagnostic).
435 cumulative_br_pushes: usize,
436 /// Cumulative rollout step count across all agents (diagnostic).
437 cumulative_rollout_steps: usize,
438 /// Held factories — used by `train_best_response` to spin up
439 /// fresh envs and (in principle) policies; kept for symmetry with
440 /// PSRO's `PsroTrainer` API.
441 #[allow(dead_code)]
442 policy_factory: FP,
443 #[allow(dead_code)]
444 optimizer_factory: FO,
445 env_factory: FE,
446}
447
448impl<B, P, O, E, FP, FO, FE> NfspTrainer<B, P, O, E, FP, FO, FE>
449where
450 B: AutodiffBackend,
451 P: JointPolicy<B>,
452 O: Optimizer<P, B>,
453 E: JointEnv,
454 FP: Fn(&B::Device, u64) -> P,
455 FO: Fn() -> BurnOptimizer<B, P, O>,
456 FE: Fn() -> E,
457{
458 /// Construct an NFSP trainer with fresh BR and AP policies.
459 ///
460 /// `joint_config.num_agents` must be `≥ 2`. For N = 2 the trainer
461 /// retains Heinrich & Silver §3's ε-Nash convergence guarantee on
462 /// zero-sum games; for N > 2 it ships as "approximate NFSP" with
463 /// no formal guarantee beyond per-agent reservoir uniformity (see
464 /// the module docstring).
465 #[allow(clippy::too_many_arguments)]
466 pub fn new(
467 config: NfspConfig,
468 joint_config: JointTrainerConfig,
469 device: B::Device,
470 policy_factory: FP,
471 optimizer_factory: FO,
472 env_factory: FE,
473 ) -> Result<Self> {
474 if joint_config.num_agents < 2 {
475 return Err(anyhow!(
476 "NfspTrainer requires joint_config.num_agents >= 2 (got {})",
477 joint_config.num_agents
478 ));
479 }
480 if !(0.0..=1.0).contains(&config.anticipatory_param) {
481 return Err(anyhow!(
482 "NfspConfig::anticipatory_param must be in [0,1], got {}",
483 config.anticipatory_param
484 ));
485 }
486 let num_agents = joint_config.num_agents;
487 // Per-construction init seeds (issue #135, Correction 1): every
488 // BR and AP policy gets a distinct, deterministic seed derived
489 // from `config.seed` so they start from different — but
490 // reproducible — weights. A factory closing over a single fixed
491 // seed would otherwise hand every policy identical weights. BR
492 // policies use counter `agent`; AP policies use
493 // `num_agents + agent`, keeping the two banks disjoint.
494 let init_seed = |idx: u64| config.seed.wrapping_add(0x9E37_79B9_u64.wrapping_mul(idx));
495 let br_policies: Vec<P> =
496 (0..num_agents).map(|i| policy_factory(&device, init_seed(i as u64))).collect();
497 let br_optimizers: Vec<BurnOptimizer<B, P, O>> =
498 (0..num_agents).map(|_| optimizer_factory()).collect();
499 let br_trainer = JointMultiAgentTrainer::<B, P, O>::new(
500 br_policies,
501 br_optimizers,
502 joint_config.clone(),
503 device.clone(),
504 )?;
505 let avg_policies: Vec<Option<P>> = (0..num_agents)
506 .map(|i| Some(policy_factory(&device, init_seed((num_agents + i) as u64))))
507 .collect();
508 let avg_optimizers: Vec<BurnOptimizer<B, P, O>> =
509 (0..num_agents).map(|_| optimizer_factory()).collect();
510 let reservoirs = (0..num_agents)
511 .map(|i| {
512 // Seed each reservoir from a derivation of the master
513 // seed so they're decorrelated but reproducible.
514 ReservoirBuffer::with_seed(
515 config.reservoir_capacity,
516 config.seed.wrapping_add(0x1ABC + i as u64),
517 )
518 })
519 .collect();
520 let rng = StdRng::seed_from_u64(config.seed.wrapping_add(0xC0DE));
521 Ok(Self {
522 config,
523 joint_config,
524 device,
525 br_trainer,
526 avg_policies,
527 avg_optimizers,
528 reservoirs,
529 rng,
530 cumulative_br_pushes: 0,
531 cumulative_rollout_steps: 0,
532 policy_factory,
533 optimizer_factory,
534 env_factory,
535 })
536 }
537
538 /// Borrow agent `i`'s best-response (BR) policy.
539 pub fn br_policy(&self, i: usize) -> &P {
540 self.br_trainer.policy(i)
541 }
542
543 /// Borrow agent `i`'s average (AP) policy.
544 pub fn avg_policy(&self, i: usize) -> &P {
545 self.avg_policies[i].as_ref().expect("AP policy is None mid-update")
546 }
547
548 /// Borrow agent `i`'s reservoir buffer.
549 pub fn reservoir(&self, i: usize) -> &ReservoirBuffer<(Vec<f32>, Vec<i64>)> {
550 &self.reservoirs[i]
551 }
552
553 /// Cumulative BR-sampled push count across all reservoirs.
554 pub fn cumulative_br_pushes(&self) -> usize {
555 self.cumulative_br_pushes
556 }
557
558 /// Cumulative rollout-step count across all agents.
559 pub fn cumulative_rollout_steps(&self) -> usize {
560 self.cumulative_rollout_steps
561 }
562
563 /// Trainer configuration.
564 pub fn config(&self) -> &NfspConfig {
565 &self.config
566 }
567
568 /// Run the NFSP outer loop and return the per-iteration history.
569 pub fn run<F>(&mut self, mut on_iteration: F) -> Result<NfspStats>
570 where
571 F: FnMut(&NfspIterationStats),
572 // Propagated from `train_average_policies` (issue #234): the
573 // per-agent AP supervised loop runs concurrently with rayon.
574 P: Send + Sync,
575 O: Send,
576 B::Device: Sync,
577 {
578 let mut stats = NfspStats::default();
579 for iter in 1..=self.config.max_iterations {
580 let mut last_br_stats: Option<JointStats> = None;
581
582 // Issue #251 AC#1 profile: coarse wall-clock attribution of the
583 // outer loop's three phases (BR rollout vs BR PPO update vs AP
584 // supervised training). Cheap `Instant` accumulators, logged once
585 // per iteration so the dominant cost is visible without any
586 // heavyweight tooling.
587 let mut br_rollout_time = std::time::Duration::ZERO;
588 let mut br_update_time = std::time::Duration::ZERO;
589
590 for _ in 0..self.config.br_train_steps_per_iteration {
591 // Collect rollout with η-anticipatory mixing and
592 // reservoir push.
593 let rollout_start = std::time::Instant::now();
594 let rollout = self.collect_anticipatory_rollout()?;
595 br_rollout_time += rollout_start.elapsed();
596 // Drive both BR policies via a single joint update
597 // (active mask = [true, true]). Heinrich & Silver §3
598 // Algorithm 1 runs the BR updates jointly per
599 // iteration; this also matches PSRO's freeze-N-1 path
600 // when N = 2 and both agents are "active".
601 let active = vec![true; self.joint_config.num_agents];
602 let update_start = std::time::Instant::now();
603 let bs = self.br_trainer.update_with_active_agents(
604 &rollout,
605 &active,
606 &mut self.rng,
607 |_features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> { None },
608 )?;
609 br_update_time += update_start.elapsed();
610 last_br_stats = Some(bs);
611 }
612
613 // Average-policy supervised step.
614 let ap_start = std::time::Instant::now();
615 let avg_losses = self.train_average_policies()?;
616 let ap_time = ap_start.elapsed();
617
618 // Issue #251 AC#1: per-iteration phase breakdown.
619 let br_rollout_s = br_rollout_time.as_secs_f64();
620 let br_update_s = br_update_time.as_secs_f64();
621 let ap_s = ap_time.as_secs_f64();
622 let phase_total = br_rollout_s + br_update_s + ap_s;
623 let pct = |x: f64| {
624 if phase_total > 0.0 {
625 100.0 * x / phase_total
626 } else {
627 0.0
628 }
629 };
630 tracing::info!(
631 "iter {iter} phase timing (issue #251): br_rollout={br_rollout_s:.1}s ({:.0}%) \
632 br_update={br_update_s:.1}s ({:.0}%) ap_train={ap_s:.1}s ({:.0}%) \
633 [total {phase_total:.1}s]",
634 pct(br_rollout_s),
635 pct(br_update_s),
636 pct(ap_s),
637 );
638
639 // Diagnostics: action marginals for the matching-pennies
640 // constant observation `[0.0]`. We emit these
641 // unconditionally; non-constant-obs envs can ignore.
642 let num_agents = self.joint_config.num_agents;
643 let mut br_marginals: Vec<Option<Vec<f32>>> = Vec::with_capacity(num_agents);
644 let mut avg_marginals: Vec<Option<Vec<f32>>> = Vec::with_capacity(num_agents);
645 for i in 0..num_agents {
646 // Clone the policy references to release the `&self`
647 // borrow on `self.br_policy(i)` / `self.avg_policy(i)`
648 // before `action_marginal_for` mutably borrows
649 // `self.rng` for the seeded probe loop (#114).
650 let br_policy_i = self.br_policy(i).clone();
651 let avg_policy_i = self.avg_policy(i).clone();
652 br_marginals.push(self.action_marginal_for(&br_policy_i));
653 avg_marginals.push(self.action_marginal_for(&avg_policy_i));
654 }
655
656 let iter_stats = NfspIterationStats {
657 iteration: iter,
658 reservoir_sizes: self.reservoirs.iter().map(|r| r.len()).collect(),
659 br_stats: last_br_stats,
660 avg_policy_loss: avg_losses,
661 br_action_marginal: br_marginals,
662 avg_action_marginal: avg_marginals,
663 cumulative_br_pushes: self.cumulative_br_pushes,
664 cumulative_rollout_steps: self.cumulative_rollout_steps,
665 };
666 on_iteration(&iter_stats);
667 stats.iterations.push(iter_stats);
668 }
669 Ok(stats)
670 }
671
672 /// Convenience entry point: drives [`Self::run`] with a no-op
673 /// iteration callback.
674 pub fn run_silent(&mut self) -> Result<NfspStats>
675 where
676 // Propagated from `run` -> `train_average_policies` (issue #234).
677 P: Send + Sync,
678 O: Send,
679 B::Device: Sync,
680 {
681 self.run(|_| {})
682 }
683
684 /// Average-policy action distribution under a *constant* observation
685 /// `[0.0; obs_dim]`. Returns `None` if the policy's `action_dims`
686 /// is unsupported (e.g. multi-dim multi-discrete). Used as a
687 /// diagnostic on matching-pennies and as the load-bearing
688 /// convergence assertion in `tests/test_nfsp_matching_pennies.rs`.
689 ///
690 /// Takes `&mut self` because the 128-probe sampling loop consumes
691 /// the trainer-owned `StdRng` via
692 /// [`JointPolicy::get_action_host_seeded`]. This makes the
693 /// diagnostic reproducible under `NfspConfig::seed` (issue #114).
694 /// Callers that hold a `&P` from [`Self::avg_policy`] / [`Self::br_policy`]
695 /// must `.clone()` the policy before calling this method to avoid a
696 /// `&self` / `&mut self` aliasing conflict — the policy clone is
697 /// cheap (Burn modules are `Clone` by design).
698 pub fn action_marginal_for(&mut self, policy: &P) -> Option<Vec<f32>> {
699 let dims = policy.action_dims_joint();
700 if dims.len() != 1 {
701 return None;
702 }
703 let action_dim = dims[0] as usize;
704 // We don't know the true obs_dim without an env. The trainer's
705 // `joint_config` carries observation-agnostic config; we infer
706 // obs_dim from the reservoirs (which were populated by the
707 // env) when possible. Falls back to obs_dim = 1 (matching
708 // pennies' OBS_DIM) when reservoirs are empty.
709 let obs_dim = self
710 .reservoirs
711 .iter()
712 .find_map(|r| r.items().first().map(|(obs, _)| obs.len()))
713 .unwrap_or(1);
714 // Use a forward+softmax probe. Implementations of
715 // `JointPolicy` for `MlpBurnPolicy` and
716 // `MultiDiscreteMlpBurnPolicy` both implement the standard
717 // policy-head structure; we don't have a direct `logits()`
718 // method on the trait, so we sample the policy repeatedly and
719 // take the empirical marginal as the diagnostic. For a single-row
720 // constant observation this is correct in expectation; it's a
721 // host-side probe with no autograd cost.
722 //
723 // Issue #235: all `probes` rows are the **same constant
724 // observation** scored through the **same** policy, so we stack
725 // them into one `[probes, obs_dim]` tensor and do a *single*
726 // batched forward via `get_actions_host_seeded_batched` instead
727 // of `probes` batch-1 forwards. The batched sampler draws one
728 // `rng.random()` per row in ascending order — bit-identical to
729 // the old per-probe loop — so the seeded marginal trace is
730 // unchanged (guarded by `test_nfsp_avg_marginal_trace_is_bit_identical`).
731 let probes = 128usize;
732 let obs_data: Vec<f32> = vec![0.0_f32; probes * obs_dim];
733 let obs =
734 Tensor::<B, 2>::from_data(TensorData::new(obs_data, [probes, obs_dim]), &self.device);
735 let (acts, _, _) = policy.get_actions_host_seeded_batched(obs, &mut self.rng);
736 let mut counts = vec![0u32; action_dim];
737 // Scalar-discrete (`dims.len() == 1`) guaranteed above, so the
738 // batched action layout is one entry per row.
739 for &a in acts.iter().take(probes) {
740 let idx = a as usize;
741 if idx < action_dim {
742 counts[idx] += 1;
743 }
744 }
745 Some(counts.iter().map(|&c| c as f32 / probes as f32).collect())
746 }
747
748 /// Collect a synchronized rollout of length
749 /// `joint_config.rollout_steps` with η-anticipatory mixing. Each
750 /// step independently flips one Bernoulli(η) coin per agent: with
751 /// probability `η` it samples the action from the BR policy and
752 /// pushes the `(obs, action)` pair into that agent's reservoir;
753 /// otherwise it samples from the AP and does NOT push.
754 ///
755 /// The action that's actually fed into `env.step_joint` is the
756 /// per-step choice (BR or AP). The resulting [`JointRollout`]
757 /// carries those per-step actions plus the rollout-time
758 /// log-probs/values from the BR policy (we use the BR's value
759 /// estimate either way — the value head is on the BR policy and
760 /// the AP module does not maintain critic targets in this
761 /// implementation).
762 fn collect_anticipatory_rollout(&mut self) -> Result<crate::multi_agent::joint::JointRollout> {
763 use crate::multi_agent::joint::JointRollout;
764 let num_steps = self.joint_config.rollout_steps;
765 let num_agents = self.joint_config.num_agents;
766 let mut env = (self.env_factory)();
767 let initial_obs = env.reset_joint(Some(self.config.seed));
768 let obs_dim = initial_obs[0].len();
769 // Per-agent persistent observation: agent `i` carries its own view.
770 let mut last_obs: Vec<Vec<f32>> = initial_obs;
771 let device = self.device.clone();
772
773 // Probe num_action_dims from BR policy 0.
774 let num_action_dims: usize = self.br_policy(0).action_dims_joint().len();
775
776 let mut obs_buf_per_agent: Vec<Vec<f32>> =
777 (0..num_agents).map(|_| vec![0.0_f32; num_steps * obs_dim]).collect();
778 let mut act_buf: Vec<Vec<i64>> =
779 (0..num_agents).map(|_| vec![0_i64; num_steps * num_action_dims]).collect();
780 let mut lp_buf: Vec<Vec<f32>> = (0..num_agents).map(|_| vec![0.0_f32; num_steps]).collect();
781 let mut val_buf: Vec<Vec<f32>> =
782 (0..num_agents).map(|_| vec![0.0_f32; num_steps]).collect();
783 let mut rew_buf: Vec<Vec<f32>> =
784 (0..num_agents).map(|_| vec![0.0_f32; num_steps]).collect();
785 let mut done_buf = vec![0.0_f32; num_steps];
786
787 for t in 0..num_steps {
788 let start = t * obs_dim;
789
790 let mut joint_action: Vec<Vec<i64>> = Vec::with_capacity(num_agents);
791 for i in 0..num_agents {
792 // Per-agent observation: each agent sees its own view.
793 let agent_obs = last_obs[i].clone();
794 obs_buf_per_agent[i][start..start + obs_dim].copy_from_slice(&agent_obs);
795 let obs_t = Tensor::<B, 2>::from_data(
796 TensorData::new(agent_obs.clone(), [1, obs_dim]),
797 &device,
798 );
799
800 // Forward the BR policy unconditionally — we use its
801 // log-prob / value as the rollout-time bookkeeping for
802 // the PPO update regardless of which path provided the
803 // sampled action. This matches Heinrich & Silver §3
804 // Algorithm 1: the BR is the PPO learner and uses its
805 // own log-prob targets.
806 //
807 // Clone the BR policy out of `self.br_trainer` so we
808 // can release that borrow before passing `&mut self.rng`
809 // to the seeded sampler. Burn modules are cheap to
810 // clone (parameters live behind `Arc`).
811 let br_policy_i = self.br_trainer.policy(i).clone();
812 let (br_actions, br_log_probs, br_values) =
813 br_policy_i.get_action_host_seeded(obs_t.clone(), &mut self.rng);
814
815 // η-coin: BR (push to reservoir) vs AP (do NOT push).
816 let u: f32 = self.rng.random();
817 let take_br = u < self.config.anticipatory_param;
818 let row: Vec<i64> = if take_br {
819 let row = br_actions[..num_action_dims].to_vec();
820 // Reservoir push for the BR action only. Agent `i`'s
821 // reservoir gets agent `i`'s observation — not agent
822 // 0's — so partial-observability envs (PR #118
823 // refactor) record the correct supervised target for
824 // the behavior-cloning update.
825 //
826 // Push the full per-dim action vector (length
827 // `num_action_dims`). For single-discrete policies
828 // this is a length-1 vec; for multi-discrete
829 // factored policies (e.g. bucket-brigade
830 // `[10, 2, 2]`) it preserves the per-head action so
831 // the supervised CE step in
832 // `train_average_policies` can feed
833 // `evaluate_actions_joint` a properly-shaped
834 // `[mb, num_action_dims]` int tensor (issue #127).
835 self.reservoirs[i].push((agent_obs.clone(), row.clone()));
836 self.cumulative_br_pushes += 1;
837 row
838 } else {
839 // Same clone pattern: release the `&self` borrow on
840 // `self.avg_policy(i)` before mutably borrowing
841 // `self.rng` through the seeded sampler.
842 let ap_policy_i = self.avg_policy(i).clone();
843 let (ap_actions, _, _) =
844 ap_policy_i.get_action_host_seeded(obs_t, &mut self.rng);
845 ap_actions[..num_action_dims].to_vec()
846 };
847
848 let off = t * num_action_dims;
849 act_buf[i][off..off + num_action_dims].copy_from_slice(&row);
850 joint_action.push(row);
851
852 // Always record BR's rollout-time bookkeeping. We use
853 // BR's log-prob even when the action was AP-sampled:
854 // PPO clipping treats the recorded `old_log_prob` as
855 // a behavior reference, and using BR's log-prob keeps
856 // the surrogate's "current policy / old policy" ratio
857 // well-defined under the BR's training update. (When
858 // the action was AP-sampled, the BR's log-prob is the
859 // log-prob the BR *would* have assigned to that
860 // action — that's the correct importance-sampling
861 // reference for behavior cloning into the BR.)
862 lp_buf[i][t] = br_log_probs.first().copied().unwrap_or(0.0);
863 val_buf[i][t] = br_values.first().copied().unwrap_or(0.0);
864 }
865
866 let result = env.step_joint(&joint_action);
867 // Index-based scan: mirrors `JointMultiAgentTrainer::collect_rollout`'s
868 // per-agent reward fan-out. Apply the optional reward scale
869 // (issue #199): scaling rewards uniformly is an affine
870 // transform of the return and does not change the optimal
871 // policy, but keeps the large-magnitude bucket-brigade band
872 // (`[−700, 0]`) in a numerically friendlier range for the
873 // BR critic's regression targets and advantage stats.
874 let reward_scale = self.config.br_reward_scale;
875 #[allow(clippy::needless_range_loop)]
876 for i in 0..num_agents {
877 rew_buf[i][t] = result.rewards[i] * reward_scale;
878 }
879 done_buf[t] = if result.done { 1.0 } else { 0.0 };
880
881 if result.done {
882 let fresh = env.reset_joint(None);
883 last_obs[..num_agents].clone_from_slice(&fresh[..num_agents]);
884 } else {
885 last_obs[..num_agents].clone_from_slice(&result.observations[..num_agents]);
886 }
887 self.cumulative_rollout_steps += 1;
888 }
889
890 Ok(JointRollout {
891 observations_per_agent: obs_buf_per_agent,
892 // NFSP's envs (bucket-brigade) expose a uniform per-agent
893 // observation, so every agent shares `obs_dim` here (issue #275
894 // generalized the field to per-agent dims).
895 obs_dims: vec![obs_dim; num_agents],
896 actions: act_buf,
897 num_action_dims,
898 log_probs: lp_buf,
899 values: val_buf,
900 rewards: rew_buf,
901 dones: done_buf,
902 })
903 }
904
905 /// One supervised cross-entropy step per agent over the agent's
906 /// reservoir buffer. Skips agents whose reservoir is empty.
907 /// Returns per-agent mean loss across all the supervised steps
908 /// (`None` if no steps were taken for that agent).
909 ///
910 /// # Parallelism (issue #234)
911 ///
912 /// The per-agent loop runs the `num_agents` agents concurrently with
913 /// rayon. Each agent's mutable state is **disjoint**
914 /// (`reservoirs[i]`, `avg_policies[i]`, `avg_optimizers[i]`), so we
915 /// zip the three per-agent vectors into a single `par_iter_mut`,
916 /// handing each task an independent `(&mut reservoir, &mut policy,
917 /// &mut optimizer)` triple — no `Mutex`, no shared mutable borrow.
918 /// The only shared state is read-only (`&self.config`, `&self.device`)
919 /// and the per-agent `num_action_dims`, which is probed from the BR
920 /// trainer *before* the parallel section (so the immutable
921 /// `self.br_trainer` borrow does not collide with the per-agent
922 /// `&mut` borrows).
923 ///
924 /// # Determinism
925 ///
926 /// AP minibatches are drawn from each reservoir's **own** seeded RNG
927 /// (`ReservoirBuffer::rng`, see `sample_with_replacement`), never from
928 /// the shared `self.rng`. Sampling is therefore agent-local and the
929 /// `avg_ap_loss` trajectory is unchanged vs. the previous serial loop
930 /// and invariant to the rayon thread count: each agent consumes
931 /// exactly its own RNG stream regardless of interleaving. (The serial
932 /// loop already sourced AP sampling from the per-reservoir RNG, so
933 /// this change does not alter the RNG sourcing.)
934 fn train_average_policies(&mut self) -> Result<Vec<Option<f64>>>
935 where
936 P: Send + Sync,
937 O: Send,
938 B::Device: Sync,
939 {
940 let num_agents = self.joint_config.num_agents;
941 let steps = self.config.avg_policy_train_steps_per_iteration;
942 // Skip entirely only when *both* the fixed budget and the
943 // adaptive coverage floor are zero — otherwise coverage may
944 // still force supervised steps even with `steps == 0` (issue
945 // #199).
946 if steps == 0 && self.config.avg_policy_min_reservoir_coverage <= 0.0 {
947 return Ok(vec![None; num_agents]);
948 }
949
950 // Probe `num_action_dims` from the BR policy for each agent
951 // *before* the parallel section. PR #103 / issue #127: the AP
952 // policy shares the same factored-action shape as the BR policy,
953 // and `evaluate_actions_joint` expects actions of shape
954 // `[mb, num_action_dims]` for both `MlpBurnPolicy` (length-1
955 // columns) and `MultiDiscreteMlpBurnPolicy`
956 // (length-`num_action_dims` columns). Hoisting this read here
957 // releases the immutable `self.br_trainer` borrow so the rayon
958 // closures can take disjoint `&mut` borrows of the per-agent
959 // state below.
960 let num_action_dims: Vec<usize> = (0..num_agents)
961 .map(|i| self.br_trainer.policy(i).action_dims_joint().len())
962 .collect();
963
964 // Bind read-only field borrows into locals so the rayon closures
965 // capture *these* references and NOT the whole `&self` (which
966 // also holds the non-`Sync` factory closures). Mirrors the PSRO
967 // payoff-parallel path.
968 let mb_size = self.config.avg_policy_minibatch_size.max(1);
969 let coverage = self.config.avg_policy_min_reservoir_coverage;
970 let device = &self.device;
971
972 // Disjoint per-agent state: zip the three per-agent vectors so
973 // each rayon task owns exactly one agent's `(&mut reservoir,
974 // &mut policy, &mut optimizer)` triple.
975 let losses: Result<Vec<Option<f64>>> = self
976 .reservoirs
977 .par_iter_mut()
978 .zip(self.avg_policies.par_iter_mut())
979 .zip(self.avg_optimizers.par_iter_mut())
980 .zip(num_action_dims.par_iter())
981 .map(|(((reservoir, avg_policy), avg_optimizer), &n_dims)| {
982 train_one_average_policy::<B, P, O>(
983 reservoir,
984 avg_policy,
985 avg_optimizer,
986 n_dims,
987 steps,
988 mb_size,
989 coverage,
990 device,
991 )
992 })
993 .collect();
994 losses
995 }
996}
997
998/// Run the average-policy supervised steps for a single agent over its
999/// own (disjoint) reservoir, policy, and optimizer. Pulled out as a free
1000/// function so [`NfspTrainer::train_average_policies`] can invoke it from
1001/// inside a rayon parallel iterator with each task owning an independent
1002/// set of `&mut` borrows. See that method's docstring for the
1003/// borrow-splitting and determinism rationale.
1004///
1005/// Returns the per-agent mean loss across the supervised steps (`None`
1006/// if no steps were taken, e.g. an empty reservoir).
1007#[allow(clippy::too_many_arguments)]
1008fn train_one_average_policy<B, P, O>(
1009 reservoir: &mut ReservoirBuffer<(Vec<f32>, Vec<i64>)>,
1010 avg_policy: &mut Option<P>,
1011 avg_optimizer: &mut BurnOptimizer<B, P, O>,
1012 num_action_dims: usize,
1013 steps: usize,
1014 mb_size: usize,
1015 coverage: f32,
1016 device: &B::Device,
1017) -> Result<Option<f64>>
1018where
1019 B: AutodiffBackend,
1020 P: JointPolicy<B>,
1021 O: Optimizer<P, B>,
1022{
1023 if reservoir.is_empty() {
1024 return Ok(None);
1025 }
1026 // Adaptive step floor (issue #199): when
1027 // `avg_policy_min_reservoir_coverage > 0`, run enough supervised
1028 // steps that the AP sees `coverage` full passes over the current
1029 // reservoir, so a large reservoir is not starved by a tiny fixed
1030 // step budget (the root cause of the `avg_ap_loss` pinned at
1031 // `ln(num_joint_actions)` on bucket-brigade). The configured `steps`
1032 // remains the floor.
1033 let agent_steps = if coverage > 0.0 {
1034 let res_len = reservoir.len();
1035 let needed = (coverage as f64 * res_len as f64 / mb_size as f64).ceil() as usize;
1036 steps.max(needed)
1037 } else {
1038 steps
1039 };
1040 let mut sum_loss = 0.0_f64;
1041 let mut n_steps_done = 0usize;
1042 for _ in 0..agent_steps {
1043 // Draw from the reservoir's OWN seeded RNG (issue #234): AP
1044 // sampling is agent-local-deterministic and invariant to the
1045 // rayon thread count.
1046 let batch = reservoir.sample_with_replacement(mb_size);
1047 if batch.is_empty() {
1048 continue;
1049 }
1050 let obs_dim = batch[0].0.len();
1051 let mb = batch.len();
1052 // Flatten obs into [mb, obs_dim], actions into
1053 // [mb, num_action_dims]. Each batch row's action vec must already
1054 // have length `num_action_dims` — that invariant is established
1055 // at push-time in `collect_anticipatory_rollout` (#127).
1056 let mut obs_flat = Vec::with_capacity(mb * obs_dim);
1057 let mut acts_flat = Vec::with_capacity(mb * num_action_dims);
1058 for (o, a) in &batch {
1059 debug_assert_eq!(
1060 a.len(),
1061 num_action_dims,
1062 "reservoir action vec length {} != num_action_dims {}",
1063 a.len(),
1064 num_action_dims,
1065 );
1066 obs_flat.extend_from_slice(o);
1067 acts_flat.extend_from_slice(a);
1068 }
1069 let obs_t = Tensor::<B, 2>::from_data(TensorData::new(obs_flat, [mb, obs_dim]), device);
1070 // Build the action tensor directly at shape
1071 // `[mb, num_action_dims]` — no `unsqueeze_dim` step. For
1072 // single-discrete (`MlpBurnPolicy`, `num_action_dims = 1`) this
1073 // matches the prior shape exactly (the JointPolicy impl squeezes
1074 // the trailing dim away). For multi-discrete
1075 // (`MultiDiscreteMlpBurnPolicy`) this is required for the per-dim
1076 // `slice([0..mb, i..i+1])` reads inside `evaluate_actions` to
1077 // land in-bounds.
1078 let acts_t: Tensor<B, 2, Int> = Tensor::<B, 2, Int>::from_data(
1079 TensorData::new(acts_flat, [mb, num_action_dims]),
1080 device,
1081 );
1082 // Cross-entropy: -mean( log_softmax(logits)[gather(actions)] )
1083 let policy = avg_policy.take().ok_or_else(|| anyhow!("AP policy is None mid-update"))?;
1084 // We don't have a direct logits method on the JointPolicy trait —
1085 // but for both `MlpBurnPolicy` and `MultiDiscreteMlpBurnPolicy`
1086 // the evaluate_actions_joint path returns log-probs over the
1087 // *taken* actions, which is exactly what cross-entropy needs. For
1088 // multi-discrete the returned log-prob is already the
1089 // sum-across-dims (see
1090 // `MultiDiscreteMlpBurnPolicy::evaluate_actions`), so
1091 // `-mean(log_probs_taken)` IS the sum-of-per-head cross-entropy
1092 // of a conditionally-independent factored distribution. No new
1093 // `supervised_loss` method needed.
1094 let (log_probs_taken, _, _) = policy.evaluate_actions_joint(obs_t, acts_t);
1095 // Loss = -mean(log_probs_taken) → minimize.
1096 let loss = log_probs_taken.neg().mean();
1097 let loss_value = scalar_f64_avg_policy(loss.clone());
1098 let mut grads = loss.backward();
1099 let grads_params = GradientsParams::from_module(&mut grads, &policy);
1100 let lr = avg_optimizer.learning_rate();
1101 let updated = avg_optimizer.inner_mut().step(lr, policy, grads_params);
1102 *avg_policy = Some(updated);
1103 sum_loss += loss_value;
1104 n_steps_done += 1;
1105 }
1106 if n_steps_done > 0 {
1107 Ok(Some(sum_loss / n_steps_done as f64))
1108 } else {
1109 Ok(None)
1110 }
1111}
1112
1113/// Scalar host-side conversion for AP loss tensors. Mirrors
1114/// `crate::train::ppo::loss::scalar_f64` but without the dependency
1115/// since that helper is currently `pub(crate)`.
1116fn scalar_f64_avg_policy<B: AutodiffBackend>(t: Tensor<B, 1>) -> f64 {
1117 let v: Vec<f32> = t.into_data().to_vec().expect("scalar tensor to_vec");
1118 v.first().copied().unwrap_or(0.0) as f64
1119}
1120
1121// =======================================================================
1122// Tests
1123// =======================================================================
1124
1125#[cfg(test)]
1126mod tests {
1127 use burn::{
1128 backend::{Autodiff, NdArray, ndarray::NdArrayDevice},
1129 optim::AdamConfig,
1130 };
1131
1132 use super::*;
1133 use crate::{env::games::matching_pennies::MatchingPennies, policy::mlp::MlpBurnPolicy};
1134
1135 type B = Autodiff<NdArray<f32>>;
1136
1137 // ------------------------------------------------------------------
1138 // ReservoirBuffer — Vitter's Algorithm R
1139 // ------------------------------------------------------------------
1140
1141 #[test]
1142 fn test_reservoir_under_capacity_retains_all() {
1143 let mut r = ReservoirBuffer::<u32>::with_seed(8, 0);
1144 for i in 0..8 {
1145 assert!(r.push(i), "every push under capacity must be retained");
1146 }
1147 assert_eq!(r.len(), 8);
1148 assert_eq!(r.items(), &[0, 1, 2, 3, 4, 5, 6, 7]);
1149 assert_eq!(r.stream_length(), 8);
1150 }
1151
1152 #[test]
1153 fn test_reservoir_at_capacity_stays_at_capacity() {
1154 let mut r = ReservoirBuffer::<u32>::with_seed(4, 0);
1155 // Stream 100 items through; reservoir size stays at 4 after first 4.
1156 for i in 0..100 {
1157 r.push(i);
1158 }
1159 assert_eq!(r.len(), 4, "reservoir size must stay at capacity");
1160 assert_eq!(r.stream_length(), 100);
1161 }
1162
1163 /// Mean-stream-index test for Vitter Algorithm R uniformity.
1164 ///
1165 /// Over many trials, each item in the held reservoir is sampled
1166 /// uniformly across the full stream history. So the mean of the
1167 /// held items' stream indices (1..=N) should be ≈ (N+1)/2 over
1168 /// repeated independent runs. A FIFO buffer would yield mean
1169 /// ≈ N − capacity/2 + 1 (recent items only). A sliding window of
1170 /// the last `capacity` items would yield mean ≈ N − (capacity − 1)/2.
1171 /// Both are far from `(N+1)/2` for `N >> capacity`.
1172 #[test]
1173 fn test_reservoir_uniformity_via_mean_stream_index() {
1174 let capacity = 16usize;
1175 let n = 1000usize; // stream length, 60x capacity
1176 let trials = 50usize;
1177 let expected_mean = (n as f64 + 1.0) / 2.0; // ≈ 500.5
1178 let mut grand_mean = 0.0_f64;
1179 for trial in 0..trials {
1180 let mut r = ReservoirBuffer::<usize>::with_seed(capacity, trial as u64);
1181 for i in 1..=n {
1182 r.push(i); // store 1-based stream index as the item
1183 }
1184 assert_eq!(r.len(), capacity);
1185 let sum: usize = r.items().iter().sum();
1186 let mean = sum as f64 / capacity as f64;
1187 grand_mean += mean;
1188 }
1189 grand_mean /= trials as f64;
1190 let abs_err = (grand_mean - expected_mean).abs();
1191 // Standard error of the mean for the uniformity test: each
1192 // trial's mean has variance ≈ N²/(12 * capacity). Across
1193 // `trials`, the SEM of `grand_mean` is N/sqrt(12 * capacity * trials).
1194 // Allow ~3 SEM tolerance.
1195 let sem = (n as f64) / ((12.0 * capacity as f64 * trials as f64).sqrt());
1196 let tol = 3.0 * sem;
1197 assert!(
1198 abs_err <= tol,
1199 "Vitter Algorithm R uniformity failed: grand_mean={grand_mean:.2}, \
1200 expected_mean={expected_mean:.2}, abs_err={abs_err:.2}, tol={tol:.2} \
1201 (this test fails for FIFO/sliding-window buffers)"
1202 );
1203 }
1204
1205 #[test]
1206 fn test_reservoir_first_overflow_inclusion_probability() {
1207 // At capacity = N, the first overflow item (stream index
1208 // N+1) has insertion probability exactly N/(N+1) under
1209 // Vitter Algorithm R. Verify empirically.
1210 let capacity = 10usize;
1211 let trials = 5000usize;
1212 let mut kept = 0usize;
1213 for t in 0..trials {
1214 let mut r = ReservoirBuffer::<u32>::with_seed(capacity, (t as u64) ^ 0xdead_beef);
1215 // Fill to capacity.
1216 for i in 0..capacity {
1217 r.push(i as u32);
1218 }
1219 // The first overflow push — should be retained with
1220 // probability capacity / (capacity + 1) = 10/11 ≈ 0.909.
1221 let was_retained = r.push(u32::MAX);
1222 if was_retained {
1223 kept += 1;
1224 }
1225 }
1226 let p_emp = kept as f64 / trials as f64;
1227 let p_target = capacity as f64 / (capacity as f64 + 1.0);
1228 let std = (p_target * (1.0 - p_target) / trials as f64).sqrt();
1229 let tol = 3.0 * std;
1230 assert!(
1231 (p_emp - p_target).abs() <= tol,
1232 "first-overflow inclusion probability deviates: p_emp={p_emp:.4}, \
1233 p_target={p_target:.4} (tol={tol:.4})"
1234 );
1235 }
1236
1237 #[test]
1238 fn test_reservoir_sample_with_replacement_empty() {
1239 let mut r = ReservoirBuffer::<u32>::with_seed(4, 0);
1240 let s = r.sample_with_replacement(8);
1241 assert!(s.is_empty());
1242 }
1243
1244 #[test]
1245 fn test_reservoir_sample_with_replacement_uniform_over_items() {
1246 let mut r = ReservoirBuffer::<u32>::with_seed(2, 0);
1247 r.push(7);
1248 r.push(11);
1249 let n = 1000;
1250 let s = r.sample_with_replacement(n);
1251 assert_eq!(s.len(), n);
1252 let c7 = s.iter().filter(|&&x| x == 7).count();
1253 let c11 = s.iter().filter(|&&x| x == 11).count();
1254 assert_eq!(c7 + c11, n, "all samples must be from the reservoir");
1255 // ~half each. Allow plenty of slack.
1256 let frac7 = c7 as f64 / n as f64;
1257 assert!((frac7 - 0.5).abs() < 0.1, "sample distribution should be ~uniform");
1258 }
1259
1260 // ------------------------------------------------------------------
1261 // η-anticipatory mixing: counter-based rate test + reservoir-push
1262 // invariant
1263 // ------------------------------------------------------------------
1264
1265 /// A "rollout-only" variant of
1266 /// [`NfspTrainer::collect_anticipatory_rollout`] that uses a much
1267 /// smaller env loop and no PPO update, to keep the test fast.
1268 /// Reimplemented here because the real `collect_anticipatory_rollout`
1269 /// is `&mut self` on the full trainer.
1270 fn run_anticipatory_simulation(eta: f32, steps: usize, seed: u64) -> (usize, usize, usize) {
1271 let mut rng = StdRng::seed_from_u64(seed);
1272 let mut br_pushed = 0usize;
1273 let ap_pushed = 0usize;
1274 let mut br_taken = 0usize;
1275 for _ in 0..steps {
1276 let u: f32 = rng.random();
1277 if u < eta {
1278 br_taken += 1;
1279 br_pushed += 1; // BR actions enter the reservoir
1280 } else {
1281 // AP path: deliberately do NOT push.
1282 let _ = ap_pushed;
1283 }
1284 }
1285 (br_taken, br_pushed, ap_pushed)
1286 }
1287
1288 #[test]
1289 fn test_eta_anticipatory_mixing_rate_concentration() {
1290 // 100k steps with η = 0.1 → BR fraction within 3σ of 0.1.
1291 let eta = 0.1f32;
1292 let n = 100_000usize;
1293 let (br_taken, _, _) = run_anticipatory_simulation(eta, n, 42);
1294 let p_emp = br_taken as f64 / n as f64;
1295 let p_target = eta as f64;
1296 let std = (p_target * (1.0 - p_target) / n as f64).sqrt();
1297 let tol = 3.0 * std;
1298 assert!(
1299 (p_emp - p_target).abs() <= tol,
1300 "η-mixing rate deviates from 0.1: p_emp={p_emp:.5} (tol={tol:.5})"
1301 );
1302 }
1303
1304 #[test]
1305 fn test_eta_anticipatory_only_br_actions_enter_reservoir() {
1306 // Counter-based invariant: ap_pushed must be exactly 0 across
1307 // any number of η flips. The "simulation" mirrors what the
1308 // real trainer does: only the BR path increments the
1309 // reservoir push counter.
1310 let eta = 0.1f32;
1311 for seed in [0u64, 1, 2, 42, 12345] {
1312 let (_, br_pushed, ap_pushed) = run_anticipatory_simulation(eta, 10_000, seed);
1313 assert_eq!(
1314 ap_pushed, 0,
1315 "AP path must NOT push to the reservoir (seed={seed}, ap_pushed={ap_pushed})"
1316 );
1317 // Sanity: BR pushes happen.
1318 assert!(br_pushed > 0, "BR should sample at least once (seed={seed})");
1319 }
1320 }
1321
1322 // ------------------------------------------------------------------
1323 // Integration: NfspTrainer on matching pennies (smoke)
1324 // ------------------------------------------------------------------
1325
1326 #[allow(clippy::type_complexity)]
1327 fn build_matching_pennies_nfsp_trainer(
1328 max_iterations: usize,
1329 eta: f32,
1330 ) -> NfspTrainer<
1331 B,
1332 MlpBurnPolicy<B>,
1333 burn::optim::adaptor::OptimizerAdaptor<burn::optim::Adam, MlpBurnPolicy<B>, B>,
1334 MatchingPennies,
1335 impl Fn(&NdArrayDevice, u64) -> MlpBurnPolicy<B>,
1336 impl Fn() -> BurnOptimizer<
1337 B,
1338 MlpBurnPolicy<B>,
1339 burn::optim::adaptor::OptimizerAdaptor<burn::optim::Adam, MlpBurnPolicy<B>, B>,
1340 >,
1341 impl Fn() -> MatchingPennies,
1342 > {
1343 let device: NdArrayDevice = Default::default();
1344 let nfsp_config = NfspConfig {
1345 max_iterations,
1346 anticipatory_param: eta,
1347 reservoir_capacity: 1_024,
1348 br_train_steps_per_iteration: 1,
1349 avg_policy_train_steps_per_iteration: 2,
1350 avg_policy_minibatch_size: 32,
1351 avg_policy_lr: 5e-3,
1352 avg_policy_min_reservoir_coverage: 0.0,
1353 br_reward_scale: 1.0,
1354 seed: 0,
1355 };
1356 let joint_config = JointTrainerConfig {
1357 num_agents: 2,
1358 rollout_steps: 64,
1359 n_epochs: 1,
1360 minibatch_size: 32,
1361 ..Default::default()
1362 };
1363 NfspTrainer::new(
1364 nfsp_config,
1365 joint_config,
1366 device,
1367 |dev: &NdArrayDevice, seed: u64| {
1368 MlpBurnPolicy::<B>::new_seeded(
1369 MatchingPennies::OBS_DIM,
1370 MatchingPennies::ACTION_DIM,
1371 16,
1372 seed,
1373 dev,
1374 )
1375 },
1376 || {
1377 let inner = AdamConfig::new().init();
1378 BurnOptimizer::new(inner, 5e-3)
1379 },
1380 MatchingPennies::new,
1381 )
1382 .expect("NfspTrainer::new should succeed for 2-agent config")
1383 }
1384
1385 #[test]
1386 fn test_nfsp_construction_rejects_num_agents_less_than_two() {
1387 let device: NdArrayDevice = Default::default();
1388 let nfsp_config = NfspConfig::default();
1389 // num_agents = 1 — below the trainer's minimum, must be rejected.
1390 let joint_config = JointTrainerConfig { num_agents: 1, ..Default::default() };
1391 let res = NfspTrainer::<
1392 B,
1393 MlpBurnPolicy<B>,
1394 burn::optim::adaptor::OptimizerAdaptor<burn::optim::Adam, MlpBurnPolicy<B>, B>,
1395 MatchingPennies,
1396 _,
1397 _,
1398 _,
1399 >::new(
1400 nfsp_config,
1401 joint_config,
1402 device,
1403 |dev: &NdArrayDevice, seed: u64| MlpBurnPolicy::<B>::new_seeded(1, 2, 8, seed, dev),
1404 || BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
1405 MatchingPennies::new,
1406 );
1407 assert!(res.is_err(), "NFSP should reject num_agents < 2");
1408 }
1409
1410 #[test]
1411 fn test_nfsp_construction_accepts_n_player_config() {
1412 // Post-#119: num_agents > 2 is no longer rejected. Verify the
1413 // trainer constructs with N=4 and the right number of
1414 // reservoirs/AP policies.
1415 use crate::env::games::n_player_matching_pennies::NPlayerMatchingPennies;
1416 let device: NdArrayDevice = Default::default();
1417 let nfsp_config = NfspConfig::default();
1418 let joint_config = JointTrainerConfig { num_agents: 4, ..Default::default() };
1419 let trainer = NfspTrainer::<
1420 B,
1421 MlpBurnPolicy<B>,
1422 burn::optim::adaptor::OptimizerAdaptor<burn::optim::Adam, MlpBurnPolicy<B>, B>,
1423 NPlayerMatchingPennies,
1424 _,
1425 _,
1426 _,
1427 >::new(
1428 nfsp_config,
1429 joint_config,
1430 device,
1431 |dev: &NdArrayDevice, seed: u64| {
1432 MlpBurnPolicy::<B>::new_seeded(
1433 NPlayerMatchingPennies::OBS_DIM,
1434 NPlayerMatchingPennies::ACTION_DIM,
1435 8,
1436 seed,
1437 dev,
1438 )
1439 },
1440 || BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
1441 || NPlayerMatchingPennies::new(4),
1442 )
1443 .expect("NFSP should accept num_agents = 4");
1444 for i in 0..4 {
1445 assert_eq!(trainer.reservoir(i).len(), 0, "agent {i} reservoir starts empty");
1446 }
1447 }
1448
1449 #[test]
1450 fn test_nfsp_construction_rejects_out_of_range_eta() {
1451 let device: NdArrayDevice = Default::default();
1452 let nfsp_config = NfspConfig { anticipatory_param: 1.5, ..Default::default() };
1453 let joint_config = JointTrainerConfig { num_agents: 2, ..Default::default() };
1454 let res = NfspTrainer::<
1455 B,
1456 MlpBurnPolicy<B>,
1457 burn::optim::adaptor::OptimizerAdaptor<burn::optim::Adam, MlpBurnPolicy<B>, B>,
1458 MatchingPennies,
1459 _,
1460 _,
1461 _,
1462 >::new(
1463 nfsp_config,
1464 joint_config,
1465 device,
1466 |dev: &NdArrayDevice, seed: u64| MlpBurnPolicy::<B>::new_seeded(1, 2, 8, seed, dev),
1467 || BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
1468 MatchingPennies::new,
1469 );
1470 assert!(res.is_err(), "NFSP should reject η outside [0,1]");
1471 }
1472
1473 #[test]
1474 fn test_nfsp_runs_end_to_end_on_matching_pennies() {
1475 let mut trainer = build_matching_pennies_nfsp_trainer(3, 0.5);
1476 let stats = trainer.run_silent().expect("NFSP run should not error");
1477 assert_eq!(stats.iterations.len(), 3, "should record 3 iterations");
1478 for (k, it) in stats.iterations.iter().enumerate() {
1479 assert_eq!(it.iteration, k + 1);
1480 assert_eq!(it.reservoir_sizes.len(), 2);
1481 // With η = 0.5 and ≥1 BR step per iter on 64 rollout steps,
1482 // reservoirs should accumulate items.
1483 assert!(it.reservoir_sizes.iter().sum::<usize>() > 0, "reservoirs should accumulate");
1484 }
1485 }
1486
1487 #[test]
1488 fn test_nfsp_eta_zero_pure_avg_policy_never_pushes() {
1489 // η = 0: every rollout step samples from AP. Reservoir must
1490 // stay empty. AP supervised step must be skipped (no data).
1491 let mut trainer = build_matching_pennies_nfsp_trainer(2, 0.0);
1492 let _ = trainer.run_silent().expect("NFSP run with η=0 should not error");
1493 for i in 0..2 {
1494 assert_eq!(trainer.reservoir(i).len(), 0, "η=0 must leave reservoir {i} empty");
1495 }
1496 assert_eq!(trainer.cumulative_br_pushes(), 0, "η=0 must result in zero BR pushes");
1497 }
1498
1499 #[test]
1500 fn test_nfsp_eta_one_only_br_path_fills_reservoir() {
1501 // η = 1: every step samples from BR; reservoir grows by one
1502 // per rollout step per agent.
1503 let mut trainer = build_matching_pennies_nfsp_trainer(1, 1.0);
1504 let _ = trainer.run_silent().expect("NFSP run with η=1 should not error");
1505 // 1 iteration × 1 BR train step × 64 rollout steps = 64
1506 // per agent.
1507 for i in 0..2 {
1508 assert_eq!(
1509 trainer.reservoir(i).len(),
1510 64,
1511 "η=1 should retain all 64 rollout steps per agent in reservoir {i}"
1512 );
1513 }
1514 // 2 agents × 64 steps = 128.
1515 assert_eq!(trainer.cumulative_br_pushes(), 128);
1516 }
1517
1518 /// Fast always-on smoke for the in-trainer η-mixing wiring. Drives a
1519 /// single iteration with η = 0.5 over a *small* rollout and asserts
1520 /// directionality: the BR path actually pushes to the reservoir and the
1521 /// empirical BR-fraction lands in a loose-but-non-vacuous band around η.
1522 ///
1523 /// This keeps default-lane coverage of the trainer-side η flip while the
1524 /// rigorous statistical-concentration proof
1525 /// (`test_nfsp_eta_mixing_rate_concentration_in_trainer`) is `#[ignore]`d
1526 /// per the #208/#209 convention — its 4096-step rollout costs ~11 min in
1527 /// the default debug test lane and dominated CI Tests jobs (#224).
1528 #[test]
1529 fn test_nfsp_eta_mixing_rate_smoke_in_trainer() {
1530 let device: NdArrayDevice = Default::default();
1531 let eta = 0.5f32;
1532 // Small rollout: enough flips to expect both paths to fire, cheap
1533 // enough to finish in well under a second in debug.
1534 let rollout_steps = 256usize;
1535 let nfsp_config = NfspConfig {
1536 max_iterations: 1,
1537 anticipatory_param: eta,
1538 reservoir_capacity: 100_000,
1539 br_train_steps_per_iteration: 1,
1540 avg_policy_train_steps_per_iteration: 0,
1541 avg_policy_minibatch_size: 32,
1542 avg_policy_lr: 1e-3,
1543 avg_policy_min_reservoir_coverage: 0.0,
1544 br_reward_scale: 1.0,
1545 seed: 7,
1546 };
1547 let joint_config = JointTrainerConfig {
1548 num_agents: 2,
1549 rollout_steps,
1550 n_epochs: 1,
1551 minibatch_size: 32,
1552 ..Default::default()
1553 };
1554 let mut trainer = NfspTrainer::new(
1555 nfsp_config,
1556 joint_config,
1557 device,
1558 |dev: &NdArrayDevice, seed: u64| {
1559 MlpBurnPolicy::<B>::new_seeded(
1560 MatchingPennies::OBS_DIM,
1561 MatchingPennies::ACTION_DIM,
1562 16,
1563 seed,
1564 dev,
1565 )
1566 },
1567 || BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
1568 MatchingPennies::new,
1569 )
1570 .expect("NfspTrainer::new should succeed");
1571 let _ = trainer.run_silent().expect("NFSP run should not error");
1572 let br_pushes = trainer.cumulative_br_pushes() as f64;
1573 let total_steps = (rollout_steps * 2) as f64;
1574 let p_emp = br_pushes / total_steps;
1575 // Directionality, not concentration: with η = 0.5 over 512 flips the
1576 // empirical BR-fraction is overwhelmingly inside [0.25, 0.75]. Also
1577 // require that the BR path fired at all (the wiring is live).
1578 assert!(
1579 br_pushes > 0.0,
1580 "η-mixing BR path must push to the reservoir (br_pushes={br_pushes})"
1581 );
1582 assert!(
1583 (0.25..=0.75).contains(&p_emp),
1584 "η-mixing BR-fraction should be near 0.5: p_emp={p_emp:.4}"
1585 );
1586 }
1587
1588 /// Rigorous statistical-concentration proof for the in-trainer η flip:
1589 /// a single iteration with η = 0.1 over a large rollout, asserting the
1590 /// empirical BR-fraction across the 2 agents is within ~4σ of 0.1.
1591 ///
1592 /// `#[ignore]`d per the #208/#209 convention: the 4096-step rollout costs
1593 /// ~11 min in the default debug test lane and dominated CI Tests jobs
1594 /// (#224). The fast `test_nfsp_eta_mixing_rate_smoke_in_trainer` keeps
1595 /// always-on coverage of the wiring; run this full proof on demand with
1596 /// `cargo test --features training -- --ignored` (prefer `--release`).
1597 #[test]
1598 #[ignore = "multi-iteration NFSP η-mixing concentration run; opt in with --ignored (prefer --release)"]
1599 fn test_nfsp_eta_mixing_rate_concentration_in_trainer() {
1600 // Drive a single iteration with η = 0.1 over a large rollout
1601 // and check the empirical BR-fraction across the 2 agents is
1602 // within ~4σ of 0.1.
1603 let device: NdArrayDevice = Default::default();
1604 let eta = 0.1f32;
1605 let rollout_steps = 4096usize;
1606 let nfsp_config = NfspConfig {
1607 max_iterations: 1,
1608 anticipatory_param: eta,
1609 reservoir_capacity: 100_000,
1610 br_train_steps_per_iteration: 1,
1611 avg_policy_train_steps_per_iteration: 0,
1612 avg_policy_minibatch_size: 32,
1613 avg_policy_lr: 1e-3,
1614 avg_policy_min_reservoir_coverage: 0.0,
1615 br_reward_scale: 1.0,
1616 seed: 7,
1617 };
1618 let joint_config = JointTrainerConfig {
1619 num_agents: 2,
1620 rollout_steps,
1621 n_epochs: 1,
1622 minibatch_size: 32,
1623 ..Default::default()
1624 };
1625 let mut trainer = NfspTrainer::new(
1626 nfsp_config,
1627 joint_config,
1628 device,
1629 |dev: &NdArrayDevice, seed: u64| {
1630 MlpBurnPolicy::<B>::new_seeded(
1631 MatchingPennies::OBS_DIM,
1632 MatchingPennies::ACTION_DIM,
1633 16,
1634 seed,
1635 dev,
1636 )
1637 },
1638 || BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
1639 MatchingPennies::new,
1640 )
1641 .expect("NfspTrainer::new should succeed");
1642 let _ = trainer.run_silent().expect("NFSP run should not error");
1643 let br_pushes = trainer.cumulative_br_pushes() as f64;
1644 let total_steps = (rollout_steps * 2) as f64;
1645 let p_emp = br_pushes / total_steps;
1646 let p_target = eta as f64;
1647 let std = (p_target * (1.0 - p_target) / total_steps).sqrt();
1648 let tol = 4.0 * std; // 4σ for resilience to the small N
1649 assert!(
1650 (p_emp - p_target).abs() <= tol,
1651 "trainer η-mixing rate deviates: p_emp={p_emp:.4}, p_target={p_target:.4}, tol={tol:.4}"
1652 );
1653 }
1654
1655 #[test]
1656 fn test_nfsp_avg_policy_supervised_step_reduces_loss_on_fixed_minibatch() {
1657 // Sanity-check the supervised wiring: a few AP updates on a
1658 // fixed reservoir reduce the per-batch cross-entropy.
1659 let device: NdArrayDevice = Default::default();
1660 let nfsp_config = NfspConfig {
1661 max_iterations: 0,
1662 anticipatory_param: 1.0,
1663 reservoir_capacity: 256,
1664 br_train_steps_per_iteration: 0,
1665 avg_policy_train_steps_per_iteration: 0,
1666 avg_policy_minibatch_size: 32,
1667 avg_policy_lr: 5e-2,
1668 avg_policy_min_reservoir_coverage: 0.0,
1669 br_reward_scale: 1.0,
1670 seed: 13,
1671 };
1672 let joint_config = JointTrainerConfig {
1673 num_agents: 2,
1674 rollout_steps: 32,
1675 n_epochs: 1,
1676 minibatch_size: 32,
1677 ..Default::default()
1678 };
1679 let mut trainer = NfspTrainer::new(
1680 nfsp_config,
1681 joint_config,
1682 device,
1683 |dev: &NdArrayDevice, seed: u64| MlpBurnPolicy::<B>::new_seeded(1, 2, 8, seed, dev),
1684 || BurnOptimizer::new(AdamConfig::new().init(), 5e-2),
1685 MatchingPennies::new,
1686 )
1687 .expect("NfspTrainer::new should succeed");
1688
1689 // Seed reservoir 0 with a fixed dataset: all `(obs=[0.0], action=[0])`.
1690 // Single-discrete callers wrap their scalar action in a length-1
1691 // vec — the post-#127 reservoir contract.
1692 for _ in 0..64 {
1693 trainer.reservoirs[0].push((vec![0.0_f32], vec![0]));
1694 }
1695 // Stage the supervised step manually a few times by setting
1696 // `avg_policy_train_steps_per_iteration` and calling the
1697 // private method. We do this by mutating the config and
1698 // invoking train_average_policies through a single iteration
1699 // of the public run loop. To avoid extra rollout
1700 // mechanics, build a config that asks for AP-only steps and
1701 // 0 BR rollouts.
1702 trainer.config.avg_policy_train_steps_per_iteration = 10;
1703
1704 // First loss probe.
1705 let losses_before = trainer.train_average_policies().unwrap();
1706 let loss_before = losses_before[0].expect("expected supervised loss for agent 0");
1707
1708 // Repeat 4 more times.
1709 for _ in 0..4 {
1710 trainer.train_average_policies().unwrap();
1711 }
1712 let losses_after = trainer.train_average_policies().unwrap();
1713 let loss_after = losses_after[0].expect("expected supervised loss for agent 0");
1714
1715 assert!(
1716 loss_after < loss_before,
1717 "AP supervised CE should decrease: before={loss_before:.4}, after={loss_after:.4}"
1718 );
1719 }
1720
1721 /// Issue #199 regression: on a **factored multi-discrete** `[10,2,2]`
1722 /// action space (bucket-brigade's shape, joint cardinality 40), the
1723 /// uniform-policy cross-entropy floor is `ln(40) ≈ 3.689`. The #134
1724 /// cluster run reported `avg_ap_loss` pinned at exactly that floor
1725 /// for an entire 48-iteration run — the average policy never fit its
1726 /// reservoir.
1727 ///
1728 /// This test reproduces the *root cause locally* and shows the fix:
1729 /// it seeds an agent's reservoir with a deterministic **non-uniform**
1730 /// target (a single fixed joint action `[3, 1, 0]` for every entry,
1731 /// the easiest possible supervised target) and runs the AP supervised
1732 /// update. With the adaptive `avg_policy_min_reservoir_coverage` floor
1733 /// the AP is given enough gradient steps to drive `avg_ap_loss`
1734 /// **well below `ln(40)`** — exactly the signal the cluster run never
1735 /// produced. Fast (NdArray CPU, tiny obs/hidden dims), so it runs on
1736 /// every `cargo test --features training` invocation, not behind
1737 /// `#[ignore]`.
1738 #[test]
1739 fn test_nfsp_multi_discrete_ap_loss_drops_below_uniform_floor() {
1740 use crate::policy::multi_discrete_mlp::MultiDiscreteMlpBurnPolicy;
1741
1742 let device: NdArrayDevice = Default::default();
1743 let action_dims = vec![10usize, 2, 2]; // joint cardinality = 40
1744 let uniform_floor = (40.0_f64).ln(); // ≈ 3.6889
1745
1746 let nfsp_config = NfspConfig {
1747 max_iterations: 0,
1748 anticipatory_param: 1.0,
1749 reservoir_capacity: 4_096,
1750 br_train_steps_per_iteration: 0,
1751 avg_policy_train_steps_per_iteration: 8,
1752 avg_policy_minibatch_size: 64,
1753 avg_policy_lr: 5e-3,
1754 // The fix under test: cover the reservoir ~4× per call so the
1755 // AP gets enough gradient steps to actually fit the target.
1756 avg_policy_min_reservoir_coverage: 4.0,
1757 br_reward_scale: 1.0,
1758 seed: 199,
1759 };
1760 let joint_config = JointTrainerConfig {
1761 num_agents: 2,
1762 rollout_steps: 8,
1763 n_epochs: 1,
1764 minibatch_size: 32,
1765 ..Default::default()
1766 };
1767
1768 // obs_dim is small and arbitrary here — we never step the env;
1769 // we drive `train_average_policies` directly on a hand-seeded
1770 // reservoir. The env type only has to satisfy the trainer's
1771 // generic bound (we use MatchingPennies, never stepped).
1772 let obs_dim = 4usize;
1773 let dims_for_factory = action_dims.clone();
1774 let mut trainer = NfspTrainer::new(
1775 nfsp_config,
1776 joint_config,
1777 device,
1778 move |dev: &NdArrayDevice, seed: u64| {
1779 MultiDiscreteMlpBurnPolicy::<B>::new_seeded(
1780 obs_dim,
1781 dims_for_factory.clone(),
1782 16,
1783 seed,
1784 dev,
1785 )
1786 },
1787 || BurnOptimizer::new(AdamConfig::new().init(), 5e-3),
1788 MatchingPennies::new,
1789 )
1790 .expect("NfspTrainer::new should succeed for multi-discrete config");
1791
1792 // Seed reservoir 0 with a fixed, non-uniform supervised target:
1793 // every entry maps a constant observation to the single joint
1794 // action [3, 1, 0]. A network that fits this perfectly drives the
1795 // CE loss toward 0; the question is whether the AP gets enough
1796 // gradient steps to move off the ln(40) uniform floor at all.
1797 let fixed_obs = vec![0.25_f32; obs_dim];
1798 let fixed_action: Vec<i64> = vec![3, 1, 0];
1799 for _ in 0..512 {
1800 trainer.reservoirs[0].push((fixed_obs.clone(), fixed_action.clone()));
1801 }
1802
1803 // First supervised pass: the freshly-initialized AP should start
1804 // near the uniform floor.
1805 let losses_first = trainer.train_average_policies().unwrap();
1806 let loss_first = losses_first[0].expect("expected supervised loss for agent 0");
1807
1808 // A handful more passes to let the adaptive-coverage budget fit
1809 // the (trivial, deterministic) target.
1810 for _ in 0..8 {
1811 trainer.train_average_policies().unwrap();
1812 }
1813 let losses_last = trainer.train_average_policies().unwrap();
1814 let loss_last = losses_last[0].expect("expected supervised loss for agent 0");
1815
1816 // Visible with `cargo test -- --nocapture`; documents the local
1817 // before/after used in the PR writeup.
1818 eprintln!(
1819 "[#199] multi-discrete AP loss: first={loss_first:.4}, last={loss_last:.4}, \
1820 ln(40) floor={uniform_floor:.4}"
1821 );
1822
1823 // The cluster run's symptom was `avg_ap_loss` stuck AT the floor.
1824 // The fix must drive it meaningfully below. Use a generous margin
1825 // (0.5 nats) so the assertion is robust across platforms but
1826 // still hard-fails the "pinned at ln(40)" pathology.
1827 assert!(
1828 loss_last < uniform_floor - 0.5,
1829 "AP loss should drop well below the ln(40) uniform floor: \
1830 first={loss_first:.4}, last={loss_last:.4}, floor={uniform_floor:.4}"
1831 );
1832 assert!(
1833 loss_last < loss_first,
1834 "AP loss should decrease across supervised passes: \
1835 first={loss_first:.4}, last={loss_last:.4}"
1836 );
1837 }
1838
1839 /// Issue #234: the parallelized per-agent AP supervised loop must be
1840 /// **deterministic for a fixed seed and invariant to the rayon thread
1841 /// count**. AP minibatches are drawn from each reservoir's own seeded
1842 /// RNG (`ReservoirBuffer::rng`), never the shared `self.rng`, so each
1843 /// agent consumes exactly its own RNG stream regardless of how the
1844 /// rayon tasks interleave.
1845 ///
1846 /// We build two structurally-identical trainers (same `NfspConfig`
1847 /// seed), seed their reservoirs with the same deterministic data, then
1848 /// run the full multi-pass AP trajectory on each — one forced onto a
1849 /// single rayon worker thread, the other onto four. The per-agent loss
1850 /// trajectories must be **bit-identical**.
1851 #[test]
1852 fn test_nfsp_ap_training_deterministic_across_thread_counts() {
1853 use crate::policy::multi_discrete_mlp::MultiDiscreteMlpBurnPolicy;
1854
1855 let action_dims = vec![10usize, 2, 2]; // joint cardinality = 40
1856 let obs_dim = 4usize;
1857
1858 // Build a trainer + seed its reservoirs identically, then run a
1859 // fixed multi-pass AP trajectory inside a rayon pool of `threads`
1860 // workers. Returns the full per-pass, per-agent loss trajectory.
1861 let run_trajectory = |threads: usize| -> Vec<Vec<Option<f64>>> {
1862 let device: NdArrayDevice = Default::default();
1863 let nfsp_config = NfspConfig {
1864 max_iterations: 0,
1865 anticipatory_param: 1.0,
1866 reservoir_capacity: 4_096,
1867 br_train_steps_per_iteration: 0,
1868 avg_policy_train_steps_per_iteration: 4,
1869 avg_policy_minibatch_size: 64,
1870 avg_policy_lr: 5e-3,
1871 avg_policy_min_reservoir_coverage: 2.0,
1872 br_reward_scale: 1.0,
1873 seed: 234,
1874 };
1875 let joint_config = JointTrainerConfig {
1876 num_agents: 2,
1877 rollout_steps: 8,
1878 n_epochs: 1,
1879 minibatch_size: 32,
1880 ..Default::default()
1881 };
1882 let dims_for_factory = action_dims.clone();
1883 let mut trainer = NfspTrainer::new(
1884 nfsp_config,
1885 joint_config,
1886 device,
1887 move |dev: &NdArrayDevice, seed: u64| {
1888 MultiDiscreteMlpBurnPolicy::<B>::new_seeded(
1889 obs_dim,
1890 dims_for_factory.clone(),
1891 16,
1892 seed,
1893 dev,
1894 )
1895 },
1896 || BurnOptimizer::new(AdamConfig::new().init(), 5e-3),
1897 MatchingPennies::new,
1898 )
1899 .expect("NfspTrainer::new should succeed");
1900
1901 // Seed both agents' reservoirs with deterministic, distinct
1902 // non-uniform targets so the AP actually moves and both agents
1903 // exercise the parallel path.
1904 let fixed_obs = vec![0.25_f32; obs_dim];
1905 for _ in 0..256 {
1906 trainer.reservoirs[0].push((fixed_obs.clone(), vec![3, 1, 0]));
1907 trainer.reservoirs[1].push((fixed_obs.clone(), vec![7, 0, 1]));
1908 }
1909
1910 let pool = rayon::ThreadPoolBuilder::new()
1911 .num_threads(threads)
1912 .build()
1913 .expect("rayon pool should build");
1914 pool.install(|| (0..6).map(|_| trainer.train_average_policies().unwrap()).collect())
1915 };
1916
1917 let serial = run_trajectory(1);
1918 let parallel = run_trajectory(4);
1919
1920 assert_eq!(
1921 serial, parallel,
1922 "AP loss trajectory must be bit-identical across rayon thread counts \
1923 (per-reservoir RNG, no shared self.rng): serial={serial:?}, parallel={parallel:?}"
1924 );
1925 // Sanity: the trajectory is non-trivial (both agents produced
1926 // losses), so the equality above is meaningful.
1927 assert!(
1928 serial.iter().all(|pass| pass.iter().all(|l| l.is_some())),
1929 "both agents should produce a loss every pass: {serial:?}"
1930 );
1931 }
1932
1933 /// Issue #199: the adaptive coverage floor must run *more* supervised
1934 /// steps than the fixed `avg_policy_train_steps_per_iteration` when
1935 /// the reservoir is large, and exactly the fixed count when coverage
1936 /// is disabled (`0.0`). We verify this by counting actual gradient
1937 /// steps via the returned per-agent loss being present and by
1938 /// asserting the coverage math through a public-ish probe: a 0-step
1939 /// fixed budget with coverage still produces a loss (steps were run),
1940 /// while disabling coverage with a 0-step budget produces `None`.
1941 #[test]
1942 fn test_nfsp_adaptive_coverage_runs_steps_when_fixed_budget_is_zero() {
1943 use crate::policy::multi_discrete_mlp::MultiDiscreteMlpBurnPolicy;
1944
1945 let device: NdArrayDevice = Default::default();
1946 let obs_dim = 3usize;
1947 let action_dims = vec![4usize, 2];
1948
1949 let make = |coverage: f32, fixed_steps: usize| {
1950 let nfsp_config = NfspConfig {
1951 max_iterations: 0,
1952 anticipatory_param: 1.0,
1953 reservoir_capacity: 1_024,
1954 br_train_steps_per_iteration: 0,
1955 avg_policy_train_steps_per_iteration: fixed_steps,
1956 avg_policy_minibatch_size: 32,
1957 avg_policy_lr: 1e-3,
1958 avg_policy_min_reservoir_coverage: coverage,
1959 br_reward_scale: 1.0,
1960 seed: 7,
1961 };
1962 let joint_config = JointTrainerConfig {
1963 num_agents: 1 + 1,
1964 rollout_steps: 8,
1965 n_epochs: 1,
1966 minibatch_size: 32,
1967 ..Default::default()
1968 };
1969 let dims = action_dims.clone();
1970 let mut trainer = NfspTrainer::new(
1971 nfsp_config,
1972 joint_config,
1973 device,
1974 move |dev: &NdArrayDevice, seed: u64| {
1975 MultiDiscreteMlpBurnPolicy::<B>::new_seeded(obs_dim, dims.clone(), 8, seed, dev)
1976 },
1977 || BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
1978 MatchingPennies::new,
1979 )
1980 .expect("trainer construction");
1981 for _ in 0..256 {
1982 trainer.reservoirs[0].push((vec![0.1_f32; obs_dim], vec![1, 0]));
1983 }
1984 trainer
1985 };
1986
1987 // Coverage disabled + 0 fixed steps → no supervised steps run.
1988 let mut disabled = make(0.0, 0);
1989 assert!(
1990 disabled.train_average_policies().unwrap()[0].is_none(),
1991 "coverage=0 with 0 fixed steps must run no supervised steps"
1992 );
1993
1994 // Coverage enabled + 0 fixed steps → coverage forces steps to run
1995 // (256 entries × 1.0 / 32 mb = 8 steps), so a loss is produced.
1996 let mut enabled = make(1.0, 0);
1997 assert!(
1998 enabled.train_average_policies().unwrap()[0].is_some(),
1999 "coverage>0 must run supervised steps even when fixed budget is 0"
2000 );
2001 }
2002
2003 #[test]
2004 fn test_nfsp_determinism_within_module_under_seed() {
2005 // Same seed produces bit-identical η-flip stream and reservoir
2006 // structure (lengths + stream-lengths) when only the in-module
2007 // RNGs are exercised. The exact (obs, action) contents of the
2008 // reservoir depend on the rollout-time `get_action_host`
2009 // draws, which are downstream of (a) the non-deterministic
2010 // per-epoch shuffle inside
2011 // `JointMultiAgentTrainer::update_with_active_agents` (tracked
2012 // in #109) and (b) the BR policy's own non-deterministic
2013 // `rand::rng()` sampling inside `get_action_host`. Both are
2014 // out of scope for this PR; this test asserts only the
2015 // determinism slice the NFSP module itself controls.
2016 let mut a = build_matching_pennies_nfsp_trainer(1, 0.5);
2017 let mut b = build_matching_pennies_nfsp_trainer(1, 0.5);
2018 let _ = a.run_silent().unwrap();
2019 let _ = b.run_silent().unwrap();
2020 for i in 0..2 {
2021 assert_eq!(
2022 a.reservoir(i).len(),
2023 b.reservoir(i).len(),
2024 "reservoir lengths must match across same-seed runs (agent {i})"
2025 );
2026 assert_eq!(
2027 a.reservoir(i).stream_length(),
2028 b.reservoir(i).stream_length(),
2029 "reservoir stream length must match across same-seed runs (agent {i})"
2030 );
2031 }
2032 // η-flip-derived counts depend only on the seeded NFSP RNG and
2033 // must be bit-identical.
2034 assert_eq!(a.cumulative_br_pushes(), b.cumulative_br_pushes());
2035 assert_eq!(a.cumulative_rollout_steps(), b.cumulative_rollout_steps());
2036 }
2037}