Skip to main content

thrust_rl/inference/
snake.rs

1//! Snake CNN inference for WASM
2//!
3//! Pure Rust implementation of Snake CNN forward pass. Stays off the Burn
4//! tensor stack used on the training side so the WASM bundle stays small;
5//! see `crate::inference` module docs and `docs/WASM_ROADMAP.md`.
6
7use serde::{Deserialize, Serialize};
8
9/// A serializable CNN model for Snake inference
10///
11/// This struct contains all the weights and biases needed to run
12/// CNN inference in pure Rust (no Burn/tensor-stack dependency).
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SnakeCNNInference {
15    /// Grid width
16    pub grid_width: usize,
17    /// Grid height
18    pub grid_height: usize,
19    /// Number of input channels (should be 5 for Snake)
20    pub input_channels: usize,
21    /// Number of actions (should be 4 for Snake)
22    pub num_actions: usize,
23
24    /// Conv1 kernel weights, shape `[out=32, in=input_channels, kh=3, kw=3]`.
25    /// Applied first in [`SnakeCNNInference::forward`] with `padding=1`.
26    pub conv1_weight: Vec<Vec<Vec<Vec<f32>>>>,
27    /// Conv1 per-output-channel bias, shape `[32]`.
28    pub conv1_bias: Vec<f32>,
29
30    /// Conv2 kernel weights, shape `[out=64, in=32, kh=3, kw=3]`. Applied
31    /// after Conv1 + ReLU with `padding=1`.
32    pub conv2_weight: Vec<Vec<Vec<Vec<f32>>>>,
33    /// Conv2 per-output-channel bias, shape `[64]`.
34    pub conv2_bias: Vec<f32>,
35
36    /// Conv3 kernel weights, shape `[out=64, in=64, kh=3, kw=3]`. Applied
37    /// after Conv2 + ReLU with `padding=1`.
38    pub conv3_weight: Vec<Vec<Vec<Vec<f32>>>>,
39    /// Conv3 per-output-channel bias, shape `[64]`.
40    pub conv3_bias: Vec<f32>,
41
42    /// Shared fully-connected weights, shape
43    /// `[256, 64 * grid_width * grid_height]`, applied to the flattened
44    /// Conv3 output.
45    pub fc_common_weight: Vec<Vec<f32>>,
46    /// Shared fully-connected bias, shape `[256]`.
47    pub fc_common_bias: Vec<f32>,
48
49    /// Policy head weights, shape `[num_actions, 256]`. Output is raw logits
50    /// (no softmax applied here — callers do that in
51    /// [`SnakeCNNInference::get_action`] or externally).
52    pub fc_policy_weight: Vec<Vec<f32>>,
53    /// Policy head bias, shape `[num_actions]`.
54    pub fc_policy_bias: Vec<f32>,
55
56    /// Value head weights, shape `[1, 256]`. Produces a scalar state-value
57    /// estimate (no activation).
58    pub fc_value_weight: Vec<Vec<f32>>,
59    /// Value head bias, shape `[1]`.
60    pub fc_value_bias: Vec<f32>,
61
62    /// Optional training metadata
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub metadata: Option<crate::policy::inference::TrainingMetadata>,
65}
66
67impl SnakeCNNInference {
68    /// Apply 2D convolution with padding=1
69    // Indexed loops mirror the [out_c][h][w][in_c][kh][kw] tensor layout and the
70    // padded neighbor arithmetic; rewriting as enumerate() iterators would obscure
71    // the spatial indexing rather than clarify it.
72    #[allow(clippy::needless_range_loop)]
73    fn conv2d(
74        &self,
75        input: &[Vec<Vec<f32>>],       // [in_channels, height, width]
76        weight: &[Vec<Vec<Vec<f32>>>], // [out_channels, in_channels, 3, 3]
77        bias: &[f32],
78    ) -> Vec<Vec<Vec<f32>>> {
79        let out_channels = weight.len();
80        let in_channels = input.len();
81        let height = input[0].len();
82        let width = input[0][0].len();
83
84        let mut output = vec![vec![vec![0.0; width]; height]; out_channels];
85
86        for out_c in 0..out_channels {
87            for h in 0..height {
88                for w in 0..width {
89                    let mut sum = bias[out_c];
90
91                    // 3x3 convolution with padding=1
92                    for in_c in 0..in_channels {
93                        for kh in 0..3 {
94                            for kw in 0..3 {
95                                let ih = h as i32 + kh as i32 - 1;
96                                let iw = w as i32 + kw as i32 - 1;
97
98                                if ih >= 0 && ih < height as i32 && iw >= 0 && iw < width as i32 {
99                                    sum += input[in_c][ih as usize][iw as usize]
100                                        * weight[out_c][in_c][kh][kw];
101                                }
102                            }
103                        }
104                    }
105
106                    output[out_c][h][w] = sum;
107                }
108            }
109        }
110
111        output
112    }
113
114    /// Apply ReLU activation
115    fn relu(&self, input: &mut [Vec<Vec<f32>>]) {
116        for channel in input.iter_mut() {
117            for row in channel.iter_mut() {
118                for val in row.iter_mut() {
119                    if *val < 0.0 {
120                        *val = 0.0;
121                    }
122                }
123            }
124        }
125    }
126
127    /// Forward pass: compute action logits and value
128    ///
129    /// # Arguments
130    /// * `grid` - Input grid [channels, height, width] flattened as
131    ///   [c0_pixels..., c1_pixels..., ...]
132    ///
133    /// # Returns
134    /// * `(logits, value)` - Action logits `[num_actions]` and state value
135    ///   (scalar)
136    // The [c][h][w] reshape loop computes a flat index from three counters;
137    // enumerate() cannot express that arithmetic cleanly.
138    #[allow(clippy::needless_range_loop)]
139    pub fn forward(&self, grid: &[f32]) -> (Vec<f32>, f32) {
140        let grid_size = self.grid_width * self.grid_height;
141        assert_eq!(grid.len(), self.input_channels * grid_size);
142
143        // Reshape input to [channels, height, width]
144        let mut input =
145            vec![vec![vec![0.0; self.grid_width]; self.grid_height]; self.input_channels];
146        for c in 0..self.input_channels {
147            for h in 0..self.grid_height {
148                for w in 0..self.grid_width {
149                    let idx = c * grid_size + h * self.grid_width + w;
150                    input[c][h][w] = grid[idx];
151                }
152            }
153        }
154
155        // Conv1 + ReLU
156        let mut x = self.conv2d(&input, &self.conv1_weight, &self.conv1_bias);
157        self.relu(&mut x);
158
159        // Conv2 + ReLU
160        x = self.conv2d(&x, &self.conv2_weight, &self.conv2_bias);
161        self.relu(&mut x);
162
163        // Conv3 + ReLU
164        x = self.conv2d(&x, &self.conv3_weight, &self.conv3_bias);
165        self.relu(&mut x);
166
167        // Global average pooling: collapse each channel to its spatial mean.
168        // Mirrors the training model's adaptive_avg_pool2d([1,1]).
169        let num_conv3_out = self.conv3_weight.len();
170        let spatial_size = (self.grid_height * self.grid_width) as f32;
171        let mut flattened = Vec::with_capacity(num_conv3_out);
172        for channel in &x {
173            let sum: f32 = channel.iter().flat_map(|row| row.iter().copied()).sum();
174            flattened.push(sum / spatial_size);
175        }
176
177        // FC common + ReLU — derive hidden size from actual weights
178        let fc_hidden = self.fc_common_weight.len();
179        let mut features = vec![0.0; fc_hidden];
180        for (i, row) in self.fc_common_weight.iter().enumerate() {
181            for (j, &val) in flattened.iter().enumerate() {
182                features[i] += row[j] * val;
183            }
184            features[i] += self.fc_common_bias[i];
185            if features[i] < 0.0 {
186                features[i] = 0.0;
187            }
188        }
189
190        // Policy head
191        let mut logits = vec![0.0; self.num_actions];
192        for (i, row) in self.fc_policy_weight.iter().enumerate() {
193            for (j, &val) in features.iter().enumerate() {
194                logits[i] += row[j] * val;
195            }
196            logits[i] += self.fc_policy_bias[i];
197        }
198
199        // Value head
200        let mut value = 0.0;
201        for (j, &val) in features.iter().enumerate() {
202            value += self.fc_value_weight[0][j] * val;
203        }
204        value += self.fc_value_bias[0];
205
206        (logits, value)
207    }
208
209    /// Get action from grid using argmax (deterministic)
210    pub fn get_action(&self, grid: &[f32]) -> usize {
211        let (logits, _value) = self.forward(grid);
212
213        logits
214            .iter()
215            .enumerate()
216            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
217            .map(|(idx, _)| idx)
218            .unwrap()
219    }
220
221    /// Save model to JSON file
222    pub fn save_json(&self, path: &str) -> anyhow::Result<()> {
223        let json = serde_json::to_string_pretty(self)?;
224        std::fs::write(path, json)?;
225        Ok(())
226    }
227
228    /// Load model from JSON file
229    pub fn load_json(path: &str) -> anyhow::Result<Self> {
230        let json = std::fs::read_to_string(path)?;
231        let model = serde_json::from_str(&json)?;
232        Ok(model)
233    }
234}