Skip to main content

ipfrs_semantic/
knowledge_graph.rs

1//! # Semantic Knowledge Graph
2//!
3//! Links concepts, entities, and their embeddings into a queryable graph
4//! for multi-hop semantic reasoning.
5
6use std::collections::{HashMap, HashSet, VecDeque};
7
8// ---------------------------------------------------------------------------
9// EntityKind
10// ---------------------------------------------------------------------------
11
12/// The category of a graph entity.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
14pub enum EntityKind {
15    Concept,
16    Document,
17    Person,
18    Organization,
19    Event,
20}
21
22// ---------------------------------------------------------------------------
23// GraphEntity
24// ---------------------------------------------------------------------------
25
26/// A node in the knowledge graph.
27#[derive(Clone, Debug)]
28pub struct GraphEntity {
29    /// Unique identifier.
30    pub entity_id: u64,
31    /// Human-readable name.
32    pub name: String,
33    /// Category of the entity.
34    pub kind: EntityKind,
35    /// Optional dense embedding vector.
36    pub embedding: Option<Vec<f32>>,
37    /// Arbitrary key-value properties.
38    pub properties: Vec<(String, String)>,
39}
40
41// ---------------------------------------------------------------------------
42// GraphEdge
43// ---------------------------------------------------------------------------
44
45/// A directed, weighted relationship between two entities.
46#[derive(Clone, Debug)]
47pub struct GraphEdge {
48    /// Unique identifier.
49    pub edge_id: u64,
50    /// Source entity id.
51    pub from_id: u64,
52    /// Destination entity id.
53    pub to_id: u64,
54    /// Relation label, e.g. "is_about", "authored_by", "mentions".
55    pub relation: String,
56    /// Edge weight (higher = stronger association).
57    pub weight: f32,
58}
59
60// ---------------------------------------------------------------------------
61// GraphQuery
62// ---------------------------------------------------------------------------
63
64/// Parameters for a multi-hop BFS traversal.
65#[derive(Clone, Debug)]
66pub struct GraphQuery {
67    /// Entity from which traversal starts.
68    pub start_entity_id: u64,
69    /// If `Some`, only traverse edges whose relation equals this string.
70    pub relation_filter: Option<String>,
71    /// Maximum number of hops from the start entity.
72    pub max_hops: usize,
73    /// If `Some`, only include entities of this kind in the results.
74    pub entity_kind_filter: Option<EntityKind>,
75}
76
77// ---------------------------------------------------------------------------
78// KnowledgeGraphStats
79// ---------------------------------------------------------------------------
80
81/// Aggregate statistics for a [`SemanticKnowledgeGraph`].
82#[derive(Clone, Debug)]
83pub struct KnowledgeGraphStats {
84    /// Number of entities stored in the graph.
85    pub total_entities: usize,
86    /// Number of directed edges stored.
87    pub total_edges: usize,
88    /// Number of entities that carry an embedding vector.
89    pub entities_with_embeddings: usize,
90    /// Undirected-approximation average degree: `total_edges * 2 / total_entities`.
91    /// Returns `0.0` when the graph has no entities.
92    pub avg_degree: f64,
93}
94
95// ---------------------------------------------------------------------------
96// Cosine similarity helper
97// ---------------------------------------------------------------------------
98
99/// Compute the cosine similarity between two equal-length slices.
100///
101/// Returns `0.0` when either vector has zero magnitude.
102pub fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
103    let len = a.len().min(b.len());
104    if len == 0 {
105        return 0.0;
106    }
107
108    let mut dot = 0.0_f32;
109    let mut mag_a = 0.0_f32;
110    let mut mag_b = 0.0_f32;
111
112    for i in 0..len {
113        dot += a[i] * b[i];
114        mag_a += a[i] * a[i];
115        mag_b += b[i] * b[i];
116    }
117
118    let denom = mag_a.sqrt() * mag_b.sqrt();
119    if denom < f32::EPSILON {
120        0.0
121    } else {
122        dot / denom
123    }
124}
125
126// ---------------------------------------------------------------------------
127// SemanticKnowledgeGraph
128// ---------------------------------------------------------------------------
129
130/// A queryable semantic knowledge graph that links entities via typed, weighted edges.
131///
132/// Supports:
133/// - Adding/removing entities and edges.
134/// - Neighbour lookup with optional relation filtering (sorted by weight desc).
135/// - BFS traversal with relation and entity-kind filters.
136/// - Cosine-similarity–based entity retrieval for entities that carry embeddings.
137pub struct SemanticKnowledgeGraph {
138    /// All entities, keyed by their `entity_id`.
139    pub entities: HashMap<u64, GraphEntity>,
140    /// All directed edges in insertion order.
141    pub edges: Vec<GraphEdge>,
142    /// Monotonically increasing counter for entity ids.
143    pub next_entity_id: u64,
144    /// Monotonically increasing counter for edge ids.
145    pub next_edge_id: u64,
146}
147
148impl SemanticKnowledgeGraph {
149    // ------------------------------------------------------------------
150    // Construction
151    // ------------------------------------------------------------------
152
153    /// Create a new, empty knowledge graph.
154    pub fn new() -> Self {
155        Self {
156            entities: HashMap::new(),
157            edges: Vec::new(),
158            next_entity_id: 0,
159            next_edge_id: 0,
160        }
161    }
162
163    // ------------------------------------------------------------------
164    // Mutation
165    // ------------------------------------------------------------------
166
167    /// Add a new entity and return its freshly assigned `entity_id`.
168    pub fn add_entity(&mut self, name: &str, kind: EntityKind, embedding: Option<Vec<f32>>) -> u64 {
169        let id = self.next_entity_id;
170        self.next_entity_id += 1;
171
172        self.entities.insert(
173            id,
174            GraphEntity {
175                entity_id: id,
176                name: name.to_owned(),
177                kind,
178                embedding,
179                properties: Vec::new(),
180            },
181        );
182
183        id
184    }
185
186    /// Add a directed edge between two entities and return its `edge_id`.
187    ///
188    /// The entities referenced by `from_id` and `to_id` need not exist yet;
189    /// no validation is performed here so that callers can build the graph in
190    /// any order.
191    pub fn add_edge(&mut self, from_id: u64, to_id: u64, relation: &str, weight: f32) -> u64 {
192        let id = self.next_edge_id;
193        self.next_edge_id += 1;
194
195        self.edges.push(GraphEdge {
196            edge_id: id,
197            from_id,
198            to_id,
199            relation: relation.to_owned(),
200            weight,
201        });
202
203        id
204    }
205
206    /// Look up an entity by id.
207    pub fn get_entity(&self, id: u64) -> Option<&GraphEntity> {
208        self.entities.get(&id)
209    }
210
211    /// Remove an entity and all edges that touch it.
212    ///
213    /// Returns `true` if the entity existed, `false` otherwise.
214    pub fn remove_entity(&mut self, entity_id: u64) -> bool {
215        if self.entities.remove(&entity_id).is_none() {
216            return false;
217        }
218        self.edges
219            .retain(|e| e.from_id != entity_id && e.to_id != entity_id);
220        true
221    }
222
223    // ------------------------------------------------------------------
224    // Query
225    // ------------------------------------------------------------------
226
227    /// Return all entities reachable via a single outgoing edge from `entity_id`.
228    ///
229    /// - When `relation` is `Some(r)`, only edges whose `relation == r` are
230    ///   considered.
231    /// - The result list is sorted by edge weight descending.
232    pub fn neighbors(&self, entity_id: u64, relation: Option<&str>) -> Vec<&GraphEntity> {
233        // Collect (weight, entity) pairs.
234        let mut candidates: Vec<(f32, &GraphEntity)> = self
235            .edges
236            .iter()
237            .filter(|e| e.from_id == entity_id && relation.is_none_or(|r| e.relation == r))
238            .filter_map(|e| self.entities.get(&e.to_id).map(|entity| (e.weight, entity)))
239            .collect();
240
241        // Sort by weight descending; use entity_id as tiebreaker for determinism.
242        candidates.sort_by(|(wa, ea), (wb, eb)| {
243            wb.partial_cmp(wa)
244                .unwrap_or(std::cmp::Ordering::Equal)
245                .then_with(|| ea.entity_id.cmp(&eb.entity_id))
246        });
247
248        candidates.into_iter().map(|(_, e)| e).collect()
249    }
250
251    /// BFS traversal from `query.start_entity_id` up to `query.max_hops` hops.
252    ///
253    /// At every hop the relation filter and entity-kind filter (from the
254    /// [`GraphQuery`]) are applied to the *destination* entity.  The start
255    /// entity itself is excluded from the result.  Results are sorted by
256    /// `entity_id` ascending.
257    pub fn traverse(&self, query: &GraphQuery) -> Vec<&GraphEntity> {
258        let mut visited: HashSet<u64> = HashSet::new();
259        let mut result_ids: Vec<u64> = Vec::new();
260
261        visited.insert(query.start_entity_id);
262
263        // Queue stores (entity_id, remaining_hops).
264        let mut queue: VecDeque<(u64, usize)> = VecDeque::new();
265        queue.push_back((query.start_entity_id, query.max_hops));
266
267        while let Some((current_id, hops_left)) = queue.pop_front() {
268            if hops_left == 0 {
269                continue;
270            }
271
272            for edge in self.edges.iter().filter(|e| e.from_id == current_id) {
273                // Apply relation filter.
274                if let Some(ref rel) = query.relation_filter {
275                    if &edge.relation != rel {
276                        continue;
277                    }
278                }
279
280                let dest_id = edge.to_id;
281
282                if visited.contains(&dest_id) {
283                    continue;
284                }
285                visited.insert(dest_id);
286
287                // Apply entity-kind filter: the destination must exist and
288                // match the requested kind (if any).
289                if let Some(entity) = self.entities.get(&dest_id) {
290                    let kind_ok = query.entity_kind_filter.is_none_or(|k| entity.kind == k);
291                    if kind_ok {
292                        result_ids.push(dest_id);
293                    }
294                    // Continue BFS regardless of the kind filter so that we
295                    // can discover matching entities further in the graph.
296                    queue.push_back((dest_id, hops_left - 1));
297                }
298            }
299        }
300
301        result_ids.sort_unstable();
302        result_ids
303            .iter()
304            .filter_map(|id| self.entities.get(id))
305            .collect()
306    }
307
308    /// Find all entities (excluding `entity_id` itself) whose embedding has a
309    /// cosine similarity ≥ `threshold` with the embedding of `entity_id`.
310    ///
311    /// Returns an empty vec if the query entity does not exist or has no
312    /// embedding.  Results are sorted by similarity descending.
313    pub fn similar_entities(&self, entity_id: u64, threshold: f32) -> Vec<(&GraphEntity, f32)> {
314        let query_emb = match self
315            .entities
316            .get(&entity_id)
317            .and_then(|e| e.embedding.as_ref())
318        {
319            Some(emb) => emb,
320            None => return Vec::new(),
321        };
322
323        let mut results: Vec<(&GraphEntity, f32)> = self
324            .entities
325            .values()
326            .filter(|e| e.entity_id != entity_id)
327            .filter_map(|e| {
328                e.embedding
329                    .as_ref()
330                    .map(|emb| (e, cosine_sim(query_emb, emb)))
331            })
332            .filter(|(_, sim)| *sim >= threshold)
333            .collect();
334
335        results.sort_by(|(_, sa), (_, sb)| sb.partial_cmp(sa).unwrap_or(std::cmp::Ordering::Equal));
336
337        results
338    }
339
340    // ------------------------------------------------------------------
341    // Statistics
342    // ------------------------------------------------------------------
343
344    /// Return aggregate statistics for this graph.
345    pub fn stats(&self) -> KnowledgeGraphStats {
346        let total_entities = self.entities.len();
347        let total_edges = self.edges.len();
348        let entities_with_embeddings = self
349            .entities
350            .values()
351            .filter(|e| e.embedding.is_some())
352            .count();
353
354        let avg_degree = if total_entities == 0 {
355            0.0
356        } else {
357            (total_edges as f64 * 2.0) / total_entities as f64
358        };
359
360        KnowledgeGraphStats {
361            total_entities,
362            total_edges,
363            entities_with_embeddings,
364            avg_degree,
365        }
366    }
367}
368
369impl Default for SemanticKnowledgeGraph {
370    fn default() -> Self {
371        Self::new()
372    }
373}
374
375// ---------------------------------------------------------------------------
376// Tests
377// ---------------------------------------------------------------------------
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    // ------------------------------------------------------------------
384    // Helper builders
385    // ------------------------------------------------------------------
386
387    fn small_graph() -> SemanticKnowledgeGraph {
388        let mut g = SemanticKnowledgeGraph::new();
389        // 0: Concept "AI"
390        let ai = g.add_entity("AI", EntityKind::Concept, Some(vec![1.0, 0.0]));
391        // 1: Document "ML paper"
392        let paper = g.add_entity("ML paper", EntityKind::Document, Some(vec![0.9, 0.1]));
393        // 2: Person "Alice"
394        let alice = g.add_entity("Alice", EntityKind::Person, Some(vec![0.0, 1.0]));
395        // 3: Organization "OpenAI"
396        let openai = g.add_entity("OpenAI", EntityKind::Organization, None);
397        // Edges
398        g.add_edge(ai, paper, "is_about", 0.9);
399        g.add_edge(paper, alice, "authored_by", 0.8);
400        g.add_edge(ai, openai, "mentions", 0.5);
401        g
402    }
403
404    // ------------------------------------------------------------------
405    // Construction
406    // ------------------------------------------------------------------
407
408    #[test]
409    fn test_new_starts_empty() {
410        let g = SemanticKnowledgeGraph::new();
411        assert!(g.entities.is_empty());
412        assert!(g.edges.is_empty());
413        assert_eq!(g.next_entity_id, 0);
414        assert_eq!(g.next_edge_id, 0);
415    }
416
417    // ------------------------------------------------------------------
418    // add_entity
419    // ------------------------------------------------------------------
420
421    #[test]
422    fn test_add_entity_returns_incrementing_ids() {
423        let mut g = SemanticKnowledgeGraph::new();
424        let id0 = g.add_entity("A", EntityKind::Concept, None);
425        let id1 = g.add_entity("B", EntityKind::Document, None);
426        let id2 = g.add_entity("C", EntityKind::Person, None);
427        assert_eq!(id0, 0);
428        assert_eq!(id1, 1);
429        assert_eq!(id2, 2);
430    }
431
432    #[test]
433    fn test_add_entity_stores_properties() {
434        let mut g = SemanticKnowledgeGraph::new();
435        let id = g.add_entity("E", EntityKind::Event, Some(vec![0.1, 0.2]));
436        let e = g.get_entity(id).expect("entity must exist");
437        assert_eq!(e.name, "E");
438        assert_eq!(e.kind, EntityKind::Event);
439        assert_eq!(e.embedding, Some(vec![0.1, 0.2]));
440        assert!(e.properties.is_empty());
441    }
442
443    // ------------------------------------------------------------------
444    // add_edge
445    // ------------------------------------------------------------------
446
447    #[test]
448    fn test_add_edge_stores_edge() {
449        let mut g = SemanticKnowledgeGraph::new();
450        let a = g.add_entity("A", EntityKind::Concept, None);
451        let b = g.add_entity("B", EntityKind::Concept, None);
452        let eid = g.add_edge(a, b, "related", 0.7);
453        assert_eq!(eid, 0);
454        assert_eq!(g.edges.len(), 1);
455        let edge = &g.edges[0];
456        assert_eq!(edge.from_id, a);
457        assert_eq!(edge.to_id, b);
458        assert_eq!(edge.relation, "related");
459        assert!((edge.weight - 0.7).abs() < f32::EPSILON);
460    }
461
462    // ------------------------------------------------------------------
463    // get_entity
464    // ------------------------------------------------------------------
465
466    #[test]
467    fn test_get_entity_some() {
468        let g = small_graph();
469        assert!(g.get_entity(0).is_some());
470        assert!(g.get_entity(1).is_some());
471    }
472
473    #[test]
474    fn test_get_entity_none() {
475        let g = small_graph();
476        assert!(g.get_entity(999).is_none());
477    }
478
479    // ------------------------------------------------------------------
480    // neighbors
481    // ------------------------------------------------------------------
482
483    #[test]
484    fn test_neighbors_returns_correct_entities() {
485        let g = small_graph();
486        // Entity 0 (AI) has outgoing edges to 1 (paper) and 3 (openai).
487        let nbrs = g.neighbors(0, None);
488        let ids: Vec<u64> = nbrs.iter().map(|e| e.entity_id).collect();
489        assert!(ids.contains(&1));
490        assert!(ids.contains(&3));
491    }
492
493    #[test]
494    fn test_neighbors_with_relation_filter() {
495        let g = small_graph();
496        let nbrs = g.neighbors(0, Some("is_about"));
497        assert_eq!(nbrs.len(), 1);
498        assert_eq!(nbrs[0].entity_id, 1);
499    }
500
501    #[test]
502    fn test_neighbors_sorted_by_weight_desc() {
503        let mut g = SemanticKnowledgeGraph::new();
504        let src = g.add_entity("src", EntityKind::Concept, None);
505        let a = g.add_entity("a", EntityKind::Concept, None);
506        let b = g.add_entity("b", EntityKind::Concept, None);
507        let c = g.add_entity("c", EntityKind::Concept, None);
508        g.add_edge(src, a, "r", 0.3);
509        g.add_edge(src, b, "r", 0.9);
510        g.add_edge(src, c, "r", 0.6);
511        let nbrs = g.neighbors(src, None);
512        let weights: Vec<f32> = nbrs
513            .iter()
514            .map(|e| {
515                g.edges
516                    .iter()
517                    .find(|edge| edge.from_id == src && edge.to_id == e.entity_id)
518                    .map(|edge| edge.weight)
519                    .unwrap_or(0.0)
520            })
521            .collect();
522        assert!(weights[0] >= weights[1]);
523        assert!(weights[1] >= weights[2]);
524    }
525
526    // ------------------------------------------------------------------
527    // traverse
528    // ------------------------------------------------------------------
529
530    #[test]
531    fn test_traverse_single_hop() {
532        let g = small_graph();
533        let query = GraphQuery {
534            start_entity_id: 0,
535            relation_filter: None,
536            max_hops: 1,
537            entity_kind_filter: None,
538        };
539        let result = g.traverse(&query);
540        let ids: Vec<u64> = result.iter().map(|e| e.entity_id).collect();
541        // Direct neighbours of 0 are 1 and 3.
542        assert!(ids.contains(&1));
543        assert!(ids.contains(&3));
544        // 2 is two hops away; must not appear.
545        assert!(!ids.contains(&2));
546    }
547
548    #[test]
549    fn test_traverse_multiple_hops() {
550        let g = small_graph();
551        let query = GraphQuery {
552            start_entity_id: 0,
553            relation_filter: None,
554            max_hops: 2,
555            entity_kind_filter: None,
556        };
557        let result = g.traverse(&query);
558        let ids: Vec<u64> = result.iter().map(|e| e.entity_id).collect();
559        // Two hops: 0 -> 1 -> 2
560        assert!(ids.contains(&1));
561        assert!(ids.contains(&2));
562        assert!(ids.contains(&3));
563    }
564
565    #[test]
566    fn test_traverse_with_relation_filter() {
567        let g = small_graph();
568        let query = GraphQuery {
569            start_entity_id: 0,
570            relation_filter: Some("is_about".to_owned()),
571            max_hops: 2,
572            entity_kind_filter: None,
573        };
574        let result = g.traverse(&query);
575        let ids: Vec<u64> = result.iter().map(|e| e.entity_id).collect();
576        // Only "is_about" edges: 0 -> 1.
577        // From 1, "authored_by" edge to 2 is filtered out.
578        assert!(ids.contains(&1));
579        assert!(!ids.contains(&2));
580        assert!(!ids.contains(&3));
581    }
582
583    #[test]
584    fn test_traverse_with_entity_kind_filter() {
585        let g = small_graph();
586        let query = GraphQuery {
587            start_entity_id: 0,
588            relation_filter: None,
589            max_hops: 2,
590            entity_kind_filter: Some(EntityKind::Person),
591        };
592        let result = g.traverse(&query);
593        let ids: Vec<u64> = result.iter().map(|e| e.entity_id).collect();
594        // Only Person entities: Alice (2).
595        assert_eq!(ids, vec![2]);
596    }
597
598    #[test]
599    fn test_traverse_excludes_start_entity() {
600        let g = small_graph();
601        let query = GraphQuery {
602            start_entity_id: 0,
603            relation_filter: None,
604            max_hops: 5,
605            entity_kind_filter: None,
606        };
607        let result = g.traverse(&query);
608        assert!(!result.iter().any(|e| e.entity_id == 0));
609    }
610
611    #[test]
612    fn test_traverse_sorted_by_entity_id() {
613        let g = small_graph();
614        let query = GraphQuery {
615            start_entity_id: 0,
616            relation_filter: None,
617            max_hops: 5,
618            entity_kind_filter: None,
619        };
620        let result = g.traverse(&query);
621        let ids: Vec<u64> = result.iter().map(|e| e.entity_id).collect();
622        let mut sorted = ids.clone();
623        sorted.sort_unstable();
624        assert_eq!(ids, sorted);
625    }
626
627    #[test]
628    fn test_traverse_zero_hops_returns_empty() {
629        let g = small_graph();
630        let query = GraphQuery {
631            start_entity_id: 0,
632            relation_filter: None,
633            max_hops: 0,
634            entity_kind_filter: None,
635        };
636        let result = g.traverse(&query);
637        assert!(result.is_empty());
638    }
639
640    // ------------------------------------------------------------------
641    // similar_entities
642    // ------------------------------------------------------------------
643
644    #[test]
645    fn test_similar_entities_above_threshold() {
646        let g = small_graph();
647        // Entity 0 embedding = [1, 0]; entity 1 embedding = [0.9, 0.1]
648        // cosine_sim should be high.
649        let sims = g.similar_entities(0, 0.9);
650        assert!(!sims.is_empty());
651        let ids: Vec<u64> = sims.iter().map(|(e, _)| e.entity_id).collect();
652        assert!(ids.contains(&1));
653    }
654
655    #[test]
656    fn test_similar_entities_excludes_self() {
657        let g = small_graph();
658        let sims = g.similar_entities(0, 0.0);
659        assert!(sims.iter().all(|(e, _)| e.entity_id != 0));
660    }
661
662    #[test]
663    fn test_similar_entities_sorted_by_sim_desc() {
664        let g = small_graph();
665        let sims = g.similar_entities(0, 0.0);
666        if sims.len() > 1 {
667            for i in 0..sims.len() - 1 {
668                assert!(sims[i].1 >= sims[i + 1].1);
669            }
670        }
671    }
672
673    #[test]
674    fn test_similar_entities_empty_when_no_embeddings() {
675        let mut g = SemanticKnowledgeGraph::new();
676        let a = g.add_entity("A", EntityKind::Concept, None);
677        let _b = g.add_entity("B", EntityKind::Concept, None);
678        // Neither entity has an embedding.
679        let sims = g.similar_entities(a, 0.0);
680        assert!(sims.is_empty());
681    }
682
683    #[test]
684    fn test_similar_entities_returns_empty_for_missing_entity() {
685        let g = small_graph();
686        let sims = g.similar_entities(9999, 0.0);
687        assert!(sims.is_empty());
688    }
689
690    // ------------------------------------------------------------------
691    // remove_entity
692    // ------------------------------------------------------------------
693
694    #[test]
695    fn test_remove_entity_removes_node() {
696        let mut g = small_graph();
697        let removed = g.remove_entity(0);
698        assert!(removed);
699        assert!(g.get_entity(0).is_none());
700    }
701
702    #[test]
703    fn test_remove_entity_removes_connected_edges() {
704        let mut g = small_graph();
705        let edges_before = g.edges.len();
706        g.remove_entity(0);
707        // Entity 0 has two outgoing edges (to 1 and 3); both must be gone.
708        assert!(g.edges.len() < edges_before);
709        assert!(g.edges.iter().all(|e| e.from_id != 0 && e.to_id != 0));
710    }
711
712    #[test]
713    fn test_remove_entity_false_for_unknown() {
714        let mut g = small_graph();
715        assert!(!g.remove_entity(999));
716    }
717
718    // ------------------------------------------------------------------
719    // stats
720    // ------------------------------------------------------------------
721
722    #[test]
723    fn test_stats_total_entities_and_edges() {
724        let g = small_graph();
725        let s = g.stats();
726        // small_graph adds 4 entities and 3 edges.
727        assert_eq!(s.total_entities, 4);
728        assert_eq!(s.total_edges, 3);
729    }
730
731    #[test]
732    fn test_stats_entities_with_embeddings() {
733        let g = small_graph();
734        let s = g.stats();
735        // AI, ML paper, Alice have embeddings; OpenAI does not.
736        assert_eq!(s.entities_with_embeddings, 3);
737    }
738
739    #[test]
740    fn test_stats_avg_degree() {
741        let g = small_graph();
742        let s = g.stats();
743        let expected = (3_f64 * 2.0) / 4.0;
744        assert!((s.avg_degree - expected).abs() < 1e-9);
745    }
746
747    #[test]
748    fn test_stats_avg_degree_empty_graph() {
749        let g = SemanticKnowledgeGraph::new();
750        let s = g.stats();
751        assert_eq!(s.avg_degree, 0.0);
752    }
753
754    // ------------------------------------------------------------------
755    // cosine_sim
756    // ------------------------------------------------------------------
757
758    #[test]
759    fn test_cosine_sim_identical_vectors() {
760        let v = vec![1.0_f32, 2.0, 3.0];
761        let sim = cosine_sim(&v, &v);
762        assert!((sim - 1.0).abs() < 1e-6);
763    }
764
765    #[test]
766    fn test_cosine_sim_orthogonal_vectors() {
767        let a = vec![1.0_f32, 0.0];
768        let b = vec![0.0_f32, 1.0];
769        let sim = cosine_sim(&a, &b);
770        assert!(sim.abs() < 1e-6);
771    }
772
773    #[test]
774    fn test_cosine_sim_zero_vector() {
775        let a = vec![0.0_f32, 0.0];
776        let b = vec![1.0_f32, 0.0];
777        let sim = cosine_sim(&a, &b);
778        assert_eq!(sim, 0.0);
779    }
780}