Skip to main content

ipfrs_semantic/
concept_hierarchy.rs

1//! # Semantic Concept Hierarchy
2//!
3//! Models a concept ontology as a directed acyclic graph (DAG) where concepts
4//! are linked by "is-a" (hypernym/hyponym) and "related-to" relationships,
5//! enabling hierarchical search expansion.
6
7use std::collections::{HashMap, HashSet, VecDeque};
8
9/// The kind of directed relationship between two concepts.
10#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
11pub enum ConceptRelation {
12    /// The `from` concept is a subtype of the `to` concept (hypernym relationship).
13    IsA,
14    /// Bidirectional semantic similarity between the two concepts.
15    RelatedTo,
16    /// Antonym relationship between the two concepts.
17    OppositeOf,
18}
19
20/// A directed, weighted edge between two concepts in the ontology.
21#[derive(Clone, Debug)]
22pub struct ConceptEdge {
23    /// Source concept name.
24    pub from: String,
25    /// Target concept name.
26    pub to: String,
27    /// The semantic relationship type.
28    pub relation: ConceptRelation,
29    /// Strength of the relationship, clamped to [0.0, 1.0].
30    pub weight: f32,
31}
32
33/// A concept node in the ontology graph.
34#[derive(Clone, Debug)]
35pub struct ConceptNode {
36    /// Unique name / identifier of the concept.
37    pub name: String,
38    /// Optional prototype embedding vector for the concept.
39    pub embedding: Option<Vec<f32>>,
40    /// Free-form human-readable description.
41    pub metadata: String,
42}
43
44/// Aggregate statistics about the hierarchy.
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct HierarchyStats {
47    /// Total number of concept nodes.
48    pub total_concepts: usize,
49    /// Total number of edges (all relation types).
50    pub total_edges: usize,
51    /// Length of the longest IsA path from any root concept.
52    pub max_depth: usize,
53    /// Number of concepts that have no incoming IsA edges (i.e., root concepts).
54    pub root_count: usize,
55}
56
57/// A concept ontology modelled as a DAG supporting hierarchical search expansion.
58///
59/// Concepts are linked by [`ConceptRelation::IsA`], [`ConceptRelation::RelatedTo`],
60/// and [`ConceptRelation::OppositeOf`] edges.  The IsA relation induces a partial
61/// order from which roots, parents, children, and transitive ancestors are derived.
62pub struct SemanticConceptHierarchy {
63    /// All known concepts, keyed by name.
64    pub concepts: HashMap<String, ConceptNode>,
65    /// All edges in the ontology.
66    pub edges: Vec<ConceptEdge>,
67}
68
69// ── construction ────────────────────────────────────────────────────────────
70
71impl SemanticConceptHierarchy {
72    /// Create an empty hierarchy.
73    pub fn new() -> Self {
74        Self {
75            concepts: HashMap::new(),
76            edges: Vec::new(),
77        }
78    }
79
80    /// Add a concept node.  If a concept with the same name already exists it is
81    /// left unchanged (idempotent on name).
82    pub fn add_concept(&mut self, node: ConceptNode) {
83        self.concepts.entry(node.name.clone()).or_insert(node);
84    }
85
86    /// Add an edge.  Both the `from` and `to` concepts are automatically
87    /// registered with empty metadata if they are not already present.
88    pub fn add_edge(&mut self, edge: ConceptEdge) {
89        // Auto-register missing endpoints.
90        for name in [edge.from.clone(), edge.to.clone()] {
91            self.concepts
92                .entry(name.clone())
93                .or_insert_with(|| ConceptNode {
94                    name,
95                    embedding: None,
96                    metadata: String::new(),
97                });
98        }
99        self.edges.push(edge);
100    }
101}
102
103// ── Default ─────────────────────────────────────────────────────────────────
104
105impl Default for SemanticConceptHierarchy {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111// ── traversal helpers ────────────────────────────────────────────────────────
112
113impl SemanticConceptHierarchy {
114    /// Direct IsA parents of `concept` (edges where `from == concept` and
115    /// relation is [`ConceptRelation::IsA`]), sorted alphabetically.
116    pub fn parents_of<'a>(&'a self, concept: &str) -> Vec<&'a str> {
117        let mut parents: Vec<&str> = self
118            .edges
119            .iter()
120            .filter(|e| e.relation == ConceptRelation::IsA && e.from == concept)
121            .map(|e| e.to.as_str())
122            .collect();
123        parents.sort_unstable();
124        parents.dedup();
125        parents
126    }
127
128    /// Direct IsA children of `concept` (edges where `to == concept` and
129    /// relation is [`ConceptRelation::IsA`]), sorted alphabetically.
130    pub fn children_of<'a>(&'a self, concept: &str) -> Vec<&'a str> {
131        let mut children: Vec<&str> = self
132            .edges
133            .iter()
134            .filter(|e| e.relation == ConceptRelation::IsA && e.to == concept)
135            .map(|e| e.from.as_str())
136            .collect();
137        children.sort_unstable();
138        children.dedup();
139        children
140    }
141
142    /// All transitive IsA ancestors of `concept` via BFS.
143    ///
144    /// The result is sorted alphabetically, contains no duplicates, and does
145    /// not include `concept` itself.  Safe against DAGs with multiple paths
146    /// to the same ancestor (no infinite loop).
147    pub fn ancestors_of(&self, concept: &str) -> Vec<String> {
148        let mut visited: HashSet<String> = HashSet::new();
149        let mut queue: VecDeque<String> = VecDeque::new();
150
151        // Seed queue with direct parents.
152        for parent in self.parents_of(concept) {
153            if visited.insert(parent.to_string()) {
154                queue.push_back(parent.to_string());
155            }
156        }
157
158        while let Some(current) = queue.pop_front() {
159            for parent in self.parents_of(&current) {
160                if visited.insert(parent.to_string()) {
161                    queue.push_back(parent.to_string());
162                }
163            }
164        }
165
166        let mut result: Vec<String> = visited.into_iter().collect();
167        result.sort_unstable();
168        result
169    }
170
171    /// Direct [`ConceptRelation::RelatedTo`] neighbors of `concept` in either
172    /// direction, sorted alphabetically and deduplicated.
173    pub fn related_to<'a>(&'a self, concept: &str) -> Vec<&'a str> {
174        let mut neighbors: Vec<&str> = self
175            .edges
176            .iter()
177            .filter(|e| e.relation == ConceptRelation::RelatedTo)
178            .filter_map(|e| {
179                if e.from == concept {
180                    Some(e.to.as_str())
181                } else if e.to == concept {
182                    Some(e.from.as_str())
183                } else {
184                    None
185                }
186            })
187            .collect();
188        neighbors.sort_unstable();
189        neighbors.dedup();
190        neighbors
191    }
192
193    /// Expand a query concept into a set of related concepts for recall improvement.
194    ///
195    /// Performs a BFS over IsA edges in **both** directions (ancestors and
196    /// descendants) up to `depth` hops, then adds all direct
197    /// [`ConceptRelation::RelatedTo`] neighbours.  The concept itself is always
198    /// included.  The result is deduplicated and sorted alphabetically.
199    ///
200    /// When `depth == 0` only the concept itself is returned.
201    pub fn expand_query(&self, concept: &str, depth: usize) -> Vec<String> {
202        let mut visited: HashSet<String> = HashSet::new();
203        visited.insert(concept.to_string());
204
205        if depth > 0 {
206            // BFS over IsA edges in both directions (up and down).
207            let mut queue: VecDeque<(String, usize)> = VecDeque::new();
208            queue.push_back((concept.to_string(), 0));
209
210            while let Some((current, current_depth)) = queue.pop_front() {
211                if current_depth >= depth {
212                    continue;
213                }
214
215                // Upward (ancestors)
216                for parent in self.parents_of(&current) {
217                    if visited.insert(parent.to_string()) {
218                        queue.push_back((parent.to_string(), current_depth + 1));
219                    }
220                }
221
222                // Downward (descendants)
223                for child in self.children_of(&current) {
224                    if visited.insert(child.to_string()) {
225                        queue.push_back((child.to_string(), current_depth + 1));
226                    }
227                }
228            }
229        }
230
231        // Include direct RelatedTo neighbours regardless of depth.
232        for neighbor in self.related_to(concept) {
233            visited.insert(neighbor.to_string());
234        }
235
236        let mut result: Vec<String> = visited.into_iter().collect();
237        result.sort_unstable();
238        result
239    }
240
241    /// Compute aggregate statistics for the hierarchy.
242    pub fn stats(&self) -> HierarchyStats {
243        let total_concepts = self.concepts.len();
244        let total_edges = self.edges.len();
245
246        // A root is a concept that has no IsA parent, i.e. it never appears as
247        // the `from` end of an IsA edge.  In the convention "from IsA to",
248        // `from` is the child/subtype and `to` is the parent/supertype, so a
249        // root (top of the hierarchy) is a concept that is never a child.
250        let has_isa_parent: HashSet<&str> = self
251            .edges
252            .iter()
253            .filter(|e| e.relation == ConceptRelation::IsA)
254            .map(|e| e.from.as_str())
255            .collect();
256
257        let roots: Vec<&str> = self
258            .concepts
259            .keys()
260            .map(|k| k.as_str())
261            .filter(|k| !has_isa_parent.contains(*k))
262            .collect();
263
264        let root_count = roots.len();
265
266        // BFS from every root, tracking the maximum path length reached.
267        let max_depth = if total_concepts == 0 {
268            0
269        } else {
270            let mut global_max = 0usize;
271            for root in roots {
272                // (concept_name, depth_from_root)
273                let mut queue: VecDeque<(&str, usize)> = VecDeque::new();
274                let mut local_visited: HashSet<&str> = HashSet::new();
275                queue.push_back((root, 0));
276                local_visited.insert(root);
277
278                while let Some((current, depth)) = queue.pop_front() {
279                    if depth > global_max {
280                        global_max = depth;
281                    }
282                    for child in self.children_of(current) {
283                        if local_visited.insert(child) {
284                            queue.push_back((child, depth + 1));
285                        }
286                    }
287                }
288            }
289            global_max
290        };
291
292        HierarchyStats {
293            total_concepts,
294            total_edges,
295            max_depth,
296            root_count,
297        }
298    }
299
300    /// Remove a concept and all edges that connect to or from it.
301    ///
302    /// Returns `true` if the concept existed and was removed, `false` if it
303    /// was not found.
304    pub fn remove_concept(&mut self, name: &str) -> bool {
305        if self.concepts.remove(name).is_none() {
306            return false;
307        }
308        self.edges.retain(|e| e.from != name && e.to != name);
309        true
310    }
311}
312
313// ── tests ─────────────────────────────────────────────────────────────────
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    // ── helpers ──────────────────────────────────────────────────────────
320
321    fn node(name: &str) -> ConceptNode {
322        ConceptNode {
323            name: name.to_string(),
324            embedding: None,
325            metadata: name.to_string(),
326        }
327    }
328
329    fn isa(from: &str, to: &str) -> ConceptEdge {
330        ConceptEdge {
331            from: from.to_string(),
332            to: to.to_string(),
333            relation: ConceptRelation::IsA,
334            weight: 1.0,
335        }
336    }
337
338    fn related(a: &str, b: &str) -> ConceptEdge {
339        ConceptEdge {
340            from: a.to_string(),
341            to: b.to_string(),
342            relation: ConceptRelation::RelatedTo,
343            weight: 0.8,
344        }
345    }
346
347    // ── test 1: new() starts empty ────────────────────────────────────────
348    #[test]
349    fn test_new_starts_empty() {
350        let h = SemanticConceptHierarchy::new();
351        assert!(h.concepts.is_empty());
352        assert!(h.edges.is_empty());
353    }
354
355    // ── test 2: add_concept idempotent ───────────────────────────────────
356    #[test]
357    fn test_add_concept_idempotent() {
358        let mut h = SemanticConceptHierarchy::new();
359        h.add_concept(ConceptNode {
360            name: "animal".to_string(),
361            embedding: Some(vec![1.0, 2.0]),
362            metadata: "original".to_string(),
363        });
364        // Second add must not overwrite.
365        h.add_concept(ConceptNode {
366            name: "animal".to_string(),
367            embedding: None,
368            metadata: "overwrite-attempt".to_string(),
369        });
370        assert_eq!(h.concepts.len(), 1);
371        assert_eq!(h.concepts["animal"].metadata, "original");
372    }
373
374    // ── test 3: add_edge auto-registers missing concepts ─────────────────
375    #[test]
376    fn test_add_edge_auto_registers_concepts() {
377        let mut h = SemanticConceptHierarchy::new();
378        h.add_edge(isa("dog", "animal"));
379        assert!(h.concepts.contains_key("dog"));
380        assert!(h.concepts.contains_key("animal"));
381        assert_eq!(h.edges.len(), 1);
382    }
383
384    // ── test 4: parents_of returns direct IsA parents ────────────────────
385    #[test]
386    fn test_parents_of_direct() {
387        let mut h = SemanticConceptHierarchy::new();
388        h.add_edge(isa("dog", "animal"));
389        h.add_edge(isa("dog", "mammal"));
390        let parents = h.parents_of("dog");
391        assert_eq!(parents, vec!["animal", "mammal"]);
392    }
393
394    // ── test 5: parents_of empty for root ────────────────────────────────
395    #[test]
396    fn test_parents_of_empty_for_root() {
397        let mut h = SemanticConceptHierarchy::new();
398        h.add_concept(node("entity"));
399        assert!(h.parents_of("entity").is_empty());
400    }
401
402    // ── test 6: children_of returns direct IsA children ──────────────────
403    #[test]
404    fn test_children_of_direct() {
405        let mut h = SemanticConceptHierarchy::new();
406        h.add_edge(isa("dog", "animal"));
407        h.add_edge(isa("cat", "animal"));
408        let mut children = h.children_of("animal");
409        children.sort_unstable();
410        assert_eq!(children, vec!["cat", "dog"]);
411    }
412
413    // ── test 7: ancestors_of transitively includes grandparents ──────────
414    #[test]
415    fn test_ancestors_of_transitive() {
416        let mut h = SemanticConceptHierarchy::new();
417        // poodle -> dog -> animal -> entity
418        h.add_edge(isa("poodle", "dog"));
419        h.add_edge(isa("dog", "animal"));
420        h.add_edge(isa("animal", "entity"));
421
422        let ancestors = h.ancestors_of("poodle");
423        assert!(ancestors.contains(&"dog".to_string()));
424        assert!(ancestors.contains(&"animal".to_string()));
425        assert!(ancestors.contains(&"entity".to_string()));
426        assert!(!ancestors.contains(&"poodle".to_string()));
427    }
428
429    // ── test 8: ancestors_of returns empty for root ───────────────────────
430    #[test]
431    fn test_ancestors_of_empty_for_root() {
432        let mut h = SemanticConceptHierarchy::new();
433        h.add_concept(node("entity"));
434        assert!(h.ancestors_of("entity").is_empty());
435    }
436
437    // ── test 9: related_to both directions ───────────────────────────────
438    #[test]
439    fn test_related_to_both_directions() {
440        let mut h = SemanticConceptHierarchy::new();
441        h.add_edge(related("cat", "tiger")); // forward
442        h.add_edge(related("lion", "cat")); // backward
443
444        let related_to_cat = h.related_to("cat");
445        assert!(related_to_cat.contains(&"tiger"));
446        assert!(related_to_cat.contains(&"lion"));
447    }
448
449    // ── test 10: related_to no duplicates ────────────────────────────────
450    #[test]
451    fn test_related_to_no_duplicates() {
452        let mut h = SemanticConceptHierarchy::new();
453        // Two RelatedTo edges in both directions between the same pair.
454        h.add_edge(related("a", "b"));
455        h.add_edge(related("b", "a"));
456
457        let rel_a = h.related_to("a");
458        let count_b = rel_a.iter().filter(|&&x| x == "b").count();
459        assert_eq!(count_b, 1, "should deduplicate 'b'");
460    }
461
462    // ── test 11: expand_query includes self ───────────────────────────────
463    #[test]
464    fn test_expand_query_includes_self() {
465        let mut h = SemanticConceptHierarchy::new();
466        h.add_concept(node("alpha"));
467        let expanded = h.expand_query("alpha", 2);
468        assert!(expanded.contains(&"alpha".to_string()));
469    }
470
471    // ── test 12: expand_query depth=0 returns only self ──────────────────
472    #[test]
473    fn test_expand_query_depth_zero() {
474        let mut h = SemanticConceptHierarchy::new();
475        h.add_edge(isa("dog", "animal"));
476        // No RelatedTo edges, so depth 0 must give only "dog".
477        let expanded = h.expand_query("dog", 0);
478        assert_eq!(expanded, vec!["dog".to_string()]);
479    }
480
481    // ── test 13: expand_query depth=1 includes parents and children ───────
482    #[test]
483    fn test_expand_query_depth_one_parents_children() {
484        let mut h = SemanticConceptHierarchy::new();
485        h.add_edge(isa("dog", "animal"));
486        h.add_edge(isa("poodle", "dog"));
487
488        let expanded = h.expand_query("dog", 1);
489        assert!(expanded.contains(&"dog".to_string()));
490        assert!(expanded.contains(&"animal".to_string())); // parent
491        assert!(expanded.contains(&"poodle".to_string())); // child
492    }
493
494    // ── test 14: expand_query includes RelatedTo neighbors ────────────────
495    #[test]
496    fn test_expand_query_includes_related_to() {
497        let mut h = SemanticConceptHierarchy::new();
498        h.add_concept(node("cat"));
499        h.add_edge(related("cat", "tiger"));
500
501        let expanded = h.expand_query("cat", 0);
502        assert!(expanded.contains(&"cat".to_string()));
503        assert!(expanded.contains(&"tiger".to_string()));
504    }
505
506    // ── test 15: expand_query sorted and deduped ──────────────────────────
507    #[test]
508    fn test_expand_query_sorted_deduped() {
509        let mut h = SemanticConceptHierarchy::new();
510        h.add_edge(isa("dog", "animal"));
511        h.add_edge(isa("dog", "mammal"));
512        h.add_edge(related("dog", "wolf"));
513
514        let expanded = h.expand_query("dog", 2);
515        let mut sorted = expanded.clone();
516        sorted.sort_unstable();
517        sorted.dedup();
518        assert_eq!(
519            expanded, sorted,
520            "expand_query must return sorted, deduped results"
521        );
522    }
523
524    // ── test 16: stats root_count correct ────────────────────────────────
525    #[test]
526    fn test_stats_root_count() {
527        let mut h = SemanticConceptHierarchy::new();
528        h.add_concept(node("entity")); // root
529        h.add_concept(node("thing")); // root
530        h.add_edge(isa("dog", "entity"));
531        let stats = h.stats();
532        assert_eq!(stats.root_count, 2);
533    }
534
535    // ── test 17: stats max_depth for linear chain ─────────────────────────
536    #[test]
537    fn test_stats_max_depth_linear_chain() {
538        let mut h = SemanticConceptHierarchy::new();
539        // entity -> animal -> mammal -> dog -> poodle  (depth 4 from root)
540        h.add_edge(isa("animal", "entity"));
541        h.add_edge(isa("mammal", "animal"));
542        h.add_edge(isa("dog", "mammal"));
543        h.add_edge(isa("poodle", "dog"));
544
545        let stats = h.stats();
546        assert_eq!(stats.max_depth, 4);
547    }
548
549    // ── test 18: stats total_concepts and total_edges ─────────────────────
550    #[test]
551    fn test_stats_totals() {
552        let mut h = SemanticConceptHierarchy::new();
553        h.add_concept(node("a"));
554        h.add_concept(node("b"));
555        h.add_edge(isa("a", "b"));
556        h.add_edge(related("a", "b"));
557
558        let stats = h.stats();
559        assert_eq!(stats.total_concepts, 2);
560        assert_eq!(stats.total_edges, 2);
561    }
562
563    // ── test 19: remove_concept removes node ─────────────────────────────
564    #[test]
565    fn test_remove_concept_removes_node() {
566        let mut h = SemanticConceptHierarchy::new();
567        h.add_concept(node("dog"));
568        assert!(h.remove_concept("dog"));
569        assert!(!h.concepts.contains_key("dog"));
570    }
571
572    // ── test 20: remove_concept removes connected edges ───────────────────
573    #[test]
574    fn test_remove_concept_removes_edges() {
575        let mut h = SemanticConceptHierarchy::new();
576        h.add_edge(isa("dog", "animal"));
577        h.add_edge(related("dog", "wolf"));
578        h.add_edge(isa("poodle", "dog"));
579
580        assert!(h.remove_concept("dog"));
581        assert!(
582            h.edges.is_empty(),
583            "all edges touching 'dog' must be removed"
584        );
585    }
586
587    // ── test 21: remove_concept returns false for unknown ─────────────────
588    #[test]
589    fn test_remove_concept_false_for_unknown() {
590        let mut h = SemanticConceptHierarchy::new();
591        assert!(!h.remove_concept("nonexistent"));
592    }
593
594    // ── test 22: ancestors_of no infinite loop in DAG with multiple paths ─
595    #[test]
596    fn test_ancestors_no_infinite_loop_multi_path() {
597        // Diamond: poodle -> dog -> animal -> entity
598        //                  mammal -> entity
599        //          dog -> mammal
600        let mut h = SemanticConceptHierarchy::new();
601        h.add_edge(isa("poodle", "dog"));
602        h.add_edge(isa("dog", "animal"));
603        h.add_edge(isa("dog", "mammal"));
604        h.add_edge(isa("animal", "entity"));
605        h.add_edge(isa("mammal", "entity"));
606
607        let ancestors = h.ancestors_of("poodle");
608        // Must contain all four ancestors exactly once.
609        let count_entity = ancestors.iter().filter(|x| x.as_str() == "entity").count();
610        assert_eq!(count_entity, 1, "'entity' must appear exactly once");
611        assert_eq!(ancestors.len(), 4, "dog, animal, mammal, entity");
612    }
613
614    // ── test 23: expand_query depth>1 traverses multiple hops ────────────
615    #[test]
616    fn test_expand_query_multi_hop() {
617        let mut h = SemanticConceptHierarchy::new();
618        // poodle -> dog -> animal -> entity
619        h.add_edge(isa("poodle", "dog"));
620        h.add_edge(isa("dog", "animal"));
621        h.add_edge(isa("animal", "entity"));
622
623        // depth 3 from "dog": should reach poodle (down 1), animal (up 1), entity (up 2)
624        let expanded = h.expand_query("dog", 3);
625        assert!(expanded.contains(&"poodle".to_string()));
626        assert!(expanded.contains(&"animal".to_string()));
627        assert!(expanded.contains(&"entity".to_string()));
628    }
629
630    // ── test 24: HierarchyStats equality ─────────────────────────────────
631    #[test]
632    fn test_hierarchy_stats_eq() {
633        let s1 = HierarchyStats {
634            total_concepts: 3,
635            total_edges: 2,
636            max_depth: 2,
637            root_count: 1,
638        };
639        let s2 = s1.clone();
640        assert_eq!(s1, s2);
641    }
642
643    // ── test 25: ConceptRelation derives ─────────────────────────────────
644    #[test]
645    fn test_concept_relation_derives() {
646        let r = ConceptRelation::IsA;
647        let r2 = r; // Copy
648        assert_eq!(r, r2);
649        assert_ne!(ConceptRelation::IsA, ConceptRelation::RelatedTo);
650        assert_ne!(ConceptRelation::RelatedTo, ConceptRelation::OppositeOf);
651    }
652}