1use std::collections::{HashMap, HashSet, VecDeque};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
20pub struct ConceptId(pub usize);
21
22#[derive(Debug, Clone, PartialEq)]
24pub enum CgConceptRelation {
25 CoOccurrence,
27 Synonym,
29 Hierarchical,
31 Antonym,
33 Related,
35}
36
37#[derive(Debug, Clone)]
39pub struct CgConcept {
40 pub id: ConceptId,
42 pub term: String,
44 pub embedding: Option<Vec<f64>>,
46 pub frequency: u64,
48 pub documents: Vec<String>,
50}
51
52#[derive(Debug, Clone)]
54pub struct CgConceptEdge {
55 pub from: ConceptId,
56 pub to: ConceptId,
57 pub weight: f64,
59 pub relation: CgConceptRelation,
60 pub co_occurrence: u64,
62}
63
64#[derive(Debug, Clone)]
66pub struct CgGraphConfig {
67 pub min_concept_frequency: u64,
69 pub max_concepts: usize,
71 pub co_occurrence_window: usize,
73 pub min_edge_weight: f64,
75}
76
77impl Default for CgGraphConfig {
78 fn default() -> Self {
79 Self {
80 min_concept_frequency: 2,
81 max_concepts: 10_000,
82 co_occurrence_window: 5,
83 min_edge_weight: 0.1,
84 }
85 }
86}
87
88#[derive(Debug, Clone)]
90pub struct ConceptGraphStats {
91 pub concept_count: usize,
92 pub edge_count: usize,
93 pub avg_degree: f64,
94 pub total_documents: u64,
95 pub vocabulary_size: usize,
97}
98
99pub struct ConceptGraphBuilder {
115 pub config: CgGraphConfig,
116 pub concepts: Vec<CgConcept>,
118 pub term_to_id: HashMap<String, ConceptId>,
120 pub edges: Vec<CgConceptEdge>,
123 pub adjacency: HashMap<usize, Vec<usize>>,
125 edge_index: HashMap<(usize, usize), usize>,
127 pub total_documents: u64,
128}
129
130impl ConceptGraphBuilder {
131 pub fn new(config: CgGraphConfig) -> Self {
133 Self {
134 config,
135 concepts: Vec::new(),
136 term_to_id: HashMap::new(),
137 edges: Vec::new(),
138 adjacency: HashMap::new(),
139 edge_index: HashMap::new(),
140 total_documents: 0,
141 }
142 }
143
144 pub fn add_concept_term(&mut self, term: String, embedding: Option<Vec<f64>>) -> ConceptId {
149 if let Some(&id) = self.term_to_id.get(&term) {
150 if embedding.is_some() && self.concepts[id.0].embedding.is_none() {
152 self.concepts[id.0].embedding = embedding;
153 }
154 return id;
155 }
156 if self.concepts.len() >= self.config.max_concepts {
157 return ConceptId(usize::MAX);
160 }
161 let id = ConceptId(self.concepts.len());
162 self.concepts.push(CgConcept {
163 id,
164 term: term.clone(),
165 embedding,
166 frequency: 0,
167 documents: Vec::new(),
168 });
169 self.term_to_id.insert(term, id);
170 id
171 }
172
173 pub fn process_document(&mut self, doc_id: &str, text: &str) {
178 let tokens = tokenize(text);
179 if tokens.is_empty() {
180 self.total_documents += 1;
181 return;
182 }
183
184 let mut concept_ids: Vec<ConceptId> = Vec::with_capacity(tokens.len());
186 for token in &tokens {
187 let cid = self.add_concept_term(token.clone(), None);
188 if cid.0 == usize::MAX {
189 concept_ids.push(cid);
191 continue;
192 }
193 let concept = &mut self.concepts[cid.0];
194 concept.frequency += 1;
195 if !concept.documents.contains(&doc_id.to_string()) {
197 concept.documents.push(doc_id.to_string());
198 }
199 concept_ids.push(cid);
200 }
201
202 let n = concept_ids.len();
204 for i in 0..n {
205 let a = concept_ids[i];
206 if a.0 == usize::MAX {
207 continue;
208 }
209 let window_end = (i + self.config.co_occurrence_window + 1).min(n);
210 for &b in concept_ids.iter().take(window_end).skip(i + 1) {
211 if b.0 == usize::MAX || a == b {
212 continue;
213 }
214 self.upsert_cooccurrence_edge(a, b);
215 }
216 }
217
218 self.total_documents += 1;
219 }
220
221 fn upsert_cooccurrence_edge(&mut self, a: ConceptId, b: ConceptId) {
224 let key = canonical_key(a, b);
225 if let Some(&edge_idx) = self.edge_index.get(&key) {
226 let edge = &mut self.edges[edge_idx];
228 edge.co_occurrence += 1;
229 let freq_a = self.concepts[a.0].frequency.max(1) as f64;
230 let freq_b = self.concepts[b.0].frequency.max(1) as f64;
231 edge.weight = (edge.co_occurrence as f64) / (freq_a * freq_b).sqrt();
232 } else {
233 let freq_a = self.concepts[a.0].frequency.max(1) as f64;
234 let freq_b = self.concepts[b.0].frequency.max(1) as f64;
235 let weight = 1.0_f64 / (freq_a * freq_b).sqrt();
236 let edge_idx = self.edges.len();
237 self.edges.push(CgConceptEdge {
238 from: a,
239 to: b,
240 weight,
241 relation: CgConceptRelation::CoOccurrence,
242 co_occurrence: 1,
243 });
244 self.edge_index.insert(key, edge_idx);
245 self.adjacency.entry(a.0).or_default().push(edge_idx);
246 self.adjacency.entry(b.0).or_default().push(edge_idx);
247 }
248 }
249
250 pub fn add_relation(
256 &mut self,
257 term_a: &str,
258 term_b: &str,
259 relation: CgConceptRelation,
260 weight: f64,
261 ) -> bool {
262 let id_a = match self.term_to_id.get(term_a).copied() {
263 Some(id) => id,
264 None => return false,
265 };
266 let id_b = match self.term_to_id.get(term_b).copied() {
267 Some(id) => id,
268 None => return false,
269 };
270 let key = canonical_key(id_a, id_b);
271 if let Some(&edge_idx) = self.edge_index.get(&key) {
272 self.edges[edge_idx].relation = relation;
274 self.edges[edge_idx].weight = weight;
275 } else {
276 let edge_idx = self.edges.len();
277 self.edges.push(CgConceptEdge {
278 from: id_a,
279 to: id_b,
280 weight,
281 relation,
282 co_occurrence: 0,
283 });
284 self.edge_index.insert(key, edge_idx);
285 self.adjacency.entry(id_a.0).or_default().push(edge_idx);
286 self.adjacency.entry(id_b.0).or_default().push(edge_idx);
287 }
288 true
289 }
290
291 pub fn concept_by_term(&self, term: &str) -> Option<&CgConcept> {
295 let id = self.term_to_id.get(term)?;
296 self.concepts.get(id.0)
297 }
298
299 pub fn concept_by_id(&self, id: ConceptId) -> Option<&CgConcept> {
301 if id.0 == usize::MAX {
302 return None;
303 }
304 self.concepts.get(id.0)
305 }
306
307 pub fn neighbors(&self, id: ConceptId) -> Vec<(&CgConcept, f64)> {
313 let edge_indices = match self.adjacency.get(&id.0) {
314 Some(v) => v,
315 None => return Vec::new(),
316 };
317 let mut result: Vec<(&CgConcept, f64)> = edge_indices
318 .iter()
319 .filter_map(|&ei| {
320 let edge = self.edges.get(ei)?;
321 let neighbour_id = if edge.from == id { edge.to } else { edge.from };
322 let concept = self.concepts.get(neighbour_id.0)?;
323 Some((concept, edge.weight))
324 })
325 .collect();
326 result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
327 result
328 }
329
330 pub fn shortest_path(&self, from: ConceptId, to: ConceptId) -> Option<Vec<ConceptId>> {
334 if from == to {
335 return Some(vec![from]);
336 }
337 let mut visited: HashSet<usize> = HashSet::new();
339 let mut queue: VecDeque<Vec<ConceptId>> = VecDeque::new();
340 visited.insert(from.0);
341 queue.push_back(vec![from]);
342
343 while let Some(path) = queue.pop_front() {
344 let current = *path.last()?;
345 let edge_indices = match self.adjacency.get(¤t.0) {
346 Some(v) => v,
347 None => continue,
348 };
349 for &ei in edge_indices {
350 let edge = self.edges.get(ei)?;
351 let next = if edge.from == current {
352 edge.to
353 } else {
354 edge.from
355 };
356 if next == to {
357 let mut full = path.clone();
358 full.push(to);
359 return Some(full);
360 }
361 if !visited.contains(&next.0) {
362 visited.insert(next.0);
363 let mut new_path = path.clone();
364 new_path.push(next);
365 queue.push_back(new_path);
366 }
367 }
368 }
369 None
370 }
371
372 pub fn similar_concepts(&self, id: ConceptId, k: usize) -> Vec<(&CgConcept, f64)> {
381 if k == 0 {
382 return Vec::new();
383 }
384 let target = match self.concept_by_id(id) {
385 Some(c) => c,
386 None => return Vec::new(),
387 };
388
389 if let Some(target_emb) = &target.embedding {
390 let mut scored: Vec<(&CgConcept, f64)> = self
392 .concepts
393 .iter()
394 .filter(|c| c.id != id)
395 .filter_map(|c| {
396 let emb = c.embedding.as_ref()?;
397 let sim = cosine_similarity(target_emb, emb);
398 Some((c, sim))
399 })
400 .collect();
401 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
402 scored.truncate(k);
403 scored
404 } else {
405 let mut nbrs = self.neighbors(id);
407 nbrs.truncate(k);
408 nbrs
409 }
410 }
411
412 pub fn prune_low_frequency(&mut self) -> usize {
419 let min_freq = self.config.min_concept_frequency;
420 let to_remove: HashSet<usize> = self
421 .concepts
422 .iter()
423 .filter(|c| c.frequency < min_freq)
424 .map(|c| c.id.0)
425 .collect();
426 if to_remove.is_empty() {
427 return 0;
428 }
429 self.remove_concepts(&to_remove)
430 }
431
432 pub fn prune_weak_edges(&mut self) -> usize {
436 let min_weight = self.config.min_edge_weight;
437 let initial = self.edges.len();
438
439 let remove_set: HashSet<usize> = self
441 .edges
442 .iter()
443 .enumerate()
444 .filter(|(_, e)| e.weight < min_weight)
445 .map(|(i, _)| i)
446 .collect();
447
448 if remove_set.is_empty() {
449 return 0;
450 }
451
452 self.rebuild_edges_excluding(&remove_set);
453 initial - self.edges.len()
454 }
455
456 pub fn graph_stats(&self) -> ConceptGraphStats {
460 let concept_count = self.concepts.len();
461 let edge_count = self.edges.len();
462 let avg_degree = if concept_count == 0 {
463 0.0
464 } else {
465 (2 * edge_count) as f64 / concept_count as f64
467 };
468 ConceptGraphStats {
469 concept_count,
470 edge_count,
471 avg_degree,
472 total_documents: self.total_documents,
473 vocabulary_size: self.term_to_id.len(),
474 }
475 }
476
477 fn remove_concepts(&mut self, to_remove: &HashSet<usize>) -> usize {
481 let mut new_index: HashMap<usize, usize> = HashMap::new();
483 let mut new_concepts: Vec<CgConcept> = Vec::new();
484 for concept in self.concepts.drain(..) {
485 if to_remove.contains(&concept.id.0) {
486 continue;
487 }
488 let new_id = new_concepts.len();
489 new_index.insert(concept.id.0, new_id);
490 let mut c = concept;
491 c.id = ConceptId(new_id);
492 new_concepts.push(c);
493 }
494 let removed = to_remove.len();
495 self.concepts = new_concepts;
496
497 self.term_to_id.clear();
499 for c in &self.concepts {
500 self.term_to_id.insert(c.term.clone(), c.id);
501 }
502
503 let mut new_edges: Vec<CgConceptEdge> = Vec::new();
505 let mut new_edge_index: HashMap<(usize, usize), usize> = HashMap::new();
506 for edge in self.edges.drain(..) {
507 let new_from = match new_index.get(&edge.from.0) {
508 Some(&i) => i,
509 None => continue,
510 };
511 let new_to = match new_index.get(&edge.to.0) {
512 Some(&i) => i,
513 None => continue,
514 };
515 let key = canonical_key(ConceptId(new_from), ConceptId(new_to));
516 let ei = new_edges.len();
517 new_edge_index.insert(key, ei);
518 new_edges.push(CgConceptEdge {
519 from: ConceptId(new_from),
520 to: ConceptId(new_to),
521 weight: edge.weight,
522 relation: edge.relation,
523 co_occurrence: edge.co_occurrence,
524 });
525 }
526 self.edges = new_edges;
527 self.edge_index = new_edge_index;
528
529 self.adjacency.clear();
531 for (ei, edge) in self.edges.iter().enumerate() {
532 self.adjacency.entry(edge.from.0).or_default().push(ei);
533 self.adjacency.entry(edge.to.0).or_default().push(ei);
534 }
535
536 removed
537 }
538
539 fn rebuild_edges_excluding(&mut self, remove_set: &HashSet<usize>) {
541 let mut new_edges: Vec<CgConceptEdge> = Vec::new();
542 let mut new_edge_index: HashMap<(usize, usize), usize> = HashMap::new();
543 for (old_idx, edge) in self.edges.drain(..).enumerate() {
544 if remove_set.contains(&old_idx) {
545 continue;
546 }
547 let key = canonical_key(edge.from, edge.to);
548 let new_idx = new_edges.len();
549 new_edge_index.insert(key, new_idx);
550 new_edges.push(edge);
551 }
552 self.edges = new_edges;
553 self.edge_index = new_edge_index;
554
555 self.adjacency.clear();
557 for (ei, edge) in self.edges.iter().enumerate() {
558 self.adjacency.entry(edge.from.0).or_default().push(ei);
559 self.adjacency.entry(edge.to.0).or_default().push(ei);
560 }
561 }
562}
563
564#[inline]
568fn canonical_key(a: ConceptId, b: ConceptId) -> (usize, usize) {
569 let (x, y) = (a.0, b.0);
570 if x <= y {
571 (x, y)
572 } else {
573 (y, x)
574 }
575}
576
577pub fn tokenize(text: &str) -> Vec<String> {
580 text.split(|c: char| c.is_whitespace() || (c.is_ascii_punctuation() && c != '\''))
581 .map(|s| s.to_lowercase())
582 .filter(|s| s.chars().count() >= 3)
583 .collect()
584}
585
586pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
590 if a.len() != b.len() || a.is_empty() {
591 return 0.0;
592 }
593 let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
594 let norm_a: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
595 let norm_b: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
596 if norm_a == 0.0 || norm_b == 0.0 {
597 return 0.0;
598 }
599 (dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
600}
601
602#[doc(hidden)]
604pub fn canonize_key_test(a: ConceptId, b: ConceptId) -> (usize, usize) {
605 canonical_key(a, b)
606}
607
608#[cfg(test)]
611mod tests {
612 use crate::concept_graph::{
613 cosine_similarity, tokenize, CgConceptRelation, CgGraphConfig, ConceptGraphBuilder,
614 ConceptId,
615 };
616
617 fn default_builder() -> ConceptGraphBuilder {
618 ConceptGraphBuilder::new(CgGraphConfig::default())
619 }
620
621 fn small_config() -> CgGraphConfig {
622 CgGraphConfig {
623 min_concept_frequency: 1,
624 max_concepts: 10_000,
625 co_occurrence_window: 3,
626 min_edge_weight: 0.01,
627 }
628 }
629
630 fn small_builder() -> ConceptGraphBuilder {
631 ConceptGraphBuilder::new(small_config())
632 }
633
634 #[test]
637 fn test_tokenize_basic() {
638 let tokens = tokenize("Hello, World!");
639 assert!(tokens.contains(&"hello".to_string()));
640 assert!(tokens.contains(&"world".to_string()));
641 }
642
643 #[test]
644 fn test_tokenize_min_length() {
645 let tokens = tokenize("I am a big cat");
646 assert!(!tokens.contains(&"i".to_string()));
647 assert!(!tokens.contains(&"am".to_string()));
648 assert!(!tokens.contains(&"a".to_string()));
649 assert!(tokens.contains(&"big".to_string()));
650 assert!(tokens.contains(&"cat".to_string()));
651 }
652
653 #[test]
654 fn test_tokenize_punctuation_split() {
655 let tokens = tokenize("hello.world");
656 assert!(tokens.contains(&"hello".to_string()));
657 assert!(tokens.contains(&"world".to_string()));
658 }
659
660 #[test]
661 fn test_tokenize_lowercase() {
662 let tokens = tokenize("RUST Language");
663 assert!(tokens.contains(&"rust".to_string()));
664 assert!(tokens.contains(&"language".to_string()));
665 }
666
667 #[test]
668 fn test_tokenize_empty() {
669 let tokens = tokenize("");
670 assert!(tokens.is_empty());
671 }
672
673 #[test]
676 fn test_cosine_identical() {
677 let v = vec![1.0, 2.0, 3.0];
678 let sim = cosine_similarity(&v, &v);
679 assert!((sim - 1.0).abs() < 1e-9);
680 }
681
682 #[test]
683 fn test_cosine_orthogonal() {
684 let a = vec![1.0, 0.0];
685 let b = vec![0.0, 1.0];
686 let sim = cosine_similarity(&a, &b);
687 assert!(sim.abs() < 1e-9);
688 }
689
690 #[test]
691 fn test_cosine_zero_vector() {
692 let a = vec![0.0, 0.0];
693 let b = vec![1.0, 2.0];
694 assert_eq!(cosine_similarity(&a, &b), 0.0);
695 }
696
697 #[test]
698 fn test_cosine_length_mismatch() {
699 let a = vec![1.0, 2.0];
700 let b = vec![1.0];
701 assert_eq!(cosine_similarity(&a, &b), 0.0);
702 }
703
704 #[test]
707 fn test_add_concept_term_new() {
708 let mut b = default_builder();
709 let id = b.add_concept_term("rust".to_string(), None);
710 assert_eq!(id, ConceptId(0));
711 assert_eq!(b.concepts.len(), 1);
712 }
713
714 #[test]
715 fn test_add_concept_term_idempotent() {
716 let mut b = default_builder();
717 let id1 = b.add_concept_term("rust".to_string(), None);
718 let id2 = b.add_concept_term("rust".to_string(), None);
719 assert_eq!(id1, id2);
720 assert_eq!(b.concepts.len(), 1);
721 }
722
723 #[test]
724 fn test_add_concept_term_embeds_updated() {
725 let mut b = default_builder();
726 b.add_concept_term("rust".to_string(), None);
727 b.add_concept_term("rust".to_string(), Some(vec![1.0, 0.0]));
728 assert!(b
729 .concept_by_term("rust")
730 .and_then(|c| c.embedding.as_ref())
731 .is_some());
732 }
733
734 #[test]
735 fn test_add_concept_term_cap() {
736 let mut b = ConceptGraphBuilder::new(CgGraphConfig {
737 max_concepts: 2,
738 ..CgGraphConfig::default()
739 });
740 b.add_concept_term("aaa".to_string(), None);
741 b.add_concept_term("bbb".to_string(), None);
742 let id = b.add_concept_term("ccc".to_string(), None);
743 assert_eq!(id, ConceptId(usize::MAX));
744 assert_eq!(b.concepts.len(), 2);
745 }
746
747 #[test]
750 fn test_process_document_increments_frequency() {
751 let mut b = small_builder();
752 b.process_document("d1", "rust programming language");
753 let rust = b.concept_by_term("rust").expect("rust concept");
754 assert_eq!(rust.frequency, 1);
755 }
756
757 #[test]
758 fn test_process_document_two_docs() {
759 let mut b = small_builder();
760 b.process_document("d1", "rust programming language");
761 b.process_document("d2", "rust systems programming");
762 let rust = b.concept_by_term("rust").expect("rust concept");
763 assert_eq!(rust.frequency, 2);
764 assert_eq!(rust.documents.len(), 2);
765 }
766
767 #[test]
768 fn test_process_document_doc_deduplicated() {
769 let mut b = small_builder();
770 b.process_document("d1", "rust rust rust");
771 let rust = b.concept_by_term("rust").expect("rust concept");
772 assert_eq!(rust.documents.len(), 1);
773 assert_eq!(rust.frequency, 3);
774 }
775
776 #[test]
777 fn test_process_document_creates_edges() {
778 let mut b = small_builder();
779 b.process_document("d1", "rust programming language systems");
780 assert!(!b.edges.is_empty());
781 }
782
783 #[test]
784 fn test_process_document_total_documents() {
785 let mut b = default_builder();
786 b.process_document("d1", "hello world foo");
787 b.process_document("d2", "another world document");
788 assert_eq!(b.total_documents, 2);
789 }
790
791 #[test]
792 fn test_process_empty_document() {
793 let mut b = default_builder();
794 b.process_document("d1", "");
795 assert_eq!(b.total_documents, 1);
796 assert!(b.concepts.is_empty());
797 }
798
799 #[test]
802 fn test_add_relation_success() {
803 let mut b = small_builder();
804 b.process_document("d1", "fast quick");
805 let ok = b.add_relation("fast", "quick", CgConceptRelation::Synonym, 0.9);
806 assert!(ok);
807 }
808
809 #[test]
810 fn test_add_relation_missing_term_returns_false() {
811 let mut b = small_builder();
812 b.process_document("d1", "fast quick");
813 let ok = b.add_relation("fast", "slow", CgConceptRelation::Antonym, 0.8);
814 assert!(!ok);
815 }
816
817 #[test]
818 fn test_add_relation_overwrites_existing() {
819 let mut b = small_builder();
820 b.process_document("d1", "fast quick");
821 b.add_relation("fast", "quick", CgConceptRelation::CoOccurrence, 0.5);
822 b.add_relation("fast", "quick", CgConceptRelation::Synonym, 0.95);
823 let key = {
825 let id_fast = b.term_to_id["fast"];
826 let id_quick = b.term_to_id["quick"];
827 let (lo, hi) = if id_fast.0 <= id_quick.0 {
828 (id_fast.0, id_quick.0)
829 } else {
830 (id_quick.0, id_fast.0)
831 };
832 (lo, hi)
833 };
834 let edge_idx = b.edge_index[&key];
835 assert!((b.edges[edge_idx].weight - 0.95).abs() < 1e-9);
836 }
837
838 #[test]
841 fn test_concept_by_term_found() {
842 let mut b = small_builder();
843 b.process_document("d1", "hello world rust");
844 assert!(b.concept_by_term("rust").is_some());
845 }
846
847 #[test]
848 fn test_concept_by_term_not_found() {
849 let b = default_builder();
850 assert!(b.concept_by_term("missing").is_none());
851 }
852
853 #[test]
854 fn test_concept_by_id_valid() {
855 let mut b = small_builder();
856 b.add_concept_term("hello".to_string(), None);
857 assert!(b.concept_by_id(ConceptId(0)).is_some());
858 }
859
860 #[test]
861 fn test_concept_by_id_invalid() {
862 let b = default_builder();
863 assert!(b.concept_by_id(ConceptId(usize::MAX)).is_none());
864 assert!(b.concept_by_id(ConceptId(999)).is_none());
865 }
866
867 #[test]
870 fn test_neighbors_sorted_desc() {
871 let mut b = small_builder();
872 b.process_document("d1", "alpha beta gamma delta");
873 b.process_document("d2", "alpha beta gamma");
874 b.process_document("d3", "alpha beta");
875 let id_alpha = b.term_to_id["alpha"];
876 let nbrs = b.neighbors(id_alpha);
877 for w in nbrs.windows(2) {
879 assert!(w[0].1 >= w[1].1);
880 }
881 }
882
883 #[test]
884 fn test_neighbors_no_edges() {
885 let mut b = small_builder();
886 b.add_concept_term("lone".to_string(), None);
887 let id = b.term_to_id["lone"];
888 let nbrs = b.neighbors(id);
889 assert!(nbrs.is_empty());
890 }
891
892 #[test]
895 fn test_shortest_path_direct() {
896 let mut b = small_builder();
897 b.process_document("d1", "alpha beta");
898 let a = b.term_to_id["alpha"];
899 let bb = b.term_to_id["beta"];
900 let path = b.shortest_path(a, bb).expect("direct path");
901 assert_eq!(path.len(), 2);
902 assert_eq!(path[0], a);
903 assert_eq!(path[1], bb);
904 }
905
906 #[test]
907 fn test_shortest_path_same_node() {
908 let mut b = small_builder();
909 b.add_concept_term("solo".to_string(), None);
910 let id = b.term_to_id["solo"];
911 let path = b.shortest_path(id, id).expect("self path");
912 assert_eq!(path, vec![id]);
913 }
914
915 #[test]
916 fn test_shortest_path_multi_hop() {
917 let mut b = small_builder();
918 b.process_document("d1", "aaa bbb");
920 b.process_document("d2", "bbb ccc");
921 let a = b.term_to_id["aaa"];
922 let c = b.term_to_id["ccc"];
923 let path = b.shortest_path(a, c).expect("multi-hop path");
924 assert!(path.len() >= 3);
925 assert_eq!(*path.first().expect("first"), a);
926 assert_eq!(*path.last().expect("last"), c);
927 }
928
929 #[test]
930 fn test_shortest_path_no_connection() {
931 let mut b = small_builder();
932 b.process_document("d1", "aaa bbb");
933 b.add_concept_term("ccc".to_string(), None);
934 let a = b.term_to_id["aaa"];
935 let c = b.term_to_id["ccc"];
936 assert!(b.shortest_path(a, c).is_none());
937 }
938
939 #[test]
942 fn test_similar_concepts_embedding_based() {
943 let mut b = small_builder();
944 b.add_concept_term("rust".to_string(), Some(vec![1.0, 0.0]));
945 b.add_concept_term("systems".to_string(), Some(vec![0.9, 0.1]));
946 b.add_concept_term("python".to_string(), Some(vec![0.0, 1.0]));
947 let id = b.term_to_id["rust"];
948 let similar = b.similar_concepts(id, 1);
949 assert_eq!(similar.len(), 1);
950 assert_eq!(similar[0].0.term, "systems");
951 }
952
953 #[test]
954 fn test_similar_concepts_graph_fallback() {
955 let mut b = small_builder();
956 b.process_document("d1", "aaa bbb ccc");
957 let id = b.term_to_id["aaa"];
958 let similar = b.similar_concepts(id, 2);
959 assert!(similar.len() <= 2);
960 }
961
962 #[test]
963 fn test_similar_concepts_k_zero() {
964 let mut b = small_builder();
965 b.process_document("d1", "alpha beta");
966 let id = b.term_to_id["alpha"];
967 assert!(b.similar_concepts(id, 0).is_empty());
968 }
969
970 #[test]
971 fn test_similar_concepts_unknown_id() {
972 let b = default_builder();
973 assert!(b.similar_concepts(ConceptId(999), 5).is_empty());
974 }
975
976 #[test]
979 fn test_prune_low_frequency_removes_rare() {
980 let mut b = ConceptGraphBuilder::new(CgGraphConfig {
981 min_concept_frequency: 2,
982 ..small_config()
983 });
984 b.process_document("d1", "common common rare");
985 b.process_document("d2", "common");
986 let removed = b.prune_low_frequency();
987 assert!(removed > 0);
988 assert!(b.concept_by_term("rare").is_none());
989 }
990
991 #[test]
992 fn test_prune_low_frequency_keeps_frequent() {
993 let mut b = ConceptGraphBuilder::new(CgGraphConfig {
994 min_concept_frequency: 2,
995 ..small_config()
996 });
997 b.process_document("d1", "common common");
998 b.process_document("d2", "common");
999 b.prune_low_frequency();
1000 assert!(b.concept_by_term("common").is_some());
1001 }
1002
1003 #[test]
1004 fn test_prune_low_frequency_none_removed() {
1005 let mut b = ConceptGraphBuilder::new(CgGraphConfig {
1006 min_concept_frequency: 1,
1007 ..small_config()
1008 });
1009 b.process_document("d1", "alpha beta");
1010 let removed = b.prune_low_frequency();
1011 assert_eq!(removed, 0);
1012 }
1013
1014 #[test]
1015 fn test_prune_low_frequency_removes_associated_edges() {
1016 let mut b = ConceptGraphBuilder::new(CgGraphConfig {
1017 min_concept_frequency: 2,
1018 ..small_config()
1019 });
1020 b.process_document("d1", "rare rare common");
1021 b.process_document("d2", "common common");
1022 let edges_before = b.edges.len();
1024 b.prune_low_frequency();
1025 assert!(b.edges.len() <= edges_before);
1026 }
1027
1028 #[test]
1031 fn test_prune_weak_edges_removes_below_threshold() {
1032 let mut b = ConceptGraphBuilder::new(CgGraphConfig {
1033 min_edge_weight: 0.5,
1034 ..small_config()
1035 });
1036 b.process_document("d1", "aaa bbb");
1037 let removed = b.prune_weak_edges();
1038 let _ = removed; assert!(b.edges.len() <= 1);
1041 }
1042
1043 #[test]
1044 fn test_prune_weak_edges_none_removed() {
1045 let mut b = ConceptGraphBuilder::new(CgGraphConfig {
1046 min_edge_weight: 0.0,
1047 ..small_config()
1048 });
1049 b.process_document("d1", "aaa bbb");
1050 let removed = b.prune_weak_edges();
1051 assert_eq!(removed, 0);
1052 }
1053
1054 #[test]
1057 fn test_graph_stats_empty() {
1058 let b = default_builder();
1059 let s = b.graph_stats();
1060 assert_eq!(s.concept_count, 0);
1061 assert_eq!(s.edge_count, 0);
1062 assert_eq!(s.avg_degree, 0.0);
1063 assert_eq!(s.total_documents, 0);
1064 }
1065
1066 #[test]
1067 fn test_graph_stats_after_processing() {
1068 let mut b = small_builder();
1069 b.process_document("d1", "alpha beta gamma");
1070 let s = b.graph_stats();
1071 assert!(s.concept_count > 0);
1072 assert!(s.edge_count > 0);
1073 assert_eq!(s.total_documents, 1);
1074 assert_eq!(s.vocabulary_size, s.concept_count);
1075 }
1076
1077 #[test]
1078 fn test_graph_stats_avg_degree() {
1079 let mut b = small_builder();
1080 b.process_document("d1", "alpha beta");
1081 let s = b.graph_stats();
1082 assert!((s.avg_degree - 1.0).abs() < 1e-9);
1084 }
1085
1086 #[test]
1089 fn test_concept_id_ordering() {
1090 assert!(ConceptId(0) < ConceptId(1));
1091 assert_eq!(ConceptId(5), ConceptId(5));
1092 }
1093
1094 #[test]
1097 fn test_full_pipeline() {
1098 let mut b = ConceptGraphBuilder::new(CgGraphConfig {
1099 min_concept_frequency: 2,
1100 min_edge_weight: 0.05,
1101 co_occurrence_window: 4,
1102 max_concepts: 1000,
1103 });
1104 let docs = [
1105 ("d1", "machine learning neural networks deep learning"),
1106 ("d2", "machine learning gradient descent optimization"),
1107 ("d3", "neural networks deep learning backpropagation"),
1108 ("d4", "deep learning convolutional neural networks"),
1109 ];
1110 for (id, text) in &docs {
1111 b.process_document(id, text);
1112 }
1113 b.prune_low_frequency();
1115 b.prune_weak_edges();
1116
1117 assert!(b.concept_by_term("machine").is_some());
1119 assert!(b.concept_by_term("learning").is_some());
1120 assert!(b.concept_by_term("neural").is_some());
1121
1122 let stats = b.graph_stats();
1124 assert!(stats.concept_count > 0);
1125 assert!(stats.edge_count > 0);
1126 }
1127}