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