Skip to main content

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}