Skip to main content

ipfrs_semantic/
content_router.rs

1//! Semantic Content Router
2//!
3//! Routes content queries to the most semantically relevant nodes/shards
4//! based on their registered topic embeddings. Uses cosine similarity
5//! with load-aware scoring to balance routing across available nodes.
6
7/// A registered topic embedding for a specific node.
8///
9/// Each node may register multiple topics (one per `TopicEmbedding`).
10/// The router uses these embeddings to determine which node best serves
11/// a given query embedding.
12#[derive(Debug, Clone)]
13pub struct TopicEmbedding {
14    /// Identifier for the node that owns this topic.
15    pub node_id: String,
16    /// Human-readable topic label (e.g., "machine-learning", "genomics").
17    pub topic: String,
18    /// The embedding vector representing this topic in latent space.
19    pub embedding: Vec<f32>,
20    /// Maximum number of content items this node can handle for this topic.
21    pub capacity: usize,
22    /// Current number of items stored/handled by this node for this topic.
23    pub current_load: usize,
24}
25
26impl TopicEmbedding {
27    /// Creates a new `TopicEmbedding`.
28    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    /// Returns the load factor: `current_load / capacity.max(1)`.
44    pub fn load_factor(&self) -> f64 {
45        self.current_load as f64 / self.capacity.max(1) as f64
46    }
47}
48
49/// Cosine similarity between two vectors.
50///
51/// Returns `0.0` if either vector has zero magnitude.
52fn 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/// A candidate routing result for a single (node, topic) pair.
66#[derive(Debug, Clone)]
67pub struct RouteScore {
68    /// Node identifier.
69    pub node_id: String,
70    /// Topic label matched.
71    pub topic: String,
72    /// Cosine similarity between the query and the topic embedding.
73    pub similarity: f32,
74    /// Load factor at the time the score was computed: `current_load / capacity.max(1)`.
75    pub load_factor: f64,
76}
77
78impl RouteScore {
79    /// Combined routing score that penalizes heavily loaded nodes.
80    ///
81    /// `combined_score = similarity * (1.0 - load_factor * 0.3)`
82    pub fn combined_score(&self) -> f64 {
83        self.similarity as f64 * (1.0 - self.load_factor * 0.3)
84    }
85}
86
87/// The outcome of a single routing decision.
88#[derive(Debug, Clone)]
89pub struct RoutingDecision {
90    /// The query embedding that triggered this decision.
91    pub query_embedding: Vec<f32>,
92    /// All candidates that passed the similarity threshold, sorted by
93    /// `combined_score` descending. Limited to at most `max_candidates`.
94    pub candidates: Vec<RouteScore>,
95    /// The `node_id` of the top-scoring candidate, if any.
96    pub best_node: Option<String>,
97}
98
99impl RoutingDecision {
100    /// Returns the top-`k` node identifiers ordered by `combined_score` descending.
101    ///
102    /// If fewer than `k` candidates exist, all are returned.
103    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/// Configuration for [`SemanticContentRouter`].
113#[derive(Debug, Clone)]
114pub struct RouterConfig {
115    /// Minimum cosine similarity required to include a node as a candidate.
116    ///
117    /// Defaults to `0.6`.
118    pub min_similarity: f32,
119    /// Maximum number of candidates to keep in a [`RoutingDecision`].
120    ///
121    /// Defaults to `10`.
122    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/// Accumulated routing statistics.
135#[derive(Debug, Clone, Default)]
136pub struct RouterStats {
137    /// Total number of routing decisions made (successful or not).
138    pub total_routed: u64,
139    /// Number of routing decisions that produced no candidates.
140    pub no_route_count: u64,
141}
142
143impl RouterStats {
144    /// Fraction of routing decisions that had at least one candidate.
145    ///
146    /// Returns `0.0` when no decisions have been made yet.
147    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/// Routes content queries to the most semantically relevant nodes.
157///
158/// Maintains a registry of `TopicEmbedding`s and, for each query, computes
159/// cosine similarity against every registered embedding, filters by
160/// `min_similarity`, ranks by `combined_score`, and caps results at
161/// `max_candidates`.
162///
163/// # Example
164///
165/// ```rust
166/// use ipfrs_semantic::content_router::{
167///     RouterConfig, SemanticContentRouter, TopicEmbedding,
168/// };
169///
170/// let config = RouterConfig { min_similarity: 0.5, max_candidates: 5 };
171/// let mut router = SemanticContentRouter::new(config);
172///
173/// let emb = TopicEmbedding::new("node-1", "science", vec![1.0, 0.0, 0.0], 100);
174/// router.register_topic(emb);
175///
176/// let decision = router.route(&[0.9, 0.1, 0.0]);
177/// assert!(decision.best_node.is_some());
178/// ```
179#[derive(Debug)]
180pub struct SemanticContentRouter {
181    /// All registered topic embeddings.
182    topics: Vec<TopicEmbedding>,
183    /// Router configuration.
184    config: RouterConfig,
185    /// Accumulated statistics.
186    stats: RouterStats,
187}
188
189impl SemanticContentRouter {
190    /// Creates a new router with the given configuration.
191    pub fn new(config: RouterConfig) -> Self {
192        Self {
193            topics: Vec::new(),
194            config,
195            stats: RouterStats::default(),
196        }
197    }
198
199    /// Registers a topic embedding.
200    ///
201    /// Multiple topics from the same node are allowed.
202    pub fn register_topic(&mut self, topic: TopicEmbedding) {
203        self.topics.push(topic);
204    }
205
206    /// Updates the current load for a specific (node, topic) pair.
207    ///
208    /// If no matching entry is found, this is a no-op.
209    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    /// Routes a query embedding to the most suitable nodes.
218    ///
219    /// Steps:
220    /// 1. Compute cosine similarity to every registered topic embedding.
221    /// 2. Filter candidates whose similarity is below `min_similarity`.
222    /// 3. Sort remaining candidates by `combined_score` (descending).
223    /// 4. Retain at most `max_candidates` results.
224    /// 5. Update statistics and return the [`RoutingDecision`].
225    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        // Sort by combined_score descending; break ties by similarity desc.
245        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    /// Removes all registered topic embeddings for the given node.
273    pub fn unregister_node(&mut self, node_id: &str) {
274        self.topics.retain(|t| t.node_id != node_id);
275    }
276
277    /// Returns a reference to the accumulated routing statistics.
278    pub fn stats(&self) -> &RouterStats {
279        &self.stats
280    }
281}
282
283// ─────────────────────────────────────────────────────────────────────────────
284// Tests
285// ─────────────────────────────────────────────────────────────────────────────
286
287#[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    // ── 1. register_topic stores the embedding ────────────────────────────────
307    #[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    // ── 2. register_topic allows multiple topics per node ─────────────────────
318    #[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    // ── 3. route finds best matching node ────────────────────────────────────
327    #[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        // Query is closest to node-a (same direction as dimension 0)
343        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    // ── 4. similarity below threshold is filtered out ─────────────────────────
349    #[test]
350    fn test_similarity_below_threshold_filtered() {
351        let mut router = make_router(0.9, 10);
352        // node-a has dim-0 embedding; query points mostly in dim-1 → low similarity
353        router.register_topic(TopicEmbedding::new("node-a", "t1", unit_vec(0, 4), 100));
354        let query = unit_vec(1, 4); // orthogonal → similarity = 0.0
355        let decision = router.route(&query);
356        assert!(decision.candidates.is_empty());
357        assert!(decision.best_node.is_none());
358    }
359
360    // ── 5. load_factor penalizes loaded nodes ─────────────────────────────────
361    #[test]
362    fn test_load_factor_penalizes() {
363        let mut router = make_router(0.5, 10);
364        // Both nodes have similar (non-identical) embeddings to the query.
365        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; // 90 % load
368        let light = TopicEmbedding::new("light", "t", vec![1.0, 0.0], 100);
369        // light.current_load = 0 (default)
370        router.register_topic(heavy);
371        router.register_topic(light);
372
373        let decision = router.route(&q);
374        // light node should score higher (no load penalty)
375        assert_eq!(decision.best_node.as_deref(), Some("light"));
376    }
377
378    // ── 6. top_k_nodes returns correct slice ──────────────────────────────────
379    #[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        // Query aligned with dim-0 ⇒ node-a highest
386        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    // ── 7. top_k_nodes with k > candidates ────────────────────────────────────
394    #[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); // only 1 candidate
402    }
403
404    // ── 8. update_load changes the score ─────────────────────────────────────
405    #[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        // Push load to 80 %
415        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    // ── 9. update_load only touches matching (node, topic) ────────────────────
427    #[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); // unchanged
437    }
438
439    // ── 10. unregister_node removes all topics for that node ──────────────────
440    #[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    // ── 11. unregister_node does nothing for unknown node ─────────────────────
454    #[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    // ── 12. no_route_count increments on empty result ─────────────────────────
463    #[test]
464    fn test_no_route_count_when_empty() {
465        let mut router = make_router(0.99, 10);
466        // No topics registered ⇒ always empty
467        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    // ── 13. no_route_count does NOT increment on successful route ─────────────
474    #[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    // ── 14. route_success_rate is 0.0 with no decisions ──────────────────────
484    #[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    // ── 15. route_success_rate correct after mixed decisions ─────────────────
491    #[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        // Succeeds
496        router.route(&unit_vec(0, 2));
497        // Fails (orthogonal, similarity = 0)
498        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    // ── 16. combined_score formula ────────────────────────────────────────────
505    #[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        // 0.8 * (1.0 - 0.5 * 0.3) = 0.8 * 0.85 = 0.68
514        // The similarity field is f32 (0.8f32), so cast to f64 first to avoid
515        // precision mismatch when comparing against a pure-f64 expected value.
516        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    // ── 17. best_node set correctly ──────────────────────────────────────────
521    #[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        // Query in dim-1 ⇒ node-b best
527        let decision = router.route(&[0.0, 1.0, 0.0]);
528        assert_eq!(decision.best_node.as_deref(), Some("node-b"));
529    }
530
531    // ── 18. max_candidates caps results ──────────────────────────────────────
532    #[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        // All embeddings have similarity 0 to query [1,1,1,1,1,1]/√6 but let's use
541        // a query that gives non-zero similarity to all.
542        let query = vec![1.0f32; 6];
543        let decision = router.route(&query);
544        assert!(decision.candidates.len() <= 3);
545    }
546
547    // ── 19. cosine similarity of identical vectors equals 1.0 ────────────────
548    #[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    // ── 20. cosine similarity of orthogonal vectors equals 0.0 ───────────────
556    #[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    // ── 21. load_factor is 0.0 when no load ──────────────────────────────────
565    #[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    // ── 22. load_factor with zero capacity uses max(1) ───────────────────────
572    #[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        // capacity.max(1) = 1, so load_factor = 5.0
577        assert!((t.load_factor() - 5.0).abs() < 1e-9);
578    }
579
580    // ── 23. candidates sorted by combined_score desc ──────────────────────────
581    #[test]
582    fn test_candidates_sorted_desc() {
583        let mut router = make_router(0.0, 10);
584        // All nodes are equally similar to the query; vary load to create order.
585        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}