Skip to main content

thrust_rl/policy/
mlp.rs

1//! Burn-backend MLP actor-critic policy.
2//!
3//! Implements a 2/3-layer MLP actor-critic architecture with orthogonal
4//! initialization (PPO recipe — gain `sqrt(2)` on the trunk, `0.01` on
5//! the output heads).
6//!
7//! # Entry points
8//!
9//! - `MlpBurnPolicy::new` — the simple scout-era constructor (random Kaiming
10//!   init, 2 layers).
11//! - `MlpBurnConfig` — builder-style configuration with orthogonal init,
12//!   activation, and depth knobs; supports the encoder-tap helper that
13//!   downstream regularizers want.
14//!
15//! # Why generic over `B: Backend`?
16//!
17//! Burn's idiomatic pattern is to make every `Module` generic over a
18//! `Backend` type parameter (CPU `NdArray`, GPU `Wgpu`/`Cuda`,
19//! autodiff-decorated variants, etc.). Production trainers can re-use
20//! the same modules with a different backend at the top of the binary
21//! without touching the policy code.
22
23use burn::{
24    module::{Module, Param},
25    nn::{Initializer, Linear},
26    tensor::{Int, Tensor, activation, backend::Backend},
27};
28
29/// Build a [`Linear`] layer with an explicit weight initializer and a
30/// zeroed bias.
31///
32/// Burn's `LinearConfig::with_initializer` applies the same initializer
33/// to both the weight and the bias, but [`Initializer::Orthogonal`]
34/// requires a rank-≥2 tensor and panics on the 1D bias. The PPO recipe
35/// (mirrored on the tch path) initializes biases to zero anyway, so the
36/// idiomatic Burn analogue is "Orthogonal on the weight, zero on the
37/// bias". This helper packages that two-step setup.
38///
39/// Re-used by [`MlpBurnPolicy`],
40/// [`crate::policy::multi_discrete_mlp::MultiDiscreteMlpBurnPolicy`],
41/// [`crate::policy::q_network::QNetworkBurn`], and
42/// [`crate::policy::snake_cnn::SnakeCnnBurnPolicy`].
43pub(crate) fn linear_with_init<B: Backend>(
44    d_input: usize,
45    d_output: usize,
46    initializer: Initializer,
47    device: &B::Device,
48) -> Linear<B> {
49    // Build a 2D weight Param via the initializer, and a 1D zero bias
50    // Param via Param::from_tensor. `LinearConfig::with_initializer`
51    // can't help here because it applies the same initializer to both
52    // weight and bias, and `Initializer::Orthogonal` panics on the
53    // rank-1 bias tensor (it requires `D >= 2`).
54    let weight: Param<Tensor<B, 2>> = initializer.init_with::<B, 2, _>(
55        [d_input, d_output],
56        Some(d_input),
57        Some(d_output),
58        device,
59    );
60    let bias_tensor = Tensor::<B, 1>::zeros([d_output], device);
61    Linear::<B> { weight, bias: Some(Param::from_tensor(bias_tensor)) }
62}
63
64/// Build a [`Linear`] layer from a pre-computed, row-major
65/// `[d_input, d_output]` weight buffer (and a zeroed bias).
66///
67/// This is the seeded counterpart to [`linear_with_init`]: instead of
68/// routing through Burn's unseedable [`Initializer`], the caller
69/// supplies weights produced by
70/// [`crate::policy::seeded_init`] (driven by `StdRng::seed_from_u64`),
71/// so two constructions with the same seed yield bit-identical layers.
72/// Used by the `with_config` seeded path on both
73/// [`MlpBurnPolicy`] and
74/// [`crate::policy::multi_discrete_mlp::MultiDiscreteMlpBurnPolicy`]
75/// (issue #135).
76pub(crate) fn linear_from_weights<B: Backend>(
77    d_input: usize,
78    d_output: usize,
79    weights: &[f32],
80    device: &B::Device,
81) -> Linear<B> {
82    debug_assert_eq!(weights.len(), d_input * d_output, "weight buffer must be d_input * d_output");
83    let weight_tensor = Tensor::<B, 2>::from_data(
84        burn::tensor::TensorData::new(weights.to_vec(), [d_input, d_output]),
85        device,
86    );
87    let bias_tensor = Tensor::<B, 1>::zeros([d_output], device);
88    Linear::<B> {
89        weight: Param::from_tensor(weight_tensor),
90        bias: Some(Param::from_tensor(bias_tensor)),
91    }
92}
93
94/// Produce a seeded weight buffer for one layer, honoring the
95/// orthogonal-vs-Kaiming recipe shared by both MLP policies.
96///
97/// Mirrors the gains used on the unseeded [`Initializer`] path:
98/// orthogonal trunk `sqrt(2)` / head `0.01`; Kaiming `1/sqrt(3)` for
99/// both heads and trunk (Burn's `KaimingUniform` default gain).
100pub(crate) fn seeded_layer_weights(
101    seed: u64,
102    d_in: usize,
103    d_out: usize,
104    use_orthogonal: bool,
105    is_head: bool,
106) -> Vec<f32> {
107    use crate::policy::seeded_init::{seeded_kaiming_uniform, seeded_orthogonal};
108    if use_orthogonal {
109        let gain = if is_head { 0.01_f32 } else { 2.0_f32.sqrt() };
110        seeded_orthogonal(seed, d_in, d_out, gain)
111    } else {
112        let gain = 1.0_f32 / 3.0_f32.sqrt();
113        seeded_kaiming_uniform(seed, d_in, d_out, gain)
114    }
115}
116
117/// Derive a distinct per-layer seed from a base construction seed.
118///
119/// Each `Linear` layer in a policy must draw from a *different* RNG
120/// stream — otherwise every layer of the same shape would get identical
121/// weights. We mix the base seed with a small per-layer index using a
122/// SplitMix64-style finalizer so the streams are decorrelated yet fully
123/// determined by `(base_seed, layer_index)`.
124pub(crate) fn derive_layer_seed(base_seed: u64, layer_index: u64) -> u64 {
125    let mut z = base_seed.wrapping_add(layer_index.wrapping_mul(0x9E37_79B9_7F4A_7C15));
126    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
127    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
128    z ^ (z >> 31)
129}
130
131/// Activation function applied between hidden layers in
132/// [`MlpBurnPolicy`] (and its multi-discrete sibling).
133///
134/// Mirrors [`crate::policy::mlp::BurnActivation`] on the tch path; the two
135/// enums are deliberately separate so the Burn module does not pull in
136/// `tch` types under `--features training-burn` alone.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum BurnActivation {
139    /// Rectified linear unit (`max(0, x)`).
140    ReLU,
141    /// Hyperbolic tangent (`tanh(x)`).
142    Tanh,
143}
144
145/// Host-side categorical distribution for one or more rows, produced by
146/// [`MlpBurnPolicy::forward_to_host_dist`].
147///
148/// Holds the flattened per-row `probs` / `log_probs` (`[batch, n_actions]`
149/// row-major) and per-row `values`. The seeded draw lives in
150/// [`HostCategoricalDist::sample_actions`] so the tensor forward and the
151/// RNG-consuming sample are cleanly separated — the precondition that lets
152/// the batched sampler do one `[N, obs_dim]` forward while keeping the
153/// per-row RNG draw order bit-identical (issue #235).
154struct HostCategoricalDist {
155    batch: usize,
156    n_actions: usize,
157    probs_flat: Vec<f32>,
158    log_probs_flat: Vec<f32>,
159    values_host: Vec<f32>,
160}
161
162impl HostCategoricalDist {
163    /// Draw one action per row from the categorical distribution, consuming
164    /// exactly one `rng.random()` per row in ascending row order. Returns
165    /// `(actions, log_probs_of_chosen, values)`.
166    ///
167    /// The draw is byte-for-byte the loop that
168    /// [`MlpBurnPolicy::get_action_host_seeded`] used before the
169    /// forward/sample split, so a same-seeded `rng` reproduces the exact
170    /// same action stream (issue #114 / #235).
171    fn sample_actions(&self, rng: &mut rand::rngs::StdRng) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
172        use rand::Rng;
173        let mut actions = Vec::with_capacity(self.batch);
174        let mut log_probs = Vec::with_capacity(self.batch);
175        for row in 0..self.batch {
176            let u: f32 = rng.random();
177            let mut cum = 0.0;
178            let mut chosen = (self.n_actions - 1) as i64;
179            for j in 0..self.n_actions {
180                cum += self.probs_flat[row * self.n_actions + j];
181                if u < cum {
182                    chosen = j as i64;
183                    break;
184                }
185            }
186            actions.push(chosen);
187            log_probs.push(self.log_probs_flat[row * self.n_actions + chosen as usize]);
188        }
189        (actions, log_probs, self.values_host.clone())
190    }
191}
192
193/// Configuration for [`MlpBurnPolicy`] architecture.
194///
195/// Mirrors [`crate::policy::mlp::MlpBurnConfig`] on the tch path. Stored
196/// inside the policy so the parity tests can compare both backends on
197/// identical hyperparameters.
198#[derive(Debug, Clone, Copy)]
199pub struct MlpBurnConfig {
200    /// Number of hidden layers in the shared trunk. Only `2` or `3` are
201    /// supported; anything else is treated as `2`.
202    pub num_layers: usize,
203    /// Width of every hidden layer.
204    pub hidden_dim: usize,
205    /// If `true`, initialize hidden-layer weights with
206    /// [`Initializer::Orthogonal`] (gain `sqrt(2)`) and output heads
207    /// with `Initializer::Orthogonal { gain = 0.01 }`. Set `false` to
208    /// fall back to Burn's default Kaiming-uniform init.
209    pub use_orthogonal_init: bool,
210    /// Activation applied between hidden layers.
211    pub activation: BurnActivation,
212    /// Optional construction seed. When `Some`, `with_config` builds
213    /// every layer from a deterministic, [`StdRng`](rand::rngs::StdRng)-
214    /// driven weight buffer (see [`crate::policy::seeded_init`]) instead
215    /// of Burn's unseedable [`Initializer`], so two constructions with
216    /// the same seed produce **bit-identical** policies. When `None`
217    /// (the default) the behavior is unchanged — Burn's `Initializer`
218    /// path is used verbatim. This is the load-bearing knob behind the
219    /// end-to-end `PsroConfig::seed` / `NfspConfig::seed` reproducibility
220    /// contract (issue #135). The seeded path covers **both** the
221    /// orthogonal and Kaiming-uniform recipes (selected by
222    /// `use_orthogonal_init`), so seeding works regardless of which init
223    /// the caller picks.
224    pub seed: Option<u64>,
225}
226
227impl Default for MlpBurnConfig {
228    fn default() -> Self {
229        Self {
230            num_layers: 2,
231            hidden_dim: 64,
232            use_orthogonal_init: true,
233            activation: BurnActivation::Tanh,
234            seed: None,
235        }
236    }
237}
238
239impl MlpBurnConfig {
240    /// Set the construction seed, enabling the deterministic
241    /// host-side init path in `with_config`.
242    ///
243    /// Builder-style; returns `self` for chaining:
244    /// `MlpBurnConfig::default().with_seed(42)`.
245    pub fn with_seed(mut self, seed: u64) -> Self {
246        self.seed = Some(seed);
247        self
248    }
249}
250
251/// Two- or three-layer MLP actor-critic for **discrete** action spaces,
252/// ported to Burn.
253///
254/// Layout mirrors [`crate::policy::mlp::MlpBurnPolicy`] at a high level:
255///
256/// ```text
257/// obs → fc1 →act→ fc2 →act→ (fc3 →act→)? policy_head (logits)
258///                                       └─ value_head  (V(s))
259/// ```
260///
261/// Both heads share the trunk activations — standard PPO actor-critic.
262///
263/// # Numerical parity
264///
265/// When constructed with `use_orthogonal_init = true` (the default), the
266/// trunk uses [`Initializer::Orthogonal { gain: sqrt(2) }`] and the
267/// output heads use `gain = 0.01`. These match the tch policy's init
268/// gains exactly (see [`crate::policy::mlp::MlpBurnPolicy::with_config`]),
269/// which is the necessary precondition for the phase-4 numerical-parity
270/// check called out on issue #81.
271#[derive(Module, Debug)]
272pub struct MlpBurnPolicy<B: Backend> {
273    fc1: Linear<B>,
274    fc2: Linear<B>,
275    fc3: Option<Linear<B>>,
276    policy_head: Linear<B>,
277    value_head: Linear<B>,
278    activation: BurnActivation,
279}
280
281impl<B: Backend> MlpBurnPolicy<B> {
282    /// Backward-compatible 2-layer constructor (the phase 1 scout
283    /// signature). Uses Burn's default Kaiming-uniform init — kept so
284    /// the existing bandit trainer and parity tests are not perturbed.
285    ///
286    /// New call sites that want PPO-style orthogonal init should call
287    /// [`MlpBurnPolicy::with_config`] instead.
288    pub fn new(obs_dim: usize, action_dim: usize, hidden_dim: usize, device: &B::Device) -> Self {
289        let config = MlpBurnConfig {
290            num_layers: 2,
291            hidden_dim,
292            // Preserve scout behavior — the phase 1 scout used the
293            // default LinearConfig init (Kaiming uniform), not the
294            // PPO orthogonal recipe.
295            use_orthogonal_init: false,
296            activation: BurnActivation::Tanh,
297            seed: None,
298        };
299        Self::with_config(obs_dim, action_dim, config, device)
300    }
301
302    /// Seeded variant of [`new`](Self::new): same 2-layer Kaiming
303    /// architecture, but constructed deterministically from `seed` so
304    /// two calls with the same seed produce bit-identical weights.
305    ///
306    /// Convenience wrapper for callers (and the PSRO/NFSP policy
307    /// factories) that want reproducible policies without assembling a
308    /// full [`MlpBurnConfig`]. Equivalent to
309    /// `with_config(.., MlpBurnConfig { use_orthogonal_init: false,
310    /// .., seed: Some(seed) }, ..)`.
311    pub fn new_seeded(
312        obs_dim: usize,
313        action_dim: usize,
314        hidden_dim: usize,
315        seed: u64,
316        device: &B::Device,
317    ) -> Self {
318        let config = MlpBurnConfig {
319            num_layers: 2,
320            hidden_dim,
321            use_orthogonal_init: false,
322            activation: BurnActivation::Tanh,
323            seed: Some(seed),
324        };
325        Self::with_config(obs_dim, action_dim, config, device)
326    }
327
328    /// Build a fresh policy on `device` with the given configuration.
329    ///
330    /// This is the production constructor for phase 4 onwards. Mirrors
331    /// [`crate::policy::mlp::MlpBurnPolicy::with_config`].
332    pub fn with_config(
333        obs_dim: usize,
334        action_dim: usize,
335        config: MlpBurnConfig,
336        device: &B::Device,
337    ) -> Self {
338        // Seeded path (issue #135): when `config.seed` is set, build
339        // every layer from a deterministic `StdRng`-driven weight buffer
340        // so two constructions with the same seed are bit-identical.
341        // Each layer gets a distinct derived seed so layers of the same
342        // shape don't collide. Layer indices are fixed:
343        // 0=fc1, 1=fc2, 2=fc3, 3=policy_head, 4=value_head.
344        if let Some(seed) = config.seed {
345            let orth = config.use_orthogonal_init;
346            let mk = |idx: u64, d_in: usize, d_out: usize, is_head: bool| {
347                let s = derive_layer_seed(seed, idx);
348                let w = seeded_layer_weights(s, d_in, d_out, orth, is_head);
349                linear_from_weights::<B>(d_in, d_out, &w, device)
350            };
351            let fc1 = mk(0, obs_dim, config.hidden_dim, false);
352            let fc2 = mk(1, config.hidden_dim, config.hidden_dim, false);
353            let fc3 = if config.num_layers >= 3 {
354                Some(mk(2, config.hidden_dim, config.hidden_dim, false))
355            } else {
356                None
357            };
358            let policy_head = mk(3, config.hidden_dim, action_dim, true);
359            let value_head = mk(4, config.hidden_dim, 1, true);
360            return Self { fc1, fc2, fc3, policy_head, value_head, activation: config.activation };
361        }
362
363        let hidden_init = if config.use_orthogonal_init {
364            Initializer::Orthogonal { gain: 2.0_f64.sqrt() }
365        } else {
366            // Burn's default — see LinearConfig docs.
367            Initializer::KaimingUniform { gain: 1.0_f64 / 3.0_f64.sqrt(), fan_out_only: false }
368        };
369        let output_init = if config.use_orthogonal_init {
370            Initializer::Orthogonal { gain: 0.01 }
371        } else {
372            Initializer::KaimingUniform { gain: 1.0_f64 / 3.0_f64.sqrt(), fan_out_only: false }
373        };
374
375        let fc1 = linear_with_init::<B>(obs_dim, config.hidden_dim, hidden_init.clone(), device);
376        let fc2 = linear_with_init::<B>(
377            config.hidden_dim,
378            config.hidden_dim,
379            hidden_init.clone(),
380            device,
381        );
382        let fc3 = if config.num_layers >= 3 {
383            Some(linear_with_init::<B>(config.hidden_dim, config.hidden_dim, hidden_init, device))
384        } else {
385            None
386        };
387
388        let policy_head =
389            linear_with_init::<B>(config.hidden_dim, action_dim, output_init.clone(), device);
390        let value_head = linear_with_init::<B>(config.hidden_dim, 1, output_init, device);
391
392        Self { fc1, fc2, fc3, policy_head, value_head, activation: config.activation }
393    }
394
395    fn apply_activation<const D: usize>(&self, x: Tensor<B, D>) -> Tensor<B, D> {
396        match self.activation {
397            BurnActivation::ReLU => activation::relu(x),
398            BurnActivation::Tanh => activation::tanh(x),
399        }
400    }
401
402    /// Forward pass: returns `(logits, value)`.
403    ///
404    /// * `obs` is shape `[batch, obs_dim]`.
405    /// * `logits` is shape `[batch, action_dim]` (pre-softmax).
406    /// * `value` is shape `[batch]` (squeezed from `[batch, 1]`).
407    pub fn forward(&self, obs: Tensor<B, 2>) -> (Tensor<B, 2>, Tensor<B, 1>) {
408        let h = self.encoder_features(obs);
409        let logits = self.policy_head.forward(h.clone());
410        let value = self.value_head.forward(h).squeeze_dim::<1>(1);
411        (logits, value)
412    }
413
414    /// Compute the shared-trunk feature representation for `obs`.
415    ///
416    /// Mirrors [`crate::policy::mlp::MlpBurnPolicy::encoder_features`] —
417    /// auxiliary regularizers (cross-agent redundancy penalties,
418    /// behavioural-diversity bonuses) tap this directly.
419    ///
420    /// Gradients flow back into the trunk.
421    pub fn encoder_features(&self, obs: Tensor<B, 2>) -> Tensor<B, 2> {
422        let h = self.apply_activation(self.fc1.forward(obs));
423        let h = self.apply_activation(self.fc2.forward(h));
424        if let Some(fc3) = &self.fc3 {
425            self.apply_activation(fc3.forward(h))
426        } else {
427            h
428        }
429    }
430
431    /// Action-head output dimensionality (number of discrete actions).
432    ///
433    /// Reads the `policy_head` weight tensor's shape — Burn's
434    /// [`burn::nn::Linear`] stores `weight: Param<Tensor<B, 2>>` with
435    /// shape `[d_input, d_output]`, so `d_output` is the action
436    /// cardinality. Used by the multi-agent joint trainer's
437    /// [`crate::multi_agent::joint::JointPolicy::action_dims_joint`] impl
438    /// to size the rollout action buffer without consuming RNG draws.
439    pub fn policy_head_action_dim(&self) -> usize {
440        self.policy_head.weight.val().dims()[1]
441    }
442
443    /// Borrow the first shared-trunk linear layer.
444    pub fn fc1(&self) -> &Linear<B> {
445        &self.fc1
446    }
447
448    /// Borrow the second shared-trunk linear layer.
449    pub fn fc2(&self) -> &Linear<B> {
450        &self.fc2
451    }
452
453    /// Borrow the policy (action-logits) head.
454    pub fn policy_head(&self) -> &Linear<B> {
455        &self.policy_head
456    }
457
458    /// Borrow the value (`V(s)`) head.
459    pub fn value_head(&self) -> &Linear<B> {
460        &self.value_head
461    }
462
463    /// Sample one action per row from the policy's categorical
464    /// distribution and return `(actions_host, log_probs_host,
465    /// values_host)` as plain `Vec`s.
466    ///
467    /// Thin backwards-compat wrapper around
468    /// [`MlpBurnPolicy::get_action_host_seeded`] that constructs a
469    /// thread-local RNG. **Not deterministic across calls** — use
470    /// [`get_action_host_seeded`](Self::get_action_host_seeded) and pass
471    /// a seeded [`rand::rngs::StdRng`] when reproducibility is required
472    /// (PSRO/NFSP/joint trainer rollouts call the seeded form via the
473    /// [`crate::multi_agent::joint::JointPolicy`] trait so that
474    /// `PsroConfig::seed` / `NfspConfig::seed` produce bit-identical
475    /// rollouts; see issue #114).
476    ///
477    /// Retained for example-driver convenience where the caller does
478    /// not need bit-exact reproducibility and would otherwise have to
479    /// thread an `&mut StdRng` through bespoke rollout loops.
480    pub fn get_action_host(&self, obs: Tensor<B, 2>) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
481        use rand::SeedableRng;
482        // Seed from OS entropy so the wrapper remains stochastic for
483        // non-deterministic callers (the same behavior pre-#114, just
484        // routed through `StdRng`).
485        let mut rng = rand::rngs::StdRng::from_os_rng();
486        self.get_action_host_seeded(obs, &mut rng)
487    }
488
489    /// Same contract as [`get_action_host`](Self::get_action_host) but
490    /// the host-side categorical draws consume `rng` instead of the
491    /// thread-local generator.
492    ///
493    /// The trainer-side rollout loop does not need gradient flow
494    /// through the sampled action (only the eventual
495    /// [`MlpBurnPolicy::evaluate_actions`] call on the stored
496    /// transitions matters for the PPO surrogate). We therefore do the
497    /// categorical draw on the host with `rand`, sidestepping Burn
498    /// 0.21's lack of a first-class `multinomial` op.
499    ///
500    /// Bit-exactness contract: two calls with the same `obs`, same
501    /// `policy` state, and same-seeded `rng` (`StdRng::seed_from_u64`)
502    /// must produce element-wise identical
503    /// `(actions, log_probs, values)`. This is the load-bearing
504    /// guarantee `PsroConfig::seed` / `NfspConfig::seed` rely on after
505    /// issue #114.
506    pub fn get_action_host_seeded(
507        &self,
508        obs: Tensor<B, 2>,
509        rng: &mut rand::rngs::StdRng,
510    ) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
511        // Decoupled into a pure-tensor forward (`forward_to_host_dist`,
512        // no RNG) followed by a host-side categorical draw
513        // (`sample_actions_from_host_dist`, the only RNG-consuming half).
514        // This split is what makes the **batched** entry point
515        // (`get_actions_host_seeded_batched`) possible without changing
516        // the per-row RNG draw order: a single forward over `[N, obs_dim]`
517        // produces N rows of host probs, then the per-row loop draws RNG in
518        // the exact same row-major sequence as N separate `[1, obs_dim]`
519        // calls would. Bit-exactness (issue #114 / #235) is therefore
520        // preserved by construction.
521        let dist = self.forward_to_host_dist(obs);
522        dist.sample_actions(rng)
523    }
524
525    /// Tensor half of [`get_action_host_seeded`]: run the policy forward
526    /// and pull the host-side categorical distribution (`probs`,
527    /// `log_probs`) plus values for every row. **Consumes no RNG.**
528    ///
529    /// Returns a [`HostCategoricalDist`] from which
530    /// [`HostCategoricalDist::sample_actions`] performs the seeded draw.
531    /// Splitting the forward (one batched tensor op over `[N, obs_dim]`)
532    /// from the sample (a row-major host loop) lets the batched sampler
533    /// replace N batch-1 forwards with a single `[N, obs_dim]` forward
534    /// while keeping the RNG draw order — and therefore the sampled
535    /// action stream — bit-identical (issue #235).
536    fn forward_to_host_dist(&self, obs: Tensor<B, 2>) -> HostCategoricalDist {
537        let (logits, value) = self.forward(obs);
538        let probs = activation::softmax(logits.clone(), 1);
539        let log_probs_all = activation::log_softmax(logits, 1);
540
541        let dims = probs.dims();
542        let batch = dims[0];
543        let n_actions = dims[1];
544
545        let probs_flat: Vec<f32> = probs.into_data().to_vec().expect("probs to_vec");
546        let log_probs_flat: Vec<f32> =
547            log_probs_all.into_data().to_vec().expect("log_probs to_vec");
548        let values_host: Vec<f32> = value.into_data().to_vec().expect("values to_vec");
549
550        HostCategoricalDist { batch, n_actions, probs_flat, log_probs_flat, values_host }
551    }
552
553    /// Batched seeded sampler: one forward over `[N, obs_dim]`, then N
554    /// host-side categorical draws in row-major order.
555    ///
556    /// Bit-identical to calling [`Self::get_action_host_seeded`] once per row
557    /// on `[1, obs_dim]` slices **provided the rows are drawn from the same
558    /// policy** — the forward is a single matmul over all N rows (same
559    /// weights), and the RNG is consumed one draw per row, ascending. This
560    /// is the [`crate::multi_agent::joint::JointPolicy`]-trait batched
561    /// entry point that eliminates per-call batch-1 overhead on the
562    /// NdArray backend wherever many observations are scored through the
563    /// **same** model in one step (issue #235).
564    pub fn get_actions_host_seeded_batched(
565        &self,
566        obs: Tensor<B, 2>,
567        rng: &mut rand::rngs::StdRng,
568    ) -> (Vec<i64>, Vec<f32>, Vec<f32>) {
569        self.forward_to_host_dist(obs).sample_actions(rng)
570    }
571
572    /// Evaluate a batch of `(obs, actions)` pairs.
573    ///
574    /// Returns `(action_log_probs, entropy_per_row, values)` — the
575    /// quantities the PPO surrogate loss needs. Entropy is per-row here
576    /// (not the mean): the caller decides how to aggregate. This
577    /// matches the tch policy's contract (the tch
578    /// `evaluate_actions` returns a scalar mean; the trainer reduces
579    /// per-row entropy on the Burn path inside
580    /// [`crate::train::ppo::trainer::PPOTrainerBurn::train_step`]).
581    pub fn evaluate_actions(
582        &self,
583        obs: Tensor<B, 2>,
584        actions: Tensor<B, 1, Int>,
585    ) -> (Tensor<B, 1>, Tensor<B, 1>, Tensor<B, 1>) {
586        let (logits, value) = self.forward(obs);
587        let log_probs = activation::log_softmax(logits, 1);
588        let probs = log_probs.clone().exp();
589
590        let action_log_probs =
591            log_probs.clone().gather(1, actions.unsqueeze_dim::<2>(1)).squeeze_dim::<1>(1);
592        // H = -Σ p * log p over the action axis.
593        let entropy = -(probs * log_probs).sum_dim(1).squeeze_dim::<1>(1);
594
595        (action_log_probs, entropy, value)
596    }
597}
598
599#[cfg(test)]
600mod tests {
601    use burn::backend::{Autodiff, NdArray};
602
603    use super::*;
604
605    type B = Autodiff<NdArray<f32>>;
606
607    #[test]
608    fn test_policy_creation_default() {
609        let device = Default::default();
610        let _policy = MlpBurnPolicy::<B>::new(4, 2, 64, &device);
611    }
612
613    #[test]
614    fn test_with_config_two_layer() {
615        let device = Default::default();
616        let cfg = MlpBurnConfig::default();
617        let policy = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
618        assert!(policy.fc3.is_none());
619    }
620
621    #[test]
622    fn test_with_config_three_layer() {
623        let device = Default::default();
624        let cfg = MlpBurnConfig { num_layers: 3, ..Default::default() };
625        let policy = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
626        assert!(policy.fc3.is_some());
627    }
628
629    #[test]
630    fn test_forward_pass_two_layer() {
631        let device = Default::default();
632        let cfg = MlpBurnConfig::default();
633        let policy = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
634        let obs = Tensor::<B, 2>::zeros([8, 4], &device);
635        let (logits, values) = policy.forward(obs);
636        assert_eq!(logits.dims(), [8, 2]);
637        assert_eq!(values.dims(), [8]);
638    }
639
640    #[test]
641    fn test_forward_pass_three_layer() {
642        let device = Default::default();
643        let cfg = MlpBurnConfig { num_layers: 3, ..Default::default() };
644        let policy = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
645        let obs = Tensor::<B, 2>::zeros([8, 4], &device);
646        let (logits, values) = policy.forward(obs);
647        assert_eq!(logits.dims(), [8, 2]);
648        assert_eq!(values.dims(), [8]);
649    }
650
651    #[test]
652    fn test_evaluate_actions_shapes() {
653        let device = Default::default();
654        let policy = MlpBurnPolicy::<B>::with_config(4, 2, MlpBurnConfig::default(), &device);
655        let obs = Tensor::<B, 2>::zeros([8, 4], &device);
656        let actions = Tensor::<B, 1, Int>::from_data(
657            burn::tensor::TensorData::new(vec![0i64, 1, 0, 1, 0, 1, 0, 1], [8]),
658            &device,
659        );
660        let (log_probs, entropy, values) = policy.evaluate_actions(obs, actions);
661        assert_eq!(log_probs.dims(), [8]);
662        assert_eq!(entropy.dims(), [8]);
663        assert_eq!(values.dims(), [8]);
664    }
665
666    #[test]
667    fn test_relu_activation_branch() {
668        let device = Default::default();
669        let cfg = MlpBurnConfig {
670            activation: BurnActivation::ReLU,
671            use_orthogonal_init: false,
672            ..Default::default()
673        };
674        let policy = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
675        let obs = Tensor::<B, 2>::zeros([2, 4], &device);
676        let (logits, _values) = policy.forward(obs);
677        assert_eq!(logits.dims(), [2, 2]);
678    }
679
680    /// Bit-exact reproducibility of [`MlpBurnPolicy::get_action_host_seeded`]
681    /// across same-seeded `StdRng` invocations.
682    ///
683    /// This is the load-bearing guarantee for `PsroConfig::seed` /
684    /// `NfspConfig::seed` after issue #114: two
685    /// `get_action_host_seeded` calls with the same `obs`, same policy
686    /// state, and same-seeded RNG must produce element-wise identical
687    /// `(actions, log_probs, values)`. The PSRO/NFSP integration
688    /// tests (`tests/test_psro_matching_pennies.rs` and
689    /// `tests/test_nfsp_matching_pennies.rs`) build their bit-exact
690    /// reproducibility chain on this primitive.
691    #[test]
692    fn test_get_action_host_seeded_is_bit_exact() {
693        use rand::{SeedableRng, rngs::StdRng};
694
695        let device = Default::default();
696        let policy = MlpBurnPolicy::<B>::with_config(4, 3, MlpBurnConfig::default(), &device);
697
698        // Two-row batch so we exercise the per-row loop body.
699        let obs_data = vec![0.1_f32, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8];
700        let obs_a = Tensor::<B, 2>::from_data(
701            burn::tensor::TensorData::new(obs_data.clone(), [2, 4]),
702            &device,
703        );
704        let obs_b =
705            Tensor::<B, 2>::from_data(burn::tensor::TensorData::new(obs_data, [2, 4]), &device);
706
707        // Same seed → bit-identical output.
708        let mut rng_a = StdRng::seed_from_u64(42);
709        let mut rng_b = StdRng::seed_from_u64(42);
710        let (a_a, lp_a, v_a) = policy.get_action_host_seeded(obs_a, &mut rng_a);
711        let (a_b, lp_b, v_b) = policy.get_action_host_seeded(obs_b, &mut rng_b);
712        assert_eq!(a_a, a_b, "same-seed actions must be bit-identical");
713        assert_eq!(lp_a, lp_b, "same-seed log_probs must be bit-identical");
714        assert_eq!(v_a, v_b, "same-seed values must be bit-identical");
715
716        // Different seed → at least one row's action should differ
717        // (modulo the unlikely event of identical samples — for 3
718        // actions, P(both rows match) = 1/9 in expectation under
719        // uniform logits; we use orthogonal init which doesn't
720        // produce uniform logits, so the probability is even lower).
721        let obs_c = Tensor::<B, 2>::from_data(
722            burn::tensor::TensorData::new(vec![0.1_f32, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8], [2, 4]),
723            &device,
724        );
725        let mut rng_c = StdRng::seed_from_u64(99);
726        let (a_c, _, _) = policy.get_action_host_seeded(obs_c, &mut rng_c);
727        // We can't assert hard inequality (low-but-nonzero probability
728        // of accidental match) — but at least the call must succeed
729        // and produce a 2-row response.
730        assert_eq!(a_c.len(), 2, "two-row batch returns two actions");
731    }
732
733    /// Flatten every weight + bias of a policy into one comparison
734    /// vector (test helper for the bit-identity assertions below).
735    fn collect_params(p: &MlpBurnPolicy<B>) -> Vec<f32> {
736        let mut out = Vec::new();
737        let mut push = |lin: &Linear<B>| {
738            out.extend::<Vec<f32>>(lin.weight.val().into_data().to_vec().unwrap());
739            if let Some(b) = &lin.bias {
740                out.extend::<Vec<f32>>(b.val().into_data().to_vec().unwrap());
741            }
742        };
743        push(&p.fc1);
744        push(&p.fc2);
745        if let Some(fc3) = &p.fc3 {
746            push(fc3);
747        }
748        push(&p.policy_head);
749        push(&p.value_head);
750        out
751    }
752
753    /// Two seeded constructions (orthogonal init) with the same seed
754    /// produce bit-identical weights; a different seed differs. This is
755    /// the core guarantee behind end-to-end `PsroConfig::seed` /
756    /// `NfspConfig::seed` reproducibility (issue #135).
757    #[test]
758    fn test_with_seed_is_bit_identical_orthogonal() {
759        let device = Default::default();
760        let cfg = MlpBurnConfig { num_layers: 3, ..Default::default() }.with_seed(42);
761        let a = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
762        let b = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
763        assert_eq!(collect_params(&a), collect_params(&b), "same seed must be bit-identical");
764
765        let cfg_diff = MlpBurnConfig { num_layers: 3, ..Default::default() }.with_seed(43);
766        let c = MlpBurnPolicy::<B>::with_config(4, 2, cfg_diff, &device);
767        assert_ne!(collect_params(&a), collect_params(&c), "different seed must differ");
768    }
769
770    /// Same as above but for the Kaiming-uniform path (`new_seeded`
771    /// uses `use_orthogonal_init = false`). This is the path the
772    /// matching-pennies tests exercise (issue #135, Correction 2).
773    #[test]
774    fn test_new_seeded_is_bit_identical_kaiming() {
775        let device = Default::default();
776        let a = MlpBurnPolicy::<B>::new_seeded(4, 2, 16, 7, &device);
777        let b = MlpBurnPolicy::<B>::new_seeded(4, 2, 16, 7, &device);
778        assert_eq!(collect_params(&a), collect_params(&b), "same seed must be bit-identical");
779        let c = MlpBurnPolicy::<B>::new_seeded(4, 2, 16, 8, &device);
780        assert_ne!(collect_params(&a), collect_params(&c), "different seed must differ");
781    }
782
783    /// Distinct layers within one policy must not share weights even
784    /// when they have the same shape (the per-layer seed derivation
785    /// must decorrelate them). fc2 and fc3 are both
786    /// `[hidden, hidden]`; assert they differ.
787    #[test]
788    fn test_seeded_layers_are_decorrelated() {
789        let device = Default::default();
790        let cfg = MlpBurnConfig { num_layers: 3, hidden_dim: 8, ..Default::default() }.with_seed(1);
791        let p = MlpBurnPolicy::<B>::with_config(8, 2, cfg, &device);
792        let fc2: Vec<f32> = p.fc2.weight.val().into_data().to_vec().unwrap();
793        let fc3: Vec<f32> = p.fc3.as_ref().unwrap().weight.val().into_data().to_vec().unwrap();
794        assert_ne!(fc2, fc3, "same-shape trunk layers must get distinct seeded weights");
795    }
796
797    /// The unseeded path (`seed: None`) is unchanged — two
798    /// constructions are *not* required to match (Burn's unseeded
799    /// init), and the seeded path must not accidentally fire. We just
800    /// assert construction succeeds and produces the right shapes, i.e.
801    /// the `None` branch is still wired to Burn's `Initializer`.
802    #[test]
803    fn test_unseeded_path_still_constructs() {
804        let device = Default::default();
805        let cfg = MlpBurnConfig::default(); // seed: None
806        assert!(cfg.seed.is_none());
807        let p = MlpBurnPolicy::<B>::with_config(4, 2, cfg, &device);
808        let obs = Tensor::<B, 2>::zeros([3, 4], &device);
809        let (logits, values) = p.forward(obs);
810        assert_eq!(logits.dims(), [3, 2]);
811        assert_eq!(values.dims(), [3]);
812    }
813}