1#[derive(Debug, Clone)]
13pub struct TopicEmbedding {
14 pub node_id: String,
16 pub topic: String,
18 pub embedding: Vec<f32>,
20 pub capacity: usize,
22 pub current_load: usize,
24}
25
26impl TopicEmbedding {
27 pub fn new(
29 node_id: impl Into<String>,
30 topic: impl Into<String>,
31 embedding: Vec<f32>,
32 capacity: usize,
33 ) -> Self {
34 Self {
35 node_id: node_id.into(),
36 topic: topic.into(),
37 embedding,
38 capacity,
39 current_load: 0,
40 }
41 }
42
43 pub fn load_factor(&self) -> f64 {
45 self.current_load as f64 / self.capacity.max(1) as f64
46 }
47}
48
49fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
53 if a.len() != b.len() || a.is_empty() {
54 return 0.0;
55 }
56 let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
57 let mag_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
58 let mag_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
59 if mag_a == 0.0 || mag_b == 0.0 {
60 return 0.0;
61 }
62 dot / (mag_a * mag_b)
63}
64
65#[derive(Debug, Clone)]
67pub struct RouteScore {
68 pub node_id: String,
70 pub topic: String,
72 pub similarity: f32,
74 pub load_factor: f64,
76}
77
78impl RouteScore {
79 pub fn combined_score(&self) -> f64 {
83 self.similarity as f64 * (1.0 - self.load_factor * 0.3)
84 }
85}
86
87#[derive(Debug, Clone)]
89pub struct RoutingDecision {
90 pub query_embedding: Vec<f32>,
92 pub candidates: Vec<RouteScore>,
95 pub best_node: Option<String>,
97}
98
99impl RoutingDecision {
100 pub fn top_k_nodes(&self, k: usize) -> Vec<&str> {
104 self.candidates
105 .iter()
106 .take(k)
107 .map(|rs| rs.node_id.as_str())
108 .collect()
109 }
110}
111
112#[derive(Debug, Clone)]
114pub struct RouterConfig {
115 pub min_similarity: f32,
119 pub max_candidates: usize,
123}
124
125impl Default for RouterConfig {
126 fn default() -> Self {
127 Self {
128 min_similarity: 0.6,
129 max_candidates: 10,
130 }
131 }
132}
133
134#[derive(Debug, Clone, Default)]
136pub struct RouterStats {
137 pub total_routed: u64,
139 pub no_route_count: u64,
141}
142
143impl RouterStats {
144 pub fn route_success_rate(&self) -> f64 {
148 if self.total_routed == 0 {
149 return 0.0;
150 }
151 let successful = self.total_routed.saturating_sub(self.no_route_count);
152 successful as f64 / self.total_routed as f64
153 }
154}
155
156#[derive(Debug)]
180pub struct SemanticContentRouter {
181 topics: Vec<TopicEmbedding>,
183 config: RouterConfig,
185 stats: RouterStats,
187}
188
189impl SemanticContentRouter {
190 pub fn new(config: RouterConfig) -> Self {
192 Self {
193 topics: Vec::new(),
194 config,
195 stats: RouterStats::default(),
196 }
197 }
198
199 pub fn register_topic(&mut self, topic: TopicEmbedding) {
203 self.topics.push(topic);
204 }
205
206 pub fn update_load(&mut self, node_id: &str, topic: &str, load: usize) {
210 for t in &mut self.topics {
211 if t.node_id == node_id && t.topic == topic {
212 t.current_load = load;
213 }
214 }
215 }
216
217 pub fn route(&mut self, query_embedding: &[f32]) -> RoutingDecision {
226 let mut candidates: Vec<RouteScore> = self
227 .topics
228 .iter()
229 .filter_map(|t| {
230 let sim = cosine_similarity(query_embedding, &t.embedding);
231 if sim >= self.config.min_similarity {
232 Some(RouteScore {
233 node_id: t.node_id.clone(),
234 topic: t.topic.clone(),
235 similarity: sim,
236 load_factor: t.load_factor(),
237 })
238 } else {
239 None
240 }
241 })
242 .collect();
243
244 candidates.sort_by(|a, b| {
246 b.combined_score()
247 .partial_cmp(&a.combined_score())
248 .unwrap_or(std::cmp::Ordering::Equal)
249 .then_with(|| {
250 b.similarity
251 .partial_cmp(&a.similarity)
252 .unwrap_or(std::cmp::Ordering::Equal)
253 })
254 });
255
256 candidates.truncate(self.config.max_candidates);
257
258 let best_node = candidates.first().map(|rs| rs.node_id.clone());
259
260 self.stats.total_routed += 1;
261 if candidates.is_empty() {
262 self.stats.no_route_count += 1;
263 }
264
265 RoutingDecision {
266 query_embedding: query_embedding.to_vec(),
267 candidates,
268 best_node,
269 }
270 }
271
272 pub fn unregister_node(&mut self, node_id: &str) {
274 self.topics.retain(|t| t.node_id != node_id);
275 }
276
277 pub fn stats(&self) -> &RouterStats {
279 &self.stats
280 }
281}
282
283#[cfg(test)]
288mod tests {
289 use super::*;
290
291 fn make_router(min_sim: f32, max_cand: usize) -> SemanticContentRouter {
292 SemanticContentRouter::new(RouterConfig {
293 min_similarity: min_sim,
294 max_candidates: max_cand,
295 })
296 }
297
298 fn unit_vec(index: usize, dim: usize) -> Vec<f32> {
299 let mut v = vec![0.0f32; dim];
300 if index < dim {
301 v[index] = 1.0;
302 }
303 v
304 }
305
306 #[test]
308 fn test_register_topic_stores_entry() {
309 let mut router = make_router(0.5, 10);
310 let emb = TopicEmbedding::new("node-a", "science", unit_vec(0, 4), 100);
311 router.register_topic(emb);
312 assert_eq!(router.topics.len(), 1);
313 assert_eq!(router.topics[0].node_id, "node-a");
314 assert_eq!(router.topics[0].topic, "science");
315 }
316
317 #[test]
319 fn test_register_multiple_topics_same_node() {
320 let mut router = make_router(0.5, 10);
321 router.register_topic(TopicEmbedding::new("node-a", "science", unit_vec(0, 4), 50));
322 router.register_topic(TopicEmbedding::new("node-a", "arts", unit_vec(1, 4), 50));
323 assert_eq!(router.topics.len(), 2);
324 }
325
326 #[test]
328 fn test_route_finds_best_match() {
329 let mut router = make_router(0.5, 10);
330 router.register_topic(TopicEmbedding::new(
331 "node-a",
332 "topic-a",
333 unit_vec(0, 3),
334 100,
335 ));
336 router.register_topic(TopicEmbedding::new(
337 "node-b",
338 "topic-b",
339 unit_vec(1, 3),
340 100,
341 ));
342 let query = vec![0.95f32, 0.05, 0.0];
344 let decision = router.route(&query);
345 assert_eq!(decision.best_node.as_deref(), Some("node-a"));
346 }
347
348 #[test]
350 fn test_similarity_below_threshold_filtered() {
351 let mut router = make_router(0.9, 10);
352 router.register_topic(TopicEmbedding::new("node-a", "t1", unit_vec(0, 4), 100));
354 let query = unit_vec(1, 4); let decision = router.route(&query);
356 assert!(decision.candidates.is_empty());
357 assert!(decision.best_node.is_none());
358 }
359
360 #[test]
362 fn test_load_factor_penalizes() {
363 let mut router = make_router(0.5, 10);
364 let q: Vec<f32> = vec![1.0, 0.0];
366 let mut heavy = TopicEmbedding::new("heavy", "t", vec![1.0, 0.0], 100);
367 heavy.current_load = 90; let light = TopicEmbedding::new("light", "t", vec![1.0, 0.0], 100);
369 router.register_topic(heavy);
371 router.register_topic(light);
372
373 let decision = router.route(&q);
374 assert_eq!(decision.best_node.as_deref(), Some("light"));
376 }
377
378 #[test]
380 fn test_top_k_nodes() {
381 let mut router = make_router(0.0, 10);
382 router.register_topic(TopicEmbedding::new("node-a", "t1", unit_vec(0, 3), 100));
383 router.register_topic(TopicEmbedding::new("node-b", "t2", unit_vec(1, 3), 100));
384 router.register_topic(TopicEmbedding::new("node-c", "t3", unit_vec(2, 3), 100));
385 let query: Vec<f32> = vec![1.0, 0.0, 0.0];
387 let decision = router.route(&query);
388 let top2 = decision.top_k_nodes(2);
389 assert_eq!(top2.len(), 2);
390 assert_eq!(top2[0], "node-a");
391 }
392
393 #[test]
395 fn test_top_k_nodes_fewer_than_k() {
396 let mut router = make_router(0.5, 10);
397 router.register_topic(TopicEmbedding::new("node-a", "t1", unit_vec(0, 3), 100));
398 let query: Vec<f32> = vec![1.0, 0.0, 0.0];
399 let decision = router.route(&query);
400 let top5 = decision.top_k_nodes(5);
401 assert_eq!(top5.len(), 1); }
403
404 #[test]
406 fn test_update_load_changes_score() {
407 let mut router = make_router(0.5, 10);
408 router.register_topic(TopicEmbedding::new("node-a", "t1", unit_vec(0, 2), 100));
409
410 let query = unit_vec(0, 2);
411 let before = router.route(&query);
412 let score_before = before.candidates[0].combined_score();
413
414 router.update_load("node-a", "t1", 80);
416
417 let after = router.route(&query);
418 let score_after = after.candidates[0].combined_score();
419
420 assert!(
421 score_after < score_before,
422 "score_after={score_after} should be less than score_before={score_before}"
423 );
424 }
425
426 #[test]
428 fn test_update_load_targets_correct_entry() {
429 let mut router = make_router(0.0, 10);
430 router.register_topic(TopicEmbedding::new("node-a", "t1", unit_vec(0, 2), 100));
431 router.register_topic(TopicEmbedding::new("node-a", "t2", unit_vec(1, 2), 100));
432
433 router.update_load("node-a", "t1", 50);
434
435 assert_eq!(router.topics[0].current_load, 50);
436 assert_eq!(router.topics[1].current_load, 0); }
438
439 #[test]
441 fn test_unregister_node_removes() {
442 let mut router = make_router(0.5, 10);
443 router.register_topic(TopicEmbedding::new("node-a", "t1", unit_vec(0, 3), 100));
444 router.register_topic(TopicEmbedding::new("node-a", "t2", unit_vec(1, 3), 100));
445 router.register_topic(TopicEmbedding::new("node-b", "t3", unit_vec(2, 3), 100));
446
447 router.unregister_node("node-a");
448
449 assert_eq!(router.topics.len(), 1);
450 assert_eq!(router.topics[0].node_id, "node-b");
451 }
452
453 #[test]
455 fn test_unregister_unknown_node_no_op() {
456 let mut router = make_router(0.5, 10);
457 router.register_topic(TopicEmbedding::new("node-a", "t1", unit_vec(0, 3), 100));
458 router.unregister_node("node-x");
459 assert_eq!(router.topics.len(), 1);
460 }
461
462 #[test]
464 fn test_no_route_count_when_empty() {
465 let mut router = make_router(0.99, 10);
466 router.route(&[0.1, 0.2]);
468 router.route(&[0.3, 0.4]);
469 assert_eq!(router.stats().no_route_count, 2);
470 assert_eq!(router.stats().total_routed, 2);
471 }
472
473 #[test]
475 fn test_no_route_count_not_incremented_on_success() {
476 let mut router = make_router(0.5, 10);
477 router.register_topic(TopicEmbedding::new("node-a", "t1", unit_vec(0, 2), 100));
478 router.route(&unit_vec(0, 2));
479 assert_eq!(router.stats().no_route_count, 0);
480 assert_eq!(router.stats().total_routed, 1);
481 }
482
483 #[test]
485 fn test_route_success_rate_no_decisions() {
486 let router = make_router(0.5, 10);
487 assert_eq!(router.stats().route_success_rate(), 0.0);
488 }
489
490 #[test]
492 fn test_route_success_rate_mixed() {
493 let mut router = make_router(0.5, 10);
494 router.register_topic(TopicEmbedding::new("node-a", "t1", unit_vec(0, 2), 100));
495 router.route(&unit_vec(0, 2));
497 router.route(&unit_vec(1, 2));
499
500 let rate = router.stats().route_success_rate();
501 assert!((rate - 0.5).abs() < 1e-9, "expected 0.5, got {rate}");
502 }
503
504 #[test]
506 fn test_combined_score_formula() {
507 let rs = RouteScore {
508 node_id: "n".to_string(),
509 topic: "t".to_string(),
510 similarity: 0.8,
511 load_factor: 0.5,
512 };
513 let expected = 0.8_f32 as f64 * (1.0_f64 - 0.5_f64 * 0.3_f64);
517 assert!((rs.combined_score() - expected).abs() < 1e-9);
518 }
519
520 #[test]
522 fn test_best_node_set_correctly() {
523 let mut router = make_router(0.0, 10);
524 router.register_topic(TopicEmbedding::new("node-a", "t1", unit_vec(0, 3), 100));
525 router.register_topic(TopicEmbedding::new("node-b", "t2", unit_vec(1, 3), 100));
526 let decision = router.route(&[0.0, 1.0, 0.0]);
528 assert_eq!(decision.best_node.as_deref(), Some("node-b"));
529 }
530
531 #[test]
533 fn test_max_candidates_caps_results() {
534 let mut router = make_router(0.0, 3);
535 for i in 0..6 {
536 let mut emb = vec![0.0f32; 6];
537 emb[i] = 1.0;
538 router.register_topic(TopicEmbedding::new(format!("node-{i}"), "t", emb, 100));
539 }
540 let query = vec![1.0f32; 6];
543 let decision = router.route(&query);
544 assert!(decision.candidates.len() <= 3);
545 }
546
547 #[test]
549 fn test_cosine_similarity_identical() {
550 let v = vec![1.0f32, 2.0, 3.0];
551 let sim = cosine_similarity(&v, &v);
552 assert!((sim - 1.0).abs() < 1e-6);
553 }
554
555 #[test]
557 fn test_cosine_similarity_orthogonal() {
558 let a = vec![1.0f32, 0.0];
559 let b = vec![0.0f32, 1.0];
560 let sim = cosine_similarity(&a, &b);
561 assert!(sim.abs() < 1e-6);
562 }
563
564 #[test]
566 fn test_load_factor_zero() {
567 let t = TopicEmbedding::new("n", "t", vec![1.0], 100);
568 assert_eq!(t.load_factor(), 0.0);
569 }
570
571 #[test]
573 fn test_load_factor_zero_capacity() {
574 let mut t = TopicEmbedding::new("n", "t", vec![1.0], 0);
575 t.current_load = 5;
576 assert!((t.load_factor() - 5.0).abs() < 1e-9);
578 }
579
580 #[test]
582 fn test_candidates_sorted_desc() {
583 let mut router = make_router(0.0, 10);
584 let q = vec![1.0f32, 0.0];
586 for pct in [80usize, 0, 50, 30] {
587 let mut t = TopicEmbedding::new(format!("node-{pct}"), "t", vec![1.0, 0.0], 100);
588 t.current_load = pct;
589 router.register_topic(t);
590 }
591 let decision = router.route(&q);
592 for window in decision.candidates.windows(2) {
593 assert!(
594 window[0].combined_score() >= window[1].combined_score(),
595 "candidates not sorted: {} < {}",
596 window[0].combined_score(),
597 window[1].combined_score()
598 );
599 }
600 }
601}