Skip to content

Commit aa40518

Browse files
committed
feat(mlp): add Multi-Layer Perceptron for learned routing
Implements simple feedforward neural network: - MLP with configurable hidden layers - Xavier weight initialization - ReLU activation for hidden layers - Softmax and argmax utilities - Basic gradient descent training (placeholder) - 9 comprehensive tests Architecture: Input → Hidden Layers (ReLU) → Output Example: 384-dim embedding → [100, 50] hidden → 3 outputs (Local/Remote/Hybrid) Future integration: - Replace heuristic router with trained MLP - Learn from user feedback (which routing was correct) - Collect training data: (query features, user-corrected route) - Train offline, deploy weights Current: Scaffolding for Phase 3 ML-based routing
1 parent 9692bfd commit aa40518

2 files changed

Lines changed: 275 additions & 0 deletions

File tree

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
pub mod context;
3030
pub mod expert;
31+
pub mod mlp;
3132
pub mod orchestrator;
3233
pub mod reservoir;
3334
pub mod router;

src/mlp.rs

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
//! Multi-Layer Perceptron for router decision making
2+
//!
3+
//! Simple feedforward neural network for query routing.
4+
//! Replaces heuristic-based routing with learned patterns.
5+
6+
#![forbid(unsafe_code)]
7+
8+
use serde::{Deserialize, Serialize};
9+
10+
/// Simple Multi-Layer Perceptron
11+
#[derive(Debug, Clone, Serialize, Deserialize)]
12+
pub struct MLP {
13+
/// Input layer size
14+
input_size: usize,
15+
/// Hidden layer sizes
16+
hidden_sizes: Vec<usize>,
17+
/// Output layer size
18+
output_size: usize,
19+
/// Weights for each layer
20+
weights: Vec<Vec<Vec<f32>>>,
21+
/// Biases for each layer
22+
biases: Vec<Vec<f32>>,
23+
}
24+
25+
impl MLP {
26+
/// Create a new MLP with random initialization
27+
///
28+
/// # Arguments
29+
///
30+
/// * `input_size` - Size of input vector
31+
/// * `hidden_sizes` - Sizes of hidden layers
32+
/// * `output_size` - Size of output vector
33+
///
34+
/// # Examples
35+
///
36+
/// ```
37+
/// use mobile_ai_orchestrator::mlp::MLP;
38+
///
39+
/// // Create MLP: 10 inputs → 50 hidden → 20 hidden → 3 outputs
40+
/// let mlp = MLP::new(10, vec![50, 20], 3);
41+
/// ```
42+
pub fn new(input_size: usize, hidden_sizes: Vec<usize>, output_size: usize) -> Self {
43+
let mut mlp = Self {
44+
input_size,
45+
hidden_sizes: hidden_sizes.clone(),
46+
output_size,
47+
weights: Vec::new(),
48+
biases: Vec::new(),
49+
};
50+
51+
// Initialize layers
52+
let mut layer_sizes = vec![input_size];
53+
layer_sizes.extend(hidden_sizes);
54+
layer_sizes.push(output_size);
55+
56+
// Xavier initialization for weights
57+
let mut seed = 123u64;
58+
for i in 0..layer_sizes.len() - 1 {
59+
let rows = layer_sizes[i + 1];
60+
let cols = layer_sizes[i];
61+
62+
let mut layer_weights = vec![vec![0.0; cols]; rows];
63+
let scale = (2.0 / cols as f32).sqrt();
64+
65+
for row in &mut layer_weights {
66+
for weight in row {
67+
seed = seed.wrapping_mul(1103515245).wrapping_add(12345);
68+
let rand = ((seed / 65536) % 32768) as f32 / 32768.0;
69+
*weight = (rand - 0.5) * 2.0 * scale;
70+
}
71+
}
72+
73+
mlp.weights.push(layer_weights);
74+
mlp.biases.push(vec![0.0; rows]);
75+
}
76+
77+
mlp
78+
}
79+
80+
/// Forward pass through the network
81+
///
82+
/// # Arguments
83+
///
84+
/// * `input` - Input vector
85+
///
86+
/// # Returns
87+
///
88+
/// Output vector after forward pass
89+
///
90+
/// # Panics
91+
///
92+
/// Panics if input size doesn't match network input size
93+
pub fn forward(&self, input: &[f32]) -> Vec<f32> {
94+
assert_eq!(
95+
input.len(),
96+
self.input_size,
97+
"Input size mismatch: expected {}, got {}",
98+
self.input_size,
99+
input.len()
100+
);
101+
102+
let mut activation = input.to_vec();
103+
104+
// Forward through each layer
105+
for (layer_weights, layer_biases) in self.weights.iter().zip(&self.biases) {
106+
let mut next_activation = vec![0.0; layer_weights.len()];
107+
108+
for (i, (weights_row, bias)) in layer_weights.iter().zip(layer_biases).enumerate() {
109+
let mut sum = *bias;
110+
for (w, a) in weights_row.iter().zip(&activation) {
111+
sum += w * a;
112+
}
113+
next_activation[i] = sum;
114+
}
115+
116+
// ReLU activation for hidden layers, no activation for output
117+
if layer_weights.len() != self.output_size {
118+
for a in &mut next_activation {
119+
*a = a.max(0.0); // ReLU
120+
}
121+
}
122+
123+
activation = next_activation;
124+
}
125+
126+
activation
127+
}
128+
129+
/// Softmax activation for output layer
130+
///
131+
/// Converts raw scores to probabilities
132+
pub fn softmax(values: &[f32]) -> Vec<f32> {
133+
let max = values.iter().copied().fold(f32::NEG_INFINITY, f32::max);
134+
let exps: Vec<f32> = values.iter().map(|&v| (v - max).exp()).collect();
135+
let sum: f32 = exps.iter().sum();
136+
exps.iter().map(|&e| e / sum).collect()
137+
}
138+
139+
/// Get the index of the maximum value
140+
pub fn argmax(values: &[f32]) -> usize {
141+
values
142+
.iter()
143+
.enumerate()
144+
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
145+
.map(|(idx, _)| idx)
146+
.unwrap_or(0)
147+
}
148+
149+
/// Simple training via gradient descent (very basic)
150+
///
151+
/// This is a placeholder - in production use a proper training framework
152+
/// like `tch-rs` (PyTorch bindings) or `burn` (pure Rust)
153+
pub fn train_step(
154+
&mut self,
155+
input: &[f32],
156+
target: &[f32],
157+
learning_rate: f32,
158+
) -> f32 {
159+
// Forward pass
160+
let output = self.forward(input);
161+
162+
// Compute MSE loss
163+
let loss: f32 = output
164+
.iter()
165+
.zip(target)
166+
.map(|(o, t)| (o - t).powi(2))
167+
.sum::<f32>()
168+
/ output.len() as f32;
169+
170+
// Simplified gradient descent (not proper backprop)
171+
// In production: use automatic differentiation
172+
for layer_weights in &mut self.weights {
173+
for row in layer_weights {
174+
for weight in row {
175+
*weight *= 0.9999; // Simple weight decay
176+
}
177+
}
178+
}
179+
180+
loss
181+
}
182+
183+
/// Get input size
184+
pub fn input_size(&self) -> usize {
185+
self.input_size
186+
}
187+
188+
/// Get output size
189+
pub fn output_size(&self) -> usize {
190+
self.output_size
191+
}
192+
}
193+
194+
#[cfg(test)]
195+
mod tests {
196+
use super::*;
197+
198+
#[test]
199+
fn test_mlp_creation() {
200+
let mlp = MLP::new(10, vec![20], 3);
201+
assert_eq!(mlp.input_size(), 10);
202+
assert_eq!(mlp.output_size(), 3);
203+
}
204+
205+
#[test]
206+
fn test_mlp_forward() {
207+
let mlp = MLP::new(10, vec![20], 3);
208+
let input = vec![1.0; 10];
209+
let output = mlp.forward(&input);
210+
assert_eq!(output.len(), 3);
211+
}
212+
213+
#[test]
214+
fn test_softmax() {
215+
let values = vec![1.0, 2.0, 3.0];
216+
let probs = MLP::softmax(&values);
217+
218+
// Should sum to 1.0
219+
let sum: f32 = probs.iter().sum();
220+
assert!((sum - 1.0).abs() < 1e-6);
221+
222+
// Should be monotonic (higher input → higher prob)
223+
assert!(probs[2] > probs[1]);
224+
assert!(probs[1] > probs[0]);
225+
}
226+
227+
#[test]
228+
fn test_argmax() {
229+
let values = vec![0.1, 0.8, 0.3];
230+
assert_eq!(MLP::argmax(&values), 1);
231+
232+
let values2 = vec![5.0, 2.0, 1.0];
233+
assert_eq!(MLP::argmax(&values2), 0);
234+
}
235+
236+
#[test]
237+
fn test_mlp_train_step() {
238+
let mut mlp = MLP::new(5, vec![10], 2);
239+
let input = vec![1.0; 5];
240+
let target = vec![0.0, 1.0];
241+
242+
let loss = mlp.train_step(&input, &target, 0.01);
243+
assert!(loss >= 0.0);
244+
}
245+
246+
#[test]
247+
#[should_panic(expected = "Input size mismatch")]
248+
fn test_mlp_forward_wrong_size() {
249+
let mlp = MLP::new(10, vec![20], 3);
250+
let wrong_input = vec![1.0; 5];
251+
mlp.forward(&wrong_input);
252+
}
253+
254+
#[test]
255+
fn test_mlp_serialization() {
256+
let mlp = MLP::new(10, vec![20], 3);
257+
let json = serde_json::to_string(&mlp).unwrap();
258+
let deserialized: MLP = serde_json::from_str(&json).unwrap();
259+
260+
assert_eq!(mlp.input_size, deserialized.input_size);
261+
assert_eq!(mlp.output_size, deserialized.output_size);
262+
}
263+
264+
#[test]
265+
fn test_mlp_multi_layer() {
266+
let mlp = MLP::new(10, vec![50, 30, 20], 3);
267+
assert_eq!(mlp.weights.len(), 4); // 3 hidden + 1 output
268+
assert_eq!(mlp.biases.len(), 4);
269+
270+
let input = vec![0.5; 10];
271+
let output = mlp.forward(&input);
272+
assert_eq!(output.len(), 3);
273+
}
274+
}

0 commit comments

Comments
 (0)