Skip to content

Commit a46276b

Browse files
committed
feat(router): integrate MLP for learned routing decisions
Replace heuristic-only routing with MLP-based routing: **New Features**: - MLP integration with Router struct - Automatic fallback to heuristics when MLP unavailable - Feature extraction (384-dimensional vectors): * Query length, word count, question markers * Priority and project context indicators * Complex keyword detection (5 features) * Text statistics (uppercase ratio, punctuation density) * Query type detection (how/what/why/when/where/who/can/should) * Simple bag-of-words encoding (360 features) * Normalized metadata (timestamp, debugging indicators) - Persistence integration (load/save trained MLP) - Enable/disable MLP at runtime **Routing Logic**: - Phase 1 (Heuristic): Rule-based routing (fallback) - Phase 2+ (MLP): Learned routing with confidence scores * Input: 384-dim feature vector * Output: [P(Local), P(Remote), P(Hybrid)] * Decision: argmax with confidence score **API Additions**: - `Router::with_mlp(mlp)` - Create router with MLP - `Router::set_mlp(mlp)` - Set MLP at runtime - `Router::set_use_mlp(bool)` - Toggle MLP usage - `Router::load_mlp(pm, name)` - Load from persistence - `Router::save_mlp(pm, name, accuracy)` - Save to persistence - `Router::extract_features(query)` - Feature extraction (private) **Tests Added**: - test_router_with_mlp: MLP routing functionality - test_router_mlp_fallback: Fallback behavior - test_feature_extraction: Feature vector validation - test_mlp_persistence_integration: Persistence round-trip All tests passing: 12/12 router tests Backward compatible: Existing heuristic tests still pass No breaking changes: Default behavior unchanged Stats: +200 lines, 4 new tests, all passing
1 parent e3605c5 commit a46276b

1 file changed

Lines changed: 275 additions & 14 deletions

File tree

src/router.rs

Lines changed: 275 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,157 @@
1010
//! Phase 2+: Actual MLP with learned weights
1111
1212
use crate::types::{Query, RouterConfig, RoutingDecision};
13+
use crate::mlp::MLP;
1314

1415
/// Router for determining query routing strategy
1516
#[derive(Debug, Clone)]
1617
pub struct Router {
1718
config: RouterConfig,
19+
/// Optional MLP for learned routing (Phase 2+)
20+
mlp: Option<MLP>,
21+
/// Whether to use MLP or fallback to heuristics
22+
use_mlp: bool,
1823
}
1924

