Skip to main content

thrust_rl/multi_agent/
joint.rs

1//! Synchronized joint multi-agent PPO trainer (Burn backend).
2//!
3//! Burn-native rebuild of the pre-Burn `JointMultiAgentTrainer`. The previous
4//! `tch`-coupled implementation was deleted in PR #98 along with the rest of
5//! `src/multi_agent/`; this module re-establishes the *synchronized* (one
6//! shared backward pass) joint trainer on top of the Burn policy networks
7//! and the [`crate::train::optimizer::BurnOptimizer`] wrapper.
8//!
9//! # When to use this module
10//!
11//! Use the joint trainer when you need a loss term that depends on **all**
12//! agents' parameters evaluated on the **same minibatch** at the **same**
13//! optimization step. The canonical motivating example is the Slepian-Wolf
14//! MARL P3 cross-agent representational redundancy penalty
15//!
16//! ```text
17//! L_red   = λ * Σ_{i<j} || corr(Z_i(obs), Z_j(obs)) ||_F² / d²
18//! L_total = Σ_i L_ppo[i](obs, a_i, ...) + L_red
19//! ```
20//!
21//! where `Z_i = encoder_features_i(obs)`. The penalty couples every policy's
22//! encoder through one shared backward pass; per-thread learners cannot
23//! express this without heavy synchronization.
24//!
25//! # Single-graph, multiple-optimizer semantics under Burn
26//!
27//! Burn's [`burn::optim::Optimizer::step`] consumes the module by value and
28//! returns the updated copy. That makes the tch-style "one `.backward()` plus
29//! N independent `Optimizer::step` calls touching disjoint var-stores"
30//! pattern slightly different in Burn:
31//!
32//! 1. Each policy computes its own `(policy_loss, value_loss, entropy)` on the
33//!    minibatch.
34//! 2. The caller-supplied `aux_fn` is invoked on every policy's encoder
35//!    features and may return an additional scalar loss.
36//! 3. All per-agent losses plus the aux loss are summed into one scalar
37//!    `joint_loss`; we call `.backward()` once.
38//! 4. For each policy `i`, we slice the joint gradients down to policy `i`'s
39//!    parameters with [`burn::optim::GradientsParams::from_grads(grads.clone(),
40//!    &policies[i])`], then call `optimizer_i.step(lr, policies[i], slice_i)`.
41//!
42//! That last step is the Burn analog of "every optimizer reads only its own
43//! var-store's `.grad`" on the tch path — gradient flow stays
44//! parameter-isolated because each `from_grads` slice only contains the
45//! ids of one policy's params. The aux term's contribution flows into every
46//! policy's slice through the shared backward pass, by construction.
47//!
48//! # Minibatch sampling
49//!
50//! By default (and to keep the smoke test deterministic) this trainer
51//! takes **one** minibatch per epoch, sampled via
52//! [`crate::train::ppo::loss::generate_minibatch_indices`] and truncated to
53//! `config.minibatch_size`. Setting
54//! [`JointTrainerConfig::iterate_all_minibatches`] switches to the
55//! conventional PPO pattern of walking *every* `minibatch_size` chunk per
56//! epoch, so a large rollout is fully consumed instead of ~97% discarded
57//! (issue #239). The default stays single-minibatch so existing NFSP/PSRO
58//! determinism tests are bit-identical.
59//!
60//! # Gradient clipping
61//!
62//! `JointTrainerConfig::max_grad_norm` is applied as a **global** L2-norm
63//! clip on each policy's gradient slice before the optimizer step (issue
64//! #239): if the joint gradient norm exceeds the cap, every gradient is
65//! scaled down by `max_norm / ‖g‖`, preserving direction. This stops the
66//! raw-scale value-loss gradient from swamping the policy heads on the
67//! shared actor-critic trunk.
68
69use anyhow::{Result, anyhow};
70use burn::{
71    module::AutodiffModule,
72    optim::{GradientsParams, Optimizer},
73    tensor::{Int, Tensor, backend::AutodiffBackend},
74};
75use rand::rngs::StdRng;
76
77use crate::train::{
78    grad_clip::clip_grads_by_global_norm,
79    optimizer::{BackendOptimizer, BurnOptimizer},
80    ppo::loss::{
81        compute_policy_loss, compute_value_loss, generate_minibatch_indices_with_rng, scalar_f64,
82    },
83};
84
85// -----------------------------------------------------------------------
86// Trait: what a policy must support to participate.
87// -----------------------------------------------------------------------
88
89/// Capabilities a policy must expose to participate in
90/// [`JointMultiAgentTrainer`].
91///
92/// The trait pins exactly the surface the trainer needs:
93///
94/// - **`get_action_host_seeded`** — rollout-time sampling. Returns
95///   `(actions_per_dim, log_probs, values)` on the host so the trainer can
96///   build the rollout buffer without tying it to a particular backend tensor.
97///   Takes the trainer-owned `StdRng` so `PsroConfig::seed` /
98///   `NfspConfig::seed` produce bit-identical rollouts (issue #114).
99/// - **`evaluate_actions`** — re-evaluate the current policy on previously
100///   sampled actions to compute updated log-probs / entropy / value for the PPO
101///   loss; this is the only place autograd-bearing tensors are produced.
102/// - **`encoder_features`** — shared-trunk activations for the auxiliary loss.
103/// - **`action_dims`** — per-dim action cardinalities, used to size action
104///   buffers without invoking the policy.
105pub trait JointPolicy<B: AutodiffBackend>: AutodiffModule<B> + Clone {
106    /// Sample actions for a single rollout step using a caller-supplied
107    /// seeded RNG.
108    ///
109    /// `obs` carries one row per environment in the rollout batch. Returns
110    /// host-side `(actions, log_probs, values)` where:
111    ///
112    /// - `actions` is laid out flat per-row: `actions[row * num_dims + d]` is
113    ///   the action sampled for dim `d` of row `row`. Length is
114    ///   `obs.dims()\[0\] * num_dims`.
115    /// - `log_probs[row]` is the joint log-probability summed across dims.
116    /// - `values[row]` is the value estimate.
117    ///
118    /// Bit-exactness: two calls with the same `obs`, same policy state,
119    /// and same-seeded `rng` produce element-wise identical outputs —
120    /// the load-bearing guarantee that `PsroConfig::seed` /
121    /// `NfspConfig::seed` rely on after issue #114 completed plumbing
122    /// the trainer-owned `StdRng` through the rollout-time action
123    /// sampler.
124    fn get_action_host_seeded(
125        &self,
126        obs: Tensor<B, 2>,
127        rng: &mut StdRng,
128    ) -> (Vec<i64>, Vec<f32>, Vec<f32>);
129
130    /// Batched seeded sampler: `obs` carries `N` rows scored through **this
131    /// one policy** in a single forward, returning `N` actions.
132    ///
133    /// # When this helps (and when it does not)
134    ///
135    /// This eliminates per-call batch-1 forward overhead on the NdArray
136    /// backend wherever many observations are scored through the **same**
137    /// model in one step — e.g. the constant-obs marginal probe
138    /// ([`crate::multi_agent::nfsp::NfspTrainer::action_marginal_for`]),
139    /// or a future parallel-env rollout where one frozen policy scores
140    /// many env rows.
141    ///
142    /// It does **not** apply across the per-agent forwards of NFSP's (or
143    /// PSRO's) rollout: those `num_agents` forwards each run a *distinct*
144    /// per-agent policy module, and a single batched forward applies one
145    /// weight set to every row — so stacking the agents' observations
146    /// into one `[N, obs_dim]` tensor would be semantically wrong. NFSP
147    /// runs a single shared joint episode (`env.step_joint`), so there is
148    /// no within-model batch dimension to collapse in the rollout itself;
149    /// the per-step forward count there is irreducible. See issue #235.
150    ///
151    /// # Determinism
152    ///
153    /// The default implementation simply calls
154    /// [`Self::get_action_host_seeded`] on the full `[N, obs_dim]` tensor,
155    /// which already draws RNG in row-major order. Implementors that
156    /// override this (the concrete MLP policies do) must keep the per-row
157    /// RNG draw order identical so the sampled action stream is
158    /// bit-for-bit reproducible under `PsroConfig::seed` / `NfspConfig::seed`
159    /// (issue #114).
160    fn get_actions_host_seeded_batched(
161        &self,
162        obs: Tensor<B, 2>,
163        rng: &mut StdRng,
164    ) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
165        self.get_action_host_seeded(obs, rng)
166    }
167
168    /// Re-evaluate the policy on previously-sampled actions.
169    ///
170    /// `actions` is shape `[batch, num_dims]`. For scalar discrete policies
171    /// (`num_dims == 1`) pass actions reshaped to `[batch, 1]`. Returns
172    /// `(log_probs, entropy, values)` where every tensor has shape
173    /// `[batch]`.
174    fn evaluate_actions_joint(
175        &self,
176        obs: Tensor<B, 2>,
177        actions: Tensor<B, 2, Int>,
178    ) -> (Tensor<B, 1>, Tensor<B, 1>, Tensor<B, 1>);
179
180    /// Shared-trunk feature representation; gradients flow back into the
181    /// encoder. The natural quantity to feed into cross-agent regularizers.
182    ///
183    /// Shape: `[batch, hidden_dim]`.
184    fn encoder_features_joint(&self, obs: Tensor<B, 2>) -> Tensor<B, 2>;
185
186    /// Per-dimension action cardinalities.
187    ///
188    /// - Scalar discrete (e.g. [`crate::policy::mlp::MlpBurnPolicy`] with
189    ///   `action_dim = 5`): returns `vec![5]` (one dim, cardinality 5). The
190    ///   rollout buffer uses `num_action_dims = 1`.
191    /// - Multi-discrete (e.g.
192    ///   [`crate::policy::multi_discrete_mlp::MultiDiscreteMlpBurnPolicy`] with
193    ///   action dims `[10, 2]`): returns `vec![10, 2]`. The rollout buffer uses
194    ///   `num_action_dims = 2`.
195    fn action_dims_joint(&self) -> Vec<i64>;
196}
197
198// MlpBurnPolicy: scalar discrete. We don't have direct access to the policy's
199// action_dim through `Module`, so the impl forces callers to record the
200// cardinality at construction time via a thin wrapper. To stay zero-friction
201// here we expose the impls on the concrete policy types directly: the MLP
202// policy's `policy_head` output dimension is what `action_dims_joint` would
203// return, but Burn's `Linear` doesn't surface that directly through the
204// `Module` API. We track action dims by inspecting the `policy_head`'s
205// weight tensor shape — it lives on the module so this is parity-preserving.
206impl<B: AutodiffBackend> JointPolicy<B> for crate::policy::mlp::MlpBurnPolicy<B>
207where
208    Self: AutodiffModule<B> + Clone,
209{
210    fn get_action_host_seeded(
211        &self,
212        obs: Tensor<B, 2>,
213        rng: &mut StdRng,
214    ) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
215        let (actions, log_probs, values) = self.get_action_host_seeded(obs, rng);
216        // Scalar discrete: actions is already 1-per-row.
217        (actions, log_probs, values)
218    }
219
220    fn get_actions_host_seeded_batched(
221        &self,
222        obs: Tensor<B, 2>,
223        rng: &mut StdRng,
224    ) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
225        // One forward over all rows (same model) instead of per-row
226        // batch-1 forwards; RNG draw order unchanged (issue #235).
227        self.get_actions_host_seeded_batched(obs, rng)
228    }
229
230    fn evaluate_actions_joint(
231        &self,
232        obs: Tensor<B, 2>,
233        actions: Tensor<B, 2, Int>,
234    ) -> (Tensor<B, 1>, Tensor<B, 1>, Tensor<B, 1>) {
235        // The MLP policy's `evaluate_actions` takes rank-1 actions. Squeeze
236        // the dim-1 axis to match.
237        let actions_1d: Tensor<B, 1, Int> = actions.squeeze_dim::<1>(1);
238        self.evaluate_actions(obs, actions_1d)
239    }
240
241    fn encoder_features_joint(&self, obs: Tensor<B, 2>) -> Tensor<B, 2> {
242        self.encoder_features(obs)
243    }
244
245    fn action_dims_joint(&self) -> Vec<i64> {
246        // `policy_head.weight` has shape `[hidden_dim, action_dim]` in Burn's
247        // `Linear` layout; we pull `action_dim` off the second axis.
248        let head_dims = self.policy_head_action_dim();
249        vec![head_dims as i64]
250    }
251}
252
253impl<B: AutodiffBackend> JointPolicy<B>
254    for crate::policy::multi_discrete_mlp::MultiDiscreteMlpBurnPolicy<B>
255where
256    Self: AutodiffModule<B> + Clone,
257{
258    fn get_action_host_seeded(
259        &self,
260        obs: Tensor<B, 2>,
261        rng: &mut StdRng,
262    ) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
263        self.get_action_host_seeded(obs, rng)
264    }
265
266    fn get_actions_host_seeded_batched(
267        &self,
268        obs: Tensor<B, 2>,
269        rng: &mut StdRng,
270    ) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
271        // One forward over all rows (same model) instead of per-row
272        // batch-1 forwards; RNG draw order unchanged (issue #235).
273        self.get_actions_host_seeded_batched(obs, rng)
274    }
275
276    fn evaluate_actions_joint(
277        &self,
278        obs: Tensor<B, 2>,
279        actions: Tensor<B, 2, Int>,
280    ) -> (Tensor<B, 1>, Tensor<B, 1>, Tensor<B, 1>) {
281        self.evaluate_actions(obs, actions)
282    }
283
284    fn encoder_features_joint(&self, obs: Tensor<B, 2>) -> Tensor<B, 2> {
285        self.encoder_features(obs)
286    }
287
288    fn action_dims_joint(&self) -> Vec<i64> {
289        self.action_dim_cardinalities().into_iter().map(|d| d as i64).collect()
290    }
291}
292
293// -----------------------------------------------------------------------
294// Trait: minimal joint environment for rollout collection.
295// -----------------------------------------------------------------------
296
297/// Per-step result returned by a [`JointEnv`] implementation.
298#[derive(Debug, Clone)]
299pub struct JointStepResult {
300    /// Per-agent rewards.
301    pub rewards: Vec<f32>,
302    /// Whether the episode has terminated.
303    pub done: bool,
304    /// Per-agent observations after the step (length = `num_agents`).
305    pub observations: Vec<Vec<f32>>,
306}
307
308/// Minimal joint-environment surface needed by
309/// [`JointMultiAgentTrainer::collect_rollout`].
310///
311/// Why a fresh trait instead of
312/// [`crate::multi_agent::environment::MultiAgentEnvironment`]? The base trait's
313/// `step_multi` already takes `actions: &[Vec<i64>]` so it nominally fits, but
314/// it also requires a full `Environment` impl (single-action `step`, action
315/// spaces, snapshot/restore, etc.). The joint trainer only needs `reset_joint`
316/// / `step_joint`, and adapter envs can implement this trait directly without
317/// touching the wider trait hierarchy.
318pub trait JointEnv {
319    /// Reset the env in-place. Returns per-agent observations.
320    fn reset_joint(&mut self, seed: Option<u64>) -> Vec<Vec<f32>>;
321
322    /// Step the env with per-agent actions.
323    ///
324    /// `actions[i]` is the full per-dim action vector for agent `i`:
325    /// - Length 1 for scalar discrete (e.g. `[3]`).
326    /// - Length `num_dims` for multi-discrete (e.g. `[house_index, mode]`).
327    fn step_joint(&mut self, actions: &[Vec<i64>]) -> JointStepResult;
328}
329
330// -----------------------------------------------------------------------
331// Config / Rollout / Stats
332// -----------------------------------------------------------------------
333
334/// Trainer configuration. Plain data; defaults match the tch-era
335/// `JointTrainerConfig` field-for-field so the smoke-test parameters
336/// stay portable.
337#[derive(Debug, Clone)]
338pub struct JointTrainerConfig {
339    /// Number of agents trained jointly. Must match the length of
340    /// [`JointMultiAgentTrainer`]'s policy / optimizer slots.
341    pub num_agents: usize,
342    /// Steps collected per rollout before each PPO update.
343    pub rollout_steps: usize,
344    /// Discount factor `γ ∈ [0, 1]`.
345    pub gamma: f64,
346    /// GAE smoothing parameter `λ ∈ [0, 1]`.
347    pub gae_lambda: f64,
348    /// PPO policy-ratio clip range `ε`.
349    pub clip_range: f64,
350    /// PPO value-function clip range. Use `f64::INFINITY` to fall back to
351    /// plain MSE (matches the Burn [`compute_value_loss`] contract).
352    pub clip_range_vf: f64,
353    /// Weight on the value-function loss term inside the joint loss.
354    pub vf_coef: f64,
355    /// Weight on the entropy bonus.
356    pub ent_coef: f64,
357    /// Number of PPO epochs per update.
358    pub n_epochs: usize,
359    /// Minibatch size for SGD within each PPO epoch.
360    pub minibatch_size: usize,
361    /// Global gradient-norm clip applied through each per-policy optimizer.
362    pub max_grad_norm: f64,
363    /// Standardize advantages to zero mean / unit variance per minibatch
364    /// before computing the surrogate.
365    pub normalize_advantages: bool,
366    /// Iterate over **all** minibatches per epoch instead of a single
367    /// truncated `minibatch_size` draw.
368    ///
369    /// The historical Burn-native cut took one shuffled minibatch per
370    /// epoch (issue #100 simplification), which discards ~97% of a large
371    /// rollout and starves the best-response (issue #239). When `true`,
372    /// each epoch shuffles the rollout once and walks every
373    /// `minibatch_size` chunk, the conventional PPO pattern.
374    ///
375    /// Defaults to `false` to keep existing NFSP/PSRO determinism tests
376    /// bit-identical; callers that want the full-rollout update (e.g. the
377    /// bucket-brigade examples) opt in explicitly.
378    pub iterate_all_minibatches: bool,
379    /// Optional **separate critic learning rate** (issue #239, ranked fix #4).
380    ///
381    /// The shared actor-critic trunk is trained with one optimizer and one
382    /// combined `policy + vf_coef·value − ent_coef·entropy` backward. On the
383    /// bucket-brigade cells the critic never fits (explained-variance pinned
384    /// at ~0 — see #239), so the normalized advantages are noise and the
385    /// policy gradient stays ~0 (entropy stuck at the uniform-max floor).
386    /// PR #240's grad-clip / all-minibatch / `vf_coef` knobs did not move
387    /// `ev` off 0, and dropping `BR_REWARD_SCALE` to 0.001 collapsed value
388    /// loss to ~8 yet `ev` *still* stayed flat — i.e. the critic is not
389    /// fitting even tiny well-scaled targets at the shared 3e-4 LR.
390    ///
391    /// When `Some(lr)`, the joint update splits the combined backward into
392    /// two passes per minibatch:
393    /// 1. **actor pass** — `policy − ent_coef·entropy` stepped through the
394    ///    usual per-agent optimizer at its construction LR;
395    /// 2. **critic pass** — `value_loss` alone stepped through a dedicated
396    ///    per-agent **critic optimizer** at `critic_lr`.
397    ///
398    /// Both passes update the full module (shared trunk + their respective
399    /// heads), but the critic gets its own Adam moment state and (typically
400    /// higher) LR, so it can fit the value target without the policy LR
401    /// dragging it. The critic optimizers are supplied via
402    /// [`JointMultiAgentTrainer::with_critic_optimizers`]; if `critic_lr` is
403    /// `Some` but no critic optimizers were supplied the trainer falls back
404    /// to the single combined backward (logged once at construction).
405    ///
406    /// Defaults to `None` (single combined backward — the historical
407    /// behaviour), so existing NFSP/PSRO runs and determinism tests are
408    /// bit-identical unless a caller opts in.
409    pub critic_lr: Option<f64>,
410    /// Optional **cap on the number of minibatch gradient steps per epoch**
411    /// (issue #251, throughput lever).
412    ///
413    /// The #239 best-response fix sets
414    /// [`iterate_all_minibatches`](Self::iterate_all_minibatches) `= true`,
415    /// which walks *every* `minibatch_size` chunk of the rollout
416    /// each epoch. Combined with `br_train_steps_per_iteration = 8` over the
417    /// un-batchable (issue #235) bucket-brigade rollout, the per-iteration BR
418    /// update became the dominant outer-loop cost (>1 h/iter at 2048 rollout
419    /// — see issue #251). At `rollout_steps = 2048`, `minibatch_size = 256`
420    /// that is 8 minibatches × `n_epochs` × `br_train_steps` gradient steps
421    /// per outer iteration.
422    ///
423    /// When `Some(cap)`, after each epoch's minibatch index-sets are built
424    /// (and globally shuffled), the set is truncated to at most `cap`
425    /// minibatches — a uniformly-random subsample of the rollout, since the
426    /// indices were shuffled before chunking. This trades a bounded amount of
427    /// BR fit per epoch for throughput **without** reverting the #239
428    /// learning behaviour: grad-clip, `vf_coef`, `iterate_all_minibatches`,
429    /// and `br_train_steps_per_iteration` are all unchanged, and each capped
430    /// minibatch is still a full forward+backward over `minibatch_size`
431    /// samples. `cap` is clamped to at least 1 so a capped update never
432    /// degenerates to zero gradient steps.
433    ///
434    /// `None` (default) preserves the full all-minibatch coverage exactly, so
435    /// existing NFSP/PSRO runs and determinism tests are bit-identical unless
436    /// a caller opts in. With the default single-minibatch path
437    /// (`iterate_all_minibatches == false`) the per-epoch set already has one
438    /// entry, so any `cap >= 1` is a no-op there.
439    pub max_minibatches_per_epoch: Option<usize>,
440    /// Weight on the optional **comms-regularization** term (issue #275,
441    /// Phase 2 of [`docs/COMMS_DESIGN.md`](../../../docs/COMMS_DESIGN.md)).
442    ///
443    /// When nonzero, each minibatch update folds a message-entropy penalty —
444    /// the Shannon entropy (nats) of every agent's sampled action-token
445    /// distribution over the minibatch, summed across agents and scaled by
446    /// `comms_coef` — into [`JointStats::aux_loss`] and the joint backward.
447    /// This reuses the existing aux-loss plumbing (the design's "reuse the
448    /// aux-loss slot, don't add a new one") rather than changing the
449    /// `aux_fn` signature that PSRO/NFSP depend on.
450    ///
451    /// The term is computed from the discrete sampled tokens, so under the
452    /// Phase 2 (non-differentiable) comms surface it contributes no gradient
453    /// and acts as a monitored regularizer; differentiable message gradients
454    /// are Phase 3 (issue #276, Gumbel-softmax).
455    ///
456    /// Defaults to `0.0` (**disabled** — no comms term added), so existing
457    /// NFSP/PSRO runs and determinism tests are bit-identical unless a caller
458    /// opts in.
459    pub comms_coef: f64,
460}
461
462impl Default for JointTrainerConfig {
463    fn default() -> Self {
464        Self {
465            num_agents: 4,
466            rollout_steps: 2048,
467            gamma: 0.99,
468            gae_lambda: 0.95,
469            clip_range: 0.2,
470            clip_range_vf: 0.2,
471            vf_coef: 0.5,
472            ent_coef: 0.01,
473            n_epochs: 4,
474            minibatch_size: 256,
475            max_grad_norm: 0.5,
476            normalize_advantages: true,
477            iterate_all_minibatches: false,
478            critic_lr: None,
479            max_minibatches_per_epoch: None,
480            comms_coef: 0.0,
481        }
482    }
483}
484
485/// Synchronized rollout buffer (host-side).
486///
487/// Per-agent observations are stored independently so environments with
488/// distinct per-agent views (e.g. partial observability, asymmetric
489/// information) work without any pre-concatenation. Each agent `i`
490/// records its own observation stream into
491/// `observations_per_agent[i]` (a flat `[T * obs_dim]` buffer).
492/// Per-agent actions / log-probs / values / rewards are stored as
493/// parallel host buffers and materialized into Burn tensors lazily
494/// inside [`JointMultiAgentTrainer::update`].
495#[derive(Debug, Clone)]
496pub struct JointRollout {
497    /// Per-agent observations: `observations_per_agent[i]` is a flat
498    /// `[T * obs_dims[i]]` buffer holding the observation stream for one
499    /// agent across the rollout.
500    pub observations_per_agent: Vec<Vec<f32>>,
501    /// Per-agent observation dimensionality (`obs_dims[i]` = length of agent
502    /// `i`'s observation vector). Heterogeneous across agents is supported —
503    /// e.g. a `SignalingGame` speaker observes a length-`V` one-hot while the
504    /// listener observes a single received-token slot (issue #275).
505    pub obs_dims: Vec<usize>,
506    /// Per-agent actions: `Vec<N>[T * num_action_dims]`. `num_action_dims`
507    /// is 1 for scalar discrete, `num_dims` for multi-discrete.
508    pub actions: Vec<Vec<i64>>,
509    /// Number of action dimensions (uniform across agents in this first cut).
510    pub num_action_dims: usize,
511    /// Per-agent rollout-time log-probs: `Vec<N>[T]`.
512    pub log_probs: Vec<Vec<f32>>,
513    /// Per-agent value estimates: `Vec<N>[T]`.
514    pub values: Vec<Vec<f32>>,
515    /// Per-agent rewards: `Vec<N>[T]`.
516    pub rewards: Vec<Vec<f32>>,
517    /// Episode-termination flag (shared across agents): `[T]`.
518    pub dones: Vec<f32>,
519}
520
521impl JointRollout {
522    /// Rollout length in steps.
523    pub fn num_steps(&self) -> usize {
524        self.dones.len()
525    }
526
527    /// Number of agents represented in this rollout.
528    pub fn num_agents(&self) -> usize {
529        self.actions.len()
530    }
531}
532
533/// Per-update training statistics for the joint trainer.
534///
535/// Mirrors [`crate::train::ppo::TrainingStats`] but with per-agent
536/// vectors for the agent-local quantities and a single shared scalar
537/// for the auxiliary cross-agent term.
538#[derive(Debug, Clone, Default)]
539pub struct JointStats {
540    /// Per-agent policy loss (averaged across PPO epochs).
541    pub policy_loss: Vec<f64>,
542    /// Per-agent value-function loss.
543    pub value_loss: Vec<f64>,
544    /// Per-agent entropy.
545    pub entropy: Vec<f64>,
546    /// Per-agent fraction of clipped updates.
547    pub clip_fraction: Vec<f64>,
548    /// Per-agent approximate KL divergence between old and new policy.
549    pub approx_kl: Vec<f64>,
550    /// Per-agent explained variance of the value function.
551    pub explained_var: Vec<f64>,
552    /// Auxiliary cross-agent loss (e.g. λ * redundancy_penalty). Scalar
553    /// shared by all agents because it's computed jointly on the same
554    /// minibatch features.
555    pub aux_loss: f64,
556    /// Total summed loss `Σ_i agent_loss_i + aux_loss` (averaged across
557    /// PPO epochs).
558    pub total_loss: f64,
559    /// Number of minibatch gradient steps actually taken across the whole
560    /// update (`Σ_epochs` minibatches walked). For the default
561    /// single-minibatch path this equals `n_epochs`; with
562    /// [`JointTrainerConfig::iterate_all_minibatches`] it equals
563    /// `n_epochs × ceil(num_steps / minibatch_size)`, and with
564    /// [`JointTrainerConfig::max_minibatches_per_epoch`] `= Some(cap)` it is
565    /// reduced to `n_epochs × min(cap, num_minibatches)`. Exposed so the
566    /// throughput lever (issue #251) is observable/testable.
567    pub num_mb_steps: usize,
568}
569
570impl JointStats {
571    /// Construct a fully-zeroed [`JointStats`] sized for `num_agents`
572    /// agents.
573    pub fn zeros(num_agents: usize) -> Self {
574        Self {
575            policy_loss: vec![0.0; num_agents],
576            value_loss: vec![0.0; num_agents],
577            entropy: vec![0.0; num_agents],
578            clip_fraction: vec![0.0; num_agents],
579            approx_kl: vec![0.0; num_agents],
580            explained_var: vec![0.0; num_agents],
581            aux_loss: 0.0,
582            total_loss: 0.0,
583            num_mb_steps: 0,
584        }
585    }
586}
587
588// -----------------------------------------------------------------------
589// Trainer
590// -----------------------------------------------------------------------
591
592/// Synchronized joint multi-agent PPO trainer (Burn backend).
593///
594/// Generic over:
595/// - `B: AutodiffBackend` — the Burn backend.
596/// - `P: JointPolicy<B>` — the per-agent policy module type.
597/// - `O: Optimizer<P, B>` — the Burn optimizer type (typically built from
598///   `AdamConfig::new().init()`).
599///
600/// The trainer owns `N` policies and `N` optimizers; gradient flow is
601/// parameter-isolated because each [`burn::optim::GradientsParams::from_grads`]
602/// slice extracts only one policy's parameters from the shared autograd
603/// gradients.
604pub struct JointMultiAgentTrainer<B, P, O>
605where
606    B: AutodiffBackend,
607    P: JointPolicy<B>,
608    O: Optimizer<P, B>,
609{
610    /// Owned policies. Stored in `Option<P>` slots because Burn's
611    /// `Optimizer::step` consumes the module by value; we `.take()` and
612    /// put back across each step.
613    policies: Vec<Option<P>>,
614    /// One optimizer per policy.
615    optimizers: Vec<BurnOptimizer<B, P, O>>,
616    /// Optional dedicated **critic** optimizer per policy (issue #239 fix
617    /// #4). Present only when [`JointTrainerConfig::critic_lr`] is `Some`
618    /// and the caller supplied them via
619    /// [`JointMultiAgentTrainer::with_critic_optimizers`]. When present, the
620    /// joint update splits actor / critic into two backward passes so the
621    /// critic can step at its own LR (see `critic_lr` docs). Empty otherwise.
622    critic_optimizers: Vec<BurnOptimizer<B, P, O>>,
623    /// Trainer configuration.
624    config: JointTrainerConfig,
625    /// Device the policies live on.
626    device: B::Device,
627}
628
629impl<B, P, O> JointMultiAgentTrainer<B, P, O>
630where
631    B: AutodiffBackend,
632    P: JointPolicy<B>,
633    O: Optimizer<P, B>,
634{
635    /// Construct a trainer from a fully-initialized set of policies and
636    /// optimizers.
637    ///
638    /// `optimizers[i]` is paired with `policies[i]` and only ever updates
639    /// `policies[i]`'s parameters.
640    pub fn new(
641        policies: Vec<P>,
642        optimizers: Vec<BurnOptimizer<B, P, O>>,
643        config: JointTrainerConfig,
644        device: B::Device,
645    ) -> Result<Self> {
646        if policies.is_empty() {
647            return Err(anyhow!("JointMultiAgentTrainer requires at least one policy"));
648        }
649        if policies.len() != config.num_agents {
650            return Err(anyhow!(
651                "JointMultiAgentTrainer: policies.len() ({}) != config.num_agents ({})",
652                policies.len(),
653                config.num_agents
654            ));
655        }
656        if optimizers.len() != policies.len() {
657            return Err(anyhow!(
658                "JointMultiAgentTrainer: optimizers.len() ({}) != policies.len() ({})",
659                optimizers.len(),
660                policies.len()
661            ));
662        }
663        // Apply the configured gradient-norm cap on every optimizer.
664        let mut optimizers = optimizers;
665        for opt in optimizers.iter_mut() {
666            opt.clip_grad_norm(config.max_grad_norm);
667        }
668        Ok(Self {
669            policies: policies.into_iter().map(Some).collect(),
670            optimizers,
671            critic_optimizers: Vec::new(),
672            config,
673            device,
674        })
675    }
676
677    /// Construct a trainer with a **dedicated per-agent critic optimizer**
678    /// (issue #239, ranked fix #4).
679    ///
680    /// `critic_optimizers[i]` is paired with `policies[i]` and steps only the
681    /// value-loss gradient (a second backward over `value_loss` alone) at its
682    /// own learning rate. This decouples the critic's effective LR from the
683    /// actor's so the critic can fit the bucket-brigade value target (whose
684    /// explained-variance was pinned at ~0 under the single shared optimizer —
685    /// see [`JointTrainerConfig::critic_lr`]).
686    ///
687    /// Requires [`JointTrainerConfig::critic_lr`] to be `Some`; the value is
688    /// the LR the supplied critic optimizers were constructed with (recorded
689    /// for diagnostics — the actual step uses each critic optimizer's own
690    /// construction LR). The same `max_grad_norm` cap is staged on the critic
691    /// optimizers too.
692    pub fn with_critic_optimizers(
693        policies: Vec<P>,
694        optimizers: Vec<BurnOptimizer<B, P, O>>,
695        critic_optimizers: Vec<BurnOptimizer<B, P, O>>,
696        config: JointTrainerConfig,
697        device: B::Device,
698    ) -> Result<Self> {
699        if critic_optimizers.len() != policies.len() {
700            return Err(anyhow!(
701                "JointMultiAgentTrainer: critic_optimizers.len() ({}) != policies.len() ({})",
702                critic_optimizers.len(),
703                policies.len()
704            ));
705        }
706        let mut trainer = Self::new(policies, optimizers, config, device)?;
707        let mut critic_optimizers = critic_optimizers;
708        for opt in critic_optimizers.iter_mut() {
709            opt.clip_grad_norm(trainer.config.max_grad_norm);
710        }
711        trainer.critic_optimizers = critic_optimizers;
712        Ok(trainer)
713    }
714
715    /// Device the trainer (and all its policies) live on.
716    pub fn device(&self) -> &B::Device {
717        &self.device
718    }
719
720    /// Trainer configuration.
721    pub fn config(&self) -> &JointTrainerConfig {
722        &self.config
723    }
724
725    /// Borrow agent `i`'s policy. Panics if the trainer is mid-`update`.
726    pub fn policy(&self, i: usize) -> &P {
727        self.policies[i].as_ref().expect("policy is None mid-update")
728    }
729
730    /// Drive a [`JointEnv`] for `config.rollout_steps` and return the
731    /// synchronized rollout buffer.
732    ///
733    /// `last_obs` is the persistent "next observation per agent" handed
734    /// in across iterations: pass the per-agent observations from the
735    /// most recent `env.reset_joint()` or step (i.e. the env's full
736    /// `Vec<Vec<f32>>` shape, length = `num_agents`). The trainer
737    /// updates it in place so callers can keep the rollout stream
738    /// stitched across iterations.
739    ///
740    /// `rng` is consumed by each per-step
741    /// [`JointPolicy::get_action_host_seeded`] call. Pass the
742    /// trainer-owned `StdRng` (e.g. `PsroTrainer::self.rng`) for
743    /// `PsroConfig::seed` / `NfspConfig::seed` to produce bit-identical
744    /// rollouts (issue #114).
745    pub fn collect_rollout<E: JointEnv>(
746        &self,
747        env: &mut E,
748        last_obs: &mut [Vec<f32>],
749        rng: &mut StdRng,
750    ) -> JointRollout {
751        let num_steps = self.config.rollout_steps;
752        let num_agents = self.config.num_agents;
753        assert_eq!(
754            last_obs.len(),
755            num_agents,
756            "collect_rollout: last_obs length ({}) must equal num_agents ({})",
757            last_obs.len(),
758            num_agents,
759        );
760        // Per-agent observation dimensionality. Agents may observe vectors of
761        // different lengths (e.g. a SignalingGame speaker sees a length-`V`
762        // one-hot while the listener sees a single received-token slot), so
763        // each agent's buffer is sized independently (issue #275).
764        let obs_dims: Vec<usize> = last_obs.iter().map(|o| o.len()).collect();
765        let device = self.device.clone();
766
767        // Probe per-dim action layout from agent 0's policy (shape-only — no
768        // tensor ops touched, so the result is RNG-neutral). For this first
769        // Burn-native cut we require every agent to share the same
770        // num_action_dims; per-agent heterogeneous layouts can come later.
771        let num_action_dims: usize = self.policies[0]
772            .as_ref()
773            .expect("policy 0 present at rollout time")
774            .action_dims_joint()
775            .len();
776
777        let mut obs_buf_per_agent: Vec<Vec<f32>> =
778            (0..num_agents).map(|i| vec![0.0_f32; num_steps * obs_dims[i]]).collect();
779        let mut act_buf: Vec<Vec<i64>> =
780            (0..num_agents).map(|_| vec![0_i64; num_steps * num_action_dims]).collect();
781        let mut lp_buf: Vec<Vec<f32>> = (0..num_agents).map(|_| vec![0.0_f32; num_steps]).collect();
782        let mut val_buf: Vec<Vec<f32>> =
783            (0..num_agents).map(|_| vec![0.0_f32; num_steps]).collect();
784        let mut rew_buf: Vec<Vec<f32>> =
785            (0..num_agents).map(|_| vec![0.0_f32; num_steps]).collect();
786        let mut done_buf = vec![0.0_f32; num_steps];
787
788        for t in 0..num_steps {
789            let mut joint_action: Vec<Vec<i64>> = Vec::with_capacity(num_agents);
790            for (i, slot) in self.policies.iter().enumerate() {
791                let policy = slot.as_ref().expect("policy present at rollout time");
792
793                // Per-agent observation: record into the agent-i buffer and
794                // build a single-row obs tensor for the agent-i policy. The
795                // stride is agent-specific because observation lengths may
796                // differ across agents (issue #275).
797                let obs_dim_i = obs_dims[i];
798                let start = t * obs_dim_i;
799                let agent_obs = &last_obs[i];
800                obs_buf_per_agent[i][start..start + obs_dim_i].copy_from_slice(agent_obs);
801                let obs_t = Tensor::<B, 2>::from_data(
802                    burn::tensor::TensorData::new(agent_obs.clone(), [1, obs_dim_i]),
803                    &device,
804                );
805
806                let (actions_host, log_probs_host, values_host) =
807                    policy.get_action_host_seeded(obs_t, rng);
808
809                // Extract per-agent action vector (length = num_action_dims).
810                let row: Vec<i64> = actions_host[..num_action_dims].to_vec();
811                let off = t * num_action_dims;
812                act_buf[i][off..off + num_action_dims].copy_from_slice(&row);
813                joint_action.push(row);
814
815                lp_buf[i][t] = log_probs_host.first().copied().unwrap_or(0.0);
816                val_buf[i][t] = values_host.first().copied().unwrap_or(0.0);
817            }
818
819            let result = env.step_joint(&joint_action);
820            for (i, rew) in rew_buf.iter_mut().enumerate().take(num_agents) {
821                rew[t] = result.rewards[i];
822            }
823            done_buf[t] = if result.done { 1.0 } else { 0.0 };
824
825            if result.done {
826                let fresh = env.reset_joint(None);
827                last_obs[..num_agents].clone_from_slice(&fresh[..num_agents]);
828            } else {
829                last_obs[..num_agents].clone_from_slice(&result.observations[..num_agents]);
830            }
831        }
832
833        JointRollout {
834            observations_per_agent: obs_buf_per_agent,
835            obs_dims,
836            actions: act_buf,
837            num_action_dims,
838            log_probs: lp_buf,
839            values: val_buf,
840            rewards: rew_buf,
841            dones: done_buf,
842        }
843    }
844
845    /// Joint PPO update.
846    ///
847    /// `aux_fn` receives a slice of per-agent encoder-feature tensors for
848    /// the current minibatch (one entry per policy, shape
849    /// `[mb, hidden_dim]`) and returns an optional pre-scaled scalar loss
850    /// (e.g. the cross-agent redundancy penalty). One `.backward()` flows
851    /// through every encoder when `aux_fn` returns `Some`.
852    ///
853    /// # Minibatch sampling
854    ///
855    /// One shuffled minibatch of size `config.minibatch_size` is drawn per
856    /// epoch, truncated against the rollout length. The order of indices
857    /// within the minibatch is irrelevant because every loss is a `mean` /
858    /// `sum` reduction over the minibatch dim and therefore
859    /// permutation-invariant.
860    pub fn update<F>(
861        &mut self,
862        rollout: &JointRollout,
863        rng: &mut StdRng,
864        aux_fn: F,
865    ) -> Result<JointStats>
866    where
867        F: FnMut(&[Tensor<B, 2>]) -> Option<Tensor<B, 1>>,
868    {
869        let num_agents = self.config.num_agents;
870        let active = vec![true; num_agents];
871        self.update_with_active_agents(rollout, &active, rng, aux_fn)
872    }
873
874    /// Joint PPO update with per-agent active mask — the freeze-N-1
875    /// primitive used by PSRO's best-response step.
876    ///
877    /// Identical to [`Self::update`] except that frozen agents
878    /// (`active[i] == false`) skip the optimizer step. Their loss is
879    /// still summed into the joint backward so the shared autograd
880    /// graph remains balanced, but their parameters are guaranteed
881    /// unchanged: we put the original policy back in its slot without
882    /// calling `optimizer.step`. Per-agent stats for frozen agents are
883    /// still recorded in the returned [`JointStats`] so callers can
884    /// monitor the mixture's behaviour on the rollout.
885    ///
886    /// # Use case
887    ///
888    /// PSRO's outer loop trains one *best-response* policy at a time
889    /// against a meta-Nash mixture over the rest of the population
890    /// (see [`crate::multi_agent::psro`]). Passing
891    /// `active = [false, ..., true (active idx), ..., false]` here is
892    /// the canonical freeze-N-1 pattern.
893    ///
894    /// # Panics
895    ///
896    /// Returns `Err` if `active.len() != config.num_agents`.
897    pub fn update_with_active_agents<F>(
898        &mut self,
899        rollout: &JointRollout,
900        active: &[bool],
901        rng: &mut StdRng,
902        mut aux_fn: F,
903    ) -> Result<JointStats>
904    where
905        F: FnMut(&[Tensor<B, 2>]) -> Option<Tensor<B, 1>>,
906    {
907        if active.len() != self.config.num_agents {
908            return Err(anyhow!(
909                "active mask length {} != config.num_agents {}",
910                active.len(),
911                self.config.num_agents
912            ));
913        }
914        let device = self.device.clone();
915        let num_agents = self.config.num_agents;
916        let num_steps = rollout.num_steps();
917        if num_steps == 0 {
918            return Err(anyhow!("rollout is empty"));
919        }
920        if rollout.num_agents() != num_agents {
921            return Err(anyhow!(
922                "rollout has {} agents but trainer is configured for {}",
923                rollout.num_agents(),
924                num_agents
925            ));
926        }
927
928        // Per-agent advantages and returns. Computed once outside the epoch
929        // loop (matches the tch-era reference behaviour).
930        let mut advantages_host: Vec<Vec<f32>> = Vec::with_capacity(num_agents);
931        let mut returns_host: Vec<Vec<f32>> = Vec::with_capacity(num_agents);
932        for i in 0..num_agents {
933            let (adv, ret) = compute_gae_single_agent(
934                &rollout.rewards[i],
935                &rollout.values[i],
936                &rollout.dones,
937                self.config.gamma as f32,
938                self.config.gae_lambda as f32,
939            );
940            let adv = if self.config.normalize_advantages {
941                normalize_advantages(&adv)
942            } else {
943                adv
944            };
945            advantages_host.push(adv);
946            returns_host.push(ret);
947        }
948
949        let mut stats = JointStats::zeros(num_agents);
950        let mb_size = self.config.minibatch_size.min(num_steps);
951        // Number of minibatch gradient steps actually taken across the
952        // whole update; used to average the accumulated per-step stats.
953        let mut num_mb_steps: usize = 0;
954
955        for _epoch in 0..self.config.n_epochs {
956            // Build this epoch's minibatch index-sets.
957            //
958            // * `iterate_all_minibatches == false` (default): one shuffled `minibatch_size`
959            //   draw per epoch — the historical #100 behaviour, kept bit-identical so
960            //   existing NFSP/PSRO determinism tests are unaffected.
961            // * `iterate_all_minibatches == true`: shuffle once and walk every
962            //   `minibatch_size` chunk so the BR consumes the full rollout instead of
963            //   discarding ~97% of it (issue #239).
964            //
965            // Both branches draw their shuffle randomness from the
966            // caller-supplied RNG so PSRO / NFSP runs stay reproducible
967            // under their configured seeds (see issues #109 / #114).
968            use rand::seq::SliceRandom;
969            let mut minibatches: Vec<Vec<usize>> = if self.config.iterate_all_minibatches {
970                generate_minibatch_indices_with_rng(num_steps, mb_size, rng)
971            } else {
972                let mut indices: Vec<usize> = (0..num_steps).collect();
973                indices.shuffle(rng);
974                indices.truncate(mb_size);
975                vec![indices]
976            };
977
978            // Issue #251 throughput lever: cap the number of minibatch
979            // gradient steps per epoch. The indices were globally shuffled
980            // before chunking, so keeping the first `cap` chunks is a
981            // uniformly-random subsample of the rollout. Clamp to >= 1 so a
982            // capped update never degenerates to zero steps. `None` (default)
983            // leaves the full all-minibatch coverage untouched.
984            if let Some(cap) = self.config.max_minibatches_per_epoch {
985                let cap = cap.max(1);
986                if cap < minibatches.len() {
987                    minibatches.truncate(cap);
988                }
989            }
990
991            for indices in minibatches {
992                num_mb_steps += 1;
993                // Per-agent obs minibatches: agent `i` reads from its own
994                // observation buffer at `rollout.observations_per_agent[i]`.
995                let obs_mb_per_agent: Vec<Tensor<B, 2>> = (0..num_agents)
996                    .map(|i| {
997                        select_obs(
998                            &rollout.observations_per_agent[i],
999                            rollout.obs_dims[i],
1000                            &indices,
1001                            &device,
1002                        )
1003                    })
1004                    .collect();
1005
1006                // Per-agent forward + per-agent loss accumulation.
1007                //
1008                // We collect per-agent loss tensors and feature tensors first,
1009                // sum them into a single `joint_loss`, then backward once. The
1010                // gradients of `joint_loss` w.r.t. each policy's parameters are
1011                // then extracted via `GradientsParams::from_grads` and applied
1012                // per-policy through that policy's optimizer.
1013                let mut per_agent_losses: Vec<Tensor<B, 1>> = Vec::with_capacity(num_agents);
1014                let mut features: Vec<Tensor<B, 2>> = Vec::with_capacity(num_agents);
1015                // Issue #239 fix #4: when a dedicated critic optimizer is
1016                // present, the value-loss term is held out of the actor's
1017                // joint loss and stepped separately at `critic_lr`. We keep
1018                // each per-agent value-loss tensor (graph still alive) for a
1019                // second backward below.
1020                let split_critic = !self.critic_optimizers.is_empty();
1021                let mut per_agent_value_losses: Vec<Tensor<B, 1>> = if split_critic {
1022                    Vec::with_capacity(num_agents)
1023                } else {
1024                    Vec::new()
1025                };
1026
1027                // Per-agent host scratch for stats.
1028                let mut policy_loss_hosts = vec![0.0_f64; num_agents];
1029                let mut value_loss_hosts = vec![0.0_f64; num_agents];
1030                let mut entropy_hosts = vec![0.0_f64; num_agents];
1031                let mut clip_frac_hosts = vec![0.0_f64; num_agents];
1032                let mut kl_hosts = vec![0.0_f64; num_agents];
1033                let mut ev_hosts = vec![0.0_f64; num_agents];
1034
1035                for i in 0..num_agents {
1036                    let policy = self.policies[i]
1037                        .as_ref()
1038                        .ok_or_else(|| anyhow!("policy {} is None mid-update", i))?;
1039
1040                    let obs_mb_i = obs_mb_per_agent[i].clone();
1041                    let actions_mb = select_actions(
1042                        &rollout.actions[i],
1043                        rollout.num_action_dims,
1044                        &indices,
1045                        &device,
1046                    );
1047                    let old_lp_mb = select_f32_row(&rollout.log_probs[i], &indices, &device);
1048                    let adv_mb = select_f32_row(&advantages_host[i], &indices, &device);
1049                    let ret_mb = select_f32_row(&returns_host[i], &indices, &device);
1050                    let old_v_mb = select_f32_row(&rollout.values[i], &indices, &device);
1051
1052                    let (new_lp, entropy, values_mb) =
1053                        policy.evaluate_actions_joint(obs_mb_i.clone(), actions_mb);
1054                    let feat = policy.encoder_features_joint(obs_mb_i);
1055
1056                    let (policy_loss, clip_frac, kl) =
1057                        compute_policy_loss(new_lp, old_lp_mb, adv_mb, self.config.clip_range);
1058                    let (value_loss, explained_var) =
1059                        compute_value_loss(values_mb, old_v_mb, ret_mb, self.config.clip_range_vf);
1060                    let entropy_mean = entropy.mean();
1061
1062                    // Host-side stats. We pull scalars from each per-agent loss
1063                    // tensor *before* moving them into the joint sum so we don't
1064                    // need to clone twice.
1065                    policy_loss_hosts[i] = scalar_f64(policy_loss.clone());
1066                    value_loss_hosts[i] = scalar_f64(value_loss.clone());
1067                    entropy_hosts[i] = scalar_f64(entropy_mean.clone());
1068                    clip_frac_hosts[i] = clip_frac;
1069                    kl_hosts[i] = kl;
1070                    ev_hosts[i] = explained_var;
1071
1072                    let entropy_term = entropy_mean.neg().mul_scalar(self.config.ent_coef as f32);
1073                    let agent_loss = if split_critic {
1074                        // Critic stepped separately at `critic_lr`; keep the
1075                        // value-loss tensor (its autodiff graph is still live)
1076                        // for the second backward, and exclude it from the
1077                        // actor's joint loss.
1078                        per_agent_value_losses.push(value_loss);
1079                        policy_loss + entropy_term
1080                    } else {
1081                        policy_loss
1082                            + value_loss.mul_scalar(self.config.vf_coef as f32)
1083                            + entropy_term
1084                    };
1085
1086                    per_agent_losses.push(agent_loss);
1087                    features.push(feat);
1088                }
1089
1090                // Aggregate per-agent losses, then add the cross-agent aux term.
1091                let mut joint_loss: Option<Tensor<B, 1>> = None;
1092                for l in per_agent_losses {
1093                    joint_loss = Some(match joint_loss.take() {
1094                        Some(acc) => acc + l,
1095                        None => l,
1096                    });
1097                }
1098                let aux_opt = aux_fn(&features);
1099                let aux_scalar: f64 =
1100                    aux_opt.as_ref().map(|t| scalar_f64(t.clone())).unwrap_or(0.0);
1101                stats.aux_loss += aux_scalar;
1102                if let Some(aux) = aux_opt {
1103                    joint_loss = Some(match joint_loss.take() {
1104                        Some(acc) => acc + aux,
1105                        None => aux,
1106                    });
1107                }
1108
1109                // Optional comms-regularization term (issue #275). Off by
1110                // default (`comms_coef == 0.0`), in which case this is exactly
1111                // 0.0 and neither `aux_loss` nor `joint_loss` is perturbed, so
1112                // existing PSRO/NFSP determinism is preserved. When enabled it
1113                // reuses the `aux_loss` slot — no `aux_fn` signature change.
1114                let comms_scalar = comms_penalty(
1115                    self.config.comms_coef,
1116                    &rollout.actions,
1117                    rollout.num_action_dims,
1118                    &indices,
1119                );
1120                if comms_scalar != 0.0 {
1121                    stats.aux_loss += comms_scalar;
1122                    // Fold into the joint backward as a constant term. Phase 2
1123                    // messages are discrete/non-differentiable, so this carries
1124                    // no gradient (Phase 3 / issue #276 makes it differentiable
1125                    // via a Gumbel-softmax head).
1126                    let comms_t = Tensor::<B, 1>::from_data(
1127                        burn::tensor::TensorData::new(vec![comms_scalar as f32], [1]),
1128                        &device,
1129                    );
1130                    joint_loss = Some(match joint_loss.take() {
1131                        Some(acc) => acc + comms_t,
1132                        None => comms_t,
1133                    });
1134                }
1135                let joint_loss = joint_loss.ok_or_else(|| anyhow!("no losses to backprop"))?;
1136                stats.total_loss += scalar_f64(joint_loss.clone());
1137
1138                // Single backward over the joint loss; the resulting `Gradients`
1139                // carry grads for every policy's parameters. Sliced per-policy
1140                // below.
1141                //
1142                // Burn's `Gradients` container is *consumed* per param when we
1143                // call `from_module(&mut grads, policy_i)` — each visit removes
1144                // policy `i`'s param tensors from the shared container. That's
1145                // exactly the per-agent isolation we want: each optimizer only
1146                // sees grads for its own policy's parameters, and a single
1147                // backward feeds all of them.
1148                let mut grads = joint_loss.backward();
1149
1150                for i in 0..num_agents {
1151                    let policy = self.policies[i]
1152                        .take()
1153                        .ok_or_else(|| anyhow!("policy {} is None mid-step", i))?;
1154                    // Drain gradient slice for policy `i` either way; this
1155                    // keeps the `Gradients` container consistent across all
1156                    // agents (Burn removes policy `i`'s params on
1157                    // `from_module`, so we always do the drain).
1158                    let policy_grads = GradientsParams::from_module(&mut grads, &policy);
1159                    let updated = if active[i] {
1160                        let lr = self.optimizers[i].learning_rate();
1161                        // Global gradient-norm clipping (issue #239). The cap
1162                        // configured via `JointTrainerConfig::max_grad_norm` is
1163                        // staged on every optimizer in `new`; apply it here to
1164                        // each policy's gradient slice before the move-through
1165                        // step so the (potentially large) value-loss gradient
1166                        // cannot swamp the shared actor-critic trunk. A `None`
1167                        // cap leaves the gradients untouched.
1168                        let policy_grads = match self.optimizers[i].grad_clip_norm() {
1169                            Some(max_norm) if max_norm > 0.0 => clip_grads_by_global_norm::<B, P>(
1170                                &policy,
1171                                policy_grads,
1172                                max_norm as f32,
1173                            ),
1174                            _ => policy_grads,
1175                        };
1176                        self.optimizers[i].inner_mut().step(lr, policy, policy_grads)
1177                    } else {
1178                        // Frozen agent: drop the gradients and put the policy
1179                        // back unchanged. This is the freeze-N-1 invariant
1180                        // PSRO's best-response step relies on.
1181                        drop(policy_grads);
1182                        policy
1183                    };
1184                    self.policies[i] = Some(updated);
1185
1186                    stats.policy_loss[i] += policy_loss_hosts[i];
1187                    stats.value_loss[i] += value_loss_hosts[i];
1188                    stats.entropy[i] += entropy_hosts[i];
1189                    stats.clip_fraction[i] += clip_frac_hosts[i];
1190                    stats.approx_kl[i] += kl_hosts[i];
1191                    stats.explained_var[i] += ev_hosts[i];
1192                }
1193
1194                // ---- Critic pass (issue #239 fix #4) -----------------------
1195                // When a dedicated critic optimizer is present, run a second
1196                // backward over the value loss alone and step it at the
1197                // critic LR. This gives the critic its own Adam moment state
1198                // and (typically higher) learning rate, decoupled from the
1199                // actor, so it can fit the bucket-brigade value target whose
1200                // explained-variance was otherwise pinned at ~0.
1201                if split_critic {
1202                    // Sum the per-agent value losses (weighted by vf_coef, so
1203                    // the scalar magnitude matches the historical combined
1204                    // term and the existing grad-clip cap stays calibrated).
1205                    let mut critic_joint: Option<Tensor<B, 1>> = None;
1206                    for vl in per_agent_value_losses {
1207                        let term = vl.mul_scalar(self.config.vf_coef as f32);
1208                        critic_joint = Some(match critic_joint.take() {
1209                            Some(acc) => acc + term,
1210                            None => term,
1211                        });
1212                    }
1213                    let critic_joint =
1214                        critic_joint.ok_or_else(|| anyhow!("no value losses to backprop"))?;
1215                    let mut critic_grads = critic_joint.backward();
1216
1217                    // Indexing `self.policies` / `self.critic_optimizers` by `i`
1218                    // is load-bearing (`.take()` mutates the `Option` slot), so
1219                    // the range loop cannot be rewritten as an iterator over a
1220                    // single slice; mirrors the actor step loop above.
1221                    #[allow(clippy::needless_range_loop)]
1222                    for i in 0..num_agents {
1223                        let policy = self.policies[i]
1224                            .take()
1225                            .ok_or_else(|| anyhow!("policy {} is None mid-critic-step", i))?;
1226                        let value_grads = GradientsParams::from_module(&mut critic_grads, &policy);
1227                        let updated = if active[i] {
1228                            let lr = self.critic_optimizers[i].learning_rate();
1229                            let value_grads = match self.critic_optimizers[i].grad_clip_norm() {
1230                                Some(max_norm) if max_norm > 0.0 => {
1231                                    clip_grads_by_global_norm::<B, P>(
1232                                        &policy,
1233                                        value_grads,
1234                                        max_norm as f32,
1235                                    )
1236                                }
1237                                _ => value_grads,
1238                            };
1239                            self.critic_optimizers[i].inner_mut().step(lr, policy, value_grads)
1240                        } else {
1241                            drop(value_grads);
1242                            policy
1243                        };
1244                        self.policies[i] = Some(updated);
1245                    }
1246                }
1247            }
1248        }
1249
1250        // Average across all minibatch gradient steps taken (one per
1251        // minibatch per epoch). With the default single-minibatch path this
1252        // is exactly `n_epochs`, preserving the historical averaging.
1253        let n = num_mb_steps as f64;
1254        if n > 0.0 {
1255            for i in 0..num_agents {
1256                stats.policy_loss[i] /= n;
1257                stats.value_loss[i] /= n;
1258                stats.entropy[i] /= n;
1259                stats.clip_fraction[i] /= n;
1260                stats.approx_kl[i] /= n;
1261                stats.explained_var[i] /= n;
1262            }
1263            stats.aux_loss /= n;
1264            stats.total_loss /= n;
1265        }
1266        stats.num_mb_steps = num_mb_steps;
1267
1268        Ok(stats)
1269    }
1270}
1271
1272// -----------------------------------------------------------------------
1273// Helpers
1274// -----------------------------------------------------------------------
1275
1276/// Per-agent single-trajectory GAE (host-side).
1277///
1278/// Mirrors the pre-Burn `compute_gae_single_agent` helper: takes 1-D
1279/// rewards / values / dones host buffers and returns
1280/// `(advantages, returns)`. The trailing-step value is taken as zero
1281/// (no post-rollout bootstrap), matching the tch reference.
1282fn compute_gae_single_agent(
1283    rewards: &[f32],
1284    values: &[f32],
1285    dones: &[f32],
1286    gamma: f32,
1287    gae_lambda: f32,
1288) -> (Vec<f32>, Vec<f32>) {
1289    let t = rewards.len();
1290    let mut advantages = vec![0.0_f32; t];
1291    let mut gae = 0.0_f32;
1292    for i in (0..t).rev() {
1293        let next_v = if i == t - 1 { 0.0 } else { values[i + 1] };
1294        let mask = 1.0 - dones[i];
1295        let delta = rewards[i] + gamma * next_v * mask - values[i];
1296        gae = delta + gamma * gae_lambda * mask * gae;
1297        advantages[i] = gae;
1298    }
1299    let returns: Vec<f32> = advantages.iter().zip(values).map(|(&a, &v)| a + v).collect();
1300    (advantages, returns)
1301}
1302
1303/// Standardize a vector of advantages to zero mean / unit variance.
1304fn normalize_advantages(adv: &[f32]) -> Vec<f32> {
1305    if adv.is_empty() {
1306        return Vec::new();
1307    }
1308    let n = adv.len() as f64;
1309    let mean: f64 = adv.iter().map(|&x| x as f64).sum::<f64>() / n;
1310    let var: f64 = adv.iter().map(|&x| (x as f64 - mean).powi(2)).sum::<f64>() / n;
1311    let std = var.sqrt().max(1e-8);
1312    adv.iter().map(|&x| ((x as f64 - mean) / std) as f32).collect()
1313}
1314
1315/// Comms-regularization penalty for one minibatch (issue #275).
1316///
1317/// Returns `comms_coef * Σ_i H_i`, where `H_i` is the Shannon entropy (in
1318/// nats) of agent `i`'s sampled action-token distribution over the minibatch
1319/// `indices`. This is the "message-entropy penalty" option from
1320/// [`docs/COMMS_DESIGN.md`](../../../docs/COMMS_DESIGN.md) §5, Phase 2: a
1321/// scalar computed from the discrete tokens already stored in the rollout's
1322/// action buffers, folded into the shared [`JointStats::aux_loss`] slot.
1323///
1324/// Returns `0.0` when `comms_coef == 0.0` (the default, disabled path), so the
1325/// caller adds nothing to `aux_loss` / `joint_loss` and existing determinism
1326/// is preserved.
1327fn comms_penalty(
1328    comms_coef: f64,
1329    actions: &[Vec<i64>],
1330    num_action_dims: usize,
1331    indices: &[usize],
1332) -> f64 {
1333    if comms_coef == 0.0 {
1334        return 0.0;
1335    }
1336    let total: f64 = actions.iter().map(|a| token_entropy(a, num_action_dims, indices)).sum();
1337    comms_coef * total
1338}
1339
1340/// Shannon entropy (nats) of the sampled action tokens for one agent over the
1341/// minibatch `indices`. Every action dim contributes its token value as a
1342/// symbol; a degenerate (single-token) distribution yields `0.0`.
1343fn token_entropy(actions_flat: &[i64], num_action_dims: usize, indices: &[usize]) -> f64 {
1344    use std::collections::HashMap;
1345    let mut counts: HashMap<i64, usize> = HashMap::new();
1346    let mut n: usize = 0;
1347    for &t in indices {
1348        let off = t * num_action_dims;
1349        for tok in &actions_flat[off..off + num_action_dims] {
1350            *counts.entry(*tok).or_insert(0) += 1;
1351            n += 1;
1352        }
1353    }
1354    if n == 0 {
1355        return 0.0;
1356    }
1357    let n_f = n as f64;
1358    counts
1359        .values()
1360        .map(|&c| {
1361            let p = c as f64 / n_f;
1362            -p * p.ln()
1363        })
1364        .sum()
1365}
1366
1367/// Build a `[mb, obs_dim]` tensor from the host observation buffer.
1368fn select_obs<B: AutodiffBackend>(
1369    obs_flat: &[f32],
1370    obs_dim: usize,
1371    indices: &[usize],
1372    device: &B::Device,
1373) -> Tensor<B, 2> {
1374    let mut out = Vec::with_capacity(indices.len() * obs_dim);
1375    for &i in indices {
1376        let start = i * obs_dim;
1377        out.extend_from_slice(&obs_flat[start..start + obs_dim]);
1378    }
1379    Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(out, [indices.len(), obs_dim]), device)
1380}
1381
1382/// Build a `[mb, num_action_dims]` int tensor from the host action buffer.
1383fn select_actions<B: AutodiffBackend>(
1384    actions_flat: &[i64],
1385    num_action_dims: usize,
1386    indices: &[usize],
1387    device: &B::Device,
1388) -> Tensor<B, 2, Int> {
1389    let mut out = Vec::with_capacity(indices.len() * num_action_dims);
1390    for &i in indices {
1391        let start = i * num_action_dims;
1392        out.extend_from_slice(&actions_flat[start..start + num_action_dims]);
1393    }
1394    Tensor::<B, 2, Int>::from_data(
1395        burn::tensor::TensorData::new(out, [indices.len(), num_action_dims]),
1396        device,
1397    )
1398}
1399
1400/// Build a `[mb]` float tensor by gathering host rows.
1401fn select_f32_row<B: AutodiffBackend>(
1402    src: &[f32],
1403    indices: &[usize],
1404    device: &B::Device,
1405) -> Tensor<B, 1> {
1406    let out: Vec<f32> = indices.iter().map(|&i| src[i]).collect();
1407    Tensor::<B, 1>::from_data(burn::tensor::TensorData::new(out, [indices.len()]), device)
1408}
1409
1410// -----------------------------------------------------------------------
1411// Tests
1412// -----------------------------------------------------------------------
1413
1414#[cfg(test)]
1415mod tests {
1416    use burn::{
1417        backend::{Autodiff, NdArray, ndarray::NdArrayDevice},
1418        optim::AdamConfig,
1419    };
1420    use rand::SeedableRng;
1421
1422    use super::*;
1423    use crate::{
1424        policy::{mlp::MlpBurnPolicy, multi_discrete_mlp::MultiDiscreteMlpBurnPolicy},
1425        train::optimizer::BurnOptimizer,
1426    };
1427
1428    type B = Autodiff<NdArray<f32>>;
1429
1430    /// Hand-computed GAE/returns reference on a tiny rollout (issue #243).
1431    ///
1432    /// This pins the [`compute_gae_single_agent`] recursion against values
1433    /// computed by hand, independent of any env. The rollout deliberately
1434    /// contains **both** boundary kinds the joint trainer can encounter:
1435    ///
1436    /// * a **terminal** boundary at interior step `t == 1` (`done == 1.0`):
1437    ///   both the value bootstrap `γ·V(s_{t+1})` *and* the GAE carry
1438    ///   `γ·λ·A_{t+1}` must be masked to zero, so the advantage at the terminal
1439    ///   step is exactly its own one-step TD error and the GAE chain does not
1440    ///   leak across the episode boundary.
1441    /// * a **truncation** boundary at the final step `t == T-1`: the rollout
1442    ///   ends here with `done == 0.0`, and this implementation uses a zero
1443    ///   trailing bootstrap (`next_v == 0.0`, no post-rollout `V(s_T)`), as
1444    ///   documented on `compute_gae_single_agent`.
1445    ///
1446    /// Reference values (γ = 0.9, λ = 0.8), computed step-by-step in reverse:
1447    ///
1448    /// ```text
1449    /// rewards = [1.0, 2.0, 3.0, 4.0]
1450    /// values  = [0.5, 1.0, 1.5, 2.0]
1451    /// dones   = [0.0, 1.0, 0.0, 0.0]
1452    ///
1453    /// t=3 (truncation/rollout-end): next_v=0,  mask=1
1454    ///     delta = 4 + 0.9*0*1 - 2.0           = 2.00
1455    ///     A_3   = 2.00 + 0.72*1*0             = 2.00
1456    /// t=2:                          next_v=2.0, mask=1
1457    ///     delta = 3 + 0.9*2.0*1 - 1.5         = 3.30
1458    ///     A_2   = 3.30 + 0.72*1*2.00          = 4.74
1459    /// t=1 (terminal, done=1):       next_v=1.5, mask=0
1460    ///     delta = 2 + 0.9*1.5*0 - 1.0         = 1.00   (bootstrap masked)
1461    ///     A_1   = 1.00 + 0.72*0*4.74          = 1.00   (GAE carry masked)
1462    /// t=0:                          next_v=1.0, mask=1
1463    ///     delta = 1 + 0.9*1.0*1 - 0.5         = 1.40
1464    ///     A_0   = 1.40 + 0.72*1*1.00          = 2.12
1465    ///
1466    /// advantages = [2.12, 1.00, 4.74, 2.00]
1467    /// returns    = advantages + values = [2.62, 2.00, 6.24, 4.00]
1468    /// ```
1469    #[test]
1470    fn gae_matches_hand_computed_reference_with_terminal_and_truncation() {
1471        let rewards = [1.0_f32, 2.0, 3.0, 4.0];
1472        let values = [0.5_f32, 1.0, 1.5, 2.0];
1473        let dones = [0.0_f32, 1.0, 0.0, 0.0];
1474        let gamma = 0.9_f32;
1475        let lambda = 0.8_f32;
1476
1477        let (adv, ret) = compute_gae_single_agent(&rewards, &values, &dones, gamma, lambda);
1478
1479        let expected_adv = [2.12_f32, 1.00, 4.74, 2.00];
1480        let expected_ret = [2.62_f32, 2.00, 6.24, 4.00];
1481
1482        assert_eq!(adv.len(), 4);
1483        assert_eq!(ret.len(), 4);
1484        for (i, (&a, &e)) in adv.iter().zip(&expected_adv).enumerate() {
1485            assert!(
1486                (a - e).abs() < 1e-5,
1487                "advantage[{i}] = {a}, expected {e} (terminal at t=1, truncation at t=3)"
1488            );
1489        }
1490        for (i, (&r, &e)) in ret.iter().zip(&expected_ret).enumerate() {
1491            assert!((r - e).abs() < 1e-5, "return[{i}] = {r}, expected {e}");
1492        }
1493
1494        // Explicit invariant check on the terminal boundary: A_1 must equal
1495        // exactly its own TD error (r_1 - V(s_1) = 1.0) with no leakage of
1496        // the γλ carry from A_2 across the episode boundary.
1497        assert!(
1498            (adv[1] - (rewards[1] - values[1])).abs() < 1e-6,
1499            "terminal-step advantage must equal its own TD error, no GAE carry-through"
1500        );
1501    }
1502
1503    /// A pure-terminal rollout (every step `done == 1`) must produce
1504    /// advantages equal to each step's own one-step TD error with the
1505    /// trailing bootstrap zeroed — there is no cross-step propagation at
1506    /// all. This isolates the `(1 - done)` masking from the recursion.
1507    #[test]
1508    fn gae_all_terminal_collapses_to_td_errors() {
1509        let rewards = [1.0_f32, -2.0, 0.5];
1510        let values = [0.25_f32, 0.5, -0.5];
1511        let dones = [1.0_f32, 1.0, 1.0];
1512        let gamma = 0.99_f32;
1513        let lambda = 0.95_f32;
1514
1515        let (adv, ret) = compute_gae_single_agent(&rewards, &values, &dones, gamma, lambda);
1516
1517        for i in 0..rewards.len() {
1518            let td = rewards[i] - values[i]; // bootstrap and carry both masked
1519            assert!((adv[i] - td).abs() < 1e-6, "adv[{i}] should be pure TD error");
1520            assert!((ret[i] - (td + values[i])).abs() < 1e-6, "ret[{i}] = adv + value");
1521        }
1522    }
1523
1524    /// `normalize_advantages` must standardize to zero mean / unit variance
1525    /// (population std) and preserve relative ordering / sign structure.
1526    #[test]
1527    fn normalize_advantages_zero_mean_unit_std() {
1528        let adv = [2.12_f32, 1.00, 4.74, 2.00];
1529        let out = normalize_advantages(&adv);
1530
1531        let n = out.len() as f64;
1532        let mean: f64 = out.iter().map(|&x| x as f64).sum::<f64>() / n;
1533        let var: f64 = out.iter().map(|&x| (x as f64 - mean).powi(2)).sum::<f64>() / n;
1534        assert!(mean.abs() < 1e-5, "normalized mean should be ~0, got {mean}");
1535        assert!(
1536            (var.sqrt() - 1.0).abs() < 1e-4,
1537            "normalized std should be ~1, got {}",
1538            var.sqrt()
1539        );
1540
1541        // Ordering preserved: the largest raw advantage stays the largest.
1542        let argmax_raw = 2usize; // 4.74
1543        let argmax_norm =
1544            out.iter().enumerate().max_by(|a, b| a.1.partial_cmp(b.1).unwrap()).unwrap().0;
1545        assert_eq!(argmax_raw, argmax_norm, "normalization must preserve ordering");
1546    }
1547
1548    /// Deterministic mock env: 4-dim obs (sin/cos-ish encoding of t),
1549    /// scalar rewards = sum of actions, never terminates within rollout.
1550    struct MockEnv {
1551        num_agents: usize,
1552        obs_dim: usize,
1553        t: usize,
1554    }
1555
1556    impl MockEnv {
1557        fn new(num_agents: usize, obs_dim: usize) -> Self {
1558            Self { num_agents, obs_dim, t: 0 }
1559        }
1560
1561        fn obs_for(&self) -> Vec<f32> {
1562            (0..self.obs_dim)
1563                .map(|i| (((self.t * 7 + i * 13) % 100) as f32) / 100.0 - 0.5)
1564                .collect()
1565        }
1566    }
1567
1568    impl JointEnv for MockEnv {
1569        fn reset_joint(&mut self, _seed: Option<u64>) -> Vec<Vec<f32>> {
1570            self.t = 0;
1571            let obs = self.obs_for();
1572            (0..self.num_agents).map(|_| obs.clone()).collect()
1573        }
1574
1575        fn step_joint(&mut self, actions: &[Vec<i64>]) -> JointStepResult {
1576            self.t += 1;
1577            let rewards: Vec<f32> = actions
1578                .iter()
1579                .map(|a| a.iter().map(|&x| x as f32).sum::<f32>() / 10.0)
1580                .collect();
1581            let obs = self.obs_for();
1582            let observations = (0..self.num_agents).map(|_| obs.clone()).collect();
1583            JointStepResult { rewards, done: false, observations }
1584        }
1585    }
1586
1587    fn make_mlp_policies(
1588        num_agents: usize,
1589        obs_dim: usize,
1590        action_dim: usize,
1591        hidden_dim: usize,
1592        device: &NdArrayDevice,
1593    ) -> Vec<MlpBurnPolicy<B>> {
1594        (0..num_agents)
1595            .map(|_| MlpBurnPolicy::<B>::new(obs_dim, action_dim, hidden_dim, device))
1596            .collect()
1597    }
1598
1599    fn make_multi_discrete_policies(
1600        num_agents: usize,
1601        obs_dim: usize,
1602        action_dims: Vec<usize>,
1603        hidden_dim: usize,
1604        device: &NdArrayDevice,
1605    ) -> Vec<MultiDiscreteMlpBurnPolicy<B>> {
1606        (0..num_agents)
1607            .map(|_| {
1608                MultiDiscreteMlpBurnPolicy::<B>::new(
1609                    obs_dim,
1610                    action_dims.clone(),
1611                    hidden_dim,
1612                    device,
1613                )
1614            })
1615            .collect()
1616    }
1617
1618    fn build_optimizers<P>(n: usize, lr: f64) -> Vec<BurnOptimizer<B, P, impl Optimizer<P, B>>>
1619    where
1620        P: AutodiffModule<B>,
1621    {
1622        (0..n)
1623            .map(|_| {
1624                let inner = AdamConfig::new().init();
1625                BurnOptimizer::<B, P, _>::new(inner, lr)
1626            })
1627            .collect()
1628    }
1629
1630    #[test]
1631    fn test_joint_trainer_smoke() {
1632        // 2 tiny MlpBurnPolicy instances, scalar discrete actions, 64-step
1633        // rollout, one update with aux_fn returning None. Assert no panics
1634        // + finite stats. This is the load-bearing acceptance test for the
1635        // Burn-native multi_agent port (issue #100).
1636        let device = Default::default();
1637        let num_agents = 2;
1638        let obs_dim: usize = 4;
1639        let action_dim: usize = 3;
1640        let policies = make_mlp_policies(num_agents, obs_dim, action_dim, 16, &device);
1641        let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
1642
1643        let config = JointTrainerConfig {
1644            num_agents,
1645            rollout_steps: 64,
1646            n_epochs: 2,
1647            minibatch_size: 32,
1648            ..Default::default()
1649        };
1650        let mut trainer =
1651            JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
1652
1653        let mut env = MockEnv::new(num_agents, obs_dim);
1654        let mut last_obs = env.reset_joint(None);
1655
1656        let mut rng = StdRng::seed_from_u64(0);
1657        let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
1658        let stats = trainer
1659            .update(&rollout, &mut rng, |_features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> {
1660                None
1661            })
1662            .expect("update should not error");
1663
1664        assert!(stats.total_loss.is_finite(), "total_loss must be finite");
1665        assert_eq!(stats.aux_loss, 0.0, "aux_loss must be 0 when aux_fn returns None");
1666        for i in 0..num_agents {
1667            assert!(stats.policy_loss[i].is_finite(), "policy_loss[{i}] finite");
1668            assert!(stats.value_loss[i].is_finite(), "value_loss[{i}] finite");
1669            assert!(stats.entropy[i].is_finite(), "entropy[{i}] finite");
1670            assert!(stats.clip_fraction[i].is_finite(), "clip_fraction[{i}] finite");
1671            assert!(stats.approx_kl[i].is_finite(), "approx_kl[{i}] finite");
1672            assert!(stats.explained_var[i].is_finite(), "explained_var[{i}] finite");
1673        }
1674    }
1675
1676    #[test]
1677    fn test_max_minibatches_per_epoch_caps_grad_steps() {
1678        // Issue #251 throughput lever: assert `max_minibatches_per_epoch`
1679        // reduces the number of minibatch gradient steps as configured, that
1680        // its `None` default preserves the full all-minibatch behaviour, and
1681        // that aggressive / edge settings do not panic.
1682        //
1683        // rollout_steps=64, minibatch_size=16 => 4 minibatches per epoch in
1684        // the all-minibatch branch; n_epochs=2 => 8 full steps uncapped.
1685        let num_agents = 2;
1686        let obs_dim: usize = 4;
1687        let action_dim: usize = 3;
1688        let n_epochs = 2;
1689        let rollout_steps = 64;
1690        let minibatch_size = 16;
1691
1692        // Returns the `num_mb_steps` taken by one update under `config`.
1693        let run = |iterate_all: bool, cap: Option<usize>| -> usize {
1694            let device = Default::default();
1695            let policies = make_mlp_policies(num_agents, obs_dim, action_dim, 16, &device);
1696            let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
1697            let config = JointTrainerConfig {
1698                num_agents,
1699                rollout_steps,
1700                n_epochs,
1701                minibatch_size,
1702                iterate_all_minibatches: iterate_all,
1703                max_minibatches_per_epoch: cap,
1704                ..Default::default()
1705            };
1706            let mut trainer =
1707                JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
1708            let mut env = MockEnv::new(num_agents, obs_dim);
1709            let mut last_obs = env.reset_joint(None);
1710            let mut rng = StdRng::seed_from_u64(0);
1711            let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
1712            trainer
1713                .update(&rollout, &mut rng, |_f: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> { None })
1714                .expect("update should not error")
1715                .num_mb_steps
1716        };
1717
1718        // All-minibatch, uncapped: every 16-sample chunk walked each epoch.
1719        assert_eq!(run(true, None), n_epochs * (rollout_steps / minibatch_size));
1720        // All-minibatch, cap=2: exactly 2 minibatch steps per epoch.
1721        assert_eq!(run(true, Some(2)), n_epochs * 2);
1722        // All-minibatch, cap=1: exactly 1 minibatch step per epoch.
1723        assert_eq!(run(true, Some(1)), n_epochs);
1724        // Cap >= available minibatches is a no-op (still full coverage).
1725        assert_eq!(run(true, Some(99)), n_epochs * (rollout_steps / minibatch_size));
1726        // cap=0 is clamped to 1 (never zero gradient steps; must not panic).
1727        assert_eq!(run(true, Some(0)), n_epochs);
1728
1729        // Default single-minibatch path: one minibatch per epoch regardless,
1730        // so a cap is a no-op and `num_mb_steps == n_epochs`.
1731        assert_eq!(run(false, None), n_epochs);
1732        assert_eq!(run(false, Some(2)), n_epochs);
1733    }
1734
1735    #[test]
1736    fn test_joint_rollout_shapes() {
1737        let device = Default::default();
1738        let num_agents = 3;
1739        let obs_dim: usize = 5;
1740        let t: usize = 32;
1741        let policies = make_mlp_policies(num_agents, obs_dim, 4, 16, &device);
1742        let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
1743
1744        let config = JointTrainerConfig {
1745            num_agents,
1746            rollout_steps: t,
1747            n_epochs: 1,
1748            minibatch_size: t,
1749            ..Default::default()
1750        };
1751        let trainer = JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
1752
1753        let mut env = MockEnv::new(num_agents, obs_dim);
1754        let mut last_obs = env.reset_joint(None);
1755        let mut rng = StdRng::seed_from_u64(0);
1756        let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
1757
1758        assert_eq!(rollout.num_steps(), t);
1759        assert_eq!(rollout.num_agents(), num_agents);
1760        assert_eq!(rollout.obs_dims, vec![obs_dim; num_agents]);
1761        assert_eq!(rollout.num_action_dims, 1);
1762        assert_eq!(rollout.observations_per_agent.len(), num_agents);
1763        for buf in &rollout.observations_per_agent {
1764            assert_eq!(buf.len(), t * obs_dim);
1765        }
1766        for a in &rollout.actions {
1767            assert_eq!(a.len(), t);
1768        }
1769        for r in &rollout.rewards {
1770            assert_eq!(r.len(), t);
1771        }
1772        for lp in &rollout.log_probs {
1773            assert_eq!(lp.len(), t);
1774        }
1775        for v in &rollout.values {
1776            assert_eq!(v.len(), t);
1777        }
1778        assert_eq!(rollout.dones.len(), t);
1779    }
1780
1781    #[test]
1782    fn test_aux_fn_couples_all_agents_into_stats() {
1783        // With aux_fn = || (features[0] - features[1]).square().sum() the
1784        // aux_loss stat must be strictly positive after one update because
1785        // the two policies' initial encoders are independently initialized
1786        // and so produce different features.
1787        let device = Default::default();
1788        let num_agents = 2;
1789        let obs_dim: usize = 4;
1790        let policies = make_mlp_policies(num_agents, obs_dim, 3, 16, &device);
1791        let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 1e-3);
1792
1793        let config = JointTrainerConfig {
1794            num_agents,
1795            rollout_steps: 32,
1796            n_epochs: 1,
1797            minibatch_size: 32,
1798            normalize_advantages: false,
1799            ..Default::default()
1800        };
1801        let mut trainer =
1802            JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
1803
1804        let mut env = MockEnv::new(num_agents, obs_dim);
1805        let mut last_obs = env.reset_joint(None);
1806        let mut rng = StdRng::seed_from_u64(0);
1807        let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
1808
1809        let stats = trainer
1810            .update(&rollout, &mut rng, |features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> {
1811                Some((features[0].clone() - features[1].clone()).powf_scalar(2.0_f32).sum())
1812            })
1813            .expect("update should not error");
1814
1815        assert!(
1816            stats.aux_loss > 0.0,
1817            "aux_loss must be > 0 with non-zero feature diff, got {}",
1818            stats.aux_loss
1819        );
1820        assert!(stats.total_loss.is_finite());
1821    }
1822
1823    #[test]
1824    fn test_joint_trainer_multi_discrete() {
1825        // Multi-discrete repeat of the smoke test: factored [3, 2] action
1826        // space, exercises the `MultiDiscreteMlpBurnPolicy` path through
1827        // `evaluate_actions_joint` and `select_actions` (action layout
1828        // `[T, num_dims]`).
1829        let device = Default::default();
1830        let num_agents = 2;
1831        let obs_dim: usize = 4;
1832        let action_dims = vec![3_usize, 2];
1833        let policies =
1834            make_multi_discrete_policies(num_agents, obs_dim, action_dims.clone(), 16, &device);
1835        let optimizers = build_optimizers::<MultiDiscreteMlpBurnPolicy<B>>(num_agents, 3e-4);
1836
1837        let config = JointTrainerConfig {
1838            num_agents,
1839            rollout_steps: 32,
1840            n_epochs: 1,
1841            minibatch_size: 32,
1842            ..Default::default()
1843        };
1844        let mut trainer =
1845            JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
1846
1847        let mut env = MockEnv::new(num_agents, obs_dim);
1848        let mut last_obs = env.reset_joint(None);
1849        let mut rng = StdRng::seed_from_u64(0);
1850        let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
1851
1852        assert_eq!(rollout.num_action_dims, action_dims.len());
1853        for a in &rollout.actions {
1854            assert_eq!(a.len(), 32 * action_dims.len());
1855        }
1856
1857        let stats = trainer
1858            .update(&rollout, &mut rng, |_features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> {
1859                None
1860            })
1861            .expect("update should not error");
1862        assert!(stats.total_loss.is_finite());
1863    }
1864
1865    /// Env that returns *distinct* per-agent observations every step:
1866    /// agent `i` always sees a one-hot vector with the `i`-th slot set.
1867    /// Used as the load-bearing regression assertion that the trainer
1868    /// reads agent `i`'s observation for agent `i` (and not agent 0's
1869    /// view for everyone) after the per-agent obs refactor.
1870    struct PerAgentObsMockEnv {
1871        num_agents: usize,
1872        obs_dim: usize,
1873    }
1874
1875    impl PerAgentObsMockEnv {
1876        fn new(num_agents: usize, obs_dim: usize) -> Self {
1877            assert!(obs_dim >= num_agents, "obs_dim must be >= num_agents for one-hot encoding");
1878            Self { num_agents, obs_dim }
1879        }
1880
1881        fn per_agent_obs(&self) -> Vec<Vec<f32>> {
1882            (0..self.num_agents)
1883                .map(|i| {
1884                    let mut v = vec![0.0_f32; self.obs_dim];
1885                    v[i] = 1.0;
1886                    v
1887                })
1888                .collect()
1889        }
1890    }
1891
1892    impl JointEnv for PerAgentObsMockEnv {
1893        fn reset_joint(&mut self, _seed: Option<u64>) -> Vec<Vec<f32>> {
1894            self.per_agent_obs()
1895        }
1896
1897        fn step_joint(&mut self, _actions: &[Vec<i64>]) -> JointStepResult {
1898            JointStepResult {
1899                rewards: vec![0.0_f32; self.num_agents],
1900                done: false,
1901                observations: self.per_agent_obs(),
1902            }
1903        }
1904    }
1905
1906    #[test]
1907    fn test_collect_rollout_reads_per_agent_observations() {
1908        // Regression guard for the per-agent observation refactor (PR
1909        // #118). The trainer must read agent `i`'s observation for
1910        // agent `i` — *not* agent 0's view for everyone. We construct
1911        // an env whose `step_joint` returns distinct one-hot
1912        // observations per agent and assert that
1913        // `rollout.observations_per_agent[i]` at every timestep
1914        // contains agent `i`'s one-hot, not agent 0's.
1915        let device = Default::default();
1916        let num_agents = 3;
1917        let obs_dim: usize = 4; // >= num_agents for one-hot encoding
1918        let t: usize = 16;
1919        let policies = make_mlp_policies(num_agents, obs_dim, 2, 8, &device);
1920        let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
1921
1922        let config = JointTrainerConfig {
1923            num_agents,
1924            rollout_steps: t,
1925            n_epochs: 1,
1926            minibatch_size: t,
1927            ..Default::default()
1928        };
1929        let trainer = JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
1930
1931        let mut env = PerAgentObsMockEnv::new(num_agents, obs_dim);
1932        let mut last_obs = env.reset_joint(None);
1933        let mut rng = StdRng::seed_from_u64(0);
1934        let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
1935
1936        assert_eq!(rollout.observations_per_agent.len(), num_agents);
1937        for (i, buf) in rollout.observations_per_agent.iter().enumerate() {
1938            assert_eq!(buf.len(), t * obs_dim, "obs buffer for agent {i} has wrong length");
1939            // Expected per-step view for agent i: one-hot with slot `i` = 1.0.
1940            let mut expected = vec![0.0_f32; obs_dim];
1941            expected[i] = 1.0;
1942            for step in 0..t {
1943                let start = step * obs_dim;
1944                let slice = &buf[start..start + obs_dim];
1945                assert_eq!(
1946                    slice,
1947                    expected.as_slice(),
1948                    "agent {i} step {step}: observation slice {:?} does not match agent {i}'s view {:?}",
1949                    slice,
1950                    expected,
1951                );
1952            }
1953        }
1954    }
1955
1956    // NOTE: the `clip_grads_by_global_norm` unit test moved to
1957    // `crate::train::grad_clip::tests` when the helper was extracted for
1958    // reuse by the single-agent PPO trainers (issue #299).
1959
1960    /// Issue #239: with `iterate_all_minibatches = true` the update walks
1961    /// every minibatch per epoch (more gradient steps than the single-draw
1962    /// default) and still produces finite stats.
1963    #[test]
1964    fn test_iterate_all_minibatches_runs() {
1965        let device = Default::default();
1966        let num_agents = 2;
1967        let obs_dim: usize = 4;
1968        let action_dim: usize = 3;
1969        let policies = make_mlp_policies(num_agents, obs_dim, action_dim, 16, &device);
1970        let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
1971
1972        // 128-step rollout, minibatch 32 => 4 minibatches per epoch.
1973        let config = JointTrainerConfig {
1974            num_agents,
1975            rollout_steps: 128,
1976            n_epochs: 2,
1977            minibatch_size: 32,
1978            iterate_all_minibatches: true,
1979            ..Default::default()
1980        };
1981        let mut trainer =
1982            JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
1983
1984        let mut env = MockEnv::new(num_agents, obs_dim);
1985        let mut last_obs = env.reset_joint(None);
1986        let mut rng = StdRng::seed_from_u64(0);
1987        let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
1988        let stats = trainer
1989            .update(&rollout, &mut rng, |_features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> {
1990                None
1991            })
1992            .expect("update should not error");
1993
1994        for i in 0..num_agents {
1995            assert!(stats.policy_loss[i].is_finite(), "policy_loss[{i}] finite");
1996            assert!(stats.value_loss[i].is_finite(), "value_loss[{i}] finite");
1997            assert!(stats.entropy[i].is_finite(), "entropy[{i}] finite");
1998        }
1999        assert!(stats.total_loss.is_finite());
2000    }
2001
2002    /// Issue #275, Phase 2 smoke test: the joint trainer drives a
2003    /// `SignalingGame` (via its `JointEnv` adapter) through one
2004    /// `collect_rollout` + `update` cycle with the comms term enabled.
2005    ///
2006    /// Exercises all three Phase 2 pieces at once:
2007    /// * **Challenge A** — `SignalingGame: JointEnv` (the trainer accepts it).
2008    /// * **Challenge B** — heterogeneous per-agent obs dims: the speaker
2009    ///   observes a length-`V` one-hot, the listener a single received-token
2010    ///   slot. Before #275 `collect_rollout` sized every agent's buffer to
2011    ///   agent 0's obs length and panicked here.
2012    /// * **Challenge C** — `comms_coef > 0` populates `stats.aux_loss` through
2013    ///   the existing aux-loss slot without touching `aux_fn`.
2014    ///
2015    /// This is a plumbing smoke test, not a convergence run: the game
2016    /// terminates every step, so the rollout is a sequence of single-step
2017    /// episodes.
2018    #[test]
2019    fn comms_smoke_joint_trainer() {
2020        use crate::env::games::signaling::{LISTENER, SPEAKER, SignalingGame};
2021
2022        let device: NdArrayDevice = Default::default();
2023        let num_agents = 2;
2024        let vocab = 4usize;
2025
2026        // Heterogeneous obs dims: speaker sees a length-`vocab` one-hot,
2027        // listener sees a single received-token slot. Both emit one token over
2028        // the vocabulary (message for the speaker, guess for the listener), so
2029        // `num_action_dims == 1` is uniform.
2030        let speaker = MlpBurnPolicy::<B>::new(vocab, vocab, 8, &device);
2031        let listener = MlpBurnPolicy::<B>::new(1, vocab, 8, &device);
2032        let policies = vec![speaker, listener];
2033        let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
2034
2035        let config = JointTrainerConfig {
2036            num_agents,
2037            rollout_steps: 16,
2038            n_epochs: 1,
2039            minibatch_size: 16,
2040            comms_coef: 0.5,
2041            ..Default::default()
2042        };
2043        let mut trainer =
2044            JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
2045
2046        let mut env = SignalingGame::with_hidden(vocab, 2);
2047        let mut last_obs = env.reset_joint(None);
2048        // The adapter reports per-agent obs dims — this is the layout that
2049        // panicked before the #275 `obs_dims` fix.
2050        assert_eq!(last_obs[SPEAKER].len(), vocab);
2051        assert_eq!(last_obs[LISTENER].len(), 1);
2052
2053        let mut rng = StdRng::seed_from_u64(7);
2054        let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
2055
2056        // Challenge B: the rollout records heterogeneous per-agent obs dims and
2057        // sizes each agent's flat buffer accordingly.
2058        assert_eq!(rollout.obs_dims, vec![vocab, 1]);
2059        assert_eq!(rollout.observations_per_agent[SPEAKER].len(), 16 * vocab);
2060        assert_eq!(rollout.observations_per_agent[LISTENER].len(), 16);
2061
2062        let stats = trainer
2063            .update(&rollout, &mut rng, |_features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> {
2064                None
2065            })
2066            .expect("update should not error on the signaling game");
2067
2068        // Challenge C: with `comms_coef > 0` the comms penalty populates the
2069        // aux-loss slot even though `aux_fn` returned `None`.
2070        assert!(stats.aux_loss.is_finite(), "aux_loss must be finite");
2071        assert_ne!(stats.aux_loss, 0.0, "comms_coef > 0 must populate aux_loss");
2072
2073        // All per-agent PPO stats remain finite across the heterogeneous-obs
2074        // joint update.
2075        for i in 0..num_agents {
2076            assert!(stats.policy_loss[i].is_finite(), "policy_loss[{i}] finite");
2077            assert!(stats.value_loss[i].is_finite(), "value_loss[{i}] finite");
2078            assert!(stats.entropy[i].is_finite(), "entropy[{i}] finite");
2079        }
2080        assert!(stats.total_loss.is_finite(), "total_loss must be finite");
2081    }
2082
2083    /// Issue #275: the comms term is strictly opt-in. With the default
2084    /// `comms_coef == 0.0` and an `aux_fn` returning `None`, `aux_loss` stays
2085    /// exactly `0.0` — the guarantee that keeps PSRO/NFSP determinism intact.
2086    #[test]
2087    fn comms_disabled_by_default_leaves_aux_loss_zero() {
2088        use crate::env::games::signaling::SignalingGame;
2089
2090        let device: NdArrayDevice = Default::default();
2091        let num_agents = 2;
2092        let vocab = 4usize;
2093
2094        let speaker = MlpBurnPolicy::<B>::new(vocab, vocab, 8, &device);
2095        let listener = MlpBurnPolicy::<B>::new(1, vocab, 8, &device);
2096        let policies = vec![speaker, listener];
2097        let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
2098
2099        // comms_coef defaults to 0.0 (disabled).
2100        let config = JointTrainerConfig {
2101            num_agents,
2102            rollout_steps: 16,
2103            n_epochs: 1,
2104            minibatch_size: 16,
2105            ..Default::default()
2106        };
2107        assert_eq!(config.comms_coef, 0.0);
2108        let mut trainer =
2109            JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
2110
2111        let mut env = SignalingGame::with_hidden(vocab, 1);
2112        let mut last_obs = env.reset_joint(None);
2113        let mut rng = StdRng::seed_from_u64(11);
2114        let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
2115        let stats = trainer
2116            .update(&rollout, &mut rng, |_features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> {
2117                None
2118            })
2119            .expect("update should not error");
2120
2121        assert_eq!(stats.aux_loss, 0.0, "aux_loss must be 0 when comms is off and aux_fn is None");
2122    }
2123
2124    /// Issue #239 fix #4: with a dedicated critic optimizer
2125    /// (`with_critic_optimizers`) the update runs the actor and critic in two
2126    /// backward passes, steps both, and still produces finite stats. Mirrors
2127    /// `test_iterate_all_minibatches_runs` but exercises the split-critic path.
2128    #[test]
2129    fn test_split_critic_optimizer_runs() {
2130        let device = Default::default();
2131        let num_agents = 2;
2132        let obs_dim: usize = 4;
2133        let action_dim: usize = 3;
2134        let policies = make_mlp_policies(num_agents, obs_dim, action_dim, 16, &device);
2135        let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
2136        // Dedicated critic optimizers at a higher LR (the #239 fix #4 pattern).
2137        let critic_optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 1e-3);
2138
2139        let config = JointTrainerConfig {
2140            num_agents,
2141            rollout_steps: 128,
2142            n_epochs: 2,
2143            minibatch_size: 32,
2144            iterate_all_minibatches: true,
2145            critic_lr: Some(1e-3),
2146            ..Default::default()
2147        };
2148        let mut trainer = JointMultiAgentTrainer::with_critic_optimizers(
2149            policies,
2150            optimizers,
2151            critic_optimizers,
2152            config,
2153            device,
2154        )
2155        .unwrap();
2156
2157        let mut env = MockEnv::new(num_agents, obs_dim);
2158        let mut last_obs = env.reset_joint(None);
2159        let mut rng = StdRng::seed_from_u64(0);
2160        let rollout = trainer.collect_rollout(&mut env, &mut last_obs, &mut rng);
2161        let stats = trainer
2162            .update(&rollout, &mut rng, |_features: &[Tensor<B, 2>]| -> Option<Tensor<B, 1>> {
2163                None
2164            })
2165            .expect("split-critic update should not error");
2166
2167        for i in 0..num_agents {
2168            assert!(stats.policy_loss[i].is_finite(), "policy_loss[{i}] finite");
2169            assert!(stats.value_loss[i].is_finite(), "value_loss[{i}] finite");
2170            assert!(stats.entropy[i].is_finite(), "entropy[{i}] finite");
2171            assert!(stats.explained_var[i].is_finite(), "explained_var[{i}] finite");
2172        }
2173        assert!(stats.total_loss.is_finite());
2174    }
2175
2176    /// Issue #239 fix #4: supplying a critic-optimizer count that mismatches
2177    /// the policy count is rejected at construction.
2178    #[test]
2179    fn test_with_critic_optimizers_length_mismatch_errors() {
2180        let device = Default::default();
2181        let num_agents = 2;
2182        let policies = make_mlp_policies(num_agents, 4, 3, 8, &device);
2183        let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
2184        // One too few critic optimizers.
2185        let critic_optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents - 1, 1e-3);
2186        let config = JointTrainerConfig { num_agents, ..Default::default() };
2187        let result = JointMultiAgentTrainer::with_critic_optimizers(
2188            policies,
2189            optimizers,
2190            critic_optimizers,
2191            config,
2192            device,
2193        );
2194        assert!(result.is_err(), "mismatched critic_optimizers length must be rejected");
2195    }
2196
2197    /// Issue #239 fix #4 regression guard: the default trainer (no critic
2198    /// optimizers) takes the single combined backward and is unaffected by
2199    /// the split-critic plumbing — `critic_lr` defaults to `None`.
2200    #[test]
2201    fn test_default_path_has_no_critic_optimizers() {
2202        let device = Default::default();
2203        let num_agents = 2;
2204        let policies = make_mlp_policies(num_agents, 4, 3, 8, &device);
2205        let optimizers = build_optimizers::<MlpBurnPolicy<B>>(num_agents, 3e-4);
2206        let config = JointTrainerConfig { num_agents, ..Default::default() };
2207        assert!(
2208            JointTrainerConfig::default().critic_lr.is_none(),
2209            "default critic_lr must be None"
2210        );
2211        let trainer = JointMultiAgentTrainer::new(policies, optimizers, config, device).unwrap();
2212        assert!(
2213            trainer.critic_optimizers.is_empty(),
2214            "default trainer must carry no critic optimizers (single combined backward)"
2215        );
2216    }
2217}