1use std::collections::{HashMap, HashSet, VecDeque};
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
11pub enum ConceptRelation {
12 IsA,
14 RelatedTo,
16 OppositeOf,
18}
19
20#[derive(Clone, Debug)]
22pub struct ConceptEdge {
23 pub from: String,
25 pub to: String,
27 pub relation: ConceptRelation,
29 pub weight: f32,
31}
32
33#[derive(Clone, Debug)]
35pub struct ConceptNode {
36 pub name: String,
38 pub embedding: Option<Vec<f32>>,
40 pub metadata: String,
42}
43
44#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct HierarchyStats {
47 pub total_concepts: usize,
49 pub total_edges: usize,
51 pub max_depth: usize,
53 pub root_count: usize,
55}
56
57pub struct SemanticConceptHierarchy {
63 pub concepts: HashMap<String, ConceptNode>,
65 pub edges: Vec<ConceptEdge>,
67}
68
69impl SemanticConceptHierarchy {
72 pub fn new() -> Self {
74 Self {
75 concepts: HashMap::new(),
76 edges: Vec::new(),
77 }
78 }
79
80 pub fn add_concept(&mut self, node: ConceptNode) {
83 self.concepts.entry(node.name.clone()).or_insert(node);
84 }
85
86 pub fn add_edge(&mut self, edge: ConceptEdge) {
89 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
103impl Default for SemanticConceptHierarchy {
106 fn default() -> Self {
107 Self::new()
108 }
109}
110
111impl SemanticConceptHierarchy {
114 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 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 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 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(¤t) {
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 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 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 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 for parent in self.parents_of(¤t) {
217 if visited.insert(parent.to_string()) {
218 queue.push_back((parent.to_string(), current_depth + 1));
219 }
220 }
221
222 for child in self.children_of(¤t) {
224 if visited.insert(child.to_string()) {
225 queue.push_back((child.to_string(), current_depth + 1));
226 }
227 }
228 }
229 }
230
231 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 pub fn stats(&self) -> HierarchyStats {
243 let total_concepts = self.concepts.len();
244 let total_edges = self.edges.len();
245
246 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 let max_depth = if total_concepts == 0 {
268 0
269 } else {
270 let mut global_max = 0usize;
271 for root in roots {
272 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 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#[cfg(test)]
316mod tests {
317 use super::*;
318
319 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]
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]
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 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]
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]
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]
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]
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]
415 fn test_ancestors_of_transitive() {
416 let mut h = SemanticConceptHierarchy::new();
417 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]
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]
439 fn test_related_to_both_directions() {
440 let mut h = SemanticConceptHierarchy::new();
441 h.add_edge(related("cat", "tiger")); h.add_edge(related("lion", "cat")); 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]
451 fn test_related_to_no_duplicates() {
452 let mut h = SemanticConceptHierarchy::new();
453 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]
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]
473 fn test_expand_query_depth_zero() {
474 let mut h = SemanticConceptHierarchy::new();
475 h.add_edge(isa("dog", "animal"));
476 let expanded = h.expand_query("dog", 0);
478 assert_eq!(expanded, vec!["dog".to_string()]);
479 }
480
481 #[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())); assert!(expanded.contains(&"poodle".to_string())); }
493
494 #[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]
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]
526 fn test_stats_root_count() {
527 let mut h = SemanticConceptHierarchy::new();
528 h.add_concept(node("entity")); h.add_concept(node("thing")); h.add_edge(isa("dog", "entity"));
531 let stats = h.stats();
532 assert_eq!(stats.root_count, 2);
533 }
534
535 #[test]
537 fn test_stats_max_depth_linear_chain() {
538 let mut h = SemanticConceptHierarchy::new();
539 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]
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]
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]
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]
589 fn test_remove_concept_false_for_unknown() {
590 let mut h = SemanticConceptHierarchy::new();
591 assert!(!h.remove_concept("nonexistent"));
592 }
593
594 #[test]
596 fn test_ancestors_no_infinite_loop_multi_path() {
597 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 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]
616 fn test_expand_query_multi_hop() {
617 let mut h = SemanticConceptHierarchy::new();
618 h.add_edge(isa("poodle", "dog"));
620 h.add_edge(isa("dog", "animal"));
621 h.add_edge(isa("animal", "entity"));
622
623 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]
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]
645 fn test_concept_relation_derives() {
646 let r = ConceptRelation::IsA;
647 let r2 = r; assert_eq!(r, r2);
649 assert_ne!(ConceptRelation::IsA, ConceptRelation::RelatedTo);
650 assert_ne!(ConceptRelation::RelatedTo, ConceptRelation::OppositeOf);
651 }
652}