1use std::collections::{HashMap, HashSet, VecDeque};
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10pub enum SynonymRelation {
11 Exact,
13 Near,
15 Broader,
17 Narrower,
19}
20
21#[derive(Clone, Debug)]
23pub struct SynonymEdge {
24 pub target: String,
26 pub relation: SynonymRelation,
28 pub weight: f32,
30}
31
32#[derive(Clone, Debug)]
34pub struct ExpanderConfig {
35 pub max_hops: usize,
37 pub min_weight: f32,
39 pub max_expansions: usize,
41}
42
43impl Default for ExpanderConfig {
44 fn default() -> Self {
45 Self {
46 max_hops: 2,
47 min_weight: 0.5,
48 max_expansions: 20,
49 }
50 }
51}
52
53#[derive(Clone, Debug)]
55pub struct ExpandedTerm {
56 pub term: String,
58 pub relation: SynonymRelation,
60 pub cumulative_weight: f32,
62 pub hops: usize,
64}
65
66#[derive(Clone, Debug, Default)]
68pub struct SynonymExpanderStats {
69 pub total_terms: usize,
71 pub total_edges: usize,
73 pub total_expand_calls: u64,
75 pub total_terms_expanded: u64,
77}
78
79struct FrontierEntry {
81 term: String,
82 hops: usize,
83 cumulative_weight: f32,
84 relation: SynonymRelation,
85}
86
87pub struct SemanticSynonymExpander {
92 pub graph: HashMap<String, Vec<SynonymEdge>>,
94 pub config: ExpanderConfig,
96 pub stats: SynonymExpanderStats,
98}
99
100impl SemanticSynonymExpander {
101 pub fn new(config: ExpanderConfig) -> Self {
103 Self {
104 graph: HashMap::new(),
105 config,
106 stats: SynonymExpanderStats::default(),
107 }
108 }
109
110 pub fn add_synonym(&mut self, from: &str, to: &str, relation: SynonymRelation, weight: f32) {
120 let reverse_relation = match relation {
121 SynonymRelation::Broader => SynonymRelation::Narrower,
122 SynonymRelation::Narrower => SynonymRelation::Broader,
123 other => other,
124 };
125
126 let forward_added = Self::insert_edge(
128 &mut self.graph,
129 from,
130 SynonymEdge {
131 target: to.to_owned(),
132 relation,
133 weight,
134 },
135 );
136
137 let reverse_added = Self::insert_edge(
139 &mut self.graph,
140 to,
141 SynonymEdge {
142 target: from.to_owned(),
143 relation: reverse_relation,
144 weight,
145 },
146 );
147
148 self.stats.total_edges += forward_added as usize + reverse_added as usize;
149 self.stats.total_terms = self.graph.len();
150 }
151
152 fn insert_edge(
155 graph: &mut HashMap<String, Vec<SynonymEdge>>,
156 node: &str,
157 edge: SynonymEdge,
158 ) -> bool {
159 let edges = graph.entry(node.to_owned()).or_default();
160 let is_dup = edges
161 .iter()
162 .any(|e| e.target == edge.target && e.relation == edge.relation);
163 if is_dup {
164 return false;
165 }
166 edges.push(edge);
167 true
168 }
169
170 pub fn expand(
177 &mut self,
178 term: &str,
179 allowed_relations: Option<&[SynonymRelation]>,
180 ) -> Vec<ExpandedTerm> {
181 self.stats.total_expand_calls += 1;
182
183 if !self.graph.contains_key(term) {
184 return Vec::new();
185 }
186
187 let mut visited: HashSet<String> = HashSet::new();
188 visited.insert(term.to_owned());
189
190 let mut queue: VecDeque<FrontierEntry> = VecDeque::new();
191
192 if let Some(edges) = self.graph.get(term) {
194 for edge in edges {
195 if edge.weight < self.config.min_weight {
196 continue;
197 }
198 if let Some(allowed) = allowed_relations {
199 if !allowed.contains(&edge.relation) {
200 continue;
201 }
202 }
203 if visited.insert(edge.target.clone()) {
204 queue.push_back(FrontierEntry {
205 term: edge.target.clone(),
206 hops: 1,
207 cumulative_weight: edge.weight,
208 relation: edge.relation,
209 });
210 }
211 }
212 }
213
214 let mut results: Vec<ExpandedTerm> = Vec::new();
215
216 while let Some(entry) = queue.pop_front() {
217 results.push(ExpandedTerm {
218 term: entry.term.clone(),
219 relation: entry.relation,
220 cumulative_weight: entry.cumulative_weight,
221 hops: entry.hops,
222 });
223
224 if entry.hops >= self.config.max_hops {
225 continue;
226 }
227
228 if let Some(edges) = self.graph.get(&entry.term) {
230 let candidates: Vec<_> = edges
232 .iter()
233 .filter(|e| {
234 e.weight >= self.config.min_weight
235 && allowed_relations
236 .map(|a| a.contains(&e.relation))
237 .unwrap_or(true)
238 && !visited.contains(&e.target)
239 })
240 .map(|e| (e.target.clone(), e.relation, e.weight))
241 .collect();
242
243 for (target, relation, weight) in candidates {
244 if visited.insert(target.clone()) {
245 queue.push_back(FrontierEntry {
246 term: target,
247 hops: entry.hops + 1,
248 cumulative_weight: entry.cumulative_weight * weight,
249 relation,
250 });
251 }
252 }
253 }
254 }
255
256 results.sort_by(|a, b| {
258 b.cumulative_weight
259 .partial_cmp(&a.cumulative_weight)
260 .unwrap_or(std::cmp::Ordering::Equal)
261 .then_with(|| a.term.cmp(&b.term))
262 });
263
264 results.truncate(self.config.max_expansions);
265
266 self.stats.total_terms_expanded += results.len() as u64;
267 results
268 }
269
270 pub fn remove_term(&mut self, term: &str) {
272 self.graph.remove(term);
273 for edges in self.graph.values_mut() {
274 edges.retain(|e| e.target != term);
275 }
276 self.stats.total_terms = self.graph.len();
277 self.stats.total_edges = self.graph.values().map(|v| v.len()).sum();
278 }
279
280 pub fn stats(&self) -> &SynonymExpanderStats {
282 &self.stats
283 }
284
285 pub fn term_count(&self) -> usize {
287 self.graph.len()
288 }
289}
290
291#[cfg(test)]
296mod tests {
297 use super::*;
298
299 fn default_expander() -> SemanticSynonymExpander {
300 SemanticSynonymExpander::new(ExpanderConfig::default())
301 }
302
303 #[test]
308 fn test_add_synonym_bidirectional_exact() {
309 let mut exp = default_expander();
310 exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.9);
311 assert!(exp.graph["car"].iter().any(|e| e.target == "automobile"));
313 assert!(exp.graph["automobile"].iter().any(|e| e.target == "car"));
315 }
316
317 #[test]
318 fn test_add_synonym_bidirectional_near() {
319 let mut exp = default_expander();
320 exp.add_synonym("happy", "joyful", SynonymRelation::Near, 0.8);
321 let fwd = exp.graph["happy"]
322 .iter()
323 .find(|e| e.target == "joyful")
324 .expect("forward edge missing");
325 let rev = exp.graph["joyful"]
326 .iter()
327 .find(|e| e.target == "happy")
328 .expect("reverse edge missing");
329 assert_eq!(fwd.relation, SynonymRelation::Near);
330 assert_eq!(rev.relation, SynonymRelation::Near);
331 }
332
333 #[test]
334 fn test_add_synonym_broader_reverse_is_narrower() {
335 let mut exp = default_expander();
336 exp.add_synonym("spaniel", "dog", SynonymRelation::Broader, 0.9);
337 let rev = exp.graph["dog"]
338 .iter()
339 .find(|e| e.target == "spaniel")
340 .expect("reverse edge missing");
341 assert_eq!(rev.relation, SynonymRelation::Narrower);
342 }
343
344 #[test]
345 fn test_add_synonym_narrower_reverse_is_broader() {
346 let mut exp = default_expander();
347 exp.add_synonym("dog", "spaniel", SynonymRelation::Narrower, 0.9);
348 let rev = exp.graph["spaniel"]
349 .iter()
350 .find(|e| e.target == "dog")
351 .expect("reverse edge missing");
352 assert_eq!(rev.relation, SynonymRelation::Broader);
353 }
354
355 #[test]
356 fn test_add_synonym_weight_preserved() {
357 let mut exp = default_expander();
358 exp.add_synonym("a", "b", SynonymRelation::Exact, 0.75);
359 assert!((exp.graph["a"][0].weight - 0.75).abs() < f32::EPSILON);
360 assert!((exp.graph["b"][0].weight - 0.75).abs() < f32::EPSILON);
361 }
362
363 #[test]
368 fn test_add_synonym_no_duplicate_edges() {
369 let mut exp = default_expander();
370 exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.9);
371 exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.7);
372 assert_eq!(exp.graph["car"].len(), 1);
374 assert_eq!(exp.graph["automobile"].len(), 1);
375 }
376
377 #[test]
378 fn test_add_synonym_duplicate_does_not_increment_stats() {
379 let mut exp = default_expander();
380 exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.9);
381 let edges_after_first = exp.stats.total_edges;
382 exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.7);
383 assert_eq!(exp.stats.total_edges, edges_after_first);
384 }
385
386 #[test]
387 fn test_add_synonym_different_relation_is_not_duplicate() {
388 let mut exp = default_expander();
389 exp.add_synonym("word", "synonym", SynonymRelation::Exact, 0.9);
390 exp.add_synonym("word", "synonym", SynonymRelation::Near, 0.7);
391 assert_eq!(exp.graph["word"].len(), 2);
393 }
394
395 #[test]
400 fn test_stats_total_terms_and_edges() {
401 let mut exp = default_expander();
402 exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
403 assert_eq!(exp.stats.total_terms, 2);
404 assert_eq!(exp.stats.total_edges, 2); }
406
407 #[test]
408 fn test_stats_multiple_synonyms() {
409 let mut exp = default_expander();
410 exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
411 exp.add_synonym("a", "c", SynonymRelation::Near, 0.8);
412 assert_eq!(exp.stats.total_terms, 3);
413 assert_eq!(exp.stats.total_edges, 4); }
415
416 #[test]
421 fn test_expand_empty_for_unknown_term() {
422 let mut exp = default_expander();
423 let results = exp.expand("ghost", None);
424 assert!(results.is_empty());
425 }
426
427 #[test]
428 fn test_expand_single_hop() {
429 let mut exp = default_expander();
430 exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.9);
431 let results = exp.expand("car", None);
432 assert_eq!(results.len(), 1);
433 assert_eq!(results[0].term, "automobile");
434 assert_eq!(results[0].hops, 1);
435 assert!((results[0].cumulative_weight - 0.9).abs() < 1e-5);
436 }
437
438 #[test]
439 fn test_expand_does_not_include_query_term() {
440 let mut exp = default_expander();
441 exp.add_synonym("car", "automobile", SynonymRelation::Exact, 0.9);
442 let results = exp.expand("car", None);
443 assert!(!results.iter().any(|r| r.term == "car"));
444 }
445
446 #[test]
451 fn test_expand_bfs_up_to_max_hops() {
452 let config = ExpanderConfig {
453 max_hops: 2,
454 ..ExpanderConfig::default()
455 };
456 let mut exp = SemanticSynonymExpander::new(config);
457 exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
459 exp.add_synonym("b", "c", SynonymRelation::Exact, 0.9);
460 exp.add_synonym("c", "d", SynonymRelation::Exact, 0.9);
461 let results = exp.expand("a", None);
462 let terms: Vec<&str> = results.iter().map(|r| r.term.as_str()).collect();
463 assert!(terms.contains(&"b"), "hop-1 term b must be present");
464 assert!(terms.contains(&"c"), "hop-2 term c must be present");
465 assert!(
467 !terms.contains(&"d"),
468 "hop-3 term d must be absent with max_hops=2"
469 );
470 }
471
472 #[test]
473 fn test_expand_max_hops_one() {
474 let config = ExpanderConfig {
475 max_hops: 1,
476 ..ExpanderConfig::default()
477 };
478 let mut exp = SemanticSynonymExpander::new(config);
479 exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
480 exp.add_synonym("b", "c", SynonymRelation::Exact, 0.9);
481 let results = exp.expand("a", None);
482 let terms: Vec<&str> = results.iter().map(|r| r.term.as_str()).collect();
483 assert!(terms.contains(&"b"));
484 assert!(!terms.contains(&"c"));
485 }
486
487 #[test]
492 fn test_expand_filters_by_min_weight() {
493 let config = ExpanderConfig {
494 min_weight: 0.7,
495 ..ExpanderConfig::default()
496 };
497 let mut exp = SemanticSynonymExpander::new(config);
498 exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9); exp.add_synonym("a", "c", SynonymRelation::Near, 0.4); let results = exp.expand("a", None);
501 let terms: Vec<&str> = results.iter().map(|r| r.term.as_str()).collect();
502 assert!(terms.contains(&"b"));
503 assert!(!terms.contains(&"c"));
504 }
505
506 #[test]
507 fn test_expand_min_weight_exact_boundary() {
508 let config = ExpanderConfig {
509 min_weight: 0.5,
510 ..ExpanderConfig::default()
511 };
512 let mut exp = SemanticSynonymExpander::new(config);
513 exp.add_synonym("x", "y", SynonymRelation::Exact, 0.5); let results = exp.expand("x", None);
515 assert!(
516 !results.is_empty(),
517 "edge at exactly min_weight should be followed"
518 );
519 }
520
521 #[test]
526 fn test_expand_filters_by_allowed_relations() {
527 let mut exp = default_expander();
528 exp.add_synonym("fruit", "apple", SynonymRelation::Narrower, 0.9);
529 exp.add_synonym("fruit", "food", SynonymRelation::Broader, 0.9);
530 let results = exp.expand("fruit", Some(&[SynonymRelation::Narrower]));
532 let terms: Vec<&str> = results.iter().map(|r| r.term.as_str()).collect();
533 assert!(terms.contains(&"apple"));
534 assert!(!terms.contains(&"food"));
535 }
536
537 #[test]
538 fn test_expand_allowed_relations_none_means_all() {
539 let mut exp = default_expander();
540 exp.add_synonym("fruit", "apple", SynonymRelation::Narrower, 0.9);
541 exp.add_synonym("fruit", "food", SynonymRelation::Broader, 0.9);
542 let results = exp.expand("fruit", None);
543 assert!(results.len() >= 2);
545 }
546
547 #[test]
552 fn test_expand_cumulative_weight_product() {
553 let config = ExpanderConfig {
554 max_hops: 3,
555 ..ExpanderConfig::default()
556 };
557 let mut exp = SemanticSynonymExpander::new(config);
558 exp.add_synonym("a", "b", SynonymRelation::Exact, 0.8);
559 exp.add_synonym("b", "c", SynonymRelation::Exact, 0.9);
560 let results = exp.expand("a", None);
561 let c = results
562 .iter()
563 .find(|r| r.term == "c")
564 .expect("c should be found");
565 let expected = 0.8_f32 * 0.9;
566 assert!(
567 (c.cumulative_weight - expected).abs() < 1e-5,
568 "expected cumulative_weight ~ {expected}, got {}",
569 c.cumulative_weight
570 );
571 assert_eq!(c.hops, 2);
572 }
573
574 #[test]
579 fn test_expand_truncates_at_max_expansions() {
580 let config = ExpanderConfig {
581 max_expansions: 3,
582 ..ExpanderConfig::default()
583 };
584 let mut exp = SemanticSynonymExpander::new(config);
585 for i in 0..10 {
586 exp.add_synonym("root", &format!("term{i}"), SynonymRelation::Near, 0.9);
587 }
588 let results = exp.expand("root", None);
589 assert!(results.len() <= 3, "results exceeded max_expansions");
590 }
591
592 #[test]
597 fn test_expand_sorted_by_weight_descending() {
598 let mut exp = default_expander();
599 exp.add_synonym("root", "high", SynonymRelation::Exact, 0.95);
600 exp.add_synonym("root", "low", SynonymRelation::Near, 0.6);
601 let results = exp.expand("root", None);
602 assert_eq!(results[0].term, "high");
604 }
605
606 #[test]
607 fn test_expand_sorted_alphabetically_on_weight_tie() {
608 let mut exp = default_expander();
609 exp.add_synonym("root", "beta", SynonymRelation::Exact, 0.8);
610 exp.add_synonym("root", "alpha", SynonymRelation::Exact, 0.8);
611 let results = exp.expand("root", None);
612 assert_eq!(results[0].term, "alpha");
614 assert_eq!(results[1].term, "beta");
615 }
616
617 #[test]
622 fn test_expand_increments_total_expand_calls() {
623 let mut exp = default_expander();
624 exp.expand("unknown", None);
625 exp.expand("unknown", None);
626 assert_eq!(exp.stats.total_expand_calls, 2);
627 }
628
629 #[test]
630 fn test_expand_accumulates_total_terms_expanded() {
631 let mut exp = default_expander();
632 exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
633 exp.add_synonym("a", "c", SynonymRelation::Exact, 0.9);
634 let r = exp.expand("a", None);
635 assert_eq!(exp.stats.total_terms_expanded, r.len() as u64);
636 exp.expand("a", None);
637 assert_eq!(exp.stats.total_terms_expanded, 2 * r.len() as u64);
638 }
639
640 #[test]
645 fn test_remove_term_removes_node() {
646 let mut exp = default_expander();
647 exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
648 exp.remove_term("b");
649 assert!(!exp.graph.contains_key("b"));
650 }
651
652 #[test]
653 fn test_remove_term_removes_incoming_edges() {
654 let mut exp = default_expander();
655 exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
656 exp.remove_term("b");
657 assert!(!exp.graph["a"].iter().any(|e| e.target == "b"));
659 }
660
661 #[test]
662 fn test_remove_term_updates_stats() {
663 let mut exp = default_expander();
664 exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
665 exp.remove_term("b");
666 assert_eq!(exp.stats.total_terms, 1); assert_eq!(exp.stats.total_edges, 0); }
669
670 #[test]
671 fn test_remove_term_unknown_is_noop() {
672 let mut exp = default_expander();
673 exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
674 let terms_before = exp.stats.total_terms;
675 let edges_before = exp.stats.total_edges;
676 exp.remove_term("ghost");
677 assert_eq!(exp.stats.total_terms, terms_before);
678 assert_eq!(exp.stats.total_edges, edges_before);
679 }
680
681 #[test]
686 fn test_term_count() {
687 let mut exp = default_expander();
688 assert_eq!(exp.term_count(), 0);
689 exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
690 assert_eq!(exp.term_count(), 2);
691 }
692
693 #[test]
698 fn test_stats_accessor_returns_ref() {
699 let exp = default_expander();
700 let s = exp.stats();
701 assert_eq!(s.total_expand_calls, 0);
702 }
703
704 #[test]
709 fn test_expander_config_defaults() {
710 let cfg = ExpanderConfig::default();
711 assert_eq!(cfg.max_hops, 2);
712 assert!((cfg.min_weight - 0.5).abs() < f32::EPSILON);
713 assert_eq!(cfg.max_expansions, 20);
714 }
715
716 #[test]
721 fn test_expand_no_revisit_cycle() {
722 let mut exp = default_expander();
723 exp.add_synonym("a", "b", SynonymRelation::Exact, 0.9);
726 exp.add_synonym("b", "c", SynonymRelation::Exact, 0.9);
727 exp.add_synonym("c", "a", SynonymRelation::Exact, 0.9);
728 let results = exp.expand("a", None);
729 assert!(!results.iter().any(|r| r.term == "a"));
731 }
732}