1#[derive(Clone, Debug, PartialEq)]
35pub enum ExpansionStrategy {
36 Synonyms,
38 Narrowing,
40 Broadening,
42 Negation,
44 Combination,
46}
47
48#[derive(Clone, Debug, PartialEq)]
50pub enum TermRelation {
51 Synonym,
53 Hypernym,
55 Hyponym,
57 Antonym,
59}
60
61#[derive(Clone, Debug, PartialEq)]
63pub struct TermEntry {
64 pub term: String,
66 pub related_term: String,
68 pub relation: TermRelation,
70 pub weight: f32,
72}
73
74#[derive(Clone, Debug, PartialEq)]
76pub struct ExpandedQuery {
77 pub original: String,
79 pub expansions: Vec<String>,
81 pub strategy: ExpansionStrategy,
83}
84
85impl ExpandedQuery {
86 #[must_use]
90 pub fn total_variants(&self) -> usize {
91 self.expansions.len() + 1
92 }
93}
94
95#[derive(Clone, Debug, PartialEq)]
97pub struct ExpanderStats {
98 pub total_expansions: u64,
100 pub avg_variants_per_query: f64,
104 pub registry_size: usize,
106}
107
108pub struct SemanticQueryExpander {
115 pub registry: Vec<TermEntry>,
117 pub max_expansions: usize,
119 pub total_expansions: u64,
121 pub total_variants_generated: u64,
123}
124
125impl SemanticQueryExpander {
126 #[must_use]
134 pub fn new(max_expansions: usize) -> Self {
135 Self {
136 registry: Vec::new(),
137 max_expansions,
138 total_expansions: 0,
139 total_variants_generated: 0,
140 }
141 }
142
143 pub fn register_term(&mut self, entry: TermEntry) {
145 self.registry.push(entry);
146 }
147
148 pub fn expand(&mut self, query: &str, strategy: ExpansionStrategy) -> ExpandedQuery {
157 let expansions = match &strategy {
158 ExpansionStrategy::Synonyms => self.expand_synonyms(query),
159 ExpansionStrategy::Narrowing => self.expand_narrowing(query),
160 ExpansionStrategy::Broadening => self.expand_broadening(query),
161 ExpansionStrategy::Negation => self.expand_negation(query),
162 ExpansionStrategy::Combination => self.expand_combination(query),
163 };
164
165 self.total_expansions += 1;
166 self.total_variants_generated += expansions.len() as u64;
167
168 ExpandedQuery {
169 original: query.to_string(),
170 expansions,
171 strategy,
172 }
173 }
174
175 #[must_use]
177 pub fn stats(&self) -> ExpanderStats {
178 let avg_variants_per_query = if self.total_expansions == 0 {
179 0.0
180 } else {
181 self.total_variants_generated as f64 / self.total_expansions as f64
182 };
183
184 ExpanderStats {
185 total_expansions: self.total_expansions,
186 avg_variants_per_query,
187 registry_size: self.registry.len(),
188 }
189 }
190
191 fn matching_entries<'a>(
198 &'a self,
199 query: &str,
200 target_relation: &TermRelation,
201 ) -> Vec<&'a TermEntry> {
202 let query_lower = query.to_lowercase();
203 let mut entries: Vec<&TermEntry> = self
204 .registry
205 .iter()
206 .filter(|e| e.term.to_lowercase() == query_lower && &e.relation == target_relation)
207 .collect();
208
209 entries.sort_by(|a, b| {
211 b.weight
212 .partial_cmp(&a.weight)
213 .unwrap_or(std::cmp::Ordering::Equal)
214 });
215 entries
216 }
217
218 fn cap_and_dedup(&self, candidates: Vec<String>) -> Vec<String> {
220 let mut seen = std::collections::HashSet::new();
221 candidates
222 .into_iter()
223 .filter(|s| seen.insert(s.clone()))
224 .take(self.max_expansions)
225 .collect()
226 }
227
228 fn expand_synonyms(&self, query: &str) -> Vec<String> {
229 let entries = self.matching_entries(query, &TermRelation::Synonym);
230 let candidates: Vec<String> = entries
231 .into_iter()
232 .map(|e| format!("{} \u{2192} {}", query, e.related_term))
233 .collect();
234 self.cap_and_dedup(candidates)
235 }
236
237 fn expand_narrowing(&self, query: &str) -> Vec<String> {
238 let entries = self.matching_entries(query, &TermRelation::Hyponym);
239 let candidates: Vec<String> = entries
240 .into_iter()
241 .map(|e| format!("{} {}", query, e.related_term))
242 .collect();
243 self.cap_and_dedup(candidates)
244 }
245
246 fn expand_broadening(&self, query: &str) -> Vec<String> {
247 let entries = self.matching_entries(query, &TermRelation::Hypernym);
248 let candidates: Vec<String> = entries
249 .into_iter()
250 .map(|e| e.related_term.clone())
251 .collect();
252 self.cap_and_dedup(candidates)
253 }
254
255 fn expand_negation(&self, query: &str) -> Vec<String> {
256 let entries = self.matching_entries(query, &TermRelation::Antonym);
257 let candidates: Vec<String> = entries
258 .into_iter()
259 .map(|e| format!("{} NOT {}", query, e.related_term))
260 .collect();
261 self.cap_and_dedup(candidates)
262 }
263
264 fn expand_combination(&self, query: &str) -> Vec<String> {
265 let syn = self.expand_synonyms(query);
267 let narrow = self.expand_narrowing(query);
268
269 let merged: Vec<String> = syn.into_iter().chain(narrow).collect();
271 self.cap_and_dedup(merged)
272 }
273}
274
275use std::collections::HashMap;
280
281#[derive(Debug, Clone)]
283pub struct VectorExpanderConfig {
284 pub max_expansions: usize,
286 pub similarity_threshold: f64,
288 pub weight_decay: f64,
290}
291
292impl Default for VectorExpanderConfig {
293 fn default() -> Self {
294 Self {
295 max_expansions: 5,
296 similarity_threshold: 0.7,
297 weight_decay: 0.8,
298 }
299 }
300}
301
302#[derive(Debug, Clone)]
304pub struct VectorExpandedQuery {
305 pub original: Vec<f64>,
307 pub expansions: Vec<VectorQueryExpansion>,
309 pub combined: Vec<f64>,
311}
312
313#[derive(Debug, Clone)]
315pub struct VectorQueryExpansion {
316 pub term: String,
318 pub vector: Vec<f64>,
320 pub weight: f64,
322 pub similarity_to_original: f64,
324}
325
326#[derive(Debug, Clone)]
328pub struct VectorExpanderStats {
329 pub total_terms: usize,
331 pub total_synonyms: usize,
333 pub expansions_performed: u64,
335}
336
337pub struct VectorQueryExpander {
345 config: VectorExpanderConfig,
346 synonym_map: HashMap<String, Vec<(String, Vec<f64>)>>,
347 expansions_performed: u64,
348}
349
350impl VectorQueryExpander {
351 #[must_use]
354 pub fn new(config: VectorExpanderConfig) -> Self {
355 Self {
356 config,
357 synonym_map: HashMap::new(),
358 expansions_performed: 0,
359 }
360 }
361
362 pub fn add_synonym(&mut self, term: &str, synonym: &str, vector: Vec<f64>) {
364 self.synonym_map
365 .entry(term.to_string())
366 .or_default()
367 .push((synonym.to_string(), vector));
368 }
369
370 pub fn expand_query(&mut self, query_term: &str, query_vector: &[f64]) -> VectorExpandedQuery {
382 self.expansions_performed += 1;
383
384 let synonyms = match self.synonym_map.get(query_term) {
385 Some(s) => s,
386 None => {
387 let combined = Self::l2_normalize(query_vector);
388 return VectorExpandedQuery {
389 original: query_vector.to_vec(),
390 expansions: Vec::new(),
391 combined,
392 };
393 }
394 };
395
396 let mut candidates: Vec<(String, Vec<f64>, f64)> = synonyms
398 .iter()
399 .filter_map(|(name, vec)| {
400 let sim = Self::cosine_similarity(query_vector, vec);
401 if sim >= self.config.similarity_threshold {
402 Some((name.clone(), vec.clone(), sim))
403 } else {
404 None
405 }
406 })
407 .collect();
408
409 candidates.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
411
412 candidates.truncate(self.config.max_expansions);
414
415 let expansions: Vec<VectorQueryExpansion> = candidates
417 .iter()
418 .enumerate()
419 .map(|(rank, (term, vec, sim))| {
420 let weight = sim * self.config.weight_decay.powi(rank as i32);
421 VectorQueryExpansion {
422 term: term.clone(),
423 vector: vec.clone(),
424 weight,
425 similarity_to_original: *sim,
426 }
427 })
428 .collect();
429
430 let weighted: Vec<(Vec<f64>, f64)> = expansions
432 .iter()
433 .map(|e| (e.vector.clone(), e.weight))
434 .collect();
435
436 let combined = Self::combine_vectors(query_vector, &weighted);
437
438 VectorExpandedQuery {
439 original: query_vector.to_vec(),
440 expansions,
441 combined,
442 }
443 }
444
445 #[must_use]
449 pub fn combine_vectors(original: &[f64], expansions: &[(Vec<f64>, f64)]) -> Vec<f64> {
450 let dim = original.len();
451 let mut result = original.to_vec();
452 let mut total_weight: f64 = 1.0;
453
454 for (vec, w) in expansions {
455 let len = vec.len().min(dim);
456 for i in 0..len {
457 result[i] += w * vec[i];
458 }
459 total_weight += w;
460 }
461
462 if total_weight.abs() > f64::EPSILON {
463 for v in &mut result {
464 *v /= total_weight;
465 }
466 }
467
468 Self::l2_normalize(&result)
469 }
470
471 #[must_use]
475 pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
476 let len = a.len().min(b.len());
477 let mut dot = 0.0_f64;
478 let mut norm_a = 0.0_f64;
479 let mut norm_b = 0.0_f64;
480 for i in 0..len {
481 dot += a[i] * b[i];
482 norm_a += a[i] * a[i];
483 norm_b += b[i] * b[i];
484 }
485 let denom = norm_a.sqrt() * norm_b.sqrt();
486 if denom < f64::EPSILON {
487 0.0
488 } else {
489 dot / denom
490 }
491 }
492
493 pub fn remove_synonym(&mut self, term: &str, synonym: &str) -> bool {
495 if let Some(synonyms) = self.synonym_map.get_mut(term) {
496 let before = synonyms.len();
497 synonyms.retain(|(s, _)| s != synonym);
498 let removed = synonyms.len() < before;
499 if synonyms.is_empty() {
501 self.synonym_map.remove(term);
502 }
503 removed
504 } else {
505 false
506 }
507 }
508
509 #[must_use]
511 pub fn synonym_count(&self, term: &str) -> usize {
512 self.synonym_map.get(term).map_or(0, |v| v.len())
513 }
514
515 pub fn clear_synonyms(&mut self) {
517 self.synonym_map.clear();
518 }
519
520 #[must_use]
522 pub fn stats(&self) -> VectorExpanderStats {
523 let total_synonyms: usize = self.synonym_map.values().map(|v| v.len()).sum();
524 VectorExpanderStats {
525 total_terms: self.synonym_map.len(),
526 total_synonyms,
527 expansions_performed: self.expansions_performed,
528 }
529 }
530
531 fn l2_normalize(v: &[f64]) -> Vec<f64> {
537 let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
538 if norm < f64::EPSILON {
539 vec![0.0; v.len()]
540 } else {
541 v.iter().map(|x| x / norm).collect()
542 }
543 }
544}
545
546#[cfg(test)]
551mod tests {
552 use super::*;
553
554 fn make_entry(term: &str, related: &str, relation: TermRelation, weight: f32) -> TermEntry {
559 TermEntry {
560 term: term.to_string(),
561 related_term: related.to_string(),
562 relation,
563 weight,
564 }
565 }
566
567 fn expander_with_car_data(max: usize) -> SemanticQueryExpander {
568 let mut e = SemanticQueryExpander::new(max);
569 e.register_term(make_entry("car", "automobile", TermRelation::Synonym, 0.9));
571 e.register_term(make_entry("car", "vehicle", TermRelation::Synonym, 0.7));
572 e.register_term(make_entry("car", "motorcar", TermRelation::Synonym, 0.5));
573 e.register_term(make_entry("car", "sedan", TermRelation::Hyponym, 0.85));
575 e.register_term(make_entry("car", "coupe", TermRelation::Hyponym, 0.80));
576 e.register_term(make_entry("car", "suv", TermRelation::Hyponym, 0.75));
577 e.register_term(make_entry("car", "transport", TermRelation::Hypernym, 0.8));
579 e.register_term(make_entry("car", "machine", TermRelation::Hypernym, 0.6));
580 e.register_term(make_entry("car", "bicycle", TermRelation::Antonym, 0.7));
582 e.register_term(make_entry("car", "train", TermRelation::Antonym, 0.65));
583 e
584 }
585
586 #[test]
591 fn test_new_starts_empty_registry() {
592 let e = SemanticQueryExpander::new(5);
593 assert!(e.registry.is_empty());
594 }
595
596 #[test]
597 fn test_new_sets_max_expansions() {
598 let e = SemanticQueryExpander::new(10);
599 assert_eq!(e.max_expansions, 10);
600 }
601
602 #[test]
603 fn test_new_counters_zero() {
604 let e = SemanticQueryExpander::new(5);
605 assert_eq!(e.total_expansions, 0);
606 assert_eq!(e.total_variants_generated, 0);
607 }
608
609 #[test]
614 fn test_register_term_adds_entry() {
615 let mut e = SemanticQueryExpander::new(5);
616 e.register_term(make_entry("dog", "canine", TermRelation::Synonym, 0.9));
617 assert_eq!(e.registry.len(), 1);
618 }
619
620 #[test]
621 fn test_register_multiple_terms() {
622 let mut e = SemanticQueryExpander::new(5);
623 e.register_term(make_entry("dog", "canine", TermRelation::Synonym, 0.9));
624 e.register_term(make_entry("cat", "feline", TermRelation::Synonym, 0.9));
625 assert_eq!(e.registry.len(), 2);
626 }
627
628 #[test]
633 fn test_expand_synonyms_finds_related_terms() {
634 let mut e = expander_with_car_data(5);
635 let result = e.expand("car", ExpansionStrategy::Synonyms);
636 assert!(!result.expansions.is_empty());
637 for exp in &result.expansions {
639 assert!(exp.contains('\u{2192}'), "expansion missing arrow: {exp}");
640 }
641 }
642
643 #[test]
644 fn test_expand_synonyms_respects_max_cap() {
645 let mut e = expander_with_car_data(2);
647 let result = e.expand("car", ExpansionStrategy::Synonyms);
648 assert!(
649 result.expansions.len() <= 2,
650 "got {} expansions, expected ≤ 2",
651 result.expansions.len()
652 );
653 }
654
655 #[test]
656 fn test_expand_synonyms_sorts_by_weight_desc() {
657 let mut e = expander_with_car_data(5);
658 let result = e.expand("car", ExpansionStrategy::Synonyms);
659 assert!(
661 result.expansions[0].contains("automobile"),
662 "first synonym should be 'automobile' (weight 0.9), got: {}",
663 result.expansions[0]
664 );
665 }
666
667 #[test]
672 fn test_expand_narrowing_adds_hyponyms() {
673 let mut e = expander_with_car_data(5);
674 let result = e.expand("car", ExpansionStrategy::Narrowing);
675 assert!(!result.expansions.is_empty());
676 for exp in &result.expansions {
678 assert!(
679 exp.starts_with("car "),
680 "narrowing expansion should start with 'car ': {exp}"
681 );
682 }
683 }
684
685 #[test]
686 fn test_expand_narrowing_sorts_by_weight() {
687 let mut e = expander_with_car_data(5);
688 let result = e.expand("car", ExpansionStrategy::Narrowing);
689 assert!(
691 result.expansions[0].contains("sedan"),
692 "first narrowing expansion should be 'car sedan', got: {}",
693 result.expansions[0]
694 );
695 }
696
697 #[test]
702 fn test_expand_broadening_uses_hypernyms() {
703 let mut e = expander_with_car_data(5);
704 let result = e.expand("car", ExpansionStrategy::Broadening);
705 assert!(!result.expansions.is_empty());
706 assert!(
708 result.expansions.iter().any(|s| s == "transport"),
709 "expected 'transport' in broadening expansions: {:?}",
710 result.expansions
711 );
712 }
713
714 #[test]
715 fn test_expand_broadening_no_query_prefix() {
716 let mut e = expander_with_car_data(5);
717 let result = e.expand("car", ExpansionStrategy::Broadening);
718 for exp in &result.expansions {
719 assert!(
720 !exp.starts_with("car"),
721 "broadening expansion must not start with query: {exp}"
722 );
723 }
724 }
725
726 #[test]
731 fn test_expand_negation_formats_not_correctly() {
732 let mut e = expander_with_car_data(5);
733 let result = e.expand("car", ExpansionStrategy::Negation);
734 assert!(!result.expansions.is_empty());
735 for exp in &result.expansions {
736 assert!(
737 exp.contains(" NOT "),
738 "negation expansion missing 'NOT': {exp}"
739 );
740 }
741 }
742
743 #[test]
744 fn test_expand_negation_contains_antonym() {
745 let mut e = expander_with_car_data(5);
746 let result = e.expand("car", ExpansionStrategy::Negation);
747 assert!(
748 result.expansions.iter().any(|s| s.contains("bicycle")),
749 "expected 'bicycle' antonym in negation: {:?}",
750 result.expansions
751 );
752 }
753
754 #[test]
759 fn test_expand_combination_merges_synonyms_and_narrowing() {
760 let mut e = expander_with_car_data(10);
761 let result = e.expand("car", ExpansionStrategy::Combination);
762 let has_arrow = result.expansions.iter().any(|s| s.contains('\u{2192}'));
764 let has_space = result
765 .expansions
766 .iter()
767 .any(|s| s.starts_with("car ") && !s.contains('\u{2192}'));
768 assert!(has_arrow, "combination should include synonym expansions");
769 assert!(has_space, "combination should include narrowing expansions");
770 }
771
772 #[test]
773 fn test_expand_combination_deduplicates() {
774 let mut e = SemanticQueryExpander::new(10);
775 e.register_term(make_entry("dog", "canine", TermRelation::Synonym, 0.9));
777 e.register_term(make_entry("dog", "canine", TermRelation::Synonym, 0.8));
778 let result = e.expand("dog", ExpansionStrategy::Combination);
779 let count = result
781 .expansions
782 .iter()
783 .filter(|s| s.contains("canine"))
784 .count();
785 assert_eq!(count, 1, "duplicate expansions not removed");
786 }
787
788 #[test]
789 fn test_expand_combination_caps_at_max_expansions() {
790 let mut e = expander_with_car_data(3);
791 let result = e.expand("car", ExpansionStrategy::Combination);
792 assert!(
793 result.expansions.len() <= 3,
794 "combination exceeded max_expansions cap"
795 );
796 }
797
798 #[test]
803 fn test_total_variants_includes_original() {
804 let mut e = expander_with_car_data(5);
805 let result = e.expand("car", ExpansionStrategy::Synonyms);
806 assert_eq!(
807 result.total_variants(),
808 result.expansions.len() + 1,
809 "total_variants should be expansions.len() + 1"
810 );
811 }
812
813 #[test]
814 fn test_total_variants_minimum_one() {
815 let mut e = SemanticQueryExpander::new(5);
816 let result = e.expand("unknown_term", ExpansionStrategy::Synonyms);
817 assert_eq!(result.total_variants(), 1);
818 }
819
820 #[test]
825 fn test_unknown_query_returns_empty_expansions() {
826 let mut e = expander_with_car_data(5);
827 let result = e.expand("zzzunknown", ExpansionStrategy::Synonyms);
828 assert!(
829 result.expansions.is_empty(),
830 "unknown term should produce no expansions"
831 );
832 }
833
834 #[test]
839 fn test_case_insensitive_matching() {
840 let mut e = expander_with_car_data(5);
841 let lower = e.expand("car", ExpansionStrategy::Synonyms);
842 let upper = e.expand("CAR", ExpansionStrategy::Synonyms);
843 assert_eq!(
844 lower.expansions.len(),
845 upper.expansions.len(),
846 "case should not affect number of matches"
847 );
848 }
849
850 #[test]
855 fn test_stats_total_expansions_increments() {
856 let mut e = expander_with_car_data(5);
857 e.expand("car", ExpansionStrategy::Synonyms);
858 e.expand("car", ExpansionStrategy::Narrowing);
859 assert_eq!(e.stats().total_expansions, 2);
860 }
861
862 #[test]
863 fn test_stats_avg_variants_zero_when_no_expansions() {
864 let e = SemanticQueryExpander::new(5);
865 assert_eq!(e.stats().avg_variants_per_query, 0.0);
866 }
867
868 #[test]
869 fn test_stats_avg_variants_computed() {
870 let mut e = expander_with_car_data(5);
871 let r1 = e.expand("car", ExpansionStrategy::Synonyms);
872 let r2 = e.expand("car", ExpansionStrategy::Narrowing);
873 let expected = (r1.expansions.len() + r2.expansions.len()) as f64 / 2.0;
874 let stats = e.stats();
875 assert!(
876 (stats.avg_variants_per_query - expected).abs() < 1e-9,
877 "avg mismatch: got {}, expected {expected}",
878 stats.avg_variants_per_query
879 );
880 }
881
882 #[test]
883 fn test_stats_registry_size_correct() {
884 let e = expander_with_car_data(5);
885 assert_eq!(e.stats().registry_size, 10);
887 }
888
889 #[test]
890 fn test_multiple_calls_accumulate_stats() {
891 let mut e = expander_with_car_data(5);
892 for _ in 0..5 {
893 e.expand("car", ExpansionStrategy::Synonyms);
894 }
895 assert_eq!(e.stats().total_expansions, 5);
896 }
897
898 #[test]
903 fn test_weight_ordering_respected() {
904 let mut e = SemanticQueryExpander::new(5);
905 e.register_term(make_entry("fruit", "berry", TermRelation::Hyponym, 0.4));
906 e.register_term(make_entry("fruit", "apple", TermRelation::Hyponym, 0.95));
907 e.register_term(make_entry("fruit", "mango", TermRelation::Hyponym, 0.7));
908 let result = e.expand("fruit", ExpansionStrategy::Narrowing);
909 assert!(
910 result.expansions[0].contains("apple"),
911 "highest-weight hyponym 'apple' should be first: {:?}",
912 result.expansions
913 );
914 }
915
916 #[test]
921 fn test_strategy_stored_in_expanded_query() {
922 let mut e = expander_with_car_data(5);
923 let result = e.expand("car", ExpansionStrategy::Broadening);
924 assert_eq!(result.strategy, ExpansionStrategy::Broadening);
925 }
926
927 #[test]
928 fn test_original_stored_in_expanded_query() {
929 let mut e = expander_with_car_data(5);
930 let result = e.expand("car", ExpansionStrategy::Synonyms);
931 assert_eq!(result.original, "car");
932 }
933
934 fn default_vec_config() -> VectorExpanderConfig {
939 VectorExpanderConfig::default()
940 }
941
942 fn unit_vec(dim: usize, len: usize) -> Vec<f64> {
944 let mut v = vec![0.0; len];
945 if dim < len {
946 v[dim] = 1.0;
947 }
948 v
949 }
950
951 fn vec_of(vals: &[f64]) -> Vec<f64> {
953 vals.to_vec()
954 }
955
956 #[test]
959 fn test_vec_cosine_parallel_vectors() {
960 let a = vec_of(&[1.0, 0.0, 0.0]);
961 let b = vec_of(&[2.0, 0.0, 0.0]);
962 let sim = VectorQueryExpander::cosine_similarity(&a, &b);
963 assert!(
964 (sim - 1.0).abs() < 1e-9,
965 "parallel vectors should have similarity 1.0, got {sim}"
966 );
967 }
968
969 #[test]
970 fn test_vec_cosine_orthogonal_vectors() {
971 let a = unit_vec(0, 3);
972 let b = unit_vec(1, 3);
973 let sim = VectorQueryExpander::cosine_similarity(&a, &b);
974 assert!(
975 sim.abs() < 1e-9,
976 "orthogonal vectors should have similarity 0.0, got {sim}"
977 );
978 }
979
980 #[test]
981 fn test_vec_cosine_antiparallel() {
982 let a = vec_of(&[1.0, 0.0]);
983 let b = vec_of(&[-1.0, 0.0]);
984 let sim = VectorQueryExpander::cosine_similarity(&a, &b);
985 assert!(
986 (sim + 1.0).abs() < 1e-9,
987 "anti-parallel vectors should have similarity -1.0, got {sim}"
988 );
989 }
990
991 #[test]
992 fn test_vec_cosine_zero_vector() {
993 let a = vec_of(&[0.0, 0.0]);
994 let b = vec_of(&[1.0, 1.0]);
995 let sim = VectorQueryExpander::cosine_similarity(&a, &b);
996 assert!(
997 sim.abs() < 1e-9,
998 "zero vector should produce similarity 0.0, got {sim}"
999 );
1000 }
1001
1002 #[test]
1003 fn test_vec_cosine_identical_vectors() {
1004 let a = vec_of(&[0.3, 0.4, 0.5]);
1005 let sim = VectorQueryExpander::cosine_similarity(&a, &a);
1006 assert!(
1007 (sim - 1.0).abs() < 1e-9,
1008 "identical vectors should have similarity 1.0, got {sim}"
1009 );
1010 }
1011
1012 #[test]
1015 fn test_vec_expand_with_known_synonyms() {
1016 let mut exp = VectorQueryExpander::new(default_vec_config());
1017 let qv = vec_of(&[1.0, 0.0, 0.0]);
1018 exp.add_synonym("cat", "feline", vec_of(&[0.9, 0.1, 0.0]));
1020 let result = exp.expand_query("cat", &qv);
1021 assert_eq!(result.expansions.len(), 1);
1022 assert_eq!(result.expansions[0].term, "feline");
1023 }
1024
1025 #[test]
1026 fn test_vec_expand_no_synonyms_returns_original() {
1027 let mut exp = VectorQueryExpander::new(default_vec_config());
1028 let qv = vec_of(&[1.0, 0.0, 0.0]);
1029 let result = exp.expand_query("unknown", &qv);
1030 assert!(result.expansions.is_empty());
1031 let norm: f64 = result.combined.iter().map(|x| x * x).sum::<f64>().sqrt();
1033 assert!(
1034 (norm - 1.0).abs() < 1e-9,
1035 "combined vector should be normalised, norm = {norm}"
1036 );
1037 }
1038
1039 #[test]
1042 fn test_vec_similarity_threshold_filters_low() {
1043 let cfg = VectorExpanderConfig {
1044 similarity_threshold: 0.9,
1045 ..default_vec_config()
1046 };
1047 let mut exp = VectorQueryExpander::new(cfg);
1048 let qv = vec_of(&[1.0, 0.0, 0.0]);
1049 exp.add_synonym("x", "y", vec_of(&[1.0, 1.0, 0.0]));
1051 let result = exp.expand_query("x", &qv);
1052 assert!(
1053 result.expansions.is_empty(),
1054 "synonym below threshold should be filtered"
1055 );
1056 }
1057
1058 #[test]
1059 fn test_vec_similarity_threshold_allows_high() {
1060 let cfg = VectorExpanderConfig {
1061 similarity_threshold: 0.5,
1062 ..default_vec_config()
1063 };
1064 let mut exp = VectorQueryExpander::new(cfg);
1065 let qv = vec_of(&[1.0, 0.0, 0.0]);
1066 exp.add_synonym("x", "y", vec_of(&[1.0, 1.0, 0.0]));
1068 let result = exp.expand_query("x", &qv);
1069 assert_eq!(result.expansions.len(), 1);
1070 }
1071
1072 #[test]
1075 fn test_vec_weight_decay_ordering() {
1076 let cfg = VectorExpanderConfig {
1077 similarity_threshold: 0.0,
1078 weight_decay: 0.5,
1079 max_expansions: 10,
1080 };
1081 let mut exp = VectorQueryExpander::new(cfg);
1082 let qv = vec_of(&[1.0, 0.0, 0.0]);
1083 exp.add_synonym("q", "a", vec_of(&[0.95, 0.05, 0.0]));
1085 exp.add_synonym("q", "b", vec_of(&[0.8, 0.2, 0.0]));
1086 let result = exp.expand_query("q", &qv);
1087 assert!(result.expansions.len() >= 2);
1088 assert!(
1090 result.expansions[0].weight > result.expansions[1].weight,
1091 "rank-0 weight {} should exceed rank-1 weight {}",
1092 result.expansions[0].weight,
1093 result.expansions[1].weight
1094 );
1095 }
1096
1097 #[test]
1098 fn test_vec_weight_decay_values() {
1099 let cfg = VectorExpanderConfig {
1100 similarity_threshold: 0.0,
1101 weight_decay: 0.8,
1102 max_expansions: 10,
1103 };
1104 let mut exp = VectorQueryExpander::new(cfg);
1105 let qv = unit_vec(0, 3);
1106 exp.add_synonym("q", "a", vec_of(&[1.0, 0.0, 0.0])); exp.add_synonym("q", "b", vec_of(&[1.0, 0.0, 0.0])); let result = exp.expand_query("q", &qv);
1110 assert!(result.expansions.len() >= 2);
1111 assert!(
1113 (result.expansions[0].weight - 1.0).abs() < 1e-9,
1114 "rank-0 weight should be 1.0, got {}",
1115 result.expansions[0].weight
1116 );
1117 assert!(
1119 (result.expansions[1].weight - 0.8).abs() < 1e-9,
1120 "rank-1 weight should be 0.8, got {}",
1121 result.expansions[1].weight
1122 );
1123 }
1124
1125 #[test]
1128 fn test_vec_combined_is_normalized() {
1129 let mut exp = VectorQueryExpander::new(default_vec_config());
1130 let qv = vec_of(&[3.0, 4.0, 0.0]);
1131 exp.add_synonym("t", "s1", vec_of(&[3.0, 4.0, 1.0]));
1132 let result = exp.expand_query("t", &qv);
1133 let norm: f64 = result.combined.iter().map(|x| x * x).sum::<f64>().sqrt();
1134 assert!(
1135 (norm - 1.0).abs() < 1e-9,
1136 "combined vector L2 norm should be ~1.0, got {norm}"
1137 );
1138 }
1139
1140 #[test]
1141 fn test_vec_combined_no_expansions_still_normalized() {
1142 let mut exp = VectorQueryExpander::new(default_vec_config());
1143 let qv = vec_of(&[3.0, 4.0]);
1144 let result = exp.expand_query("none", &qv);
1145 let norm: f64 = result.combined.iter().map(|x| x * x).sum::<f64>().sqrt();
1146 assert!(
1147 (norm - 1.0).abs() < 1e-9,
1148 "combined vector should be normalised even with 0 expansions, norm = {norm}"
1149 );
1150 }
1151
1152 #[test]
1155 fn test_vec_max_expansions_cap() {
1156 let cfg = VectorExpanderConfig {
1157 max_expansions: 2,
1158 similarity_threshold: 0.0,
1159 ..default_vec_config()
1160 };
1161 let mut exp = VectorQueryExpander::new(cfg);
1162 let qv = unit_vec(0, 3);
1163 for i in 0..5 {
1164 exp.add_synonym("t", &format!("s{i}"), vec_of(&[1.0, 0.01 * i as f64, 0.0]));
1165 }
1166 let result = exp.expand_query("t", &qv);
1167 assert!(
1168 result.expansions.len() <= 2,
1169 "should cap at 2, got {}",
1170 result.expansions.len()
1171 );
1172 }
1173
1174 #[test]
1177 fn test_vec_add_synonym() {
1178 let mut exp = VectorQueryExpander::new(default_vec_config());
1179 exp.add_synonym("a", "b", vec![1.0]);
1180 assert_eq!(exp.synonym_count("a"), 1);
1181 }
1182
1183 #[test]
1184 fn test_vec_add_multiple_synonyms() {
1185 let mut exp = VectorQueryExpander::new(default_vec_config());
1186 exp.add_synonym("a", "b", vec![1.0]);
1187 exp.add_synonym("a", "c", vec![2.0]);
1188 assert_eq!(exp.synonym_count("a"), 2);
1189 }
1190
1191 #[test]
1192 fn test_vec_remove_synonym_exists() {
1193 let mut exp = VectorQueryExpander::new(default_vec_config());
1194 exp.add_synonym("a", "b", vec![1.0]);
1195 assert!(exp.remove_synonym("a", "b"));
1196 assert_eq!(exp.synonym_count("a"), 0);
1197 }
1198
1199 #[test]
1200 fn test_vec_remove_synonym_not_found() {
1201 let mut exp = VectorQueryExpander::new(default_vec_config());
1202 assert!(!exp.remove_synonym("a", "b"));
1203 }
1204
1205 #[test]
1206 fn test_vec_remove_synonym_partial() {
1207 let mut exp = VectorQueryExpander::new(default_vec_config());
1208 exp.add_synonym("a", "b", vec![1.0]);
1209 exp.add_synonym("a", "c", vec![2.0]);
1210 assert!(exp.remove_synonym("a", "b"));
1211 assert_eq!(exp.synonym_count("a"), 1);
1212 }
1213
1214 #[test]
1217 fn test_vec_empty_query_term() {
1218 let mut exp = VectorQueryExpander::new(default_vec_config());
1219 let qv = vec_of(&[1.0, 0.0]);
1220 let result = exp.expand_query("", &qv);
1221 assert!(result.expansions.is_empty());
1222 }
1223
1224 #[test]
1227 fn test_vec_clear_synonyms() {
1228 let mut exp = VectorQueryExpander::new(default_vec_config());
1229 exp.add_synonym("a", "b", vec![1.0]);
1230 exp.add_synonym("c", "d", vec![2.0]);
1231 exp.clear_synonyms();
1232 assert_eq!(exp.synonym_count("a"), 0);
1233 assert_eq!(exp.synonym_count("c"), 0);
1234 }
1235
1236 #[test]
1239 fn test_vec_stats_initial() {
1240 let exp = VectorQueryExpander::new(default_vec_config());
1241 let s = exp.stats();
1242 assert_eq!(s.total_terms, 0);
1243 assert_eq!(s.total_synonyms, 0);
1244 assert_eq!(s.expansions_performed, 0);
1245 }
1246
1247 #[test]
1248 fn test_vec_stats_after_operations() {
1249 let mut exp = VectorQueryExpander::new(default_vec_config());
1250 exp.add_synonym("a", "b", vec![1.0]);
1251 exp.add_synonym("a", "c", vec![2.0]);
1252 exp.add_synonym("x", "y", vec![3.0]);
1253 let s = exp.stats();
1254 assert_eq!(s.total_terms, 2);
1255 assert_eq!(s.total_synonyms, 3);
1256 }
1257
1258 #[test]
1259 fn test_vec_stats_expansions_performed() {
1260 let mut exp = VectorQueryExpander::new(default_vec_config());
1261 let qv = vec_of(&[1.0]);
1262 exp.expand_query("a", &qv);
1263 exp.expand_query("b", &qv);
1264 exp.expand_query("c", &qv);
1265 assert_eq!(exp.stats().expansions_performed, 3);
1266 }
1267
1268 #[test]
1271 fn test_vec_overlapping_synonyms_different_terms() {
1272 let cfg = VectorExpanderConfig {
1273 similarity_threshold: 0.0,
1274 ..default_vec_config()
1275 };
1276 let mut exp = VectorQueryExpander::new(cfg);
1277 exp.add_synonym("a", "shared_syn", vec_of(&[1.0, 0.0]));
1279 exp.add_synonym("b", "shared_syn", vec_of(&[0.0, 1.0]));
1280
1281 let r_a = exp.expand_query("a", &vec_of(&[1.0, 0.0]));
1282 let r_b = exp.expand_query("b", &vec_of(&[0.0, 1.0]));
1283 assert_eq!(r_a.expansions.len(), 1);
1284 assert_eq!(r_b.expansions.len(), 1);
1285 assert_eq!(r_a.expansions[0].term, "shared_syn");
1286 assert_eq!(r_b.expansions[0].term, "shared_syn");
1287 }
1288
1289 #[test]
1292 fn test_vec_combine_vectors_no_expansions() {
1293 let orig = vec_of(&[3.0, 4.0]);
1294 let combined = VectorQueryExpander::combine_vectors(&orig, &[]);
1295 let norm: f64 = combined.iter().map(|x| x * x).sum::<f64>().sqrt();
1296 assert!((norm - 1.0).abs() < 1e-9);
1297 assert!(combined[0] > 0.0);
1299 assert!(combined[1] > 0.0);
1300 }
1301
1302 #[test]
1303 fn test_vec_combine_vectors_weighted() {
1304 let orig = vec_of(&[1.0, 0.0]);
1305 let exp_vec = vec_of(&[0.0, 1.0]);
1306 let combined = VectorQueryExpander::combine_vectors(&orig, &[(exp_vec, 1.0)]);
1307 let expected = 1.0 / 2.0_f64.sqrt();
1309 assert!(
1310 (combined[0] - expected).abs() < 1e-9,
1311 "expected {expected}, got {}",
1312 combined[0]
1313 );
1314 assert!(
1315 (combined[1] - expected).abs() < 1e-9,
1316 "expected {expected}, got {}",
1317 combined[1]
1318 );
1319 }
1320
1321 #[test]
1324 fn test_vec_synonym_count_unknown_term() {
1325 let exp = VectorQueryExpander::new(default_vec_config());
1326 assert_eq!(exp.synonym_count("nonexistent"), 0);
1327 }
1328}