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