Skip to main content

ipfrs_semantic/
document_graph.rs

1//! Semantic Document Graph
2//!
3//! Graph structure for document relationships based on semantic similarity.
4//! Nodes represent documents with embeddings; edges encode similarity, citation,
5//! or cluster-membership relationships.
6
7use std::collections::{HashMap, HashSet, VecDeque};
8
9// ---------------------------------------------------------------------------
10// Types
11// ---------------------------------------------------------------------------
12
13/// Kind of relationship between two documents.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum EdgeKind {
16    /// Cosine similarity above the graph threshold.
17    Similar,
18    /// Explicit citation / reference.
19    Citation,
20    /// Both documents belong to the same cluster.
21    SameCluster,
22}
23
24/// A node in the semantic document graph.
25#[derive(Debug, Clone)]
26pub struct DocGraphNode {
27    /// Unique document identifier.
28    pub doc_id: String,
29    /// Embedding vector for this document.
30    pub embedding: Vec<f64>,
31    /// Arbitrary key-value metadata.
32    pub metadata: HashMap<String, String>,
33}
34
35/// A directed edge in the semantic document graph.
36#[derive(Debug, Clone)]
37pub struct DocGraphEdge {
38    /// Source document id.
39    pub source: String,
40    /// Target document id.
41    pub target: String,
42    /// Relationship kind.
43    pub kind: EdgeKind,
44    /// Edge weight (e.g. similarity score).
45    pub weight: f64,
46}
47
48/// Summary statistics for the graph.
49#[derive(Debug, Clone)]
50pub struct DocumentGraphStats {
51    pub node_count: usize,
52    pub edge_count: usize,
53    pub avg_degree: f64,
54    pub component_count: usize,
55}
56
57// ---------------------------------------------------------------------------
58// Cosine similarity helper
59// ---------------------------------------------------------------------------
60
61/// Cosine similarity between two vectors.  Returns 0.0 when either vector has
62/// zero magnitude or the lengths differ.
63pub fn cosine_sim(a: &[f64], b: &[f64]) -> f64 {
64    if a.len() != b.len() || a.is_empty() {
65        return 0.0;
66    }
67    let mut dot = 0.0_f64;
68    let mut mag_a = 0.0_f64;
69    let mut mag_b = 0.0_f64;
70    for (x, y) in a.iter().zip(b.iter()) {
71        dot += x * y;
72        mag_a += x * x;
73        mag_b += y * y;
74    }
75    let denom = mag_a.sqrt() * mag_b.sqrt();
76    if denom == 0.0 {
77        0.0
78    } else {
79        dot / denom
80    }
81}
82
83// ---------------------------------------------------------------------------
84// SemanticDocumentGraph
85// ---------------------------------------------------------------------------
86
87/// Graph of document relationships based on semantic similarity and explicit
88/// annotations (citations, cluster membership).
89pub struct SemanticDocumentGraph {
90    nodes: HashMap<String, DocGraphNode>,
91    edges: Vec<DocGraphEdge>,
92    /// doc_id → indices into `edges` where this doc appears as source **or** target.
93    adjacency: HashMap<String, Vec<usize>>,
94    similarity_threshold: f64,
95}
96
97impl SemanticDocumentGraph {
98    /// Create a new graph with the given cosine-similarity threshold for
99    /// automatic linking.
100    pub fn new(similarity_threshold: f64) -> Self {
101        Self {
102            nodes: HashMap::new(),
103            edges: Vec::new(),
104            adjacency: HashMap::new(),
105            similarity_threshold,
106        }
107    }
108
109    /// Add a document node.  If a node with the same `doc_id` already exists
110    /// it is silently replaced (edges are **not** touched).
111    pub fn add_node(
112        &mut self,
113        doc_id: &str,
114        embedding: Vec<f64>,
115        metadata: HashMap<String, String>,
116    ) {
117        let node = DocGraphNode {
118            doc_id: doc_id.to_string(),
119            embedding,
120            metadata,
121        };
122        self.nodes.insert(doc_id.to_string(), node);
123        // Ensure the adjacency entry exists.
124        self.adjacency.entry(doc_id.to_string()).or_default();
125    }
126
127    /// Remove a node and all edges incident to it.  Returns `true` if the node
128    /// existed.
129    pub fn remove_node(&mut self, doc_id: &str) -> bool {
130        if self.nodes.remove(doc_id).is_none() {
131            return false;
132        }
133
134        // Collect indices of edges to remove (those touching this node).
135        let indices_to_remove: HashSet<usize> = self
136            .adjacency
137            .remove(doc_id)
138            .unwrap_or_default()
139            .into_iter()
140            .collect();
141
142        if indices_to_remove.is_empty() {
143            return true;
144        }
145
146        // Build a new edge list, keeping only edges not in the removal set.
147        // We also need to rebuild the entire adjacency map because indices shift.
148        let old_edges = std::mem::take(&mut self.edges);
149        let mut new_adjacency: HashMap<String, Vec<usize>> =
150            self.nodes.keys().map(|k| (k.clone(), Vec::new())).collect();
151
152        for (old_idx, edge) in old_edges.into_iter().enumerate() {
153            if indices_to_remove.contains(&old_idx) {
154                continue;
155            }
156            let new_idx = self.edges.len();
157            if let Some(list) = new_adjacency.get_mut(&edge.source) {
158                list.push(new_idx);
159            }
160            if let Some(list) = new_adjacency.get_mut(&edge.target) {
161                list.push(new_idx);
162            }
163            self.edges.push(edge);
164        }
165
166        self.adjacency = new_adjacency;
167        true
168    }
169
170    /// Add an explicit edge between two existing nodes.  Returns an error if
171    /// either endpoint does not exist.
172    pub fn add_edge(
173        &mut self,
174        source: &str,
175        target: &str,
176        kind: EdgeKind,
177        weight: f64,
178    ) -> Result<(), String> {
179        if !self.nodes.contains_key(source) {
180            return Err(format!("source node '{}' does not exist", source));
181        }
182        if !self.nodes.contains_key(target) {
183            return Err(format!("target node '{}' does not exist", target));
184        }
185
186        let idx = self.edges.len();
187        self.edges.push(DocGraphEdge {
188            source: source.to_string(),
189            target: target.to_string(),
190            kind,
191            weight,
192        });
193
194        self.adjacency
195            .entry(source.to_string())
196            .or_default()
197            .push(idx);
198        self.adjacency
199            .entry(target.to_string())
200            .or_default()
201            .push(idx);
202
203        Ok(())
204    }
205
206    /// Compute pairwise cosine similarity for all node pairs and add `Similar`
207    /// edges for every pair whose similarity meets or exceeds the threshold.
208    ///
209    /// Existing `Similar` edges are **not** removed first — call this on a
210    /// fresh graph or manually prune duplicates if needed.
211    pub fn auto_link_similar(&mut self) {
212        let ids: Vec<String> = self.nodes.keys().cloned().collect();
213        let len = ids.len();
214
215        // Collect edges to add (avoid borrow conflict).
216        let mut new_edges: Vec<(String, String, f64)> = Vec::new();
217
218        for i in 0..len {
219            for j in (i + 1)..len {
220                let a = self
221                    .nodes
222                    .get(&ids[i])
223                    .map(|n| n.embedding.as_slice())
224                    .unwrap_or(&[]);
225                let b = self
226                    .nodes
227                    .get(&ids[j])
228                    .map(|n| n.embedding.as_slice())
229                    .unwrap_or(&[]);
230
231                let sim = cosine_sim(a, b);
232                if sim >= self.similarity_threshold {
233                    new_edges.push((ids[i].clone(), ids[j].clone(), sim));
234                }
235            }
236        }
237
238        for (src, tgt, w) in new_edges {
239            let idx = self.edges.len();
240            self.edges.push(DocGraphEdge {
241                source: src.clone(),
242                target: tgt.clone(),
243                kind: EdgeKind::Similar,
244                weight: w,
245            });
246            self.adjacency.entry(src).or_default().push(idx);
247            self.adjacency.entry(tgt).or_default().push(idx);
248        }
249    }
250
251    /// Return neighbour nodes with their edge weights.
252    pub fn neighbors(&self, doc_id: &str) -> Vec<(&DocGraphNode, f64)> {
253        let indices = match self.adjacency.get(doc_id) {
254            Some(v) => v,
255            None => return Vec::new(),
256        };
257
258        let mut result = Vec::new();
259        for &idx in indices {
260            if let Some(edge) = self.edges.get(idx) {
261                let other_id = if edge.source == doc_id {
262                    &edge.target
263                } else {
264                    &edge.source
265                };
266                if let Some(node) = self.nodes.get(other_id) {
267                    result.push((node, edge.weight));
268                }
269            }
270        }
271        result
272    }
273
274    /// BFS shortest path (by hop count) from `from` to `to`.  Returns the
275    /// sequence of doc-ids including both endpoints, or `None` if no path
276    /// exists.
277    pub fn shortest_path(&self, from: &str, to: &str) -> Option<Vec<String>> {
278        if !self.nodes.contains_key(from) || !self.nodes.contains_key(to) {
279            return None;
280        }
281        if from == to {
282            return Some(vec![from.to_string()]);
283        }
284
285        let mut visited: HashSet<String> = HashSet::new();
286        let mut queue: VecDeque<String> = VecDeque::new();
287        let mut parent: HashMap<String, String> = HashMap::new();
288
289        visited.insert(from.to_string());
290        queue.push_back(from.to_string());
291
292        while let Some(current) = queue.pop_front() {
293            if let Some(indices) = self.adjacency.get(&current) {
294                for &idx in indices {
295                    if let Some(edge) = self.edges.get(idx) {
296                        let neighbor = if edge.source == current {
297                            &edge.target
298                        } else {
299                            &edge.source
300                        };
301                        if visited.contains(neighbor) {
302                            continue;
303                        }
304                        visited.insert(neighbor.clone());
305                        parent.insert(neighbor.clone(), current.clone());
306
307                        if neighbor == to {
308                            // Reconstruct path.
309                            let mut path = vec![to.to_string()];
310                            let mut cur = to.to_string();
311                            while let Some(p) = parent.get(&cur) {
312                                path.push(p.clone());
313                                cur = p.clone();
314                            }
315                            path.reverse();
316                            return Some(path);
317                        }
318                        queue.push_back(neighbor.clone());
319                    }
320                }
321            }
322        }
323
324        None
325    }
326
327    /// Return the connected components of the (undirected) graph.
328    pub fn connected_components(&self) -> Vec<Vec<String>> {
329        let mut visited: HashSet<String> = HashSet::new();
330        let mut components: Vec<Vec<String>> = Vec::new();
331
332        for id in self.nodes.keys() {
333            if visited.contains(id) {
334                continue;
335            }
336            let mut component = Vec::new();
337            let mut stack = vec![id.clone()];
338            while let Some(cur) = stack.pop() {
339                if !visited.insert(cur.clone()) {
340                    continue;
341                }
342                component.push(cur.clone());
343
344                if let Some(indices) = self.adjacency.get(&cur) {
345                    for &idx in indices {
346                        if let Some(edge) = self.edges.get(idx) {
347                            let neighbor = if edge.source == cur {
348                                &edge.target
349                            } else {
350                                &edge.source
351                            };
352                            if !visited.contains(neighbor) {
353                                stack.push(neighbor.clone());
354                            }
355                        }
356                    }
357                }
358            }
359            component.sort();
360            components.push(component);
361        }
362
363        components.sort_by(|a, b| a.first().cmp(&b.first()));
364        components
365    }
366
367    /// Number of nodes.
368    pub fn node_count(&self) -> usize {
369        self.nodes.len()
370    }
371
372    /// Number of edges.
373    pub fn edge_count(&self) -> usize {
374        self.edges.len()
375    }
376
377    /// Degree (number of incident edges) for a given node.  Returns 0 if the
378    /// node does not exist.
379    pub fn degree(&self, doc_id: &str) -> usize {
380        self.adjacency.get(doc_id).map(|v| v.len()).unwrap_or(0)
381    }
382
383    /// Aggregate statistics.
384    pub fn stats(&self) -> DocumentGraphStats {
385        let nc = self.node_count();
386        let ec = self.edge_count();
387        let avg = if nc == 0 {
388            0.0
389        } else {
390            // Each edge contributes to two nodes' degree, so sum of degrees = 2*ec.
391            (2.0 * ec as f64) / nc as f64
392        };
393        let cc = self.connected_components().len();
394        DocumentGraphStats {
395            node_count: nc,
396            edge_count: ec,
397            avg_degree: avg,
398            component_count: cc,
399        }
400    }
401}
402
403// ---------------------------------------------------------------------------
404// Tests
405// ---------------------------------------------------------------------------
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    fn empty_meta() -> HashMap<String, String> {
412        HashMap::new()
413    }
414
415    fn meta(pairs: &[(&str, &str)]) -> HashMap<String, String> {
416        pairs
417            .iter()
418            .map(|(k, v)| (k.to_string(), v.to_string()))
419            .collect()
420    }
421
422    // -- cosine_sim --
423
424    #[test]
425    fn cosine_sim_identical_vectors() {
426        let v = vec![1.0, 2.0, 3.0];
427        let s = cosine_sim(&v, &v);
428        assert!((s - 1.0).abs() < 1e-9);
429    }
430
431    #[test]
432    fn cosine_sim_orthogonal() {
433        let a = vec![1.0, 0.0];
434        let b = vec![0.0, 1.0];
435        assert!(cosine_sim(&a, &b).abs() < 1e-9);
436    }
437
438    #[test]
439    fn cosine_sim_opposite() {
440        let a = vec![1.0, 0.0];
441        let b = vec![-1.0, 0.0];
442        assert!((cosine_sim(&a, &b) + 1.0).abs() < 1e-9);
443    }
444
445    #[test]
446    fn cosine_sim_different_lengths() {
447        assert_eq!(cosine_sim(&[1.0, 2.0], &[1.0]), 0.0);
448    }
449
450    #[test]
451    fn cosine_sim_empty() {
452        assert_eq!(cosine_sim(&[], &[]), 0.0);
453    }
454
455    #[test]
456    fn cosine_sim_zero_vector() {
457        assert_eq!(cosine_sim(&[0.0, 0.0], &[1.0, 2.0]), 0.0);
458    }
459
460    // -- add / remove nodes --
461
462    #[test]
463    fn add_node_increases_count() {
464        let mut g = SemanticDocumentGraph::new(0.7);
465        assert_eq!(g.node_count(), 0);
466        g.add_node("a", vec![1.0], empty_meta());
467        assert_eq!(g.node_count(), 1);
468        g.add_node("b", vec![2.0], empty_meta());
469        assert_eq!(g.node_count(), 2);
470    }
471
472    #[test]
473    fn add_node_replaces_existing() {
474        let mut g = SemanticDocumentGraph::new(0.7);
475        g.add_node("a", vec![1.0], meta(&[("k", "v1")]));
476        g.add_node("a", vec![2.0], meta(&[("k", "v2")]));
477        assert_eq!(g.node_count(), 1);
478    }
479
480    #[test]
481    fn remove_node_returns_false_for_missing() {
482        let mut g = SemanticDocumentGraph::new(0.7);
483        assert!(!g.remove_node("x"));
484    }
485
486    #[test]
487    fn remove_node_decreases_count() {
488        let mut g = SemanticDocumentGraph::new(0.7);
489        g.add_node("a", vec![1.0], empty_meta());
490        g.add_node("b", vec![2.0], empty_meta());
491        assert!(g.remove_node("a"));
492        assert_eq!(g.node_count(), 1);
493    }
494
495    #[test]
496    fn remove_node_cascades_edges() {
497        let mut g = SemanticDocumentGraph::new(0.7);
498        g.add_node("a", vec![1.0], empty_meta());
499        g.add_node("b", vec![2.0], empty_meta());
500        g.add_node("c", vec![3.0], empty_meta());
501        g.add_edge("a", "b", EdgeKind::Citation, 1.0).ok();
502        g.add_edge("b", "c", EdgeKind::Citation, 1.0).ok();
503        g.add_edge("a", "c", EdgeKind::Similar, 0.8).ok();
504        assert_eq!(g.edge_count(), 3);
505
506        g.remove_node("a");
507        // Only b-c should remain.
508        assert_eq!(g.edge_count(), 1);
509        assert_eq!(g.degree("b"), 1);
510        assert_eq!(g.degree("c"), 1);
511    }
512
513    // -- add_edge --
514
515    #[test]
516    fn add_edge_ok() {
517        let mut g = SemanticDocumentGraph::new(0.7);
518        g.add_node("a", vec![1.0], empty_meta());
519        g.add_node("b", vec![2.0], empty_meta());
520        assert!(g.add_edge("a", "b", EdgeKind::Citation, 1.0).is_ok());
521        assert_eq!(g.edge_count(), 1);
522    }
523
524    #[test]
525    fn add_edge_missing_source() {
526        let mut g = SemanticDocumentGraph::new(0.7);
527        g.add_node("b", vec![2.0], empty_meta());
528        let r = g.add_edge("x", "b", EdgeKind::Citation, 1.0);
529        assert!(r.is_err());
530        assert!(r.err().unwrap_or_default().contains("source"));
531    }
532
533    #[test]
534    fn add_edge_missing_target() {
535        let mut g = SemanticDocumentGraph::new(0.7);
536        g.add_node("a", vec![1.0], empty_meta());
537        let r = g.add_edge("a", "x", EdgeKind::Citation, 1.0);
538        assert!(r.is_err());
539        assert!(r.err().unwrap_or_default().contains("target"));
540    }
541
542    #[test]
543    fn add_edge_both_missing() {
544        let mut g = SemanticDocumentGraph::new(0.7);
545        assert!(g.add_edge("x", "y", EdgeKind::Citation, 1.0).is_err());
546    }
547
548    // -- auto_link_similar --
549
550    #[test]
551    fn auto_link_similar_above_threshold() {
552        let mut g = SemanticDocumentGraph::new(0.9);
553        // Two very similar vectors.
554        g.add_node("a", vec![1.0, 0.0, 0.0], empty_meta());
555        g.add_node("b", vec![1.0, 0.01, 0.0], empty_meta());
556        g.auto_link_similar();
557        // They should be linked.
558        assert_eq!(g.edge_count(), 1);
559    }
560
561    #[test]
562    fn auto_link_similar_below_threshold() {
563        let mut g = SemanticDocumentGraph::new(0.99);
564        g.add_node("a", vec![1.0, 0.0], empty_meta());
565        g.add_node("b", vec![0.0, 1.0], empty_meta());
566        g.auto_link_similar();
567        assert_eq!(g.edge_count(), 0);
568    }
569
570    #[test]
571    fn auto_link_similar_multiple_pairs() {
572        let mut g = SemanticDocumentGraph::new(0.5);
573        g.add_node("a", vec![1.0, 0.0], empty_meta());
574        g.add_node("b", vec![0.9, 0.1], empty_meta());
575        g.add_node("c", vec![0.0, 1.0], empty_meta());
576        g.auto_link_similar();
577        // a-b should be linked (high sim), a-c and b-c probably not at 0.5 threshold
578        // cosine(a, b) ≈ 0.994 => linked
579        // cosine(a, c) = 0.0 => not linked
580        // cosine(b, c) ≈ 0.11 => not linked
581        assert_eq!(g.edge_count(), 1);
582    }
583
584    // -- neighbors --
585
586    #[test]
587    fn neighbors_returns_correct_set() {
588        let mut g = SemanticDocumentGraph::new(0.7);
589        g.add_node("a", vec![1.0], empty_meta());
590        g.add_node("b", vec![2.0], empty_meta());
591        g.add_node("c", vec![3.0], empty_meta());
592        g.add_edge("a", "b", EdgeKind::Citation, 0.9).ok();
593        g.add_edge("a", "c", EdgeKind::Similar, 0.8).ok();
594
595        let nbrs = g.neighbors("a");
596        assert_eq!(nbrs.len(), 2);
597
598        let ids: HashSet<String> = nbrs.iter().map(|(n, _)| n.doc_id.clone()).collect();
599        assert!(ids.contains("b"));
600        assert!(ids.contains("c"));
601    }
602
603    #[test]
604    fn neighbors_for_missing_node() {
605        let g = SemanticDocumentGraph::new(0.7);
606        assert!(g.neighbors("x").is_empty());
607    }
608
609    #[test]
610    fn neighbors_no_edges() {
611        let mut g = SemanticDocumentGraph::new(0.7);
612        g.add_node("a", vec![1.0], empty_meta());
613        assert!(g.neighbors("a").is_empty());
614    }
615
616    // -- shortest_path --
617
618    #[test]
619    fn shortest_path_direct() {
620        let mut g = SemanticDocumentGraph::new(0.7);
621        g.add_node("a", vec![1.0], empty_meta());
622        g.add_node("b", vec![2.0], empty_meta());
623        g.add_edge("a", "b", EdgeKind::Citation, 1.0).ok();
624
625        let path = g.shortest_path("a", "b");
626        assert_eq!(path, Some(vec!["a".to_string(), "b".to_string()]));
627    }
628
629    #[test]
630    fn shortest_path_multi_hop() {
631        let mut g = SemanticDocumentGraph::new(0.7);
632        g.add_node("a", vec![1.0], empty_meta());
633        g.add_node("b", vec![2.0], empty_meta());
634        g.add_node("c", vec![3.0], empty_meta());
635        g.add_edge("a", "b", EdgeKind::Citation, 1.0).ok();
636        g.add_edge("b", "c", EdgeKind::Citation, 1.0).ok();
637
638        let path = g.shortest_path("a", "c");
639        assert_eq!(
640            path,
641            Some(vec!["a".to_string(), "b".to_string(), "c".to_string()])
642        );
643    }
644
645    #[test]
646    fn shortest_path_same_node() {
647        let mut g = SemanticDocumentGraph::new(0.7);
648        g.add_node("a", vec![1.0], empty_meta());
649        assert_eq!(g.shortest_path("a", "a"), Some(vec!["a".to_string()]));
650    }
651
652    #[test]
653    fn shortest_path_no_connection() {
654        let mut g = SemanticDocumentGraph::new(0.7);
655        g.add_node("a", vec![1.0], empty_meta());
656        g.add_node("b", vec![2.0], empty_meta());
657        assert_eq!(g.shortest_path("a", "b"), None);
658    }
659
660    #[test]
661    fn shortest_path_missing_node() {
662        let g = SemanticDocumentGraph::new(0.7);
663        assert_eq!(g.shortest_path("x", "y"), None);
664    }
665
666    // -- connected_components --
667
668    #[test]
669    fn connected_components_single() {
670        let mut g = SemanticDocumentGraph::new(0.7);
671        g.add_node("a", vec![1.0], empty_meta());
672        g.add_node("b", vec![2.0], empty_meta());
673        g.add_edge("a", "b", EdgeKind::Citation, 1.0).ok();
674
675        let cc = g.connected_components();
676        assert_eq!(cc.len(), 1);
677        assert_eq!(cc[0].len(), 2);
678    }
679
680    #[test]
681    fn connected_components_multiple() {
682        let mut g = SemanticDocumentGraph::new(0.7);
683        g.add_node("a", vec![1.0], empty_meta());
684        g.add_node("b", vec![2.0], empty_meta());
685        g.add_node("c", vec![3.0], empty_meta());
686        g.add_node("d", vec![4.0], empty_meta());
687        g.add_edge("a", "b", EdgeKind::Citation, 1.0).ok();
688        g.add_edge("c", "d", EdgeKind::SameCluster, 1.0).ok();
689
690        let cc = g.connected_components();
691        assert_eq!(cc.len(), 2);
692    }
693
694    #[test]
695    fn connected_components_all_isolated() {
696        let mut g = SemanticDocumentGraph::new(0.7);
697        g.add_node("a", vec![1.0], empty_meta());
698        g.add_node("b", vec![2.0], empty_meta());
699        g.add_node("c", vec![3.0], empty_meta());
700        let cc = g.connected_components();
701        assert_eq!(cc.len(), 3);
702    }
703
704    // -- degree --
705
706    #[test]
707    fn degree_counts_correctly() {
708        let mut g = SemanticDocumentGraph::new(0.7);
709        g.add_node("a", vec![1.0], empty_meta());
710        g.add_node("b", vec![2.0], empty_meta());
711        g.add_node("c", vec![3.0], empty_meta());
712        g.add_edge("a", "b", EdgeKind::Citation, 1.0).ok();
713        g.add_edge("a", "c", EdgeKind::Citation, 1.0).ok();
714        assert_eq!(g.degree("a"), 2);
715        assert_eq!(g.degree("b"), 1);
716        assert_eq!(g.degree("c"), 1);
717    }
718
719    #[test]
720    fn degree_missing_node() {
721        let g = SemanticDocumentGraph::new(0.7);
722        assert_eq!(g.degree("x"), 0);
723    }
724
725    // -- stats --
726
727    #[test]
728    fn stats_empty_graph() {
729        let g = SemanticDocumentGraph::new(0.7);
730        let s = g.stats();
731        assert_eq!(s.node_count, 0);
732        assert_eq!(s.edge_count, 0);
733        assert_eq!(s.avg_degree, 0.0);
734        assert_eq!(s.component_count, 0);
735    }
736
737    #[test]
738    fn stats_non_empty() {
739        let mut g = SemanticDocumentGraph::new(0.7);
740        g.add_node("a", vec![1.0], empty_meta());
741        g.add_node("b", vec![2.0], empty_meta());
742        g.add_node("c", vec![3.0], empty_meta());
743        g.add_edge("a", "b", EdgeKind::Citation, 1.0).ok();
744        g.add_edge("b", "c", EdgeKind::Similar, 0.8).ok();
745
746        let s = g.stats();
747        assert_eq!(s.node_count, 3);
748        assert_eq!(s.edge_count, 2);
749        // avg_degree = 2*2/3 ≈ 1.333
750        assert!((s.avg_degree - 4.0 / 3.0).abs() < 1e-9);
751        assert_eq!(s.component_count, 1);
752    }
753
754    // -- edge_count / node_count --
755
756    #[test]
757    fn empty_graph_counts() {
758        let g = SemanticDocumentGraph::new(0.7);
759        assert_eq!(g.node_count(), 0);
760        assert_eq!(g.edge_count(), 0);
761    }
762
763    // -- metadata preserved --
764
765    #[test]
766    fn metadata_is_stored() {
767        let mut g = SemanticDocumentGraph::new(0.7);
768        g.add_node("a", vec![1.0], meta(&[("title", "hello")]));
769        let nbrs_unused = g.neighbors("a"); // just ensure it doesn't panic
770        drop(nbrs_unused);
771        // Access node directly via stats / node_count; we can also verify via
772        // auto_link_similar to exercise the embedding path.
773        assert_eq!(g.node_count(), 1);
774    }
775
776    // -- edge kind preserved --
777
778    #[test]
779    fn edge_kind_preserved() {
780        let mut g = SemanticDocumentGraph::new(0.7);
781        g.add_node("a", vec![1.0, 0.0], empty_meta());
782        g.add_node("b", vec![1.0, 0.01], empty_meta());
783        g.add_edge("a", "b", EdgeKind::SameCluster, 0.5).ok();
784        g.auto_link_similar();
785        // Should have two edges: one SameCluster and one Similar.
786        assert_eq!(g.edge_count(), 2);
787    }
788
789    // -- complex scenario --
790
791    #[test]
792    fn complex_graph_scenario() {
793        let mut g = SemanticDocumentGraph::new(0.8);
794        for i in 0..5 {
795            let id = format!("doc{}", i);
796            let emb = vec![(i as f64) * 0.1 + 0.5, 1.0 - (i as f64) * 0.1];
797            g.add_node(&id, emb, meta(&[("idx", &i.to_string())]));
798        }
799        // Chain: doc0 - doc1 - doc2 - doc3 - doc4
800        for i in 0..4 {
801            g.add_edge(
802                &format!("doc{}", i),
803                &format!("doc{}", i + 1),
804                EdgeKind::Citation,
805                1.0,
806            )
807            .ok();
808        }
809
810        assert_eq!(g.node_count(), 5);
811        assert_eq!(g.edge_count(), 4);
812        assert_eq!(g.connected_components().len(), 1);
813
814        let path = g.shortest_path("doc0", "doc4");
815        assert!(path.is_some());
816        let path = path.unwrap_or_default();
817        assert_eq!(path.len(), 5);
818        assert_eq!(path[0], "doc0");
819        assert_eq!(path[4], "doc4");
820
821        // Remove middle node.
822        g.remove_node("doc2");
823        assert_eq!(g.node_count(), 4);
824        // Edges doc1-doc2 and doc2-doc3 removed => 2 remain.
825        assert_eq!(g.edge_count(), 2);
826        // Now two components: {doc0, doc1} and {doc3, doc4}.
827        assert_eq!(g.connected_components().len(), 2);
828        assert_eq!(g.shortest_path("doc0", "doc4"), None);
829    }
830}