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}