Skip to main content

thrust_rl/policy/
multi_discrete_mlp.rs

1//! Burn-backend multi-discrete actor-critic MLP for factored action
2//! spaces.
3//!
4//! Shared-trunk + per-dim head architecture used by environments like
5//! Bucket Brigade and the multi-agent self-play paths that need a
6//! factored `[house_index, mode]` action.
7
8use burn::{
9    module::Module,
10    nn::{Initializer, Linear},
11    tensor::{Int, Tensor, activation, backend::Backend},
12};
13
14use super::mlp::{
15    BurnActivation, MlpBurnConfig, derive_layer_seed, linear_from_weights, linear_with_init,
16    seeded_layer_weights,
17};
18
19/// Host-side per-dim categorical distributions for one or more rows,
20/// produced by [`MultiDiscreteMlpBurnPolicy::forward_to_host_dist`].
21///
22/// `probs_per_dim[d] = (n_actions_d, probs_flat_d, log_probs_flat_d)`
23/// where the flats are `[batch, n_actions_d]` row-major. The seeded draw
24/// lives in [`MultiDiscreteHostDist::sample_actions`] so the tensor
25/// forward and the RNG-consuming sample are cleanly separated — the
26/// precondition that lets the batched sampler do one `[N, obs_dim]`
27/// forward while keeping the per-row/per-dim RNG draw order bit-identical
28/// (issue #235).
29struct MultiDiscreteHostDist {
30    batch: usize,
31    num_dims: usize,
32    probs_per_dim: Vec<(usize, Vec<f32>, Vec<f32>)>,
33    values_host: Vec<f32>,
34}
35
36impl MultiDiscreteHostDist {
37    /// Draw one action per dim per row, consuming exactly one
38    /// `rng.random()` per (row, dim) in `row major → dim major` order.
39    /// Returns `(actions [batch*num_dims], joint_log_probs [batch],
40    /// values [batch])`.
41    ///
42    /// Byte-for-byte the loop `get_action_host_seeded` used before the
43    /// forward/sample split, so a same-seeded `rng` reproduces the exact
44    /// same action stream (issue #114 / #235).
45    fn sample_actions(&self, rng: &mut rand::rngs::StdRng) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
46        use rand::Rng;
47        let mut actions = vec![0_i64; self.batch * self.num_dims];
48        let mut log_probs = vec![0.0_f32; self.batch];
49        for row in 0..self.batch {
50            let mut joint_lp = 0.0_f32;
51            for (d, (n_actions, probs_flat, log_probs_flat)) in
52                self.probs_per_dim.iter().enumerate()
53            {
54                let u: f32 = rng.random();
55                let mut cum = 0.0;
56                let mut chosen = (*n_actions - 1) as i64;
57                for j in 0..*n_actions {
58                    cum += probs_flat[row * n_actions + j];
59                    if u < cum {
60                        chosen = j as i64;
61                        break;
62                    }
63                }
64                actions[row * self.num_dims + d] = chosen;
65                joint_lp += log_probs_flat[row * n_actions + chosen as usize];
66            }
67            log_probs[row] = joint_lp;
68        }
69        (actions, log_probs, self.values_host.clone())
70    }
71}
72
73/// Multi-discrete MLP actor-critic policy on Burn.
74///
75/// Shared trunk built from the same `MlpBurnConfig` knobs the
76/// single-action `MlpBurnPolicy` consumes, plus one [`Linear`] action
77/// head per dimension. Per-step log-probs are summed across dims
78/// (treating the dims as conditionally independent given the state),
79/// and per-step entropies are averaged.
80#[derive(Module, Debug)]
81pub struct MultiDiscreteMlpBurnPolicy<B: Backend> {
82    fc1: Linear<B>,
83    fc2: Linear<B>,
84    fc3: Option<Linear<B>>,
85    action_heads: Vec<Linear<B>>,
86    value_head: Linear<B>,
87    activation: BurnActivation,
88}
89
90impl<B: Backend> MultiDiscreteMlpBurnPolicy<B> {
91    /// Build a fresh multi-discrete policy with the default 2-layer
92    /// architecture (mirrors
93    /// [`crate::policy::multi_discrete_mlp::MultiDiscreteMlpBurnPolicy::new`]).
94    pub fn new(
95        obs_dim: usize,
96        action_dims: Vec<usize>,
97        hidden_dim: usize,
98        device: &B::Device,
99    ) -> Self {
100        let config = MlpBurnConfig { hidden_dim, ..Default::default() };
101        Self::with_config(obs_dim, action_dims, config, device)
102    }
103
104    /// Seeded variant of [`new`](Self::new): same default architecture
105    /// (orthogonal init), but constructed deterministically from `seed`
106    /// so two calls with the same seed produce bit-identical weights
107    /// (issue #135). Convenience wrapper for PSRO/NFSP policy factories
108    /// over the multi-discrete policy.
109    pub fn new_seeded(
110        obs_dim: usize,
111        action_dims: Vec<usize>,
112        hidden_dim: usize,
113        seed: u64,
114        device: &B::Device,
115    ) -> Self {
116        let config = MlpBurnConfig { hidden_dim, ..Default::default() }.with_seed(seed);
117        Self::with_config(obs_dim, action_dims, config, device)
118    }
119
120    /// Build a fresh multi-discrete policy with custom configuration.
121    pub fn with_config(
122        obs_dim: usize,
123        action_dims: Vec<usize>,
124        config: MlpBurnConfig,
125        device: &B::Device,
126    ) -> Self {
127        assert!(!action_dims.is_empty(), "action_dims must have at least one element");
128        for (i, d) in action_dims.iter().enumerate() {
129            assert!(*d >= 1, "action_dims[{i}] = {d}; must be >= 1");
130        }
131
132        // Seeded path (issue #135): mirror the single-action policy's
133        // deterministic construction per-head. Layer indices are fixed:
134        // 0=fc1, 1=fc2, 2=fc3, 3=value_head, then 100+i for action
135        // head i (offset keeps action-head streams distinct from the
136        // trunk/value streams regardless of head count).
137        if let Some(seed) = config.seed {
138            let orth = config.use_orthogonal_init;
139            let mk = |idx: u64, d_in: usize, d_out: usize, is_head: bool| {
140                let s = derive_layer_seed(seed, idx);
141                let w = seeded_layer_weights(s, d_in, d_out, orth, is_head);
142                linear_from_weights::<B>(d_in, d_out, &w, device)
143            };
144            let fc1 = mk(0, obs_dim, config.hidden_dim, false);
145            let fc2 = mk(1, config.hidden_dim, config.hidden_dim, false);
146            let fc3 = if config.num_layers >= 3 {
147                Some(mk(2, config.hidden_dim, config.hidden_dim, false))
148            } else {
149                None
150            };
151            let value_head = mk(3, config.hidden_dim, 1, true);
152            let action_heads: Vec<Linear<B>> = action_dims
153                .iter()
154                .enumerate()
155                .map(|(i, &dim)| mk(100 + i as u64, config.hidden_dim, dim, true))
156                .collect();
157            return Self { fc1, fc2, fc3, action_heads, value_head, activation: config.activation };
158        }
159
160        let hidden_init = if config.use_orthogonal_init {
161            Initializer::Orthogonal { gain: 2.0_f64.sqrt() }
162        } else {
163            Initializer::KaimingUniform { gain: 1.0_f64 / 3.0_f64.sqrt(), fan_out_only: false }
164        };
165        let output_init = if config.use_orthogonal_init {
166            Initializer::Orthogonal { gain: 0.01 }
167        } else {
168            Initializer::KaimingUniform { gain: 1.0_f64 / 3.0_f64.sqrt(), fan_out_only: false }
169        };
170
171        let fc1 = linear_with_init::<B>(obs_dim, config.hidden_dim, hidden_init.clone(), device);
172        let fc2 = linear_with_init::<B>(
173            config.hidden_dim,
174            config.hidden_dim,
175            hidden_init.clone(),
176            device,
177        );
178        let fc3 = if config.num_layers >= 3 {
179            Some(linear_with_init::<B>(config.hidden_dim, config.hidden_dim, hidden_init, device))
180        } else {
181            None
182        };
183
184        let action_heads: Vec<Linear<B>> = action_dims
185            .iter()
186            .map(|&dim| linear_with_init::<B>(config.hidden_dim, dim, output_init.clone(), device))
187            .collect();
188        let value_head = linear_with_init::<B>(config.hidden_dim, 1, output_init, device);
189
190        Self { fc1, fc2, fc3, action_heads, value_head, activation: config.activation }
191    }
192
193    fn apply_activation<const D: usize>(&self, x: Tensor<B, D>) -> Tensor<B, D> {
194        match self.activation {
195            BurnActivation::ReLU => activation::relu(x),
196            BurnActivation::Tanh => activation::tanh(x),
197        }
198    }
199
200    /// Shared-trunk features (mirrors
201    /// [`crate::policy::multi_discrete_mlp::MultiDiscreteMlpBurnPolicy::encoder_features`]).
202    pub fn encoder_features(&self, obs: Tensor<B, 2>) -> Tensor<B, 2> {
203        let h = self.apply_activation(self.fc1.forward(obs));
204        let h = self.apply_activation(self.fc2.forward(h));
205        if let Some(fc3) = &self.fc3 {
206            self.apply_activation(fc3.forward(h))
207        } else {
208            h
209        }
210    }
211
212    /// Forward pass: per-dim action logits plus value estimate.
213    ///
214    /// Returns `(Vec<logits_i>, value)` where
215    /// `logits_i: [batch, action_dims[i]]` and `value: [batch]`.
216    pub fn forward(&self, obs: Tensor<B, 2>) -> (Vec<Tensor<B, 2>>, Tensor<B, 1>) {
217        let features = self.encoder_features(obs);
218        let logits: Vec<Tensor<B, 2>> =
219            self.action_heads.iter().map(|h| h.forward(features.clone())).collect();
220        let value = self.value_head.forward(features).squeeze_dim::<1>(1);
221        (logits, value)
222    }
223
224    /// Number of action dimensions (heads).
225    pub fn num_action_dims(&self) -> usize {
226        self.action_heads.len()
227    }
228
229    /// Per-dimension action cardinalities (one entry per head).
230    ///
231    /// Returns the same vector that was passed to
232    /// [`MultiDiscreteMlpBurnPolicy::with_config`] /
233    /// [`MultiDiscreteMlpBurnPolicy::new`]. Reads each action head's
234    /// `weight` tensor shape — Burn's [`burn::nn::Linear`] stores `weight:
235    /// Param<Tensor<B, 2>>` with shape `[d_input, d_output]`, so `d_output`
236    /// is the per-dim cardinality. Used by the multi-agent joint trainer's
237    /// [`crate::multi_agent::joint::JointPolicy::action_dims_joint`] impl to
238    /// size the rollout action buffer.
239    pub fn action_dim_cardinalities(&self) -> Vec<usize> {
240        self.action_heads.iter().map(|h| h.weight.val().dims()[1]).collect()
241    }
242
243    /// Sample one action per row per dim from the per-dim categorical
244    /// distributions and return `(actions_host, log_probs_host,
245    /// values_host)` as plain `Vec`s.
246    ///
247    /// Thin backwards-compat wrapper around
248    /// [`MultiDiscreteMlpBurnPolicy::get_action_host_seeded`] that
249    /// constructs a thread-local RNG. **Not deterministic across
250    /// calls** — use
251    /// [`get_action_host_seeded`](Self::get_action_host_seeded) and
252    /// pass a seeded [`rand::rngs::StdRng`] when reproducibility is
253    /// required (PSRO/NFSP/joint trainer rollouts call the seeded form
254    /// via the [`crate::multi_agent::joint::JointPolicy`] trait so that
255    /// `PsroConfig::seed` / `NfspConfig::seed` produce bit-identical
256    /// rollouts; see issue #114).
257    pub fn get_action_host(&self, obs: Tensor<B, 2>) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
258        use rand::SeedableRng;
259        // Seed from OS entropy so the wrapper remains stochastic for
260        // non-deterministic callers (same behavior pre-#114, routed
261        // through `StdRng`).
262        let mut rng = rand::rngs::StdRng::from_os_rng();
263        self.get_action_host_seeded(obs, &mut rng)
264    }
265
266    /// Same contract as [`get_action_host`](Self::get_action_host) but
267    /// the host-side categorical draws consume `rng` instead of the
268    /// thread-local generator.
269    ///
270    /// Mirrors [`crate::policy::mlp::MlpBurnPolicy::get_action_host_seeded`] —
271    /// the trainer-side rollout loop does not need gradient flow through
272    /// the sampled action (only the eventual
273    /// [`MultiDiscreteMlpBurnPolicy::evaluate_actions`] call on the stored
274    /// transitions matters for the PPO surrogate). We therefore do the
275    /// categorical draw on the host with `rand`, sidestepping Burn
276    /// 0.21's lack of a first-class `multinomial` op.
277    ///
278    /// Layout:
279    /// - `actions[row * num_dims + d]` is the action for dim `d` of row `row`.
280    ///   Length is `batch * num_dims`.
281    /// - `log_probs[row]` is the joint log-probability summed across dims.
282    /// - `values[row]` is the value estimate.
283    ///
284    /// Bit-exactness contract: two calls with the same `obs`, same
285    /// policy state, and same-seeded `rng` (`StdRng::seed_from_u64`)
286    /// produce element-wise identical `(actions, log_probs, values)`.
287    /// The per-row outer loop and per-dim inner loop consume RNG draws
288    /// in a fixed `row major → dim major` order.
289    pub fn get_action_host_seeded(
290        &self,
291        obs: Tensor<B, 2>,
292        rng: &mut rand::rngs::StdRng,
293    ) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
294        // Decoupled into a pure-tensor forward (`forward_to_host_dist`, no
295        // RNG) and a host-side categorical draw
296        // (`MultiDiscreteHostDist::sample_actions`, the only RNG-consuming
297        // half). See the analogous split on `MlpBurnPolicy`: it preserves
298        // the per-row `row major → dim major` RNG draw order while letting
299        // the batched entry point replace N batch-1 forwards with a single
300        // `[N, obs_dim]` forward (issue #235).
301        self.forward_to_host_dist(obs).sample_actions(rng)
302    }
303
304    /// Tensor half of [`get_action_host_seeded`]: run the policy forward
305    /// and extract the per-dim host-side categorical distributions for
306    /// every row. **Consumes no RNG.**
307    fn forward_to_host_dist(&self, obs: Tensor<B, 2>) -> MultiDiscreteHostDist {
308        let (logits_per_dim, value) = self.forward(obs);
309        let num_dims = logits_per_dim.len();
310        assert!(num_dims > 0, "at least one action dim");
311
312        // Pre-extract host-side probs & log-probs for every head once. We
313        // can sample dim-by-dim cheaply from there.
314        let mut probs_per_dim: Vec<(usize, Vec<f32>, Vec<f32>)> = Vec::with_capacity(num_dims);
315        let mut batch_opt: Option<usize> = None;
316        for logits in logits_per_dim.into_iter() {
317            let dims = logits.dims();
318            let batch = dims[0];
319            let n_actions = dims[1];
320            batch_opt.get_or_insert(batch);
321            let probs = activation::softmax(logits.clone(), 1);
322            let log_probs = activation::log_softmax(logits, 1);
323            let probs_flat: Vec<f32> = probs.into_data().to_vec().expect("probs to_vec");
324            let log_probs_flat: Vec<f32> =
325                log_probs.into_data().to_vec().expect("log_probs to_vec");
326            probs_per_dim.push((n_actions, probs_flat, log_probs_flat));
327        }
328        let batch = batch_opt.unwrap_or(0);
329        let values_host: Vec<f32> = value.into_data().to_vec().expect("values to_vec");
330
331        MultiDiscreteHostDist { batch, num_dims, probs_per_dim, values_host }
332    }
333
334    /// Batched seeded sampler: one forward over `[N, obs_dim]`, then N
335    /// host-side categorical draws in `row major → dim major` order.
336    ///
337    /// Bit-identical to calling [`Self::get_action_host_seeded`] once per row
338    /// on `[1, obs_dim]` slices **provided the rows are drawn from the same
339    /// policy** (same weights in the single batched forward, RNG consumed
340    /// one draw per dim per row, ascending). The
341    /// [`crate::multi_agent::joint::JointPolicy`]-trait batched entry point
342    /// that eliminates per-call batch-1 overhead wherever many
343    /// observations are scored through the **same** model in one step
344    /// (issue #235).
345    pub fn get_actions_host_seeded_batched(
346        &self,
347        obs: Tensor<B, 2>,
348        rng: &mut rand::rngs::StdRng,
349    ) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
350        self.forward_to_host_dist(obs).sample_actions(rng)
351    }
352
353    /// Evaluate given actions: per-step summed log-prob, per-step mean
354    /// entropy (across dims), and value.
355    ///
356    /// # Arguments
357    /// * `obs`     - `[batch, obs_dim]`
358    /// * `actions` - `[batch, num_dims]` int (one action per dim per row)
359    ///
360    /// # Returns
361    /// `(log_probs [batch], entropy [batch], values [batch])`.
362    /// `log_probs` is summed across dims; `entropy` is averaged across
363    /// dims (matching the tch convention so parity holds).
364    pub fn evaluate_actions(
365        &self,
366        obs: Tensor<B, 2>,
367        actions: Tensor<B, 2, Int>,
368    ) -> (Tensor<B, 1>, Tensor<B, 1>, Tensor<B, 1>) {
369        let (logits_per_dim, value) = self.forward(obs);
370
371        let num_dims = logits_per_dim.len();
372        assert!(num_dims > 0, "logits_per_dim must be non-empty");
373
374        let mut summed_log_probs: Option<Tensor<B, 1>> = None;
375        let mut summed_entropy: Option<Tensor<B, 1>> = None;
376
377        for (i, logits) in logits_per_dim.into_iter().enumerate() {
378            let log_probs = activation::log_softmax(logits, 1);
379            let probs = log_probs.clone().exp();
380            let per_dim_entropy: Tensor<B, 1> =
381                -(probs * log_probs.clone()).sum_dim(1).squeeze_dim::<1>(1);
382
383            // actions[:, i] as [batch, 1] int then gather → [batch]
384            let actions_i: Tensor<B, 1, Int> =
385                actions.clone().slice([0..actions.dims()[0], i..i + 1]).squeeze_dim::<1>(1);
386            let per_dim_log_p: Tensor<B, 1> =
387                log_probs.gather(1, actions_i.unsqueeze_dim::<2>(1)).squeeze_dim::<1>(1);
388
389            summed_log_probs = Some(match summed_log_probs.take() {
390                Some(acc) => acc + per_dim_log_p,
391                None => per_dim_log_p,
392            });
393            summed_entropy = Some(match summed_entropy.take() {
394                Some(acc) => acc + per_dim_entropy,
395                None => per_dim_entropy,
396            });
397        }
398
399        let log_probs = summed_log_probs.expect("at least one dim");
400        // Mean entropy across dims (matches the tch trainer convention).
401        let entropy = summed_entropy.expect("at least one dim").div_scalar(num_dims as f32);
402
403        (log_probs, entropy, value)
404    }
405}
406
407#[cfg(test)]
408mod tests {
409    use burn::backend::{Autodiff, NdArray};
410
411    use super::*;
412
413    type B = Autodiff<NdArray<f32>>;
414
415    #[test]
416    fn test_creation_default() {
417        let device = Default::default();
418        let _policy = MultiDiscreteMlpBurnPolicy::<B>::new(4, vec![10, 2], 32, &device);
419    }
420
421    #[test]
422    fn test_forward_shapes() {
423        let device = Default::default();
424        let policy = MultiDiscreteMlpBurnPolicy::<B>::with_config(
425            4,
426            vec![10, 2],
427            MlpBurnConfig::default(),
428            &device,
429        );
430        let obs = Tensor::<B, 2>::zeros([3, 4], &device);
431        let (logits, value) = policy.forward(obs);
432        assert_eq!(logits.len(), 2);
433        assert_eq!(logits[0].dims(), [3, 10]);
434        assert_eq!(logits[1].dims(), [3, 2]);
435        assert_eq!(value.dims(), [3]);
436    }
437
438    #[test]
439    fn test_evaluate_actions_shapes() {
440        let device = Default::default();
441        let policy = MultiDiscreteMlpBurnPolicy::<B>::new(4, vec![3, 4], 16, &device);
442        let obs = Tensor::<B, 2>::zeros([5, 4], &device);
443        let actions_data: Vec<i64> = vec![0, 1, 1, 2, 2, 0, 0, 3, 1, 2];
444        let actions = Tensor::<B, 2, Int>::from_data(
445            burn::tensor::TensorData::new(actions_data, [5, 2]),
446            &device,
447        );
448        let (log_probs, entropy, values) = policy.evaluate_actions(obs, actions);
449        assert_eq!(log_probs.dims(), [5]);
450        assert_eq!(entropy.dims(), [5]);
451        assert_eq!(values.dims(), [5]);
452    }
453
454    #[test]
455    fn test_num_action_dims() {
456        let device = Default::default();
457        let policy = MultiDiscreteMlpBurnPolicy::<B>::new(4, vec![10, 2, 5], 32, &device);
458        assert_eq!(policy.num_action_dims(), 3);
459    }
460
461    /// Bit-exact reproducibility of
462    /// [`MultiDiscreteMlpBurnPolicy::get_action_host_seeded`] across
463    /// same-seeded `StdRng` invocations.
464    ///
465    /// Mirror of the unit test in `src/policy/mlp.rs` — the
466    /// multi-discrete path consumes one RNG draw per (row, dim) pair
467    /// in `row major → dim major` order, so two calls with the same
468    /// observation and same-seeded RNG must produce element-wise
469    /// identical `(actions, log_probs, values)`.
470    #[test]
471    fn test_get_action_host_seeded_is_bit_exact() {
472        use rand::{SeedableRng, rngs::StdRng};
473
474        let device = Default::default();
475        let policy = MultiDiscreteMlpBurnPolicy::<B>::new(4, vec![3, 4], 16, &device);
476
477        // 3-row batch, 2 action dims.
478        let obs_data: Vec<f32> = vec![0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2];
479        let obs_a = Tensor::<B, 2>::from_data(
480            burn::tensor::TensorData::new(obs_data.clone(), [3, 4]),
481            &device,
482        );
483        let obs_b =
484            Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(obs_data, [3, 4]), &device);
485
486        let mut rng_a = StdRng::seed_from_u64(123);
487        let mut rng_b = StdRng::seed_from_u64(123);
488        let (a_a, lp_a, v_a) = policy.get_action_host_seeded(obs_a, &mut rng_a);
489        let (a_b, lp_b, v_b) = policy.get_action_host_seeded(obs_b, &mut rng_b);
490        // 3 rows × 2 dims = 6 action ints.
491        assert_eq!(a_a.len(), 6, "row-major (row, dim) layout = 3*2 entries");
492        assert_eq!(a_a, a_b, "same-seed actions must be bit-identical");
493        assert_eq!(lp_a, lp_b, "same-seed log_probs must be bit-identical");
494        assert_eq!(v_a, v_b, "same-seed values must be bit-identical");
495    }
496
497    /// Flatten every weight + bias (trunk, value head, all action
498    /// heads) into one comparison vector.
499    fn collect_params(p: &MultiDiscreteMlpBurnPolicy<B>) -> Vec<f32> {
500        let mut out = Vec::new();
501        let mut push = |lin: &Linear<B>| {
502            out.extend::<Vec<f32>>(lin.weight.val().into_data().to_vec().unwrap());
503            if let Some(b) = &lin.bias {
504                out.extend::<Vec<f32>>(b.val().into_data().to_vec().unwrap());
505            }
506        };
507        push(&p.fc1);
508        push(&p.fc2);
509        if let Some(fc3) = &p.fc3 {
510            push(fc3);
511        }
512        push(&p.value_head);
513        for h in &p.action_heads {
514            push(h);
515        }
516        out
517    }
518
519    /// Two `new_seeded` constructions with the same seed produce
520    /// bit-identical weights across the trunk and every per-head; a
521    /// different seed differs (issue #135).
522    #[test]
523    fn test_new_seeded_is_bit_identical() {
524        let device = Default::default();
525        let a = MultiDiscreteMlpBurnPolicy::<B>::new_seeded(4, vec![3, 4, 2], 16, 42, &device);
526        let b = MultiDiscreteMlpBurnPolicy::<B>::new_seeded(4, vec![3, 4, 2], 16, 42, &device);
527        assert_eq!(collect_params(&a), collect_params(&b), "same seed must be bit-identical");
528        let c = MultiDiscreteMlpBurnPolicy::<B>::new_seeded(4, vec![3, 4, 2], 16, 43, &device);
529        assert_ne!(collect_params(&a), collect_params(&c), "different seed must differ");
530    }
531
532    /// Action heads of the same cardinality must get distinct seeded
533    /// weights (per-head seed derivation decorrelates them).
534    #[test]
535    fn test_seeded_action_heads_are_distinct() {
536        let device = Default::default();
537        // Two heads of identical cardinality (2) — must differ.
538        let p = MultiDiscreteMlpBurnPolicy::<B>::new_seeded(4, vec![2, 2], 8, 5, &device);
539        let h0: Vec<f32> = p.action_heads[0].weight.val().into_data().to_vec().unwrap();
540        let h1: Vec<f32> = p.action_heads[1].weight.val().into_data().to_vec().unwrap();
541        assert_ne!(h0, h1, "same-cardinality action heads must get distinct seeded weights");
542    }
543}