2025
impl Router {
2126
/// Create a new router with default configuration
2227
pub fn new() -> Self {
2328
Self {
2429
config: RouterConfig::default(),
30+
mlp: None,
31+
use_mlp: false,
2532
}
2633
}
2734

2835
/// Create a router with custom configuration
2936
pub fn with_config(config: RouterConfig) -> Self {
30-
Self { config }
37+
Self {
38+
config,
39+
mlp: None,
40+
use_mlp: false,
41+
}
42+
}
43+
44+
/// Create a router with a trained MLP
45+
pub fn with_mlp(mlp: MLP) -> Self {
46+
Self {
47+
config: RouterConfig::default(),
48+
mlp: Some(mlp),
49+
use_mlp: true,
50+
}
51+
}
52+
53+
/// Load MLP from persistence
54+
#[cfg(feature = "persistence")]
55+
pub fn load_mlp(&mut self, pm: &crate::persistence::PersistenceManager, name: &str) -> Result<(), String> {
56+
match pm.load_mlp(name) {
57+
Ok(Some(mlp)) => {
58+
self.mlp = Some(mlp);
59+
self.use_mlp = true;
60+
Ok(())
61+
}
62+
Ok(None) => Err(format!("No MLP found with name '{}'", name)),
63+
Err(e) => Err(format!("Failed to load MLP: {}", e)),
64+
}
65+
}
66+
67+
/// Save MLP to persistence
68+
#[cfg(feature = "persistence")]
69+
pub fn save_mlp(&self, pm: &crate::persistence::PersistenceManager, name: &str, accuracy: Option<f32>) -> Result<(), String> {
70+
if let Some(ref mlp) = self.mlp {
71+
pm.save_mlp(name, mlp, accuracy)
72+
.map_err(|e| format!("Failed to save MLP: {}", e))
73+
} else {
74+
Err("No MLP to save".to_string())
75+
}
76+
}
77+
78+
/// Set the MLP for routing
79+
pub fn set_mlp(&mut self, mlp: MLP) {
80+
self.mlp = Some(mlp);
81+
self.use_mlp = true;
82+
}
83+
84+
/// Enable or disable MLP usage (fallback to heuristics if disabled)
85+
pub fn set_use_mlp(&mut self, use_mlp: bool) {
86+
self.use_mlp = use_mlp && self.mlp.is_some();
87+
}
88+
89+
/// Extract features from query for MLP input
90+
fn extract_features(&self, query: &Query) -> Vec<f32> {
91+
let mut features = vec![0.0; 384];
92+
93+
// Feature 0: Normalized query length
94+
features[0] = (query.text.len() as f32 / 1000.0).min(1.0);
95+
96+
// Feature 1: Word count normalized
97+
features[1] = (query.text.split_whitespace().count() as f32 / 100.0).min(1.0);
98+
99+
// Feature 2: Has question mark
100+
features[2] = if query.text.contains('?') { 1.0 } else { 0.0 };
101+
102+
// Feature 3: Priority normalized
103+
features[3] = query.priority as f32 / 10.0;
104+
105+
// Feature 4: Has project context
106+
features[4] = if query.project_context.is_some() { 1.0 } else { 0.0 };
107+
108+
// Feature 5-9: Complex keyword indicators
109+
for (i, keyword) in self.config.complex_keywords.iter().enumerate().take(5) {
110+
features[5 + i] = if query.text.to_lowercase().contains(&keyword.to_lowercase()) {
111+
1.0
112+
} else {
113+
0.0
114+
};
115+
}
116+
117+
// Feature 10: Uppercase ratio (might indicate emphasis/urgency)
118+
let uppercase_count = query.text.chars().filter(|c| c.is_uppercase()).count();
119+
features[10] = if !query.text.is_empty() {
120+
uppercase_count as f32 / query.text.len() as f32
121+
} else {
122+
0.0
123+
};
124+
125+
// Feature 11: Punctuation density
126+
let punct_count = query.text.chars().filter(|c| c.is_ascii_punctuation()).count();
127+
features[11] = if !query.text.is_empty() {
128+
punct_count as f32 / query.text.len() as f32
129+
} else {
130+
0.0
131+
};
132+
133+
// Features 12-19: Query type indicators
134+
features[12] = if query.text.to_lowercase().starts_with("how") { 1.0 } else { 0.0 };
135+
features[13] = if query.text.to_lowercase().starts_with("what") { 1.0 } else { 0.0 };
136+
features[14] = if query.text.to_lowercase().starts_with("why") { 1.0 } else { 0.0 };
137+
features[15] = if query.text.to_lowercase().starts_with("when") { 1.0 } else { 0.0 };
138+
features[16] = if query.text.to_lowercase().starts_with("where") { 1.0 } else { 0.0 };
139+
features[17] = if query.text.to_lowercase().starts_with("who") { 1.0 } else { 0.0 };
140+
features[18] = if query.text.to_lowercase().starts_with("can") { 1.0 } else { 0.0 };
141+
features[19] = if query.text.to_lowercase().starts_with("should") { 1.0 } else { 0.0 };
142+
143+
// Features 20-379: Simple bag-of-words encoding (placeholder for better embedding)
144+
// In production, replace with sentence-transformers or similar
145+
let words: Vec<&str> = query.text.split_whitespace().collect();
146+
for (i, word) in words.iter().enumerate().take(360) {
147+
// Simple hash-based encoding
148+
let hash = word.chars().map(|c| c as u32).sum::<u32>() % 360;
149+
features[20 + hash as usize] += 1.0 / words.len() as f32;
150+
}
151+
152+
// Features 380-383: Metadata
153+
// Timestamp normalized to [0,1] based on time of day (seconds since midnight / 86400)
154+
features[380] = ((query.timestamp % 86400) as f32 / 86400.0).min(1.0);
155+
features[381] = if query.is_high_priority() { 1.0 } else { 0.0 };
156+
features[382] = if query.text.len() > 200 { 1.0 } else { 0.0 }; // Long query indicator
157+
features[383] = if query.text.contains("error") || query.text.contains("bug") {
158+
1.0
159+
} else {
160+
0.0
161+
}; // Debugging indicator
162+
163+
features
31164
}
32165

33166
/// Route a query to the appropriate processing path
@@ -39,14 +172,55 @@ impl Router {
39172
/// 3. Check project context - known projects → Local (cached knowledge)
40173
/// 4. Default: Local for simple queries
41174
///
42-
/// # Future (Phase 2+)
175+
/// # Phase 2+ (MLP-based)
43176
///
44-
/// Replace with actual MLP:
45-
/// - Input: query embedding + context features
177+
/// When MLP is available and enabled:
178+
/// - Input: query embedding + context features (384-dim)
46179
/// - Hidden layers: 2-3 layers, ~50-100 neurons each
47180
/// - Output: [local_score, remote_score, hybrid_score]
48181
/// - Decision: argmax(output)
49182
pub fn route(&self, query: &Query) -> (RoutingDecision, f32) {
183+
// Phase 2+: Try MLP routing first
184+
if self.use_mlp && self.mlp.is_some() {
185+
return self.route_with_mlp(query);
186+
}
187+
188+
// Phase 1: Fallback to heuristic routing
189+
self.route_heuristic(query)
190+
}
191+
192+
/// MLP-based routing
193+
fn route_with_mlp(&self, query: &Query) -> (RoutingDecision, f32) {
194+
if let Some(ref mlp) = self.mlp {
195+
// Extract features
196+
let features = self.extract_features(query);
197+
198+
// Forward pass
199+
let logits = mlp.forward(&features);
200+
201+
// Softmax to get probabilities
202+
let probs = MLP::softmax(&logits);
203+
204+
// Get decision (argmax)
205+
let decision_idx = MLP::argmax(&probs);
206+
let confidence = probs[decision_idx];
207+
208+
let decision = match decision_idx {
209+
0 => RoutingDecision::Local,
210+
1 => RoutingDecision::Remote,
211+
2 => RoutingDecision::Hybrid,
212+
_ => RoutingDecision::Local, // Fallback
213+
};
214+
215+
(decision, confidence)
216+
} else {
217+
// Shouldn't reach here, but fallback just in case
218+
self.route_heuristic(query)
219+
}
220+
}
221+
222+
/// Heuristic-based routing (Phase 1)
223+
fn route_heuristic(&self, query: &Query) -> (RoutingDecision, f32) {
50224
// Rule 1: Very long queries are complex
51225
if query.text.len() > self.config.max_local_length {
52226
return (RoutingDecision::Remote, 0.9);
@@ -100,19 +274,10 @@ impl Default for Router {
100274
}
101275
}
102276

103-
// Future Phase 2: Actual MLP implementation placeholder
104-
#[allow(dead_code)]
105-
struct SimpleMLP {
106-
// Placeholder for future MLP implementation
107-
// Will use ndarray or similar for matrix operations
108-
input_size: usize,
109-
hidden_size: usize,
110-
output_size: usize,
111-
}
112-
113277
#[cfg(test)]
114278
mod tests {
115279
use super::*;
280+
use crate::mlp::MLP;
116281

117282
#[test]
118283
fn test_router_creation() {
@@ -174,4 +339,100 @@ mod tests {
174339
router.update_config(new_config);
175340
assert_eq!(router.config().local_threshold, 0.8);
176341
}
342+
343+
#[test]
344+
fn test_router_with_mlp() {
345+
// Create a simple MLP for testing
346+
let mlp = MLP::new(384, vec![100, 50], 3);
347+
let router = Router::with_mlp(mlp);
348+
349+
let query = Query::new("What is Rust?");
350+
let (decision, confidence) = router.route(&query);
351+
352+
// Should use MLP routing (decision can be anything, but confidence should be in [0,1])
353+
assert!(confidence >= 0.0 && confidence <= 1.0);
354+
// Decision should be one of the valid types
355+
assert!(matches!(
356+
decision,
357+
RoutingDecision::Local
358+
| RoutingDecision::Remote
359+
| RoutingDecision::Hybrid
360+
| RoutingDecision::Blocked
361+
));
362+
}
363+
364+
#[test]
365+
fn test_router_mlp_fallback() {
366+
let mut router = Router::new();
367+
// No MLP set, should use heuristics
368+
assert!(!router.use_mlp);
369+
370+
let query = Query::new("Short query");
371+
let (decision, _) = router.route(&query);
372+
assert_eq!(decision, RoutingDecision::Local);
373+
374+
// Set MLP
375+
let mlp = MLP::new(384, vec![100, 50], 3);
376+
router.set_mlp(mlp);
377+
assert!(router.use_mlp);
378+
379+
// Disable MLP
380+
router.set_use_mlp(false);
381+
assert!(!router.use_mlp);
382+
383+
// Should fallback to heuristics
384+
let (decision, _) = router.route(&query);
385+
assert_eq!(decision, RoutingDecision::Local);
386+
}
387+
388+
#[test]
389+
fn test_feature_extraction() {
390+
let router = Router::new();
391+
let query = Query::new("How do I iterate a HashMap in Rust?");
392+
393+
let features = router.extract_features(&query);
394+
395+
// Check feature vector size
396+
assert_eq!(features.len(), 384);
397+
398+
// Check that some basic features are set correctly
399+
assert!(features[2] > 0.0); // Has question mark
400+
assert!(features[12] > 0.0); // Starts with "how"
401+
402+
// Check normalization
403+
assert!(features.iter().all(|&f| f >= 0.0 && f <= 1.0));
404+
}
405+
406+
#[test]
407+
#[cfg(feature = "persistence")]
408+
fn test_mlp_persistence_integration() {
409+
use crate::persistence::PersistenceManager;
410+
411+
let pm = PersistenceManager::new_in_memory().unwrap();
412+
413+
// Create router with MLP
414+
let mlp = MLP::new(384, vec![100, 50], 3);
415+
let mut router = Router::with_mlp(mlp);
416+
417+
// Save MLP
418+
router.save_mlp(&pm, "test_router", Some(0.85)).unwrap();
419+
420+
// Create new router and load MLP
421+
let mut new_router = Router::new();
422+
new_router.load_mlp(&pm, "test_router").unwrap();
423+
424+
assert!(new_router.use_mlp);
425+
426+
// Test that it can route
427+
let query = Query::new("Test query");
428+
let (decision, confidence) = new_router.route(&query);
429+
assert!(confidence >= 0.0 && confidence <= 1.0);
430+
assert!(matches!(
431+
decision,
432+
RoutingDecision::Local
433+
| RoutingDecision::Remote
434+
| RoutingDecision::Hybrid
435+
| RoutingDecision::Blocked
436+
));
437+
}
177438
}

0 commit comments

Comments
 (0)