Skip to main content

ipfrs_semantic/
graph_linker.rs

1//! Semantic Graph Linker — builds a semantic graph by linking embeddings above a similarity
2//! threshold, enabling graph-based search and community detection.
3
4use std::collections::HashMap;
5
6// ---------------------------------------------------------------------------
7// EdgeType
8// ---------------------------------------------------------------------------
9
10/// Classifies the semantic relationship between two linked nodes.
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
12pub enum EdgeType {
13    /// Two nodes share similar (but not identical) content.
14    SimilarContent,
15    /// Two nodes are near-duplicate (cosine similarity ≥ duplicate_threshold).
16    Duplicate,
17    /// Two nodes are loosely related.
18    Related,
19    /// Two nodes express opposing/contradictory information (very low similarity).
20    Contradictory,
21}
22
23// ---------------------------------------------------------------------------
24// SemanticEdge
25// ---------------------------------------------------------------------------
26
27/// A directed (logically undirected) edge in the semantic graph.
28#[derive(Clone, Debug)]
29pub struct SemanticEdge {
30    pub from_id: u64,
31    pub to_id: u64,
32    pub similarity: f32,
33    pub edge_type: EdgeType,
34}
35
36// ---------------------------------------------------------------------------
37// GraphNode
38// ---------------------------------------------------------------------------
39
40/// A node in the semantic graph, carrying its embedding vector and optional label.
41#[derive(Clone, Debug)]
42pub struct GraphNode {
43    pub id: u64,
44    pub embedding: Vec<f32>,
45    pub label: Option<String>,
46}
47
48impl GraphNode {
49    /// Returns the number of edges incident to this node (i.e., where `from_id` or
50    /// `to_id` equals `self.id`).
51    pub fn degree(&self, edges: &[SemanticEdge]) -> usize {
52        edges
53            .iter()
54            .filter(|e| e.from_id == self.id || e.to_id == self.id)
55            .count()
56    }
57}
58
59// ---------------------------------------------------------------------------
60// LinkerConfig
61// ---------------------------------------------------------------------------
62
63/// Configuration for `SemanticGraphLinker`.
64#[derive(Clone, Debug)]
65pub struct LinkerConfig {
66    /// Minimum cosine similarity to create a `SimilarContent` edge (default 0.8).
67    pub similarity_threshold: f32,
68    /// Minimum cosine similarity to classify an edge as `Duplicate` (default 0.99).
69    pub duplicate_threshold: f32,
70    /// Maximum number of edges stored per node (default 20). Surplus edges are
71    /// removed keeping only the highest-similarity ones.
72    pub max_edges_per_node: usize,
73    /// Maximum cosine similarity to classify a pair as `Contradictory` (default 0.1).
74    pub contradiction_threshold: f32,
75}
76
77impl Default for LinkerConfig {
78    fn default() -> Self {
79        Self {
80            similarity_threshold: 0.8,
81            duplicate_threshold: 0.99,
82            max_edges_per_node: 20,
83            contradiction_threshold: 0.1,
84        }
85    }
86}
87
88// ---------------------------------------------------------------------------
89// GraphLinkerStats
90// ---------------------------------------------------------------------------
91
92/// Aggregate statistics about the semantic graph.
93#[derive(Clone, Debug, Default)]
94pub struct GraphLinkerStats {
95    pub node_count: usize,
96    pub edge_count: usize,
97    pub duplicate_count: usize,
98}
99
100impl GraphLinkerStats {
101    /// Average degree = 2 * edge_count / max(node_count, 1) (each edge contributes
102    /// to two nodes' degree counts).
103    pub fn avg_degree(&self) -> f64 {
104        (2 * self.edge_count) as f64 / self.node_count.max(1) as f64
105    }
106}
107
108// ---------------------------------------------------------------------------
109// SemanticGraphLinker
110// ---------------------------------------------------------------------------
111
112/// Builds and queries a semantic similarity graph over embedding vectors.
113pub struct SemanticGraphLinker {
114    pub nodes: HashMap<u64, GraphNode>,
115    pub edges: Vec<SemanticEdge>,
116    pub config: LinkerConfig,
117}
118
119impl SemanticGraphLinker {
120    /// Creates a new linker with the provided configuration.
121    pub fn new(config: LinkerConfig) -> Self {
122        Self {
123            nodes: HashMap::new(),
124            edges: Vec::new(),
125            config,
126        }
127    }
128
129    /// Inserts a node into the graph.  Any existing node with the same `id` is
130    /// replaced (its edges are not automatically removed; call `remove_node`
131    /// first if you want a clean replacement).
132    pub fn add_node(&mut self, node: GraphNode) {
133        self.nodes.insert(node.id, node);
134    }
135
136    /// Links all node pairs whose cosine similarity exceeds the configured
137    /// thresholds, then enforces the `max_edges_per_node` cap.
138    ///
139    /// Calling this method more than once is safe but will duplicate edges for
140    /// pairs that were already linked; it is the caller's responsibility to
141    /// clear edges first if a full rebuild is desired.
142    pub fn link_all(&mut self) {
143        let ids: Vec<u64> = self.nodes.keys().copied().collect();
144        let n = ids.len();
145
146        let sim_threshold = self.config.similarity_threshold;
147        let dup_threshold = self.config.duplicate_threshold;
148        let cont_threshold = self.config.contradiction_threshold;
149        let related_threshold = sim_threshold * 0.8;
150
151        for i in 0..n {
152            for j in (i + 1)..n {
153                let id_a = ids[i];
154                let id_b = ids[j];
155
156                let sim =
157                    cosine_similarity(&self.nodes[&id_a].embedding, &self.nodes[&id_b].embedding);
158
159                let edge_type = if sim >= dup_threshold {
160                    EdgeType::Duplicate
161                } else if sim >= sim_threshold {
162                    EdgeType::SimilarContent
163                } else if sim <= cont_threshold {
164                    EdgeType::Contradictory
165                } else if sim >= related_threshold {
166                    EdgeType::Related
167                } else {
168                    // Between related_threshold and sim_threshold: skip.
169                    continue;
170                };
171
172                self.edges.push(SemanticEdge {
173                    from_id: id_a,
174                    to_id: id_b,
175                    similarity: sim,
176                    edge_type,
177                });
178            }
179        }
180
181        // Enforce max_edges_per_node.
182        self.trim_edges();
183    }
184
185    /// Returns the IDs of all nodes directly adjacent to `node_id`.
186    pub fn neighbors(&self, node_id: u64) -> Vec<u64> {
187        let mut result = Vec::new();
188        for edge in &self.edges {
189            if edge.from_id == node_id {
190                result.push(edge.to_id);
191            } else if edge.to_id == node_id {
192                result.push(edge.from_id);
193            }
194        }
195        result.sort_unstable();
196        result.dedup();
197        result
198    }
199
200    /// Computes connected components considering only `SimilarContent` and
201    /// `Duplicate` edges (using union-find).
202    pub fn connected_components(&self) -> Vec<Vec<u64>> {
203        let ids: Vec<u64> = self.nodes.keys().copied().collect();
204        if ids.is_empty() {
205            return Vec::new();
206        }
207
208        // Build a mapping id -> index for union-find.
209        let mut index_map: HashMap<u64, usize> = HashMap::with_capacity(ids.len());
210        for (idx, &id) in ids.iter().enumerate() {
211            index_map.insert(id, idx);
212        }
213
214        let mut parent: Vec<usize> = (0..ids.len()).collect();
215        let mut rank: Vec<u8> = vec![0; ids.len()];
216
217        for edge in &self.edges {
218            if edge.edge_type != EdgeType::SimilarContent && edge.edge_type != EdgeType::Duplicate {
219                continue;
220            }
221            if let (Some(&a), Some(&b)) = (index_map.get(&edge.from_id), index_map.get(&edge.to_id))
222            {
223                union(&mut parent, &mut rank, a, b);
224            }
225        }
226
227        // Group by root.
228        let mut groups: HashMap<usize, Vec<u64>> = HashMap::new();
229        for (idx, &id) in ids.iter().enumerate() {
230            let root = find(&mut parent, idx);
231            groups.entry(root).or_default().push(id);
232        }
233
234        let mut components: Vec<Vec<u64>> = groups.into_values().collect();
235        for comp in &mut components {
236            comp.sort_unstable();
237        }
238        components.sort_by_key(|c| c[0]);
239        components
240    }
241
242    /// Removes a node and all edges incident to it from the graph.
243    pub fn remove_node(&mut self, node_id: u64) {
244        self.nodes.remove(&node_id);
245        self.edges
246            .retain(|e| e.from_id != node_id && e.to_id != node_id);
247    }
248
249    /// Returns aggregate statistics for the current graph.
250    pub fn stats(&self) -> GraphLinkerStats {
251        let duplicate_count = self
252            .edges
253            .iter()
254            .filter(|e| e.edge_type == EdgeType::Duplicate)
255            .count();
256
257        GraphLinkerStats {
258            node_count: self.nodes.len(),
259            edge_count: self.edges.len(),
260            duplicate_count,
261        }
262    }
263
264    // -----------------------------------------------------------------------
265    // Private helpers
266    // -----------------------------------------------------------------------
267
268    /// Trims edges so that no node participates in more than `max_edges_per_node`
269    /// edges.  When a node exceeds the cap, its lowest-similarity edges are
270    /// removed first.
271    fn trim_edges(&mut self) {
272        let max = self.config.max_edges_per_node;
273
274        // Count edges per node and identify which edges need pruning.
275        // We do this in a stable, deterministic way:
276        //   1. Sort all edges by similarity (descending) so we prefer to keep
277        //      the highest-similarity edges when trimming.
278        //   2. Walk the sorted list and track how many edges each node has
279        //      accumulated; mark as removed when the cap is hit.
280
281        // Build a list of (original_index, similarity) sorted descending.
282        let mut order: Vec<usize> = (0..self.edges.len()).collect();
283        order.sort_by(|&a, &b| {
284            self.edges[b]
285                .similarity
286                .partial_cmp(&self.edges[a].similarity)
287                .unwrap_or(std::cmp::Ordering::Equal)
288        });
289
290        let mut degree: HashMap<u64, usize> = HashMap::new();
291        let mut keep: Vec<bool> = vec![false; self.edges.len()];
292
293        for idx in order {
294            let edge = &self.edges[idx];
295            let da = *degree.get(&edge.from_id).unwrap_or(&0);
296            let db = *degree.get(&edge.to_id).unwrap_or(&0);
297            if da < max && db < max {
298                keep[idx] = true;
299                *degree.entry(edge.from_id).or_insert(0) += 1;
300                *degree.entry(edge.to_id).or_insert(0) += 1;
301            }
302        }
303
304        let mut kept = Vec::with_capacity(self.edges.len());
305        for (idx, edge) in self.edges.drain(..).enumerate() {
306            if keep[idx] {
307                kept.push(edge);
308            }
309        }
310        self.edges = kept;
311    }
312}
313
314// ---------------------------------------------------------------------------
315// Cosine similarity
316// ---------------------------------------------------------------------------
317
318/// Computes the cosine similarity between two vectors.  Returns 0.0 if either
319/// vector has zero magnitude.
320fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
321    let len = a.len().min(b.len());
322    if len == 0 {
323        return 0.0;
324    }
325
326    let mut dot = 0.0_f32;
327    let mut mag_a = 0.0_f32;
328    let mut mag_b = 0.0_f32;
329
330    for i in 0..len {
331        dot += a[i] * b[i];
332        mag_a += a[i] * a[i];
333        mag_b += b[i] * b[i];
334    }
335
336    let denom = mag_a.sqrt() * mag_b.sqrt();
337    if denom < f32::EPSILON {
338        0.0
339    } else {
340        (dot / denom).clamp(-1.0, 1.0)
341    }
342}
343
344// ---------------------------------------------------------------------------
345// Union-Find helpers
346// ---------------------------------------------------------------------------
347
348fn find(parent: &mut [usize], mut x: usize) -> usize {
349    while parent[x] != x {
350        parent[x] = parent[parent[x]]; // path compression (halving)
351        x = parent[x];
352    }
353    x
354}
355
356fn union(parent: &mut [usize], rank: &mut [u8], x: usize, y: usize) {
357    let rx = find(parent, x);
358    let ry = find(parent, y);
359    if rx == ry {
360        return;
361    }
362    match rank[rx].cmp(&rank[ry]) {
363        std::cmp::Ordering::Less => parent[rx] = ry,
364        std::cmp::Ordering::Greater => parent[ry] = rx,
365        std::cmp::Ordering::Equal => {
366            parent[ry] = rx;
367            rank[rx] += 1;
368        }
369    }
370}
371
372// ---------------------------------------------------------------------------
373// Tests
374// ---------------------------------------------------------------------------
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379
380    // -----------------------------------------------------------------------
381    // Helper builders
382    // -----------------------------------------------------------------------
383
384    fn make_node(id: u64, embedding: Vec<f32>) -> GraphNode {
385        GraphNode {
386            id,
387            embedding,
388            label: None,
389        }
390    }
391
392    fn make_node_labeled(id: u64, embedding: Vec<f32>, label: &str) -> GraphNode {
393        GraphNode {
394            id,
395            embedding,
396            label: Some(label.to_string()),
397        }
398    }
399
400    fn default_linker() -> SemanticGraphLinker {
401        SemanticGraphLinker::new(LinkerConfig::default())
402    }
403
404    // Produce a unit vector with all entries equal.
405    fn uniform_vec(dim: usize, value: f32) -> Vec<f32> {
406        let norm = (dim as f32).sqrt();
407        vec![value / norm; dim]
408    }
409
410    // Two orthogonal vectors (cosine = 0).
411    fn orthogonal_pair() -> (Vec<f32>, Vec<f32>) {
412        let mut a = vec![0.0_f32; 4];
413        let mut b = vec![0.0_f32; 4];
414        a[0] = 1.0;
415        b[1] = 1.0;
416        (a, b)
417    }
418
419    // -----------------------------------------------------------------------
420    // Test 1: add_node stores the node
421    // -----------------------------------------------------------------------
422    #[test]
423    fn test_add_node_stores_node() {
424        let mut linker = default_linker();
425        let node = make_node(1, vec![1.0, 0.0, 0.0]);
426        linker.add_node(node);
427        assert!(linker.nodes.contains_key(&1));
428    }
429
430    // -----------------------------------------------------------------------
431    // Test 2: add_node with label
432    // -----------------------------------------------------------------------
433    #[test]
434    fn test_add_node_with_label() {
435        let mut linker = default_linker();
436        let node = make_node_labeled(42, vec![0.5, 0.5], "hello");
437        linker.add_node(node);
438        assert_eq!(linker.nodes[&42].label.as_deref(), Some("hello"));
439    }
440
441    // -----------------------------------------------------------------------
442    // Test 3: link_all creates SimilarContent edge for similar vectors
443    // -----------------------------------------------------------------------
444    #[test]
445    fn test_link_all_similar_content() {
446        let mut linker = default_linker();
447        // Nearly identical vectors: cosine ~= 1.0 but let's make them slightly below
448        // duplicate_threshold (0.99) and above similarity_threshold (0.8).
449        // We do this by nudging one component slightly.
450        let a = vec![1.0_f32, 0.0, 0.0, 0.0];
451        let b = vec![0.97_f32, 0.24_f32, 0.0, 0.0]; // cos ≈ 0.97 / 1.0 = 0.97
452        linker.add_node(make_node(1, a));
453        linker.add_node(make_node(2, b));
454        linker.link_all();
455        let similar: Vec<_> = linker
456            .edges
457            .iter()
458            .filter(|e| e.edge_type == EdgeType::SimilarContent)
459            .collect();
460        assert!(
461            !similar.is_empty(),
462            "expected at least one SimilarContent edge"
463        );
464    }
465
466    // -----------------------------------------------------------------------
467    // Test 4: link_all creates Duplicate edge above duplicate_threshold
468    // -----------------------------------------------------------------------
469    #[test]
470    fn test_link_all_duplicate() {
471        let mut linker = default_linker();
472        // Two identical vectors → cosine = 1.0 ≥ 0.99.
473        let v = vec![1.0_f32, 0.0, 0.0];
474        linker.add_node(make_node(1, v.clone()));
475        linker.add_node(make_node(2, v));
476        linker.link_all();
477        let dup: Vec<_> = linker
478            .edges
479            .iter()
480            .filter(|e| e.edge_type == EdgeType::Duplicate)
481            .collect();
482        assert!(!dup.is_empty(), "expected at least one Duplicate edge");
483    }
484
485    // -----------------------------------------------------------------------
486    // Test 5: link_all creates Contradictory edge below contradiction_threshold
487    // -----------------------------------------------------------------------
488    #[test]
489    fn test_link_all_contradictory() {
490        let mut linker = default_linker();
491        let (a, b) = orthogonal_pair(); // cosine = 0.0 ≤ 0.1
492        linker.add_node(make_node(1, a));
493        linker.add_node(make_node(2, b));
494        linker.link_all();
495        let cont: Vec<_> = linker
496            .edges
497            .iter()
498            .filter(|e| e.edge_type == EdgeType::Contradictory)
499            .collect();
500        assert!(!cont.is_empty(), "expected at least one Contradictory edge");
501    }
502
503    // -----------------------------------------------------------------------
504    // Test 6: link_all creates Related edge in intermediate range
505    // -----------------------------------------------------------------------
506    #[test]
507    fn test_link_all_related() {
508        // similarity_threshold = 0.8, related threshold = 0.64.
509        // We need cosine in [0.64, 0.80).
510        // cos(a, b) = dot / (|a||b|).
511        // a = [1,0,0,0], b = [0.7, 0.714, 0, 0] → dot = 0.7, |b| = sqrt(0.49+0.51) = 1.0
512        // cosine ≈ 0.7 which is in [0.64, 0.80) ✓
513        let mut linker = default_linker();
514        let a = vec![1.0_f32, 0.0, 0.0, 0.0];
515        let b = vec![0.7_f32, 0.71414_f32, 0.0, 0.0]; // |b| ≈ 1.0, dot ≈ 0.7
516        linker.add_node(make_node(1, a));
517        linker.add_node(make_node(2, b));
518        linker.link_all();
519        let related: Vec<_> = linker
520            .edges
521            .iter()
522            .filter(|e| e.edge_type == EdgeType::Related)
523            .collect();
524        assert!(!related.is_empty(), "expected at least one Related edge");
525    }
526
527    // -----------------------------------------------------------------------
528    // Test 7: neighbors returns correct adjacent node IDs
529    // -----------------------------------------------------------------------
530    #[test]
531    fn test_neighbors() {
532        let mut linker = default_linker();
533        let v = vec![1.0_f32, 0.0];
534        linker.add_node(make_node(1, v.clone()));
535        linker.add_node(make_node(2, v.clone()));
536        linker.add_node(make_node(3, vec![0.0, 1.0])); // orthogonal → Contradictory
537        linker.link_all();
538        // Node 1 and 2 are identical → Duplicate; 1↔3 and 2↔3 are Contradictory.
539        let n1 = linker.neighbors(1);
540        assert!(n1.contains(&2), "node 1 should be adjacent to node 2");
541    }
542
543    // -----------------------------------------------------------------------
544    // Test 8: neighbors returns empty for isolated node
545    // -----------------------------------------------------------------------
546    #[test]
547    fn test_neighbors_isolated() {
548        let mut linker = SemanticGraphLinker::new(LinkerConfig {
549            similarity_threshold: 0.8,
550            duplicate_threshold: 0.99,
551            max_edges_per_node: 20,
552            // Set below -1.0 so even perfectly anti-parallel vectors (cos=-1) are
553            // not classified as Contradictory, giving node 10 no edges.
554            contradiction_threshold: -1.1,
555        });
556        // Nodes 1 and 2 are identical (Duplicate edge between them).
557        linker.add_node(make_node(1, vec![1.0, 0.0]));
558        linker.add_node(make_node(2, vec![1.0, 0.0]));
559        // Node 10 is orthogonal to 1 and 2 (cosine = 0.0).
560        // 0.0 is not >= similarity_threshold(0.8), not >= related_threshold(0.64),
561        // and not <= contradiction_threshold(-1.1), so no edge is created.
562        linker.add_node(make_node(10, vec![0.0, 1.0]));
563        linker.link_all();
564        let n10 = linker.neighbors(10);
565        assert!(n10.is_empty(), "node 10 should have no neighbors");
566    }
567
568    // -----------------------------------------------------------------------
569    // Test 9: connected_components separates two clusters
570    // -----------------------------------------------------------------------
571    #[test]
572    fn test_connected_components_two_clusters() {
573        let mut linker = SemanticGraphLinker::new(LinkerConfig {
574            similarity_threshold: 0.8,
575            duplicate_threshold: 0.99,
576            max_edges_per_node: 20,
577            contradiction_threshold: 0.0, // no contradictory
578        });
579        // Cluster A: nodes 1, 2 with identical vectors.
580        linker.add_node(make_node(1, vec![1.0, 0.0]));
581        linker.add_node(make_node(2, vec![1.0, 0.0]));
582        // Cluster B: nodes 3, 4 with identical orthogonal vectors.
583        linker.add_node(make_node(3, vec![0.0, 1.0]));
584        linker.add_node(make_node(4, vec![0.0, 1.0]));
585        linker.link_all();
586
587        let comps = linker.connected_components();
588        assert_eq!(comps.len(), 2, "expected 2 connected components");
589        let flat: std::collections::HashSet<u64> = comps.iter().flatten().copied().collect();
590        assert!(flat.contains(&1) && flat.contains(&2));
591        assert!(flat.contains(&3) && flat.contains(&4));
592    }
593
594    // -----------------------------------------------------------------------
595    // Test 10: connected_components with single node
596    // -----------------------------------------------------------------------
597    #[test]
598    fn test_connected_components_single_node() {
599        let mut linker = default_linker();
600        linker.add_node(make_node(99, vec![1.0]));
601        linker.link_all();
602        let comps = linker.connected_components();
603        assert_eq!(comps.len(), 1);
604        assert_eq!(comps[0], vec![99]);
605    }
606
607    // -----------------------------------------------------------------------
608    // Test 11: connected_components on empty graph
609    // -----------------------------------------------------------------------
610    #[test]
611    fn test_connected_components_empty() {
612        let linker = default_linker();
613        let comps = linker.connected_components();
614        assert!(comps.is_empty());
615    }
616
617    // -----------------------------------------------------------------------
618    // Test 12: remove_node removes node and all incident edges
619    // -----------------------------------------------------------------------
620    #[test]
621    fn test_remove_node_cleans_edges() {
622        let mut linker = default_linker();
623        let v = vec![1.0_f32, 0.0];
624        linker.add_node(make_node(1, v.clone()));
625        linker.add_node(make_node(2, v));
626        linker.link_all();
627        assert!(!linker.edges.is_empty(), "should have edges before removal");
628        linker.remove_node(1);
629        assert!(!linker.nodes.contains_key(&1));
630        assert!(
631            linker.edges.iter().all(|e| e.from_id != 1 && e.to_id != 1),
632            "all edges involving node 1 should be removed"
633        );
634    }
635
636    // -----------------------------------------------------------------------
637    // Test 13: remove_node on non-existent node is a no-op
638    // -----------------------------------------------------------------------
639    #[test]
640    fn test_remove_node_nonexistent() {
641        let mut linker = default_linker();
642        linker.add_node(make_node(1, vec![1.0, 0.0]));
643        linker.link_all();
644        let edge_count_before = linker.edges.len();
645        linker.remove_node(999); // does not exist
646        assert_eq!(linker.edges.len(), edge_count_before);
647        assert!(linker.nodes.contains_key(&1));
648    }
649
650    // -----------------------------------------------------------------------
651    // Test 14: max_edges_per_node cap is enforced
652    // -----------------------------------------------------------------------
653    #[test]
654    fn test_max_edges_per_node_cap() {
655        let max = 2_usize;
656        let config = LinkerConfig {
657            similarity_threshold: 0.0, // link everything
658            duplicate_threshold: 0.99,
659            max_edges_per_node: max,
660            contradiction_threshold: -1.0, // never contradictory
661        };
662        let mut linker = SemanticGraphLinker::new(config);
663        // Add 6 nodes. Every pair will be "similar" (sim_threshold = 0).
664        for i in 0..6_u64 {
665            linker.add_node(make_node(i, vec![1.0, 0.0]));
666        }
667        linker.link_all();
668
669        // No node should participate in more than `max` edges.
670        for id in linker.nodes.keys() {
671            let deg = linker
672                .edges
673                .iter()
674                .filter(|e| e.from_id == *id || e.to_id == *id)
675                .count();
676            assert!(
677                deg <= max,
678                "node {id} has degree {deg} which exceeds max {max}"
679            );
680        }
681    }
682
683    // -----------------------------------------------------------------------
684    // Test 15: stats — node_count and edge_count
685    // -----------------------------------------------------------------------
686    #[test]
687    fn test_stats_counts() {
688        let mut linker = default_linker();
689        linker.add_node(make_node(1, vec![1.0, 0.0]));
690        linker.add_node(make_node(2, vec![1.0, 0.0]));
691        linker.link_all();
692        let s = linker.stats();
693        assert_eq!(s.node_count, 2);
694        assert!(s.edge_count >= 1);
695    }
696
697    // -----------------------------------------------------------------------
698    // Test 16: stats — duplicate_count
699    // -----------------------------------------------------------------------
700    #[test]
701    fn test_stats_duplicate_count() {
702        let mut linker = default_linker();
703        let v = vec![1.0_f32, 0.0];
704        linker.add_node(make_node(1, v.clone()));
705        linker.add_node(make_node(2, v));
706        linker.link_all();
707        let s = linker.stats();
708        assert!(
709            s.duplicate_count >= 1,
710            "expected at least one duplicate edge"
711        );
712    }
713
714    // -----------------------------------------------------------------------
715    // Test 17: avg_degree correctness
716    // -----------------------------------------------------------------------
717    #[test]
718    fn test_avg_degree() {
719        let mut linker = default_linker();
720        // 3 identical nodes → C(3,2)=3 Duplicate edges; avg_degree = 2*3/3 = 2.0
721        let v = vec![1.0_f32, 0.0];
722        linker.add_node(make_node(1, v.clone()));
723        linker.add_node(make_node(2, v.clone()));
724        linker.add_node(make_node(3, v));
725        linker.link_all();
726        let s = linker.stats();
727        let expected = (2 * s.edge_count) as f64 / 3.0;
728        let diff = (s.avg_degree() - expected).abs();
729        assert!(
730            diff < 1e-10,
731            "avg_degree mismatch: {} vs {}",
732            s.avg_degree(),
733            expected
734        );
735    }
736
737    // -----------------------------------------------------------------------
738    // Test 18: avg_degree on empty graph (should not panic)
739    // -----------------------------------------------------------------------
740    #[test]
741    fn test_avg_degree_empty() {
742        let s = GraphLinkerStats::default();
743        assert_eq!(s.avg_degree(), 0.0);
744    }
745
746    // -----------------------------------------------------------------------
747    // Test 19: GraphNode::degree counts correctly
748    // -----------------------------------------------------------------------
749    #[test]
750    fn test_graph_node_degree() {
751        let node = make_node(5, vec![1.0, 0.0]);
752        let edges = vec![
753            SemanticEdge {
754                from_id: 5,
755                to_id: 1,
756                similarity: 0.9,
757                edge_type: EdgeType::SimilarContent,
758            },
759            SemanticEdge {
760                from_id: 2,
761                to_id: 5,
762                similarity: 0.85,
763                edge_type: EdgeType::SimilarContent,
764            },
765            SemanticEdge {
766                from_id: 3,
767                to_id: 4,
768                similarity: 0.9,
769                edge_type: EdgeType::SimilarContent,
770            },
771        ];
772        assert_eq!(node.degree(&edges), 2);
773    }
774
775    // -----------------------------------------------------------------------
776    // Test 20: EdgeType equality and copy
777    // -----------------------------------------------------------------------
778    #[test]
779    fn test_edge_type_equality() {
780        let a = EdgeType::Duplicate;
781        let b = a; // Copy
782        assert_eq!(a, b);
783        assert_ne!(EdgeType::Related, EdgeType::Contradictory);
784    }
785
786    // -----------------------------------------------------------------------
787    // Test 21: cosine_similarity zero-vector safety
788    // -----------------------------------------------------------------------
789    #[test]
790    fn test_cosine_zero_vector() {
791        let zero = vec![0.0_f32; 3];
792        let v = vec![1.0_f32, 0.0, 0.0];
793        assert_eq!(cosine_similarity(&zero, &v), 0.0);
794        assert_eq!(cosine_similarity(&zero, &zero), 0.0);
795    }
796
797    // -----------------------------------------------------------------------
798    // Test 22: link_all on empty graph is a no-op
799    // -----------------------------------------------------------------------
800    #[test]
801    fn test_link_all_empty_graph() {
802        let mut linker = default_linker();
803        linker.link_all(); // should not panic
804        assert!(linker.edges.is_empty());
805    }
806
807    // -----------------------------------------------------------------------
808    // Test 23: uniform vectors produce high cosine similarity
809    // -----------------------------------------------------------------------
810    #[test]
811    fn test_uniform_vectors_high_similarity() {
812        let a = uniform_vec(128, 1.0);
813        let b = uniform_vec(128, 1.0);
814        let sim = cosine_similarity(&a, &b);
815        assert!(
816            (sim - 1.0).abs() < 1e-5,
817            "uniform identical vectors should have cosine ≈ 1"
818        );
819    }
820}