Skip to main content

thrust_rl/policy/
inference.rs

1//! Inference-only model format for WASM deployment
2//!
3//! This module provides a pure Rust implementation of neural network inference
4//! that stays off the training-side Burn tensor stack so it can compile to
5//! WebAssembly with a minimal bundle size. The training side (PPO/DQN) runs
6//! on Burn; weights are exported as JSON and consumed here for browser
7//! inference. See `crate::inference` module docs and `docs/WASM_ROADMAP.md`.
8
9use anyhow::Result;
10use serde::{Deserialize, Serialize};
11
12/// A serializable CNN model for Snake inference
13///
14/// This struct contains all the weights and biases needed to run
15/// CNN inference in pure Rust (no Burn/tensor-stack dependency).
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SnakeCNNInference {
18    /// Grid width
19    pub grid_width: usize,
20    /// Grid height
21    pub grid_height: usize,
22    /// Number of input channels (should be 5 for Snake)
23    pub input_channels: usize,
24    /// Number of actions (should be 4 for Snake)
25    pub num_actions: usize,
26
27    /// Conv1 kernel weights, shape `[out=32, in=input_channels, kh=3, kw=3]`.
28    /// Applied first in [`SnakeCNNInference::forward`] with `padding=1`.
29    pub conv1_weight: Vec<Vec<Vec<Vec<f32>>>>,
30    /// Conv1 per-output-channel bias, shape `[32]`.
31    pub conv1_bias: Vec<f32>,
32
33    /// Conv2 kernel weights, shape `[out=64, in=32, kh=3, kw=3]`. Applied
34    /// after Conv1 + ReLU with `padding=1`.
35    pub conv2_weight: Vec<Vec<Vec<Vec<f32>>>>,
36    /// Conv2 per-output-channel bias, shape `[64]`.
37    pub conv2_bias: Vec<f32>,
38
39    /// Conv3 kernel weights, shape `[out=64, in=64, kh=3, kw=3]`. Applied
40    /// after Conv2 + ReLU with `padding=1`.
41    pub conv3_weight: Vec<Vec<Vec<Vec<f32>>>>,
42    /// Conv3 per-output-channel bias, shape `[64]`.
43    pub conv3_bias: Vec<f32>,
44
45    /// Shared fully-connected weights, shape
46    /// `[256, 64 * grid_width * grid_height]`, applied to the flattened
47    /// Conv3 output.
48    pub fc_common_weight: Vec<Vec<f32>>,
49    /// Shared fully-connected bias, shape `[256]`.
50    pub fc_common_bias: Vec<f32>,
51
52    /// Policy head weights, shape `[num_actions, 256]`. Output is raw logits
53    /// (no softmax applied here — callers do that in
54    /// [`SnakeCNNInference::get_action`] or externally).
55    pub fc_policy_weight: Vec<Vec<f32>>,
56    /// Policy head bias, shape `[num_actions]`.
57    pub fc_policy_bias: Vec<f32>,
58
59    /// Value head weights, shape `[1, 256]`. Produces a scalar state-value
60    /// estimate (no activation).
61    pub fc_value_weight: Vec<Vec<f32>>,
62    /// Value head bias, shape `[1]`.
63    pub fc_value_bias: Vec<f32>,
64
65    /// Optional training metadata
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub metadata: Option<TrainingMetadata>,
68}
69
70impl SnakeCNNInference {
71    /// Save model to JSON file
72    pub fn save_json<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
73        let json = serde_json::to_string_pretty(self)?;
74        std::fs::write(path, json)?;
75        Ok(())
76    }
77
78    /// Load model from JSON file
79    pub fn load_json<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
80        let json = std::fs::read_to_string(path)?;
81        let model = serde_json::from_str(&json)?;
82        Ok(model)
83    }
84
85    /// Apply 2D convolution with padding=1
86    // Indexed loops mirror the [out_c][h][w][in_c][kh][kw] tensor layout and the
87    // padded neighbor arithmetic; rewriting as enumerate() iterators would obscure
88    // the spatial indexing rather than clarify it.
89    #[allow(clippy::needless_range_loop)]
90    fn conv2d(
91        &self,
92        input: &[Vec<Vec<f32>>],       // [in_channels, height, width]
93        weight: &[Vec<Vec<Vec<f32>>>], // [out_channels, in_channels, 3, 3]
94        bias: &[f32],
95    ) -> Vec<Vec<Vec<f32>>> {
96        let out_channels = weight.len();
97        let in_channels = input.len();
98        let height = input[0].len();
99        let width = input[0][0].len();
100
101        let mut output = vec![vec![vec![0.0; width]; height]; out_channels];
102
103        for out_c in 0..out_channels {
104            for h in 0..height {
105                for w in 0..width {
106                    let mut sum = bias[out_c];
107
108                    // 3x3 convolution with padding=1
109                    for in_c in 0..in_channels {
110                        for kh in 0..3 {
111                            for kw in 0..3 {
112                                let ih = h as i32 + kh as i32 - 1;
113                                let iw = w as i32 + kw as i32 - 1;
114
115                                if ih >= 0 && ih < height as i32 && iw >= 0 && iw < width as i32 {
116                                    sum += input[in_c][ih as usize][iw as usize]
117                                        * weight[out_c][in_c][kh][kw];
118                                }
119                            }
120                        }
121                    }
122
123                    output[out_c][h][w] = sum;
124                }
125            }
126        }
127
128        output
129    }
130
131    /// Apply ReLU activation
132    fn relu(&self, input: &mut [Vec<Vec<f32>>]) {
133        for channel in input.iter_mut() {
134            for row in channel.iter_mut() {
135                for val in row.iter_mut() {
136                    if *val < 0.0 {
137                        *val = 0.0;
138                    }
139                }
140            }
141        }
142    }
143
144    /// Forward pass: compute action logits and value
145    ///
146    /// # Arguments
147    /// * `grid` - Input grid [channels, height, width] flattened as
148    ///   [c0_pixels..., c1_pixels..., ...]
149    ///
150    /// # Returns
151    /// * `(logits, value)` - Action logits `[num_actions]` and state value
152    ///   (scalar)
153    // The [c][h][w] reshape loop computes a flat index from three counters;
154    // enumerate() cannot express that arithmetic cleanly.
155    #[allow(clippy::needless_range_loop)]
156    pub fn forward(&self, grid: &[f32]) -> (Vec<f32>, f32) {
157        let grid_size = self.grid_width * self.grid_height;
158        assert_eq!(grid.len(), self.input_channels * grid_size);
159
160        // Reshape input to [channels, height, width]
161        let mut input =
162            vec![vec![vec![0.0; self.grid_width]; self.grid_height]; self.input_channels];
163        for c in 0..self.input_channels {
164            for h in 0..self.grid_height {
165                for w in 0..self.grid_width {
166                    let idx = c * grid_size + h * self.grid_width + w;
167                    input[c][h][w] = grid[idx];
168                }
169            }
170        }
171
172        // Conv1 + ReLU
173        let mut x = self.conv2d(&input, &self.conv1_weight, &self.conv1_bias);
174        self.relu(&mut x);
175
176        // Conv2 + ReLU
177        x = self.conv2d(&x, &self.conv2_weight, &self.conv2_bias);
178        self.relu(&mut x);
179
180        // Conv3 + ReLU
181        x = self.conv2d(&x, &self.conv3_weight, &self.conv3_bias);
182        self.relu(&mut x);
183
184        // Flatten
185        let flat_size = 64 * grid_size;
186        let mut flattened = Vec::with_capacity(flat_size);
187        for channel in &x {
188            for row in channel {
189                for &val in row {
190                    flattened.push(val);
191                }
192            }
193        }
194
195        // FC common + ReLU
196        let mut features = vec![0.0; 256];
197        for (i, row) in self.fc_common_weight.iter().enumerate() {
198            for (j, &val) in flattened.iter().enumerate() {
199                features[i] += row[j] * val;
200            }
201            features[i] += self.fc_common_bias[i];
202            if features[i] < 0.0 {
203                features[i] = 0.0;
204            }
205        }
206
207        // Policy head
208        let mut logits = vec![0.0; self.num_actions];
209        for (i, row) in self.fc_policy_weight.iter().enumerate() {
210            for (j, &val) in features.iter().enumerate() {
211                logits[i] += row[j] * val;
212            }
213            logits[i] += self.fc_policy_bias[i];
214        }
215
216        // Value head
217        let mut value = 0.0;
218        for (j, &val) in features.iter().enumerate() {
219            value += self.fc_value_weight[0][j] * val;
220        }
221        value += self.fc_value_bias[0];
222
223        (logits, value)
224    }
225
226    /// Get action from grid using argmax (deterministic)
227    pub fn get_action(&self, grid: &[f32]) -> usize {
228        let (logits, _value) = self.forward(grid);
229
230        logits
231            .iter()
232            .enumerate()
233            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
234            .map(|(idx, _)| idx)
235            .unwrap()
236    }
237}
238
239/// Activation function type for inference
240///
241/// Selected at model export time and serialized into the `.json` model file.
242/// Applied element-wise inside [`InferenceModel::forward`] on the hidden
243/// layers, hence the deliberately small variant set.
244#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
245pub enum InferenceActivation {
246    /// Rectified linear unit, `max(x, 0)`. Matches PPO's default hidden
247    /// activation when training scripts export with `--activation relu`.
248    ReLU,
249    /// Hyperbolic tangent, `x.tanh()`. Used as the implicit default when a
250    /// serialized model omits the `activation` field (see the serde
251    /// `default` attribute on [`InferenceModel::activation`]).
252    Tanh,
253}
254
255/// Training metadata for the model
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct TrainingMetadata {
258    /// Total training steps
259    pub total_steps: usize,
260    /// Total episodes
261    pub total_episodes: usize,
262    /// Final average performance (steps per episode)
263    pub final_performance: f64,
264    /// Training wall time in seconds
265    pub training_time_secs: f64,
266    /// Device used for training (CPU, CUDA, MPS)
267    pub device: String,
268    /// Environment name
269    pub environment: String,
270    /// Training algorithm
271    pub algorithm: String,
272    /// Timestamp when trained
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub timestamp: Option<String>,
275    /// Hyperparameters used for training
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub hyperparameters: Option<std::collections::HashMap<String, serde_json::Value>>,
278    /// Additional notes
279    #[serde(skip_serializing_if = "Option::is_none")]
280    pub notes: Option<String>,
281}
282
283/// A serializable MLP model for inference
284///
285/// This struct contains all the weights and biases needed to run
286/// inference in pure Rust (no Burn/tensor-stack dependency).
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct InferenceModel {
289    /// Input dimension
290    pub obs_dim: usize,
291    /// Output dimension (number of actions)
292    pub action_dim: usize,
293    /// Hidden layer dimension
294    pub hidden_dim: usize,
295    /// Activation function
296    #[serde(default = "default_activation")]
297    pub activation: InferenceActivation,
298
299    /// Training metadata (optional)
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub metadata: Option<TrainingMetadata>,
302
303    /// Shared layer 1: weights [obs_dim, hidden_dim]
304    pub shared_fc1_weight: Vec<Vec<f32>>,
305    /// Shared layer 1: bias `[hidden_dim]`
306    pub shared_fc1_bias: Vec<f32>,
307
308    /// Shared layer 2: weights [hidden_dim, hidden_dim]
309    pub shared_fc2_weight: Vec<Vec<f32>>,
310    /// Shared layer 2: bias `[hidden_dim]`
311    pub shared_fc2_bias: Vec<f32>,
312
313    /// Policy head: weights [hidden_dim, action_dim]
314    pub policy_weight: Vec<Vec<f32>>,
315    /// Policy head: bias `[action_dim]`
316    pub policy_bias: Vec<f32>,
317
318    /// Value head: weights [hidden_dim, 1]
319    pub value_weight: Vec<Vec<f32>>,
320    /// Value head: bias `[1]`
321    pub value_bias: Vec<f32>,
322}
323
324fn default_activation() -> InferenceActivation {
325    InferenceActivation::Tanh
326}
327
328impl InferenceModel {
329    /// Save model to JSON file
330    pub fn save_json<P: AsRef<std::path::Path>>(&self, path: P) -> Result<()> {
331        let json = serde_json::to_string_pretty(self)?;
332        std::fs::write(path, json)?;
333        Ok(())
334    }
335
336    /// Load model from JSON file
337    pub fn load_json<P: AsRef<std::path::Path>>(path: P) -> Result<Self> {
338        let json = std::fs::read_to_string(path)?;
339        let model = serde_json::from_str(&json)?;
340        Ok(model)
341    }
342
343    /// Apply activation function
344    #[inline]
345    fn activate(&self, x: f32) -> f32 {
346        match self.activation {
347            InferenceActivation::ReLU => {
348                if x < 0.0 {
349                    0.0
350                } else {
351                    x
352                }
353            }
354            InferenceActivation::Tanh => x.tanh(),
355        }
356    }
357
358    /// Forward pass: compute action logits and value
359    ///
360    /// # Arguments
361    /// * `obs` - Observation vector `[obs_dim]`
362    ///
363    /// # Returns
364    /// * `(logits, value)` - Action logits `[action_dim]` and state value
365    ///   (scalar)
366    pub fn forward(&self, obs: &[f32]) -> (Vec<f32>, f32) {
367        assert_eq!(obs.len(), self.obs_dim, "Observation dimension mismatch");
368
369        // Layer 1: obs -> hidden_dim
370        let mut hidden1 = vec![0.0; self.hidden_dim];
371        for (i, row) in self.shared_fc1_weight.iter().enumerate() {
372            for (j, &val) in obs.iter().enumerate() {
373                hidden1[i] += row[j] * val;
374            }
375            hidden1[i] = self.activate(hidden1[i] + self.shared_fc1_bias[i]);
376        }
377
378        // Layer 2: hidden_dim -> hidden_dim
379        let mut hidden2 = vec![0.0; self.hidden_dim];
380        for (i, row) in self.shared_fc2_weight.iter().enumerate() {
381            for (j, &val) in hidden1.iter().enumerate() {
382                hidden2[i] += row[j] * val;
383            }
384            hidden2[i] = self.activate(hidden2[i] + self.shared_fc2_bias[i]);
385        }
386
387        // Policy head: hidden_dim -> action_dim
388        let mut logits = vec![0.0; self.action_dim];
389        for (i, row) in self.policy_weight.iter().enumerate() {
390            for (j, &val) in hidden2.iter().enumerate() {
391                logits[i] += row[j] * val;
392            }
393            logits[i] += self.policy_bias[i];
394        }
395
396        // Value head: hidden_dim -> 1
397        let mut value = 0.0;
398        for row in self.value_weight.iter() {
399            for (j, &val) in hidden2.iter().enumerate() {
400                value += row[j] * val;
401            }
402        }
403        value += self.value_bias[0];
404
405        (logits, value)
406    }
407
408    /// Get action from observation using softmax sampling
409    ///
410    /// # Arguments
411    /// * `obs` - Observation vector `[obs_dim]`
412    ///
413    /// # Returns
414    /// * Action index
415    pub fn get_action(&self, obs: &[f32]) -> usize {
416        let (logits, _value) = self.forward(obs);
417
418        // Convert logits to probabilities using softmax
419        let max_logit = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max);
420        let exp_logits: Vec<f32> = logits.iter().map(|&x| (x - max_logit).exp()).collect();
421        let sum_exp: f32 = exp_logits.iter().sum();
422        let probs: Vec<f32> = exp_logits.iter().map(|&x| x / sum_exp).collect();
423
424        // For deterministic behavior (useful for demo), just take argmax
425        probs
426            .iter()
427            .enumerate()
428            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
429            .map(|(idx, _)| idx)
430            .unwrap()
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn test_forward_pass() {
440        // Create a simple model with known weights
441        let model = InferenceModel {
442            obs_dim: 2,
443            action_dim: 2,
444            hidden_dim: 4,
445            activation: InferenceActivation::Tanh,
446            metadata: None,
447            shared_fc1_weight: vec![vec![1.0, 0.0]; 4],
448            shared_fc1_bias: vec![0.0; 4],
449            shared_fc2_weight: vec![vec![1.0, 0.0, 0.0, 0.0]; 4],
450            shared_fc2_bias: vec![0.0; 4],
451            policy_weight: vec![vec![1.0, 0.0, 0.0, 0.0]; 2],
452            policy_bias: vec![0.0; 2],
453            value_weight: vec![vec![1.0, 0.0, 0.0, 0.0]],
454            value_bias: vec![0.0],
455        };
456
457        let obs = vec![1.0, 2.0];
458        let (logits, value) = model.forward(&obs);
459
460        assert_eq!(logits.len(), 2);
461        assert!(value.is_finite());
462    }
463
464    #[test]
465    fn test_get_action() {
466        let model = InferenceModel {
467            obs_dim: 2,
468            action_dim: 2,
469            hidden_dim: 4,
470            activation: InferenceActivation::ReLU,
471            metadata: None,
472            shared_fc1_weight: vec![vec![1.0, 0.0]; 4],
473            shared_fc1_bias: vec![0.0; 4],
474            shared_fc2_weight: vec![vec![1.0, 0.0, 0.0, 0.0]; 4],
475            shared_fc2_bias: vec![0.0; 4],
476            policy_weight: vec![vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 0.0, 0.0, 0.0]],
477            policy_bias: vec![0.0, 0.0],
478            value_weight: vec![vec![1.0, 0.0, 0.0, 0.0]],
479            value_bias: vec![0.0],
480        };
481
482        let obs = vec![1.0, 2.0];
483        let action = model.get_action(&obs);
484
485        assert!(action < 2);
486    }
487
488    #[test]
489    fn test_save_load_json() {
490        let model = InferenceModel {
491            obs_dim: 4,
492            action_dim: 2,
493            hidden_dim: 64,
494            activation: InferenceActivation::Tanh,
495            metadata: None,
496            shared_fc1_weight: vec![vec![0.0; 4]; 64],
497            shared_fc1_bias: vec![0.0; 64],
498            shared_fc2_weight: vec![vec![0.0; 64]; 64],
499            shared_fc2_bias: vec![0.0; 64],
500            policy_weight: vec![vec![0.0; 64]; 2],
501            policy_bias: vec![0.0; 2],
502            value_weight: vec![vec![0.0; 64]],
503            value_bias: vec![0.0],
504        };
505
506        let temp_path = "/tmp/test_inference_model.json";
507        model.save_json(temp_path).unwrap();
508
509        let loaded = InferenceModel::load_json(temp_path).unwrap();
510        assert_eq!(loaded.obs_dim, model.obs_dim);
511        assert_eq!(loaded.action_dim, model.action_dim);
512        assert_eq!(loaded.hidden_dim, model.hidden_dim);
513
514        std::fs::remove_file(temp_path).ok();
515    }
516}