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            obs_dim,
893            actions: act_buf,
894            num_action_dims,
895            log_probs: lp_buf,
896            values: val_buf,
897            rewards: rew_buf,
898            dones: done_buf,
899        })
900    }
901
902    /// One supervised cross-entropy step per agent over the agent's
903    /// reservoir buffer. Skips agents whose reservoir is empty.
904    /// Returns per-agent mean loss across all the supervised steps
905    /// (`None` if no steps were taken for that agent).
906    ///
907    /// # Parallelism (issue #234)
908    ///
909    /// The per-agent loop runs the `num_agents` agents concurrently with
910    /// rayon. Each agent's mutable state is **disjoint**
911    /// (`reservoirs[i]`, `avg_policies[i]`, `avg_optimizers[i]`), so we
912    /// zip the three per-agent vectors into a single `par_iter_mut`,
913    /// handing each task an independent `(&mut reservoir, &mut policy,
914    /// &mut optimizer)` triple — no `Mutex`, no shared mutable borrow.
915    /// The only shared state is read-only (`&self.config`, `&self.device`)
916    /// and the per-agent `num_action_dims`, which is probed from the BR
917    /// trainer *before* the parallel section (so the immutable
918    /// `self.br_trainer` borrow does not collide with the per-agent
919    /// `&mut` borrows).
920    ///
921    /// # Determinism
922    ///
923    /// AP minibatches are drawn from each reservoir's **own** seeded RNG
924    /// (`ReservoirBuffer::rng`, see `sample_with_replacement`), never from
925    /// the shared `self.rng`. Sampling is therefore agent-local and the
926    /// `avg_ap_loss` trajectory is unchanged vs. the previous serial loop
927    /// and invariant to the rayon thread count: each agent consumes
928    /// exactly its own RNG stream regardless of interleaving. (The serial
929    /// loop already sourced AP sampling from the per-reservoir RNG, so
930    /// this change does not alter the RNG sourcing.)
931    fn train_average_policies(&mut self) -> Result<Vec<Option<f64>>>
932    where
933        P: Send + Sync,
934        O: Send,
935        B::Device: Sync,
936    {
937        let num_agents = self.joint_config.num_agents;
938        let steps = self.config.avg_policy_train_steps_per_iteration;
939        // Skip entirely only when *both* the fixed budget and the
940        // adaptive coverage floor are zero — otherwise coverage may
941        // still force supervised steps even with `steps == 0` (issue
942        // #199).
943        if steps == 0 && self.config.avg_policy_min_reservoir_coverage <= 0.0 {
944            return Ok(vec![None; num_agents]);
945        }
946
947        // Probe `num_action_dims` from the BR policy for each agent
948        // *before* the parallel section. PR #103 / issue #127: the AP
949        // policy shares the same factored-action shape as the BR policy,
950        // and `evaluate_actions_joint` expects actions of shape
951        // `[mb, num_action_dims]` for both `MlpBurnPolicy` (length-1
952        // columns) and `MultiDiscreteMlpBurnPolicy`
953        // (length-`num_action_dims` columns). Hoisting this read here
954        // releases the immutable `self.br_trainer` borrow so the rayon
955        // closures can take disjoint `&mut` borrows of the per-agent
956        // state below.
957        let num_action_dims: Vec<usize> = (0..num_agents)
958            .map(|i| self.br_trainer.policy(i).action_dims_joint().len())
959            .collect();
960
961        // Bind read-only field borrows into locals so the rayon closures
962        // capture *these* references and NOT the whole `&self` (which
963        // also holds the non-`Sync` factory closures). Mirrors the PSRO
964        // payoff-parallel path.
965        let mb_size = self.config.avg_policy_minibatch_size.max(1);
966        let coverage = self.config.avg_policy_min_reservoir_coverage;
967        let device = &self.device;
968
969        // Disjoint per-agent state: zip the three per-agent vectors so
970        // each rayon task owns exactly one agent's `(&mut reservoir,
971        // &mut policy, &mut optimizer)` triple.
972        let losses: Result<Vec<Option<f64>>> = self
973            .reservoirs
974            .par_iter_mut()
975            .zip(self.avg_policies.par_iter_mut())
976            .zip(self.avg_optimizers.par_iter_mut())
977            .zip(num_action_dims.par_iter())
978            .map(|(((reservoir, avg_policy), avg_optimizer), &n_dims)| {
979                train_one_average_policy::<B, P, O>(
980                    reservoir,
981                    avg_policy,
982                    avg_optimizer,
983                    n_dims,
984                    steps,
985                    mb_size,
986                    coverage,
987                    device,
988                )
989            })
990            .collect();
991        losses
992    }
993}
994
995/// Run the average-policy supervised steps for a single agent over its
996/// own (disjoint) reservoir, policy, and optimizer. Pulled out as a free
997/// function so [`NfspTrainer::train_average_policies`] can invoke it from
998/// inside a rayon parallel iterator with each task owning an independent
999/// set of `&mut` borrows. See that method's docstring for the
1000/// borrow-splitting and determinism rationale.
1001///
1002/// Returns the per-agent mean loss across the supervised steps (`None`
1003/// if no steps were taken, e.g. an empty reservoir).
1004#[allow(clippy::too_many_arguments)]
1005fn train_one_average_policy<B, P, O>(
1006    reservoir: &mut ReservoirBuffer<(Vec<f32>, Vec<i64>)>,
1007    avg_policy: &mut Option<P>,
1008    avg_optimizer: &mut BurnOptimizer<B, P, O>,
1009    num_action_dims: usize,
1010    steps: usize,
1011    mb_size: usize,
1012    coverage: f32,
1013    device: &B::Device,
1014) -> Result<Option<f64>>
1015where
1016    B: AutodiffBackend,
1017    P: JointPolicy<B>,
1018    O: Optimizer<P, B>,
1019{
1020    if reservoir.is_empty() {
1021        return Ok(None);
1022    }
1023    // Adaptive step floor (issue #199): when
1024    // `avg_policy_min_reservoir_coverage > 0`, run enough supervised
1025    // steps that the AP sees `coverage` full passes over the current
1026    // reservoir, so a large reservoir is not starved by a tiny fixed
1027    // step budget (the root cause of the `avg_ap_loss` pinned at
1028    // `ln(num_joint_actions)` on bucket-brigade). The configured `steps`
1029    // remains the floor.
1030    let agent_steps = if coverage > 0.0 {
1031        let res_len = reservoir.len();
1032        let needed = (coverage as f64 * res_len as f64 / mb_size as f64).ceil() as usize;
1033        steps.max(needed)
1034    } else {
1035        steps
1036    };
1037    let mut sum_loss = 0.0_f64;
1038    let mut n_steps_done = 0usize;
1039    for _ in 0..agent_steps {
1040        // Draw from the reservoir's OWN seeded RNG (issue #234): AP
1041        // sampling is agent-local-deterministic and invariant to the
1042        // rayon thread count.
1043        let batch = reservoir.sample_with_replacement(mb_size);
1044        if batch.is_empty() {
1045            continue;
1046        }
1047        let obs_dim = batch[0].0.len();
1048        let mb = batch.len();
1049        // Flatten obs into [mb, obs_dim], actions into
1050        // [mb, num_action_dims]. Each batch row's action vec must already
1051        // have length `num_action_dims` — that invariant is established
1052        // at push-time in `collect_anticipatory_rollout` (#127).
1053        let mut obs_flat = Vec::with_capacity(mb * obs_dim);
1054        let mut acts_flat = Vec::with_capacity(mb * num_action_dims);
1055        for (o, a) in &batch {
1056            debug_assert_eq!(
1057                a.len(),
1058                num_action_dims,
1059                "reservoir action vec length {} != num_action_dims {}",
1060                a.len(),
1061                num_action_dims,
1062            );
1063            obs_flat.extend_from_slice(o);
1064            acts_flat.extend_from_slice(a);
1065        }
1066        let obs_t = Tensor::<B, 2>::from_data(TensorData::new(obs_flat, [mb, obs_dim]), device);
1067        // Build the action tensor directly at shape
1068        // `[mb, num_action_dims]` — no `unsqueeze_dim` step. For
1069        // single-discrete (`MlpBurnPolicy`, `num_action_dims = 1`) this
1070        // matches the prior shape exactly (the JointPolicy impl squeezes
1071        // the trailing dim away). For multi-discrete
1072        // (`MultiDiscreteMlpBurnPolicy`) this is required for the per-dim
1073        // `slice([0..mb, i..i+1])` reads inside `evaluate_actions` to
1074        // land in-bounds.
1075        let acts_t: Tensor<B, 2, Int> = Tensor::<B, 2, Int>::from_data(
1076            TensorData::new(acts_flat, [mb, num_action_dims]),
1077            device,
1078        );
1079        // Cross-entropy: -mean( log_softmax(logits)[gather(actions)] )
1080        let policy = avg_policy.take().ok_or_else(|| anyhow!("AP policy is None mid-update"))?;
1081        // We don't have a direct logits method on the JointPolicy trait —
1082        // but for both `MlpBurnPolicy` and `MultiDiscreteMlpBurnPolicy`
1083        // the evaluate_actions_joint path returns log-probs over the
1084        // *taken* actions, which is exactly what cross-entropy needs. For
1085        // multi-discrete the returned log-prob is already the
1086        // sum-across-dims (see
1087        // `MultiDiscreteMlpBurnPolicy::evaluate_actions`), so
1088        // `-mean(log_probs_taken)` IS the sum-of-per-head cross-entropy
1089        // of a conditionally-independent factored distribution. No new
1090        // `supervised_loss` method needed.
1091        let (log_probs_taken, _, _) = policy.evaluate_actions_joint(obs_t, acts_t);
1092        // Loss = -mean(log_probs_taken) → minimize.
1093        let loss = log_probs_taken.neg().mean();
1094        let loss_value = scalar_f64_avg_policy(loss.clone());
1095        let mut grads = loss.backward();
1096        let grads_params = GradientsParams::from_module(&mut grads, &policy);
1097        let lr = avg_optimizer.learning_rate();
1098        let updated = avg_optimizer.inner_mut().step(lr, policy, grads_params);
1099        *avg_policy = Some(updated);
1100        sum_loss += loss_value;
1101        n_steps_done += 1;
1102    }
1103    if n_steps_done > 0 {
1104        Ok(Some(sum_loss / n_steps_done as f64))
1105    } else {
1106        Ok(None)
1107    }
1108}
1109
1110/// Scalar host-side conversion for AP loss tensors. Mirrors
1111/// `crate::train::ppo::loss::scalar_f64` but without the dependency
1112/// since that helper is currently `pub(crate)`.
1113fn scalar_f64_avg_policy<B: AutodiffBackend>(t: Tensor<B, 1>) -> f64 {
1114    let v: Vec<f32> = t.into_data().to_vec().expect("scalar tensor to_vec");
1115    v.first().copied().unwrap_or(0.0) as f64
1116}
1117
1118// =======================================================================
1119// Tests
1120// =======================================================================
1121
1122#[cfg(test)]
1123mod tests {
1124    use burn::{
1125        backend::{Autodiff, NdArray, ndarray::NdArrayDevice},
1126        optim::AdamConfig,
1127    };
1128
1129    use super::*;
1130    use crate::{env::games::matching_pennies::MatchingPennies, policy::mlp::MlpBurnPolicy};
1131
1132    type B = Autodiff<NdArray<f32>>;
1133
1134    // ------------------------------------------------------------------
1135    // ReservoirBuffer — Vitter's Algorithm R
1136    // ------------------------------------------------------------------
1137
1138    #[test]
1139    fn test_reservoir_under_capacity_retains_all() {
1140        let mut r = ReservoirBuffer::<u32>::with_seed(8, 0);
1141        for i in 0..8 {
1142            assert!(r.push(i), "every push under capacity must be retained");
1143        }
1144        assert_eq!(r.len(), 8);
1145        assert_eq!(r.items(), &[0, 1, 2, 3, 4, 5, 6, 7]);
1146        assert_eq!(r.stream_length(), 8);
1147    }
1148
1149    #[test]
1150    fn test_reservoir_at_capacity_stays_at_capacity() {
1151        let mut r = ReservoirBuffer::<u32>::with_seed(4, 0);
1152        // Stream 100 items through; reservoir size stays at 4 after first 4.
1153        for i in 0..100 {
1154            r.push(i);
1155        }
1156        assert_eq!(r.len(), 4, "reservoir size must stay at capacity");
1157        assert_eq!(r.stream_length(), 100);
1158    }
1159
1160    /// Mean-stream-index test for Vitter Algorithm R uniformity.
1161    ///
1162    /// Over many trials, each item in the held reservoir is sampled
1163    /// uniformly across the full stream history. So the mean of the
1164    /// held items' stream indices (1..=N) should be ≈ (N+1)/2 over
1165    /// repeated independent runs. A FIFO buffer would yield mean
1166    /// ≈ N − capacity/2 + 1 (recent items only). A sliding window of
1167    /// the last `capacity` items would yield mean ≈ N − (capacity − 1)/2.
1168    /// Both are far from `(N+1)/2` for `N >> capacity`.
1169    #[test]
1170    fn test_reservoir_uniformity_via_mean_stream_index() {
1171        let capacity = 16usize;
1172        let n = 1000usize; // stream length, 60x capacity
1173        let trials = 50usize;
1174        let expected_mean = (n as f64 + 1.0) / 2.0; // ≈ 500.5
1175        let mut grand_mean = 0.0_f64;
1176        for trial in 0..trials {
1177            let mut r = ReservoirBuffer::<usize>::with_seed(capacity, trial as u64);
1178            for i in 1..=n {
1179                r.push(i); // store 1-based stream index as the item
1180            }
1181            assert_eq!(r.len(), capacity);
1182            let sum: usize = r.items().iter().sum();
1183            let mean = sum as f64 / capacity as f64;
1184            grand_mean += mean;
1185        }
1186        grand_mean /= trials as f64;
1187        let abs_err = (grand_mean - expected_mean).abs();
1188        // Standard error of the mean for the uniformity test: each
1189        // trial's mean has variance ≈ N²/(12 * capacity). Across
1190        // `trials`, the SEM of `grand_mean` is N/sqrt(12 * capacity * trials).
1191        // Allow ~3 SEM tolerance.
1192        let sem = (n as f64) / ((12.0 * capacity as f64 * trials as f64).sqrt());
1193        let tol = 3.0 * sem;
1194        assert!(
1195            abs_err <= tol,
1196            "Vitter Algorithm R uniformity failed: grand_mean={grand_mean:.2}, \
1197             expected_mean={expected_mean:.2}, abs_err={abs_err:.2}, tol={tol:.2} \
1198             (this test fails for FIFO/sliding-window buffers)"
1199        );
1200    }
1201
1202    #[test]
1203    fn test_reservoir_first_overflow_inclusion_probability() {
1204        // At capacity = N, the first overflow item (stream index
1205        // N+1) has insertion probability exactly N/(N+1) under
1206        // Vitter Algorithm R. Verify empirically.
1207        let capacity = 10usize;
1208        let trials = 5000usize;
1209        let mut kept = 0usize;
1210        for t in 0..trials {
1211            let mut r = ReservoirBuffer::<u32>::with_seed(capacity, (t as u64) ^ 0xdead_beef);
1212            // Fill to capacity.
1213            for i in 0..capacity {
1214                r.push(i as u32);
1215            }
1216            // The first overflow push — should be retained with
1217            // probability capacity / (capacity + 1) = 10/11 ≈ 0.909.
1218            let was_retained = r.push(u32::MAX);
1219            if was_retained {
1220                kept += 1;
1221            }
1222        }
1223        let p_emp = kept as f64 / trials as f64;
1224        let p_target = capacity as f64 / (capacity as f64 + 1.0);
1225        let std = (p_target * (1.0 - p_target) / trials as f64).sqrt();
1226        let tol = 3.0 * std;
1227        assert!(
1228            (p_emp - p_target).abs() <= tol,
1229            "first-overflow inclusion probability deviates: p_emp={p_emp:.4}, \
1230             p_target={p_target:.4} (tol={tol:.4})"
1231        );
1232    }
1233
1234    #[test]
1235    fn test_reservoir_sample_with_replacement_empty() {
1236        let mut r = ReservoirBuffer::<u32>::with_seed(4, 0);
1237        let s = r.sample_with_replacement(8);
1238        assert!(s.is_empty());
1239    }
1240
1241    #[test]
1242    fn test_reservoir_sample_with_replacement_uniform_over_items() {
1243        let mut r = ReservoirBuffer::<u32>::with_seed(2, 0);
1244        r.push(7);
1245        r.push(11);
1246        let n = 1000;
1247        let s = r.sample_with_replacement(n);
1248        assert_eq!(s.len(), n);
1249        let c7 = s.iter().filter(|&&x| x == 7).count();
1250        let c11 = s.iter().filter(|&&x| x == 11).count();
1251        assert_eq!(c7 + c11, n, "all samples must be from the reservoir");
1252        // ~half each. Allow plenty of slack.
1253        let frac7 = c7 as f64 / n as f64;
1254        assert!((frac7 - 0.5).abs() < 0.1, "sample distribution should be ~uniform");
1255    }
1256
1257    // ------------------------------------------------------------------
1258    // η-anticipatory mixing: counter-based rate test + reservoir-push
1259    // invariant
1260    // ------------------------------------------------------------------
1261
1262    /// A "rollout-only" variant of
1263    /// [`NfspTrainer::collect_anticipatory_rollout`] that uses a much
1264    /// smaller env loop and no PPO update, to keep the test fast.
1265    /// Reimplemented here because the real `collect_anticipatory_rollout`
1266    /// is `&mut self` on the full trainer.
1267    fn run_anticipatory_simulation(eta: f32, steps: usize, seed: u64) -> (usize, usize, usize) {
1268        let mut rng = StdRng::seed_from_u64(seed);
1269        let mut br_pushed = 0usize;
1270        let ap_pushed = 0usize;
1271        let mut br_taken = 0usize;
1272        for _ in 0..steps {
1273            let u: f32 = rng.random();
1274            if u < eta {
1275                br_taken += 1;
1276                br_pushed += 1; // BR actions enter the reservoir
1277            } else {
1278                // AP path: deliberately do NOT push.
1279                let _ = ap_pushed;
1280            }
1281        }
1282        (br_taken, br_pushed, ap_pushed)
1283    }
1284
1285    #[test]
1286    fn test_eta_anticipatory_mixing_rate_concentration() {
1287        // 100k steps with η = 0.1 → BR fraction within 3σ of 0.1.
1288        let eta = 0.1f32;
1289        let n = 100_000usize;
1290        let (br_taken, _, _) = run_anticipatory_simulation(eta, n, 42);
1291        let p_emp = br_taken as f64 / n as f64;
1292        let p_target = eta as f64;
1293        let std = (p_target * (1.0 - p_target) / n as f64).sqrt();
1294        let tol = 3.0 * std;
1295        assert!(
1296            (p_emp - p_target).abs() <= tol,
1297            "η-mixing rate deviates from 0.1: p_emp={p_emp:.5} (tol={tol:.5})"
1298        );
1299    }
1300
1301    #[test]
1302    fn test_eta_anticipatory_only_br_actions_enter_reservoir() {
1303        // Counter-based invariant: ap_pushed must be exactly 0 across
1304        // any number of η flips. The "simulation" mirrors what the
1305        // real trainer does: only the BR path increments the
1306        // reservoir push counter.
1307        let eta = 0.1f32;
1308        for seed in [0u64, 1, 2, 42, 12345] {
1309            let (_, br_pushed, ap_pushed) = run_anticipatory_simulation(eta, 10_000, seed);
1310            assert_eq!(
1311                ap_pushed, 0,
1312                "AP path must NOT push to the reservoir (seed={seed}, ap_pushed={ap_pushed})"
1313            );
1314            // Sanity: BR pushes happen.
1315            assert!(br_pushed > 0, "BR should sample at least once (seed={seed})");
1316        }
1317    }
1318
1319    // ------------------------------------------------------------------
1320    // Integration: NfspTrainer on matching pennies (smoke)
1321    // ------------------------------------------------------------------
1322
1323    #[allow(clippy::type_complexity)]
1324    fn build_matching_pennies_nfsp_trainer(
1325        max_iterations: usize,
1326        eta: f32,
1327    ) -> NfspTrainer<
1328        B,
1329        MlpBurnPolicy<B>,
1330        burn::optim::adaptor::OptimizerAdaptor<burn::optim::Adam, MlpBurnPolicy<B>, B>,
1331        MatchingPennies,
1332        impl Fn(&NdArrayDevice, u64) -> MlpBurnPolicy<B>,
1333        impl Fn() -> BurnOptimizer<
1334            B,
1335            MlpBurnPolicy<B>,
1336            burn::optim::adaptor::OptimizerAdaptor<burn::optim::Adam, MlpBurnPolicy<B>, B>,
1337        >,
1338        impl Fn() -> MatchingPennies,
1339    > {
1340        let device: NdArrayDevice = Default::default();
1341        let nfsp_config = NfspConfig {
1342            max_iterations,
1343            anticipatory_param: eta,
1344            reservoir_capacity: 1_024,
1345            br_train_steps_per_iteration: 1,
1346            avg_policy_train_steps_per_iteration: 2,
1347            avg_policy_minibatch_size: 32,
1348            avg_policy_lr: 5e-3,
1349            avg_policy_min_reservoir_coverage: 0.0,
1350            br_reward_scale: 1.0,
1351            seed: 0,
1352        };
1353        let joint_config = JointTrainerConfig {
1354            num_agents: 2,
1355            rollout_steps: 64,
1356            n_epochs: 1,
1357            minibatch_size: 32,
1358            ..Default::default()
1359        };
1360        NfspTrainer::new(
1361            nfsp_config,
1362            joint_config,
1363            device,
1364            |dev: &NdArrayDevice, seed: u64| {
1365                MlpBurnPolicy::<B>::new_seeded(
1366                    MatchingPennies::OBS_DIM,
1367                    MatchingPennies::ACTION_DIM,
1368                    16,
1369                    seed,
1370                    dev,
1371                )
1372            },
1373            || {
1374                let inner = AdamConfig::new().init();
1375                BurnOptimizer::new(inner, 5e-3)
1376            },
1377            MatchingPennies::new,
1378        )
1379        .expect("NfspTrainer::new should succeed for 2-agent config")
1380    }
1381
1382    #[test]
1383    fn test_nfsp_construction_rejects_num_agents_less_than_two() {
1384        let device: NdArrayDevice = Default::default();
1385        let nfsp_config = NfspConfig::default();
1386        // num_agents = 1 — below the trainer's minimum, must be rejected.
1387        let joint_config = JointTrainerConfig { num_agents: 1, ..Default::default() };
1388        let res = NfspTrainer::<
1389            B,
1390            MlpBurnPolicy<B>,
1391            burn::optim::adaptor::OptimizerAdaptor<burn::optim::Adam, MlpBurnPolicy<B>, B>,
1392            MatchingPennies,
1393            _,
1394            _,
1395            _,
1396        >::new(
1397            nfsp_config,
1398            joint_config,
1399            device,
1400            |dev: &NdArrayDevice, seed: u64| MlpBurnPolicy::<B>::new_seeded(1, 2, 8, seed, dev),
1401            || BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
1402            MatchingPennies::new,
1403        );
1404        assert!(res.is_err(), "NFSP should reject num_agents < 2");
1405    }
1406
1407    #[test]
1408    fn test_nfsp_construction_accepts_n_player_config() {
1409        // Post-#119: num_agents > 2 is no longer rejected. Verify the
1410        // trainer constructs with N=4 and the right number of
1411        // reservoirs/AP policies.
1412        use crate::env::games::n_player_matching_pennies::NPlayerMatchingPennies;
1413        let device: NdArrayDevice = Default::default();
1414        let nfsp_config = NfspConfig::default();
1415        let joint_config = JointTrainerConfig { num_agents: 4, ..Default::default() };
1416        let trainer = NfspTrainer::<
1417            B,
1418            MlpBurnPolicy<B>,
1419            burn::optim::adaptor::OptimizerAdaptor<burn::optim::Adam, MlpBurnPolicy<B>, B>,
1420            NPlayerMatchingPennies,
1421            _,
1422            _,
1423            _,
1424        >::new(
1425            nfsp_config,
1426            joint_config,
1427            device,
1428            |dev: &NdArrayDevice, seed: u64| {
1429                MlpBurnPolicy::<B>::new_seeded(
1430                    NPlayerMatchingPennies::OBS_DIM,
1431                    NPlayerMatchingPennies::ACTION_DIM,
1432                    8,
1433                    seed,
1434                    dev,
1435                )
1436            },
1437            || BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
1438            || NPlayerMatchingPennies::new(4),
1439        )
1440        .expect("NFSP should accept num_agents = 4");
1441        for i in 0..4 {
1442            assert_eq!(trainer.reservoir(i).len(), 0, "agent {i} reservoir starts empty");
1443        }
1444    }
1445
1446    #[test]
1447    fn test_nfsp_construction_rejects_out_of_range_eta() {
1448        let device: NdArrayDevice = Default::default();
1449        let nfsp_config = NfspConfig { anticipatory_param: 1.5, ..Default::default() };
1450        let joint_config = JointTrainerConfig { num_agents: 2, ..Default::default() };
1451        let res = NfspTrainer::<
1452            B,
1453            MlpBurnPolicy<B>,
1454            burn::optim::adaptor::OptimizerAdaptor<burn::optim::Adam, MlpBurnPolicy<B>, B>,
1455            MatchingPennies,
1456            _,
1457            _,
1458            _,
1459        >::new(
1460            nfsp_config,
1461            joint_config,
1462            device,
1463            |dev: &NdArrayDevice, seed: u64| MlpBurnPolicy::<B>::new_seeded(1, 2, 8, seed, dev),
1464            || BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
1465            MatchingPennies::new,
1466        );
1467        assert!(res.is_err(), "NFSP should reject η outside [0,1]");
1468    }
1469
1470    #[test]
1471    fn test_nfsp_runs_end_to_end_on_matching_pennies() {
1472        let mut trainer = build_matching_pennies_nfsp_trainer(3, 0.5);
1473        let stats = trainer.run_silent().expect("NFSP run should not error");
1474        assert_eq!(stats.iterations.len(), 3, "should record 3 iterations");
1475        for (k, it) in stats.iterations.iter().enumerate() {
1476            assert_eq!(it.iteration, k + 1);
1477            assert_eq!(it.reservoir_sizes.len(), 2);
1478            // With η = 0.5 and ≥1 BR step per iter on 64 rollout steps,
1479            // reservoirs should accumulate items.
1480            assert!(it.reservoir_sizes.iter().sum::<usize>() > 0, "reservoirs should accumulate");
1481        }
1482    }
1483
1484    #[test]
1485    fn test_nfsp_eta_zero_pure_avg_policy_never_pushes() {
1486        // η = 0: every rollout step samples from AP. Reservoir must
1487        // stay empty. AP supervised step must be skipped (no data).
1488        let mut trainer = build_matching_pennies_nfsp_trainer(2, 0.0);
1489        let _ = trainer.run_silent().expect("NFSP run with η=0 should not error");
1490        for i in 0..2 {
1491            assert_eq!(trainer.reservoir(i).len(), 0, "η=0 must leave reservoir {i} empty");
1492        }
1493        assert_eq!(trainer.cumulative_br_pushes(), 0, "η=0 must result in zero BR pushes");
1494    }
1495
1496    #[test]
1497    fn test_nfsp_eta_one_only_br_path_fills_reservoir() {
1498        // η = 1: every step samples from BR; reservoir grows by one
1499        // per rollout step per agent.
1500        let mut trainer = build_matching_pennies_nfsp_trainer(1, 1.0);
1501        let _ = trainer.run_silent().expect("NFSP run with η=1 should not error");
1502        // 1 iteration × 1 BR train step × 64 rollout steps = 64
1503        // per agent.
1504        for i in 0..2 {
1505            assert_eq!(
1506                trainer.reservoir(i).len(),
1507                64,
1508                "η=1 should retain all 64 rollout steps per agent in reservoir {i}"
1509            );
1510        }
1511        // 2 agents × 64 steps = 128.
1512        assert_eq!(trainer.cumulative_br_pushes(), 128);
1513    }
1514
1515    /// Fast always-on smoke for the in-trainer η-mixing wiring. Drives a
1516    /// single iteration with η = 0.5 over a *small* rollout and asserts
1517    /// directionality: the BR path actually pushes to the reservoir and the
1518    /// empirical BR-fraction lands in a loose-but-non-vacuous band around η.
1519    ///
1520    /// This keeps default-lane coverage of the trainer-side η flip while the
1521    /// rigorous statistical-concentration proof
1522    /// (`test_nfsp_eta_mixing_rate_concentration_in_trainer`) is `#[ignore]`d
1523    /// per the #208/#209 convention — its 4096-step rollout costs ~11 min in
1524    /// the default debug test lane and dominated CI Tests jobs (#224).
1525    #[test]
1526    fn test_nfsp_eta_mixing_rate_smoke_in_trainer() {
1527        let device: NdArrayDevice = Default::default();
1528        let eta = 0.5f32;
1529        // Small rollout: enough flips to expect both paths to fire, cheap
1530        // enough to finish in well under a second in debug.
1531        let rollout_steps = 256usize;
1532        let nfsp_config = NfspConfig {
1533            max_iterations: 1,
1534            anticipatory_param: eta,
1535            reservoir_capacity: 100_000,
1536            br_train_steps_per_iteration: 1,
1537            avg_policy_train_steps_per_iteration: 0,
1538            avg_policy_minibatch_size: 32,
1539            avg_policy_lr: 1e-3,
1540            avg_policy_min_reservoir_coverage: 0.0,
1541            br_reward_scale: 1.0,
1542            seed: 7,
1543        };
1544        let joint_config = JointTrainerConfig {
1545            num_agents: 2,
1546            rollout_steps,
1547            n_epochs: 1,
1548            minibatch_size: 32,
1549            ..Default::default()
1550        };
1551        let mut trainer = NfspTrainer::new(
1552            nfsp_config,
1553            joint_config,
1554            device,
1555            |dev: &NdArrayDevice, seed: u64| {
1556                MlpBurnPolicy::<B>::new_seeded(
1557                    MatchingPennies::OBS_DIM,
1558                    MatchingPennies::ACTION_DIM,
1559                    16,
1560                    seed,
1561                    dev,
1562                )
1563            },
1564            || BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
1565            MatchingPennies::new,
1566        )
1567        .expect("NfspTrainer::new should succeed");
1568        let _ = trainer.run_silent().expect("NFSP run should not error");
1569        let br_pushes = trainer.cumulative_br_pushes() as f64;
1570        let total_steps = (rollout_steps * 2) as f64;
1571        let p_emp = br_pushes / total_steps;
1572        // Directionality, not concentration: with η = 0.5 over 512 flips the
1573        // empirical BR-fraction is overwhelmingly inside [0.25, 0.75]. Also
1574        // require that the BR path fired at all (the wiring is live).
1575        assert!(
1576            br_pushes > 0.0,
1577            "η-mixing BR path must push to the reservoir (br_pushes={br_pushes})"
1578        );
1579        assert!(
1580            (0.25..=0.75).contains(&p_emp),
1581            "η-mixing BR-fraction should be near 0.5: p_emp={p_emp:.4}"
1582        );
1583    }
1584
1585    /// Rigorous statistical-concentration proof for the in-trainer η flip:
1586    /// a single iteration with η = 0.1 over a large rollout, asserting the
1587    /// empirical BR-fraction across the 2 agents is within ~4σ of 0.1.
1588    ///
1589    /// `#[ignore]`d per the #208/#209 convention: the 4096-step rollout costs
1590    /// ~11 min in the default debug test lane and dominated CI Tests jobs
1591    /// (#224). The fast `test_nfsp_eta_mixing_rate_smoke_in_trainer` keeps
1592    /// always-on coverage of the wiring; run this full proof on demand with
1593    /// `cargo test --features training -- --ignored` (prefer `--release`).
1594    #[test]
1595    #[ignore = "multi-iteration NFSP η-mixing concentration run; opt in with --ignored (prefer --release)"]
1596    fn test_nfsp_eta_mixing_rate_concentration_in_trainer() {
1597        // Drive a single iteration with η = 0.1 over a large rollout
1598        // and check the empirical BR-fraction across the 2 agents is
1599        // within ~4σ of 0.1.
1600        let device: NdArrayDevice = Default::default();
1601        let eta = 0.1f32;
1602        let rollout_steps = 4096usize;
1603        let nfsp_config = NfspConfig {
1604            max_iterations: 1,
1605            anticipatory_param: eta,
1606            reservoir_capacity: 100_000,
1607            br_train_steps_per_iteration: 1,
1608            avg_policy_train_steps_per_iteration: 0,
1609            avg_policy_minibatch_size: 32,
1610            avg_policy_lr: 1e-3,
1611            avg_policy_min_reservoir_coverage: 0.0,
1612            br_reward_scale: 1.0,
1613            seed: 7,
1614        };
1615        let joint_config = JointTrainerConfig {
1616            num_agents: 2,
1617            rollout_steps,
1618            n_epochs: 1,
1619            minibatch_size: 32,
1620            ..Default::default()
1621        };
1622        let mut trainer = NfspTrainer::new(
1623            nfsp_config,
1624            joint_config,
1625            device,
1626            |dev: &NdArrayDevice, seed: u64| {
1627                MlpBurnPolicy::<B>::new_seeded(
1628                    MatchingPennies::OBS_DIM,
1629                    MatchingPennies::ACTION_DIM,
1630                    16,
1631                    seed,
1632                    dev,
1633                )
1634            },
1635            || BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
1636            MatchingPennies::new,
1637        )
1638        .expect("NfspTrainer::new should succeed");
1639        let _ = trainer.run_silent().expect("NFSP run should not error");
1640        let br_pushes = trainer.cumulative_br_pushes() as f64;
1641        let total_steps = (rollout_steps * 2) as f64;
1642        let p_emp = br_pushes / total_steps;
1643        let p_target = eta as f64;
1644        let std = (p_target * (1.0 - p_target) / total_steps).sqrt();
1645        let tol = 4.0 * std; // 4σ for resilience to the small N
1646        assert!(
1647            (p_emp - p_target).abs() <= tol,
1648            "trainer η-mixing rate deviates: p_emp={p_emp:.4}, p_target={p_target:.4}, tol={tol:.4}"
1649        );
1650    }
1651
1652    #[test]
1653    fn test_nfsp_avg_policy_supervised_step_reduces_loss_on_fixed_minibatch() {
1654        // Sanity-check the supervised wiring: a few AP updates on a
1655        // fixed reservoir reduce the per-batch cross-entropy.
1656        let device: NdArrayDevice = Default::default();
1657        let nfsp_config = NfspConfig {
1658            max_iterations: 0,
1659            anticipatory_param: 1.0,
1660            reservoir_capacity: 256,
1661            br_train_steps_per_iteration: 0,
1662            avg_policy_train_steps_per_iteration: 0,
1663            avg_policy_minibatch_size: 32,
1664            avg_policy_lr: 5e-2,
1665            avg_policy_min_reservoir_coverage: 0.0,
1666            br_reward_scale: 1.0,
1667            seed: 13,
1668        };
1669        let joint_config = JointTrainerConfig {
1670            num_agents: 2,
1671            rollout_steps: 32,
1672            n_epochs: 1,
1673            minibatch_size: 32,
1674            ..Default::default()
1675        };
1676        let mut trainer = NfspTrainer::new(
1677            nfsp_config,
1678            joint_config,
1679            device,
1680            |dev: &NdArrayDevice, seed: u64| MlpBurnPolicy::<B>::new_seeded(1, 2, 8, seed, dev),
1681            || BurnOptimizer::new(AdamConfig::new().init(), 5e-2),
1682            MatchingPennies::new,
1683        )
1684        .expect("NfspTrainer::new should succeed");
1685
1686        // Seed reservoir 0 with a fixed dataset: all `(obs=[0.0], action=[0])`.
1687        // Single-discrete callers wrap their scalar action in a length-1
1688        // vec — the post-#127 reservoir contract.
1689        for _ in 0..64 {
1690            trainer.reservoirs[0].push((vec![0.0_f32], vec![0]));
1691        }
1692        // Stage the supervised step manually a few times by setting
1693        // `avg_policy_train_steps_per_iteration` and calling the
1694        // private method. We do this by mutating the config and
1695        // invoking train_average_policies through a single iteration
1696        // of the public run loop. To avoid extra rollout
1697        // mechanics, build a config that asks for AP-only steps and
1698        // 0 BR rollouts.
1699        trainer.config.avg_policy_train_steps_per_iteration = 10;
1700
1701        // First loss probe.
1702        let losses_before = trainer.train_average_policies().unwrap();
1703        let loss_before = losses_before[0].expect("expected supervised loss for agent 0");
1704
1705        // Repeat 4 more times.
1706        for _ in 0..4 {
1707            trainer.train_average_policies().unwrap();
1708        }
1709        let losses_after = trainer.train_average_policies().unwrap();
1710        let loss_after = losses_after[0].expect("expected supervised loss for agent 0");
1711
1712        assert!(
1713            loss_after < loss_before,
1714            "AP supervised CE should decrease: before={loss_before:.4}, after={loss_after:.4}"
1715        );
1716    }
1717
1718    /// Issue #199 regression: on a **factored multi-discrete** `[10,2,2]`
1719    /// action space (bucket-brigade's shape, joint cardinality 40), the
1720    /// uniform-policy cross-entropy floor is `ln(40) ≈ 3.689`. The #134
1721    /// cluster run reported `avg_ap_loss` pinned at exactly that floor
1722    /// for an entire 48-iteration run — the average policy never fit its
1723    /// reservoir.
1724    ///
1725    /// This test reproduces the *root cause locally* and shows the fix:
1726    /// it seeds an agent's reservoir with a deterministic **non-uniform**
1727    /// target (a single fixed joint action `[3, 1, 0]` for every entry,
1728    /// the easiest possible supervised target) and runs the AP supervised
1729    /// update. With the adaptive `avg_policy_min_reservoir_coverage` floor
1730    /// the AP is given enough gradient steps to drive `avg_ap_loss`
1731    /// **well below `ln(40)`** — exactly the signal the cluster run never
1732    /// produced. Fast (NdArray CPU, tiny obs/hidden dims), so it runs on
1733    /// every `cargo test --features training` invocation, not behind
1734    /// `#[ignore]`.
1735    #[test]
1736    fn test_nfsp_multi_discrete_ap_loss_drops_below_uniform_floor() {
1737        use crate::policy::multi_discrete_mlp::MultiDiscreteMlpBurnPolicy;
1738
1739        let device: NdArrayDevice = Default::default();
1740        let action_dims = vec![10usize, 2, 2]; // joint cardinality = 40
1741        let uniform_floor = (40.0_f64).ln(); // ≈ 3.6889
1742
1743        let nfsp_config = NfspConfig {
1744            max_iterations: 0,
1745            anticipatory_param: 1.0,
1746            reservoir_capacity: 4_096,
1747            br_train_steps_per_iteration: 0,
1748            avg_policy_train_steps_per_iteration: 8,
1749            avg_policy_minibatch_size: 64,
1750            avg_policy_lr: 5e-3,
1751            // The fix under test: cover the reservoir ~4× per call so the
1752            // AP gets enough gradient steps to actually fit the target.
1753            avg_policy_min_reservoir_coverage: 4.0,
1754            br_reward_scale: 1.0,
1755            seed: 199,
1756        };
1757        let joint_config = JointTrainerConfig {
1758            num_agents: 2,
1759            rollout_steps: 8,
1760            n_epochs: 1,
1761            minibatch_size: 32,
1762            ..Default::default()
1763        };
1764
1765        // obs_dim is small and arbitrary here — we never step the env;
1766        // we drive `train_average_policies` directly on a hand-seeded
1767        // reservoir. The env type only has to satisfy the trainer's
1768        // generic bound (we use MatchingPennies, never stepped).
1769        let obs_dim = 4usize;
1770        let dims_for_factory = action_dims.clone();
1771        let mut trainer = NfspTrainer::new(
1772            nfsp_config,
1773            joint_config,
1774            device,
1775            move |dev: &NdArrayDevice, seed: u64| {
1776                MultiDiscreteMlpBurnPolicy::<B>::new_seeded(
1777                    obs_dim,
1778                    dims_for_factory.clone(),
1779                    16,
1780                    seed,
1781                    dev,
1782                )
1783            },
1784            || BurnOptimizer::new(AdamConfig::new().init(), 5e-3),
1785            MatchingPennies::new,
1786        )
1787        .expect("NfspTrainer::new should succeed for multi-discrete config");
1788
1789        // Seed reservoir 0 with a fixed, non-uniform supervised target:
1790        // every entry maps a constant observation to the single joint
1791        // action [3, 1, 0]. A network that fits this perfectly drives the
1792        // CE loss toward 0; the question is whether the AP gets enough
1793        // gradient steps to move off the ln(40) uniform floor at all.
1794        let fixed_obs = vec![0.25_f32; obs_dim];
1795        let fixed_action: Vec<i64> = vec![3, 1, 0];
1796        for _ in 0..512 {
1797            trainer.reservoirs[0].push((fixed_obs.clone(), fixed_action.clone()));
1798        }
1799
1800        // First supervised pass: the freshly-initialized AP should start
1801        // near the uniform floor.
1802        let losses_first = trainer.train_average_policies().unwrap();
1803        let loss_first = losses_first[0].expect("expected supervised loss for agent 0");
1804
1805        // A handful more passes to let the adaptive-coverage budget fit
1806        // the (trivial, deterministic) target.
1807        for _ in 0..8 {
1808            trainer.train_average_policies().unwrap();
1809        }
1810        let losses_last = trainer.train_average_policies().unwrap();
1811        let loss_last = losses_last[0].expect("expected supervised loss for agent 0");
1812
1813        // Visible with `cargo test -- --nocapture`; documents the local
1814        // before/after used in the PR writeup.
1815        eprintln!(
1816            "[#199] multi-discrete AP loss: first={loss_first:.4}, last={loss_last:.4}, \
1817             ln(40) floor={uniform_floor:.4}"
1818        );
1819
1820        // The cluster run's symptom was `avg_ap_loss` stuck AT the floor.
1821        // The fix must drive it meaningfully below. Use a generous margin
1822        // (0.5 nats) so the assertion is robust across platforms but
1823        // still hard-fails the "pinned at ln(40)" pathology.
1824        assert!(
1825            loss_last < uniform_floor - 0.5,
1826            "AP loss should drop well below the ln(40) uniform floor: \
1827             first={loss_first:.4}, last={loss_last:.4}, floor={uniform_floor:.4}"
1828        );
1829        assert!(
1830            loss_last < loss_first,
1831            "AP loss should decrease across supervised passes: \
1832             first={loss_first:.4}, last={loss_last:.4}"
1833        );
1834    }
1835
1836    /// Issue #234: the parallelized per-agent AP supervised loop must be
1837    /// **deterministic for a fixed seed and invariant to the rayon thread
1838    /// count**. AP minibatches are drawn from each reservoir's own seeded
1839    /// RNG (`ReservoirBuffer::rng`), never the shared `self.rng`, so each
1840    /// agent consumes exactly its own RNG stream regardless of how the
1841    /// rayon tasks interleave.
1842    ///
1843    /// We build two structurally-identical trainers (same `NfspConfig`
1844    /// seed), seed their reservoirs with the same deterministic data, then
1845    /// run the full multi-pass AP trajectory on each — one forced onto a
1846    /// single rayon worker thread, the other onto four. The per-agent loss
1847    /// trajectories must be **bit-identical**.
1848    #[test]
1849    fn test_nfsp_ap_training_deterministic_across_thread_counts() {
1850        use crate::policy::multi_discrete_mlp::MultiDiscreteMlpBurnPolicy;
1851
1852        let action_dims = vec![10usize, 2, 2]; // joint cardinality = 40
1853        let obs_dim = 4usize;
1854
1855        // Build a trainer + seed its reservoirs identically, then run a
1856        // fixed multi-pass AP trajectory inside a rayon pool of `threads`
1857        // workers. Returns the full per-pass, per-agent loss trajectory.
1858        let run_trajectory = |threads: usize| -> Vec<Vec<Option<f64>>> {
1859            let device: NdArrayDevice = Default::default();
1860            let nfsp_config = NfspConfig {
1861                max_iterations: 0,
1862                anticipatory_param: 1.0,
1863                reservoir_capacity: 4_096,
1864                br_train_steps_per_iteration: 0,
1865                avg_policy_train_steps_per_iteration: 4,
1866                avg_policy_minibatch_size: 64,
1867                avg_policy_lr: 5e-3,
1868                avg_policy_min_reservoir_coverage: 2.0,
1869                br_reward_scale: 1.0,
1870                seed: 234,
1871            };
1872            let joint_config = JointTrainerConfig {
1873                num_agents: 2,
1874                rollout_steps: 8,
1875                n_epochs: 1,
1876                minibatch_size: 32,
1877                ..Default::default()
1878            };
1879            let dims_for_factory = action_dims.clone();
1880            let mut trainer = NfspTrainer::new(
1881                nfsp_config,
1882                joint_config,
1883                device,
1884                move |dev: &NdArrayDevice, seed: u64| {
1885                    MultiDiscreteMlpBurnPolicy::<B>::new_seeded(
1886                        obs_dim,
1887                        dims_for_factory.clone(),
1888                        16,
1889                        seed,
1890                        dev,
1891                    )
1892                },
1893                || BurnOptimizer::new(AdamConfig::new().init(), 5e-3),
1894                MatchingPennies::new,
1895            )
1896            .expect("NfspTrainer::new should succeed");
1897
1898            // Seed both agents' reservoirs with deterministic, distinct
1899            // non-uniform targets so the AP actually moves and both agents
1900            // exercise the parallel path.
1901            let fixed_obs = vec![0.25_f32; obs_dim];
1902            for _ in 0..256 {
1903                trainer.reservoirs[0].push((fixed_obs.clone(), vec![3, 1, 0]));
1904                trainer.reservoirs[1].push((fixed_obs.clone(), vec![7, 0, 1]));
1905            }
1906
1907            let pool = rayon::ThreadPoolBuilder::new()
1908                .num_threads(threads)
1909                .build()
1910                .expect("rayon pool should build");
1911            pool.install(|| (0..6).map(|_| trainer.train_average_policies().unwrap()).collect())
1912        };
1913
1914        let serial = run_trajectory(1);
1915        let parallel = run_trajectory(4);
1916
1917        assert_eq!(
1918            serial, parallel,
1919            "AP loss trajectory must be bit-identical across rayon thread counts \
1920             (per-reservoir RNG, no shared self.rng): serial={serial:?}, parallel={parallel:?}"
1921        );
1922        // Sanity: the trajectory is non-trivial (both agents produced
1923        // losses), so the equality above is meaningful.
1924        assert!(
1925            serial.iter().all(|pass| pass.iter().all(|l| l.is_some())),
1926            "both agents should produce a loss every pass: {serial:?}"
1927        );
1928    }
1929
1930    /// Issue #199: the adaptive coverage floor must run *more* supervised
1931    /// steps than the fixed `avg_policy_train_steps_per_iteration` when
1932    /// the reservoir is large, and exactly the fixed count when coverage
1933    /// is disabled (`0.0`). We verify this by counting actual gradient
1934    /// steps via the returned per-agent loss being present and by
1935    /// asserting the coverage math through a public-ish probe: a 0-step
1936    /// fixed budget with coverage still produces a loss (steps were run),
1937    /// while disabling coverage with a 0-step budget produces `None`.
1938    #[test]
1939    fn test_nfsp_adaptive_coverage_runs_steps_when_fixed_budget_is_zero() {
1940        use crate::policy::multi_discrete_mlp::MultiDiscreteMlpBurnPolicy;
1941
1942        let device: NdArrayDevice = Default::default();
1943        let obs_dim = 3usize;
1944        let action_dims = vec![4usize, 2];
1945
1946        let make = |coverage: f32, fixed_steps: usize| {
1947            let nfsp_config = NfspConfig {
1948                max_iterations: 0,
1949                anticipatory_param: 1.0,
1950                reservoir_capacity: 1_024,
1951                br_train_steps_per_iteration: 0,
1952                avg_policy_train_steps_per_iteration: fixed_steps,
1953                avg_policy_minibatch_size: 32,
1954                avg_policy_lr: 1e-3,
1955                avg_policy_min_reservoir_coverage: coverage,
1956                br_reward_scale: 1.0,
1957                seed: 7,
1958            };
1959            let joint_config = JointTrainerConfig {
1960                num_agents: 1 + 1,
1961                rollout_steps: 8,
1962                n_epochs: 1,
1963                minibatch_size: 32,
1964                ..Default::default()
1965            };
1966            let dims = action_dims.clone();
1967            let mut trainer = NfspTrainer::new(
1968                nfsp_config,
1969                joint_config,
1970                device,
1971                move |dev: &NdArrayDevice, seed: u64| {
1972                    MultiDiscreteMlpBurnPolicy::<B>::new_seeded(obs_dim, dims.clone(), 8, seed, dev)
1973                },
1974                || BurnOptimizer::new(AdamConfig::new().init(), 1e-3),
1975                MatchingPennies::new,
1976            )
1977            .expect("trainer construction");
1978            for _ in 0..256 {
1979                trainer.reservoirs[0].push((vec![0.1_f32; obs_dim], vec![1, 0]));
1980            }
1981            trainer
1982        };
1983
1984        // Coverage disabled + 0 fixed steps → no supervised steps run.
1985        let mut disabled = make(0.0, 0);
1986        assert!(
1987            disabled.train_average_policies().unwrap()[0].is_none(),
1988            "coverage=0 with 0 fixed steps must run no supervised steps"
1989        );
1990
1991        // Coverage enabled + 0 fixed steps → coverage forces steps to run
1992        // (256 entries × 1.0 / 32 mb = 8 steps), so a loss is produced.
1993        let mut enabled = make(1.0, 0);
1994        assert!(
1995            enabled.train_average_policies().unwrap()[0].is_some(),
1996            "coverage>0 must run supervised steps even when fixed budget is 0"
1997        );
1998    }
1999
2000    #[test]
2001    fn test_nfsp_determinism_within_module_under_seed() {
2002        // Same seed produces bit-identical η-flip stream and reservoir
2003        // structure (lengths + stream-lengths) when only the in-module
2004        // RNGs are exercised. The exact (obs, action) contents of the
2005        // reservoir depend on the rollout-time `get_action_host`
2006        // draws, which are downstream of (a) the non-deterministic
2007        // per-epoch shuffle inside
2008        // `JointMultiAgentTrainer::update_with_active_agents` (tracked
2009        // in #109) and (b) the BR policy's own non-deterministic
2010        // `rand::rng()` sampling inside `get_action_host`. Both are
2011        // out of scope for this PR; this test asserts only the
2012        // determinism slice the NFSP module itself controls.
2013        let mut a = build_matching_pennies_nfsp_trainer(1, 0.5);
2014        let mut b = build_matching_pennies_nfsp_trainer(1, 0.5);
2015        let _ = a.run_silent().unwrap();
2016        let _ = b.run_silent().unwrap();
2017        for i in 0..2 {
2018            assert_eq!(
2019                a.reservoir(i).len(),
2020                b.reservoir(i).len(),
2021                "reservoir lengths must match across same-seed runs (agent {i})"
2022            );
2023            assert_eq!(
2024                a.reservoir(i).stream_length(),
2025                b.reservoir(i).stream_length(),
2026                "reservoir stream length must match across same-seed runs (agent {i})"
2027            );
2028        }
2029        // η-flip-derived counts depend only on the seeded NFSP RNG and
2030        // must be bit-identical.
2031        assert_eq!(a.cumulative_br_pushes(), b.cumulative_br_pushes());
2032        assert_eq!(a.cumulative_rollout_steps(), b.cumulative_rollout_steps());
2033    }
2034}