Skip to main content

ipfrs_semantic/
concept_graph.rs

1//! # Concept Graph Builder
2//!
3//! A semantic concept graph that extracts concepts from text, builds weighted
4//! relationships, and enables concept-based navigation and similarity search.
5//!
6//! The graph supports:
7//! - Automatic concept extraction via tokenization
8//! - Co-occurrence-based edge weighting
9//! - Explicit semantic relation tagging (synonym, hierarchical, antonym, related)
10//! - BFS shortest-path computation
11//! - Embedding-based or graph-topology-based similarity search
12//! - Pruning by frequency and edge weight
13
14use std::collections::{HashMap, HashSet, VecDeque};
15
16// ─── Core types ─────────────────────────────────────────────────────────────
17
18/// Opaque concept index newtype.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
20pub struct ConceptId(pub usize);
21
22/// Semantic relation between two concepts.
23#[derive(Debug, Clone, PartialEq)]
24pub enum CgConceptRelation {
25    /// Concepts frequently appear together in the same context window.
26    CoOccurrence,
27    /// Two terms are synonyms.
28    Synonym,
29    /// One concept is a subtype / supertype of the other.
30    Hierarchical,
31    /// Concepts are semantically opposite.
32    Antonym,
33    /// Generic semantic relatedness.
34    Related,
35}
36
37/// A node in the concept graph.
38#[derive(Debug, Clone)]
39pub struct CgConcept {
40    /// Stable identifier.
41    pub id: ConceptId,
42    /// Canonical term string.
43    pub term: String,
44    /// Optional dense embedding vector.
45    pub embedding: Option<Vec<f64>>,
46    /// Number of times this concept was seen across all processed documents.
47    pub frequency: u64,
48    /// Document IDs in which this concept appears (deduplication done at insertion).
49    pub documents: Vec<String>,
50}
51
52/// A directed (but treated as undirected for traversal) weighted edge.
53#[derive(Debug, Clone)]
54pub struct CgConceptEdge {
55    pub from: ConceptId,
56    pub to: ConceptId,
57    /// Normalised edge weight ∈ (0, 1].
58    pub weight: f64,
59    pub relation: CgConceptRelation,
60    /// Raw co-occurrence count (incremented each time the pair is observed).
61    pub co_occurrence: u64,
62}
63
64/// Build-time configuration.
65#[derive(Debug, Clone)]
66pub struct CgGraphConfig {
67    /// Concepts appearing fewer than this many times are considered noise.
68    pub min_concept_frequency: u64,
69    /// Hard cap on the total number of concepts tracked.
70    pub max_concepts: usize,
71    /// Token window used when counting co-occurrences.
72    pub co_occurrence_window: usize,
73    /// Edges below this weight are considered insignificant.
74    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/// Snapshot statistics about the concept graph.
89#[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    /// Number of distinct terms (same as concept_count after pruning).
96    pub vocabulary_size: usize,
97}
98
99// ─── Builder ────────────────────────────────────────────────────────────────
100
101/// Builds and queries a semantic concept graph.
102///
103/// # Example
104///
105/// ```rust
106/// use ipfrs_semantic::{CgGraphConfig, ConceptGraphBuilder};
107///
108/// let mut builder = ConceptGraphBuilder::new(CgGraphConfig::default());
109/// builder.process_document("doc1", "the quick brown fox jumps over the lazy dog");
110/// builder.process_document("doc2", "the brown fox ran across the field");
111/// let stats = builder.graph_stats();
112/// assert!(stats.concept_count > 0);
113/// ```
114pub struct ConceptGraphBuilder {
115    pub config: CgGraphConfig,
116    /// All concepts indexed by their stable [`ConceptId`].
117    pub concepts: Vec<CgConcept>,
118    /// Reverse map: term → concept index.
119    pub term_to_id: HashMap<String, ConceptId>,
120    /// All edges (both directions may be represented; deduplication is done via
121    /// the canonical-pair key stored in `edge_index`).
122    pub edges: Vec<CgConceptEdge>,
123    /// Concept id → list of indices into `edges` (both in-coming and out-going).
124    pub adjacency: HashMap<usize, Vec<usize>>,
125    /// Canonical edge key `(min, max)` → index in `edges`.
126    edge_index: HashMap<(usize, usize), usize>,
127    pub total_documents: u64,
128}
129
130impl ConceptGraphBuilder {
131    /// Create a new builder with the supplied configuration.
132    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    // ── Concept management ───────────────────────────────────────────────
145
146    /// Return the [`ConceptId`] for `term`, creating it if it does not exist
147    /// and the max-concept cap has not been reached.
148    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            // Update embedding if one was supplied and none was stored yet.
151            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 sentinel id pointing past the end; callers must handle this.
158            // We use usize::MAX as the "invalid" marker.
159            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    // ── Document processing ──────────────────────────────────────────────
174
175    /// Tokenize `text`, index all tokens as concepts, and record
176    /// co-occurrence edges within the sliding window.
177    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        // Resolve (or create) concept ids for every token.
185        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                // Cap hit; skip this token.
190                concept_ids.push(cid);
191                continue;
192            }
193            let concept = &mut self.concepts[cid.0];
194            concept.frequency += 1;
195            // Deduplicated document tracking.
196            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        // Build co-occurrence edges within the sliding window.
203        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    /// Insert or update a co-occurrence edge between `a` and `b`, then
222    /// recompute the normalised weight.
223    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            // Increment co-occurrence and recompute weight.
227            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    // ── Explicit relations ───────────────────────────────────────────────
251
252    /// Add an explicit semantic relation edge.
253    ///
254    /// Returns `false` if either `term_a` or `term_b` is unknown.
255    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            // Overwrite the existing edge's relation and weight.
273            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    // ── Lookup helpers ───────────────────────────────────────────────────
292
293    /// Look up a concept by its term string.
294    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    /// Look up a concept by its [`ConceptId`].
300    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    // ── Graph traversal ──────────────────────────────────────────────────
308
309    /// Return all direct neighbours of `id` sorted by edge weight (descending).
310    ///
311    /// Each element is `(neighbour_concept, edge_weight)`.
312    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    /// BFS shortest path from `from` to `to`.
331    ///
332    /// Returns `None` if the nodes are not connected.
333    pub fn shortest_path(&self, from: ConceptId, to: ConceptId) -> Option<Vec<ConceptId>> {
334        if from == to {
335            return Some(vec![from]);
336        }
337        // Standard BFS.
338        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(&current.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    // ── Similarity search ────────────────────────────────────────────────
373
374    /// Return the top-`k` concepts most similar to `id`.
375    ///
376    /// Strategy:
377    /// - If the target concept has an embedding, rank all other embedded
378    ///   concepts by cosine similarity.
379    /// - Otherwise fall back to the top-k neighbours by edge weight.
380    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            // Embedding-based cosine similarity.
391            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            // Fall back: top-k neighbours by edge weight.
406            let mut nbrs = self.neighbors(id);
407            nbrs.truncate(k);
408            nbrs
409        }
410    }
411
412    // ── Pruning ──────────────────────────────────────────────────────────
413
414    /// Remove all concepts whose frequency is below `min_concept_frequency`.
415    ///
416    /// Associated edges and adjacency entries are also removed.
417    /// Returns the number of concepts removed.
418    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    /// Remove all edges whose weight is below `min_edge_weight`.
433    ///
434    /// Returns the number of edges removed.
435    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        // Collect indices of edges to remove.
440        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    // ── Statistics ───────────────────────────────────────────────────────
457
458    /// Compute a snapshot of graph statistics.
459    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            // Each edge contributes to two nodes' degree.
466            (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    // ── Internal helpers ─────────────────────────────────────────────────
478
479    /// Remove the given concept indices and rebuild all derived data structures.
480    fn remove_concepts(&mut self, to_remove: &HashSet<usize>) -> usize {
481        // Build a mapping old_id → new_id for surviving concepts.
482        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        // Rebuild term_to_id.
498        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        // Rebuild edges — drop any that reference a removed concept.
504        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        // Rebuild adjacency.
530        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    /// Rebuild internal edge structures excluding the given edge indices.
540    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        // Rebuild adjacency.
556        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// ─── Free functions ──────────────────────────────────────────────────────────
565
566/// Return the canonical (min, max) key for an undirected edge.
567#[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
577/// Split `text` on whitespace and ASCII punctuation; lowercase; keep tokens
578/// with at least 3 characters.
579pub 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
586/// Cosine similarity between two equal-length vectors.
587///
588/// Returns 0.0 if either vector is all-zero or if they have different lengths.
589pub 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// Re-export a helper only used in tests (needed because tests reference a private fn via full path).
603#[doc(hidden)]
604pub fn canonize_key_test(a: ConceptId, b: ConceptId) -> (usize, usize) {
605    canonical_key(a, b)
606}
607
608// ─── Tests ───────────────────────────────────────────────────────────────────
609
610#[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    // ── tokenize ─────────────────────────────────────────────────────────
635
636    #[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    // ── cosine_similarity ────────────────────────────────────────────────
674
675    #[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    // ── add_concept_term ─────────────────────────────────────────────────
705
706    #[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    // ── process_document ─────────────────────────────────────────────────
748
749    #[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    // ── add_relation ──────────────────────────────────────────────────────
800
801    #[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        // There should still be only one edge between them.
824        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    // ── concept_by_term / concept_by_id ──────────────────────────────────
839
840    #[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    // ── neighbors ────────────────────────────────────────────────────────
868
869    #[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        // Neighbours should be sorted by descending weight.
878        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    // ── shortest_path ────────────────────────────────────────────────────
893
894    #[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        // a-b edge, b-c edge — a reaches c through b.
919        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    // ── similar_concepts ─────────────────────────────────────────────────
940
941    #[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    // ── prune_low_frequency ───────────────────────────────────────────────
977
978    #[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        // edges before prune: rare-common
1023        let edges_before = b.edges.len();
1024        b.prune_low_frequency();
1025        assert!(b.edges.len() <= edges_before);
1026    }
1027
1028    // ── prune_weak_edges ─────────────────────────────────────────────────
1029
1030    #[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        // The single co-occurrence edge will likely be below 0.5.
1039        let _ = removed; // we just verify it doesn't panic
1040        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    // ── graph_stats ───────────────────────────────────────────────────────
1055
1056    #[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        // 2 concepts, 1 edge → avg_degree = 2 * 1 / 2 = 1.0
1083        assert!((s.avg_degree - 1.0).abs() < 1e-9);
1084    }
1085
1086    // ── ConceptId ordering ────────────────────────────────────────────────
1087
1088    #[test]
1089    fn test_concept_id_ordering() {
1090        assert!(ConceptId(0) < ConceptId(1));
1091        assert_eq!(ConceptId(5), ConceptId(5));
1092    }
1093
1094    // ── Full integration ──────────────────────────────────────────────────
1095
1096    #[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        // Prune noise.
1114        b.prune_low_frequency();
1115        b.prune_weak_edges();
1116
1117        // Core concepts should survive.
1118        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        // Graph should be connected.
1123        let stats = b.graph_stats();
1124        assert!(stats.concept_count > 0);
1125        assert!(stats.edge_count > 0);
1126    }
1127}