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}