Skip to main content

thrust_rl/inference/
mod.rs

1//! Pure Rust inference module for WASM compatibility
2//!
3//! This module provides a lightweight neural network inference engine
4//! that can be compiled to WebAssembly. It deliberately avoids the Burn
5//! tensor stack used on the training side (see `src/train/`, `src/policy/`)
6//! so the WASM bundle stays small — Burn's WebGPU backend has a
7//! non-trivial WASM bundle-size cost on a pre-1.0 backend. See
8//! `docs/WASM_ROADMAP.md` and Burn migration epic #65 (phase 6 closure) for
9//! the rationale.
10//!
11//! Forward pass only — no autodiff, no training.
12
13pub mod nn;
14pub mod snake;
15pub mod weights;
16
17use serde::{Deserialize, Serialize};
18
19/// Exported model format for WASM inference
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct ExportedModel {
22    /// Input dimension
23    pub input_dim: usize,
24    /// Output dimension (number of actions)
25    pub output_dim: usize,
26    /// Hidden layer dimension
27    pub hidden_dim: usize,
28    /// Feature extractor weights and biases
29    pub feature_extractor: LayerWeights,
30    /// Policy head weights and biases
31    pub policy_head: LayerWeights,
32    /// Value head weights and biases (optional, not needed for inference)
33    pub value_head: Option<LayerWeights>,
34}
35
36/// Weights and biases for a single layer
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct LayerWeights {
39    /// Weight matrix (flattened, row-major)
40    pub weights: Vec<f32>,
41    /// Bias vector
42    pub biases: Vec<f32>,
43    /// Input dimension
44    pub in_features: usize,
45    /// Output dimension
46    pub out_features: usize,
47}
48
49impl ExportedModel {
50    /// Create a new exported model
51    pub fn new(
52        input_dim: usize,
53        output_dim: usize,
54        hidden_dim: usize,
55        feature_extractor: LayerWeights,
56        policy_head: LayerWeights,
57        value_head: Option<LayerWeights>,
58    ) -> Self {
59        Self { input_dim, output_dim, hidden_dim, feature_extractor, policy_head, value_head }
60    }
61
62    /// Run inference on the model
63    pub fn predict(&self, observation: &[f32]) -> Vec<f32> {
64        assert_eq!(observation.len(), self.input_dim, "Input dimension mismatch");
65
66        // Feature extraction with ReLU
67        let features = self.feature_extractor.forward(observation);
68        let features_relu: Vec<f32> = features.iter().map(|&x| x.max(0.0)).collect();
69
70        // Policy head
71        let logits = self.policy_head.forward(&features_relu);
72
73        // Softmax for action probabilities
74        softmax(&logits)
75    }
76
77    /// Get the best action (greedy policy)
78    pub fn get_action(&self, observation: &[f32]) -> usize {
79        let probs = self.predict(observation);
80        probs
81            .iter()
82            .enumerate()
83            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
84            .map(|(idx, _)| idx)
85            .unwrap()
86    }
87
88    /// Get action probabilities
89    pub fn get_action_probs(&self, observation: &[f32]) -> Vec<f32> {
90        self.predict(observation)
91    }
92}
93
94impl LayerWeights {
95    /// Create new layer weights
96    pub fn new(
97        weights: Vec<f32>,
98        biases: Vec<f32>,
99        in_features: usize,
100        out_features: usize,
101    ) -> Self {
102        assert_eq!(weights.len(), in_features * out_features, "Weight matrix size mismatch");
103        assert_eq!(biases.len(), out_features, "Bias vector size mismatch");
104
105        Self { weights, biases, in_features, out_features }
106    }
107
108    /// Forward pass through a linear layer
109    // The matmul uses row-major flat indexing `weights[i * in_features + j]`,
110    // which needs both counters; enumerate() cannot express it cleanly.
111    #[allow(clippy::needless_range_loop)]
112    pub fn forward(&self, input: &[f32]) -> Vec<f32> {
113        assert_eq!(input.len(), self.in_features, "Input size mismatch");
114
115        let mut output = vec![0.0; self.out_features];
116
117        for i in 0..self.out_features {
118            let mut sum = self.biases[i];
119            for j in 0..self.in_features {
120                // Row-major indexing: weights[i * in_features + j]
121                sum += self.weights[i * self.in_features + j] * input[j];
122            }
123            output[i] = sum;
124        }
125
126        output
127    }
128}
129
130/// Softmax activation function
131fn softmax(logits: &[f32]) -> Vec<f32> {
132    // Subtract max for numerical stability
133    let max_logit = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
134    let exps: Vec<f32> = logits.iter().map(|&x| (x - max_logit).exp()).collect();
135    let sum: f32 = exps.iter().sum();
136    exps.iter().map(|&x| x / sum).collect()
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn test_layer_forward() {
145        // 2D input -> 3D output
146        let weights = vec![
147            1.0, 2.0, // First output neuron
148            3.0, 4.0, // Second output neuron
149            5.0, 6.0, // Third output neuron
150        ];
151        let biases = vec![0.1, 0.2, 0.3];
152        let layer = LayerWeights::new(weights, biases, 2, 3);
153
154        let input = vec![1.0, 2.0];
155        let output = layer.forward(&input);
156
157        // Expected: [1*1 + 2*2 + 0.1, 1*3 + 2*4 + 0.2, 1*5 + 2*6 + 0.3]
158        //         = [5.1, 11.2, 17.3]
159        assert!((output[0] - 5.1).abs() < 1e-5);
160        assert!((output[1] - 11.2).abs() < 1e-5);
161        assert!((output[2] - 17.3).abs() < 1e-5);
162    }
163
164    #[test]
165    fn test_softmax() {
166        let logits = vec![1.0, 2.0, 3.0];
167        let probs = softmax(&logits);
168
169        // Should sum to 1
170        let sum: f32 = probs.iter().sum();
171        assert!((sum - 1.0).abs() < 1e-5);
172
173        // Should be in ascending order (higher logit -> higher prob)
174        assert!(probs[0] < probs[1]);
175        assert!(probs[1] < probs[2]);
176    }
177
178    #[test]
179    fn test_exported_model() {
180        // Simple 2-2-2 network
181        let feature_weights = LayerWeights::new(
182            vec![1.0, 0.0, 0.0, 1.0], // Identity-like
183            vec![0.0, 0.0],
184            2,
185            2,
186        );
187        let policy_weights = LayerWeights::new(
188            vec![1.0, -1.0, -1.0, 1.0], // Swap with negation
189            vec![0.0, 0.0],
190            2,
191            2,
192        );
193
194        let model = ExportedModel::new(2, 2, 2, feature_weights, policy_weights, None);
195
196        let obs = vec![1.0, 0.5];
197        let action = model.get_action(&obs);
198
199        // Should return valid action (0 or 1)
200        assert!(action < 2);
201    }
202}